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/.csharpierignore b/.csharpierignore index b9178ab..a816241 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -9,6 +9,9 @@ src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs src/src/Examples.Basic/TenantError.cs +src/src/Examples.Basic/BillingError.cs src/src/Examples.Zod/TenantError.cs +src/src/Examples.ValueObjects.Zod/TenantError.cs src/src/Examples.AspNetCore/TenantError.cs +src/src/Examples.AspNetCore/BillingError.cs src/src/Examples.AspNetCore.Zod/TenantError.cs 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 73b18d0..bec93ff 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,7 +24,7 @@ jobs: # Mirrors Build:TestFilter in purview-build.json: every test project is a *.UnitTests project # whose tests the SDK categorises as Unit. test-filter: "/*/*/*/*[Category=Unit]" - # All five library projects are packable, so pack and validate the produced packages. + # All four library projects are packable, so pack and validate the produced packages. run-pack: true validate-pack: true secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index 1a00e57..1f6ec9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,8 @@ ## Purpose and authority -This repository contains the Purview result types for .NET: `Purview.Results`, a Roslyn source generator that -makes C# 15 union error cases ergonomic, and the ZodSharp and ASP.NET Core integrations. +This repository contains the Purview result types for .NET: `Purview.Results` (which bundles the Roslyn source +generator that makes C# 15 union error cases ergonomic), and the ZodSharp and ASP.NET Core integrations. - This file is the repository-wide source of truth for AI agents. A more-specific `AGENTS.md` in a subtree, if one is ever added, takes precedence for that subtree. @@ -19,15 +19,15 @@ makes C# 15 union error cases ergonomic, and the ZodSharp and ASP.NET Core integ | Path | Purpose | | --- | --- | | `src/Results.slnx` | Canonical solution for restore, build, test and pack | -| `src/src/Results` | `Result`, the `Result` factories, `IResultValue` | -| `src/src/SourceGenerator` | Roslyn incremental generator, diagnostic analyzer, `[GenerateResult]` attribute | -| `src/src/SourceGenerator.CodeFixes` | IDE code fix for `CS0029`: rewrites a returned union case into the generated `AsFailure()` | +| `src/src/Results` | `Result`, the value-less `Result`, the `Success` marker, the `Result` factories, `IResultValue` | +| `src/src/SourceGenerator` | Roslyn incremental generator, diagnostic analyzer and `[GenerateResult]` attribute; not a package of its own, packed into `Purview.Results` | +| `src/src/SourceGenerator.CodeFixes` | IDE code fix for `CS0029`: rewrites a returned union case into the generated union-receiver factory `Union.Failure(case)` (value result) or `Union.Failure(case)` (unit result) | | `src/src/AspNetCore` | Result-to-response mapping, endpoint filter, DI registration | | `src/src/ZodSharp` | ZodSharp `ValidationResult` bridge | | `src/src/ZodSharp.AspNetCore` | Validation-problem mapping for validation-carrying failures | | `src/src//Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README; `Sdk/.agents/**` would ship agent skills with the package | | `src/tests` | TUnit unit tests, including source-generation and incremental-cache tests | -| `src/examples` | Runnable, non-packable examples: one project per integration aspect on a shared Tenant* domain | +| `src/src/Examples.*` | Runnable, non-packable examples: one project per integration aspect on a shared Tenant* domain | | `docs/wiki` | User-facing documentation suite, aggregated by the purview-dev website | | `Directory.Packages.props` | Centrally managed NuGet versions | | `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package and build behaviour | @@ -53,14 +53,32 @@ makes C# 15 union error cases ergonomic, and the ZodSharp and ASP.NET Core integ - `Result` is a `readonly record struct` with three states: `Uninitialized` (the `default` value), `Success` and `Failure`. Keep all three observable through `IsInitialized`, `IsSuccess` and `IsFailure`. +- `Result` is the value-less counterpart: the same `readonly record struct` state machine, the same + throw-on-misuse contract and the same three states, but a success holds the `Success` marker (a stateless + `readonly record struct` in `Purview.Results`) instead of a value. Its success callbacks take no argument + (`Match`, `Switch`, `Tap`, `Ensure`), `Error` throws in the wrong state, `ToString()` is `Success` / + `Failure(error)` / `Uninitialized`, and `IResultValue.SuccessValue` is the marker. Do not merge the two types or + let one silently convert into the other. - Keep the throw-on-misuse contract: `Value` and `Error` throw `InvalidOperationException` with the existing messages in the wrong state, and `Match`, `Map`, `Bind` and `MapError` throw `"The result is uninitialized."` for `default`. Never silently coerce an uninitialized result into a success or a failure. -- `ToString()` stays `Success(value)` / `Failure(error)` / `Uninitialized`. +- `ToString()` stays `Success(value)` / `Failure(error)` / `Uninitialized`; for `Result` a success is + `Success` with no value. - `IResultValue` is the non-generic view used by infrastructure. Its accessors must never throw; the accessor that does not describe the current state returns `null`. -- Implicit conversions from `TValue` and `TError`, and the `Result.Success`/`Result.Failure` and - `Result.Success`/`.Failure` factories, are public contract. +- Implicit conversions from `TValue`, `TError` and `Success`, and the `Result.Success`/`Result.Failure`, + `Result.Success`/`.Failure` and `Result.Success`/`.Failure` factories, are public + contract. `Result.DiscardValue()` (value result → unit result) is the value-style bridge between + the two shapes. +- `Bind` preserves `TError`. Chaining a step that fails with a different error is the widening + `Bind(bind, mapError)` / `BindAsync(bind, mapError)` overload (equivalent to `MapError(mapError).Bind(bind)`), or + a `TryGetError` guard with the generated `AsFailure()`. A caller union that includes the upstream error + union as a case keeps the lift to a single union conversion. +- `Throw()` returns the successful value — or, for `Result`, the `Success` marker — throws + `ResultException` (with the error on `Error`, and a non-generic `ResultException` base carrying it as + `object?` for a catch-all) on failure, and throws `InvalidOperationException` + (`"The result is uninitialized."`) for `default`. It is the only deliberate result-to-exception escape hatch and + must never coerce `default`. - `Purview.Results` stays dependency-free. The ZodSharp and ASP.NET Core packages depend on it, never the reverse, and `Purview.Results.AspNetCore` deliberately knows nothing about ZodSharp. - Keep runtime packages free of reflection, `dynamic` and runtime type discovery. Union structure is inspected @@ -70,14 +88,14 @@ makes C# 15 union error cases ergonomic, and the ZodSharp and ASP.NET Core integ ## Source generator rules -`src/src/SourceGenerator/Sdk/README.md` is the authoritative design document — keep it in sync with behaviour, +`src/src/SourceGenerator/README.md` is the authoritative design document — keep it in sync with behaviour, including the diagnostics table, build properties and activation rules. - **One analyzer, one generator, one diagnostics library.** `Diagnostics/DiagnosticLibrary.cs`, `Diagnostics/ResultDiagnostic.cs` and `Diagnostics/ResultUnionDiagnostics.cs` hold the single implementation - of the union rules. The analyzer reports the per-target rules (`RSG1000`–`RSG1004`, `RSG1007`); the generator - reports the compilation-wide rules (`RSG1005`, `RSG1006`). Never report the same rule from both hosts, and - keep `DiagnosticLibrary.IsBlocking` as the single blocking policy. + of the union rules. The analyzer reports the per-target rules (`RSG1000`–`RSG1004`, `RSG1007`, `RSG1008`); the + generator reports the compilation-wide rules (`RSG1005`, `RSG1006`). Never report the same rule from both + hosts, and keep `DiagnosticLibrary.IsBlocking` as the single blocking policy. - New or changed rules require an `AnalyzerReleases.Unshipped.md` entry (the compiler's RS2008 rule catalogue). - **One suppressor.** `Suppressors/UnionEqualityDiagnosticSuppressor.cs` is the only programmatic suppression in this repository: it suppresses `CA1815` and only `CA1815`, and only on a declaration that is both a union @@ -98,11 +116,12 @@ including the diagnostics table, build properties and activation rules. have. - Create the `CodeWriter` inside the `RegisterSourceOutput` callback; never store it in incremental pipeline state or a custom context. -- `ResultsSourceGenerator_Disable` must stay declared as both a `PurviewGeneratorVisibleProperty` and a +- `DisableResultsSourceGenerator` must stay declared as both a `PurviewGeneratorVisibleProperty` and a `CompilerVisibleProperty`, and shipped to consumers by - `src/src/SourceGenerator/Sdk/buildTransitive/Purview.Results.SourceGenerator.props`. The framework's PSGF0003 - validation fails the build when a declared generator-read property is neither compiler-visible nor declared by - the package's own `Sdk/build*` assets, which is what keeps the packaged switch honest. + `src/src/Results/Sdk/buildTransitive/Purview.Results.props` (the package that carries the generator). The + framework's PSGF0003 validation fails the build when a declared generator-read property is neither + compiler-visible nor declared by a package's own `Sdk/build*` assets, which is what keeps the packaged switch + honest. - `[GenerateResult]` is supported on union declarations only. Generic unions and `IUnionMembers` member providers are deliberately unsupported and reported as `RSG1002`/`RSG1007`. - **Do not attempt to generate implicit conversions.** C# blocks every route: an operator is illegal in a static @@ -111,6 +130,27 @@ including the diagnostics table, build properties and activation rules. is unavailable for a non-generic case type (`CS0246`), and only one user-defined conversion may participate in a sequence (`CS0029`). All five are recorded in `UnionCompilerBehaviourTests.cs`. Improve call-site ergonomics through the code fix in `src/src/SourceGenerator.CodeFixes` instead. +- Emit **two per-case helpers**: the value-producing `AsFailure()` (`Result`) and the + non-generic `AsFailure()` (`Result` unit result). Keep them overloads of the same name in the one + generated extension class, keep the bodies pure `Result<...>.Failure(error)` calls, and keep case ordering + deterministic. +- Also emit the **union-receiver factory** as a C# 14 extension block on the union type + (`extension(TUnion) { public static ... }`): `Failure(case)`/`Failure(case)` and `Success()`/ + `Success(value)`. The factory names the union by its receiver, so it is the shared-case safe form: a + leaf case type shared with another union (`RSG1006`) has its per-case `AsFailure` helper generated once, but + every union's factory covers all of its own cases. `ResultUnionModel.Cases` is the union's full case set (used + by the factory) and `ResultUnionModel.HelperCases` is the deduplicated subset (used by `AsFailure`); never + conflate the two. The code fix rewrites `CS0029` to the factory, not to `AsFailure`, so it cannot bind to the + wrong union. +- **Union inclusion.** A case type that is itself a union is an *included union*: the including union's factory + also covers every case reachable through it, constructing the nested value (`new Outer(new Inner(leaf))`). + `ResultUnionModel.IncludedCases` carries that expansion (the case plus its innermost-first construction path) + and the emitter writes one factory pair per included case. Inclusion is automatic for every union-typed case and + is the supported way to avoid `RSG1006`. A union-typed case shared by two unions is composition, not a shared + leaf: it is **not** reported as `RSG1006` and its per-case `AsFailure` helper is not generated for any owner. A + case reachable through two different included unions is reported as `RSG1008` and its inclusion factory is + skipped, because no single construction path can be named. Expansion walks the union's own case symbols during + `ResultUnionDiagnostics.Analyze`, so it needs no opted-in included union and no symbol in cached state. - Adding a code fix means adding it to `src/src/SourceGenerator.CodeFixes`, not to the generator project: the code fix needs `Microsoft.CodeAnalysis.*.Workspaces`, which must not enter the generator's analyzer closure. The two projects stay independent (no project reference, no `InternalsVisibleTo`) so no reference cycle can @@ -120,9 +160,15 @@ including the diagnostics table, build properties and activation rules. ## ASP.NET Core integration invariants -- Keep the failure resolution order in `DefaultResultsHttpMapper`: the mapping for the error **case** type, - then the mapping for the **error** type (which covers every case without its own mapping), then the - registered fallbacks in order, then the unmapped-failure response. +- A successful value result is serialized with `SuccessStatusCode`; a successful unit `Result` carries no + payload and answers `204 No Content`. `SuccessMapper` overrides both, and a successful value that is itself an + `IResult` still passes through untouched. +- Keep the failure resolution order in `DefaultResultsHttpMapper`: the mapping for the most specific **case** type, + then the mapping for each enclosing **union** type from the inside out, then the mapping for the **error** type + (which covers every case without its own mapping), then the registered fallbacks in order, then the + unmapped-failure response. A union error resolves to its innermost active case, so a nested union resolves to its + leaf while a mapping for an enclosing union still applies when the leaf has none; the fallback stage receives that + leaf case. - **One fallback stage, one ordered list.** `ResultsHttpOptions.AddFallback(...)` delegates and `AddFailureMapper()` failure mappers append to the *same* list, in the order they are called, and an entry defers by returning `null`. `IResultsFailureMapper` (with `ResultsFailureContext`, which carries the case, @@ -145,11 +191,12 @@ including the diagnostics table, build properties and activation rules. wins, and so does a host failure mapper registered before `AddResultsZodSharpHttp`. It must reuse the ZodSharp problem mapper (`ZodValidationProblems.ToProblem`) rather than reimplementing error-to-problem mapping, so a result-carried validation failure and a thrown `ZodException` produce identical responses. -- Keep the ZodSharp code/category rules' precedence structural: **code** rules are consulted before **category** - rules (each in registration order), then the default validation problem, so a rule can only narrow what the host - already gets. A rule matches when *any* of the failure's errors carries its code or category — a rule a schema - can silently never reach is exactly the kind of gap this repository surfaces rather than hides — and a factory - returns `null` to decline, with matching continuing. Factories see the failure's whole error set. +- Keep the ZodSharp code/category/origin rules' precedence structural: **code** rules are consulted before + **category** rules, which are consulted before **origin** rules (each in registration order), then the default + validation problem, so a rule can only narrow what the host already gets. A rule matches when *any* of the + failure's errors carries its code, category or origin — a rule a schema can silently never reach is exactly the + kind of gap this repository surfaces rather than hides — and a factory returns `null` to decline, with matching + continuing. Factories see the failure's whole error set. ## Packaging rules @@ -165,22 +212,28 @@ including the diagnostics table, build properties and activation rules. into the package root, so `Sdk/README.md` becomes the `.nupkg` README and takes precedence over the repository-root `README.md`. Update it for any user-visible change, and keep the repository-root `README.md` consistent with it. -- The packed shapes that must not regress: the Roslyn component ships its analyzer assembly under - `analyzers/dotnet/cs/` with no `lib/` folder and **no PDB** (`PurviewPackAnalyzerPdb=false`), and the library - packages ship `lib//.dll` plus the XML documentation file and a symbol package. -- The component opts out of the analyzer PDB because the packaged analyzer is the framework's merged (ILRepack) +- The packed shapes that must not regress: `Purview.Results` ships the merged Roslyn component and its code fix + under `analyzers/dotnet/cs/` with no `lib/` folder and **no PDB**, and every library package ships + `lib//.dll` plus the XML documentation file and a symbol package. +- The generator ships **no analyzer PDB** because the packaged analyzer is the framework's merged (ILRepack) assembly: the merge tool's rewritten PDB carries no Roslyn compiler-flags record, and the pipeline's pack validation asserts `optimization=release` for every assembly that ships a PDB, with no way to scope that check - to one package. Telemetry and the other component packages opt out the same way. Revisit if the framework's - merge tool starts preserving the compiler-flags record. + to one package. `Purview.Results` packs only the merged assembly (through the framework's + `GetPurviewMergedAnalyzerFile` target) and the code fix, so no analyzer PDB is contributed. Telemetry and the + other component packages opt out the same way. Revisit if the framework's merge tool starts preserving the + compiler-flags record. - `purview-build.json`'s `PackValidation.RequiredContent` is the exhaustive declaration of what each package ships (`RequireExplicitContent` defaults to `true`, so an undeclared entry fails too). Update it whenever package content changes — a new asset, a removed PDB, a renamed analyzer — and verify with `just pipeline-pack-validate`, which runs restore, build, lint, tests, pack and the validation. -- `Purview.Results.SourceGenerator` therefore carries **two** analyzer assemblies in `analyzers/dotnet/cs/`: - the merged generator and `Purview.Results.SourceGenerator.CodeFixes.dll`. The code fix is packed by the SDK's - `PackProjectReferencedSourceGenerators` from the analyzer project reference in `SourceGenerator.csproj`, and it - is never IL-merged into the generator. Verify both are present whenever analyzers or packaging change. +- `Purview.Results` therefore carries **two** analyzer assemblies in `analyzers/dotnet/cs/`: the merged + generator and `Purview.Results.SourceGenerator.CodeFixes.dll`. The code fix is packed by the + `PackResultsSourceGenerator` target in `Results.csproj`, which also calls the framework's + `GetPurviewMergedAnalyzerFile` for the generator, and it is never IL-merged into the generator. The generator + is deliberately **not** referenced as an analyzer by `Purview.Results`: it emits its opt-in attribute into + every compilation it runs on, so running it on the library would bake a conflicting + `Purview.Results.GenerateResultAttribute` into the package and raise `CS0436` in every consumer. Verify both + assemblies are present whenever analyzers or packaging change. - Verify packaging by packing and inspecting the output, for example `dotnet pack src/Results.slnx -o ` followed by opening the `.nupkg`. The PR and release pipelines run the same check through `Purview.Build`'s pack validation. @@ -218,10 +271,14 @@ including the diagnostics table, build properties and activation rules. ## Examples -`src/examples` holds one runnable example per integration aspect, all on the Tenant* domain the READMEs -document: `Examples.Basic` (the result type and the generated helpers), `Examples.Zod` -(`Purview.Results.ZodSharp`), `Examples.AspNetCore` (`Purview.Results.AspNetCore`) and `Examples.AspNetCore.Zod` -(`Purview.Results.ZodSharp.AspNetCore`). +`src/src/Examples.*` holds one runnable example per integration aspect, all on the Tenant* domain the READMEs +document: `Examples.Basic` (the result type, the value-less `Result`, the generated `AsFailure()` +and `AsFailure()` helpers, and the union-receiver `Union.Failure(...)`/`Union.Success(...)` factories), +`Examples.Zod` (`Purview.Results.ZodSharp`, including `ToUnitResult`), +`Examples.AspNetCore` (`Purview.Results.AspNetCore`, including a unit result answering `204`), +`Examples.AspNetCore.Zod` (`Purview.Results.ZodSharp.AspNetCore`) and `Examples.ValueObjects.Zod` +(`Purview.Results.ZodSharp` + `Purview.ValueObjects`, a type-level `[ZodRule]` whose code and origin flow into the +result and the HTTP mapping). - Examples are **documentation that compiles**: keep them non-packable (never declare `true`), keep them out of test discovery (no `*Tests` suffix, and they are not under @@ -264,8 +321,7 @@ maintained here. | Content | Package | Path | | --- | --- | --- | -| `skills/purview-results-core` | `Purview.Results` | `src/src/Results/Sdk/.agents` | -| `skills/purview-results-union-errors`, `agents/purview-results-union-author.agent.md`, `prompts/migrate-error-returns-to-result-unions.prompt.md` | `Purview.Results.SourceGenerator` | `src/src/SourceGenerator/Sdk/.agents` | +| `skills/purview-results-core`, `skills/purview-results-union-errors`, `agents/purview-results-union-author.agent.md`, `prompts/migrate-error-returns-to-result-unions.prompt.md` | `Purview.Results` | `src/src/Results/Sdk/.agents` | | `skills/purview-results-http-mapping` | `Purview.Results.AspNetCore` | `src/src/AspNetCore/Sdk/.agents` | | `skills/purview-results-zodsharp-validation` | `Purview.Results.ZodSharp` | `src/src/ZodSharp/Sdk/.agents` | | `skills/purview-results-zodsharp-problems` | `Purview.Results.ZodSharp.AspNetCore` | `src/src/ZodSharp.AspNetCore/Sdk/.agents` | @@ -342,9 +398,8 @@ just scrub just build ``` -`pipeline-local-release` runs the shared pipeline with `Release:Mode=LocalNuGet`. It is present in this -repository's `Justfile` but currently commented out; until it is re-enabled, run the same command directly -(after `just ensure-pipeline-tool`): +`pipeline-local-release` runs the shared pipeline with `Release:Mode=LocalNuGet`. It is a live recipe in this +repository's `Justfile`; the equivalent direct invocation (after `just ensure-pipeline-tool`) is: ```text .tools/purview-build/purview-build --Release:Mode=LocalNuGet --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..af6b37c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,159 @@ +# Changelog + +All notable changes to this repository are recorded here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). `package.json` is the authoritative version; see +[Release Flow](docs/wiki/Release-Flow.md). + +Released versions correspond to `v` GitHub releases. Entries below the `Unreleased` heading have not +been published to NuGet. + +## Unreleased + +### Added + +- The value-less `Result` type: the same three-state `readonly record struct` contract as + `Result`, but a success holds the stateless `Success` marker instead of a value. Comes with + `Match`, `Switch`, `Tap`, `TapError`, `Ensure`, `Map`, `Bind`, `MapError`, `MapAsync`, `BindAsync`, `OrElse`, + `TryGetError`, `GetErrorOrDefault` and `Throw()`, plus implicit conversions from `Success` and `TError`. + `Result.DiscardValue()` bridges a value result to a unit result. +- `Purview.Results.ZodSharp` integration with `Purview.ValueObjects`, so a type-level `[ZodRule]` carries its + code and origin through a result and into the HTTP mapping. Demonstrated by the new + `Examples.ValueObjects.Zod` example. +- Union inclusion: a union-typed case is expanded automatically, so an including union's generated factory also + covers every case reachable through it, constructing the nested value. This is the supported way to avoid + `RSG1006` without flattening a union. +- `RSG1008`, reported when a case is reachable through two different included unions, where no single + construction path can be named. +- `RSG1009`, reported when a union case is not a named type (an array, for example) so no helper or factory + can be declared against it. +- `RSG9000`, reported when the generator fails unexpectedly, instead of letting the exception escape. +- The union-receiver factory as a C# 14 extension block on the union type — `Union.Failure(case)`, + `Union.Failure(case)`, `Union.Success()` and `Union.Success(value)`. Because it names the union + by its receiver, it is safe for cases shared between unions, and the code fix rewrites `CS0029` to it. +- Examples recipe `just example-valueobjects-zod`. + +### Changed + +- **Consuming these packages no longer forces preview features on your solution.** `EnablePreviewFeatures` + was set repo-wide, which emitted `[assembly: RequiresPreviewFeatures]` into all four shipped assemblies. + Because that attribute is assembly-wide, `CA2252` — an **error** by default — fired on every member a + consumer touched, including `Result`, which uses no union feature. A consuming application + could not adopt the result type at all without enabling preview features solution-wide, so partial adoption + was impossible. The property was unnecessary: union declarations are a *language* feature needing only + `LangVersion=preview`, and nothing here uses a runtime API annotated `[RequiresPreviewFeatures]` — every + project, including the union-declaring examples and tests, compiles without it. + + The requirement is now scoped and per-project: + - Using the result types and combinators needs **nothing special**. + - Declaring your own `[GenerateResult]` union needs `LangVersion=preview` in **that** project. + + Both paths are verified against the packed package, the first with `TreatWarningsAsErrors=true`. +- **Breaking:** the generated `[GenerateResult]` opt-in attribute moved from `Purview.Results.SourceGenerator` + to `Purview.Results`. Remove any `using Purview.Results.SourceGenerator;` added for the attribute. +- **All four packages are now marked `IsAotCompatible`**, enabling `IsTrimmable` and both the trim and AOT + analyzers, and all four are verified clean. For `Purview.Results` and `Purview.Results.ZodSharp` this + independently confirms the "no reflection, no dynamic, no runtime type discovery" guarantee. +- **The ASP.NET Core success path no longer erases the value's type.** `DefaultResultsHttpMapper` serialized + through `IResultValue.SuccessValue`, which is `object?`, so `TypedResults.Ok` bound `Ok`. Under + trimming or Native AOT that leaves the serializer with no `JsonTypeInfo` to resolve, and it also erased the + response type ASP.NET Core infers for OpenAPI. The value now reaches `TypedResults` with its static type + through the new `IResultValue.AcceptSuccess` double dispatch. + +### Added — observability + +- **Metrics under the meter `Purview.Results.AspNetCore`**: `purview.results.failures.mapped`, + `purview.results.failures.unmapped` and `purview.results.uninitialized`. The design calls an unmapped + failure a host mapping gap rather than a domain outcome, but the only signal was a log message — so + answering "how often, and for which error" meant scraping log text. Each measurement carries a + `purview.results.error_type` tag, and the failure paths add the same tag to `Activity.Current`, enriching + the request span ASP.NET Core already started rather than creating one. Register with + `metrics.AddMeter(ResultsHttpTelemetry.MeterName)`. +- The error type is **always** present in telemetry, because metrics and traces stay inside your own + infrastructure. Disclosing it in the HTTP *response* remains opt-in via + `IncludeErrorTypeInProblemDetails`, so a mapping gap stays diagnosable without being published. +- **Stable `EventId`s** on every log statement, via source-generated `LoggerMessage`: `1000` uninitialized + result, `1001` unmapped failure, `2000` a ZodSharp rule answering a multi-error failure. Previously none + had an identifier, so alerting meant matching message text. The generated path also removes the + hand-written `IsEnabled` guard the Debug call site needed. +- No telemetry package dependency is added: `Meter` and `Activity` come from the base class library through + the ASP.NET Core framework reference, so the repository's dependency-light position is unchanged. + +### Added — .NET 10 support + +- **All four packages now multi-target `net10.0` and `net11.0`**, so consumers choose their runtime rather + than being forced onto .NET 11. .NET 8 and 9 are deliberately not targeted — both are close to end of + support. Verified end to end: a `net10.0` console consumer and a `net10.0` ASP.NET Core minimal API both + build and run against the packed packages, with `TreatWarningsAsErrors`. +- **Unions still require .NET 11**, and that is a compiler constraint rather than a choice: a union + declaration needs `System.Runtime.CompilerServices.IUnion` and `UnionAttribute`, neither of which exists + before .NET 11. Everything else works on `net10.0` — the result types, the combinators, the ASP.NET Core + mapping and the ZodSharp bridge. The HTTP mapper's union unwrapping is compiled only for `net11.0`; on + `net10.0` an error is its own single case, which is the answer the union path already gives for a + non-union error. Projects that declare a union (the examples and the union-declaring test projects) are + pinned to `net11.0`. +- `Results.UnitTests` multi-targets, so the core is now exercised on both runtimes — the suite went from + 342 to 492 executions. +- **Runtime async is scoped to `net11.0`.** `Features=runtime-async=on` was set repo-wide. On `net10.0` it + lowers `await` to `System.Runtime.CompilerServices.AsyncHelpers`, which is evaluation-only and fails the + build with `SYSLIB5007` — the API is explicitly subject to change or removal. A `net10.0` consumer + therefore gets the stable async lowering. + +### Changed — analyzer release tracking + +- **Every diagnostic moved from `AnalyzerReleases.Unshipped.md` to `AnalyzerReleases.Shipped.md`** under + `## Release 1.0.0`. There was no `Shipped.md` at all, so the catalogue claimed nothing had ever shipped; + all eleven rules (`RSG1000`–`RSG1009`, `RSG9000`) are now recorded. `RSG2000` is deliberately absent from + both files: it is a suppression id, and suppression descriptors are not release-tracked. + +### Added — `IResultValueVisitor` + +- A new visitor interface and `IResultValue.AcceptSuccess` hand a result's successful value + to infrastructure **with its static type intact**, without reflection. `SuccessValue` remains the right + shape for inspection; use `AcceptSuccess` whenever the type matters, such as serialization. +- **Breaking for anyone implementing `IResultValue` themselves** (the two result types implement it in-box; + external implementations are not expected). +- Async combinators now await with `ConfigureAwait(false)`, so they no longer resume on a captured + synchronization context. This affects consumers that relied on continuation back onto a UI or legacy + ASP.NET context. + +### Fixed + +- **An array-typed union case crashed the generator and discarded every other union's output.** The + discovery code explicitly handles `IArrayTypeSymbol` when checking accessibility and type parameters, then + cast the case to `INamedTypeSymbol` — which an array is not — throwing `InvalidCastException`. With no + catch-all in the pipeline the compiler reported `CS8785` and dropped **all** generated source for the + compilation: every `AsFailure` helper and union factory vanished, producing a cascade of unrelated + `CS1061`/`CS0103` errors with no indication of the cause, and `AD0001` in the IDE where analysis then + stopped. A single malformed union anywhere in a solution did this. Now reported as `RSG1009` and skipped; + the union's other cases still generate. +- **Added a generator catch-all (`RSG9000`).** An unexpected failure is now reported against the + compilation rather than allowed to escape, so the rest of the generated output survives and the cause is + identifiable. `OperationCanceledException` is rethrown, because the IDE cancels generation on every + keystroke. + +### Security + +- The source generator is now bundled into the `Purview.Results` package correctly. `Purview.Results` ships both + the IL-merged generator and `Purview.Results.SourceGenerator.CodeFixes.dll` under `analyzers/dotnet/cs/`. + +### Documentation + +- Recorded that results have **no serialization support** and that reflection-based `System.Text.Json` + serialization throws, in [Guarantees and Limitations](docs/wiki/Guarantees-and-Limitations.md). +- Added XML documentation to the four public implicit conversion operators. +- Corrected the `Examples.*` paths in the `Justfile` (`src/src`, not `src/examples`), the packable-project count + in `pr.yml`, the `pipeline-local-release` status in `AGENTS.md`, and the hardcoded version in + [Release Flow](docs/wiki/Release-Flow.md). + +## 1.0.0-prerelease.2 + +See the [`v1.0.0-prerelease.2`](https://github.com/purview-dev/results/releases/tag/v1.0.0-prerelease.2) +release notes. + +## 1.0.0-prerelease.1 + +Initial prerelease: `Result`, the `[GenerateResult]` source generator, the `CS0029` code fix, +and the ZodSharp and ASP.NET Core integrations. See the +[`v1.0.0-prerelease.1`](https://github.com/purview-dev/results/releases/tag/v1.0.0-prerelease.1) release notes. diff --git a/Directory.Packages.props b/Directory.Packages.props index bec92e1..206b0ff 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,13 +1,24 @@ + + true + + 11.0.0-rc.1.26425.128 + 10.0.12 - 2.0.0 + 2.0.1 + 1.0.0 - 1.0.0-prerelease.54 + 1.0.0 5.9.0 @@ -17,6 +28,7 @@ + diff --git a/Justfile b/Justfile index 2cd891f..9cd068a 100644 --- a/Justfile +++ b/Justfile @@ -111,22 +111,27 @@ pack publish_folder=artifacts_folder *args: # Run the Basic example (result states, combinators and the generated AsFailure helpers) [group('Examples')] example-basic *args: - dotnet run --project src/examples/Examples.Basic {{ args }} + dotnet run --project src/src/Examples.Basic {{ args }} # Run the ZodSharp example (a validation outcome flowing through the result pipeline) [group('Examples')] example-zod *args: - dotnet run --project src/examples/Examples.Zod {{ args }} + dotnet run --project src/src/Examples.Zod {{ args }} # Run the ASP.NET Core example (result-to-response mapping), listening on http://localhost:5215 [group('Examples')] example-aspnetcore *args: - dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 {{ args }} + dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 {{ args }} # Run the ASP.NET Core + ZodSharp example (validation problems from result failures), listening on http://localhost:5216 [group('Examples')] example-aspnetcore-zod *args: - dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 {{ args }} + dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 {{ args }} + +# Run the ValueObjects + ZodSharp example (a type-level [ZodRule] flowing into the result and HTTP mapping) +[group('Examples')] +example-valueobjects-zod *args: + dotnet run --project src/src/Examples.ValueObjects.Zod {{ args }} # Open the solution in Visual Studio/ Registered application [group('Utilities')] diff --git a/README.md b/README.md index 38d5493..c8999a9 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ Exceptional circumstances still throw; expected outcomes are values. | Package | Purpose | Targets | | --- | --- | --- | -| [`Purview.Results`](src/src/Results/Sdk/README.md) | `Result` and the `Result` factories. No dependencies. | `net11.0` | -| [`Purview.Results.SourceGenerator`](src/src/SourceGenerator/Sdk/README.md) | Generates `AsFailure()` helpers for `[GenerateResult]` unions. | `netstandard2.0` | +| [`Purview.Results`](src/src/Results/Sdk/README.md) | `Result` and its value-less counterpart `Result`, the `Result` factories, and the bundled source generator that emits per-case `AsFailure()`/`AsFailure()` helpers and union-receiver `Union.Failure(...)`/`Union.Success(...)` factories for `[GenerateResult]` unions. No runtime dependencies. | `net11.0` | | [`Purview.Results.ZodSharp`](src/src/ZodSharp/Sdk/README.md) | Bridges ZodSharp `ValidationResult` values into results. | `net11.0` | | [`Purview.Results.AspNetCore`](src/src/AspNetCore/Sdk/README.md) | Maps results onto ASP.NET Core responses (`IResult`, `ProblemDetails`). | `net11.0` | | [`Purview.Results.ZodSharp.AspNetCore`](src/src/ZodSharp.AspNetCore/Sdk/README.md) | Maps result failures that carry validation errors onto `HttpValidationProblemDetails`. | `net11.0` | Each package README is the same file that ships inside the `.nupkg` (project `Sdk/README.md`), so this table -links straight to the package documentation. Every package also ships agent skills under `.agents/` (union -modelling and the result core, HTTP mapping, and the two ZodSharp bridges), which `Purview.BuildSdk` mirrors -into a consuming repository's own `.agents/` folder on the next restore or build. +links straight to the package documentation. The source generator is not a separate package: it ships inside +`Purview.Results` under `analyzers/dotnet/cs`, together with its IDE code fix. Every package also ships agent +skills under `.agents/` (union modelling and the result core, HTTP mapping, and the two ZodSharp bridges), which +`Purview.BuildSdk` mirrors into a consuming repository's own `.agents/` folder on the next restore or build. ## Quick start @@ -33,8 +33,8 @@ Result GetTenant(TenantId tenantId) => : new TenantNotFound(tenantId).AsFailure(); ``` -`AsFailure()` is generated by `Purview.Results.SourceGenerator` for every case of a union opted in with -`[GenerateResult]`: +`AsFailure()` is generated by the source generator bundled in `Purview.Results` for every case of a +union opted in with `[GenerateResult]`: ```csharp [GenerateResult] @@ -45,13 +45,22 @@ public readonly record struct TenantDisabled(TenantId TenantId); public readonly record struct TenantAlreadyExists(TenantId TenantId); ``` +The generator also declares a C# 14 extension block on the union itself, so the union names the error and the +call site stays unambiguous: `TenantError.Failure(tenantNotFound)` produces `Result`, +`TenantError.Failure(tenantNotFound)` produces `Result`, and +`TenantError.Success()` / `TenantError.Success(tenant)` produce the success shapes. This union-receiver factory is +the shared-case safe form: when a **leaf** case type belongs to more than one union, the per-case `AsFailure` +helper is generated once and reported as `RSG1006`, so the factory is the call that always binds to the union you +name. A case type that is itself a union is an *included union*: the factory also covers its cases and builds the +nested value, so composition replaces leaf sharing and no `RSG1006` is raised for it. + The generator cannot make a bare case value convert implicitly — C# forbids operators in a static class, conversion operators in extension members, and more than one user-defined conversion per sequence — so the -per-case helper is the ergonomics the language allows. The generator package also ships a code fix for the -IDE and a diagnostic suppressor that answers `CA1815` for opted-in unions, so a `[GenerateResult]` union needs -no `#pragma warning disable CA1815`, and returning the union itself +per-case helper is the ergonomics the language allows. `Purview.Results` also ships a code fix for the IDE and +a diagnostic suppressor that answers `CA1815` for opted-in unions, so a `[GenerateResult]` union needs no +`#pragma warning disable CA1815`, and returning the union itself (`(TenantError)new TenantNotFound(id)`) is the only helper-free form; see -the [generator package README](src/src/SourceGenerator/Sdk/README.md) for the compiler evidence. +the [source generator README](src/src/SourceGenerator/README.md) for the compiler evidence. Expose the result over HTTP with the ASP.NET Core package, which maps each error case to a response: @@ -66,21 +75,23 @@ app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp(); ## Examples -Every example is a runnable, non-packable project under [`src/examples`](src/examples), built on the same -Tenancy domain the Quick start uses, so the one `TenantError` union drives all four integration aspects. +Every example is a runnable, non-packable project under `src/src/Examples.*`, built on the same +Tenancy domain the Quick start uses, so one error-union vocabulary drives every integration aspect. | Example | Packages | Demonstrates | | --- | --- | --- | -| [`Examples.Basic`](src/examples/Examples.Basic) | `Purview.Results`, `Purview.Results.SourceGenerator` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, and the generated `AsFailure()` helper | -| [`Examples.Zod`](src/examples/Examples.Zod) | + `Purview.Results.ZodSharp` | A `[ZodSchema]` input validated into a result, where the rejection carries its `ValidationError`s | -| [`Examples.AspNetCore`](src/examples/Examples.AspNetCore) | + `Purview.Results.AspNetCore` | `AddResultsHttp`/`Map`/`WithResultsHttp`, including the mapping-gap and uninitialized-result paths | -| [`Examples.AspNetCore.Zod`](src/examples/Examples.AspNetCore.Zod) | + `Purview.Results.ZodSharp.AspNetCore` | A validation-carrying failure rendered as `HttpValidationProblemDetails`, with a case mapping winning over the fallback | +| [`Examples.Basic`](src/src/Examples.Basic) | `Purview.Results` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, the generated `AsFailure()` and `AsFailure()` helpers and the union-receiver `Union.Failure(...)`/`Union.Success(...)` factories, cross-service error-union widening, value-less `Result` commands, and `Throw()` | +| [`Examples.Zod`](src/src/Examples.Zod) | + `Purview.Results.ZodSharp` | A `[ZodSchema]` input validated into a result, where the rejection carries its `ValidationError`s, and a value-discarding `ToUnitResult` validation | +| [`Examples.AspNetCore`](src/src/Examples.AspNetCore) | + `Purview.Results.AspNetCore` | `AddResultsHttp`/`Map`/`WithResultsHttp`, nested error-union leaf mapping, a successful unit result answering `204`, the mapping-gap and uninitialized-result paths | +| [`Examples.AspNetCore.Zod`](src/src/Examples.AspNetCore.Zod) | + `Purview.Results.ZodSharp.AspNetCore` | A validation-carrying failure rendered as `HttpValidationProblemDetails`, with a case mapping winning over the fallback | +| [`Examples.ValueObjects.Zod`](src/src/Examples.ValueObjects.Zod) | `Purview.Results.ZodSharp`, `Purview.ValueObjects` | A `[Scalar]` value object whose type-level `[ZodRule]` owns its code and origin, validated into a result failure the HTTP layer can answer by origin | ```bash -dotnet run --project src/examples/Examples.Basic -dotnet run --project src/examples/Examples.Zod -dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 -dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 +dotnet run --project src/src/Examples.Basic +dotnet run --project src/src/Examples.Zod +dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 +dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 +dotnet run --project src/src/Examples.ValueObjects.Zod ``` ### Basic @@ -92,7 +103,7 @@ dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhos Result GetTenant(TenantId tenantId) => _tenants.TryGetValue(tenantId, out var tenant) ? Result.Success(tenant) - : new TenantNotFound(tenantId).AsFailure(); + : TenantError.Failure(new TenantNotFound(tenantId)); var loaded = GetTenant(tenantId); @@ -104,6 +115,39 @@ loaded.TryGetError(out var error); // probing never throws, even for `default` loaded.Value; // throws InvalidOperationException unless the result is a success ``` +When one service calls another, the caller's operation-family union includes the callee's error union as a case, +and the widening `Bind` (or a `TryGetError` guard with the generated helper) lifts the callee's failure into it. +`Throw()` is the escape hatch for a boundary that must throw: + +```csharp +[GenerateResult] +public readonly union RegisterTenantError(TenantError, BillingError); + +Result Register(TenantId id, string name) => + billing.ReserveQuota(id) // Result + .Bind(quota => CreateTenant(quota, name), error => error); + +store.GetTenant(id).Throw(); // returns the value, or throws ResultException +``` + +When an operation has nothing to return on success, use the value-less `Result`. It carries the same +three states and the same throw-on-misuse contract, but succeeds with the `Success` marker instead of a value, +and the generator emits a non-generic `AsFailure()` for the same cases: + +```csharp +Result DeleteTenant(TenantId tenantId) => + _tenants.Remove(tenantId) + ? Result.Success() + : new TenantNotFound(tenantId).AsFailure(); + +var deleted = DeleteTenant(tenantId); + +deleted.Match(() => "deleted", error => $"could not delete: {error}"); // the success arm takes no value +deleted.Map(() => 1); // attach a value to a success +Result loaded = deleted.Bind(() => GetTenant(tenantId)); +loaded.DiscardValue(); // and back to a unit result +``` + ### Zod ZodSharp validation never throws: `Validate` returns a `ValidationResult` carrying the validated @@ -142,8 +186,9 @@ return RegisterValidated(validation.Value); ### ASP.NET Core -The host decides what each error case looks like on the wire. A mapping registered for the **case** type wins; -the mapping registered for the **error** type covers every case without one: +The host decides what each error case looks like on the wire. A mapping registered for the most specific **case** +type wins, then a mapping for an enclosing **union** type, then the mapping registered for the **error** type, +which covers every case without one. A nested union resolves to its leaf: ```csharp builder.Services.AddResultsHttp(options => options @@ -163,6 +208,11 @@ Running `Examples.AspNetCore` answers as follows: | `GET /tenants/initech` | `404 Not Found` — the mapping for the `TenantNotFound` case | | `GET /tenants/globex/usage` | `403 Forbidden` — the mapping for the `TenantDisabled` case | | `POST /tenants/acme` | `409 Conflict` — the mapping for the `TenantError` error type | +| `DELETE /tenants/hooli` | `204 No Content` — a successful unit `Result` carries no payload | +| `DELETE /tenants/initech` | `404 Not Found` — the unit result's `TenantNotFound` failure maps by case | +| `GET /tenants/acme/billing` | `503 Service Unavailable` — the leaf of the nested `TenantOperationError` union | +| `GET /tenants/hooli/billing` | `402 Payment Required` — another leaf of the nested union | +| `GET /tenants/initech/billing` | `404 Not Found` — a nested tenant failure resolves to its leaf | | `GET /tenants/broken` | `500` with an `errorType` extension, because an endpoint returning `default` is a host bug | **Case** and **error** mappings are keyed by type. When the answer depends on the *value* a failure carries — a @@ -178,8 +228,8 @@ builder.Services.AddResultsHttp(options => options ### ASP.NET Core + Zod Register the validation mapping last, so any mapping or failure mapper the host declared earlier always wins, and -reuse the ZodSharp problem mapper rather than reimplementing error-to-problem mapping. Per-code and per-category -rules answer particular validation failures with a response of their own: +reuse the ZodSharp problem mapper rather than reimplementing error-to-problem mapping. Per-code, per-category and +per-origin rules answer particular validation failures with a response of their own: ```csharp builder.Services.AddZodSharpProblemDetails(); @@ -188,11 +238,12 @@ builder.Services.AddResultsHttp(options => options ); builder.Services.AddResultsZodSharpHttp(options => options .MapCode("tenant_id_matches_name", StatusCodes.Status422UnprocessableEntity) + .MapOrigin("value_object", StatusCodes.Status422UnprocessableEntity) ); ``` A `TenantInputInvalid` failure implements `IValidationErrorCarrier`, so it becomes a validation problem without -the host mapping it — unless a rule answers one of its codes or categories: +the host mapping it — unless a rule answers one of its codes, categories or origins: ```json { @@ -214,28 +265,44 @@ The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md - [Source generator](docs/wiki/Source-Generator.md) and [diagnostics](docs/wiki/Diagnostics.md) - [ASP.NET Core integration](docs/wiki/AspNetCore-Integration.md) - [ZodSharp integration](docs/wiki/ZodSharp-Integration.md) and [problem details](docs/wiki/ZodSharp-ProblemDetails.md) +- [Value objects composition](docs/wiki/Value-Objects-Composition.md) - [Guarantees and limitations](docs/wiki/Guarantees-and-Limitations.md) ## Requirements -- **.NET 11 SDK or later** — the runtime packages target `net11.0`; the source generator targets - `netstandard2.0` so any compiler host can load it. -- **C# 15 preview** — union declarations are a preview language feature, so consuming projects need an SDK with - union support and `LangVersion=preview` (the repository sets both). +- **.NET 10 or .NET 11** — the runtime packages multi-target `net10.0` and `net11.0`, so adopting them does + not force a runtime upgrade. The source generator bundled in `Purview.Results` targets `netstandard2.0` + so any compiler host can load it. .NET 8 and 9 are not targeted; both are close to end of support. +- **Unions require .NET 11.** The compiler needs `System.Runtime.CompilerServices.IUnion` and + `UnionAttribute`, which do not exist earlier, so a `[GenerateResult]` union declaration cannot compile for + `net10.0`. Everything else works on both: the result types, the combinators, the ASP.NET Core mapping and + the ZodSharp bridge. On `net10.0` an error type is simply a plain type rather than a union, which the HTTP + mapper already handles — register a mapping for the error type itself. +- **C# 15 preview — only to *declare* a union.** The requirement is per-project and scoped: + - Using `Result`, `Result` and the combinators needs **nothing special**. No + `LangVersion`, no `EnablePreviewFeatures`. + - Declaring your own `[GenerateResult]` union needs `LangVersion=preview` in **that** project, because the + union syntax is yours to compile. + + These packages deliberately do **not** set `EnablePreviewFeatures`. Doing so emits + `[assembly: RequiresPreviewFeatures]`, which makes CA2252 (an error by default) fire on every member a + consumer touches — assembly-wide, so it applied even to the non-union result type and made partial adoption + impossible. Nothing here uses a runtime API annotated `[RequiresPreviewFeatures]`, so the attribute was + removed. If you see CA2252 from these packages, that is a bug — please report it. ## Repository layout | Path | Purpose | | --- | --- | | `src/Results.slnx` | Canonical solution for restore, build, test and pack | -| `src/src/Results` | `Result`, `Result` factories, `IResultValue` | +| `src/src/Results` | `Result` and `Result`, `Success`, the `Result` factories, `IResultValue` | | `src/src/SourceGenerator` | Roslyn incremental generator + diagnostic analyzer for `[GenerateResult]` | | `src/src/AspNetCore` | Result-to-response mapping, endpoint filter and DI registration | | `src/src/ZodSharp` | ZodSharp `ValidationResult` bridge | | `src/src/ZodSharp.AspNetCore` | `HttpValidationProblemDetails` mapping for validation-carrying failures | | `src/src//Sdk` | Package-only assets: `README.md` (packed as the package README) and any `Sdk/.agents/**` skills | | `src/tests` | TUnit unit tests, including source-generation and incremental-cache tests | -| `src/examples` | Runnable, non-packable examples: one project per integration aspect, built on the Tenant* domain | +| `src/src/Examples.*` | Runnable, non-packable examples: one project per integration aspect, built on the Tenant* domain | | `docs/wiki` | User-facing documentation suite, aggregated by the purview-dev website | | `Directory.Packages.props` | Centrally managed NuGet versions | | `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package and build behaviour | @@ -251,7 +318,7 @@ dotnet tool restore just build # build the solution (Debug) just test-unit # run the unit tests just lint-check # CSharpier + .slnx GUID validation -just pack # pack all five packages to ./artifacts +just pack # pack all four packages to ./artifacts just pipeline-pr # the full PR pipeline (restore, build, lint, test, pack) ``` diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md index 6519f3e..d06087b 100644 --- a/docs/wiki/Agent-Skills.md +++ b/docs/wiki/Agent-Skills.md @@ -10,12 +10,12 @@ automatically. The content is authored in this repository under `src/src/` onto an ASP.NET Core response, so an endpoint can -return a result and let the host decide what each error case looks like on the wire. +`Purview.Results.AspNetCore` maps a `Result` or a value-less `Result` onto an ASP.NET Core +response, so an endpoint can return a result and let the host decide what each error case looks like on the wire. ## Installation @@ -39,17 +39,21 @@ non-invasive: a handler that returns something other than an `IResultValue` is l ## How a result becomes a response **Success** — the value is serialized with `SuccessStatusCode` (`200 OK` by default). A result whose successful -value is itself an `IResult` is passed through untouched, and `SuccessMapper` overrides both when set. +value is itself an `IResult` is passed through untouched, and `SuccessMapper` overrides both when set. A +successful unit `Result` carries no payload, so it answers `204 No Content` unless `SuccessMapper` is set. -**Failure** — the error is resolved to its *case* value (the active case of a union error, or the error itself for -a non-union error), then mapped in this order: +**Failure** — the error is resolved to its *case* value (the innermost active case of a union error, so a nested +union resolves to its leaf; or the error itself for a non-union error), then mapped in this order: -1. a mapping registered for the **case** type — `Map(...)` -2. a mapping registered for the **error** type — `Map(...)`, which handles every case without its own +1. a mapping registered for the most specific **case** type — `Map(...)` +2. a mapping registered for each enclosing **union** type, from the inside out — `Map(...)` handles a + whole nested union when its leaf has no mapping of its own +3. a mapping registered for the **error** type — `Map(...)`, which handles every case without its own mapping -3. the **fallback stage**, in registration order: `AddFallback(...)` delegates and `AddFailureMapper()` - mappers share one ordered list; a fallback or mapper returns `null` to defer to the next entry -4. a `ProblemDetails` response using `UnmappedStatusCode` (`500`), `UnmappedTitle`, and an `errorType` extension +4. the **fallback stage**, in registration order: `AddFallback(...)` delegates and `AddFailureMapper()` + mappers share one ordered list; a fallback or mapper receives the leaf case and returns `null` to defer to the + next entry +5. a `ProblemDetails` response using `UnmappedStatusCode` (`500`), `UnmappedTitle`, and an `errorType` extension naming the unmapped case — or an `InvalidOperationException` when `ThrowOnUnmappedFailure` is set An **uninitialized** result (`default`) takes the same path and is logged, because an endpoint returning `default` @@ -98,11 +102,11 @@ builder.Services.AddResultsHttp(options => options .AddFailureMapper()); ``` -`ResultsFailureContext` carries the failure's **case** (the active case of a union error, or the error itself), -the **error** the result carries, and the request. A mapper is resolved from the failing request's services the -first time it is needed, so it may take its own dependencies in its constructor; register it before the first -request. A failure that reaches an unregistered mapper throws an `InvalidOperationException` naming the -registration that is missing. +`ResultsFailureContext` carries the failure's **case** (the innermost active case of a union error, so a nested +union resolves to its leaf; or the error itself), the **error** the result carries, and the request. A mapper is +resolved from the failing request's services the first time it is needed, so it may take its own dependencies in its +constructor; register it before the first request. A failure that reaches an unregistered mapper throws an +`InvalidOperationException` naming the registration that is missing. Answering every failure in a mapper — with a generic problem or a `202`, for example — turns a mapping gap in the host into a plausible-looking response, which is exactly what the unmapped-failure path exists to expose. Map the @@ -135,10 +139,16 @@ Running `Examples.AspNetCore`: | `GET /tenants/initech` | `404 Not Found` — the mapping for the `TenantNotFound` case | | `GET /tenants/globex/usage` | `403 Forbidden` — the mapping for the `TenantDisabled` case | | `POST /tenants/acme` | `409 Conflict` — the mapping for the `TenantError` error type | +| `DELETE /tenants/hooli` | `204 No Content` — a successful unit `Result` carries no payload | +| `DELETE /tenants/initech` | `404 Not Found` — the unit result's `TenantNotFound` failure maps by case | +| `GET /tenants/acme/billing` | `503 Service Unavailable` — the leaf `BillingServiceUnavailable` case of the nested `TenantOperationError` union | +| `GET /tenants/hooli/billing` | `402 Payment Required` — the leaf `BillingAccountMissing` case | +| `GET /tenants/initech/billing` | `404 Not Found` — a nested tenant failure resolves to the `TenantNotFound` leaf | +| `GET /tenants/globex/billing` | `403 Forbidden` — a nested tenant failure resolves to the `TenantDisabled` leaf | | `GET /tenants/broken` | `500` with an `errorType` extension, because an endpoint returning `default` is a host bug | ```bash -dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 +dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 ``` ## Related diff --git a/docs/wiki/Combinators.md b/docs/wiki/Combinators.md index 8ce8cb1..fd39a0e 100644 --- a/docs/wiki/Combinators.md +++ b/docs/wiki/Combinators.md @@ -11,6 +11,7 @@ throwing input check for a `null` delegate is `ArgumentNullException`. | `Match(success, failure)` | `success(value)` | `failure(error)` | throws | | `Map(map)` | `Result.Success(map(value))` | the existing error, unchanged | throws | | `Bind(bind)` | the result `bind` returns | the existing error, unchanged | throws | +| `Bind(bind, mapError)` | the result `bind` returns | `Result.Failure(mapError(error))` | throws | | `MapError(map)` | the value, unchanged | `Result.Failure(map(error))` | throws | ```csharp @@ -26,6 +27,25 @@ Result renamed = GetTenant(tenantId) `Map` and `Bind` preserve the error type; `MapError` preserves the value. `Bind` is the operation to reach for when the next step can fail with the **same** error type — otherwise `MapError` first or a `Match` is clearer. +## Chaining across error types + +`Bind` preserves the error type, so a step that fails with a **different** error needs the widening overload: it +maps the existing error into the next step's error type and binds in one call. It is equivalent to +`MapError(mapError).Bind(bind)`, and `BindAsync(bind, mapError)` is the asynchronous counterpart. + +```csharp +Result Register(TenantId id, string name) => + billing.ReserveQuota(id) // Result + .Bind( + quota => CreateTenant(quota, name), // Result + error => error); // BillingError -> RegisterTenantError +``` + +The target union is the operation family's error, and the upstream service's error union is one of its cases, so +`error => error` relies on the implicit union conversion. When the target union flattens the services' leaf cases +instead, map them explicitly with `MapError`. [Union Errors](Union-Errors.md) covers the modelling choice and its +HTTP-mapping consequence. + ## Constraining a success `Ensure(predicate, errorFactory)` turns a successful value that fails the predicate into a failure: @@ -83,8 +103,42 @@ Task> saved = GetTenantAsync(tenantId) .BindAsync(tenant => _store.SaveAsync(tenant)); ``` -`MapAsync` and `BindAsync` behave exactly like their synchronous counterparts, and both throw for `default`. -They are extension methods in `ResultExtensions`, not members on the result type. +`MapAsync`, `BindAsync` and the widening `BindAsync(bind, mapError)` behave exactly like their synchronous +counterparts, and all throw for `default`. They are extension methods in `ResultExtensions`, not members on the +result type. + +## Unwrapping with `Throw` + +`Throw()` returns the successful value and throws a `ResultException` carrying the error when the result +represents failure: + +```csharp +var tenant = store.GetTenant(tenantId).Throw(); // throws ResultException on failure +``` + +It is the deliberate result-to-exception escape hatch — reach for it at a boundary that must throw, or in a test, +not to handle an expected failure, which is normally a value. The thrown exception exposes the error with its +original type through `ResultException.Error`; a non-generic `ResultException` base carries the same error +as `object?` for a catch-all. An uninitialized result is a bug, so `Throw()` reports it with +`InvalidOperationException` (`"The result is uninitialized."`) rather than `ResultException`. + +## Value-less results + +`Result` supports the same operations, but its success callbacks take no argument because there is no +value to pass. `Match`, `Switch`, `Tap` and `Ensure` therefore use a no-argument success delegate: + +```csharp +Result deleted = DeleteTenant(tenantId); + +deleted.Match(() => "deleted", error => $"could not delete: {error}"); +deleted.Ensure(() => _tenants.Count > 0, () => new TenantError()); +``` + +`Map(() => value)` attaches a value to a success, producing a `Result`, and `Bind` continues with +either another unit operation or a value-producing one. The widening `Bind` and `BindAsync` overloads exist in +both shapes. `Result.DiscardValue()` is the inverse of `Map`: it drops the value and keeps the +error, so a value result can join a unit-result chain. `Throw()` returns the `Success` marker and throws +`ResultException` on failure, and `default` still throws `InvalidOperationException`. ## Related diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index e27a2a5..cc59fe1 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -9,7 +9,7 @@ Contributions are welcome. Open an issue or a pull request against src/ Results.slnx Canonical solution for restore, build, test and pack src/ - Results/ Result, Result factories, IResultValue + Results/ Result, Result, Result factories, IResultValue SourceGenerator/ Roslyn incremental generator + analyzer + [GenerateResult] SourceGenerator.CodeFixes/ CS0029 code fix for returning a bare union case AspNetCore/ Result-to-response mapping, endpoint filter, DI registration diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 6d22d97..ce8b77b 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -28,6 +28,11 @@ An expected failure is a value; a misuse is a bug. The contract is deliberately An uninitialized result is never treated as a failure or as a success. If that is too strict for a boundary — inspecting a result you did not create — use the probing methods instead of the throwing ones. +`Throw()` is the one member that turns an expected failure back into an exception: it returns the successful value +and throws `ResultException` carrying the error on failure, and for `default` it throws the same +`InvalidOperationException` as the combinators. Treat it as an escape hatch for a boundary that must throw, not as +a way to handle a failure. + ## Probing without throwing `TryGetValue` and `TryGetError` in `ResultExtensions` are the deliberate exception to the throwing contract: they @@ -76,6 +81,29 @@ error types — the ASP.NET Core endpoint filter, for example. Its accessors **never throw**: the accessor that does not describe the current state returns `null`. That is what makes it safe for a framework component to inspect a result without knowing the two type arguments. +## Value-less results + +An operation that has nothing to return on success — a command, a validation-only step — uses `Result`, +the value-less counterpart of `Result`. It has the same three states and the same throw-on-misuse +contract, but a success holds the `Success` marker rather than a value: + +| State | How it is reached | `ToString()` | +| --- | --- | --- | +| `Uninitialized` | `default(Result)` | `Uninitialized` | +| `Success` | `Result.Success()` | `Success` | +| `Failure` | `Result.Failure(error)` | `Failure(error)` | + +Because there is no success value, the success callbacks take no argument: `Match(() => ..., error => ...)`, +`Switch(() => ..., error => ...)` and `Tap(() => ...)`. `Map` produces a value from a success that had none, +`Bind` continues with another operation, and the value-result extension `DiscardValue()` turns a +`Result` back into a unit result, preserving the error. `Throw()` returns the `Success` marker +and throws `ResultException` on failure, exactly as the value result does. + +The implicit conversions from `Success` and `TError`, and the `Result.Success`/`.Failure` and +`Result.Success()`/`Result.Failure(error)` factories, are public contract. `IResultValue` sees a +successful unit result's `SuccessValue` as the `Success` marker, which is what lets the ASP.NET Core mapper answer +it with `204 No Content`. + ## Where extension methods live Value-style operations that do not belong on the result type itself — probing, fallbacks, side effects, diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index 374914a..490bff3 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -3,8 +3,8 @@ The union rules live in one shared library (`Diagnostics/DiagnosticLibrary.cs`, `Diagnostics/ResultUnionDiagnostics.cs`, `Diagnostics/ResultDiagnostic.cs`) that both hosts consume: -- **`ResultsDiagnosticAnalyzer` reports the per-target rules** (`RSG1000`–`RSG1004`, `RSG1007`) in the IDE and in - build output. +- **`ResultsDiagnosticAnalyzer` reports the per-target rules** (`RSG1000`–`RSG1004`, `RSG1007`, `RSG1008`) in the + IDE and in build output. - **The generator reports the compilation-wide rules** (`RSG1005`, `RSG1006`) that need every opted-in union in the compilation. - The generator never reports a rule the analyzer reports. It still runs the same shared analysis, so @@ -20,8 +20,9 @@ The union rules live in one shared library (`Diagnostics/DiagnosticLibrary.cs`, | `RSG1003` | Error | Analyzer | Union: yes / case: no | A type that generated code must reference is not accessible (for example a `file` type). | | `RSG1004` | Error | Analyzer | No | The same union is configured more than once (for example on two partial declarations); the helpers are generated once. | | `RSG1005` | Error | Generator | Yes (the colliding union is skipped) | Two unions produce the same generated class name in one namespace. | -| `RSG1006` | Warning | Generator | No (only the shared case's helper is skipped) | A case type is shared with another union, so its helper is generated once to keep call sites unambiguous. | +| `RSG1006` | Warning | Generator | No (only the shared leaf case's `AsFailure` helper is skipped) | A **leaf** case type is shared with another union, so its per-case helper is generated once; the union-receiver `Failure(...)` factory is generated for every union and is the shared-case safe form. A union-typed case shared across unions is composition, not a shared leaf, and is not reported. | | `RSG1007` | Error | Analyzer | Yes | The union uses an `IUnionMembers` member provider, which the first implementation does not support. | +| `RSG1008` | Warning | Analyzer | No (only the ambiguous case's inclusion factory is skipped) | A case is reachable through two different included unions, so no inclusion factory can name its construction path. List the case through the union that directly declares it instead. | Case-level findings never block the union: the remaining cases are still generated and the skipped case is reported. Blocking is decided per rule in `DiagnosticLibrary.IsBlocking`, not derived from severity, because a @@ -47,7 +48,7 @@ The suppression is narrow on purpose: - The union's **case types** keep the warning, as does every other value type. A case declared as a plain `struct` still gets `CA1815`; a `record struct` case never gets it, because records synthesise equality. - Nothing is hidden: a suppressor can only suppress non-error, configurable diagnostics, and `RSG2000` is a - suppression id rather than a rule, so `RSG1000`–`RSG1007` remain the only diagnostics the package reports. + suppression id rather than a rule, so `RSG1000`–`RSG1008` remain the only diagnostics the package reports. Every suppression is logged as an `Info` diagnostic against `RSG2000`, in the verbose build log and in an MSBuild binlog (and as a suppressed diagnostic in an `/errorlog` SARIF file), so a build can always be audited for what it diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 2295fad..a98232d 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -4,21 +4,29 @@ This guide installs the packages, models an error union, returns a result, and m ## Requirements -- **.NET 11 SDK or later** — the runtime packages target `net11.0`; the source generator targets - `netstandard2.0` so any compiler host can load it. -- **C# 15 preview** — union declarations are a preview language feature, so a project that declares one needs - `LangVersion=preview` (the repository sets it centrally). +- **.NET 10 or .NET 11** — the runtime packages multi-target `net10.0` and `net11.0`; the source generator + targets `netstandard2.0` so any compiler host can load it. .NET 8 and 9 are not targeted. +- **Unions require .NET 11**, because the compiler needs `System.Runtime.CompilerServices.IUnion` and + `UnionAttribute`. On `net10.0` the result types, combinators, ASP.NET Core mapping and ZodSharp bridge all + work; only the union declaration itself is unavailable, so map the error type directly instead of a case. +- **C# 15 preview — only to *declare* a union.** Using the result types and combinators needs nothing + special: no `LangVersion`, no `EnablePreviewFeatures`. A project that declares its own + `[GenerateResult]` union needs `LangVersion=preview` in that project, because the union syntax is yours to + compile. Adoption is therefore incremental — you can take `Result` into an existing + solution first and introduce unions per project later. + + These packages do not set `EnablePreviewFeatures`, so they do not force preview features on your + solution. See [Guarantees and Limitations](Guarantees-and-Limitations.md). ## 1. Reference the packages ```bash dotnet add package Purview.Results -dotnet add package Purview.Results.SourceGenerator # only when you model errors as a union ``` -`Purview.Results.SourceGenerator` is a Roslyn component: add it as an ordinary `PackageReference` and it applies -to the compilation automatically. It also brings the analyzer, the `CS0029` code fix, and the `CA1815` -suppression for opted-in unions. +`Purview.Results` carries the result type and, under `analyzers/dotnet/cs`, the Roslyn component that makes +union errors ergonomic: it applies to the compilation automatically and brings the analyzer, the `CS0029` code +fix, and the `CA1815` suppression for opted-in unions. There is no separate generator package to reference. ## 2. Model the error as a union (optional) @@ -36,17 +44,35 @@ public readonly record struct TenantAlreadyExists(TenantId TenantId); ``` `[GenerateResult]` is generated by the source generator itself, so it needs no separate reference. For every case -the generator emits `AsFailure()` in the union's namespace: +the generator emits a value-producing `AsFailure()` and a non-generic `AsFailure()` in the union's +namespace, plus a union-receiver factory on the union type itself: ```csharp public static class TenantErrorResultExtensions { public static Result AsFailure(this TenantNotFound error) => Result.Failure(error); - // ... one overload per case type + + public static Result AsFailure(this TenantNotFound error) => + Result.Failure(error); + // ... the same pair per case type + + extension(TenantError) + { + public static Result Failure(TenantNotFound error) => + Result.Failure(error); + + public static Result Failure(TenantNotFound error) => + Result.Failure(error); + // ... the same Failure pair per case type, plus Success() and Success(value) + } } ``` +The union-receiver factory names the union, so it stays unambiguous when a leaf case type is shared with another +union (`RSG1006`). A case type that is itself a union is an *included union*: the factory also covers its cases and +builds the nested value, so list the callee's union as a case instead of repeating its leaf cases. + See [Union Errors](Union-Errors.md) for the modelling rules and [Source Generator](Source-Generator.md) for the generated shape. @@ -127,13 +153,14 @@ See [ZodSharp Integration](ZodSharp-Integration.md) and, for ProblemDetails rend ## Runnable examples -Every example is a non-packable project under `src/examples`, built on the same Tenancy domain: +Every example is a non-packable project under `src/src/Examples.*`, built on the same Tenancy domain: ```bash -dotnet run --project src/examples/Examples.Basic -dotnet run --project src/examples/Examples.Zod -dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 -dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 +dotnet run --project src/src/Examples.Basic +dotnet run --project src/src/Examples.Zod +dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 +dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 +dotnet run --project src/src/Examples.ValueObjects.Zod ``` ## Next steps diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index dec615b..9af6656 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -8,13 +8,83 @@ change must not break. - `Result` is a `readonly record struct` with exactly three observable states: `Uninitialized` (the `default` value), `Success` and `Failure`, each observable through `IsInitialized`, `IsSuccess` and `IsFailure`. +- `Result` is the value-less counterpart: a `readonly record struct` with the same three states and the + same throw-on-misuse contract, whose success holds the `Success` marker instead of a value. `Error` throws in + the wrong state, `Match`, `Map`, `Bind`, `MapError` and the extension operations throw + `"The result is uninitialized."` for `default`, `Throw()` returns the `Success` marker or throws + `ResultException`, and `ToString()` stays `Success` / `Failure(error)` / `Uninitialized`. - The throw-on-misuse contract holds: `Value` and `Error` throw `InvalidOperationException` in the wrong state, and `Match`, `Map`, `Bind` and `MapError` throw `"The result is uninitialized."` for `default`. An uninitialized result is never silently coerced into a success or a failure. +- `Throw()` returns the successful value, throws `ResultException` carrying the error on failure, and + throws `"The result is uninitialized."` for `default`. It is the one deliberate result-to-exception escape hatch. - `ToString()` stays `Success(value)` / `Failure(error)` / `Uninitialized`. -- `IResultValue` accessors never throw; the accessor that does not describe the current state returns `null`. -- The implicit conversions from `TValue` and `TError`, and the `Result.Success`/`Result.Failure` and - `Result.Success`/`.Failure` factories, are public contract. +- `IResultValue` accessors never throw; the accessor that does not describe the current state returns `null`. A + successful unit result's `SuccessValue` is the `Success` marker. +- The implicit conversions from `TValue`, `TError` and `Success`, and the `Result.Success`/`Result.Failure`, + `Result.Success`/`.Failure` and `Result.Success`/`.Failure` factories, are public + contract. + +## Observability + +`Purview.Results.AspNetCore` emits metrics under the meter `Purview.Results.AspNetCore` +(`ResultsHttpTelemetry.MeterName`): + +| Instrument | Meaning | +| --- | --- | +| `purview.results.failures.mapped` | failures a registered mapping or fallback answered | +| `purview.results.failures.unmapped` | failures with no mapping, answered with the unmapped-failure response | +| `purview.results.uninitialized` | endpoints that returned a `default` result, which is always a bug | + +Each measurement carries a `purview.results.error_type` tag with the error's type name, and the failure +paths add the same tag to `Activity.Current` — enriching the request span ASP.NET Core already created +rather than starting one. The type name is always present in telemetry, because metrics and traces stay +inside your own infrastructure; disclosing it in the HTTP *response* is separately opt-in through +[`IncludeErrorTypeInProblemDetails`](#http-mapping-philosophy). + +```csharp +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => metrics.AddMeter(ResultsHttpTelemetry.MeterName)); +``` + +Log messages carry stable event identifiers, so they can be filtered without matching message text: +`1000` uninitialized result, `1001` unmapped failure, `2000` a ZodSharp rule answering a multi-error +failure. No log statement records a validation payload or any user input. + +These use `Meter` and `Activity` from the base class library, available through the ASP.NET Core framework +reference, so no telemetry package dependency is added. + +## Thread safety + +- **A result is safe to pass between threads by value.** It is a `readonly record struct` with no mutable + state, so copying it — returning it, passing it as an argument, awaiting a `Task>` — is safe. +- **A result is _not_ safe to store in a shared mutable field and write concurrently.** A multi-field struct + is not written atomically: `Result` holds the value, the error and a state byte, so a + reader can observe a *torn* value where the state says `Success` while the value field is still the + previous one. + + This is worse than it first looks, because of the nullability annotations. `IsSuccess` is annotated + `[MemberNotNullWhen(true, nameof(Value))]`, so the compiler has been told a success implies a non-null + `Value` — and will elide the null check at your call site. A torn read therefore surfaces as a + `NullReferenceException` in your code, with nothing pointing back here. + + If you need to share a result through mutable state, synchronise it, or box it into a field of a reference + type and publish that reference (a reference assignment *is* atomic): + + ```csharp + // Not safe: concurrent writers can tear the struct. + static Result _cached; + + // Safe: the reference is published atomically. + static Tuple>? _cached; + ``` + +- `ResultsHttpOptions` is **startup configuration**. Its mapping dictionary and fallback list are frozen the + first time a request reads them, so a registration added after the first failure is handled is ignored + rather than corrupting a collection another thread is enumerating. Configure it in `AddResultsHttp` and do + not mutate it afterwards. +- The generated helpers and factories are static and stateless, and `DefaultResultsHttpMapper` holds no + per-request state, so both are safe to use concurrently. ## Dependency guarantees @@ -30,7 +100,8 @@ change must not break. - `[GenerateResult]` is supported on union declarations only. Generic unions are unsupported (`RSG1002`), and so are `IUnionMembers` member providers (`RSG1007`). - Generated implicit conversions are not possible — see [Union Errors](Union-Errors.md) for the five compiler - rules. The per-case `AsFailure()` helper and the IDE code fix are the ergonomics the language allows; + rules. The per-case `AsFailure()` and `AsFailure()` helpers, the union-receiver + `Union.Failure(...)`/`Union.Success(...)` factories and the IDE code fix are the ergonomics the language allows; the helper-free alternative is the `(TenantError)caseValue` cast. - Accessibility never widens: a union or case type that is not visible produces an `internal` generated class (`RSG1003` covers the case where generated code could not reference a type at all). @@ -48,18 +119,27 @@ change must not break. ## ZodSharp mapping philosophy -- Code rules are consulted before category rules (each in registration order), then the default validation - problem, so a rule can only narrow what the host already gets. -- A rule matches when **any** of the failure's errors carries its code or category: a rule a schema can silently - never reach is the kind of gap this suite surfaces rather than hides. +- Code rules are consulted before category rules, which are consulted before origin rules (each in registration + order), then the default validation problem, so a rule can only narrow what the host already gets. +- A rule matches when **any** of the failure's errors carries its code, category or origin: a rule a schema can + silently never reach is the kind of gap this suite surfaces rather than hides. - A factory returns `null` to decline, and matching continues. Factories see the failure's whole error set. ## Not in scope -- No exception-to-result conversion: exceptions remain for exceptional circumstances. +- No exception-to-result conversion: exceptions remain for exceptional circumstances. The reverse — turning a + result failure into an exception — is available explicitly through `Throw()`. - No runtime union inspection: matching an error case is ordinary C# pattern matching. -- No replacement for `Result`: the generator only adds call-site ergonomics. -- No `Task`/`ValueTask`-specific async combinators beyond `MapAsync` and `BindAsync`. +- No replacement for `Result` or `Result`: the generator only adds call-site ergonomics. +- No `Task`/`ValueTask`-specific async combinators beyond `MapAsync` and `BindAsync`. The async combinators take + `Task` only, carry no `CancellationToken` overloads, and await with `ConfigureAwait(false)`. +- **No serialization support.** A result is a control-flow type, not a DTO, so neither `Result` + nor `Result` ships a `JsonConverter` and neither is designed to round-trip. Reflection-based + `System.Text.Json` serialization **throws** `InvalidOperationException`, because it probes whichever of + `Value`/`Error` does not describe the current state and those accessors throw by contract. Serialize the + outcome you want instead: unwrap with `Match`, `TryGetValue`/`TryGetError`, or — over HTTP — let + [`Purview.Results.AspNetCore`](AspNetCore-Integration.md) map the result to a response. `IResultValue` is the + non-throwing view available to infrastructure that has to inspect a result generically. ## Related diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 9d0dd67..6ec37bc 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -1,7 +1,8 @@ # Purview.Results Wiki Purview.Results is the Purview result suite for .NET: a small, dependency-light `Result` value -that makes **expected** failures part of a method's contract instead of an exception, a Roslyn source generator +that makes **expected** failures part of a method's contract instead of an exception, its value-less +`Result` counterpart for operations that have nothing to return on success, a Roslyn source generator that makes C# 15 union error cases ergonomic, and adapters that carry those results to ASP.NET Core and ZodSharp. Exceptional circumstances still throw; states you expect — not found, invalid, conflict — are values. @@ -37,8 +38,7 @@ This wiki is the project documentation hub. Packages are published under the `Pu | Package | Purpose | | --- | --- | -| `Purview.Results` | `Result`, the `Result` factories and `IResultValue`. No dependencies. | -| `Purview.Results.SourceGenerator` | Generates `AsFailure()` helpers for `[GenerateResult]` unions, plus the analyzer and code fix. | +| `Purview.Results` | `Result` and the value-less `Result`, the `Result` factories and `IResultValue`, plus the bundled source generator for `[GenerateResult]` unions. No runtime dependencies. | | `Purview.Results.AspNetCore` | Maps results onto ASP.NET Core responses (`IResult`, `ProblemDetails`). | | `Purview.Results.ZodSharp` | Bridges ZodSharp `ValidationResult` values into results. | | `Purview.Results.ZodSharp.AspNetCore` | Renders validation-carrying failures as `HttpValidationProblemDetails`. | @@ -50,8 +50,9 @@ This wiki is the project documentation hub. Packages are published under the `Pu - **Throw-on-misuse, never silent** — `Value` and `Error` throw in the wrong state, and `Match`/`Map`/`Bind`/ `MapError` throw for `default`; probing (`TryGetValue`, `TryGetError`) is the non-throwing way in. - **Union error types** — a C# 15 union keeps every error case strongly typed while the result stays a single - value, and the generator supplies the per-case `AsFailure()` helper the language cannot express itself. -- **Compile-time diagnostics** — `RSG1000`–`RSG1007` report unsupported union shapes at build time, and the one + value, and the generator supplies the per-case `AsFailure()` and `AsFailure()` helpers plus the + union-receiver `Union.Failure(...)`/`Union.Success(...)` factories the language cannot express itself. +- **Compile-time diagnostics** — `RSG1000`–`RSG1008` report unsupported union shapes at build time, and the one suppression (`RSG2000`) answers `CA1815` only for opted-in unions. - **Host-controlled HTTP** — the ASP.NET Core package resolves an error **case**, then the **error type**, then ordered fallbacks, and answers an unmapped failure with a logged `500` so mapping gaps stay visible. diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 8be52b8..494ac87 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -7,8 +7,8 @@ GitHub Actions workflows in `.github/workflows/`. Consuming repositories own con ## Versioning `package.json` is the authoritative release and package version; every package is versioned from it by -`Purview.BuildSdk`. Never diverge a project's version by hand. The current line is `1.0.0-prerelease.1`; a -prerelease uses a `MAJOR.MINOR.PATCH-prerelease.N` suffix. +`Purview.BuildSdk`. Never diverge a project's version by hand. Read the current line from `package.json` +(`just version`) rather than from this page; a prerelease uses a `MAJOR.MINOR.PATCH-prerelease.N` suffix. ## Workflows @@ -43,10 +43,11 @@ removed PDB, a renamed analyzer. ## Packed shapes that must not regress -- `Purview.Results.SourceGenerator` ships its merged analyzer under `analyzers/dotnet/cs/` with **no `lib/` - folder** and **no PDB** (`PurviewPackAnalyzerPdb=false`), because the packaged analyzer is the framework's - ILRepack-merged assembly whose rewritten PDB carries no Roslyn compiler-flags record. The `CS0029` code fix - ships beside it as `Purview.Results.SourceGenerator.CodeFixes.dll` and is never IL-merged into the generator. +- `Purview.Results` ships the merged source generator under `analyzers/dotnet/cs/` with **no `lib/` folder** + and **no PDB**, because the packaged analyzer is the framework's ILRepack-merged assembly whose rewritten PDB + carries no Roslyn compiler-flags record. The `CS0029` code fix ships beside it as + `Purview.Results.SourceGenerator.CodeFixes.dll` and is never IL-merged into the generator. The generator is + not published as a separate package. - The library packages ship `lib//.dll` plus the XML documentation file, and a symbol package. - Every package ships its `README.md`, its `.agents/**` content and `purview-logo-light.png`. @@ -55,8 +56,8 @@ The framework assembly must never appear loose beside the merged analyzer — `F ## Analyzer release tracking -`Purview.Results.SourceGenerator` ships public diagnostics (`RSG1000`–`RSG1007`), so it maintains the Roslyn -release-tracking files. New or changed rules go in +The generator bundled in `Purview.Results` ships public diagnostics (`RSG1000`–`RSG1008`), so it maintains the +Roslyn release-tracking files. New or changed rules go in `src/src/SourceGenerator/AnalyzerReleases.Unshipped.md`, which the compiler's RS2008 catalogue validates during the build. `RSG2000` is a *suppression* id, not a reported rule, so it must not appear there. diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index f7af625..e789792 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -1,19 +1,21 @@ # Source Generator -`Purview.Results.SourceGenerator` is an incremental Roslyn source generator that makes C# 15 **union** error cases -ergonomic with `Result`. It generates the `[GenerateResult]` attribute, one `AsFailure()` -helper per union case, and the diagnostics that keep unsupported shapes out of a build. +The [`Purview.Results`](https://www.nuget.org/packages/Purview.Results) package ships an incremental Roslyn +source generator that makes C# 15 **union** error cases ergonomic with `Result` and the value-less +`Result`. It generates the `[GenerateResult]` attribute, the per-case `AsFailure()` and `AsFailure()` +helpers, the union-receiver `Union.Failure(...)` and `Union.Success(...)` factories for every union case (including +the cases reachable through an included union), and the diagnostics that keep unsupported shapes out of a build. It +is not published as a separate package. ## Installation ```bash -dotnet add package Purview.Results.SourceGenerator dotnet add package Purview.Results ``` -The component targets `netstandard2.0` (with the SDK's Roslyn defaults) so any compiler host can load it. -Requirements: a C# 15 compiler with union declaration support (the .NET 11 SDK or later) and -`LangVersion=preview`. +The component targets `netstandard2.0` (with the SDK's Roslyn defaults) so any compiler host can load it, and it +is applied to the compilation automatically from the package's `analyzers/dotnet/cs` folder. Requirements: a +C# 15 compiler with union declaration support (the .NET 11 SDK or later) and `LangVersion=preview`. ## Activation and design @@ -64,33 +66,63 @@ public static class TenantErrorResultExtensions public static Result AsFailure(this TenantNotFound error) => Result.Failure(error); - public static Result AsFailure(this TenantDisabled error) => - Result.Failure(error); + public static Result AsFailure(this TenantNotFound error) => + Result.Failure(error); + + // ... the same pair for TenantDisabled and TenantAlreadyExists + + extension(TenantError) + { + public static Result Failure(TenantNotFound error) => + Result.Failure(error); + + public static Result Failure(TenantNotFound error) => + Result.Failure(error); + + // ... the same Failure pair per case ... + + public static Result Success() => + Result.Success(); + + public static Result Success(TValue value) => + Result.Success(value); + } } ``` -The generated helpers are pure static methods that call the existing `Result.Failure` factory: -there is no reflection, no `dynamic`, no runtime type discovery and no mutable static state. The generator does -not change, wrap or replace `Result`. +The per-case `AsFailure` helpers let the *case* name the call (`new TenantNotFound(id).AsFailure()`); the +union-receiver factory lets the *union* name it (`TenantError.Failure(new TenantNotFound(id))`). The +factory is the **shared-case safe** form: when a **leaf** case type belongs to more than one union, its per-case +`AsFailure` helper is generated once and reported as `RSG1006`, while the factory is generated for every union. + +A case type that is itself a union is an **included union**. The factory then also covers every case reachable +through it and constructs the nested value (`RegisterTenantError.Failure(new BillingAccountMissing(id))` +builds `RegisterTenantError(BillingError(BillingAccountMissing))`). Inclusion is automatic and is the supported way +to avoid `RSG1006`: list the callee's union as a case instead of repeating its leaf cases. A union-typed case shared +by two unions is composition, so it is not reported as `RSG1006` and its ambiguous per-case helper is not generated +at all. A case reachable through two different included unions is reported as `RSG1008` and skipped. + +The generated helpers are pure static methods that call the existing `Result<...>.Failure`/`.Success` factories: +there is no reflection, no `dynamic`, no runtime type discovery and no mutable static state. The generator does not +change, wrap or replace `Result` or `Result`. ## Build properties | Property | Default | Purpose | | --- | --- | --- | -| `ResultsSourceGenerator_Disable` | `false` | Disables generation while still emitting the opt-in attribute | +| `DisableResultsSourceGenerator` | `false` | Disables generation while still emitting the opt-in attribute | -The switch reaches `PackageReference` consumers through -`buildTransitive/Purview.Results.SourceGenerator.props`, so `-p:ResultsSourceGenerator_Disable=true` (or a -`Directory.Build.props` setting) disables the helpers there too. Release tracking for the rules lives in -`src/src/SourceGenerator/AnalyzerReleases.Unshipped.md`. +The switch reaches `PackageReference` consumers through `buildTransitive/Purview.Results.props`, so +`-p:DisableResultsSourceGenerator=true` (or a `Directory.Build.props` setting) disables the helpers there too. +Release tracking for the rules lives in `src/src/SourceGenerator/AnalyzerReleases.Unshipped.md`. ## Packaging -The generator is never packed loose: the package ships its merged analyzer under `analyzers/dotnet/cs/` with no -`lib/` folder and **no PDB** (`PurviewPackAnalyzerPdb=false`), because the packaged analyzer is the framework's -ILRepack-merged assembly whose rewritten PDB carries no Roslyn compiler-flags record. The `CS0029` code fix ships -beside it as a second analyzer assembly (`Purview.Results.SourceGenerator.CodeFixes.dll`) and is never IL-merged -into the generator. +The generator is never packed loose: `Purview.Results` ships its merged analyzer under `analyzers/dotnet/cs/` +with no `lib/` folder and **no PDB**, because the packaged analyzer is the framework's ILRepack-merged assembly +whose rewritten PDB carries no Roslyn compiler-flags record. The `CS0029` code fix ships beside it as a second +analyzer assembly (`Purview.Results.SourceGenerator.CodeFixes.dll`) and is never IL-merged into the generator. +The generator is not a separate NuGet package. ## Related diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index 2aaca59..b41e408 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -23,7 +23,7 @@ category, which is what the filter matches. Use `--treenode-filter` for filterin | Project | Covers | | --- | --- | -| `src/tests/Results.UnitTests` | `Result` states, the throw-on-misuse contract, and the extension operations (`ResultsTests`, `ResultExtensionsTests`) | +| `src/tests/Results.UnitTests` | `Result` and `Result` states, the throw-on-misuse contract, and the extension operations (`ResultsTests`, `ResultTErrorTests`, `ResultExtensionsTests`) | | `src/tests/SourceGenerator.UnitTests` | Generation, diagnostics, incremental caching, the compiler experiments, the code fix, and the suppressor | | `src/tests/AspNetCore.UnitTests` | `DefaultResultsHttpMapper`, the endpoint filter, failure mappers and unmapped-failure behaviour | | `src/tests/ZodSharp.UnitTests` | `ToResult` and the validation-result integration | diff --git a/docs/wiki/Union-Errors.md b/docs/wiki/Union-Errors.md index d4047cc..6e8e806 100644 --- a/docs/wiki/Union-Errors.md +++ b/docs/wiki/Union-Errors.md @@ -17,14 +17,15 @@ public readonly record struct TenantDisabled(TenantId TenantId); public readonly record struct TenantAlreadyExists(TenantId TenantId); ``` -`[GenerateResult]` is generated by `Purview.Results.SourceGenerator` during post-initialization, so the consuming -project does not declare it and needs no second package reference. Opt-in is explicit: a compilation that never -applies the attribute gets no helpers. +`[GenerateResult]` is generated by the source generator bundled in `Purview.Results` during post-initialization, +so the consuming project does not declare it and needs no second package reference. Opt-in is explicit: a +compilation that never applies the attribute gets no helpers. -## The generated helper +## The generated helpers -For each case type the generator emits one overload in a `{Union}ResultExtensions` static class in the union's -own namespace: +For each case type the generator emits two per-case overloads in a `{Union}ResultExtensions` static class in the +union's own namespace — a value-producing `AsFailure()` and a non-generic `AsFailure()` that produces a +unit result: ```csharp public static class TenantErrorResultExtensions @@ -32,11 +33,10 @@ public static class TenantErrorResultExtensions public static Result AsFailure(this TenantNotFound error) => Result.Failure(error); - public static Result AsFailure(this TenantDisabled error) => - Result.Failure(error); + public static Result AsFailure(this TenantNotFound error) => + Result.Failure(error); - public static Result AsFailure(this TenantAlreadyExists error) => - Result.Failure(error); + // ... the same pair for TenantDisabled and TenantAlreadyExists } ``` @@ -45,10 +45,39 @@ Result GetTenant(TenantId tenantId) => _tenants.TryGetValue(tenantId, out var tenant) ? Result.Success(tenant) : new TenantNotFound(tenantId).AsFailure(); + +Result DeleteTenant(TenantId tenantId) => + _tenants.Remove(tenantId) + ? Result.Success() + : new TenantNotFound(tenantId).AsFailure(); +``` + +The same class also declares a C# 14 extension block on the union type, so the union can name the call instead of +the case: + +```csharp +Result GetTenant(TenantId tenantId) => + TenantError.Failure(new TenantNotFound(tenantId)); + +Result DeleteTenant(TenantId tenantId) => + TenantError.Failure(new TenantNotFound(tenantId)); + +Result ok = TenantError.Success(); +Result created = TenantError.Success(tenant); ``` -One overload is generated **per case type** rather than one for the union, because C# does not apply union -conversions to an extension-method receiver (`CS1929`), so a helper declared on the union itself would never bind. +The per-case `AsFailure` helper is generated **once per case type**, not once per union, because C# does not apply +union conversions to an *instance* extension-method receiver (`CS1929`). A **leaf** case type shared by two unions +therefore gets a single `AsFailure` helper, reported as `RSG1006`, which can bind to the wrong union. The +union-receiver factory is a **static** extension member (`extension(Union) { public static ... }`): the receiver is +the union type itself and the case is an argument, so it is generated for every union and every case and is the form +to use when a case type is shared. + +A case type that is itself a union is an **included union**, and sharing it is composition rather than a shared +leaf: the including union's factory also covers every case reachable through it (constructing the nested value +`new Outer(new Inner(leaf))`), no `RSG1006` is reported, and no ambiguous per-case helper is generated for it. A +case reachable through two different included unions cannot name a construction path, so it is reported as +`RSG1008` and skipped. ## Why there is no implicit conversion @@ -87,13 +116,55 @@ Returning a bare case value where a result is expected is a compiler error (`CS0 `UnionCaseResultCodeFixProvider`, which offers the rewrite in the IDE from the lightbulb: ```csharp -return new TenantNotFound(id); // CS0029 -return new TenantNotFound(id).AsFailure(); // after the fix +return new TenantNotFound(id); // CS0029 +return TenantError.Failure(new TenantNotFound(id)); // after the fix, for a value result +return TenantError.Failure(new TenantNotFound(id)); // after the fix, for a unit result +``` + +A fix is offered only when the rewrite will bind: the converted type is `Purview.Results.Result` or +the unit `Purview.Results.Result`, `TError` is a union **and** opted in with `[GenerateResult]`, the +expression's type is one of that union's case types, and the union is reachable by its simple name at the call +site. A value result gets `Union.Failure(case)`; a unit result gets `Union.Failure(case)`. The rewrite names +the union, so it is correct even when the case type is shared with another union. + +## Chaining across services + +When one service calls another, the caller's error type has to include the callee's. Declare the operation +family's union with the callee's error union as a case, then widen with `Bind`'s `mapError` argument or a guard: + +```csharp +[GenerateResult] +public readonly union BillingError(BillingAccountMissing, BillingServiceUnavailable); + +// The caller's operation-family error: its own cases plus the callee's whole error union as one case. +[GenerateResult] +public readonly union RegisterTenantError(TenantError, BillingError); +``` + +```csharp +// Widening Bind: map a billing failure into the registration contract and chain. +Result Register(TenantId id, string name) => + billing.ReserveQuota(id) + .Bind(quota => CreateTenant(quota, name), error => error); + +// Guard idiom: BillingError is a case of RegisterTenantError, so the union-receiver factory produces the +// composite failure directly. +if (reserved.TryGetError(out var billingError)) + return RegisterTenantError.Failure(billingError); ``` -A fix is offered only when the rewrite will bind: the converted type is `Purview.Results.Result`, -`TError` is a union **and** opted in with `[GenerateResult]`, the expression's type is one of that union's case -types, and the union is reachable by its simple name at the call site. +List the callee's **union** as the case, not its leaf cases. The union's own cases are then reachable through the +including union's factory, so `RegisterTenantError.Failure(new BillingAccountMissing(id))` constructs the +nested value directly. A leaf listed in two unions is a shared case, which raises `RSG1006` and generates the +per-case `AsFailure` helper for only one of them, so a case-receiver call site can bind to the wrong union; convert +it with the union-receiver factory (`CallerUnion.Failure(leaf)`) instead. A union-typed case shared by two unions is +composition, so it is not reported and its per-case helper is not generated at all. If the caller must not expose +the callee's types at all, map the callee's cases into the caller's own case types with `MapError(error => error +switch { ... })` instead — the compiler then forces the mapping to stay exhaustive as the callee's union grows. + +Nesting also works with the HTTP layer: the ASP.NET Core mapper resolves a union error to its innermost case, so a +mapping for the leaf (`Map`) wins, and a mapping for the enclosing union +(`Map` or `Map`) still applies when the leaf has none. ## Union shapes that are supported @@ -101,7 +172,9 @@ types, and the union is reachable by its simple name at the call site. | --- | --- | | A union declaration whose cases are public single-parameter constructors | Yes | | A type marked `[Union]` with public single-parameter constructors | Yes | -| A case type shared by two unions | Yes — `RSG1006` warning; the helper is generated once | +| A **leaf** case type shared by two unions | Yes — `RSG1006` warning; the per-case `AsFailure` helper is generated once, and the union-receiver `Failure(...)` factory is generated for both | +| A **union-typed** case shared by two unions (composition) | Yes — no warning; the including union's factory covers the included union's cases, and no ambiguous per-case helper is generated | +| A case reachable through two different included unions | Yes — `RSG1008` warning; that case's inclusion factory is skipped | | A generic union | No — `RSG1002` | | A case type that itself contains type parameters | No — `RSG1002` (the remaining cases still generate) | | `IUnionMembers` member providers | No — `RSG1007` | diff --git a/docs/wiki/Value-Objects-Composition.md b/docs/wiki/Value-Objects-Composition.md new file mode 100644 index 0000000..826015a --- /dev/null +++ b/docs/wiki/Value-Objects-Composition.md @@ -0,0 +1,133 @@ +# Composing with Value Objects and ZodSharp + +`Purview.Results` composes with [Purview.ValueObjects](https://www.nuget.org/packages/Purview.ValueObjects) and +[Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) without depending on either. This page shows +the end-to-end shape: a value object's invariants are validated by ZodSharp, the outcome becomes an ordinary +result failure, and the failure is rendered by the ZodSharp.AspNetCore mapping. + +## Why the composition works + +- **ZodSharp owns the rules and the validation result.** `Validate` never throws; it returns a + `ValidationResult` carrying the value or every `ValidationError`. +- **ValueObjects owns the invariants.** A `[Scalar]` or `[ValueObject]` type is the only place its own + construction is guarded. +- **Purview.Results owns the outcome.** `Purview.Results.ZodSharp.ToResult` turns the validation result into a + `Result` whose error is one of the union's cases, so a rejected input flows through the same + pipeline as every other expected outcome. + +`Purview.Results` stays dependency-free: the integration packages depend on it, never the reverse, so a +consumer opts in by referencing the packages it needs. + +## Type-level rules and the error identity + +ZodSharp rules own their error identity. A rule that validates a value object *as a unit* can implement +`ZodSharp.Core.IZodRule` to report its own `Code` and `Origin` — which is what lets the same attribute produce a +different code per annotated scalar, and what makes an **origin** a rule the HTTP layer can key on: + +```csharp +using ZodSharp.Core; +using Purview.ValueObjects; // IScalarValueObject + +public readonly record struct NonEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + public const string ErrorCode = "invalid_value"; + public const string MessageFormat = "Value must not be empty."; + + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; + + string IValidationRule.Code => Code ?? ErrorCode; + + string? IZodRule.Code => Code; + string? IZodRule.Origin => "value_object"; +} + +[ZodRule(typeof(NonEmptyRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class NonEmptyAttribute : ValidationAttribute +{ + public string? Code { get; set; } + public string? Message { get; set; } +} +``` + +Applying the attribute to a scalar makes its generated `Create` reject the sentinel value, reporting the rule's own +code and origin (`Code = "invalid_asset_id"`, `Origin = "value_object"`, empty path), while `Hydrate` stays +replay-safe: + +```csharp +[Scalar] +[ZodSchema] +[NonEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId +{ + public Guid Value { get; } +} +``` + +## The rejection as a result failure + +The case that carries the errors implements `IValidationErrorCarrier`, so the HTTP layer can render it without +knowing the error type: + +```csharp +[GenerateResult] +readonly union TenantError(TenantInputInvalid, TenantAlreadyExists); + +readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArray Errors) + : IValidationErrorCarrier +{ + ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; +} +``` + +`ToResult` maps the validation outcome into that union, naming the error type explicitly because a union case does +not carry the union that contains it: + +```csharp +return TenantInputSchema + .Validate(input) + .ToResult(errors => new TenantInputInvalid(input, errors)) + .Bind(RegisterValidated); +``` + +## Rendering it, keyed by the rule's identity + +`Purview.Results.ZodSharp.AspNetCore` renders the carrier as a validation problem, and the rules registered on +`ZodResultsHttpOptions` can answer by code, category **or origin**: + +```csharp +builder.Services.AddZodSharpProblemDetails(); +builder.Services.AddResultsHttp(); +builder.Services.AddResultsZodSharpHttp(options => options + // One code a schema can report. + .MapCode("invalid_asset_id", StatusCodes.Status422UnprocessableEntity) + // Every type-level rule failure, whatever its code, because the rule owns Origin = "value_object". + .MapOrigin("value_object", StatusCodes.Status422UnprocessableEntity) +); +``` + +Because the origin is the axis a rule owns, `MapOrigin("value_object", …)` answers every type-level rule failure — +including future rules — while the `MapCode` rule still narrows the one code that deserves its own status. A code +is narrower than a category, which is narrower than an origin, so precedence is structural rather than +registration-ordered; see [ZodSharp Problem Details](ZodSharp-ProblemDetails.md). + +## Runnable example + +[`Examples.ValueObjects.Zod`](https://github.com/purview-dev/results/tree/main/src/src/Examples.ValueObjects.Zod) +runs the whole composition: a `[Scalar]` tenant identifier whose type-level `[ZodRule]` owns +`Code = "invalid_tenant_id"` and `Origin = "value_object"`, validated into a `Result` +failure and answerable by `MapOrigin("value_object", …)`. + +```bash +dotnet run --project src/src/Examples.ValueObjects.Zod +``` + +## Related + +- [ZodSharp Integration](ZodSharp-Integration.md) — producing results that carry validation errors. +- [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) — the per-code, per-category and per-origin rules. +- [Union Errors](Union-Errors.md) — the error union the rejected input becomes a case of. \ No newline at end of file diff --git a/docs/wiki/ZodSharp-Integration.md b/docs/wiki/ZodSharp-Integration.md index 2db2362..6b7917a 100644 --- a/docs/wiki/ZodSharp-Integration.md +++ b/docs/wiki/ZodSharp-Integration.md @@ -48,6 +48,21 @@ if (!validated.IsSuccess) return await ReconcileCoreAsync(validated.Value, repositories, cancellationToken); ``` +## Validating without producing a value + +When the method reports only why an input was rejected, `ToUnitResult` discards the validated value and returns a +unit `Result`. It still names the validated value type — a union case does not carry its union — but the +success holds the `Success` marker rather than the value: + +```csharp +Result Validate(TenantInput input) => + TenantInputSchema + .Validate(input) + .ToUnitResult(errors => new TenantInputInvalid(input, errors)); +``` + +Use `ToResult` when the success has to carry the validated value, and `ToUnitResult` when it does not. + ## Carrying validation errors in the error value `IValidationErrorCarrier` is implemented by an error value that carries ZodSharp validation errors, so an HTTP @@ -62,7 +77,9 @@ public readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArr ``` `IValidationErrorCarrier` exposes a single `ValidationErrors` member, preserving each `ValidationError`'s code, -category, path and parameters. +origin, category, path and parameters. The **origin** is the structured origin a rule owns (`"value_object"` for a +type-level rule, `"array"` for a collection rule), which is how an HTTP layer can answer a whole family of rules +without naming each code. [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) consumes the interface; without it, a host would have to map every validation-carrying error individually. @@ -72,6 +89,7 @@ map every validation-carrying error individually. | Member | Purpose | | --- | --- | | `ValidationResult.ToResult(Func, TError> onFailure)` | Success carries the validated value; failure carries the created error. `onFailure` runs only when validation failed, so a successful validation allocates no error | +| `ValidationResult.ToUnitResult(Func, TError> onFailure)` | The value-discarding counterpart: success is a unit `Result`, failure carries the created error | | `IValidationErrorCarrier` | Implemented by an error value that carries ZodSharp validation errors | A factory returning a **result** rather than an error is deliberately not offered: for a lambda returning @@ -81,7 +99,7 @@ Naming the error type explicitly keeps the intent unambiguous. ## Example ```bash -dotnet run --project src/examples/Examples.Zod +dotnet run --project src/src/Examples.Zod ``` `Examples.Zod` validates a `[ZodSchema] TenantInput` and turns the outcome into a `Result`, diff --git a/docs/wiki/ZodSharp-ProblemDetails.md b/docs/wiki/ZodSharp-ProblemDetails.md index defd72f..7804525 100644 --- a/docs/wiki/ZodSharp-ProblemDetails.md +++ b/docs/wiki/ZodSharp-ProblemDetails.md @@ -22,8 +22,8 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddZodSharpProblemDetails(); builder.Services.AddResultsHttp(); -// Every validation-carrying failure becomes one validation problem, except the codes and categories that a -// rule answers with something else. +// Every validation-carrying failure becomes one validation problem, except the codes, categories and origins +// that a rule answers with something else. builder.Services.AddResultsZodSharpHttp(options => options .MapCode("tenant_not_found", StatusCodes.Status404NotFound) .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) @@ -42,7 +42,7 @@ app.MapPost("/reconcile", (ReconciliationRequest request) => Reconcile(request)) Register it **last**, therefore, so everything the host declared earlier takes precedence. -## Answering by validation error code or category +## Answering by validation error code, category or origin | Member | Purpose | | --- | --- | @@ -50,12 +50,19 @@ Register it **last**, therefore, so everything the host declared earlier takes p | `MapCode(string code, Func, HttpContext, IResult?>)` | Renders a response of your own | | `MapCategory(string category, int statusCode)` | The same, for a category that spans many codes | | `MapCategory(string category, Func, HttpContext, IResult?>)` | Renders a response of your own | +| `MapOrigin(string origin, int statusCode)` | The same, for an origin a rule owns (`"value_object"`, `"array"`, …) | +| `MapOrigin(string origin, Func, HttpContext, IResult?>)` | Renders a response of your own | -A rule applies when **any** of the failure's errors carries its code or category, so a registered code rule is +The three axes are the identity a `ValidationError` carries: **code** is the specific rule that failed, +**category** is a broad grouping many codes share, and **origin** is the structured origin a rule owns. Use +`MapOrigin` when the answer is keyed by the *kind* of rule rather than one code — for example rendering every +type-level rule failure, which reports `"value_object"`, as `422` whatever its code. + +A rule applies when **any** of the failure's errors carries its code, category or origin, so a registered rule is always reachable whatever else the schema reported. Matching walks the **code** rules first, then the **category** -rules, each in registration order, and finally the default validation problem — a code is narrower than a -category, so it wins however the two were registered. A factory that wants stricter semantics returns `null` to -decline the failure, and matching continues: +rules, then the **origin** rules, each in registration order, and finally the default validation problem — a code is +narrower than a category, which is narrower than an origin, so a code wins however the rules were registered. A +factory that wants stricter semantics returns `null` to decline the failure, and matching continues: ```csharp options @@ -65,12 +72,14 @@ options ? TypedResults.NotFound() : null) // A category rule that answers the failures the code rule declined. - .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity); + .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) + // An origin rule: every type-level rule failure, whatever its code. + .MapOrigin("value_object", StatusCodes.Status422UnprocessableEntity); ``` A factory receives the failure's **full error set**, so a rule never hides the other problems the caller has to -fix, and the `int statusCode` overloads still render every error as an `HttpValidationProblemDetails`. A code or -category registered twice with different behaviour is rejected at configuration time. +fix, and the `int statusCode` overloads still render every error as an `HttpValidationProblemDetails`. A code, +category or origin registered twice with different behaviour is rejected at configuration time. A failure that does **not** carry validation errors is declined (`null`), so other mappers and fallbacks still apply — this mapper never answers a failure it does not understand. @@ -80,7 +89,7 @@ apply — this mapper never answers a failure it does not understand. | Member | Purpose | | --- | --- | | `AddResultsZodSharpHttp(Action? configure = null)` | Registers the ZodSharp options and adds the validation failure mapper to `ResultsHttpOptions` | -| `ZodResultsHttpOptions.MapCode` / `.MapCategory` | The per-code and per-category rules described above | +| `ZodResultsHttpOptions.MapCode` / `.MapCategory` / `.MapOrigin` | The per-code, per-category and per-origin rules described above | | `ZodResultsFailureMapper` | The mapper itself, for a host that wants to register or compose it by hand | | `IValidationErrorCarrier.ToValidationProblem(HttpContext, int statusCode = 400)` | Creates the validation problem for the errors the error value carries | | `ImmutableArray.ToValidationProblem(HttpContext, int statusCode = 400)` | Creates the validation problem for a set of errors | @@ -97,7 +106,7 @@ detail and the `issues` extension. ## Example ```bash -dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 +dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 ``` `Examples.AspNetCore.Zod` shows a `TenantInputInvalid` failure carrying ZodSharp errors becoming a `400` diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 44dc81b..1885bb2 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -8,6 +8,7 @@ - [ASP.NET Core Integration](AspNetCore-Integration.md) - [ZodSharp Integration](ZodSharp-Integration.md) - [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) +- [Value Objects Composition](Value-Objects-Composition.md) - [Guarantees and Limitations](Guarantees-and-Limitations.md) - [Testing](Testing.md) - [Agent Skills](Agent-Skills.md) diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 2972bba..7fa753b 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -1,7 +1,8 @@ # Purview Results -Result types for .NET — a dependency-light `Result`, C# 15 union ergonomics for its error cases, -and integrations that let expected failures flow through a value instead of an exception. +Result types for .NET — a dependency-light `Result` and its value-less `Result`, C# 15 +union ergonomics for their error cases, and integrations that let expected failures flow through a value instead +of an exception. [Get started](Getting-Started.md){ .md-button .md-button--primary } [Documentation overview](Home.md){ .md-button } diff --git a/global.json b/global.json index 7b17e04..1803204 100644 --- a/global.json +++ b/global.json @@ -5,9 +5,9 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.0" + "Purview.BuildSdk": "1.0.3" }, "test": { "runner": "Microsoft.Testing.Platform" } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 318df55..91b9548 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-results", - "version": "1.0.0-prerelease.2", + "version": "1.0.0", "license": "MIT", "private": true, "keywords": [], diff --git a/purview-build.json b/purview-build.json index 91d907b..1eea3a4 100644 --- a/purview-build.json +++ b/purview-build.json @@ -7,10 +7,10 @@ "TestFilter": "/*/*/*/*[Category=Unit]" }, "PackValidation": { - // Purview.Results.SourceGenerator is a Roslyn component: the SDK's Roslyn defaults set - // IsRoslynComponentOnly, so it produces no .snupkg. It also sets PurviewPackAnalyzerPdb=false, so it - // ships no PDB either; the four library packages produce symbol packages, and RequireSymbolFiles and - // the assembly checks below all apply to them. + // Every package is a library package that produces a symbol package, so RequireSymbolFiles and the + // assembly checks below all apply to them. Purview.Results additionally carries the merged source + // generator and its code fix under analyzers/dotnet/cs (no PDB, no lib folder for those), so the + // source generator is no longer published as its own package. "RequireSymbolPackage": false, // RequireExplicitContent defaults to true, so this is the exhaustive declaration of what each // package ships: a package with no rule here, or an entry matching none of its globs, fails. @@ -18,6 +18,9 @@ "purview.results": [ ".agents/**/*", "README.md", + "analyzers/dotnet/cs/Purview.Results.SourceGenerator.CodeFixes.dll", + "analyzers/dotnet/cs/Purview.Results.SourceGenerator.dll", + "buildTransitive/Purview.Results.props", "lib/$(TFM)/Purview.Results.dll", "lib/$(TFM)/Purview.Results.xml", "purview-logo-light.png" @@ -29,14 +32,6 @@ "lib/$(TFM)/Purview.Results.AspNetCore.xml", "purview-logo-light.png" ], - "purview.results.sourcegenerator": [ - ".agents/**/*", - "README.md", - "analyzers/dotnet/cs/Purview.Results.SourceGenerator.CodeFixes.dll", - "analyzers/dotnet/cs/Purview.Results.SourceGenerator.dll", - "buildTransitive/Purview.Results.SourceGenerator.props", - "purview-logo-light.png" - ], "purview.results.zodsharp": [ ".agents/**/*", "README.md", diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2c6086b..1288fe8 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,11 +3,45 @@ Purview.Results https://github.com/purview-dev/results - - net11.0 + + Supported preview - true - $(Features);runtime-async=on + + + $(Features);runtime-async=on + + true - true + + + + + + + + true + + + + net11.0 + + + net11.0 + + + net11.0 + + + net11.0 + + + + Exe + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/src/Examples.ValueObjects.Zod/Program.cs b/src/src/Examples.ValueObjects.Zod/Program.cs new file mode 100644 index 0000000..8fce660 --- /dev/null +++ b/src/src/Examples.ValueObjects.Zod/Program.cs @@ -0,0 +1,87 @@ +// Demonstrates composing Purview.ValueObjects with Purview.Results.ZodSharp: a scalar value object's type-level +// ZodSharp rule reports its own code and origin, and the outcome flows through the result pipeline as a value +// instead of an exception. +// +// dotnet run --project src/src/Examples.ValueObjects.Zod +// +// TenantId is a [Scalar] + [ZodSchema] value object carrying a type-level rule ([NonEmptyTenantId]) whose rule +// implements IZodRule, so it owns the reported code (invalid_tenant_id) and origin (value_object). That identity +// is what lets a host answer the failure by code or by origin without parsing a message. + +TenantRegistrationService service = new(); +var acmeId = Guid.NewGuid(); + +Heading("1. A valid identifier registers, carrying the value the method produces"); + +ShowResult("Register(acmeId, 'Acme')", service.Register(acmeId, "Acme")); + +Heading("2. An empty identifier is the value object's sentinel, reported by its type-level rule"); + +var rejected = service.Register(Guid.Empty, "Nameless"); + +ShowResult("Register(Guid.Empty, 'Nameless')", rejected); +PrintErrors(rejected); + +Heading("3. The rule owns its identity, so a host can answer it by code or by origin"); + +PrintIdentity(rejected); +Show("code rule", "MapCode(\"invalid_tenant_id\", 422)"); +Show("origin rule", "MapOrigin(\"value_object\", 422)"); + +Heading("4. A duplicate fails through exactly the same pipeline"); + +ShowResult("Register(acmeId, 'Acme Copy')", service.Register(acmeId, "Acme Copy")); + +static void Heading(string title) +{ + Console.WriteLine(); + Console.WriteLine(title); + Console.WriteLine(new string('-', title.Length)); +} + +static void Show(string label, object? value) => Console.WriteLine($" {label, -30} -> {value}"); + +static void ShowResult(string label, Result result) +{ + var text = result.Match(value => $"Success({value})", DescribeError); + + Console.WriteLine($" {label, -30} -> {text}"); +} + +static string DescribeError(TenantError error) => + error switch + { + TenantInputInvalid invalid => $"TenantInputInvalid({invalid.Errors.Length} error(s))", + TenantAlreadyExists exists => $"TenantAlreadyExists({exists.TenantId.Value})", + _ => nameof(TenantError), + }; + +static void PrintErrors(Result result) +{ + if (!result.TryGetError(out var error) || error is not TenantInputInvalid invalid) + return; + + foreach (var validationError in invalid.Errors) + { + var path = string.Join(".", validationError.Path); + + Console.WriteLine($" '{path}': {validationError.Message} [{validationError.Code}]"); + } +} + +static void PrintIdentity(Result result) +{ + if (!result.TryGetError(out var error) || error is not TenantInputInvalid invalid) + return; + + foreach (var validationError in invalid.Errors) + { + // A type-level rule validates the value object as a unit, so the path is empty and the origin is the + // one the rule owns. + var path = string.Join(".", validationError.Path); + + Console.WriteLine( + $" code={validationError.Code}, origin={validationError.Origin ?? ""}, path=[{path}]" + ); + } +} diff --git a/src/src/Examples.ValueObjects.Zod/Tenant.cs b/src/src/Examples.ValueObjects.Zod/Tenant.cs new file mode 100644 index 0000000..efa7fc3 --- /dev/null +++ b/src/src/Examples.ValueObjects.Zod/Tenant.cs @@ -0,0 +1,9 @@ +namespace Purview.Results.Examples.ValueObjects.Zod; + +/// +/// A tenant. +/// +/// The identifier of the tenant. +/// The display name of the tenant. +/// Whether the tenant may be used. +readonly record struct Tenant(TenantId Id, string Name, bool Enabled); diff --git a/src/src/Examples.ValueObjects.Zod/TenantError.cs b/src/src/Examples.ValueObjects.Zod/TenantError.cs new file mode 100644 index 0000000..cfd20ad --- /dev/null +++ b/src/src/Examples.ValueObjects.Zod/TenantError.cs @@ -0,0 +1,36 @@ +using System.Collections.Immutable; +using Purview.Results.ZodSharp; +using ZodSharp.Core; + +namespace Purview.Results.Examples.ValueObjects.Zod; + +/// +/// The expected failures of the tenant registration operation. +/// +/// +/// The failure that carries ZodSharp validation errors is a union case like any other, so a rejected value object +/// flows through the same result pipeline as a duplicate tenant. +/// +[GenerateResult] +readonly union TenantError(TenantInputInvalid, TenantAlreadyExists); + +/// +/// The supplied tenant id did not satisfy the value object's type-level rule. +/// +/// The rejected identifier. +/// The validation errors the tenant id's schema reported. +/// +/// Implementing lets an HTTP layer turn the failure into a validation +/// problem without knowing the error type — including the rule's code and origin. +/// +readonly record struct TenantInputInvalid(Guid TenantId, ImmutableArray Errors) + : IValidationErrorCarrier +{ + ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; +} + +/// +/// A tenant with the requested identifier already exists. +/// +/// The identifier of the tenant. +readonly record struct TenantAlreadyExists(TenantId TenantId); \ No newline at end of file diff --git a/src/src/Examples.ValueObjects.Zod/TenantId.cs b/src/src/Examples.ValueObjects.Zod/TenantId.cs new file mode 100644 index 0000000..f1adeb9 --- /dev/null +++ b/src/src/Examples.ValueObjects.Zod/TenantId.cs @@ -0,0 +1,85 @@ +using System.ComponentModel.DataAnnotations; +using Purview.ValueObjects; +using Purview.ValueObjects.Serialization; +using ZodSharp; +using ZodSharp.Core; + +namespace Purview.Results.Examples.ValueObjects.Zod; + +/// +/// A custom rule that validates a scalar value object as a unit, reading it through its +/// contract. +/// +/// The scalar value object the rule validates. +/// An optional code that overrides for one annotated scalar. +/// An optional message that overrides . +/// +/// Implementing lets the rule own the reported error code and origin, which is what makes +/// one attribute able to report a domain code (invalid_tenant_id) and mark the failure as coming from the +/// value object (value_object) at the same time. +/// +readonly record struct NonEmptyTenantIdRule(string? Code = null, string? Message = null) + : IValidationRule, + IZodRule + where TSelf : IScalarValueObject +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "invalid_value"; + + /// Gets the message format used when the rule reports its own message. + public const string MessageFormat = "Value must not be empty."; + + /// Validates that the scalar does not hold the sentinel . + /// The value to validate. + /// when the value is not the empty identifier. + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + /// Gets the error message for a failed validation. + /// The value that failed validation. + /// The configured message, or the rule's default. + public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; + + // IValidationRule.Code is the rule's own default; IZodRule.Code is the optional per-use override, so the + // generator prefers a code supplied where the attribute is applied and falls back to ErrorCode. + string IValidationRule.Code => Code ?? ErrorCode; + + /// + string? IZodRule.Code => Code; + + /// + string? IZodRule.Origin => "value_object"; +} + +/// +/// Maps to a type-level attribute. The ZodSharp generator closes the +/// open generic with the annotated scalar, so the rule validates the value object as a unit and reports an empty +/// path. +/// +[ZodRule(typeof(NonEmptyTenantIdRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +sealed class NonEmptyTenantIdAttribute : ValidationAttribute +{ + /// Gets or sets the error code reported for this application of the rule. + public string? Code { get; set; } + + /// Gets or sets the error message reported for this application of the rule. + public string? Message { get; set; } +} + +/// +/// The identifier of a tenant, as a scalar value object. +/// +/// +/// [Scalar] makes the value-object generator emit Create/Hydrate, and [ZodSchema] +/// makes ZodSharp's generator emit TenantIdSchema from the type-level rule below, so the value object's +/// own invariant is expressed as schema rules rather than hand-written guards. Hydrate never runs them, +/// which is what keeps rehydrating persisted values replay-safe. +/// +[Scalar] +[ZodSchema] +[NonEmptyTenantId(Code = "invalid_tenant_id", Message = "A tenant id must not be empty.")] +readonly partial record struct TenantId +{ + /// Gets the underlying identifier value. + public Guid Value { get; } +} diff --git a/src/src/Examples.ValueObjects.Zod/TenantRegistrationService.cs b/src/src/Examples.ValueObjects.Zod/TenantRegistrationService.cs new file mode 100644 index 0000000..44536bc --- /dev/null +++ b/src/src/Examples.ValueObjects.Zod/TenantRegistrationService.cs @@ -0,0 +1,36 @@ +namespace Purview.Results.Examples.ValueObjects.Zod; + +/// +/// Registers tenants, validating every identifier through its value object's schema. +/// +sealed class TenantRegistrationService +{ + readonly Dictionary _tenants = []; + + /// + /// Registers a tenant from a raw identifier. + /// + /// The raw identifier to validate and register. + /// The display name of the new tenant. + /// The registered tenant, or a describing why it was rejected. + public Result Register(Guid tenantId, string name) + { + // Hydrate builds the value object without running its rule; Validate reports the rule instead of + // throwing, so a rejected identifier is an expected outcome rather than an exception. + var candidate = TenantId.Hydrate(tenantId); + var validation = TenantIdSchema.Validate(candidate); + + if (!validation.IsSuccess) + return new TenantInputInvalid(tenantId, validation.Errors).AsFailure(); + + var id = validation.Value; + + if (_tenants.ContainsKey(id)) + return new TenantAlreadyExists(id).AsFailure(); + + Tenant tenant = new(id, name, Enabled: true); + _tenants[id] = tenant; + + return Result.Success(tenant); + } +} diff --git a/src/src/Examples.Zod/Examples.Zod.csproj b/src/src/Examples.Zod/Examples.Zod.csproj index f0788a7..316cd38 100644 --- a/src/src/Examples.Zod/Examples.Zod.csproj +++ b/src/src/Examples.Zod/Examples.Zod.csproj @@ -1,4 +1,13 @@ + + + + net11.0 + + + true + + true Purview Results Dependency-free Result<TValue, TError> for .NET, with C# 15 union ergonomics for its error cases and integrations that let expected failures flow through a value instead of an exception. results;result;railway;error-handling;union;unions;csharp-15;net11;purview; + + $(TargetsForTfmSpecificContentInPackage);PackResultsSourceGenerator + + + + + + + + + + + + + + + diff --git a/src/src/SourceGenerator/Sdk/.agents/agents/purview-results-union-author.agent.md b/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md similarity index 65% rename from src/src/SourceGenerator/Sdk/.agents/agents/purview-results-union-author.agent.md rename to src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md index 71cfad8..71f503b 100644 --- a/src/src/SourceGenerator/Sdk/.agents/agents/purview-results-union-author.agent.md +++ b/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md @@ -1,6 +1,6 @@ --- name: Purview Results Union Author -description: "Specialist for modelling expected failures as C# 15 unions and wiring them into Purview.Results — declaring [GenerateResult] unions, replacing exception or boolean returns with Result, and resolving the RSG1000-RSG1007 diagnostics." +description: "Specialist for modelling expected failures as C# 15 unions and wiring them into Purview.Results — declaring [GenerateResult] unions, replacing exception or boolean returns with Result, and resolving the RSG1000-RSG1008 diagnostics." tools: [ "search/codebase", @@ -19,8 +19,9 @@ You are a specialist for authoring Purview.Results error unions. ## Primary objective Replace ad-hoc failure signalling (thrown domain exceptions, `bool`/`out` pairs, sentinel return values, -error-code enums) with a `union` of focused case types and a generated `AsFailure()` helper, so -callers match on types instead of parsing strings. +error-code enums) with a `union` of focused case types and the generated union-receiver +`Union.Failure(...)`/`Union.Success(...)` factories (plus the per-case `AsFailure()`/`AsFailure()` +helpers), so callers match on types instead of parsing strings. ## Background knowledge @@ -34,9 +35,13 @@ The most important rules are: - **One case type per meaning.** A case with a `string Reason` or an `int Code` invites string/int matching and defeats the point. - **One union per operation family**, so methods can come and go without changing the error contract. +- **Compose unions, do not repeat leaf cases.** A case type that is itself a union is an *included union*: the + including union's factory also covers its cases and builds the nested value, and sharing a union-typed case across + unions is not reported. This is how to avoid `RSG1006`, which only applies to a shared **leaf** case. - **Never generate or hand-write implicit conversions.** Five compiler rules block them (`CS0715`, `CS0556`, - `CS9282`, `CS0246`, `CS0029`); the generated `AsFailure()` helper — or the `(Union)case` cast — is - the whole toolbox. + `CS9282`, `CS0246`, `CS0029`); the generated union-receiver `Union.Failure(...)` factory, the per-case + `AsFailure()` / `AsFailure()` helpers, and the `(Union)case` cast are the whole toolbox. Prefer the + union-receiver factory when a leaf case type is shared with another union (`RSG1006`), because it names the union. - **Keep the result transport-agnostic.** No HTTP types, status codes or `IResult` values in the union. - **`default` is a bug, not a failure.** Never let a method fall off the end of its body. @@ -45,8 +50,9 @@ The most important rules are: 1. Inventory the failure modes of the operation and name each one as a case type with the payload the caller needs. 2. Declare the union with `[GenerateResult]` in the same namespace as the cases. -3. Change the signature to `Result` and replace throwing exits with - `new Case(...).AsFailure()`. +3. Change the signature to `Result` — or the value-less `Result` when the member has + nothing to return on success — and replace throwing exits with `Union.Failure(case)` or + `Union.Failure(case)` (or the per-case `new Case(...).AsFailure()` / `AsFailure()` helpers). 4. Leave genuinely exceptional exits (`InvalidOperationException`, infrastructure faults) throwing. 5. Keep the call sites honest: unwrap with `Match`/`Map`/`Bind` rather than `if (IsSuccess)` when a value has to come out. @@ -57,8 +63,8 @@ The most important rules are: 1. Load `purview-results-union-errors` before editing. 2. Cases are `readonly record struct`s with meaningful payloads. -3. Every failure is created through a generated helper or the cast form; never by hand-rolled conversion. -4. The affected projects must build with no `RSG1000`–`RSG1007` diagnostics. +3. Every failure is created through a generated factory/helper or the cast form; never by hand-rolled conversion. +4. The affected projects must build with no `RSG1000`–`RSG1008` diagnostics. 5. Update tests alongside the signatures, and add a compiler experiment rather than an assumption when a language behaviour is in question. 6. Do not edit generated code; change the union or the generator input instead. diff --git a/src/src/SourceGenerator/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md b/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md similarity index 81% rename from src/src/SourceGenerator/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md rename to src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md index 2e21c12..bc9e8cd 100644 --- a/src/src/SourceGenerator/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md +++ b/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md @@ -1,6 +1,6 @@ --- agent: ask -description: "Migrate ad-hoc failure signalling (domain exceptions, bool/out pairs, error enums) in the selected files to a C# 15 union of case types with Purview.Results and generated AsFailure() helpers." +description: "Migrate ad-hoc failure signalling (domain exceptions, bool/out pairs, error enums) in the selected files to a C# 15 union of case types with Purview.Results and the generated union-receiver Union.Failure(...)/Union.Success(...) factories and per-case AsFailure()/AsFailure() helpers." --- You are modernising error handling in this repository. Apply the `purview-results-union-errors` skill for union @@ -29,13 +29,14 @@ case types and a `Result` return type built with the generated h public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); ``` -2. Change the affected signatures to `Result` and produce failures with - `new Case(...).AsFailure()`. +2. Change the affected signatures to `Result` — or the value-less `Result` when + the member has nothing to return on success — and produce failures with `TenantError.Failure(case)` / + `TenantError.Failure(case)` (or the per-case `new Case(...).AsFailure()` / `AsFailure()` helpers). 3. Leave genuinely exceptional exits throwing when `keepExceptions` is true — a lost connection or a violated invariant is not a domain outcome. 4. Do **not** attempt implicit conversions from a case to a result, and do not add operators, extension conversions or wrapper types: `CS0715`, `CS0556`, `CS9282`, `CS0246` and `CS0029` each block that route. Use - the generated helper, or `(TenantError)new TenantNotFound(id)` when a cast reads better. + the generated factory/helper, or `(TenantError)new TenantNotFound(id)` when a cast reads better. 5. Remove the replaced mechanism — thrown domain exceptions, error enums, `out` error parameters, sentinel values — rather than leaving both paths in place. 6. Keep the union free of transport concerns: no `IResult`, no status codes, no HTTP types. @@ -51,7 +52,7 @@ case types and a `Result` return type built with the generated h ### Verification -- Build the solution and confirm no `RSG1000`–`RSG1007` diagnostics. +- Build the solution and confirm no `RSG1000`–`RSG1008` diagnostics. - Run the affected tests. - Confirm no remaining `throw` for an outcome the caller is expected to handle. - Confirm the union appears in exactly one mapper. diff --git a/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md b/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md index abe2b4a..93719fd 100644 --- a/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md +++ b/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md @@ -1,11 +1,12 @@ --- name: purview-results-core -description: "Use when writing or reviewing code that returns Purview.Results Result values — choosing between exceptions and results, creating, reading, or transforming a result, and reasoning about the uninitialized default state." +description: "Use when writing or reviewing code that returns Purview.Results Result or value-less Result values — choosing between exceptions and results, creating, reading, or transforming a result, and reasoning about the uninitialized default state." --- # Purview.Results core -Use this skill whenever a method returns `Result` from the `Purview.Results` package. +Use this skill whenever a method returns `Result` or the value-less `Result` from the +`Purview.Results` package. ## The three states @@ -35,7 +36,14 @@ Result.Failure(new TenantNotFound(id)); // Implicit conversions (one hop only) Result ok = tenant; -Result failed = new TenantNotFound(id); +TenantError error = new TenantNotFound(id); // case -> union is one hop +Result failed = error; // union -> result is the next hop + +// Union-receiver factory (generated for [GenerateResult] unions; shared-case safe) +TenantError.Failure(new TenantNotFound(id)); +TenantError.Failure(new TenantNotFound(id)); +TenantError.Success(); +TenantError.Success(tenant); ``` The implicit conversions accept `TValue` or `TError` directly. They do **not** compose: a union *case* cannot @@ -49,6 +57,7 @@ convert to the result in one hop, which is why the source generator exists (see | `Match(success, failure)` | Fold both states into one value | | `Map(map)` | Transform the successful value, preserving the error | | `Bind(bind)` | Chain an operation that can fail, preserving the error type | +| `Bind(bind, mapError)` | Chain an operation that fails with a different error type, widening the existing error | | `MapError(map)` | Transform the error, preserving the value | | `TryGetValue(out value)` | Probe without throwing: `true` only for a success | | `TryGetError(out error)` | Probe without throwing: `true` only for a failure | @@ -59,6 +68,8 @@ convert to the result in one hop, which is why the source generator exists (see | `Tap(success)` / `TapError(failure)` | Side effects that return the result, so calls can be chained | | `Ensure(predicate, errorFactory)` | Turn a value that fails a predicate into a failure | | `MapAsync(map)` / `BindAsync(bind)` | Asynchronous `Map`/`Bind` | +| `BindAsync(bind, mapError)` | Asynchronous widening `Bind` | +| `Throw()` | Return the value, or throw `ResultException` carrying the error (escape hatch) | Prefer `Match`/`Map`/`Bind` over `if (result.IsSuccess)` when a value has to come out of the result: they keep the failure path explicit and cannot silently leak a `default`. @@ -66,12 +77,43 @@ the failure path explicit and cannot silently leak a `default`. `TryGetValue`/`TryGetError` are the only members that never throw; the rest throw for `default` so a forgotten initialization surfaces immediately. +## Value-less results + +When an operation has nothing to return on success — a command, a validation-only step — use `Result` +instead of inventing a meaningless value (a `bool`, a `Unit`). It has the same three states and the same +throw-on-misuse contract, but a success holds the `Success` marker and its success callbacks take no argument: + +```csharp +Result DeleteTenant(TenantId tenantId) => + _tenants.Remove(tenantId) + ? Result.Success() + : new TenantNotFound(tenantId).AsFailure(); + +deleted.Match(() => "deleted", error => $"could not delete: {error}"); +deleted.Map(() => 1); // attach a value: Result +deleted.Bind(() => GetTenant(tenantId)); // continue with another operation +deleted.Ensure(() => _tenants.Count > 0, () => new TenantError()); +``` + +- `Result.Success()` / `.Failure(error)`, `Result.Success()` / `Result.Failure(error)`, + and the implicit conversions from `Success` and `TError` create a unit result. +- `Match`, `Switch`, `Tap` and `Ensure` take a **no-argument** success delegate; `Map` produces a value from a + success that had none; `Bind` continues with either another unit operation or a value-producing one. +- `Error` throws in the wrong state; `Throw()` returns the `Success` marker or throws `ResultException`; + `ToString()` is `Success` / `Failure(error)` / `Uninitialized`. +- `Result.DiscardValue()` drops a value result's value and keeps the error, so a value result can + join a unit-result chain. + +Use `Result` when the failure is the only interesting outcome; use `Result` when the +caller needs the produced value. + ## Infrastructural view `IResultValue` is the non-generic, read-only view (`IsInitialized`, `IsSuccess`, `SuccessValue`, `ErrorValue`) for code that cannot be generic over the value and error types — an endpoint filter or a logging middleware, for -example. Its accessors never throw: the one that does not describe the current state returns `null`. Use it in -infrastructure, and the generic type in domain code. +example. Its accessors never throw: the one that does not describe the current state returns `null`, and a +successful unit `Result` reports the `Success` marker as its `SuccessValue`. Use it in infrastructure, and +the generic type in domain code. ## When to use a result @@ -86,8 +128,8 @@ Rules of thumb: 1. If the caller is expected to *do* something different per outcome, the outcome is a result value. 2. If the caller can only log and rethrow, it is an exception. -3. Never use `Result` where the failure is the only interesting outcome — the boolean usually - carries no information; model the outcomes as cases instead. +3. Never use `Result` where the failure is the only interesting outcome — use the value-less + `Result` instead, or model the outcomes as cases. 4. Convert at the boundaries: a controller or endpoint handler turns the result into a response, an application service returns it, and repositories return it rather than throwing on "not found". diff --git a/src/src/SourceGenerator/Sdk/.agents/skills/purview-results-union-errors/SKILL.md b/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md similarity index 52% rename from src/src/SourceGenerator/Sdk/.agents/skills/purview-results-union-errors/SKILL.md rename to src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md index b11d454..988dcfe 100644 --- a/src/src/SourceGenerator/Sdk/.agents/skills/purview-results-union-errors/SKILL.md +++ b/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md @@ -1,18 +1,17 @@ --- name: purview-results-union-errors -description: "Use when modelling expected failures as a C# 15 union and converting its cases into Purview.Results values — declaring [GenerateResult] unions, using the generated AsFailure() helpers, reacting to the RSG1000-RSG1007 diagnostics, or explaining why a union case cannot convert implicitly." +description: "Use when modelling expected failures as a C# 15 union and converting its cases into Purview.Results values — declaring [GenerateResult] unions, using the generated per-case AsFailure()/AsFailure() helpers and the union-receiver Union.Failure(...)/Union.Success(...) factories, reacting to the RSG1000-RSG1008 diagnostics, or explaining why a union case cannot convert implicitly." --- # Modelling error unions for Purview.Results Use this skill when a codebase models expected failures as a **union** and returns -`Result` from the `Purview.Results` package. +`Result` or the value-less `Result` from the `Purview.Results` package. ## The shape ```csharp using Purview.Results; -using Purview.Results.SourceGeneration; // One small, focused type per case. public readonly record struct TenantNotFound(TenantId TenantId); @@ -29,8 +28,30 @@ public static Result Get(TenantId id) => : new TenantNotFound(id).AsFailure(); // generated ``` -`AsFailure()` is generated **once per case type**, into `public static class {Union}ResultExtensions` in -the union's own namespace, and it calls `Result.Failure(case)`. +`AsFailure()` and the non-generic `AsFailure()` are generated **once per case type**, into +`public static class {Union}ResultExtensions` in the union's own namespace. The generic helper calls +`Result.Failure(case)`; the non-generic one calls `Result.Failure(case)` and produces a +value-less unit result: + +```csharp +Result Get(TenantId id) => new TenantNotFound(id).AsFailure(); +Result Delete(TenantId id) => new TenantNotFound(id).AsFailure(); +``` + +The same class declares a C# 14 extension block on the union type, so the union can name the call instead of the +case: + +```csharp +Result Get(TenantId id) => TenantError.Failure(new TenantNotFound(id)); +Result Delete(TenantId id) => TenantError.Failure(new TenantNotFound(id)); +Result ok = TenantError.Success(); +Result created = TenantError.Success(tenant); +``` + +Prefer the union-receiver factory when a case type is shared with another union (`RSG1006`): the per-case +`AsFailure` helper is generated once and can bind to the wrong union, while `Union.Failure(...)` always names the +union you want. A case type that is itself a union is an *included union*: sharing it is composition, so it is not +reported, and the factory also covers the included union's cases by building the nested value. ## Why the helper is required @@ -54,11 +75,36 @@ A helper-free form exists when a cast reads better, because the cast closes the Result Get(TenantId id) => (TenantError)new TenantNotFound(id); ``` -`Purview.Results.SourceGenerator` also ships an IDE code fix: when you write -`return new TenantNotFound(id);` in a `Result`-returning member, the lightbulb offers the -`AsFailure()` rewrite. The fix is only offered when the rewrite will bind (the converted type is a -result, the error type is a `[GenerateResult]` union, the value is one of its cases, and the union is in scope -by simple name). +`Purview.Results` also ships an IDE code fix: when you write +`return new TenantNotFound(id);` in a `Result`- or `Result`-returning member, the +lightbulb offers the `Union.Failure(case)` or `Union.Failure(case)` rewrite. The fix names the union, so it +is correct even when the case type is shared with another union. It is only offered when the rewrite will bind (the +converted type is a result, the error type is a `[GenerateResult]` union, the value is one of its cases, and the +union is in scope by simple name). + +## Chaining across services + +When one service calls another, the caller's operation-family union takes the callee's **error union** as a case, +then the widening `Bind` (or a `TryGetError` guard) lifts the callee's failure into the caller's contract: + +```csharp +[GenerateResult] +public readonly union RegisterTenantError(TenantError, BillingError); + +Result Register(TenantId id, string name) => + billing.ReserveQuota(id) + .Bind(quota => CreateTenant(quota, name), error => error); // BillingError -> RegisterTenantError +``` + +List the union as a case, not its leaf cases: the caller's union-receiver factory then covers the callee's cases and +builds the nested value (`CallerUnion.Failure(leaf)`), so no leaf is shared and no `RSG1006` is raised. A leaf listed +in two unions is a shared leaf: its per-case helper is generated for only one of them, so a case-receiver call site +can bind to the wrong union; convert it with the union-receiver factory instead. A case reachable through two +different included unions is reported as `RSG1008` and its inclusion factory is skipped. To keep the callee's types +out of the caller's contract, map its cases into the caller's own case types with `MapError` instead — the compiler +then forces the mapping to stay exhaustive as the callee's union grows. The ASP.NET Core mapper resolves a union +error to its innermost case, so a leaf mapping wins and an enclosing-union mapping still applies when the leaf has +none. ## Rules of thumb @@ -85,10 +131,12 @@ The package ships an analyzer and a generator that share one diagnostics library | `RSG1003` | Error | A type generated code must reference is not accessible (for example a `file` type) | | `RSG1004` | Error | The same union is configured more than once; the helpers are generated once | | `RSG1005` | Error | Two unions produce the same generated class name in one namespace | -| `RSG1006` | Warning | A case type is shared with another union, so its helper is generated once | +| `RSG1006` | Warning | A **leaf** case type is shared with another union, so its per-case `AsFailure` helper is generated once; the union-receiver `Failure(...)` factory is generated for every union. A union-typed case shared across unions is composition and is not reported | | `RSG1007` | Error | The union uses an `IUnionMembers` member provider, which is not supported | +| `RSG1008` | Warning | A case is reachable through two different included unions, so no inclusion factory can name its construction path; that case's inclusion factory is skipped | -`RSG1004` and `RSG1006` are non-blocking: the union (or the shared case) is skipped and reported. +`RSG1004`, `RSG1006` and `RSG1008` are non-blocking: the union (or the shared/ambiguous case) is skipped and +reported. Unsupported by design: `IUnionMembers` member providers (`RSG1007`) and generic unions (`RSG1002`). @@ -109,8 +157,9 @@ compared by value. ## Requirements and switches - **.NET 11 SDK or later** with `LangVersion=preview` — union declarations are a preview language feature. -- A reference to `Purview.Results` for the result type the helpers build. -- `-p:ResultsSourceGenerator_Disable=true` (or a `Directory.Build.props` setting) disables the helpers while +- A reference to `Purview.Results`, which provides both result shapes (`Result` and + `Result`) and this generator. +- `-p:DisableResultsSourceGenerator=true` (or a `Directory.Build.props` setting) disables the helpers while the opt-in attribute is still emitted, which is useful when the generated code has to be inspected in isolation. @@ -118,7 +167,9 @@ compared by value. 1. Cases are small records with meaningful payloads. 2. The union is a `union` declaration annotated with `[GenerateResult]`. -3. Every method that fails for an expected reason returns `Result`. -4. Failures are produced with `new Case(...).AsFailure()` (or the cast form). -5. The build is clean of `RSG1000`–`RSG1007`. +3. Every method that fails for an expected reason returns `Result`, or `Result` when the + success carries no value. +4. Failures are produced with `new Case(...).AsFailure()` (or `AsFailure()` for a unit result, the + union-receiver `Union.Failure(case)` / `Union.Failure(case)` factory, or the cast form). +5. The build is clean of `RSG1000`–`RSG1008`. 6. The union is mapped to responses once, in the host (see `purview-results-http-mapping`). diff --git a/src/src/Results/Sdk/README.md b/src/src/Results/Sdk/README.md index f511514..529aa14 100644 --- a/src/src/Results/Sdk/README.md +++ b/src/src/Results/Sdk/README.md @@ -5,7 +5,7 @@ A small, dependency-light `Result` for .NET that lets **expected** failures flow through a value instead of an exception. Exceptional circumstances still throw; states you expect — not found, invalid, -conflict — are values. +conflict — are values. A value-less `Result` covers operations that have nothing to return on success. ## Installation @@ -54,7 +54,9 @@ Result failed = "not a number"; | `Match(success, failure)` | Fold both states into one value | | `Map(map)` | Transform the successful value, preserving the error | | `Bind(bind)` | Chain another operation that can fail, preserving the error type | +| `Bind(bind, mapError)` | Chain another operation that fails with a different error type, widening the existing error | | `MapError(map)` | Transform the error, preserving the value | +| `Throw()` | Return the successful value, or throw `ResultException` carrying the error | | `ToString()` | `Success(value)`, `Failure(error)` or `Uninitialized` | `Result` is a `readonly record struct`. `default` is *uninitialized*: `IsInitialized`, @@ -66,22 +68,79 @@ Result failed = "not a number"; endpoint filter, for example. Its accessors never throw: the one that does not describe the current state returns `null`. +`Throw()` is the deliberate escape hatch: it returns the successful value and throws a `ResultException` +carrying the error on failure, so a boundary that must throw does not have to branch. The exception exposes the +error with its original type through `Error`; a non-generic `ResultException` base carries it as `object?` for a +catch-all. An uninitialized result is still a bug, so `Throw()` reports `default` with +`InvalidOperationException`. + +## Unit results + +An operation that has nothing to return on success — a command, a validation-only step — uses the value-less +`Result`. It has the same three states and the same throw-on-misuse contract, but its success holds the +`Success` marker rather than a value, so its success callbacks take no argument: + +```csharp +Result DeleteTenant(TenantId tenantId) => + _tenants.Remove(tenantId) + ? Result.Success() + : new TenantNotFound(tenantId).AsFailure(); + +var deleted = DeleteTenant(tenantId); + +deleted.Match(() => "deleted", error => $"could not delete: {error}"); // success takes no value +deleted.Map(() => 1); // attach a value to a success +deleted.Bind(() => GetTenant(tenantId)); // continue with another operation +deleted.Ensure(() => _tenants.Count > 0, () => new TenantError()); // turn a success into a failure +``` + +| Member | Purpose | +| --- | --- | +| `Result.Success()` / `.Failure(error)` | Create a unit result | +| `Result.Success()` / `Result.Failure(error)` | Create a unit result without naming the error twice | +| `IsInitialized` / `IsSuccess` / `IsFailure` | The three states, shared with `Result` | +| `Error` | The error; throws `InvalidOperationException` in the other state | +| `Match(success, failure)` / `Map(map)` / `Bind(bind)` / `MapError(map)` | Fold or transform; the success callbacks take no value | +| `Throw()` | Return the `Success` marker, or throw `ResultException` carrying the error | +| `ToString()` | `Success`, `Failure(error)` or `Uninitialized` | +| `Result.DiscardValue()` | Turn a value result into a unit result, preserving the error | +| `Result.MapAsync` / `.BindAsync` / widening `Bind` / `Ensure` / `Tap` / `Switch` / `OrElse` | The unit-result counterparts of the value-result extensions, in `ResultExtensions` | + +The implicit conversions from `Success` and from `TError` are public contract, so a method returning +`Result` can `return` either the marker or a plain error. + ## Union error types A C# 15 union is a natural `TError`: the error cases stay strongly typed. Because C# never composes the union -conversion with the result's own conversion, a case value cannot be returned directly — the -[`Purview.Results.SourceGenerator`](https://www.nuget.org/packages/Purview.Results.SourceGenerator) package -generates a per-case `AsFailure()` helper for `[GenerateResult]` unions: +conversion with the result's own conversion, a case value cannot be returned directly — the source generator +bundled in this package generates a per-case `AsFailure()` helper for `[GenerateResult]` unions, plus a +non-generic `AsFailure()` that produces a unit result: ```csharp Result GetTenant(TenantId tenantId) => new TenantNotFound(tenantId).AsFailure(); +Result DeleteTenant(TenantId tenantId) => new TenantNotFound(tenantId).AsFailure(); +``` + +The generator also declares a C# 14 extension block on the union type itself, so the union names the error: + +```csharp +Result GetTenant(TenantId tenantId) => TenantError.Failure(new TenantNotFound(tenantId)); +Result DeleteTenant(TenantId tenantId) => TenantError.Failure(new TenantNotFound(tenantId)); +Result ok = TenantError.Success(); +Result created = TenantError.Success(tenant); ``` +Use the union-receiver factory when a **leaf** case type is shared with another union: the per-case `AsFailure` +helper is generated once (for the union that sorts first) and reported as `RSG1006`, so it can bind to the wrong +union, while `Union.Failure(...)` always names the union you want. A case type that is itself a union is an +*included union*: sharing it is composition, so it is not reported, and the factory also covers the included +union's cases by building the nested value +(`RegisterTenantError.Failure(new BillingAccountMissing(id))`). + The generator cannot declare that conversion for you: C# forbids user-defined operators in a static class, forbids conversion operators in extension members, and permits only one user-defined conversion per conversion -sequence. The -[`Purview.Results.SourceGenerator`](https://www.nuget.org/packages/Purview.Results.SourceGenerator) package -ships a code fix that offers the rewrite in the IDE when a case value is returned where a result is expected. +sequence. The same package ships a code fix that offers the rewrite in the IDE when a case value is returned +where a result is expected. If you prefer to avoid the helper entirely, the cast form needs no generated code, because the cast closes the case → union conversion so only the library's union → result conversion remains: @@ -90,33 +149,41 @@ case → union conversion so only the library's union → result conversion rema Result GetTenant(TenantId tenantId) => (TenantError)new TenantNotFound(tenantId); ``` +When one service calls another, widen the callee's error union into the caller's operation-family union with the +widening `Bind`, or lift it at a guard with the generated helper. List the callee's union as a case rather than its +leaf cases: the caller's factory then covers the callee's cases by constructing the nested value, and a leaf is +never shared. If a **leaf** case type must be shared, convert it with the union-receiver `Union.Failure(...)` +factory, which is generated for every union. A case reachable through two different included unions is reported as +`RSG1008` and skipped. + ## Examples -[`src/examples/Examples.Basic`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.Basic) is a +[`src/src/Examples.Basic`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Basic) is a runnable console example over the Tenant* domain this README documents: the three result states, the combinators, -probing, the throw-on-misuse contract and the generated `AsFailure()` helper. +probing, the throw-on-misuse contract, the generated `AsFailure()` helper, chaining two services by +widening one error union into another, and `Throw()`. ```bash -dotnet run --project src/examples/Examples.Basic +dotnet run --project src/src/Examples.Basic ``` -The [repository README](https://github.com/purview-dev/results#examples) lists the ZodSharp, ASP.NET Core and -ASP.NET Core + Zod examples too. +The [repository README](https://github.com/purview-dev/results#examples) lists the ZodSharp, ASP.NET Core, +ASP.NET Core + Zod and value-objects composition examples too. ## Related packages | Package | Purpose | | --- | --- | -| [`Purview.Results.SourceGenerator`](https://www.nuget.org/packages/Purview.Results.SourceGenerator) | Generates `AsFailure()` helpers for `[GenerateResult]` unions | | [`Purview.Results.AspNetCore`](https://www.nuget.org/packages/Purview.Results.AspNetCore) | Maps results onto ASP.NET Core responses, including `ProblemDetails` | | [`Purview.Results.ZodSharp`](https://www.nuget.org/packages/Purview.Results.ZodSharp) | Bridges ZodSharp `ValidationResult` into results | | [`Purview.Results.ZodSharp.AspNetCore`](https://www.nuget.org/packages/Purview.Results.ZodSharp.AspNetCore) | Maps validation-carrying failures onto `HttpValidationProblemDetails` | ## Agent skills -This package ships the `purview-results-core` agent skill under `.agents/`. Repositories that import -`Purview.BuildSdk` get it mirrored into their own `.agents/` folder on the next restore or build, so AI agents -working there receive the guidance automatically. +This package ships the `purview-results-core` and `purview-results-union-errors` agent skills, the +`purview-results-union-author` agent and the `migrate-error-returns-to-result-unions` prompt under `.agents/`. +Repositories that import `Purview.BuildSdk` get them mirrored into their own `.agents/` folder on the next +restore or build, so AI agents working there receive the guidance automatically. ## License diff --git a/src/src/SourceGenerator/Sdk/buildTransitive/Purview.Results.SourceGenerator.props b/src/src/Results/Sdk/buildTransitive/Purview.Results.props similarity index 53% rename from src/src/SourceGenerator/Sdk/buildTransitive/Purview.Results.SourceGenerator.props rename to src/src/Results/Sdk/buildTransitive/Purview.Results.props index eef90f2..2d96c29 100644 --- a/src/src/SourceGenerator/Sdk/buildTransitive/Purview.Results.SourceGenerator.props +++ b/src/src/Results/Sdk/buildTransitive/Purview.Results.props @@ -1,17 +1,17 @@ - + True to disable the generated result helpers while still emitting the opt-in [GenerateResult] attribute. diff --git a/src/src/Results/Success.cs b/src/src/Results/Success.cs new file mode 100644 index 0000000..7df4d93 --- /dev/null +++ b/src/src/Results/Success.cs @@ -0,0 +1,26 @@ +namespace Purview.Results; + +/// +/// Represents the successful outcome of an operation that produces no value. +/// +/// +/// +/// is a result whose success carries no payload; it succeeds with this marker +/// instead of a value, so an operation that only has a meaningful failure — a command that either completes or +/// reports why it could not — can still be expressed as a result. +/// +/// +/// The type has no state: every value equals and default, +/// and its is the stable text "Success". +/// +/// +public readonly record struct Success +{ + /// + /// Gets the single value. + /// + public static Success Instance => default; + + /// + public override string ToString() => "Success"; +} diff --git a/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj b/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj index 68e05ef..912473e 100644 --- a/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj +++ b/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj @@ -1,17 +1,16 @@ true netstandard2.0 - Purview.Results.SourceGeneration.CodeFixes Purview Results Source Generator Code Fixes IDE code fixes for the Purview.Results source generator: rewrites a returned union case value into the generated AsFailure<TValue>() failed result. diff --git a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs index 71c4e01..12ecf30 100644 --- a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs +++ b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs @@ -12,11 +12,12 @@ // ITypeSymbol.IsUnion is [Experimental] until C# 15 union support ships, and this provider targets it. #pragma warning disable RSEXPERIMENTAL006 -namespace Purview.Results.SourceGeneration.CodeFixes; +namespace Purview.Results.SourceGenerator.CodeFixes; /// -/// Offers the generated AsFailure<TValue>() helper when a union case value is returned where a -/// Result<TValue, TError> is expected (CS0029). +/// Offers the generated union-receiver factories Union.Failure<TValue>(case) and +/// Union.Failure(case) when a union case value is returned where a +/// Result<TValue, TError> or a unit Result<TError> is expected (CS0029). /// /// /// @@ -27,22 +28,30 @@ namespace Purview.Results.SourceGeneration.CodeFixes; /// recorded in UnionCompilerBehaviourTests in this repository. /// /// +/// The fix names the union by its receiver (Union.Failure(case)) rather than the case, because the +/// union-receiver factory is generated for every case of every union. The per-case AsFailure helpers +/// are generated once when a case type is shared with another union, so a case-receiver rewrite could bind to +/// the wrong union; the factory cannot. +/// +/// /// A fix is only offered when the rewritten call will actually bind: the converted type must be -/// Purview.Results.Result<TValue, TError>, TError must be a union opted in with -/// [GenerateResult] (so the helper exists), the expression must be one of that union's case values, and -/// the union must be reachable by its simple name at the call site (the helper is generated into the union's -/// own namespace). +/// Purview.Results.Result<TValue, TError> or Purview.Results.Result<TError>, +/// TError must be a union opted in with [GenerateResult] (so the factory exists), the expression +/// must be one of that union's case values — including a case reached through an included union, because the +/// factory covers those too — and the union must be reachable by its simple name at the call site (the factory +/// is generated into the union's own namespace). A value result gets +/// Union.Failure<TValue>(case); a unit result gets Union.Failure(case). /// /// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UnionCaseResultCodeFixProvider)), Shared] public sealed class UnionCaseResultCodeFixProvider : CodeFixProvider { - const string EquivalenceKey = "PurviewResultsUseAsFailure"; + const string EquivalenceKey = "PurviewResultsUseUnionFailureFactory"; const string ResultNamespace = "Purview.Results"; const string ResultMetadataName = "Result`2"; - const string SourceGenerationNamespace = "Purview.Results.SourceGeneration"; + const string ResultUnitMetadataName = "Result`1"; const string GenerateResultAttributeMetadataName = "GenerateResultAttribute"; - const string FailureHelperName = "AsFailure"; + const string FailureFactoryName = "Failure"; /// public override ImmutableArray FixableDiagnosticIds => ["CS0029"]; @@ -66,8 +75,8 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) if (expression is null) continue; - var (valueType, unionType) = ResolveResultTarget(model, expression, cancellationToken); - if (valueType is null || unionType is null) + var (valueType, unionType, isUnit) = ResolveResultTarget(model, expression, cancellationToken); + if (unionType is null || (!isUnit && valueType is null)) continue; // The expression must be a case of the union, otherwise AsFailure() would not exist for @@ -80,10 +89,15 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) if (!IsUnionVisibleBySimpleName(model, unionType, expression.SpanStart)) continue; + var title = isUnit + ? $"Convert the union case to a failed unit result with {unionType.Name}.{FailureFactoryName}()" + : $"Convert the union case to a failed result with {unionType.Name}.{FailureFactoryName}<{valueType!.Name}>()"; + context.RegisterCodeFix( CodeAction.Create( - title: $"Convert the union case to a failed result with {FailureHelperName}<{valueType.Name}>()", - createChangedDocument: token => UseAsFailureAsync(context.Document, expression, valueType, token), + title: title, + createChangedDocument: token => + UseUnionFactoryAsync(context.Document, expression, unionType, isUnit ? null : valueType, token), equivalenceKey: EquivalenceKey ), diagnostic @@ -98,26 +112,30 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) return node as ExpressionSyntax ?? node.FirstAncestorOrSelf(); } - static (ITypeSymbol? ValueType, INamedTypeSymbol? UnionType) ResolveResultTarget( + static (ITypeSymbol? ValueType, INamedTypeSymbol? UnionType, bool IsUnit) ResolveResultTarget( SemanticModel model, ExpressionSyntax expression, CancellationToken cancellationToken ) { if (model.GetTypeInfo(expression, cancellationToken).ConvertedType is not INamedTypeSymbol converted) - return (null, null); + return (null, null, false); - if (converted.Arity != 2 || !HasMetadataName(converted, ResultNamespace, ResultMetadataName)) - return (null, null); + var isValueResult = converted.Arity == 2 && HasMetadataName(converted, ResultNamespace, ResultMetadataName); + var isUnitResult = converted.Arity == 1 && HasMetadataName(converted, ResultNamespace, ResultUnitMetadataName); + if (!isValueResult && !isUnitResult) + return (null, null, false); + + // The union is the last type argument in both shapes: Result and Result. if ( - converted.TypeArguments[1] is not INamedTypeSymbol union + converted.TypeArguments[converted.Arity - 1] is not INamedTypeSymbol union || !union.IsUnion || !HasGenerateResultAttribute(union) ) - return (null, null); + return (null, null, false); - return (converted.TypeArguments[0], union); + return isUnitResult ? (null, union, true) : (converted.TypeArguments[0], union, false); } static bool HasGenerateResultAttribute(INamedTypeSymbol union) @@ -126,7 +144,7 @@ static bool HasGenerateResultAttribute(INamedTypeSymbol union) { if ( attribute.AttributeClass is { } attributeClass - && HasMetadataName(attributeClass, SourceGenerationNamespace, GenerateResultAttributeMetadataName) + && HasMetadataName(attributeClass, ResultNamespace, GenerateResultAttributeMetadataName) ) { return true; @@ -141,17 +159,35 @@ static bool IsCaseOf(INamedTypeSymbol union, ITypeSymbol? caseType) if (caseType is null) return false; + HashSet visited = [with(SymbolEqualityComparer.Default)]; + + return IsCaseOf(union, caseType, visited); + } + + static bool IsCaseOf(INamedTypeSymbol union, ITypeSymbol caseType, HashSet visited) + { + // A case type that is itself a union is an included union: its own cases are reachable through the + // union-receiver factory of the outer union too, so the rewrite binds for them as well. The visited + // set keeps a recursive union graph from looping. + if (!visited.Add(union)) + return false; + foreach (var constructor in union.InstanceConstructors) { if ( - constructor.DeclaredAccessibility == Accessibility.Public - && constructor.Parameters.Length == 1 - && constructor.Parameters[0].RefKind is RefKind.None or RefKind.In - && SymbolEqualityComparer.Default.Equals(constructor.Parameters[0].Type, caseType) + constructor.DeclaredAccessibility != Accessibility.Public + || constructor.Parameters.Length != 1 + || constructor.Parameters[0].RefKind is not (RefKind.None or RefKind.In) ) - { + continue; + + var parameterType = constructor.Parameters[0].Type; + + if (SymbolEqualityComparer.Default.Equals(parameterType, caseType)) + return true; + + if (parameterType is INamedTypeSymbol { IsUnion: true } included && IsCaseOf(included, caseType, visited)) return true; - } } return false; @@ -174,10 +210,11 @@ static bool IsUnionVisibleBySimpleName(SemanticModel model, INamedTypeSymbol uni static bool HasMetadataName(INamedTypeSymbol type, string @namespace, string metadataName) => type.MetadataName == metadataName && type.ContainingNamespace.ToDisplayString() == @namespace; - static async Task UseAsFailureAsync( + static async Task UseUnionFactoryAsync( Document document, ExpressionSyntax expression, - ITypeSymbol valueType, + INamedTypeSymbol unionType, + ITypeSymbol? valueType, CancellationToken cancellationToken ) { @@ -185,19 +222,34 @@ CancellationToken cancellationToken if (root is null) return document; - var typeArgument = SyntaxFactory - .ParseTypeName(valueType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) + // The union-receiver factory is named on the union type: Union.Failure(case) for a unit result and + // Union.Failure(case) for a value result. + var unionName = SyntaxFactory + .ParseTypeName(unionType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) .WithAdditionalAnnotations(Simplifier.Annotation); + SimpleNameSyntax factoryName; + if (valueType is null) + { + factoryName = SyntaxFactory.IdentifierName(FailureFactoryName); + } + else + { + var typeArgument = SyntaxFactory + .ParseTypeName(valueType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) + .WithAdditionalAnnotations(Simplifier.Annotation); + + factoryName = SyntaxFactory.GenericName( + SyntaxFactory.Identifier(FailureFactoryName), + SyntaxFactory.TypeArgumentList(SyntaxFactory.SingletonSeparatedList(typeArgument)) + ); + } + var invocation = SyntaxFactory .InvocationExpression( - SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - expression.WithoutTrivia(), - SyntaxFactory.GenericName( - SyntaxFactory.Identifier(FailureHelperName), - SyntaxFactory.TypeArgumentList(SyntaxFactory.SingletonSeparatedList(typeArgument)) - ) + SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, unionName, factoryName), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(expression.WithoutTrivia())) ) ) .WithTriviaFrom(expression) diff --git a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..333672c --- /dev/null +++ b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md @@ -0,0 +1,17 @@ +## Release 1.0.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +RSG1000 | Purview.Results.Usage | Error | GenerateResult target is not a union +RSG1001 | Purview.Results.Usage | Error | Union has no union cases +RSG1002 | Purview.Results.Usage | Error | Unsupported generic union configuration +RSG1003 | Purview.Results.Usage | Error | Union case type is not accessible +RSG1004 | Purview.Results.Usage | Error | Duplicate GenerateResult configuration +RSG1005 | Purview.Results.Usage | Error | Generated helper class name collision +RSG1006 | Purview.Results.Usage | Warning | Union case type is shared with another union +RSG1007 | Purview.Results.Usage | Error | Unsupported union member provider +RSG1008 | Purview.Results.Usage | Warning | Union inclusion case is ambiguous +RSG1009 | Purview.Results.Usage | Warning | Union case type is not a named type +RSG9000 | Purview.Results.Usage | Error | Unhandled exception in the results source generator diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index 2989dda..afde190 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -1,15 +1,8 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md - -### New Rules - -Rule ID | Category | Severity | Notes ---------|----------|----------|------- -RSG1000 | Purview.Results.Usage | Error | GenerateResult target is not a union -RSG1001 | Purview.Results.Usage | Error | Union has no union cases -RSG1002 | Purview.Results.Usage | Error | Unsupported generic union configuration -RSG1003 | Purview.Results.Usage | Error | Union case type is not accessible -RSG1004 | Purview.Results.Usage | Error | Duplicate GenerateResult configuration -RSG1005 | Purview.Results.Usage | Error | Generated helper class name collision -RSG1006 | Purview.Results.Usage | Warning | Union case type is shared with another union -RSG1007 | Purview.Results.Usage | Error | Unsupported union member provider +; +; 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. +; +; Note RSG2000 is the suppression id a consumer adds to NoWarn to turn the CA1815 suppression off. +; Suppression descriptors are not release-tracked, so it must not be listed in either file. diff --git a/src/src/SourceGenerator/Analyzers/ResultsDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/ResultsDiagnosticAnalyzer.cs index e735320..90a4ff3 100644 --- a/src/src/SourceGenerator/Analyzers/ResultsDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/ResultsDiagnosticAnalyzer.cs @@ -1,9 +1,9 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; -using Purview.Results.SourceGeneration.Diagnostics; +using Purview.Results.SourceGenerator.Diagnostics; -namespace Purview.Results.SourceGeneration.Analyzers; +namespace Purview.Results.SourceGenerator.Analyzers; /// /// Raises the per-target result diagnostics so consumers see them in the IDE and in build output. diff --git a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs index 14050e5..d63ba83 100644 --- a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; -namespace Purview.Results.SourceGeneration.Diagnostics; +namespace Purview.Results.SourceGenerator.Diagnostics; /// /// Identifies which host owns a rule and how it affects generation. @@ -129,11 +129,11 @@ static class DiagnosticLibrary public static readonly DiagnosticDescriptor SharedCaseType = new( id: "RSG1006", title: "Union case type is shared with another union", - messageFormat: "The case type '{0}' is also a case of '{1}', so its result helper is generated for '{1}' only to keep the call site unambiguous", + messageFormat: "The case type '{0}' is also a case of '{1}', so its AsFailure helper is generated for '{1}' only; use the union-receiver factory '{2}.Failure(...)' to convert the case for this union without ambiguity", category: Category, defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, - description: "A case type may belong to more than one union. Generating the same helper for both would make extension method resolution ambiguous, so the helper is generated once." + description: "A case type may belong to more than one union. Generating the same per-case AsFailure helper for both would make extension method resolution ambiguous, so the helper is generated once. The union-receiver Failure(...) factory is generated for every union, including the shared case, and is the shared-case safe form." ); /// @@ -149,6 +149,59 @@ static class DiagnosticLibrary description: "The first implementation resolves union cases from the union type's own public single-parameter constructors." ); + /// + /// A case is reachable through more than one of a union's included unions, so no inclusion factory can + /// be generated for it unambiguously. + /// + public static readonly DiagnosticDescriptor AmbiguousIncludedCase = new( + id: "RSG1008", + title: "Union inclusion case is ambiguous", + messageFormat: "The case type '{0}' is reachable through more than one included union of '{1}', so no inclusion factory is generated for it; convert it through the union that directly declares it instead", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A union may include another union as a case, which exposes the included union's cases through the union-receiver factory. When the same case type is reachable through two included unions, no single factory can name the construction path, so the case is skipped and reported. Only that case's inclusion factory is skipped; the union is still generated." + ); + + /// + /// A case type the generated helpers cannot be written against, such as an array type. + /// + /// + /// Accessibility and type-parameter checking both accept an array type — an array of a referenceable + /// element type is itself referenceable — but the emitted helpers and the union-receiver factory are + /// declared on a named type. Before this rule existed the generator cast the case to + /// INamedTypeSymbol regardless and threw , which took the whole + /// compilation's generated output with it. + /// + public static readonly DiagnosticDescriptor UnsupportedCaseType = new( + id: "RSG1009", + title: "Unsupported union case type", + messageFormat: "The case type '{0}' is not a named type, so no failure helper or factory can be generated for it; wrap it in a record or struct case instead", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A union case must be a named type for the generated helpers to be declared against it. An array type, for example, is skipped and reported; the union's other cases are still generated." + ); + + /// + /// The generator failed unexpectedly while processing a union. + /// + /// + /// Reported instead of letting an exception escape the generator. An escaping exception surfaces as + /// CS8785 and discards all generated output for the compilation — so one malformed union + /// removes every other union's helpers and produces a cascade of unrelated errors — and raises + /// AD0001 in the IDE, where analysis then stops. + /// + public static readonly DiagnosticDescriptor UnhandledException = new( + id: "RSG9000", + title: "Unhandled exception in the results source generator", + messageFormat: "The results source generator failed while processing '{0}': {1}", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "An unexpected failure is reported against the union being processed rather than allowed to escape, so the rest of the compilation's generated output survives and the cause is identifiable. Please report it." + ); + /// /// Gets every rule in the catalogue, which is exactly the analyzer's supported diagnostics. /// @@ -162,6 +215,9 @@ static class DiagnosticLibrary GeneratedClassNameCollision, SharedCaseType, UnsupportedMemberProvider, + AmbiguousIncludedCase, + UnsupportedCaseType, + UnhandledException, ]; /// @@ -188,6 +244,7 @@ static class DiagnosticLibrary /// RSG1005 (generated class-name collision): blocking for the colliding union, which is skipped. /// RSG1006 (shared case type): non-blocking; only the shared case's helper is skipped. /// RSG1007 (member provider): nothing can be generated. + /// RSG1008 (ambiguous included case): non-blocking; only the ambiguous case's inclusion factory is skipped. /// /// /// @@ -207,6 +264,7 @@ public static bool IsBlocking(DiagnosticDescriptor descriptor, DiagnosticScope s "RSG1005" => true, "RSG1006" => false, "RSG1007" => true, + "RSG1008" => false, _ => descriptor.DefaultSeverity == DiagnosticSeverity.Error, }; } diff --git a/src/src/SourceGenerator/Diagnostics/ResultDiagnostic.cs b/src/src/SourceGenerator/Diagnostics/ResultDiagnostic.cs index 9883f20..9528850 100644 --- a/src/src/SourceGenerator/Diagnostics/ResultDiagnostic.cs +++ b/src/src/SourceGenerator/Diagnostics/ResultDiagnostic.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; -namespace Purview.Results.SourceGeneration.Diagnostics; +namespace Purview.Results.SourceGenerator.Diagnostics; /// /// A single diagnostic finding produced by the shared diagnostics library. diff --git a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs index 09ea7e8..df3c122 100644 --- a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs +++ b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs @@ -1,12 +1,24 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration.Helpers; +using Purview.Results.SourceGenerator.Helpers; // The language's union support is a preview feature of C# 15; ITypeSymbol.IsUnion is marked // [Experimental] until the feature ships, and this analysis deliberately targets it. #pragma warning disable RSEXPERIMENTAL006 -namespace Purview.Results.SourceGeneration.Diagnostics; +namespace Purview.Results.SourceGenerator.Diagnostics; + +/// +/// Describes one case a union exposes through an included (union-typed) case rather than declaring it +/// directly, together with the chain of unions that must be constructed to reach it. +/// +/// The transitively reachable case type. +/// +/// The chain of unions that must be constructed to reach the case, innermost first. The declaring union is +/// not part of the path; the generated factory wraps the case in each path union and finally in the +/// declaring union. +/// +readonly record struct ResultUnionIncludedCase(INamedTypeSymbol CaseType, ImmutableArray UnionPath); /// /// The outcome of analyzing one opted-in target, shared by the analyzer (which reports @@ -19,6 +31,9 @@ namespace Purview.Results.SourceGeneration.Diagnostics; /// Whether the union is public. /// Whether every usable case type is public. /// The usable case types, in constructor order. +/// +/// The case types reachable through the union's included (union-typed) cases, in discovery order. +/// /// Every finding, in discovery order. readonly record struct ResultUnionTargetAnalysis( bool HasGenerateResultAttribute, @@ -27,6 +42,7 @@ readonly record struct ResultUnionTargetAnalysis( bool UnionIsPublic, bool EveryCaseIsPublic, ImmutableArray CaseTypes, + ImmutableArray IncludedCases, ImmutableArray Diagnostics ) { @@ -134,6 +150,7 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell UnionIsPublic: false, EveryCaseIsPublic: false, CaseTypes: [], + IncludedCases: [], Diagnostics: [] ); } @@ -151,6 +168,7 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell UnionIsPublic: false, EveryCaseIsPublic: false, CaseTypes: [], + IncludedCases: [], Diagnostics: [ ResultDiagnostic.Create( @@ -223,12 +241,21 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell UnionIsPublic: unionIsPublic, EveryCaseIsPublic: false, CaseTypes: [], + IncludedCases: [], Diagnostics: diagnostics.ToImmutable() ); } var everyCaseIsPublic = true; var caseTypes = AnalyzeCases(symbol, diagnostics, ref everyCaseIsPublic, cancellationToken); + var includedCases = AnalyzeIncludedCases( + symbol, + caseTypes, + attributeLocation, + diagnostics, + ref everyCaseIsPublic, + cancellationToken + ); if (caseTypes.Length == 0) { @@ -249,6 +276,7 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell UnionIsPublic: unionIsPublic, EveryCaseIsPublic: everyCaseIsPublic, CaseTypes: caseTypes, + IncludedCases: includedCases, Diagnostics: diagnostics.ToImmutable() ); } @@ -305,13 +333,213 @@ CancellationToken cancellationToken continue; } + // The accessibility and type-parameter checks above both accept an array type, so the case can + // reach here without being a named type. The generated helpers and the union-receiver factory + // are declared against a named type, so report and skip rather than casting — the cast threw + // InvalidCastException out of the generator, which discarded every other union's output too. + if (caseType is not INamedTypeSymbol namedCaseType) + { + diagnostics.Add( + ResultDiagnostic.Create( + DiagnosticLibrary.UnsupportedCaseType, + DiagnosticScope.Case, + caseLocation, + displayName + ) + ); + continue; + } + + everyCaseIsPublic &= caseIsPublic; + caseTypes.Add(namedCaseType); + } + + return caseTypes.ToImmutable(); + } + + /// + /// Expands the union's included (union-typed) cases transitively, so the union-receiver factory can + /// convert a case of an included union by naming this union. + /// + /// + /// + /// A union-typed case is an inclusion: the value is wrapped in the included union, so the + /// runtime value is nested (Outer(Inner(leaf))). The recorded path is that nesting, innermost + /// first, and the generated factory wraps the case in each union in order. + /// + /// + /// A case reachable through two different included unions cannot be constructed unambiguously; it is + /// reported as RSG1008 and its inclusion factory is skipped. A case whose construction path + /// passes through an ambiguous union is skipped for the same reason. + /// + /// + static ImmutableArray AnalyzeIncludedCases( + INamedTypeSymbol union, + ImmutableArray caseTypes, + Location reportLocation, + ImmutableArray.Builder diagnostics, + ref bool everyCaseIsPublic, + CancellationToken cancellationToken + ) + { + HashSet directCases = new(SymbolEqualityComparer.Default); + foreach (var caseType in caseTypes) + directCases.Add(caseType); + + Dictionary> paths = new(SymbolEqualityComparer.Default); + HashSet ambiguous = [with(SymbolEqualityComparer.Default)]; + HashSet stack = [with(SymbolEqualityComparer.Default)]; + + foreach (var caseType in caseTypes) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (caseType.IsUnion) + Walk(caseType, []); + } + + if (paths.Count == 0) + return []; + + var results = ImmutableArray.CreateBuilder(paths.Count); + + foreach ( + var pair in paths.OrderBy(static pair => pair.Key.ToDisplayString(DisplayFormat), StringComparer.Ordinal) + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var caseType = pair.Key; + var path = pair.Value; + + // A case is ambiguous when it is reachable through two included unions, or when any union in its + // construction path is itself ambiguous: the second path would construct a different value. + if (ambiguous.Contains(caseType) || path.Any(ambiguous.Contains)) + continue; + + // Every type the generated factory references must be referenceable from generated code; a + // non-public one narrows the generated class rather than dropping the case. + var referenceable = TryGetReferenceableAccessibility(caseType, out var caseIsPublic); + foreach (var pathUnion in path) + { + if (!TryGetReferenceableAccessibility(pathUnion, out var pathIsPublic)) + { + referenceable = false; + break; + } + + caseIsPublic &= pathIsPublic; + } + + if (!referenceable) + continue; + everyCaseIsPublic &= caseIsPublic; - caseTypes.Add((INamedTypeSymbol)caseType); + results.Add(new ResultUnionIncludedCase(caseType, path)); + } + + foreach (var caseType in ambiguous) + { + diagnostics.Add( + ResultDiagnostic.Create( + DiagnosticLibrary.AmbiguousIncludedCase, + DiagnosticScope.Union, + reportLocation, + caseType.ToDisplayString(DisplayFormat), + union.ToDisplayString(DisplayFormat) + ) + ); + } + + return results.ToImmutable(); + + void Walk(INamedTypeSymbol currentUnion, ImmutableArray parentPath) + { + cancellationToken.ThrowIfCancellationRequested(); + + // The declaring union is never its own inclusion, and a union already on the current path is a + // cycle: neither can be constructed. + if (SymbolEqualityComparer.Default.Equals(currentUnion, union)) + return; + + if (!stack.Add(currentUnion)) + return; + + foreach (var caseType in GetUsableCaseTypes(currentUnion)) + { + if ( + SymbolEqualityComparer.Default.Equals(caseType, union) + || directCases.Contains(caseType) + || stack.Contains(caseType) + ) + continue; + + var path = ImmutableArray.Create(currentUnion).AddRange(parentPath); + + if (paths.TryGetValue(caseType, out var existing)) + { + if (!PathsEqual(existing, path)) + ambiguous.Add(caseType); + + continue; + } + + paths.Add(caseType, path); + + if (caseType.IsUnion) + Walk(caseType, path); + } + + stack.Remove(currentUnion); + } + } + + /// + /// Resolves an included union's usable case types without reporting findings, which are reported when + /// that union is itself analyzed as an opted-in target. + /// + static ImmutableArray GetUsableCaseTypes(INamedTypeSymbol union) + { + var caseTypes = ImmutableArray.CreateBuilder(); + + foreach (var constructor in union.InstanceConstructors) + { + if (!IsCaseConstructor(constructor)) + continue; + + var caseType = constructor.Parameters[0].Type; + if (ContainsTypeParameters(caseType)) + continue; + + if (!TryGetReferenceableAccessibility(caseType, out _)) + continue; + + // An array type passes both checks above but is not a named type. This path walks included + // unions and reports nothing of its own, so the case is simply skipped; the declaring union's + // own analysis reports it as RSG1009. + if (caseType is not INamedTypeSymbol namedCaseType) + continue; + + caseTypes.Add(namedCaseType); } return caseTypes.ToImmutable(); } + static bool PathsEqual(ImmutableArray left, ImmutableArray right) + { + if (left.Length != right.Length) + return false; + + for (var index = 0; index < left.Length; index++) + { + if (!SymbolEqualityComparer.Default.Equals(left[index], right[index])) + return false; + } + + return true; + } + /// /// Determines whether a constructor follows the language's union case pattern: a public, single /// by-value (or in) parameter constructor whose parameter type is therefore a case type. diff --git a/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs b/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs index e7418cc..9dfa421 100644 --- a/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs +++ b/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs @@ -1,11 +1,15 @@ using System.Collections.Immutable; using System.Text; using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration.Diagnostics; -using Purview.Results.SourceGeneration.Helpers; -using Purview.Results.SourceGeneration.Models; +using Purview.Results.SourceGenerator.Diagnostics; +using Purview.Results.SourceGenerator.Helpers; +using Purview.Results.SourceGenerator.Models; -namespace Purview.Results.SourceGeneration.Discovery; +// The language's union support is a preview feature of C# 15; ITypeSymbol.IsUnion is marked +// [Experimental] until the feature ships, and this discovery deliberately targets it. +#pragma warning disable RSEXPERIMENTAL006 + +namespace Purview.Results.SourceGenerator.Discovery; /// /// Converts an opted-in attribute target into the immutable the pipeline @@ -51,6 +55,8 @@ CancellationToken cancellationToken : union.ContainingNamespace.ToDisplayString(); var extensionClassName = CreateExtensionClassName(union); + var cases = CreateCaseModels(analysis.CaseTypes); + var includedCases = CreateIncludedCaseModels(analysis.IncludedCases); return GeneratorResult.Create( new ResultUnionModel( @@ -66,7 +72,9 @@ CancellationToken cancellationToken ? extensionClassName + ".g.cs" : namespaceName + "." + extensionClassName + ".g.cs", Location: ToSourceLocation(union), - Cases: CreateCaseModels(analysis.CaseTypes) + Cases: cases, + HelperCases: cases, + IncludedCases: includedCases ), diagnostics ); @@ -93,7 +101,33 @@ static EquatableArray CreateCaseModels(ImmutableArray string.CompareOrdinal(left.FullyQualifiedName, right.FullyQualifiedName)); + } + + static EquatableArray CreateIncludedCaseModels( + ImmutableArray includedCases + ) + { + if (includedCases.IsDefaultOrEmpty) + return EquatableArray.Empty; + + var cases = ImmutableArray.CreateBuilder(includedCases.Length); + + foreach (var includedCase in includedCases) + { + cases.Add( + new IncludedUnionCaseModel( + TypeReference.Create(includedCase.CaseType), + includedCase.CaseType.ToDisplayString(ResultUnionDiagnostics.DisplayFormat), + includedCase.UnionPath.Select(static unionType => new TypeIdentity(unionType)).ToImmutableArray() ) ); } diff --git a/src/src/SourceGenerator/Emit/GenerateResultAttributeEmitter.cs b/src/src/SourceGenerator/Emit/GenerateResultAttributeEmitter.cs index 6f6662e..0bf05d2 100644 --- a/src/src/SourceGenerator/Emit/GenerateResultAttributeEmitter.cs +++ b/src/src/SourceGenerator/Emit/GenerateResultAttributeEmitter.cs @@ -1,9 +1,9 @@ using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -using Purview.Results.SourceGeneration.Helpers; +using Purview.Results.SourceGenerator.Helpers; -namespace Purview.Results.SourceGeneration.Emit; +namespace Purview.Results.SourceGenerator.Emit; /// /// Emits the GenerateResultAttribute that consumers apply to their unions. @@ -20,7 +20,7 @@ public static void EmitAttribute(IncrementalGeneratorPostInitializationContext c CodeWriter writer = new(GenerationSettings.Create()); writer.AutoGeneratedHeader(); - writer.FileScopedNamespace(PropertyLibrary.SourceGenerationNamespace); + writer.FileScopedNamespace(PropertyLibrary.ResultsNamespace); writer.XmlSummary( "Opts a C# 15 union declaration into result helper generation.", diff --git a/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs b/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs index fd7d9e6..90f7ff7 100644 --- a/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs +++ b/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs @@ -1,10 +1,10 @@ using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -using Purview.Results.SourceGeneration.Helpers; -using Purview.Results.SourceGeneration.Models; +using Purview.Results.SourceGenerator.Helpers; +using Purview.Results.SourceGenerator.Models; -namespace Purview.Results.SourceGeneration.Emit; +namespace Purview.Results.SourceGenerator.Emit; /// /// Emits the strongly typed result helpers for every opted-in union. @@ -46,7 +46,7 @@ static void EmitUnion(SourceProductionContext context, GeneratorContext generati writer.FileScopedNamespace(union.Namespace); writer.XmlSummary( - $"Provides strongly typed failure helpers that create {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} values from the union cases of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + $"Provides strongly typed failure and success helpers that create {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} and {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultUnitTypeName))} values from the union cases of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." ); writer.Class( @@ -55,8 +55,10 @@ static void EmitUnion(SourceProductionContext context, GeneratorContext generati options => options with { IsStatic = true, IsPartial = false }, body => { - foreach (var unionCase in union.Cases) + foreach (var unionCase in union.HelperCases) EmitHelper(body, union, unionCase); + + EmitUnionFactory(body, union); } ); @@ -64,6 +66,12 @@ static void EmitUnion(SourceProductionContext context, GeneratorContext generati } static void EmitHelper(CodeWriter writer, ResultUnionModel union, ResultUnionCaseModel unionCase) + { + EmitValueHelper(writer, union, unionCase); + EmitUnitHelper(writer, union, unionCase); + } + + static void EmitValueHelper(CodeWriter writer, ResultUnionModel union, ResultUnionCaseModel unionCase) { var resultType = CreateResultTypeReference(union); var resultTypeName = resultType.RenderFullName; @@ -104,6 +112,300 @@ options with ); } + static void EmitUnitHelper(CodeWriter writer, ResultUnionModel union, ResultUnionCaseModel unionCase) + { + var resultType = CreateUnitResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a failed unit result containing the {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + ); + writer.XmlParam( + PropertyLibrary.ErrorParameterName, + $"The {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case to convert." + ); + writer.XmlReturn( + $"A failed {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultUnitTypeName))} whose error is {XmlParamRef(PropertyLibrary.ErrorParameterName)}." + ); + + writer.Method( + PropertyLibrary.FailureHelperName, + resultType, + union.Accessibility, + options => + options with + { + IsStatic = true, + Parameters = + [ + new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, unionCase.CaseType) + { + IsThis = true, + }, + ], + }, + body => body.Return($"{resultTypeName}.Failure({PropertyLibrary.ErrorParameterName})") + ); + } + + /// + /// Emits the union-receiver factory members as a C# 14 extension block on the union type. + /// + /// + /// Naming the union by the receiver (Union.Failure(case)) rather than the case + /// (case.AsFailure<TValue>()) keeps the call site unambiguous when a case type is shared with + /// another union, because each union owns its own factory set. The factory is therefore the shared-case + /// safe form of the per-case helpers. + /// + static void EmitUnionFactory(CodeWriter writer, ResultUnionModel union) + { + writer.ExtensionBlock( + new TypeReference(union.UnionType), + body => + { + foreach (var unionCase in union.Cases) + { + EmitUnitFactory(body, union, unionCase); + EmitValueFactory(body, union, unionCase); + } + + foreach (var includedCase in union.IncludedCases) + { + EmitIncludedUnitFactory(body, union, includedCase); + EmitIncludedValueFactory(body, union, includedCase); + } + + EmitUnitSuccessFactory(body, union); + EmitValueSuccessFactory(body, union); + } + ); + } + + static void EmitUnitFactory(CodeWriter writer, ResultUnionModel union, ResultUnionCaseModel unionCase) + { + var resultType = CreateUnitResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a failed unit result containing the {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + ); + writer.XmlParam( + PropertyLibrary.ErrorParameterName, + $"The {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case to convert." + ); + writer.XmlReturn( + $"A failed {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultUnitTypeName))} whose error is {XmlParamRef(PropertyLibrary.ErrorParameterName)}." + ); + + writer.Method( + PropertyLibrary.FailureFactoryName, + resultType, + union.Accessibility, + options => + options with + { + IsStatic = true, + Parameters = + [ + new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, unionCase.CaseType), + ], + }, + body => body.Return($"{resultTypeName}.Failure({PropertyLibrary.ErrorParameterName})") + ); + } + + static void EmitValueFactory(CodeWriter writer, ResultUnionModel union, ResultUnionCaseModel unionCase) + { + var resultType = CreateResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a failed result containing the {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + ); + writer.XmlTypeParam( + PropertyLibrary.ValueTypeParameterName, + "The type of the successful value of the created result." + ); + writer.XmlParam( + PropertyLibrary.ErrorParameterName, + $"The {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case to convert." + ); + writer.XmlReturn( + $"A failed {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} whose error is {XmlParamRef(PropertyLibrary.ErrorParameterName)}." + ); + + writer.Method( + PropertyLibrary.FailureFactoryName, + resultType, + union.Accessibility, + options => + options with + { + IsStatic = true, + GenericTypes = [new GenericTypeParameterOptions(PropertyLibrary.ValueTypeParameterName)], + Parameters = + [ + new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, unionCase.CaseType), + ], + }, + body => body.Return($"{resultTypeName}.Failure({PropertyLibrary.ErrorParameterName})") + ); + } + + /// + /// Emits the unit factory for a case reached through an included union, constructing the nested union + /// value so the case can be converted by naming this union. + /// + static void EmitIncludedUnitFactory(CodeWriter writer, ResultUnionModel union, IncludedUnionCaseModel includedCase) + { + var resultType = CreateUnitResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a failed unit result containing the {XmlInlineCode(DocText.Escape(includedCase.FullyQualifiedName))} case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}, reached through the union(s) that include it." + ); + writer.XmlParam( + PropertyLibrary.ErrorParameterName, + $"The {XmlInlineCode(DocText.Escape(includedCase.FullyQualifiedName))} case to convert." + ); + writer.XmlReturn( + $"A failed {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultUnitTypeName))} whose error is {XmlParamRef(PropertyLibrary.ErrorParameterName)}." + ); + + writer.Method( + PropertyLibrary.FailureFactoryName, + resultType, + union.Accessibility, + options => + options with + { + IsStatic = true, + Parameters = + [ + new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, includedCase.CaseType), + ], + }, + body => body.Return($"{resultTypeName}.Failure({RenderIncludedValue(union, includedCase)})") + ); + } + + /// + /// Emits the value factory for a case reached through an included union, constructing the nested union + /// value so the case can be converted by naming this union. + /// + static void EmitIncludedValueFactory(CodeWriter writer, ResultUnionModel union, IncludedUnionCaseModel includedCase) + { + var resultType = CreateResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a failed result containing the {XmlInlineCode(DocText.Escape(includedCase.FullyQualifiedName))} case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}, reached through the union(s) that include it." + ); + writer.XmlTypeParam( + PropertyLibrary.ValueTypeParameterName, + "The type of the successful value of the created result." + ); + writer.XmlParam( + PropertyLibrary.ErrorParameterName, + $"The {XmlInlineCode(DocText.Escape(includedCase.FullyQualifiedName))} case to convert." + ); + writer.XmlReturn( + $"A failed {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} whose error is {XmlParamRef(PropertyLibrary.ErrorParameterName)}." + ); + + writer.Method( + PropertyLibrary.FailureFactoryName, + resultType, + union.Accessibility, + options => + options with + { + IsStatic = true, + GenericTypes = [new GenericTypeParameterOptions(PropertyLibrary.ValueTypeParameterName)], + Parameters = + [ + new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, includedCase.CaseType), + ], + }, + body => body.Return($"{resultTypeName}.Failure({RenderIncludedValue(union, includedCase)})") + ); + } + + /// + /// Renders the nested union value for a case reached through an included union: the case wrapped in each + /// path union innermost first, and finally in the declaring union. + /// + static string RenderIncludedValue(ResultUnionModel union, IncludedUnionCaseModel includedCase) + { + var current = PropertyLibrary.ErrorParameterName; + + foreach (var unionType in includedCase.UnionPath) + current = $"new {unionType.RenderFullName}({current})"; + + return $"new {union.UnionType.RenderFullName}({current})"; + } + + static void EmitUnitSuccessFactory(CodeWriter writer, ResultUnionModel union) + { + var resultType = CreateUnitResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a successful unit result for {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + ); + writer.XmlReturn($"A successful {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultUnitTypeName))}."); + + writer.Method( + PropertyLibrary.SuccessFactoryName, + resultType, + union.Accessibility, + options => options with { IsStatic = true }, + body => body.Return($"{resultTypeName}.Success()") + ); + } + + static void EmitValueSuccessFactory(CodeWriter writer, ResultUnionModel union) + { + var resultType = CreateResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a successful result carrying a value for {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + ); + writer.XmlTypeParam( + PropertyLibrary.ValueTypeParameterName, + "The type of the successful value of the created result." + ); + writer.XmlParam(PropertyLibrary.ValueParameterName, "The successful value."); + writer.XmlReturn( + $"A successful {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} carrying {XmlParamRef(PropertyLibrary.ValueParameterName)}." + ); + + writer.Method( + PropertyLibrary.SuccessFactoryName, + resultType, + union.Accessibility, + options => + options with + { + IsStatic = true, + GenericTypes = [new GenericTypeParameterOptions(PropertyLibrary.ValueTypeParameterName)], + Parameters = + [ + // The method's own type parameter is referenced as a literal named argument, the same + // representation the generic result type reference uses: the framework's + // TypeReference.ForTypeParameter carries no identity, and the writer's parameter + // validation rejects a parameter whose reference has no identity name. + new ParameterDeclarationOptions( + PropertyLibrary.ValueParameterName, + new TypeReference(new TypeIdentity(PropertyLibrary.ValueTypeParameterName, null)) + ), + ], + }, + body => body.Return($"{resultTypeName}.Success({PropertyLibrary.ValueParameterName})") + ); + } + /// /// Creates the Result<TValue, TUnion> reference for a union, using the framework's /// structured type-reference abstraction so generic arguments, nesting and namespace qualification are @@ -123,4 +425,12 @@ static TypeReference CreateResultTypeReference(ResultUnionModel union) => union.UnionType ) ); + + /// + /// Creates the Result<TUnion> unit result reference for a union. + /// + /// The union whose cases the helper converts. + /// The structured reference to the unit result type. + static TypeReference CreateUnitResultTypeReference(ResultUnionModel union) => + new(ResultsTypeLibrary.ResultUnit.MakeGeneric(union.UnionType)); } diff --git a/src/src/SourceGenerator/Helpers/DocText.cs b/src/src/SourceGenerator/Helpers/DocText.cs index efbac0b..50c1a89 100644 --- a/src/src/SourceGenerator/Helpers/DocText.cs +++ b/src/src/SourceGenerator/Helpers/DocText.cs @@ -1,4 +1,4 @@ -namespace Purview.Results.SourceGeneration.Helpers; +namespace Purview.Results.SourceGenerator.Helpers; /// /// Composes and escapes text that is written into generated XML documentation. diff --git a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs index 250bb47..5f12fdb 100644 --- a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs +++ b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs @@ -1,4 +1,4 @@ -namespace Purview.Results.SourceGeneration.Helpers; +namespace Purview.Results.SourceGenerator.Helpers; /// /// Contains the names, hint names and build properties used by the results source generator. @@ -11,12 +11,6 @@ static class PropertyLibrary /// public const string ResultsNamespace = "Purview.Results"; - /// - /// The namespace of the GenerateResultAttribute opt-in attribute generated into consuming - /// assemblies. - /// - public const string SourceGenerationNamespace = "Purview.Results.SourceGeneration"; - /// /// The metadata name of the generated opt-in attribute. /// @@ -25,7 +19,7 @@ static class PropertyLibrary /// /// The MSBuild property that disables the generator when set to . /// - public const string DisableGenerator = "ResultsSourceGenerator_Disable"; + public const string DisableGenerator = "DisableResultsSourceGenerator"; /// /// The hint name of the generated opt-in attribute source. @@ -42,6 +36,18 @@ static class PropertyLibrary /// public const string FailureHelperName = "AsFailure"; + /// + /// The name of the generated union-receiver failure factory method, declared as a static extension + /// member on the union type so the union is named by the receiver. + /// + public const string FailureFactoryName = "Failure"; + + /// + /// The name of the generated union-receiver success factory method, declared as a static extension + /// member on the union type so the union is named by the receiver. + /// + public const string SuccessFactoryName = "Success"; + /// /// The name of the generic value type parameter used by the generated helpers. /// @@ -52,10 +58,22 @@ static class PropertyLibrary /// public const string ErrorParameterName = "error"; + /// + /// The name of the value parameter used by the generated success factory. + /// + public const string ValueParameterName = "value"; + /// /// The fully-qualified name of the runtime Result<TValue, TError> type, used when the /// generated source is described in XML documentation. The value is raw, so callers escape it with /// before it is written into documentation text. /// public const string ResultTypeName = ResultsNamespace + ".Result"; + + /// + /// The fully-qualified name of the runtime Result<TError> unit result type, used when the + /// generated source is described in XML documentation. The value is raw, so callers escape it with + /// before it is written into documentation text. + /// + public const string ResultUnitTypeName = ResultsNamespace + ".Result"; } diff --git a/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs b/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs index 6077758..c1ffbce 100644 --- a/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs +++ b/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs @@ -1,4 +1,4 @@ -namespace Purview.Results.SourceGeneration.Helpers; +namespace Purview.Results.SourceGenerator.Helpers; /// /// Provides the TypeIdentity values and namespaces the results source generator resolves @@ -10,16 +10,6 @@ namespace Purview.Results.SourceGeneration.Helpers; /// static class ResultsTypeLibrary { - /// - /// The namespace of the runtime Purview.Results library. - /// - public const string ResultsNamespace = PropertyLibrary.ResultsNamespace; - - /// - /// The namespace of the generated opt-in attribute. - /// - public const string SourceGenerationNamespace = PropertyLibrary.SourceGenerationNamespace; - /// /// The namespace that contains the union contracts the language uses to describe unions. /// @@ -30,14 +20,28 @@ static class ResultsTypeLibrary /// public static readonly TypeIdentity GenerateResultAttribute = new( PropertyLibrary.GenerateResultAttributeName, - SourceGenerationNamespace + PropertyLibrary.ResultsNamespace ); /// /// The runtime Result<TValue, TError> result type. Callers apply the generic arguments /// for a specific union. /// - public static readonly TypeIdentity Result = new(typeName: "Result", @namespace: ResultsNamespace, arity: 2); + public static readonly TypeIdentity Result = new( + typeName: "Result", + @namespace: PropertyLibrary.ResultsNamespace, + arity: 2 + ); + + /// + /// The runtime Result<TError> unit result type. Callers apply the generic argument for a + /// specific union. + /// + public static readonly TypeIdentity ResultUnit = new( + typeName: "Result", + @namespace: PropertyLibrary.ResultsNamespace, + arity: 1 + ); /// /// The language-specific System.Runtime.CompilerServices.IUnion interface implemented by diff --git a/src/src/SourceGenerator/Models/ResultUnionModel.cs b/src/src/SourceGenerator/Models/ResultUnionModel.cs index 5fb0bf2..5d50aad 100644 --- a/src/src/SourceGenerator/Models/ResultUnionModel.cs +++ b/src/src/SourceGenerator/Models/ResultUnionModel.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -namespace Purview.Results.SourceGeneration.Models; +namespace Purview.Results.SourceGenerator.Models; /// /// A cache-safe description of a source location: the originating file path plus spans. @@ -48,7 +48,11 @@ public Location ToLocation() => /// /// The fully-qualified display name of the case type, used for diagnostics and deterministic ordering. /// -readonly record struct ResultUnionCaseModel(TypeReference CaseType, string FullyQualifiedName) +/// +/// Whether the case type is itself a union. A union-typed case is an included union: its own +/// cases are reachable through it, and sharing it across unions is the intended composition pattern. +/// +readonly record struct ResultUnionCaseModel(TypeReference CaseType, string FullyQualifiedName, bool IsUnionCase) { /// /// Gets an empty case model. @@ -56,6 +60,31 @@ readonly record struct ResultUnionCaseModel(TypeReference CaseType, string Fully public static readonly ResultUnionCaseModel Empty; } +/// +/// Describes one case that a union exposes through an included union rather than declaring directly. +/// +/// +/// The reference to the transitively reachable case type. +/// +/// +/// The fully-qualified display name of the case type, used for deterministic ordering. +/// +/// +/// The chain of unions that must be constructed to reach the case, innermost first. The generated +/// factory wraps the case in each union in order and finally in the declaring union. +/// +readonly record struct IncludedUnionCaseModel( + TypeReference CaseType, + string FullyQualifiedName, + EquatableArray UnionPath +) +{ + /// + /// Gets an empty included case model. + /// + public static readonly IncludedUnionCaseModel Empty; +} + /// /// An immutable, value-equatable description of one opted-in union containing only the information /// required to generate its result helpers. @@ -72,7 +101,21 @@ readonly record struct ResultUnionCaseModel(TypeReference CaseType, string Fully /// The deterministic hint name of the generated source file. /// The declaration location of the union. /// -/// The union's case types in deterministic (ordinal, fully-qualified name) order. +/// The union's full set of case types in deterministic (ordinal, fully-qualified name) order. The +/// union-receiver factory is generated for every one of these, including a case type shared with another +/// union. +/// +/// +/// The subset of whose per-case AsFailure helpers are generated for this +/// union. A leaf case type shared with another union is owned by the union that sorts first, so it is +/// absent here for the others; a union-typed case shared with another union is absent for every union, +/// because its per-case helper would be ambiguous and the union-receiver factory is the safe form. The +/// union-receiver factory still covers every case through . +/// +/// +/// The case types reachable through the union's included (union-typed) cases, in deterministic order. The +/// union-receiver factory is generated for each of these as well, so a case of an included union can be +/// converted by naming this union. /// readonly record struct ResultUnionModel( string Namespace, @@ -83,7 +126,9 @@ readonly record struct ResultUnionModel( string ExtensionClassName, string HintName, ResultSourceLocation Location, - EquatableArray Cases + EquatableArray Cases, + EquatableArray HelperCases, + EquatableArray IncludedCases ) { /// diff --git a/src/src/SourceGenerator/Sdk/README.md b/src/src/SourceGenerator/README.md similarity index 65% rename from src/src/SourceGenerator/Sdk/README.md rename to src/src/SourceGenerator/README.md index c21842e..4bd0adf 100644 --- a/src/src/SourceGenerator/Sdk/README.md +++ b/src/src/SourceGenerator/README.md @@ -1,21 +1,22 @@ -# Purview.Results.SourceGenerator +# Purview.Results source generator -[![NuGet version](https://img.shields.io/nuget/v/Purview.Results.SourceGenerator.svg)](https://www.nuget.org/packages/Purview.Results.SourceGenerator) +[![NuGet version](https://img.shields.io/nuget/v/Purview.Results.svg)](https://www.nuget.org/packages/Purview.Results) [![Release](https://github.com/purview-dev/results/actions/workflows/release.yml/badge.svg)](https://github.com/purview-dev/results/actions/workflows/release.yml) An incremental Roslyn source generator that makes C# 15 **union** error cases ergonomic to use with -`Purview.Results.Result`. +`Purview.Results.Result` and its value-less counterpart `Purview.Results.Result`. It ships +inside the [`Purview.Results`](https://www.nuget.org/packages/Purview.Results) package; it is not published as a +separate package. ## Installation ```bash -dotnet add package Purview.Results.SourceGenerator dotnet add package Purview.Results ``` -The generator is a Roslyn component: reference it as a normal `PackageReference` and it is applied to the -compilation automatically. `Purview.Results` provides the `Result` type the generated helpers -build. +`Purview.Results` provides both the `Result` type and this generator: the package carries the +merged Roslyn component under `analyzers/dotnet/cs`, so it is applied to the compilation automatically. There is +no separate generator package to reference. **Requirements:** a C# 15 compiler with union declaration support (the .NET 11 SDK or later) and `LangVersion=preview`. @@ -30,7 +31,8 @@ public readonly record struct TenantAlreadyExists(TenantId TenantId); ``` For a union opted in with `[GenerateResult]` the generator emits one strongly typed extension class in the -union's namespace: +union's namespace, with two helpers per case — a value-producing `AsFailure()` and a non-generic +`AsFailure()` that produces a unit result: ```csharp namespace Test.Tenancy; @@ -40,11 +42,10 @@ public static class TenantErrorResultExtensions public static Result AsFailure(this TenantNotFound error) => Result.Failure(error); - public static Result AsFailure(this TenantDisabled error) => - Result.Failure(error); + public static Result AsFailure(this TenantNotFound error) => + Result.Failure(error); - public static Result AsFailure(this TenantAlreadyExists error) => - Result.Failure(error); + // ... one pair per case: TenantDisabled, TenantAlreadyExists } ``` @@ -52,8 +53,78 @@ Usage: ```csharp Result result = new TenantNotFound(tenantId).AsFailure(); +Result unitResult = new TenantNotFound(tenantId).AsFailure(); ``` +The same class also declares a C# 14 extension block on the union type itself, so a case can be converted by +naming the union (`TenantError.Failure(case)`) rather than the case: + +```csharp +public static class TenantErrorResultExtensions +{ + // ... the per-case AsFailure helpers above ... + + extension(TenantError) + { + public static Result Failure(TenantNotFound error) => + Result.Failure(error); + + public static Result Failure(TenantNotFound error) => + Result.Failure(error); + + // ... one Failure pair per case: TenantDisabled, TenantAlreadyExists ... + + public static Result Success() => + Result.Success(); + + public static Result Success(TValue value) => + Result.Success(value); + } +} +``` + +Usage: + +```csharp +Result result = TenantError.Failure(new TenantNotFound(tenantId)); +Result unitResult = TenantError.Failure(new TenantNotFound(tenantId)); +Result success = TenantError.Success(); +Result valueSuccess = TenantError.Success(tenant); +``` + +The union-receiver factory is the **shared-case safe** form. When a case type is a case of more than one union, the +per-case `AsFailure` helper is generated once — for the union that sorts first — and reported as `RSG1006`, so a +case-receiver call can bind to the wrong union. The factory names the union by its receiver, so it is generated for +every union and covers every one of the union's cases, including a shared one. + +## Union inclusion + +A case type that is itself a union is an **included union**: the including union's factory also covers every case +reachable through it, so a case of an included union can be converted by naming the including union. The value is +constructed nested, innermost first: + +```csharp +[GenerateResult] +public readonly union BillingError(BillingAccountMissing, BillingServiceUnavailable); + +[GenerateResult] +public readonly union RegisterTenantError(TenantError, BillingError); + +// BillingAccountMissing is reachable through BillingError, so the factory covers it and builds the nested value. +Result result = + RegisterTenantError.Failure(new BillingAccountMissing(accountId)); +// equivalent to: +Result explicitNesting = + RegisterTenantError.Failure(new BillingError(new BillingAccountMissing(accountId))); +``` + +Inclusion is automatic for every union-typed case; no attribute is needed. It is also the way to **avoid `RSG1006` +entirely**: list the callee's union as a case instead of repeating its leaf cases. A union-typed case shared by two +unions is the intended composition pattern, so it is **not** reported as `RSG1006` and its ambiguous per-case +`AsFailure` helper is not generated for any union; the union-receiver factory is the only (and safe) form. A case +reachable through two different included unions cannot name a construction path, so it is reported as `RSG1008` and +its inclusion factory is skipped. + ## Why the generator exists C# composes a union *case* into its union and `Result` composes the *union* into a result, @@ -68,9 +139,9 @@ in `src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs`. The sam extension method declared on the *union* type cannot help either (`CS1929`): C# does not apply union conversions to an extension-method receiver, which is why one helper is generated **per case type**. -The generator does not change, wrap or replace `Result`; it only produces the missing call -site ergonomics. There is no reflection, no `dynamic`, no runtime type discovery and no mutable static state: -every helper is a pure static method that calls the existing `Result.Failure` factory. +The generator does not change, wrap or replace `Result` or `Result`; it only produces +the missing call site ergonomics. There is no reflection, no `dynamic`, no runtime type discovery and no mutable +static state: every helper is a pure static method that calls the existing `Result<...>.Failure` factory. ## Why there are no implicit conversions @@ -133,8 +204,8 @@ needs no generated code. The diagnostics live in one shared library (`Diagnostics/DiagnosticLibrary.cs` + `Diagnostics/ResultUnionDiagnostics.cs` + `Diagnostics/ResultDiagnostic.cs`) that both hosts consume: -- **`ResultsDiagnosticAnalyzer` reports the per-target rules** (`RSG1000`–`RSG1004`, `RSG1007`) in the IDE - and in build output. +- **`ResultsDiagnosticAnalyzer` reports the per-target rules** (`RSG1000`–`RSG1004`, `RSG1007`, `RSG1008`) in the + IDE and in build output. - **The generator reports the compilation-wide rules** (`RSG1005`, `RSG1006`) that need every opted-in union in the compilation. - **The generator never reports a rule the analyzer reports.** It still runs the same shared analysis, so the @@ -149,8 +220,9 @@ The diagnostics live in one shared library (`Diagnostics/DiagnosticLibrary.cs` + | `RSG1003` | Error | Analyzer | Union: yes / case: no | A type that generated code must reference is not accessible (for example a `file` type). | | `RSG1004` | Error | Analyzer | No | The same union is configured more than once (for example on two partial declarations); the helpers are generated once. | | `RSG1005` | Error | Generator | Yes (the colliding union is skipped) | Two unions produce the same generated class name in one namespace. | -| `RSG1006` | Warning | Generator | No (only the shared case's helper is skipped) | A case type is shared with another union, so its helper is generated once to keep call sites unambiguous. | +| `RSG1006` | Warning | Generator | No (only the shared leaf case's `AsFailure` helper is skipped) | A **leaf** case type is shared with another union, so its per-case helper is generated once; the union-receiver `Failure(...)` factory is generated for every union and is the shared-case safe form. A union-typed case shared across unions is composition, not a shared leaf, and is not reported. | | `RSG1007` | Error | Analyzer | Yes | The union uses an `IUnionMembers` member provider, which the first implementation does not support. | +| `RSG1008` | Warning | Analyzer | No (only the ambiguous case's inclusion factory is skipped) | A case is reachable through two different included unions, so no inclusion factory can name its construction path. List the case through the union that directly declares it instead. | Case-level findings never block the union: the remaining cases are still generated, and the skipped case is reported. @@ -173,7 +245,7 @@ The suppression is narrow on purpose: - The union's **case types** keep the warning, as does every other value type. A case declared as a plain `struct` still gets `CA1815`; a `record struct` case never gets it, because records synthesise equality. - Nothing is hidden: a suppressor can only suppress non-error, configurable diagnostics, and `RSG2000` is a - suppression id rather than a rule, so `RSG1000`–`RSG1007` remain the only diagnostics this package reports. + suppression id rather than a rule, so `RSG1000`–`RSG1008` remain the only diagnostics this package reports. Every suppression is logged as an `Info` diagnostic against `RSG2000`, in the verbose build log and in an MSBuild binlog (and as a suppressed diagnostic in an `/errorlog` SARIF file), so a build can always be audited @@ -196,40 +268,42 @@ for, and a union that has it never raises the warning in the first place, so the | Property | Default | Purpose | | --- | --- | --- | -| `ResultsSourceGenerator_Disable` | `false` | Disables generation while still emitting the opt-in attribute. | +| `DisableResultsSourceGenerator` | `false` | Disables generation while still emitting the opt-in attribute. | The property is declared as a compiler-visible MSBuild property and shipped to `PackageReference` consumers as -`buildTransitive/Purview.Results.SourceGenerator.props` (from -`Sdk/buildTransitive/Purview.Results.SourceGenerator.props` in this repository), so -`-p:ResultsSourceGenerator_Disable=true` — or a `Directory.Build.props` setting — disables the helpers for +`buildTransitive/Purview.Results.props` (from `Sdk/buildTransitive/Purview.Results.props` in this repository), +so `-p:DisableResultsSourceGenerator=true` — or a `Directory.Build.props` setting — disables the helpers for consumers too. ## Consumer requirements - A C# 15 compiler with union declaration support (`.NET 11` SDK or later) and `LangVersion=preview`. -- A reference to `Purview.Results` for `Result`. +- A reference to `Purview.Results`, which provides both `Result` and the unit `Result`, + and this generator. ## Call-site ergonomics: the code fix -Because the generator cannot make a case value convert (`AsFailure()` is the best the language allows), -the package also ships an IDE code fix: `UnionCaseResultCodeFixProvider` in the companion -`SourceGenerator.CodeFixes` component (`src/src/SourceGenerator.CodeFixes`). +Because the generator cannot make a case value convert (`Union.Failure(...)` and the per-case `AsFailure()` +are the best the language allows), the package also ships an IDE code fix: `UnionCaseResultCodeFixProvider` in the +companion `SourceGenerator.CodeFixes` component (`src/src/SourceGenerator.CodeFixes`). It answers the compiler's `CS0029` ("cannot implicitly convert") and rewrites a returned case value into the -generated helper, so `return new TenantNotFound(id);` becomes -`return new TenantNotFound(id).AsFailure();` from the lightbulb. +union-receiver factory, so `return new TenantNotFound(id);` becomes +`return TenantError.Failure(new TenantNotFound(id));` from the lightbulb, or +`TenantError.Failure(new TenantNotFound(id))` when the method returns a unit `Result`. Naming the union +keeps the rewrite correct even when the case type is shared with another union (`RSG1006`). A fix is only offered when the rewrite will bind: | Guard | Why | | --- | --- | -| The converted type is `Purview.Results.Result` | Only results have generated helpers | -| `TError` is a union **and** opted in with `[GenerateResult]` | The helper is only generated for opted-in unions | -| The expression's type is one of `TError`'s case types | `AsFailure()` exists once per case | -| The union is reachable by its simple name at the call site | The helper class is generated into the union's own namespace | +| The converted type is `Purview.Results.Result` or the unit `Purview.Results.Result` | Only results have generated factories; a value result gets `Failure(case)` and a unit result `Failure(case)` | +| `TError` is a union **and** opted in with `[GenerateResult]` | The factory is only generated for opted-in unions | +| The expression's type is one of `TError`'s case types | The factory exists once per case | +| The union is reachable by its simple name at the call site | The factory extension block is generated into the union's own namespace | The component is not packable on its own: it needs `Microsoft.CodeAnalysis.CSharp.Workspaces`, which only the -IDE host provides, so it ships inside this package's `analyzers/dotnet/cs/` as a second analyzer assembly +IDE host provides, so `Purview.Results` packs it into `analyzers/dotnet/cs/` as a second analyzer assembly (never IL-merged into the generator). The two projects stay independent — the code fix does not reference the generator — so no reference cycle can form and Workspaces never enters the generator's dependencies. @@ -254,12 +328,13 @@ rewritten source to prove it compiles. ## Examples -[`src/examples/Examples.Basic`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.Basic) -declares a `[GenerateResult]` union over the Tenant* domain the README uses and calls the helper this generator +[`src/src/Examples.Basic`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Basic) +declares a `[GenerateResult]` union over the Tenant* domain the README uses and calls the helpers this generator emits for each of its cases: ```csharp Result result = new TenantNotFound(tenantId).AsFailure(); +Result unitResult = new TenantNotFound(tenantId).AsFailure(); ``` The [repository README](https://github.com/purview-dev/results#examples) covers the ZodSharp, ASP.NET Core and diff --git a/src/src/SourceGenerator/ResultsSourceGenerator.cs b/src/src/SourceGenerator/ResultsSourceGenerator.cs index 0718590..97e13c4 100644 --- a/src/src/SourceGenerator/ResultsSourceGenerator.cs +++ b/src/src/SourceGenerator/ResultsSourceGenerator.cs @@ -1,10 +1,12 @@ using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration.Discovery; -using Purview.Results.SourceGeneration.Emit; -using Purview.Results.SourceGeneration.Helpers; -using Purview.Results.SourceGeneration.Validation; +using Purview.Results.SourceGenerator.Diagnostics; +using Purview.Results.SourceGenerator.Discovery; +using Purview.Results.SourceGenerator.Emit; +using Purview.Results.SourceGenerator.Helpers; +using Purview.Results.SourceGenerator.Models; +using Purview.Results.SourceGenerator.Validation; -namespace Purview.Results.SourceGeneration; +namespace Purview.Results.SourceGenerator; /// /// Generates strongly typed helpers that create @@ -56,7 +58,51 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.RegisterSourceOutput( generationModel, - static (sourceProductionContext, model) => ResultUnionEmitter.Execute(sourceProductionContext, model) + static (sourceProductionContext, model) => ExecuteSafely(sourceProductionContext, model) ); } + + /// + /// Runs the emitter, reporting an unexpected failure instead of letting it escape. + /// + /// + /// An exception escaping a generator surfaces as CS8785 and discards all generated output + /// for the compilation: one malformed union removes every other union's AsFailure helpers and + /// factories, producing a cascade of unrelated CS1061/CS0103 errors with no indication of + /// the cause. In the IDE it raises AD0001 and analysis stops. Reporting RSG9000 instead + /// keeps the rest of the output intact and names the cause. + /// + /// is deliberately not caught: the IDE cancels generation on + /// every keystroke, and swallowing that would both report spurious errors and defeat cancellation. + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "This is the generator's last line of defence. Any exception that escapes discards " + + "every generated source in the compilation (CS8785) and stops IDE analysis (AD0001), so the " + + "catch is deliberately broad. Cancellation is rethrown." + )] + static void ExecuteSafely(SourceProductionContext context, ResultUnionGenerationModel model) + { + try + { + ResultUnionEmitter.Execute(context, model); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.UnhandledException, + location: null, + "the results source generator", + exception.Message + ) + ); + } + } } diff --git a/src/src/SourceGenerator/SourceGenerator.csproj b/src/src/SourceGenerator/SourceGenerator.csproj index 941f43e..37f54de 100644 --- a/src/src/SourceGenerator/SourceGenerator.csproj +++ b/src/src/SourceGenerator/SourceGenerator.csproj @@ -1,20 +1,12 @@ - true true - - false netstandard2.0 - Purview.Results.SourceGeneration Purview Results Source Generator .NET source generator that makes C# 15 union error cases ergonomic to use with the Purview.Results Result<TValue, TError> type. source-generator;results;result;union;unions;csharp-15;net11;purview;results; - ResultsSourceGenerator_Disable + DisableResultsSourceGenerator @@ -65,11 +56,11 @@ /// Suppresses CA1815 (override Equals and the equality operators on value types) for the union diff --git a/src/src/SourceGenerator/Validation/ResultUnionValidator.cs b/src/src/SourceGenerator/Validation/ResultUnionValidator.cs index ced96b3..643e951 100644 --- a/src/src/SourceGenerator/Validation/ResultUnionValidator.cs +++ b/src/src/SourceGenerator/Validation/ResultUnionValidator.cs @@ -1,9 +1,9 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration.Diagnostics; -using Purview.Results.SourceGeneration.Models; +using Purview.Results.SourceGenerator.Diagnostics; +using Purview.Results.SourceGenerator.Models; -namespace Purview.Results.SourceGeneration.Validation; +namespace Purview.Results.SourceGenerator.Validation; /// /// Validates the discovered union models against each other and produces the single aggregate generation @@ -52,7 +52,6 @@ GeneratorContext context var unions = ImmutableArray.CreateBuilder(ordered.Length); HashSet configuredUnions = []; Dictionary generatedTypes = [with(StringComparer.Ordinal)]; - Dictionary caseOwners = []; foreach (var union in ordered) { @@ -78,39 +77,80 @@ GeneratorContext context } generatedTypes[generatedTypeKey] = union; + unions.Add(union); + } + + var generated = unions.ToImmutable(); - var cases = ImmutableArray.CreateBuilder(union.Cases.Count); + // The per-case AsFailure helper is generated once per case type, so the owner of each case type has + // to be known before the helpers are decided. The unions are in ordinal order, so the first owner + // recorded for a case type is the ordinal-first owner. + Dictionary> caseOwners = []; + foreach (var union in generated) + { foreach (var unionCase in union.Cases) { - if (caseOwners.TryGetValue(unionCase.CaseType, out var caseOwner)) + if (!caseOwners.TryGetValue(unionCase.CaseType, out var owners)) { - Add( - diagnostics, - DiagnosticLibrary.SharedCaseType, - DiagnosticScope.Compilation, - union, - unionCase.FullyQualifiedName, - caseOwner.FullyQualifiedName - ); - continue; + owners = []; + caseOwners[unionCase.CaseType] = owners; } - caseOwners[unionCase.CaseType] = union; - cases.Add(unionCase); + owners.Add(union); } + } + + var generatedUnions = ImmutableArray.CreateBuilder(generated.Length); + + foreach (var union in generated) + { + var helperCases = ImmutableArray.CreateBuilder(union.Cases.Count); - if (cases.Count == 0) + foreach (var unionCase in union.Cases) { - // Every case was either shared with another union (reported above) or the union has no cases - // (reported by the analyzer); either way there is nothing left to generate for this union. - continue; + var owners = caseOwners[unionCase.CaseType]; + + if (owners.Count == 1) + { + helperCases.Add(unionCase); + + continue; + } + + if (unionCase.IsUnionCase) + { + // A union-typed case is an inclusion: sharing it across unions is the composition pattern, + // and the union-receiver factory is the safe form. No per-case helper is generated for any + // owner and no diagnostic is reported, because the helper would bind to the wrong union. + continue; + } + + if (owners[0].UnionType.Equals(union.UnionType)) + { + helperCases.Add(unionCase); + + continue; + } + + Add( + diagnostics, + DiagnosticLibrary.SharedCaseType, + DiagnosticScope.Compilation, + union, + unionCase.FullyQualifiedName, + owners[0].FullyQualifiedName, + union.FullyQualifiedName + ); } - unions.Add(cases.Count == union.Cases.Count ? union : union with { Cases = cases.ToImmutable() }); + // The union is always generated: its union-receiver factory covers every case in union.Cases and + // every case reachable through an included union, even one shared with another union. Only the + // per-case AsFailure helpers are dropped, which is why the union keeps its own HelperCases subset. + generatedUnions.Add(union with { HelperCases = helperCases.ToImmutable() }); } - return new ResultUnionGenerationModel(context, unions.ToImmutable(), diagnostics.ToImmutable()); + return new ResultUnionGenerationModel(context, generatedUnions.ToImmutable(), diagnostics.ToImmutable()); } /// diff --git a/src/src/ZodSharp.AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpResultsServiceCollectionExtensions.cs b/src/src/ZodSharp.AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpResultsServiceCollectionExtensions.cs index 43eae1b..b357281 100644 --- a/src/src/ZodSharp.AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpResultsServiceCollectionExtensions.cs +++ b/src/src/ZodSharp.AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpResultsServiceCollectionExtensions.cs @@ -15,12 +15,12 @@ public static class ZodSharpResultsServiceCollectionExtensions { /// /// Makes a result failure that carries ZodSharp validation errors produce an - /// HttpValidationProblemDetails response, with optional rules per validation error code and - /// category. + /// HttpValidationProblemDetails response, with optional rules per validation error code, + /// category and origin. /// /// - /// Configures which responses particular validation error codes and categories produce. Without it, every - /// failure that carries validation errors is rendered as one validation problem. + /// Configures which responses particular validation error codes, categories and origins produce. Without + /// it, every failure that carries validation errors is rendered as one validation problem. /// /// /// diff --git a/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md b/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md index 020cbf2..ed0dd93 100644 --- a/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md +++ b/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md @@ -21,33 +21,40 @@ builder.Services.AddResultsZodSharpHttp(); // the bridge: validation-carryi - Because it is a failure mapper, **a mapping the host registered for a specific error case always wins**, a mapping registered for the error type wins too, and a host failure mapper registered *before* `AddResultsZodSharpHttp` wins as well. That is the escape hatch: when a case deserves its own response, register - it — and when a validation **code** deserves one, use the rules below. + it — and when a validation **code**, **category** or **origin** deserves one, use the rules below. - Pair it with `AddZodSharpProblemDetails()` so a thrown `ZodException` and a result-carried rejection are rendered by the same mapper and therefore produce identical responses. -## Rules per validation error code and category +## Rules per validation error code, category and origin -Pass a callback to answer particular codes or categories with a response of their own: +Pass a callback to answer particular codes, categories or origins with a response of their own: ```csharp builder.Services.AddResultsZodSharpHttp(options => options .MapCode("tenant_not_found", StatusCodes.Status404NotFound) // validation problem, 404 default .MapCode("tenant_id_matches_name", (errors, context) => TypedResults.Conflict()) .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) + .MapOrigin("value_object", StatusCodes.Status422UnprocessableEntity) // every type-level rule failure ); ``` +The three axes are the identity a `ValidationError` carries: **code** (the specific rule), **category** (a broad +grouping many codes share) and **origin** (the structured origin a rule owns — `"value_object"` for a type-level +rule, `"array"` for a collection rule). Reach for `MapOrigin` when the answer is keyed by the *kind* of rule +rather than one code. + Matching and precedence: 1. every matching **code** rule in registration order, 2. then every matching **category** rule in registration order, -3. then the default validation problem — so rules only ever narrow what the host already gets. +3. then every matching **origin** rule in registration order, +4. then the default validation problem — so rules only ever narrow what the host already gets. -- A rule matches when **any** of the failure's errors carries its code/category, so a registered rule is always - reachable; a factory that wants stricter semantics returns `null` to decline and matching continues. +- A rule matches when **any** of the failure's errors carries its code/category/origin, so a registered rule is + always reachable; a factory that wants stricter semantics returns `null` to decline and matching continues. - A factory receives the failure's **whole error set**, so a rule never hides the other problems the caller has to fix; the `int statusCode` overloads still render every error. -- A code or category registered twice with different behaviour throws at configuration time. +- A code, category or origin registered twice with different behaviour throws at configuration time. ## Rendering by hand diff --git a/src/src/ZodSharp.AspNetCore/Sdk/README.md b/src/src/ZodSharp.AspNetCore/Sdk/README.md index e93b23e..0b1f06f 100644 --- a/src/src/ZodSharp.AspNetCore/Sdk/README.md +++ b/src/src/ZodSharp.AspNetCore/Sdk/README.md @@ -25,8 +25,8 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddZodSharpProblemDetails(); builder.Services.AddResultsHttp(); -// Every validation-carrying failure becomes one validation problem, except the codes and categories that a -// rule answers with something else. +// Every validation-carrying failure becomes one validation problem, except the codes, categories and origins +// that a rule answers with something else. builder.Services.AddResultsZodSharpHttp(options => options .MapCode("tenant_not_found", StatusCodes.Status404NotFound) .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) @@ -41,7 +41,7 @@ app.MapPost("/reconcile", (ReconciliationRequest request) => Reconcile(request)) specific error case always wins, a mapping registered for the error type wins, and a host failure mapper registered before it wins too. -## Answering by validation error code or category +## Answering by validation error code, category or origin | Member | Purpose | | --- | --- | @@ -49,12 +49,19 @@ registered before it wins too. | `MapCode(string code, Func, HttpContext, IResult?>)` | Renders a response of your own | | `MapCategory(string category, int statusCode)` | The same, for a category that spans many codes | | `MapCategory(string category, Func, HttpContext, IResult?>)` | Renders a response of your own | +| `MapOrigin(string origin, int statusCode)` | The same, for an origin a rule owns (`"value_object"`, `"array"`, …) | +| `MapOrigin(string origin, Func, HttpContext, IResult?>)` | Renders a response of your own | -A rule applies when **any** of the failure's errors carries its code or category, so a registered code rule is +The three axes are the identity a `ValidationError` carries: **code** is the specific rule that failed, +**category** is a broad grouping many codes share, and **origin** is the structured origin a rule owns +(`"value_object"` for a type-level rule, `"array"` for a collection rule). Use `MapOrigin` when the answer is +keyed by the kind of rule rather than one code — for example to render every `"value_object"` failure as `422`. + +A rule applies when **any** of the failure's errors carries its code, category or origin, so a registered rule is always reachable whatever else the schema reported. Matching walks the **code** rules first, then the **category** -rules, each in registration order, and finally the default validation problem — a code is narrower than a -category, so it wins however the two were registered. A factory that wants stricter semantics returns `null` to -decline the failure, and matching continues: +rules, then the **origin** rules, each in registration order, and finally the default validation problem — a code is +narrower than a category, which is narrower than an origin, so a code wins however the rules were registered. A +factory that wants stricter semantics returns `null` to decline the failure, and matching continues: ```csharp options @@ -64,19 +71,21 @@ options ? TypedResults.NotFound() : null) // A category rule that answers the failures the code rule declined. - .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity); + .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) + // An origin rule: every type-level rule failure, whatever its code. + .MapOrigin("value_object", StatusCodes.Status422UnprocessableEntity); ``` A factory receives the failure's **full error set**, so a rule never hides the other problems the caller has to -fix, and the `int statusCode` overloads still render every error as an `HttpValidationProblemDetails`. A code or -category registered twice with different behaviour is rejected at configuration time. +fix, and the `int statusCode` overloads still render every error as an `HttpValidationProblemDetails`. A code, +category or origin registered twice with different behaviour is rejected at configuration time. ## API | Member | Purpose | | --- | --- | | `AddResultsZodSharpHttp(Action? configure = null)` | Registers the ZodSharp options and adds the validation failure mapper to `ResultsHttpOptions` | -| `ZodResultsHttpOptions.MapCode` / `.MapCategory` | The per-code and per-category rules described above | +| `ZodResultsHttpOptions.MapCode` / `.MapCategory` / `.MapOrigin` | The per-code, per-category and per-origin rules described above | | `ZodResultsFailureMapper` | The mapper itself, for a host that wants to register or compose it by hand | | `IValidationErrorCarrier.ToValidationProblem(HttpContext, int statusCode = 400)` | Creates the validation problem for the errors the error value carries | | `ImmutableArray.ToValidationProblem(HttpContext, int statusCode = 400)` | Creates the validation problem for a set of errors | @@ -88,16 +97,18 @@ trace identifier is included when `ResultsHttpOptions.IncludeTraceId` is `true` ## Examples -[`src/examples/Examples.AspNetCore.Zod`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.AspNetCore.Zod) +[`src/src/Examples.AspNetCore.Zod`](https://github.com/purview-dev/results/tree/main/src/src/Examples.AspNetCore.Zod) is a runnable minimal-API example where a `TenantInputInvalid` failure carrying ZodSharp errors becomes a `400` validation problem, while the host's own `TenantAlreadyExists` mapping still returns `409`. ```bash -dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 +dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 ``` The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ZodSharp and -ASP.NET Core examples too. +ASP.NET Core examples too. The +[value-objects composition example](https://github.com/purview-dev/results/tree/main/src/src/Examples.ValueObjects.Zod) +shows a type-level `[ZodRule]` whose `Origin = "value_object"` is answered by `MapOrigin`. ## Related packages diff --git a/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs b/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs index eade1d6..7b50d4d 100644 --- a/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs +++ b/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs @@ -9,17 +9,17 @@ namespace Purview.Results.ZodSharp.AspNetCore; /// -/// Renders a result failure that carries ZodSharp validation errors, honouring the code and category rules -/// registered on . +/// Renders a result failure that carries ZodSharp validation errors, honouring the code, category and origin +/// rules registered on . /// /// /// -/// The failure's errors are matched against the registered rules — codes first, then categories, each in -/// registration order — and the first rule that answers wins. A rule with a status code renders the standard -/// validation problem with that default status; a rule with a factory renders whatever the factory returns, and a -/// factory returning declines the failure so matching continues. When no rule answers, the -/// failure is rendered as the standard validation problem, so registering rules only ever narrows what a host -/// already gets. +/// The failure's errors are matched against the registered rules — codes first, then categories, then origins, +/// each in registration order — and the first rule that answers wins. A rule with a status code renders the +/// standard validation problem with that default status; a rule with a factory renders whatever the factory +/// returns, and a factory returning declines the failure so matching continues. When no +/// rule answers, the failure is rendered as the standard validation problem, so registering rules only ever +/// narrows what a host already gets. /// /// /// A failure that does not carry validation errors is declined (), so other mappers and @@ -28,7 +28,7 @@ namespace Purview.Results.ZodSharp.AspNetCore; /// the status code, title, detail and issues extension. /// /// -public sealed class ZodResultsFailureMapper( +public sealed partial class ZodResultsFailureMapper( IOptions problemOptions, IOptions rules, IOptions results, @@ -72,15 +72,8 @@ IResult Render( ZodErrorRule? rule ) { - if (rule is not null && errors.Length > 1 && logger.IsEnabled(LogLevel.Debug)) - { - logger.LogDebug( - "The validation error rule for '{Rule}' answered a failure carrying {ErrorCount} validation errors with status {StatusCode}.", - rule.Value, - errors.Length, - statusCode - ); - } + if (rule is not null && errors.Length > 1) + Log.RuleAnsweredMultipleErrors(logger, rule.Value, errors.Length, statusCode); return ZodValidationProblems.ToProblem( errors, @@ -89,4 +82,26 @@ IResult Render( _results.IncludeTraceId ? context.HttpContext.TraceIdentifier : null ); } + + /// + /// The log messages this mapper emits, with stable s. + /// + /// + /// Source-generated, so the identifier is fixed and the message allocates nothing when Debug is + /// disabled — which also removes the hand-written IsEnabled guard the call site used to need. + /// + static partial class Log + { + [LoggerMessage( + EventId = 2000, + Level = LogLevel.Debug, + Message = "The validation error rule for '{Rule}' answered a failure carrying {ErrorCount} validation errors with status {StatusCode}." + )] + internal static partial void RuleAnsweredMultipleErrors( + ILogger logger, + string rule, + int errorCount, + int statusCode + ); + } } diff --git a/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs b/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs index 272389f..ef13f20 100644 --- a/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs +++ b/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs @@ -7,32 +7,43 @@ namespace Purview.Results.ZodSharp.AspNetCore; /// /// Configures how a result failure that carries ZodSharp validation errors is rendered, by validation error -/// code and by category. +/// code, by category and by origin. /// /// /// /// Without a rule, a failure that carries validation errors is rendered as one HttpValidationProblemDetails, /// produced by the same mapping a thrown ZodException uses. A rule answers a failure whose errors include -/// its code (or category) with a response of its own, which is what lets one error code be a plain 404 -/// while the rest stay validation problems. +/// its code (or category, or origin) with a response of its own, which is what lets one error code be a plain +/// 404 while the rest stay validation problems. +/// +/// +/// The three axes are the identity a carries: is +/// the specific rule that failed, is a broad grouping many codes share, and +/// is the structured origin a rule owns ("value_object" for a +/// type-level rule, "array" for a collection rule, and so on). A code, a category and an origin can each +/// answer one failure, so a rule that must key off something other than a code — a whole family of rules sharing +/// an origin — is expressible here. /// /// /// Rules are matched against the failure's whole error set, in this order: every code rule in registration -/// order, then every category rule in registration order, then the default validation problem. A rule -/// therefore applies when any of the failure's errors carries its code or category, so registering a code -/// rule guarantees it is reachable whatever else the schema reported. A factory that wants stricter semantics -/// returns to decline the failure, and matching continues. +/// order, then every category rule in registration order, then every origin rule in registration +/// order, then the default validation problem. A rule therefore applies when any of the failure's errors +/// carries its code, category or origin, so registering a code rule guarantees it is reachable whatever else the +/// schema reported. A factory that wants stricter semantics returns to decline the failure, +/// and matching continues. The order is fixed rather than registration-ordered across axes, because a code is +/// narrower than a category, which is narrower than an origin: a rule can only narrow what the host already gets. /// /// /// A factory is handed the failure's full error set, so a rule never hides the other problems the caller has to -/// fix. Register one rule per code or category; registering the same value twice with different behaviour is -/// rejected at configuration time. +/// fix. Register one rule per code, category or origin; registering the same value twice with different behaviour +/// is rejected at configuration time. /// /// public sealed class ZodResultsHttpOptions { readonly List _codeRules = []; readonly List _categoryRules = []; + readonly List _originRules = []; /// /// Renders a validation failure whose errors include as the standard validation @@ -109,11 +120,58 @@ public ZodResultsHttpOptions MapCategory( return AddCategory(category, new(null, map)); } + /// + /// Renders a validation failure whose errors include the as the standard validation + /// problem with a different default status code. + /// + /// + /// The the rule applies to — the origin a rule owns, such as + /// "value_object" for a type-level rule or "array" for a collection rule. + /// + /// + /// The status code used when and the resolved error + /// type do not define one. + /// + /// The options instance for chaining. + /// + /// Thrown when is empty, or already has a rule with different behaviour. + /// + public ZodResultsHttpOptions MapOrigin(string origin, int statusCode) => AddOrigin(origin, new(statusCode, null)); + + /// + /// Renders a validation failure whose errors include the with a response of your own. + /// + /// + /// The the rule applies to — the origin a rule owns, such as + /// "value_object" for a type-level rule or "array" for a collection rule. + /// + /// + /// Creates the response from the failure's validation errors, or returns to decline the + /// failure and let matching continue. + /// + /// The options instance for chaining. + /// + /// Thrown when is empty, or already has a rule with different behaviour. + /// + /// Thrown when is null. + public ZodResultsHttpOptions MapOrigin( + string origin, + Func, HttpContext, IResult?> map + ) + { + ArgumentNullException.ThrowIfNull(map); + + return AddOrigin(origin, new(null, map)); + } + /// /// Finds the rules that could answer a failure, in the order they are consulted. /// /// The validation errors the failure carries. - /// Every matching code rule in registration order, then every matching category rule. + /// + /// Every matching code rule in registration order, then every matching category rule, then every matching + /// origin rule. + /// internal IEnumerable Candidates(ImmutableArray errors) { foreach (var rule in Matches(_codeRules, errors, static error => error.Code)) @@ -121,6 +179,9 @@ internal IEnumerable Candidates(ImmutableArray er foreach (var rule in Matches(_categoryRules, errors, static error => error.Category)) yield return rule; + + foreach (var rule in Matches(_originRules, errors, static error => error.Origin)) + yield return rule; } ZodResultsHttpOptions AddCode(string code, ZodErrorRuleBehaviour behaviour) => @@ -129,6 +190,9 @@ ZodResultsHttpOptions AddCode(string code, ZodErrorRuleBehaviour behaviour) => ZodResultsHttpOptions AddCategory(string category, ZodErrorRuleBehaviour behaviour) => Add(_categoryRules, category, "category", behaviour); + ZodResultsHttpOptions AddOrigin(string origin, ZodErrorRuleBehaviour behaviour) => + Add(_originRules, origin, "origin", behaviour); + ZodResultsHttpOptions Add(List rules, string value, string kind, ZodErrorRuleBehaviour behaviour) { ArgumentException.ThrowIfNullOrWhiteSpace(value); @@ -177,7 +241,7 @@ static IEnumerable Matches( } } -/// How a code or category rule answers the failures that mention it. +/// How a code, category or origin rule answers the failures that mention it. /// /// The default status code of the validation problem, or when the rule supplies a factory. /// @@ -189,7 +253,7 @@ readonly record struct ZodErrorRuleBehaviour( Func, HttpContext, IResult?>? Map ); -/// One registered code or category rule. -/// The error code or category the rule applies to. +/// One registered code, category or origin rule. +/// The error code, category or origin the rule applies to. /// How the rule answers a failure that mentions the value. sealed record ZodErrorRule(string Value, ZodErrorRuleBehaviour Behaviour); diff --git a/src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj b/src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj index e06d752..143f4c7 100644 --- a/src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj +++ b/src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj @@ -6,6 +6,13 @@ results;result;aspnetcore;problemdetails;validation;zod;purview; + + + true + + diff --git a/src/src/ZodSharp.AspNetCore/ZodValidationProblems.cs b/src/src/ZodSharp.AspNetCore/ZodValidationProblems.cs index e7b07d1..553573e 100644 --- a/src/src/ZodSharp.AspNetCore/ZodValidationProblems.cs +++ b/src/src/ZodSharp.AspNetCore/ZodValidationProblems.cs @@ -1,5 +1,7 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using ZodSharp; using ZodSharp.AspNetCore; using ZodSharp.Core; @@ -47,6 +49,31 @@ public static IResult ToProblem( if (!string.IsNullOrEmpty(traceId)) problem.Extensions["traceId"] = traceId; + return WriteProblem(problem, defaultStatusCode); + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "The serialized type is the concrete HttpValidationProblemDetails, not object, and it " + + "is the same type ASP.NET Core's own validation-problem results write, so a trimmed or AOT host " + + "already roots it. TypedResults.Json carries the requirement unconditionally regardless of T." + )] + [UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "The serialized type is the concrete HttpValidationProblemDetails, not object, and it " + + "is the same type ASP.NET Core's own validation-problem results write, so a trimmed or AOT host " + + "already roots it. TypedResults.Json carries the requirement unconditionally regardless of T." + )] + // The concrete return type avoids boxing the result into IResult at every call site. + static JsonHttpResult WriteProblem( + HttpValidationProblemDetails problem, + int defaultStatusCode + ) + { + // Written as JSON rather than through TypedResults.Problem because that serializes the declared + // ProblemDetails shape and would drop the validation `errors` member this type carries. return TypedResults.Json( problem, contentType: "application/problem+json", diff --git a/src/src/ZodSharp/IValidationErrorCarrier.cs b/src/src/ZodSharp/IValidationErrorCarrier.cs index e820e81..e242950 100644 --- a/src/src/ZodSharp/IValidationErrorCarrier.cs +++ b/src/src/ZodSharp/IValidationErrorCarrier.cs @@ -9,7 +9,7 @@ namespace Purview.Results.ZodSharp; /// /// A union case that carries the errors of a rejected input implements this interface, so an HTTP layer can turn /// it into a validation problem without knowing the error type. The errors preserve each -/// 's code, category, path and parameters. +/// 's code, origin, category, path and parameters. /// public interface IValidationErrorCarrier { diff --git a/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md b/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md index 7f15cba..a20490a 100644 --- a/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md +++ b/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-results-zodsharp-validation -description: "Use when ZodSharp validation has to feed Purview.Results — converting ValidationResult with ToResult, carrying ValidationError values on a union case, implementing IValidationErrorCarrier, or deciding what a rejected input becomes in the result pipeline." +description: "Use when ZodSharp validation has to feed Purview.Results — converting ValidationResult with ToResult or the value-discarding ToUnitResult, carrying ValidationError values on a union case, implementing IValidationErrorCarrier, or deciding what a rejected input becomes in the result pipeline." --- # Folding ZodSharp validation into results @@ -51,6 +51,22 @@ return await ReconcileCoreAsync(validated.Value, repositories, cancellationToken This keeps the failure case carrying the errors while the success type stays whatever the method actually produces. +## When the method has no success value + +When the method reports only why an input was rejected, `ToUnitResult` discards the validated value and returns a +value-less `Result`. It still names the validated value type, because a union case does not carry its +union: + +```csharp +Result Validate(TenantInput input) => + TenantInputSchema + .Validate(input) + .ToUnitResult(errors => new TenantInputInvalid(input, errors)); +``` + +Use `ToResult` when the success carries the validated value, `ToUnitResult` when it carries nothing, and the +generated `AsFailure()` when the success carries something else. + ## Carrying the errors ```csharp @@ -61,8 +77,8 @@ public readonly record struct ProviderConnectionInvalid( ``` Implement `IValidationErrorCarrier` on the case (or error) that carries ZodSharp `ValidationError` values. -`ValidationErrors` preserves each error's code, category, path and parameters, so an HTTP layer can render a -validation problem **without knowing the error type** — which is exactly how +`ValidationErrors` preserves each error's code, origin, category, path and parameters, so an HTTP layer can render +a validation problem **without knowing the error type** — which is exactly how `Purview.Results.ZodSharp.AspNetCore` maps it (see the `purview-results-zodsharp-problems` skill). Guidance: @@ -81,7 +97,8 @@ results. Naming the error type explicitly keeps the intent unambiguous. ## Checklist 1. Validation outcomes are mapped, never thrown. -2. The error type is named explicitly (`ToResult(…)`). +2. The error type is named explicitly (`ToResult(…)` or `ToUnitResult(…)`). 3. The case that carries the errors implements `IValidationErrorCarrier`. -4. A method whose success type differs uses `AsFailure()` with the validation errors. +4. A method whose success type differs uses `AsFailure()`, or `AsFailure()` for a unit result, with the + validation errors. 5. The host maps the carrier to a validation problem once (see `purview-results-zodsharp-problems`). diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index fe2c3bc..f2f2ff4 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -46,11 +46,13 @@ return await ReconcileCoreAsync(validated.Value, repositories, cancellationToken | Member | Purpose | | --- | --- | | `ValidationResult.ToResult(Func, TError> onFailure)` | Success carries the validated value; failure carries the created error. `onFailure` runs only when validation failed, so a successful validation allocates no error | +| `ValidationResult.ToUnitResult(Func, TError> onFailure)` | The value-discarding counterpart: success is a unit `Result` and failure carries the created error. Use it when the method reports only why the input was rejected | | `IValidationErrorCarrier` | Implemented by an error value that carries ZodSharp validation errors, so an HTTP layer can turn it into a validation problem without knowing the error type | The error type must be named explicitly when it is a union, as in `ToResult(...)`, because a union case does not carry the -union type that contains it. +union type that contains it. `ToUnitResult` still names the validated value type for the same reason, even though +it discards it. A factory returning a result rather than an error is deliberately **not** offered: for a lambda returning `Result` the compiler prefers a `Func<..., TError>` parameter and would silently nest the @@ -58,16 +60,16 @@ results. Naming the error type explicitly keeps the intent unambiguous. ## Examples -[`src/examples/Examples.Zod`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.Zod) is a +[`src/src/Examples.Zod`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Zod) is a runnable console example that validates a `[ZodSchema] TenantInput` and turns the outcome into a `Result`, with the rejection carrying its reported `ValidationError`s. ```bash -dotnet run --project src/examples/Examples.Zod +dotnet run --project src/src/Examples.Zod ``` -The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ASP.NET Core and -ASP.NET Core + Zod examples too. +The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ASP.NET Core, +ASP.NET Core + Zod and value-objects composition examples too. ## Related packages diff --git a/src/src/ZodSharp/ZodSharp.csproj b/src/src/ZodSharp/ZodSharp.csproj index 775ffed..bcef141 100644 --- a/src/src/ZodSharp/ZodSharp.csproj +++ b/src/src/ZodSharp/ZodSharp.csproj @@ -4,6 +4,14 @@ It stays a leaf library: Results never depends on ZodSharp, and consumers opt in by referencing this package. --> + + + true + + true Purview Results ZodSharp diff --git a/src/src/ZodSharp/ZodValidationResultExtensions.cs b/src/src/ZodSharp/ZodValidationResultExtensions.cs index 09d35dd..a7d55dd 100644 --- a/src/src/ZodSharp/ZodValidationResultExtensions.cs +++ b/src/src/ZodSharp/ZodValidationResultExtensions.cs @@ -80,4 +80,39 @@ Func, TError> onFailure ? Result.Success(validation.Value) : Result.Failure(onFailure(validation.Errors)); } + + /// + /// Converts a validation result into a unit result whose failure carries the error produced by + /// , discarding the validated value. + /// + /// The type of the validated value. + /// + /// The error type of the result. A union type must be named explicitly, as in + /// ToUnitResult<TenantInput, TenantError>(...), because a union case does not carry the union + /// type that contains it. + /// + /// The validation result to convert. + /// + /// Creates the error from the validation errors. Only invoked when validation failed, so a successful + /// validation allocates no error. + /// + /// + /// A successful unit result, or a failed unit result carrying the created error. + /// + /// + /// Reach for this when the surrounding method validates a value but succeeds with nothing — a command that + /// accepts an input and reports only why it was rejected. The validated value is deliberately dropped, which + /// is why the value type still has to be named; use when the success + /// has to carry it. + /// + /// Thrown when is null. + public static Result ToUnitResult( + this ValidationResult validation, + Func, TError> onFailure + ) + { + ArgumentNullException.ThrowIfNull(onFailure); + + return validation.IsSuccess ? Result.Success() : Result.Failure(onFailure(validation.Errors)); + } } diff --git a/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj b/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj index 16fc8d4..3bfa98e 100644 --- a/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj +++ b/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj @@ -1,4 +1,13 @@ + + + + net11.0 + diff --git a/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs b/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs index 70f0ed9..c4b9729 100644 --- a/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs +++ b/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs @@ -39,6 +39,66 @@ public async Task Map_GivenSuccessfulValueThatIsAResponse_ReturnsItUnchanged() await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status204NoContent); } + [Test] + public async Task Map_GivenSuccessfulUnitResult_ReturnsNoContent() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(Result.Success(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status204NoContent); + await Assert.That(response.Body).IsEqualTo(string.Empty); + } + + [Test] + public async Task Map_GivenSuccessfulUnitResultAndSuccessMapper_ReturnsTheMapperResponse() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.SuccessMapper = (_, _) => TypedResults.Ok("overridden") + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(Result.Success(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status200OK); + await Assert.That(response.Body).Contains("overridden"); + } + + [Test] + public async Task Map_GivenUnitFailure_ReturnsTheMappedResponse() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.Map(notFound => + TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, title: $"Item {notFound.ItemId}.") + ) + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(new ItemNotFound(7).AsFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status404NotFound); + await Assert.That(response.Body).Contains("Item 7."); + } + [Test] public async Task Map_GivenMappedCase_ReturnsTheMappedResponse() { @@ -168,4 +228,161 @@ public async Task Map_GivenMappingAndUnusedFallback_ReturnsTheMappedResponse() await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status404NotFound); await Assert.That(fallbackInvocations).IsEqualTo(0); } + + [Test] + public async Task Map_GivenNestedUnionFailure_ReturnsTheLeafCaseMapping() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.Map(unavailable => + TypedResults.Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: unavailable.Reason) + ) + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status503ServiceUnavailable); + await Assert.That(response.Body).Contains("down"); + } + + [Test] + public async Task Map_GivenNestedUnionFailure_LeafMappingBeatsTheNestedUnionAndErrorMappings() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + { + options.Map(_ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)); + options.Map(_ => TypedResults.Problem(statusCode: StatusCodes.Status502BadGateway)); + options.Map(_ => + TypedResults.Problem(statusCode: StatusCodes.Status503ServiceUnavailable) + ); + }); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status503ServiceUnavailable); + } + + [Test] + public async Task Map_GivenNestedUnionFailureWithoutLeafMapping_ReturnsTheNestedUnionMapping() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + { + options.Map(_ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)); + options.Map(_ => TypedResults.Problem(statusCode: StatusCodes.Status502BadGateway)); + }); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status502BadGateway); + } + + [Test] + public async Task Map_GivenNestedUnionFailureWithoutInnerMapping_ReturnsTheErrorTypeMapping() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.Map(_ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)) + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status409Conflict); + } + + [Test] + public async Task Map_GivenNestedUnionFailure_ReturnsTheFallbackResponseForTheLeafCase() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.AddFallback( + (error, _) => + error is ItemBillingUnavailable unavailable + ? TypedResults.Problem( + statusCode: StatusCodes.Status422UnprocessableEntity, + title: unavailable.Reason + ) + : null + ) + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(response.Body).Contains("down"); + } + + [Test] + public async Task Map_GivenUnmappedNestedUnionFailure_NamesTheLeafCase() + { + // Arrange — naming the error type in the response is opt-in, because it discloses internal + // namespace topology to the caller. Enabled here to assert the resolution picks the leaf case. + var mapper = ResultsHttpTestFactory.CreateMapper(static options => + options.IncludeErrorTypeInProblemDetails = true + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status500InternalServerError); + await Assert.That(response.Body).Contains(typeof(ItemBillingUnavailable).FullName!); + } + + [Test] + public async Task Map_GivenUnmappedFailure_DoesNotNameTheErrorTypeByDefault() + { + // Arrange — the default must not leak the error's CLR type name to the caller. + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status500InternalServerError); + await Assert.That(response.Body).DoesNotContain(typeof(ItemBillingUnavailable).FullName!); + await Assert.That(response.Body).DoesNotContain("errorType"); + } + + static Result CreateNestedBillingFailure() => + ((HttpTestBillingError)new ItemBillingUnavailable("down")).AsFailure(); } diff --git a/src/tests/AspNetCore.UnitTests/ResultsEndpointFilterTests.cs b/src/tests/AspNetCore.UnitTests/ResultsEndpointFilterTests.cs index 1dab557..507d49d 100644 --- a/src/tests/AspNetCore.UnitTests/ResultsEndpointFilterTests.cs +++ b/src/tests/AspNetCore.UnitTests/ResultsEndpointFilterTests.cs @@ -73,7 +73,12 @@ public async Task Endpoint_GivenUnmappedFailure_ReturnsAnInternalServerErrorProb // Assert await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.InternalServerError); - await Assert.That(await response.Content.ReadAsStringAsync()).Contains(nameof(ItemRejected)); + // The unmapped-failure response is identified by its title, not by the error's CLR type name: + // naming the type is opt-in because it discloses internal type names to the caller. + var body = await response.Content.ReadAsStringAsync(); + + await Assert.That(body).Contains("is not mapped to an HTTP response"); + await Assert.That(body).DoesNotContain(nameof(ItemRejected)); } [Test] diff --git a/src/tests/AspNetCore.UnitTests/ResultsHttpFailuresTests.cs b/src/tests/AspNetCore.UnitTests/ResultsHttpFailuresTests.cs index 06c435f..5a66c8e 100644 --- a/src/tests/AspNetCore.UnitTests/ResultsHttpFailuresTests.cs +++ b/src/tests/AspNetCore.UnitTests/ResultsHttpFailuresTests.cs @@ -7,8 +7,10 @@ public sealed class ResultsHttpFailuresTests [Test] public async Task Map_GivenUnmappedCase_ReturnsAnInternalServerErrorProblem() { - // Arrange - var mapper = ResultsHttpTestFactory.CreateMapper(); + // Arrange — the error type is opt-in because it discloses internal type names to the caller. + var mapper = ResultsHttpTestFactory.CreateMapper(static options => + options.IncludeErrorTypeInProblemDetails = true + ); var context = ResultsHttpTestFactory.CreateContext(); // Act diff --git a/src/tests/AspNetCore.UnitTests/ResultsHttpOptionsSafetyTests.cs b/src/tests/AspNetCore.UnitTests/ResultsHttpOptionsSafetyTests.cs new file mode 100644 index 0000000..f2e889a --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ResultsHttpOptionsSafetyTests.cs @@ -0,0 +1,63 @@ +namespace Purview.Results.AspNetCore; + +/// +/// Covers the information-disclosure and shared-state behaviour of . +/// +public class ResultsHttpOptionsSafetyTests +{ + [Test] + public async Task IncludeErrorTypeInProblemDetails_DefaultsToOff() + { + // The unmapped-failure path fires when something unexpected happened, and the extension would carry + // the error's full CLR type name to whoever made the request. That has to be opt-in. + ResultsHttpOptions options = new(); + + await Assert.That(options.IncludeErrorTypeInProblemDetails).IsFalse(); + } + + [Test] + public async Task ThrowOnUnmappedFailure_DefaultsToOff() + { + ResultsHttpOptions options = new(); + + await Assert.That(options.ThrowOnUnmappedFailure).IsFalse(); + } + + [Test] + public async Task Fallbacks_OnceRead_IgnoresLaterAdditions() + { + // Arrange — the options are startup configuration and the mapper reading them is a singleton, so + // the list is frozen on first read rather than mutated while request threads enumerate it. + ResultsHttpOptions options = new(); + options.AddFallback(static (_, _) => null); + + // Act + var firstRead = options.Fallbacks; + options.AddFallback(static (_, _) => null); + var secondRead = options.Fallbacks; + + // Assert + await Assert.That(firstRead.Count).IsEqualTo(1); + await Assert.That(secondRead.Count).IsEqualTo(1); + await Assert.That(ReferenceEquals(firstRead, secondRead)).IsTrue(); + } + + [Test] + public async Task Fallbacks_ReadConcurrently_IsStable() + { + // Arrange + ResultsHttpOptions options = new(); + options.AddFallback(static (_, _) => null); + options.AddFallback(static (_, _) => null); + + // Act — enumerate from many threads at once. Against the previous plain List this is the shape that + // produced "Collection was modified" when a fallback was added after startup. + var counts = await Task.WhenAll( + Enumerable.Range(0, 64).Select(_ => Task.Run(() => options.Fallbacks.Count(static f => f is not null))) + ); + + // Assert + await Assert.That(counts.Distinct().Count()).IsEqualTo(1); + await Assert.That(counts[0]).IsEqualTo(2); + } +} diff --git a/src/tests/AspNetCore.UnitTests/ResultsHttpTelemetryTests.cs b/src/tests/AspNetCore.UnitTests/ResultsHttpTelemetryTests.cs new file mode 100644 index 0000000..9d141b0 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ResultsHttpTelemetryTests.cs @@ -0,0 +1,152 @@ +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Http; + +namespace Purview.Results.AspNetCore; + +/// +/// Covers the metrics the HTTP mapper emits. +/// +/// +/// The design treats an unmapped failure as a host mapping gap rather than a domain outcome, which is only +/// actionable if it can be counted. Previously the only signal was a log message, so answering "how often, +/// and for which error" meant scraping log text. +/// +public class ResultsHttpTelemetryTests +{ + [Test] + public async Task Map_GivenUnmappedFailure_CountsIt() + { + // Arrange + using MeterListener listener = new(); + var measurements = Listen(listener, "purview.results.failures.unmapped"); + + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + _ = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(new ItemNotFound(7).AsFailure(), context), + context + ); + + // Assert — filtered by the error-type tag rather than counted outright. The Meter is process-static + // and other tests in this assembly exercise the same counters concurrently, so an exact total would + // be flaky. + var mine = ForErrorType(measurements, typeof(ItemNotFound)); + + await Assert.That(mine.Count).IsGreaterThanOrEqualTo(1); + await Assert.That(mine[0].Value).IsEqualTo(1L); + } + + [Test] + public async Task Map_GivenUnmappedFailure_TagsTheMeasurementWithTheErrorType() + { + // Arrange — the type name is the dimension that makes the count actionable. Metrics stay inside the + // host's own infrastructure, so unlike the HTTP response it is always included. + using MeterListener listener = new(); + var measurements = Listen(listener, "purview.results.failures.unmapped"); + + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + _ = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(new ItemNotFound(7).AsFailure(), context), + context + ); + + // Assert + var tag = measurements[0].Tags.Single(static pair => pair.Key == ResultsHttpTelemetry.ErrorTypeTag); + + await Assert.That(tag.Value).IsEqualTo(typeof(ItemNotFound).FullName); + } + + [Test] + public async Task Map_GivenMappedFailure_CountsItAsMappedNotUnmapped() + { + // Arrange + using MeterListener listener = new(); + // One Listen per listener: it registers the callback and starts the listener, so calling it twice + // overwrites the first subscription. + var mapped = Listen(listener, "purview.results.failures.mapped"); + + var mapper = ResultsHttpTestFactory.CreateMapper(static options => + options.Map(static _ => TypedResults.Problem(statusCode: StatusCodes.Status404NotFound)) + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(new ItemNotFound(7).AsFailure(), context), + context + ); + + // Assert — the 404 proves the mapped path ran, and the mapped counter recorded it against this + // error type. The unmapped counter is not asserted here: the counters are process-static and a + // sibling test deliberately produces an unmapped ItemNotFound, so it cannot be isolated. + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status404NotFound); + await Assert.That(ForErrorType(mapped, typeof(ItemNotFound)).Count).IsGreaterThanOrEqualTo(1); + } + + [Test] + public async Task Map_GivenUninitializedResult_CountsIt() + { + // Arrange — an endpoint returning default is always a bug, so it is counted separately. + using MeterListener listener = new(); + var measurements = Listen(listener, "purview.results.uninitialized"); + + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + _ = await ResultsHttpTestFactory.ExecuteAsync(mapper.Map(default(Result), context), context); + + // Assert — the uninitialized counter carries no error type, so this one is only tolerant of extras. + await Assert.That(measurements.Count).IsGreaterThanOrEqualTo(1); + } + + [Test] + public async Task MeterName_IsStable() + { + // Consumers register this name with their metrics pipeline, so it is public contract. + await Assert.That(ResultsHttpTelemetry.MeterName).IsEqualTo("Purview.Results.AspNetCore"); + await Assert.That(ResultsHttpTelemetry.ErrorTypeTag).IsEqualTo("purview.results.error_type"); + } + + /// + /// The measurements tagged with . + /// + static List<(long Value, KeyValuePair[] Tags)> ForErrorType( + List<(long Value, KeyValuePair[] Tags)> measurements, + Type errorType + ) => + [ + .. measurements.Where(measurement => + measurement.Tags.Any(tag => + tag.Key == ResultsHttpTelemetry.ErrorTypeTag && (string?)tag.Value == errorType.FullName + ) + ), + ]; + + /// + /// Subscribes to one instrument by name and collects its measurements. + /// + static List<(long Value, KeyValuePair[] Tags)> Listen(MeterListener listener, string instrument) + { + List<(long, KeyValuePair[])> measurements = []; + + listener.InstrumentPublished += (published, active) => + { + if (published.Meter.Name == ResultsHttpTelemetry.MeterName && published.Name == instrument) + active.EnableMeasurementEvents(published); + }; + + listener.SetMeasurementEventCallback( + (_, measurement, tags, _) => measurements.Add((measurement, tags.ToArray())) + ); + + listener.Start(); + + return measurements; + } +} diff --git a/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs b/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs index 7a4ac60..b5e291d 100644 --- a/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs +++ b/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs @@ -1,5 +1,3 @@ -using Purview.Results.SourceGeneration; - namespace Purview.Results.AspNetCore; // CA1815 is answered by the package's suppressor for every union opted in with [GenerateResult]. @@ -11,3 +9,15 @@ namespace Purview.Results.AspNetCore; public readonly record struct ItemConflict(int ItemId, string Reason); public readonly record struct ItemRejected(int ItemId); + +public readonly record struct ItemBillingMissing(int ItemId); + +public readonly record struct ItemBillingUnavailable(string Reason); + +// A downstream service's error union, nested inside the outer operation-family union so the mapper has to +// unwrap two levels to reach the leaf. +[GenerateResult] +public readonly union HttpTestBillingError(ItemBillingMissing, ItemBillingUnavailable); + +[GenerateResult] +public readonly union HttpTestOuterError(HttpTestError, HttpTestBillingError); diff --git a/src/tests/Results.UnitTests/AcceptSuccessTests.cs b/src/tests/Results.UnitTests/AcceptSuccessTests.cs new file mode 100644 index 0000000..4f1efe0 --- /dev/null +++ b/src/tests/Results.UnitTests/AcceptSuccessTests.cs @@ -0,0 +1,113 @@ +namespace Purview.Results; + +/// +/// Pins the double-dispatch contract of . +/// +/// +/// erases the value to . Infrastructure that has +/// to act on the value generically — serializing it — would then bind its generic parameter to +/// , leaving a trimmed or Native AOT host with no type to resolve, and in ASP.NET Core +/// erasing the response type OpenAPI infers. These tests assert the real type arrives instead. +/// +public class AcceptSuccessTests +{ + [Test] + public async Task AcceptSuccess_GivenValueResult_VisitsWithTheDeclaredValueType() + { + // Arrange + IResultValue result = Result.Success(42); + RecordingVisitor visitor = new(); + + // Act + var visitedType = result.AcceptSuccess(visitor, 0); + + // Assert — int, not object. This is the whole point. + await Assert.That(visitedType).IsEqualTo(typeof(int)); + await Assert.That(visitor.CapturedValue).IsEqualTo(42); + } + + [Test] + public async Task AcceptSuccess_GivenReferenceTypeValue_VisitsWithTheDeclaredValueType() + { + // Arrange — a reference type must not widen to object either. + IResultValue result = Result.Success("acme"); + RecordingVisitor visitor = new(); + + // Act + var visitedType = result.AcceptSuccess(visitor, 0); + + // Assert + await Assert.That(visitedType).IsEqualTo(typeof(string)); + await Assert.That(visitor.CapturedValue).IsEqualTo("acme"); + } + + [Test] + public async Task AcceptSuccess_GivenUnitResult_VisitsWithTheSuccessMarker() + { + // Arrange — the unit result's value is the marker, which is how infrastructure tells a payload-free + // success from one carrying a value. + IResultValue result = Result.Success(); + RecordingVisitor visitor = new(); + + // Act + var visitedType = result.AcceptSuccess(visitor, 0); + + // Assert + await Assert.That(visitedType).IsEqualTo(typeof(Success)); + } + + [Test] + public async Task AcceptSuccess_PassesStateThroughWithoutCapturing() + { + // Arrange + IResultValue result = Result.Success(7); + RecordingVisitor visitor = new(); + + // Act + _ = result.AcceptSuccess(visitor, 99); + + // Assert + await Assert.That(visitor.CapturedState).IsEqualTo(99); + } + + [Test] + public async Task AcceptSuccess_GivenNullVisitor_Throws() + { + IResultValue valueResult = Result.Success(1); + IResultValue unitResult = Result.Success(); + + await Assert.That(() => valueResult.AcceptSuccess(null!, 0)).Throws(); + await Assert.That(() => unitResult.AcceptSuccess(null!, 0)).Throws(); + } + + [Test] + public async Task AcceptSuccess_GivenFailure_DoesNotThrow() + { + // Arrange — like every other IResultValue member this must never throw, so infrastructure can call it + // before branching on IsSuccess. + IResultValue result = Result.Failure("nope"); + RecordingVisitor visitor = new(); + + // Act + var visitedType = result.AcceptSuccess(visitor, 0); + + // Assert + await Assert.That(visitedType).IsEqualTo(typeof(int)); + await Assert.That(visitor.CapturedValue).IsEqualTo(0); + } + + sealed class RecordingVisitor : IResultValueVisitor + { + public object? CapturedValue { get; private set; } + + public int CapturedState { get; private set; } + + public Type VisitSuccess(TValue value, int state) + { + CapturedValue = value; + CapturedState = state; + + return typeof(TValue); + } + } +} diff --git a/src/tests/Results.UnitTests/ResultExtensionsTests.cs b/src/tests/Results.UnitTests/ResultExtensionsTests.cs index 8063282..fb4601b 100644 --- a/src/tests/Results.UnitTests/ResultExtensionsTests.cs +++ b/src/tests/Results.UnitTests/ResultExtensionsTests.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace Purview.Results; public sealed class ResultExtensionsTests @@ -469,8 +471,639 @@ public async Task BindAsync_WhenBindIsNull_ShouldThrow() #endregion + #region Error-widening composition + + [Test] + public async Task Bind_WhenSuccessAndBindingSucceeds_ShouldReturnBoundSuccess() + { + var result = Successful("123"); + + var actual = result.Bind( + value => Result.Success(int.Parse(value, CultureInfo.InvariantCulture)), + error => new OtherTestError(error.Message.Length) + ); + + await Assert.That(actual.IsSuccess).IsTrue(); + await Assert.That(actual.Value).IsEqualTo(123); + } + + [Test] + public async Task Bind_WhenSuccessAndBindingSucceeds_ShouldNotInvokeMapError() + { + var result = Successful(); + var invoked = false; + + result.Bind( + _ => Result.Success(0), + _ => + { + invoked = true; + + return new OtherTestError(0); + } + ); + + await Assert.That(invoked).IsFalse(); + } + + [Test] + public async Task Bind_WhenSuccessAndBindingFails_ShouldReturnBoundFailure() + { + var result = Successful(); + OtherTestError error = new(42); + + var actual = result.Bind(_ => Result.Failure(error), _ => new OtherTestError(0)); + + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(error); + } + + [Test] + public async Task Bind_WhenFailure_ShouldMapErrorAndNotInvokeBinding() + { + var result = Failed(); + var invoked = false; + + var actual = result.Bind( + value => + { + invoked = true; + + return Result.Success(value.Length); + }, + error => new OtherTestError(error.Message.Length) + ); + + await Assert.That(invoked).IsFalse(); + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new OtherTestError(Error.Length)); + } + + [Test] + public async Task Bind_WhenFailure_ShouldInvokeMapErrorOnce() + { + var result = Failed(); + var invoked = 0; + + result.Bind( + _ => Result.Success(0), + _ => + { + invoked++; + + return new OtherTestError(0); + } + ); + + await Assert.That(invoked).IsEqualTo(1); + } + + [Test] + public async Task Bind_WhenUninitialized_ShouldThrow() + { + Result result = default; + + Result Act() => + result.Bind(value => Result.Success(value.Length), _ => new OtherTestError(0)); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + [Test] + public async Task Bind_WhenBindIsNull_ShouldThrow() + { + var result = Successful(); + + Result Act() => + result.Bind((Func>)null!, _ => new OtherTestError(0)); + + await Assert.That(Act).ThrowsExactly(); + } + + [Test] + public async Task Bind_WhenMapErrorIsNull_ShouldThrow() + { + var result = Successful(); + + Result Act() => result.Bind(_ => Result.Success(0), null!); + + await Assert.That(Act).ThrowsExactly(); + } + + [Test] + public async Task BindAsyncWithMapError_WhenSuccessAndBindingSucceeds_ShouldReturnBoundSuccess() + { + var result = Successful("123"); + + var actual = await result.BindAsync( + value => + Task.FromResult(Result.Success(int.Parse(value, CultureInfo.InvariantCulture))), + error => new OtherTestError(error.Message.Length) + ); + + await Assert.That(actual.IsSuccess).IsTrue(); + await Assert.That(actual.Value).IsEqualTo(123); + } + + [Test] + public async Task BindAsync_WhenSuccessAndBindingSucceeds_ShouldNotInvokeMapError() + { + var result = Successful(); + var invoked = false; + + await result.BindAsync( + _ => Task.FromResult(Result.Success(0)), + _ => + { + invoked = true; + + return new OtherTestError(0); + } + ); + + await Assert.That(invoked).IsFalse(); + } + + [Test] + public async Task BindAsync_WhenFailure_ShouldMapErrorAndNotInvokeBinding() + { + var result = Failed(); + var invoked = false; + + var actual = await result.BindAsync( + value => + { + invoked = true; + + return Task.FromResult(Result.Success(value.Length)); + }, + error => new OtherTestError(error.Message.Length) + ); + + await Assert.That(invoked).IsFalse(); + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new OtherTestError(Error.Length)); + } + + [Test] + public async Task BindAsync_WhenUninitialized_ShouldThrow() + { + Result result = default; + + async Task Act() => + await result.BindAsync( + value => Task.FromResult(Result.Success(value.Length)), + _ => new OtherTestError(0) + ); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + [Test] + public async Task BindAsyncWithMapError_WhenBindIsNull_ShouldThrow() + { + var result = Successful(); + + async Task Act() => + await result.BindAsync((Func>>)null!, _ => new OtherTestError(0)); + + await Assert.That(Act).ThrowsExactly(); + } + + [Test] + public async Task BindAsync_WhenMapErrorIsNull_ShouldThrow() + { + var result = Successful(); + + async Task Act() => await result.BindAsync(_ => Task.FromResult(Result.Success(0)), null!); + + await Assert.That(Act).ThrowsExactly(); + } + + #endregion + + #region Throwing + + [Test] + public async Task Throw_WhenSuccess_ShouldReturnValue() + { + var result = Successful(); + + await Assert.That(result.Throw()).IsEqualTo(Value); + } + + [Test] + public async Task Throw_WhenFailure_ShouldThrowResultException() + { + var result = Failed(); + + string Act() => result.Throw(); + + await Assert.That(Act).ThrowsExactly>(); + } + + [Test] + public async Task Throw_WhenFailure_ShouldExposeTheError() + { + var result = Failed(); + + var exception = ThrowAndCapture(result); + + await Assert.That(exception.Error).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task Throw_WhenFailure_ShouldIncludeTheErrorInTheMessage() + { + var result = Failed(); + + var exception = ThrowAndCapture(result); + + await Assert.That(exception.Message).IsEqualTo($"The result represents a failure: {Error}"); + } + + [Test] + public async Task Throw_WhenFailure_ShouldBeCatchableAsTheNonGenericBase() + { + var result = Failed(); + var caught = false; + + try + { + result.Throw(); + } + catch (ResultException exception) + { + caught = exception.Error is TestError; + } + + await Assert.That(caught).IsTrue(); + } + + [Test] + public async Task Throw_WhenUninitialized_ShouldThrowInvalidOperationException() + { + Result result = default; + + string Act() => result.Throw(); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + #endregion + + #region Unit result extensions + + static Result UnitSuccessful() => Result.Success(); + + static Result UnitFailed() => Result.Failure(new(Error)); + + [Test] + public async Task TryGetError_WhenUnitFailure_ShouldReturnTrueAndError() + { + var result = UnitFailed(); + + var found = result.TryGetError(out var error); + + await Assert.That(found).IsTrue(); + await Assert.That(error).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task TryGetError_WhenUnitSuccess_ShouldReturnFalse() + { + var result = UnitSuccessful(); + + var found = result.TryGetError(out var error); + + await Assert.That(found).IsFalse(); + await Assert.That(error).IsEqualTo(default); + } + + [Test] + public async Task TryGetError_WhenUnitUninitialized_ShouldReturnFalse() + { + Result result = default; + + var found = result.TryGetError(out var error); + + await Assert.That(found).IsFalse(); + await Assert.That(error).IsEqualTo(default); + } + + [Test] + public async Task GetErrorOrDefault_WhenUnitFailure_ShouldReturnError() + { + var result = UnitFailed(); + + await Assert.That(result.GetErrorOrDefault(new TestError("fallback"))).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task GetErrorOrDefault_WhenUnitSuccess_ShouldReturnFallback() + { + var result = UnitSuccessful(); + + await Assert.That(result.GetErrorOrDefault(new TestError("fallback"))).IsEqualTo(new TestError("fallback")); + } + + [Test] + public async Task OrElse_WhenUnitFailure_ShouldReturnFallback() + { + var result = UnitFailed(); + + var actual = result.OrElse(UnitSuccessful()); + + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task OrElse_WhenUnitSuccess_ShouldReturnOriginal() + { + var result = UnitSuccessful(); + + var actual = result.OrElse(UnitFailed()); + + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task Switch_WhenUnitSuccess_ShouldInvokeSuccessOnly() + { + var result = UnitSuccessful(); + var successInvoked = false; + var failureInvoked = false; + + result.Switch(() => successInvoked = true, _ => failureInvoked = true); + + await Assert.That(successInvoked).IsTrue(); + await Assert.That(failureInvoked).IsFalse(); + } + + [Test] + public async Task Switch_WhenUnitFailure_ShouldInvokeFailureOnly() + { + var result = UnitFailed(); + var successInvoked = false; + var failureInvoked = false; + + result.Switch(() => successInvoked = true, _ => failureInvoked = true); + + await Assert.That(successInvoked).IsFalse(); + await Assert.That(failureInvoked).IsTrue(); + } + + [Test] + public async Task Tap_WhenUnitSuccess_ShouldInvokeAndReturnResult() + { + var result = UnitSuccessful(); + var invoked = false; + + var actual = result.Tap(() => invoked = true); + + await Assert.That(invoked).IsTrue(); + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task TapError_WhenUnitFailure_ShouldInvokeAndReturnResult() + { + var result = UnitFailed(); + TestError? observed = null; + + var actual = result.TapError(error => observed = error); + + await Assert.That(observed).IsEqualTo(new TestError(Error)); + await Assert.That(actual.IsFailure).IsTrue(); + } + + [Test] + public async Task Ensure_WhenUnitPredicateFails_ShouldReturnFailureWithCreatedError() + { + var result = UnitSuccessful(); + + var actual = result.Ensure(() => false, () => new TestError("constraint failed")); + + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new TestError("constraint failed")); + } + + [Test] + public async Task Ensure_WhenUnitPredicateSucceeds_ShouldReturnOriginalSuccess() + { + var result = UnitSuccessful(); + + var actual = result.Ensure(() => true, () => new TestError("constraint failed")); + + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task MapAsync_WhenUnitSuccess_ShouldProduceValue() + { + var result = UnitSuccessful(); + + var actual = await result.MapAsync(() => Task.FromResult(Value.Length)); + + await Assert.That(actual.IsSuccess).IsTrue(); + await Assert.That(actual.Value).IsEqualTo(Value.Length); + } + + [Test] + public async Task MapAsync_WhenUnitFailure_ShouldPreserveError() + { + var result = UnitFailed(); + + var actual = await result.MapAsync(() => Task.FromResult(Value.Length)); + + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task BindAsync_WhenUnitSuccess_ShouldReturnBoundUnitSuccess() + { + var result = UnitSuccessful(); + + var actual = await result.BindAsync(() => Task.FromResult(UnitSuccessful())); + + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task BindAsyncToValue_WhenUnitSuccess_ShouldReturnBoundValueResult() + { + var result = UnitSuccessful(); + + var actual = await result.BindAsync(() => Task.FromResult(Result.Success(123))); + + await Assert.That(actual.IsSuccess).IsTrue(); + await Assert.That(actual.Value).IsEqualTo(123); + } + + [Test] + public async Task BindAsync_WhenUnitFailure_ShouldPreserveErrorAndNotInvokeBinding() + { + var result = UnitFailed(); + var invoked = false; + + var actual = await result.BindAsync(() => + { + invoked = true; + + return Task.FromResult(UnitSuccessful()); + }); + + await Assert.That(invoked).IsFalse(); + await Assert.That(actual.Error).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task Bind_WhenUnitSuccessAndBindingSucceeds_ShouldWidenAndReturnBoundUnitSuccess() + { + var result = UnitSuccessful(); + + var actual = result.Bind(Result.Success, error => new OtherTestError(error.Message.Length)); + + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task BindToValue_WhenUnitFailure_ShouldMapError() + { + var result = UnitFailed(); + + var actual = result.Bind( + () => Result.Success(123), + error => new OtherTestError(error.Message.Length) + ); + + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new OtherTestError(Error.Length)); + } + + [Test] + public async Task BindAsyncWithMapError_WhenUnitFailure_ShouldMapError() + { + var result = UnitFailed(); + + var actual = await result.BindAsync( + () => Task.FromResult(Result.Success()), + error => new OtherTestError(error.Message.Length) + ); + + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new OtherTestError(Error.Length)); + } + + [Test] + public async Task Throw_WhenUnitSuccess_ShouldReturnTheSuccessMarker() + { + var result = UnitSuccessful(); + + await Assert.That(result.Throw()).IsEqualTo(Success.Instance); + } + + [Test] + public async Task Throw_WhenUnitFailure_ShouldThrowResultException() + { + var result = UnitFailed(); + + var exception = ThrowUnitAndCapture(result); + + await Assert.That(exception.Error).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task Throw_WhenUnitUninitialized_ShouldThrowInvalidOperationException() + { + Result result = default; + + Success Act() => result.Throw(); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + [Test] + public async Task DiscardValue_WhenValueSuccess_ShouldReturnUnitSuccess() + { + var result = Successful(); + + var actual = result.DiscardValue(); + + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task DiscardValue_WhenValueFailure_ShouldPreserveError() + { + var result = Failed(); + + var actual = result.DiscardValue(); + + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task DiscardValue_WhenValueUninitialized_ShouldThrow() + { + Result result = default; + + Result Act() => result.DiscardValue(); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + #endregion + + static ResultException ThrowAndCapture(Result result) + { + try + { + result.Throw(); + } + catch (ResultException exception) + { + return exception; + } + + throw new InvalidOperationException("The result did not throw."); + } + + static ResultException ThrowUnitAndCapture(Result result) + { + try + { + result.Throw(); + } + catch (ResultException exception) + { + return exception; + } + + throw new InvalidOperationException("The result did not throw."); + } + readonly record struct TestError(string Message) { public override string ToString() => Message; } + + readonly record struct OtherTestError(int Code); } diff --git a/src/tests/Results.UnitTests/ResultTErrorTests.cs b/src/tests/Results.UnitTests/ResultTErrorTests.cs new file mode 100644 index 0000000..7b4fff9 --- /dev/null +++ b/src/tests/Results.UnitTests/ResultTErrorTests.cs @@ -0,0 +1,356 @@ +namespace Purview.Results; + +public sealed class ResultTErrorTests +{ + const string Error = "error"; + + #region State + + [Test] + public async Task DefaultResult_ShouldBeUninitialized() + { + Result result = default; + + await Assert.That(result.IsInitialized).IsFalse(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsFalse(); + } + + [Test] + public async Task Success_ShouldHaveExpectedState() + { + var result = Result.Success(); + + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.IsFailure).IsFalse(); + } + + [Test] + public async Task Failure_ShouldHaveExpectedState() + { + TestError error = new(Error); + + var result = Result.Failure(error); + + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(error); + } + + [Test] + public async Task Error_WhenSuccess_ShouldThrow() + { + var result = Result.Success(); + + TestError Act() => result.Error; + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The error of a non-failed result cannot be accessed.", StringComparison.Ordinal); + } + + [Test] + public async Task Error_WhenUninitialized_ShouldThrow() + { + Result result = default; + + TestError Act() => result.Error; + + await Assert.That(Act).ThrowsExactly(); + } + + #endregion + + #region Factories and conversions + + [Test] + public async Task ResultSuccessFactory_ShouldCreateSuccess() + { + var result = Result.Success(); + + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task ResultFailureFactory_ShouldCreateFailure() + { + TestError error = new(Error); + + var result = Result.Failure(error); + + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(error); + } + + [Test] + public async Task ImplicitSuccessConversion_ShouldCreateSuccess() + { + Result result = Success.Instance; + + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task ImplicitErrorConversion_ShouldCreateFailure() + { + TestError error = new(Error); + + Result result = error; + + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(error); + } + + #endregion + + #region IResultValue + + [Test] + public async Task IResultValue_WhenSuccess_ShouldExposeTheSuccessMarker() + { + IResultValue result = Result.Success(); + + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.SuccessValue).IsEqualTo(Success.Instance); + await Assert.That(result.ErrorValue).IsNull(); + } + + [Test] + public async Task IResultValue_WhenFailure_ShouldExposeTheError() + { + TestError error = new(Error); + IResultValue result = Result.Failure(error); + + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.SuccessValue).IsNull(); + await Assert.That(result.ErrorValue).IsEqualTo(error); + } + + [Test] + public async Task IResultValue_WhenUninitialized_ShouldExposeNeitherState() + { + IResultValue result = default(Result); + + await Assert.That(result.IsInitialized).IsFalse(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.SuccessValue).IsNull(); + await Assert.That(result.ErrorValue).IsNull(); + } + + #endregion + + #region ToString + + [Test] + public async Task ToString_WhenSuccess_ShouldDescribeSuccess() + { + var result = Result.Success(); + + await Assert.That(result.ToString()).IsEqualTo("Success"); + } + + [Test] + public async Task ToString_WhenFailure_ShouldDescribeFailure() + { + var result = Result.Failure(new(Error)); + + await Assert.That(result.ToString()).IsEqualTo("Failure(error)"); + } + + [Test] + public async Task ToString_WhenUninitialized_ShouldDescribeUninitialized() + { + Result result = default; + + await Assert.That(result.ToString()).IsEqualTo("Uninitialized"); + } + + #endregion + + #region Match + + [Test] + public async Task Match_WhenSuccess_ShouldInvokeSuccess() + { + var result = Result.Success(); + + var matched = result.Match(success: () => "success", failure: error => $"failure:{error}"); + + await Assert.That(matched).IsEqualTo("success"); + } + + [Test] + public async Task Match_WhenFailure_ShouldInvokeFailure() + { + var result = Result.Failure(new(Error)); + + var matched = result.Match(success: () => "success", failure: error => $"failure:{error}"); + + await Assert.That(matched).IsEqualTo("failure:error"); + } + + [Test] + public async Task Match_WhenUninitialized_ShouldThrow() + { + Result result = default; + + string Act() => result.Match(success: () => "success", failure: error => error.Message); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + #endregion + + #region Map + + [Test] + public async Task Map_WhenSuccess_ShouldProduceValue() + { + var result = Result.Success(); + + var mapped = result.Map(() => 123); + + await Assert.That(mapped.IsSuccess).IsTrue(); + await Assert.That(mapped.Value).IsEqualTo(123); + } + + [Test] + public async Task Map_WhenFailure_ShouldPreserveErrorAndNotInvokeMapping() + { + TestError error = new(Error); + var result = Result.Failure(error); + var invoked = false; + + var mapped = result.Map(() => + { + invoked = true; + + return 123; + }); + + await Assert.That(invoked).IsFalse(); + await Assert.That(mapped.IsFailure).IsTrue(); + await Assert.That(mapped.Error).IsEqualTo(error); + } + + [Test] + public async Task Map_WhenUninitialized_ShouldThrow() + { + Result result = default; + + Result Act() => result.Map(() => 123); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + #endregion + + #region MapError + + [Test] + public async Task MapError_WhenFailure_ShouldTransformError() + { + var result = Result.Failure(new(Error)); + + var mapped = result.MapError(error => new OtherTestError(error.Message.Length)); + + await Assert.That(mapped.IsFailure).IsTrue(); + await Assert.That(mapped.Error).IsEqualTo(new OtherTestError(5)); + } + + [Test] + public async Task MapError_WhenSuccess_ShouldPreserveSuccess() + { + var result = Result.Success(); + + var mapped = result.MapError(error => new OtherTestError(error.Message.Length)); + + await Assert.That(mapped.IsSuccess).IsTrue(); + } + + #endregion + + #region Bind + + [Test] + public async Task Bind_WhenSuccessAndBindingSucceeds_ShouldReturnBoundUnitSuccess() + { + var result = Result.Success(); + + var bound = result.Bind(Result.Success); + + await Assert.That(bound.IsSuccess).IsTrue(); + } + + [Test] + public async Task Bind_WhenSuccessAndBindingFails_ShouldReturnBoundFailure() + { + var result = Result.Success(); + TestError error = new(Error); + + var bound = result.Bind(() => Result.Failure(error)); + + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error).IsEqualTo(error); + } + + [Test] + public async Task Bind_WhenFailure_ShouldPreserveErrorAndNotInvokeBinding() + { + TestError error = new(Error); + var result = Result.Failure(error); + var invoked = false; + + var bound = result.Bind(() => + { + invoked = true; + + return Result.Success(); + }); + + await Assert.That(invoked).IsFalse(); + await Assert.That(bound.Error).IsEqualTo(error); + } + + [Test] + public async Task BindToValue_WhenSuccess_ShouldReturnBoundValueResult() + { + var result = Result.Success(); + + var bound = result.Bind(() => Result.Success(123)); + + await Assert.That(bound.IsSuccess).IsTrue(); + await Assert.That(bound.Value).IsEqualTo(123); + } + + [Test] + public async Task BindToValue_WhenFailure_ShouldPreserveError() + { + TestError error = new(Error); + var result = Result.Failure(error); + + var bound = result.Bind(() => Result.Success(123)); + + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error).IsEqualTo(error); + } + + #endregion + + readonly record struct TestError(string Message) + { + public override string ToString() => Message; + } + + readonly record struct OtherTestError(int Code); +} diff --git a/src/tests/Results.UnitTests/ResultsTests.cs b/src/tests/Results.UnitTests/ResultsTests.cs index 05efa1b..8125ee0 100644 --- a/src/tests/Results.UnitTests/ResultsTests.cs +++ b/src/tests/Results.UnitTests/ResultsTests.cs @@ -48,10 +48,10 @@ public async Task Value_WhenFailure_ShouldThrow() { var result = Result.Failure(new(Error)); - string act() => result.Value; + string Act() => result.Value; await Assert - .That((Func)act) + .That((Func)Act) .ThrowsExactly() .WithMessage("The value of a non-successful result cannot be accessed.", StringComparison.Ordinal); } @@ -61,10 +61,10 @@ public async Task Error_WhenSuccess_ShouldThrow() { var result = Result.Success(Value); - TestError act() => result.Error; + TestError Act() => result.Error; await Assert - .That(act) + .That(Act) .ThrowsExactly() .WithMessage("The error of a non-failed result cannot be accessed.", StringComparison.Ordinal); } @@ -74,9 +74,9 @@ public async Task Value_WhenUninitialized_ShouldThrow() { Result result = default; - string act() => result.Value; + string Act() => result.Value; - await Assert.That((Func)act).ThrowsExactly(); + await Assert.That((Func)Act).ThrowsExactly(); } [Test] @@ -84,9 +84,9 @@ public async Task Error_WhenUninitialized_ShouldThrow() { Result result = default; - TestError act() => result.Error; + TestError Act() => result.Error; - await Assert.That(act).ThrowsExactly(); + await Assert.That(Act).ThrowsExactly(); } #endregion @@ -190,10 +190,10 @@ public async Task Match_WhenUninitialized_ShouldThrow() { Result result = default; - string act() => result.Match(success: value => value, failure: error => error.Message); + string Act() => result.Match(success: value => value, failure: error => error.Message); await Assert - .That((Func)act) + .That((Func)Act) .ThrowsExactly() .WithMessage("The result is uninitialized.", StringComparison.Ordinal); } @@ -245,10 +245,10 @@ public async Task Map_WhenUninitialized_ShouldThrow() { Result result = default; - Result act() => result.Map(value => value.Length); + Result Act() => result.Map(value => value.Length); await Assert - .That(act) + .That(Act) .ThrowsExactly() .WithMessage("The result is uninitialized.", StringComparison.Ordinal); } @@ -355,10 +355,10 @@ public async Task Bind_WhenUninitialized_ShouldThrow() { Result result = default; - Result act() => result.Bind(value => Result.Success(value.Length)); + Result Act() => result.Bind(value => Result.Success(value.Length)); await Assert - .That(act) + .That(Act) .ThrowsExactly() .WithMessage("The result is uninitialized.", StringComparison.Ordinal); } diff --git a/src/tests/Results.UnitTests/SuccessTests.cs b/src/tests/Results.UnitTests/SuccessTests.cs new file mode 100644 index 0000000..118b6cb --- /dev/null +++ b/src/tests/Results.UnitTests/SuccessTests.cs @@ -0,0 +1,16 @@ +namespace Purview.Results; + +public sealed class SuccessTests +{ + [Test] + public async Task Instance_ShouldEqualDefault() + { + await Assert.That(Success.Instance).IsEqualTo(default); + } + + [Test] + public async Task ToString_ShouldDescribeSuccess() + { + await Assert.That(Success.Instance.ToString()).IsEqualTo("Success"); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedResultsIntegrationTests.cs b/src/tests/SourceGenerator.UnitTests/GeneratedResultsIntegrationTests.cs index be06341..c7fe791 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedResultsIntegrationTests.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedResultsIntegrationTests.cs @@ -118,4 +118,82 @@ public async Task Map_GivenGeneratedSuccess_InvokesTheMapping() await Assert.That(mapped.IsSuccess).IsTrue(); await Assert.That(mapped.Value).IsEqualTo("tenant-1"); } + + [Test] + public async Task Bind_GivenBillingFailure_ShouldWidenTheErrorIntoTheCompositeUnion() + { + // Arrange + var billing = new BillingServiceUnavailable("down").AsFailure(); + + // Act + // The widening Bind maps the billing error into RegisterTenantError, so the binder may return the + // composite error type directly. The lambda returns the error unchanged; the union conversion lifts + // BillingError into RegisterTenantError. + var bound = billing.Bind( + _ => Result.Success(new TenantId("tenant-1")), + error => error + ); + + // Assert + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error is BillingError).IsTrue(); + } + + [Test] + public async Task Bind_GivenBillingSuccess_ShouldReturnTheBoundResult() + { + // Arrange + var billing = Result.Success(new Tenant(new TenantId("t"))); + + // Act + var bound = billing.Bind( + // A tenant error is a case of the composite union, so its generated helper produces the composite + // failure directly once the case has been lifted to TenantError. + _ => ((TenantError)new TenantAlreadyExists(new TenantId("t"))).AsFailure(), + error => error + ); + + // Assert + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error is TenantError).IsTrue(); + } + + [Test] + public async Task AsFailure_GivenNestedBillingUnionCase_ShouldProduceTheCompositeFailure() + { + // Arrange + var billing = new BillingServiceUnavailable("down").AsFailure(); + + // Act + // The guard idiom: a whole union case of the composite union has its own AsFailure helper, so the + // billing failure lifts into the registration contract without an explicit MapError. + await Assert.That(billing.TryGetError(out var billingError)).IsTrue(); + var failure = billingError.AsFailure(); + + // Assert + await Assert.That(failure.IsFailure).IsTrue(); + await Assert.That(failure.Error is BillingError).IsTrue(); + } + + [Test] + public async Task Throw_GivenGeneratedFailure_ShouldThrowResultExceptionCarryingTheUnion() + { + // Arrange + var result = new TenantNotFound(new TenantId("tenant-1")).AsFailure(); + ResultException? exception = null; + + // Act + try + { + result.Throw(); + } + catch (ResultException caught) + { + exception = caught; + } + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Error is TenantNotFound).IsTrue(); + } } diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs b/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs index 60516a5..00f917e 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs @@ -117,4 +117,130 @@ public async Task Result_GivenSuccessValueWithGeneratedErrorUnion_IsInitializedS await Assert.That(result.IsSuccess).IsTrue(); await Assert.That(result.Value).IsEqualTo(tenant); } + + [Test] + public async Task AsFailureUnit_GivenNotFoundCase_ProducesAUnitFailure() + { + // Arrange + TenantNotFound tenantNotFound = new(new TenantId("tenant-1")); + + // Act + // The explicit type is the compile-time proof that the non-generic helper produces exactly a + // Result for this union. +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = tenantNotFound.AsFailure(); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error is TenantNotFound).IsTrue(); + } + + [Test] + public async Task AsFailureUnit_GivenDisabledCase_ProducesAFailureWhoseToStringNamesTheState() + { + // Arrange + // Act + var result = new TenantDisabled(new TenantId("tenant-2")).AsFailure(); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.ToString()).StartsWith("Failure("); + } + + [Test] + public async Task UnionFactoryFailure_GivenNotFoundCase_ProducesAUnitFailure() + { + // Arrange + TenantNotFound tenantNotFound = new(new TenantId("tenant-1")); + + // Act + // The explicit type is the compile-time proof that the union-receiver factory produces exactly a + // Result for the union named by the receiver. +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = TenantError.Failure(tenantNotFound); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is TenantNotFound).IsTrue(); + } + + [Test] + public async Task UnionFactoryValueFailure_GivenNotFoundCase_ProducesAValueFailure() + { + // Arrange + TenantNotFound tenantNotFound = new(new TenantId("tenant-1")); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = TenantError.Failure(tenantNotFound); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is TenantNotFound).IsTrue(); + } + + [Test] + public async Task UnionFactoryFailure_GivenIncludedCase_ConstructsTheNestedUnion() + { + // Arrange + // BillingAccountMissing is not a direct case of RegisterTenantError: it is reachable through + // BillingError, which RegisterTenantError includes. The generated factory constructs the whole + // nested value. + BillingAccountMissing missing = new("account-1"); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = RegisterTenantError.Failure(missing); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + + // The active leaf is the included case, reached by matching through the included union. + BillingAccountMissing? activeCase = result.Error switch + { + BillingError billing => billing switch + { + BillingAccountMissing accountMissing => accountMissing, + _ => null, + }, + _ => null, + }; + + await Assert.That(activeCase).IsEqualTo(missing); + } + + [Test] + public async Task UnionFactorySuccess_ProducesAUnitSuccess() + { + // Arrange + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = TenantError.Success(); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task UnionFactoryValueSuccess_GivenValue_ProducesAValueSuccess() + { + // Arrange + Tenant tenant = new(new TenantId("tenant-1")); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = TenantError.Success(tenant); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(tenant); + } } diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs index eb5d102..0f05f1b 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs @@ -1,5 +1,3 @@ -using Purview.Results.SourceGeneration; - namespace Purview.Results.SourceGenerator; // Union declarations used by the generated-code runtime tests. They live in the test project so the @@ -32,3 +30,31 @@ namespace Purview.Results.SourceGenerator; /// [GenerateResult] public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); + +/// A billing account does not exist. +/// The identifier of the billing account. +public readonly record struct BillingAccountMissing(string AccountId); + +/// The billing service is unavailable. +/// The reason the billing service is unavailable. +public readonly record struct BillingServiceUnavailable(string Reason); + +/// The errors a billing operation can produce. +/// +/// This union stands in for a service boundary: a tenant operation consumes it and widens its error into +/// rather than leaking the billing case types. +/// +[GenerateResult] +public readonly union BillingError(BillingAccountMissing, BillingServiceUnavailable); + +/// +/// The errors a tenant registration can produce: the whole and +/// unions, each as a single case. +/// +/// +/// The case types are unions, not their leaf cases. Each is an included union: the generated factory +/// also covers its cases and constructs the nested value, so a billing failure can be lifted into this contract +/// with one union conversion and no leaf case is shared with . +/// +[GenerateResult] +public readonly union RegisterTenantError(TenantError, BillingError); diff --git a/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs index d1541e9..f5ee3ef 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs @@ -1,6 +1,6 @@ using System.Globalization; using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration.Analyzers; +using Purview.Results.SourceGenerator.Analyzers; namespace Purview.Results.SourceGenerator; @@ -24,7 +24,19 @@ await Assert .That( analyzer.SupportedDiagnostics.Select(static descriptor => descriptor.Id).Order(StringComparer.Ordinal) ) - .IsEquivalentTo(["RSG1000", "RSG1001", "RSG1002", "RSG1003", "RSG1004", "RSG1005", "RSG1006", "RSG1007"]); + .IsEquivalentTo([ + "RSG1000", + "RSG1001", + "RSG1002", + "RSG1003", + "RSG1004", + "RSG1005", + "RSG1006", + "RSG1007", + "RSG1008", + "RSG1009", + "RSG9000", + ]); } [Test] @@ -196,6 +208,40 @@ public interface IUnionMembers await Assert.That(result).HasDiagnostic("RSG1007"); } + [Test] + public async Task AnalyzeAsync_GivenAmbiguousIncludedCase_ReportsAmbiguousIncludedCase( + CancellationToken cancellationToken + ) + { + // Arrange + // Both LeftError and RightError include NotFound, so OuterError cannot name a single construction + // path for it. + const string source = """ + namespace Test + { + public readonly record struct NotFound(int Id); + + [GenerateResult] + public readonly union LeftError(NotFound); + + [GenerateResult] + public readonly union RightError(NotFound); + + [GenerateResult] + public readonly union OuterError(LeftError, RightError); + } + """; + + // Act + var result = await AnalyzeAsync([TestSources.GenerateResultAttributeDeclaration, source], cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic("RSG1008"); + + var diagnostic = result.Diagnostics.Single(static candidate => candidate.Id == "RSG1008"); + await Assert.That(diagnostic.Severity).IsEqualTo(DiagnosticSeverity.Warning); + } + [Test] public async Task AnalyzeAsync_GivenValidOptedInUnion_ReportsNothing(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorArrayCaseTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorArrayCaseTests.cs new file mode 100644 index 0000000..aa78ba7 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorArrayCaseTests.cs @@ -0,0 +1,56 @@ +namespace Purview.Results.SourceGenerator; + +/// +/// Covers an array-typed union case. +/// +/// +/// The discovery code explicitly handles IArrayTypeSymbol when deciding accessibility and when +/// looking for type parameters, and then cast the case type to INamedTypeSymbol — which an array is +/// not. An array-typed case therefore threw out of the generator, and +/// because there is no catch-all in the pipeline the compiler reported CS8785 and dropped +/// all generated output for the compilation: every AsFailure helper and union factory +/// vanished, producing a cascade of unrelated errors with no indication of the real cause. In the IDE the +/// analyzer raised AD0001 and stopped analysing. +/// +public class ResultsSourceGeneratorArrayCaseTests + : TUnitSourceGeneratorTestBase +{ + const string ArrayCaseSource = """ + namespace Test + { + public readonly record struct TenantId(string Value); + + public readonly record struct TenantNotFound(TenantId TenantId); + + [GenerateResult] + public readonly union TenantError(TenantNotFound, string[]); + } + """; + + [Test] + public async Task GenerateAsync_GivenArrayTypedCase_DoesNotThrowOutOfTheGenerator( + CancellationToken cancellationToken + ) + { + // Act — the generator must report or skip, never crash. A crash takes every other union in the + // compilation down with it. + var result = await GenerateAsync(ArrayCaseSource, cancellationToken); + + // Assert + await Assert.That(result).IsNotNull(); + } + + [Test] + public async Task GenerateAsync_GivenArrayTypedCase_StillEmitsHelpersForTheValidCases( + CancellationToken cancellationToken + ) + { + // Act + var result = await GenerateAsync(ArrayCaseSource, cancellationToken); + + // Assert — the named case is unaffected by the unusable one. + var generated = result.AssertSingleGeneratedSource(); + + await Assert.That(generated).Contains("this global::Test.TenantNotFound error"); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorCacheTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorCacheTests.cs index c3600eb..037e3c0 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorCacheTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorCacheTests.cs @@ -1,5 +1,4 @@ using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration; namespace Purview.Results.SourceGenerator; diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs index e57f505..5e5d998 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs @@ -1,5 +1,4 @@ -using Purview.Results.SourceGeneration; -using Purview.Results.SourceGeneration.Analyzers; +using Purview.Results.SourceGenerator.Analyzers; namespace Purview.Results.SourceGenerator; @@ -198,6 +197,13 @@ namespace Test await Assert.That(auditSource).Contains("this global::Test.NotFound error"); await Assert.That(tenantSource).Contains("this global::Test.Disabled error"); await Assert.That(tenantSource).DoesNotContain("this global::Test.NotFound error"); + + // The shared case's per-case AsFailure helper is generated once, but each union still owns its + // union-receiver factory, which is the shared-case safe form. + await Assert.That(auditSource).Contains("> Failure("); + await Assert.That(auditSource).Contains("global::Test.NotFound error"); + await Assert.That(tenantSource).Contains("> Failure("); + await Assert.That(tenantSource).Contains("global::Test.NotFound error"); } [Test] diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTestOptions.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTestOptions.cs index f974864..18473ab 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTestOptions.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTestOptions.cs @@ -17,7 +17,7 @@ public record ResultsSourceGeneratorTestOptions : AnalyzerTestOptions /// /// The MSBuild/analyzer-config property that disables the generator. /// - public const string DisableGeneratorProperty = "ResultsSourceGenerator_Disable"; + public const string DisableGeneratorProperty = "DisableResultsSourceGenerator"; /// /// The hint names of the sources emitted during post-initialization, which are excluded from the @@ -38,7 +38,7 @@ public ResultsSourceGeneratorTestOptions() LanguageVersion = Microsoft.CodeAnalysis.CSharp.LanguageVersion.Preview; AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange(typeof(Result<,>)); - AdditionalNamespaces = AdditionalNamespaces.Add("Purview.Results").Add("Purview.Results.SourceGeneration"); + AdditionalNamespaces = AdditionalNamespaces.Add("Purview.Results"); DisableSourceGeneratorPropertyName = DisableGeneratorProperty; ExcludeGeneratedSourceHintNames = [.. PostInitializationHintNames]; diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs index 82ffb20..77608d1 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs @@ -1,5 +1,3 @@ -using Purview.Results.SourceGeneration; - namespace Purview.Results.SourceGenerator; /// @@ -56,6 +54,109 @@ public async Task GenerateAsync_GivenOptedInUnion_EmitsOneHelperPerCase(Cancella await Assert.That(generated).Contains("global::Purview.Results.Result"); } + [Test] + public async Task GenerateAsync_GivenOptedInUnion_EmitsAUnitFailureHelperPerCase( + CancellationToken cancellationToken + ) + { + // Arrange + // Act + var result = await GenerateAsync(TenantUnionSource, cancellationToken); + + // Assert + var generated = result.AssertSingleGeneratedSource(); + + // The unit helper's body names Result with no value type argument, which distinguishes it from + // the generic AsFailure() helper's Result body. + await Assert + .That(generated) + .Contains("global::Purview.Results.Result.Failure(error)"); + + // The generic value helper is still emitted alongside it. + await Assert + .That(generated) + .Contains("global::Purview.Results.Result.Failure(error)"); + } + + [Test] + public async Task GenerateAsync_GivenOptedInUnion_EmitsUnionFactoryExtensionBlock( + CancellationToken cancellationToken + ) + { + // Arrange + // Act + var result = await GenerateAsync(TenantUnionSource, cancellationToken); + + // Assert + // The union-receiver factory is a C# 14 static extension block on the union type, so a case can be + // converted by naming the union (Union.Failure(case)) rather than the case. The writer wraps the long + // parameter list onto its own line, so the assertions target the signature head and the parameter. + var generated = result.AssertSingleGeneratedSource(); + await Assert.That(generated).Contains("extension(global::Test.TenantError)"); + await Assert.That(generated).Contains("> Failure("); + await Assert.That(generated).Contains("> Failure("); + await Assert.That(generated).Contains("global::Test.TenantNotFound error"); + await Assert.That(generated).Contains("> Success()"); + await Assert.That(generated).Contains("> Success("); + await Assert.That(generated).Contains("Result.Success()"); + await Assert + .That(generated) + .Contains("global::Purview.Results.Result.Success(value)"); + } + + [Test] + public async Task GenerateAsync_GivenSharedCaseType_EmitsTheUnionFactoryForBothUnions( + CancellationToken cancellationToken + ) + { + // Arrange + // NotFound is shared by both unions (RSG1006), so its per-case AsFailure helper is generated once — + // but each union still owns its factory, which is the shared-case safe form. + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct NotFound(int Id); + + public readonly record struct Rejected(string Reason); + + [GenerateResult] + public readonly union GetError(NotFound); + + [GenerateResult] + public readonly union SetError(NotFound, Rejected); + + public static class Usage + { + public static Result Unit(NotFound error) => SetError.Failure(error); + + public static Result Value(NotFound error) => SetError.Failure(error); + } + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + result.AssertNoCompilationErrors(); + + var setTree = result.PrimarySyntaxTrees.Single(static tree => + tree.FilePath.EndsWith("SetErrorResultExtensions.g.cs", StringComparison.Ordinal) + ); + var setSource = (await setTree.GetTextAsync(cancellationToken)).ToString(); + + // The shared case's factory is present on the union that lost the AsFailure helper. + await Assert.That(setSource).Contains("> Failure("); + await Assert.That(setSource).Contains("> Failure("); + await Assert.That(setSource).Contains("global::Test.NotFound error"); + + // The per-case AsFailure helper is generated once, for GetError (the ordinal winner). + await Assert.That(setSource).DoesNotContain("this global::Test.NotFound error"); + await Assert.That(setSource).Contains("this global::Test.Rejected error"); + } + [Test] public async Task GenerateAsync_GivenOptedInUnion_EmitsNullableEnabledFullyQualifiedSource( CancellationToken cancellationToken @@ -73,6 +174,211 @@ CancellationToken cancellationToken await Assert.That(generated).Contains("Result.Failure(error)"); } + [Test] + public async Task GenerateAsync_GivenIncludedUnion_EmitsFlattenedFactoriesForTheIncludedCases( + CancellationToken cancellationToken + ) + { + // Arrange + // LookupError is a case of SetError, so its own case (NotFound) is reachable through it. The + // generator emits a union-receiver factory for NotFound on SetError that constructs the nested value. + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct NotFound(int Id); + + public readonly record struct Rejected(string Reason); + + [GenerateResult] + public readonly union LookupError(NotFound); + + [GenerateResult] + public readonly union SetError(LookupError, Rejected); + + public static class Usage + { + public static Result Included(NotFound error) => SetError.Failure(error); + + public static Result IncludedUnit(NotFound error) => SetError.Failure(error); + + public static Result Direct(Rejected error) => SetError.Failure(error); + } + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + result.AssertNoCompilationErrors(); + + var setTree = result.PrimarySyntaxTrees.Single(static tree => + tree.FilePath.EndsWith("SetErrorResultExtensions.g.cs", StringComparison.Ordinal) + ); + var setSource = (await setTree.GetTextAsync(cancellationToken)).ToString(); + + // The included case gets a factory, and its body constructs the nested union value. + await Assert.That(setSource).Contains("global::Test.NotFound error"); + await Assert.That(setSource).Contains("new global::Test.SetError(new global::Test.LookupError(error))"); + } + + [Test] + public async Task GenerateAsync_GivenSharedUnionCase_DropsThePerCaseHelperAndReportsNoSharedCaseWarning( + CancellationToken cancellationToken + ) + { + // Arrange + // LookupError is a union-typed case of both GetError and SetError. Sharing it is the composition + // pattern, so no RSG1006 is reported and no per-case AsFailure helper is generated for it on either + // union; the union-receiver factory is the safe form. + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct NotFound(int Id); + + [GenerateResult] + public readonly union LookupError(NotFound); + + [GenerateResult] + public readonly union GetError(LookupError); + + [GenerateResult] + public readonly union SetError(LookupError); + + public static class Usage + { + public static Result Get(LookupError error) => GetError.Failure(error); + + public static Result Set(LookupError error) => SetError.Failure(error); + } + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + result.AssertNoCompilationErrors(); + await Assert.That(result).DoesNotHaveDiagnostic("RSG1006"); + + var getTree = result.PrimarySyntaxTrees.Single(static tree => + tree.FilePath.EndsWith("GetErrorResultExtensions.g.cs", StringComparison.Ordinal) + ); + var getSource = (await getTree.GetTextAsync(cancellationToken)).ToString(); + + await Assert.That(getSource).DoesNotContain("this global::Test.LookupError error"); + await Assert.That(getSource).Contains("global::Test.LookupError error"); + } + + [Test] + public async Task GenerateAsync_GivenNestedIncludedUnions_ConstructsTheWholeChain( + CancellationToken cancellationToken + ) + { + // Arrange + // TopError -> MiddleError -> LeafError -> NotFound, so the included factory must construct the whole + // nested value, innermost first. + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct NotFound(int Id); + + [GenerateResult] + public readonly union LeafError(NotFound); + + [GenerateResult] + public readonly union MiddleError(LeafError); + + [GenerateResult] + public readonly union TopError(MiddleError); + + public static class Usage + { + public static Result Value(NotFound error) => TopError.Failure(error); + } + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + result.AssertNoCompilationErrors(); + + var topTree = result.PrimarySyntaxTrees.Single(static tree => + tree.FilePath.EndsWith("TopErrorResultExtensions.g.cs", StringComparison.Ordinal) + ); + var topSource = (await topTree.GetTextAsync(cancellationToken)).ToString(); + + await Assert + .That(topSource) + .Contains("new global::Test.TopError(new global::Test.MiddleError(new global::Test.LeafError(error)))"); + } + + [Test] + public async Task GenerateAsync_GivenAmbiguousIncludedCase_SkipsTheAmbiguousFactoryAndKeepsTheOthers( + CancellationToken cancellationToken + ) + { + // Arrange + // NotFound is reachable through both LeftError and RightError, so no single construction path can be + // named for it. The direct cases still get their factories. + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct NotFound(int Id); + + public readonly record struct Rejected(string Reason); + + [GenerateResult] + public readonly union LeftError(NotFound); + + [GenerateResult] + public readonly union RightError(NotFound); + + [GenerateResult] + public readonly union OuterError(LeftError, RightError, Rejected); + } + """; + + // Act + var result = await GenerateAsync( + source, + new ResultsSourceGeneratorTestOptions + { + AnalyzerTypes = [typeof(Purview.Results.SourceGenerator.Analyzers.ResultsDiagnosticAnalyzer)], + }, + cancellationToken + ); + + // Assert + result.AssertNoCompilationErrors(); + await Assert.That(result.AnalyzerResult).IsNotNull(); + + var analyzerDiagnostics = result + .AnalyzerResult!.Diagnostics.Select(static diagnostic => diagnostic.Id) + .ToArray(); + await Assert.That(analyzerDiagnostics).Contains("RSG1008"); + + var outerTree = result.PrimarySyntaxTrees.Single(static tree => + tree.FilePath.EndsWith("OuterErrorResultExtensions.g.cs", StringComparison.Ordinal) + ); + var outerSource = (await outerTree.GetTextAsync(cancellationToken)).ToString(); + + await Assert.That(outerSource).Contains("global::Test.LeftError error"); + await Assert.That(outerSource).Contains("global::Test.RightError error"); + await Assert.That(outerSource).Contains("global::Test.Rejected error"); + await Assert.That(outerSource).DoesNotContain("global::Test.NotFound error"); + } + [Test] public async Task GenerateAsync_GivenMultipleUnions_EmitsIndependentHelpersInDeterministicOrder( CancellationToken cancellationToken diff --git a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj index 626d400..eb0eba4 100644 --- a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj +++ b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj @@ -1,4 +1,13 @@ + + + + net11.0 + + + net11.0 + @@ -9,6 +18,17 @@ + + + + + + (), context), context ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status400BadRequest); - await Assert.That(Body).Contains(InvalidNameCode); - await Assert.That(Body).Contains(context.TraceIdentifier); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(body).Contains(InvalidNameCode); + await Assert.That(body).Contains(context.TraceIdentifier); } [Test] @@ -39,11 +39,13 @@ public async Task Map_GivenErrorThatIsNotAValidationCarrier_ReturnsTheUnmappedPr var context = CreateContext(services); // Act - var (StatusCode, Body) = await ExecuteAsync(mapper.Map(new ItemMissing(7).AsFailure(), context), context); + var (statusCode, body) = await ExecuteAsync(mapper.Map(new ItemMissing(7).AsFailure(), context), context); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status500InternalServerError); - await Assert.That(Body).Contains(nameof(ItemMissing)); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status500InternalServerError); + // Identified by the unmapped title rather than the error's CLR type name, which is opt-in. + await Assert.That(body).Contains("is not mapped to an HTTP response"); + await Assert.That(body).DoesNotContain(nameof(ItemMissing)); } [Test] @@ -59,14 +61,14 @@ public async Task Map_GivenMappingForTheCase_ReturnsTheMappedResponseInsteadOfTh var context = CreateContext(services); // Act - var (StatusCode, Body) = await ExecuteAsync( + var (statusCode, body) = await ExecuteAsync( mapper.Map(new InputRejected(7, CreateErrors()).AsFailure(), context), context ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status409Conflict); - await Assert.That(Body).Contains("rejected 7"); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status409Conflict); + await Assert.That(body).Contains("rejected 7"); } [Test] @@ -78,14 +80,14 @@ public async Task Map_GivenAggregateRejectedCase_ReturnsValidationProblem() var context = CreateContext(services); // Act - var (StatusCode, Body) = await ExecuteAsync( + var (statusCode, body) = await ExecuteAsync( mapper.Map(new AggregateRejected("operation", CreateErrors()).AsFailure(), context), context ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status400BadRequest); - await Assert.That(Body).Contains(InvalidNameCode); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(body).Contains(InvalidNameCode); } static ServiceProvider CreateServices(Action? configure = null) diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs index 184e292..bd09331 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs @@ -19,16 +19,16 @@ public async Task ToProblem_GivenValidationErrors_ReturnsValidationProblemWithTh var errors = CreateErrors(); // Act - var (StatusCode, Body, ContentType) = await ExecuteAsync( + var (statusCode, body, contentType) = await ExecuteAsync( ZodValidationProblems.ToProblem(errors, CreateOptions()), CreateContext() ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status400BadRequest); - await Assert.That(ContentType).Contains("application/problem+json"); - await Assert.That(Body).Contains(InvalidNameCode); - await Assert.That(Body).Contains("issues"); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(contentType).Contains("application/problem+json"); + await Assert.That(body).Contains(InvalidNameCode); + await Assert.That(body).Contains("issues"); } [Test] @@ -49,14 +49,14 @@ public async Task ToProblem_GivenRegisteredErrorType_ReturnsItsStatusAndTitle() ZodProblemDetailsOptions options = new() { Registry = registry }; // Act - var (StatusCode, Body, _) = await ExecuteAsync( + var (statusCode, body, _) = await ExecuteAsync( ZodValidationProblems.ToProblem(CreateErrors(), options), CreateContext() ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); - await Assert.That(Body).Contains("The name is not valid."); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(body).Contains("The name is not valid."); } [Test] @@ -66,13 +66,13 @@ public async Task ToProblem_GivenStatusCodeSelector_ReturnsItsStatus() ZodProblemDetailsOptions options = new() { StatusCodeSelector = _ => StatusCodes.Status409Conflict }; // Act - var (StatusCode, Body, ContentType) = await ExecuteAsync( + var (statusCode, body, contentType) = await ExecuteAsync( ZodValidationProblems.ToProblem(CreateErrors(), options), CreateContext() ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status409Conflict); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status409Conflict); } [Test] @@ -82,13 +82,13 @@ public async Task ToProblem_GivenTraceId_IncludesItInTheExtensions() var context = CreateContext(); // Act - var (_, Body, _) = await ExecuteAsync( + var (_, body, _) = await ExecuteAsync( ZodValidationProblems.ToProblem(CreateErrors(), CreateOptions(), traceId: context.TraceIdentifier), context ); // Assert - await Assert.That(Body).Contains(context.TraceIdentifier); + await Assert.That(body).Contains(context.TraceIdentifier); } [Test] @@ -101,7 +101,7 @@ public async Task ToProblem_GivenTheSameErrorsAShownException_ProducesTheSameRes var context = CreateContext(); // Act - var (StatusCode, Body, ContentType) = await ExecuteAsync( + var (statusCode, body, contentType) = await ExecuteAsync( ZodValidationProblems.ToProblem(errors, options), context ); @@ -111,10 +111,10 @@ public async Task ToProblem_GivenTheSameErrorsAShownException_ProducesTheSameRes ); // Assert - await Assert.That(StatusCode).IsEqualTo(exceptionProblem.Status); - await Assert.That(ContentType).Contains("application/problem+json"); + await Assert.That(statusCode).IsEqualTo(exceptionProblem.Status); + await Assert.That(contentType).Contains("application/problem+json"); - using var document = JsonDocument.Parse(Body); + using var document = JsonDocument.Parse(body); var root = document.RootElement; await Assert.That(root.GetProperty("title").GetString()).IsEqualTo(exceptionProblem.Title); @@ -132,14 +132,14 @@ public async Task ToValidationProblem_GivenValidationErrorCarrier_ReturnsValidat var context = CreateContext(); // Act - var (StatusCode, Body, _) = await ExecuteAsync( + var (statusCode, body, _) = await ExecuteAsync( new InputRejected(7, CreateErrors()).ToValidationProblem(context), context ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status400BadRequest); - await Assert.That(Body).Contains(InvalidNameCode); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(body).Contains(InvalidNameCode); } [Test] @@ -154,13 +154,13 @@ public async Task ToValidationProblem_GivenTraceIdDisabled_OmitsTheTraceIdentifi var context = CreateContext(provider); // Act - var (StatusCode, Body, ContentType) = await ExecuteAsync( + var (statusCode, body, contentType) = await ExecuteAsync( new InputRejected(7, CreateErrors()).ToValidationProblem(context), context ); // Assert - await Assert.That(Body).DoesNotContain(context.TraceIdentifier); + await Assert.That(body).DoesNotContain(context.TraceIdentifier); } static ZodProblemDetailsOptions CreateOptions() => new(); diff --git a/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs b/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs index 2e3249d..5410339 100644 --- a/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs +++ b/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs @@ -1,4 +1,3 @@ -using Purview.Results.SourceGeneration; using System.Collections.Immutable; using ZodSharp.Core; diff --git a/src/tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj b/src/tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj index c8d6e8f..ceb5f77 100644 --- a/src/tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj +++ b/src/tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj @@ -1,4 +1,13 @@ + + + + net11.0 +