diff --git a/.agents/skills/purview-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md index 0619225f..55d0ca8b 100644 --- a/.agents/skills/purview-architecture-testing/SKILL.md +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "2.0" + repository: purview-dev/engineering-playbook --- # Purview architecture testing @@ -83,7 +84,7 @@ debugging the package. Then add a test that the documentation matches the policy, so the table in the wiki is falsifiable too. Full worked example: -[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). ## 3. Read project files, and assemblies @@ -180,4 +181,4 @@ project classified so every dependency rule still applies to it. Prefer a waiver - `purview-architecture` — what the projects are for, and how they are named - `purview-buildsdk-architecture-tests` — the package's own skill, authoritative on its API -- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Architecture-As-Tests.md) +- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Architecture-As-Tests.md) diff --git a/.agents/skills/purview-architecture/SKILL.md b/.agents/skills/purview-architecture/SKILL.md index c04f3d61..0c435966 100644 --- a/.agents/skills/purview-architecture/SKILL.md +++ b/.agents/skills/purview-architecture/SKILL.md @@ -10,6 +10,7 @@ license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview architecture @@ -173,4 +174,4 @@ small telemetry — it does not get none. See `purview-telemetry`. - `purview-telemetry` — instrumentation at every scale - `purview-persistence` — storage and GUID-key guidance, only when persistence is real - [Engineering Principles](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Engineering-Principles.md) -- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Adopting-This-At-Your-Scale.md) +- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/governance/Adopting-This-At-Your-Scale.md) diff --git a/.agents/skills/purview-architecture/references/contract-strategy.md b/.agents/skills/purview-architecture/references/contract-strategy.md index e281a410..62b5a17f 100644 --- a/.agents/skills/purview-architecture/references/contract-strategy.md +++ b/.agents/skills/purview-architecture/references/contract-strategy.md @@ -230,7 +230,7 @@ combines" stays a review obligation, because a dependency matrix cannot express ## See also -- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Contracts-And-Data-Exchange.md) +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/data/Contracts-And-Data-Exchange.md) — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape - [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema export diff --git a/.agents/skills/purview-architecture/references/project-responsibilities.md b/.agents/skills/purview-architecture/references/project-responsibilities.md index d74d2cf4..dc4e67cc 100644 --- a/.agents/skills/purview-architecture/references/project-responsibilities.md +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -3,7 +3,7 @@ The catalogue in brief. The authoritative definition is [`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), and the full page — with diagrams — is -[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). Do not restate either here or in another skill; link to them. ## The catalogue diff --git a/.agents/skills/purview-buildsdk-adoption/SKILL.md b/.agents/skills/purview-buildsdk-adoption/SKILL.md index b1f222c5..bb24fa32 100644 --- a/.agents/skills/purview-buildsdk-adoption/SKILL.md +++ b/.agents/skills/purview-buildsdk-adoption/SKILL.md @@ -100,9 +100,14 @@ silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. ## 5. Agent skills are a separate, explicit step -The SDK does not install agent skills, and nothing it does during a build writes to an agent -directory. It reads which skills each referenced package ships and recommends the command that -installs them; the skills CLI does the installing. See `purview-buildsdk-skill-installation`. +Nothing the SDK does during a restore or a build touches agent skills. Installing, updating and +cleaning them up are separate targets a person runs by name — `InstallPurviewSkills`, +`UpdatePurviewSkills`, `CheckPurviewSkills`, `FixPurviewSkills` — and the ones that change the +repository refuse to run inside a build (`PRSKL0009`). Do not hook them into one. The skills CLI does +the installing, through `bun x` by default; with `PurviewSkillsAgents` unset it auto-detects agents. + +If the repository uses `just`, add recipes for them — see `purview-buildsdk-skills-justfile`. For what each +target does and the `PRSKL*` diagnostics, see `purview-buildsdk-skill-installation`. ## Constraints diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md index d82768b3..b540704b 100644 --- a/.agents/skills/purview-buildsdk-configuration/SKILL.md +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -41,7 +41,7 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `TestingFramework` | `TUnit` | `TUnit`, `Xunit` or `None` | | `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute` or `None` | | `TestDataFramework` | `Bogus` | `Bogus` or `None` | -| `TargetFramework` | `net10.0` | `netstandard2.0` when `IsRoslynComponent=true` | +| `TargetFramework` | `net10.0;net11.0` | `netstandard2.0` when `IsRoslynComponent=true` | | `IsPackable` | `false` | Opts a project into the packable defaults | | `UsePackageJsonVersion` | `true` | `true`, `false` or `Strict` — version from `package.json` | | `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | @@ -50,6 +50,11 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | | `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | | `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | +| `MemorySafetyRules` | `2` | C# 15 memory-safety tier on `net11.0`+: `0` off, `1` preview syntax, `2` updated rules | +| `PurviewRuntimeAsync` | `true` | `runtime-async=on` on `net11.0`+ | +| `IsClientSideProject` | *(computed)* | Whether the project's assemblies can reach a browser; overrides the `ServerShared` classification | +| `PurviewAutoServerSharedReference` | `true` | Adds a sibling `ServerShared` reference to a non-client-side project | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a `Directory.Build.rsp` enabling MSBuild `-mt` | The full set, grouped by area and with the behaviour each one changes, is in [references/properties.md](references/properties.md). Read it rather than guessing a property name — @@ -89,11 +94,15 @@ is the SDK's answer, and it stays correct when the rules change. The exported se ## Repository bootstrap -The SDK bootstraps two repository-wide files from the package: `.editorconfig` and `global.json`. +The SDK bootstraps three repository-wide files from the package: `.editorconfig`, `global.json` and +`Directory.Build.rsp`. - `PurviewRepoBootstrapMode` — `IfMissing` (default), `Always`, `WarnOnDrift`, `Never` - `DisableAutoCopySdkFiles` — the master off switch - `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` +- `BootstrapMultiThreadedBuildRspToRepoRoot` / `PurviewMultiThreadedBuild` — the response file that + enables MSBuild `-mt` (and `-nr:false`, which avoids a `RoslynCodeTaskFactory` cache left by a + non-`-mt` build) Every project in a solution writes those same destinations, so the write is race-tolerant by design: an existing file is never overwritten unless asked, each write is staged into a temporary @@ -105,6 +114,18 @@ the repository; that is gone, along with `EnableAgentFolderInPackage`, `AgentPac and the `PurviewAgentFolder*` properties. Skills have exactly one installer, the skills CLI — see `purview-buildsdk-skill-installation`. +The skill targets read their settings when they run, so these can go in `Directory.Build.props` or +on the command line, and a repository-wide choice belongs in `Directory.Build.props`: + +| Property | Default | Controls | +| -- | -- | -- | +| `PurviewSkillsRunner` | `bun` (`bun x skills`) | Which package runner launches the skills CLI | +| `PurviewSkillsAgents` | empty — the CLI auto-detects | Agents to install to; when set, the only agent directories `CheckPurviewSkills` allows | +| `PurviewSkillsDryRun` | `false` | Install and update print commands instead of running them | + +On a command line, `dotnet msbuild -p:` splits values on `,` and `;` — write +`-p:PurviewSkillsAgents=claude-code%2Ccodex`. For `just` recipes, see `purview-buildsdk-skills-justfile`. + ## Guidance when changing SDK configuration 1. Preserve existing defaults unless the task explicitly changes product behaviour. diff --git a/.agents/skills/purview-buildsdk-configuration/references/properties.md b/.agents/skills/purview-buildsdk-configuration/references/properties.md index 1fb1f81b..82f84d77 100644 --- a/.agents/skills/purview-buildsdk-configuration/references/properties.md +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -21,7 +21,7 @@ consuming project supplies nothing. | Property | Default | Behaviour | | -- | -- | -- | -| `TargetFramework` | `net10.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `TargetFramework` | `net10.0;net11.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | | `IsRoslynComponent` | *(unset)* | When explicitly `true`: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry exclusion, and `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` references | | `IsRoslynComponentOnly` | `true` for Roslyn components | Analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, portable PDB packed beside the analyzer under `analyzers/dotnet/cs/`. Set `false` for a dual-role component using normal library symbol packaging | | `DisableRoslynCompilerDefaultsValidation` | `false` | Opts out of `ValidateRoslynComponentCompilerSettings`, which fails the pack of a packable Roslyn component missing `LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules` | @@ -36,6 +36,19 @@ consuming project supplies nothing. | `DisablePurviewTestContextRuleSet` | `false` | Makes test and shared-testing projects enforce the production API-surface rules too | | `PurviewPolicyExemptNoWarn` | *(derived)* | The SDK-injected `NoWarn` entries `ValidatePurviewStylePolicy` accepts | +## .NET 11 features and ServerShared + +| Property | Default | Behaviour | +| -- | -- | -- | +| `MemorySafetyRules` | `2` | C# 15 memory-safety tier on `net11.0`+ targets: `0` off, `1` preview pointer syntax (sets `LangVersion=preview` when none was chosen), `2` `updated-memory-safety-rules`. Unknown values fail with `PRCFG0001`. Never touches `AllowUnsafeBlocks` | +| `PurviewRuntimeAsync` | `true` | `runtime-async=on` on `net11.0`+ targets. `UseRuntimeAsync=false` also opts out | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a managed `Directory.Build.rsp` with `-mt`/`-nr:false`; `false` writes `-mt:false` | +| `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Whether that bootstrapped response file is written at all | +| `IsClientSideProject` | *(computed)* | `true` for a WebAssembly client, `PurviewBrowserSafe` library, Razor class library, or `*.Client`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` name. Explicit value wins | +| `PurviewAutoServerSharedReference` | `true` | Adds `../ServerShared*/ServerShared*.csproj` to a non-client-side project | +| `PurviewServerSharedReferenceCheckAsError` | `false` | Makes `PRSHR0001`/`PRSHR0002` errors | +| `DisablePurviewServerSharedReferenceCheck` | `false` | Turns `PRSHR0001`/`PRSHR0002` off | + ## Testing | Property | Default | Behaviour | @@ -119,6 +132,9 @@ reference was judged browser-safe when it cannot say so itself. | `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | | `BootstrapGlobalJsonToRepoRoot` | `true` | | | `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | +| `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Creates a managed `Directory.Build.rsp` enabling `-mt` | +| `RepositoryDirectoryBuildRspFilePath` | *(derived)* | Overrides the destination `Directory.Build.rsp` | +| `PurviewMultiThreadedBuild` | `true` | `false` rewrites a managed rsp to `-mt:false` | | `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | | `PurviewRepoBootstrapCopyRetries` | `3` | | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | @@ -136,7 +152,7 @@ nothing else was left for the mirror to deliver. Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, `PurviewSkillsRunnerCandidates`, `PurviewSkillsCommand`, `PurviewSkillsPackage`, `PurviewSkillsAgents`, `PurviewSkillsSource`, `PurviewSkillsInstallRoot`, `PurviewSkillsRepoRoot`, -`EnablePurviewSkillRecommendations`, `PurviewSkillRecommendationsInCI`. +`PurviewSkillsDryRun`, and the `PurviewSkillsAgentDirectory` item group. ## Compiler-visible properties @@ -148,7 +164,11 @@ Exported to analyzers and generators as `build_property.`: - `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` - `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`, `DisableGenerateAssemblyIdentifierClass`, `AssemblyIdentifierClassName` -- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject`, + `IsServerSharedProject`, `IsClientSideProject` +- `PurviewAutoServerSharedReference`, `DisablePurviewServerSharedReferenceCheck`, + `PurviewServerSharedReferenceCheckAsError` +- `MemorySafetyRules`, `PurviewRuntimeAsync`, `PurviewMultiThreadedBuild` - `TestingType`, `TargetProjectName` - `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` @@ -163,6 +183,6 @@ Exported to analyzers and generators as `build_property.`: - `PurviewSuppressCopyRetryWarnings` - `PurviewSkillsSourceFolder`, `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, - `PurviewSkillsRunner` + `PurviewSkillsRunner`, `PurviewSkillsDryRun` - `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, `AutoGeneratedAssemblyIdentifierFile` diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md index 8573ca40..bd80b3f5 100644 --- a/.agents/skills/purview-buildsdk-project-detection/SKILL.md +++ b/.agents/skills/purview-buildsdk-project-detection/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-project-detection -description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, ServerShared, client-side, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. license: MIT metadata: package: Purview.BuildSdk @@ -25,7 +25,8 @@ dotnet msbuild -getProperty:PurviewLogicalProjectName -getProperty:Roo The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, `PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, -`IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, +`IsTestProject`, `IsSharedProject`, `IsServerSharedProject`, `IsClientSideProject`, +`IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, `IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject`. @@ -52,11 +53,16 @@ a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each na suffix outside it still builds, runs and gets a category — it just cannot see `internal` members. The 53 names are in [references/test-types.md](references/test-types.md). -### Shared and shared-testing projects +### Shared, shared-testing and ServerShared projects -Recognised by **exact name**, never by substring. The two lists are in +Recognised by **exact name**, never by substring. The three lists are in [references/detection.md](references/detection.md). Derived: `IsSharedProject`, -`IsSharedTestingProject`. +`IsSharedTestingProject`, `IsServerSharedProject`. + +`ServerShared` is separate from `Shared` for a reason: a `Shared` project is referenced by almost +everything, including code that ends up in a browser, so server-only helpers get their own name and +are referenced automatically only by a project that cannot be downloaded. `IsClientSideProject` is the +classification that decides it. ### SDK and content based diff --git a/.agents/skills/purview-buildsdk-project-detection/references/detection.md b/.agents/skills/purview-buildsdk-project-detection/references/detection.md index 85fde09c..51243c96 100644 --- a/.agents/skills/purview-buildsdk-project-detection/references/detection.md +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -5,6 +5,20 @@ `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, `SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` +## ServerShared project names (exact) + +`ServerShared`, `ServerSharedFramework`, `ServerSharedInfrastructure`, `ServerSharedInfra`, +`ServerSharedUtilities`, `ServerSharedUtils`, `ServerSharedLibrary`, `ServerSharedLib`, +`ServerSharedHelpers` + +A `ServerShared` project is referenced automatically by every non-client-side project, and never by a +WebAssembly client, a `PurviewBrowserSafe` library, a Razor class library, a project named with a +client-side segment (`*.Client`, `*.ClientShared`, `*.Browser`, `*.Wasm`, `*.WebAssembly`, +`*.Components`, or exactly `Components`/`ClientShared`), or a `Shared`/`SharedTesting` project. +`IsClientSideProject` overrides the classification. `PRSHR0001` reports a client-side reference and +`PRSHR0002` a `Shared` reference; `PurviewBrowserSafe="true"` on the reference records an accepted +decision. + ## Shared testing project names (exact) `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, @@ -64,7 +78,8 @@ dual-role component that wants normal library symbol packaging. | Container | `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, container tooling packages | | Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless marked as a separate web-project mode | | Aspire host | `OutputType=Exe` | -| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared`/`ServerShared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| ServerShared project | referenced automatically by every non-client-side project; never by a WebAssembly client, browser-safe library, Razor class library, client-side name, or `Shared`/`SharedTesting` project | | Blazor host | an automatic reference to its `.Client` - see [blazor.md](blazor.md) | | BFF | no packages, no OpenAPI interceptor unless it exposes an API, `PDS0008`/`PDS0009` - see [blazor.md](blazor.md) | | `PurviewBrowserSafe` library | `IsAotCompatible`, `SupportedPlatform browser`, trim/AOT/`CA1416` as errors - see [blazor.md](blazor.md) | diff --git a/.agents/skills/purview-buildsdk-repository-review/SKILL.md b/.agents/skills/purview-buildsdk-repository-review/SKILL.md index ce1fd294..2be4c08f 100644 --- a/.agents/skills/purview-buildsdk-repository-review/SKILL.md +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-repository-review -description: Review a repository that uses Purview.BuildSdk for naming, placement and test-structure alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +description: Review a repository that uses Purview.BuildSdk for naming, placement, test-structure and agent-skill-management alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. license: MIT metadata: package: Purview.BuildSdk @@ -89,7 +89,24 @@ go stale: is not any more. - A package the SDK already adds, restated in a project file, is maintenance cost with no effect. -## 6. Explain consequences, not just mismatches +## 6. Check how agent skills are managed + +Only relevant when the repository has a `skills-lock.json`, an agent skills directory, or a package +reference that ships skills: + +- **Anything that runs `InstallPurviewSkills`, `UpdatePurviewSkills` or `FixPurviewSkills` + implicitly** — an `AfterTargets`/`BeforeTargets` hook, a `*DependsOn` property, a git hook, a + `just` recipe dependency — is structural debt: the SDK fails it with `PRSKL0009`, and skill changes + should only happen when somebody asks. +- **Hand-written skill scripts** (`update-agent-skills.ts`, `ensure-universal-skills.ts`, + `package.json` `skills:*` entries) are an incremental opportunity: the SDK targets replace them, and + pin package skills to their package's commit where the scripts install latest. See + `purview-buildsdk-skills-justfile`. +- **Drift**: run `dotnet msbuild -t:RecommendPurviewSkills` and quote any `PRSKL0010`/`PRSKL0011`. +- **Layout**: if `PurviewSkillsAgents` is set, run `-t:CheckPurviewSkills` and quote any `PRSKL0015`. + If it is not set, every agent directory is legitimate — that is acceptable variance, not a finding. + +## 7. Explain consequences, not just mismatches For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, `AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the diff --git a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md index 66c20a33..5c2694dc 100644 --- a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-skill-installation -description: Install, list and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, ListPurviewSkills and RecommendPurviewSkills MSBuild targets, for any PRSKL0001-PRSKL0014 diagnostic, for choosing a package runner (npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +description: Install, update, list, check and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, UpdatePurviewSkills, CheckPurviewSkills, FixPurviewSkills, ListPurviewSkills, RecommendPurviewSkills and VerifyPurviewSkills MSBuild targets, for updating skills after a package upgrade, for choosing agents with PurviewSkillsAgents, for any PRSKL diagnostic, for choosing a package runner (bun x, npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. license: MIT metadata: package: Purview.BuildSdk @@ -9,7 +9,8 @@ metadata: # Installing the skills a package ships -For *authoring* skills, use `purview-buildsdk-skill-tests`. +For *authoring* skills, use `purview-buildsdk-skill-tests`; for `just` recipes that run these +targets, `purview-buildsdk-skills-justfile`. ## The one thing to understand first @@ -30,10 +31,22 @@ restore, a build, a design-time build or a test run. ```bash dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +dotnet msbuild -t:UpdatePurviewSkills # re-add every skills-lock.json source: refresh, and add new skills +dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist +dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory ``` -Neither target has `BeforeTargets` or `AfterTargets`. If skills are appearing or changing on their -own, something has been wired up that should not be. +They run **only when named**. None has `BeforeTargets` or `AfterTargets`, nothing in the SDK depends +on them, and `InstallPurviewSkills`, `UpdatePurviewSkills` and `FixPurviewSkills` refuse to run when +something else pulls them into a build, a restore or a design-time build (`PRSKL0009`). Do not hook +them into a build, a `*DependsOn` property or a git hook. If skills are changing on their own, +something has been wired up that should not be. + +Name **the solution**. Skills belong to the repository, so each target runs once however many +projects the solution holds: every project hands the work to the same elected leader, and MSBuild +runs it once. Package resolution reads every project in the solution, so a package only one project +references is still found. A single project works too, but sees only its own packages. For `just` +recipes that run these targets, see `purview-buildsdk-skills-justfile`. ## How a skill is resolved, and why the version is always right @@ -48,6 +61,57 @@ So the resolved ref is the commit the *restored package version* was built from. `PackageReference` gets that version's skills, not whatever is on the default branch. Compatibility is exact by construction rather than by convention. +## Updating + +```bash +# after bumping the PackageReference +dotnet restore +dotnet msbuild -t:UpdatePurviewSkills +``` + +`UpdatePurviewSkills` re-adds every unique source recorded in `skills-lock.json` with `skills add`: + +- a source a restored package ships is re-added at **that package's commit**, naming every skill + that package version ships — the same pinned `add` `PRSKL0011` recommends, so the skills match the + package version referenced; +- every other source is re-added at its latest with `--skill "*"`. + +That is the whole update. `skills add` reinstalls every skill it selects, so one re-add both +refreshes the installed skills and adds any the source has gained; `skills update` would only +re-fetch what the lockfile already lists, and is never run. `-p:PurviewSkillsDryRun=true` prints every +command and runs none. Each failed source is named (`PRSKL0008`). + +`InstallPurviewSkills` also works as an update for package skills alone: it re-adds each at its +package's commit. Do not reach for a bare `skills update` instead; see below. + +## Choosing agents + +`PurviewSkillsAgents` is **empty by default**, which passes no `--agent` and lets the CLI auto-detect +the agents on the machine. Set it to choose — in a project file `universal`, +or on a command line with the separator escaped or the value quoted, because `dotnet msbuild -p:` +itself splits on `,` and `;`: + +```bash +dotnet msbuild -t:UpdatePurviewSkills -p:PurviewSkillsAgents=claude-code%2Ccodex +``` + +Each agent becomes its own `--agent` flag; the CLI does not split a comma-separated value. + +When `PurviewSkillsAgents` is set, `CheckPurviewSkills` fails (`PRSKL0015`) for any agent skill +directory at the repository root that belongs to an agent it does not name. `.agents/skills` is always +allowed — it is the CLI's canonical store — and the authored `skills/` folder is never touched. +`FixPurviewSkills` removes links (symlinks, junctions) from those directories, never their targets, +and then any folder left empty; a real file is kept and reported, because it is not provably the +CLI's. The agent-to-directory map is the `PurviewSkillsAgentDirectory` item group, taken from the +CLI's own agent table; an agent it does not know is `PRSKL0016`, fixed by adding an item: + +```xml + +``` + +With `PurviewSkillsAgents` empty, both targets say so and do nothing: every agent directory is then a +legitimate destination. + ## Installing from an explicit source For a skill you want but have no `PackageReference` for: @@ -66,58 +130,78 @@ The caller owns the ref here — append `#` to pin it. This path needs no r | `PRSKL0001` | `project.assets.json` is missing | Restore. Resolution reads the restore graph and deliberately does not trigger a restore of its own | | `PRSKL0002` | The package's nuspec has no GitHub repository URL, or no commit | The package cannot be pinned. It is reported rather than installed from a guessed branch | | `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=` | -| `PRSKL0004` | No skills CLI and no package runner found | Install Node, or set `PurviewSkillsCommand` | +| `PRSKL0004` | No skills CLI and no package runner found | Install Bun or Node, or set `PurviewSkillsCommand` | | `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | -| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `npx skills add ...` | +| `PRSKL0006` | Retired, with `PurviewSkillsUpdateMode` | Remove the property; update no longer runs `skills update` | +| `PRSKL0007` | `UpdatePurviewSkills` found no `skills-lock.json` | Install first with `-t:InstallPurviewSkills` | +| `PRSKL0008` | Re-adding a lockfile source failed | Read the CLI output above it | +| `PRSKL0009` | Install, update or fix was pulled into a build, a restore or a design-time build | Remove the hook; run the target on its own | +| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `bun x skills add ...` | | `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | | `PRSKL0012` | A skill cannot be verified | Message | | `PRSKL0013` | An orphaned lockfile entry | Message | | `PRSKL0014` | Recorded in the lockfile but never restored | Message | +| `PRSKL0015` | An agent skill directory exists for an agent `PurviewSkillsAgents` does not name | `-t:FixPurviewSkills`, then remove anything it kept by hand | +| `PRSKL0016` | `PurviewSkillsAgents` names an agent with no known skills directory | Warning. Add a `PurviewSkillsAgentDirectory` item | +| `PRSKL0017` | Projects in the solution reference one skill-shipping package at different versions | Warning. Align the versions; only one version's skills can be installed | + +## Nothing about skills runs during a build -## Recommendations run during an ordinary build +This used to be the opposite: a `RecommendPurviewSkillsOnBuild` hook ran on `AfterTargets="Build"`. +It was removed after measurement — on a 39-project solution it read and regex-scanned every project's +`project.assets.json` (≈500 KB, 256 libraries each), made thousands of failing `Directory.Exists` +calls, read the lockfile and walked the repository root, on every build, to print advice that a +repository managing its own skills had already acted on. All of its inputs are repository-scoped, so +the per-project cost bought nothing. -This is the **one** skill feature that does. `RecommendPurviewSkillsOnBuild` runs after `Build` and -delegates to `RecommendPurviewSkills`, which can also be invoked by name: +The work is now paid only when somebody names a target: ```bash -dotnet msbuild -t:RecommendPurviewSkills +dotnet msbuild -t:RecommendPurviewSkills # report, never fail +dotnet msbuild -t:VerifyPurviewSkills # report, and fail on PRSKL0010/0011 ``` `EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own `skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. -Four things keep it quiet, and all of them are load-bearing: +`VerifyPurviewSkills` is the gate to call from CI. It escalates only `PRSKL0010` (missing) and +`PRSKL0011` (drifted) to errors: both are factual, both are fixed by one command, and neither can fire +without a referenced package that really ships skills. `PRSKL0012` stays a warning because a package +published without repository metadata is not the consumer's to fix, and `PRSKL0013`/`PRSKL0014` stay +messages. Failing over something the caller cannot clear is how a gate gets switched off. + +Three things shape the output: -- Design-time builds, CI builds, non-default TFM passes and - `EnablePurviewSkillRecommendations=false` are skipped outright. - `PurviewSkillRecommendationsInCI=true` re-enables CI. -- A fingerprint stamp at `/.purview/skill-recommendations.cache` means a given state is - reported once per repository, not once per project and again on every rebuild. The explicit target - ignores the stamp. - Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are - **messages**. No amount of skill advice can break a build using `TreatWarningsAsErrors`. + **messages**. `VerifyPurviewSkills` turns missing and drifted into failures, and promotes + unverifiable to a warning. - A correctly installed set produces no output at all. +- There is no deduplication stamp any more. `EnablePurviewSkillRecommendations`, + `PurviewSkillRecommendationsInCI` and `PurviewSkillRecommendationStampPath` were retired with the + hook: a stamp existed so a per-build hook could report one state once per repository, and a target + a person invokes should answer every time it is asked. Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so only an entry under an owner the project references — but from a repository it no longer references — is reported. A skill from any other owner is the developer's own, and is never mentioned. -### Drift never recommends `npx skills update` +### Drift never recommends a bare `skills update` -`update` installs the *newest* published skill, which is only correct when the newest skill happens -to describe the referenced package version. `PRSKL0011` names the referenced package's own commit in -a pinned `npx skills add` instead. +Re-adding a source without a ref installs the *newest* published skill, which is only correct when +the newest skill happens to describe the referenced package version. `PRSKL0011` names the referenced +package's own commit in a pinned `skills add` instead — which is what `-t:InstallPurviewSkills` and +`-t:UpdatePurviewSkills` run for package skills. ## Package runners The CLI is published to npm, and every runner spells "run this npm package" differently. -`PurviewSkillsRunner` selects one, defaulting to `npx` because npx ships with Node itself. +`PurviewSkillsRunner` selects one, defaulting to `bun` (`bun x skills`). | Runner | Invocation | | -- | -- | +| `bun` *(default)* | `bun x skills@1` | | `npx` | `npx -y skills@1` | | `bunx` | `bunx skills@1` | -| `bun` | `bun x skills@1` | | `pnpm` | `pnpm dlx skills@1` | | `yarn` | `yarn dlx skills@1` — Yarn 2+ only | | `deno` | `deno run -A npm:skills@1` | @@ -156,11 +240,9 @@ two-pass behaviour and its home-directory bound are both load-bearing. The CLI owns the agent-specific destinations and their discovery conventions, which differ per agent and change independently of this SDK. It also owns `skills-lock.json`, so a repository can restore -the same set later with `npx skills experimental_install`. - -`PurviewSkillsAgents` (default `*`) narrows the target agents. +the same set later with `bun x skills experimental_install`. -The lockfile is read **directly** rather than by parsing `npx skills list --json`, because that +The lockfile is read **directly** rather than by parsing `skills list --json`, because that listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs no Node tooling, which is what keeps recommendations working on a machine that can build .NET but has no Node. There is deliberately no competing lockfile. diff --git a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md index e90b695c..d5561745 100644 --- a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -44,7 +44,9 @@ an agent directory somewhere that is not the repository. A property a dependency computes is still empty in the depending target's `Condition`. That is why the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on -`RecommendPurviewSkills` — and equally why the build hook's CI and design-time gating works at all. +`RecommendPurviewSkills`, and why the explicit-only guard (`PRSKL0009`) is a dependency target with +its own `Error` rather than a `Condition` on `InstallPurviewSkills`, `UpdatePurviewSkills` or +`FixPurviewSkills`. ## The managed port diff --git a/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md b/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md new file mode 100644 index 00000000..2da36a29 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md @@ -0,0 +1,177 @@ +--- +name: purview-buildsdk-skills-justfile +description: Add or update Justfile recipes that run Purview.BuildSdk's agent-skill targets - skills-update, skills-check, skills-fix, skills-install, skills-recommend and skills-verify. Use when a repository wants a just recipe to update, install, check or clean up its agent skills, when replacing hand-written skill scripts (update-agent-skills.ts, ensure-universal-skills.ts, package.json skills:* scripts) with the SDK targets, or when an existing skills recipe runs the wrong project, the wrong runner or the wrong agents. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Justfile recipes for the agent-skill targets + +The recipes are thin: each one names one SDK target. The behaviour lives in the targets, which are +covered by `purview-buildsdk-skill-installation`; this skill is about wiring them into a `Justfile` +correctly. + +## The rule that matters most + +**Every skill recipe is run by a person, on purpose.** `InstallPurviewSkills`, `UpdatePurviewSkills` +and `FixPurviewSkills` change the repository, and the SDK refuses to run them inside a build, a +restore or a design-time build (`PRSKL0009`). Keep the Justfile consistent with that: + +- never make a skills recipe a dependency of `build`, `test`, `default` or any other recipe; +- never call `skills-update`, `skills-install` or `skills-fix` from a git hook (`lefthook.yml`, + `.husky/`) or a CI job that commits; +- `skills-verify` is the one recipe CI may call: it is read-only and fails on missing or drifted skills. + +## Recipes + +Put them in their own file beside the `Justfile`, and import it from the `Justfile`: + +```just +# Justfile +import "Justfile.skills.just" +``` + +The only line that differs between repositories is `skills_project`. Work it out from the repository +as described in [Choosing `skills_project`](#choosing-skills_project) — do not ask for it, and do not +leave a placeholder: + +```just +skills_project := solution_file # or a path, when the Justfile has no such variable + +# Update agent skills: re-add every skills-lock.json source, refreshing installed skills and adding new ones +[group('Agent skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} + +# Install the skills the referenced packages ship (pass -p:PurviewSkillsSource=owner/repo for others) +[group('Agent skills')] +skills-install *args: + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -v:n {{ args }} + +# Report missing, drifted or unverifiable skills without changing anything +[group('Agent skills')] +skills-recommend *args: + dotnet msbuild "{{ skills_project }}" -t:RecommendPurviewSkills -v:n {{ args }} + +# Fail when a referenced package's skills are missing or drifted - the CI gate +[group('Agent skills')] +skills-verify *args: + dotnet msbuild "{{ skills_project }}" -t:VerifyPurviewSkills -v:n {{ args }} +``` + +Only add the recipes the repository will use. `skills-update` and `skills-check` are the usual pair. + +### Choosing `skills_project` + +Take the first of these that applies: + +1. **The `Justfile` already names the solution in a variable** — `solution_file`, `solution`, `sln` + or similar, typically used by `build`, `test` and `restore`. Reference it: + `skills_project := solution_file`. An imported file shares the importing file's variables, so the + skill recipes then follow the solution if it is ever renamed or moved, with nothing to keep in step. +2. **An existing recipe runs `dotnet build`, `test` or `restore` on a literal solution path.** Use that + path. If several recipes repeat it, offer to lift it into a `solution_file` variable and reference + that, but do not restructure the `Justfile` unasked. +3. **No `Justfile`, or one that never names a solution.** Search for `*.slnx` and `*.sln`, ignoring + `bin/`, `obj/`, `node_modules/` and `artifacts/`. If there is exactly one, use its path relative to + the `Justfile`. With several, prefer the one the CI workflow or README builds; ask only if that + still leaves more than one. +4. **No solution at all.** Name a project that imports the SDK and references every package that + ships skills, and say in a comment that it sees only its own packages. + +An existing `Justfile.skills.just` pointed at a hand-picked project gets the same treatment: replace +the project with the solution, chosen as above. Remove any comment explaining why that project was +picked, because the reason no longer holds. + +### Details that break a recipe if missed + +- **Name the solution.** Every skill target runs once for the whole solution, however many projects + it holds: each project hands the work to the same elected leader, and MSBuild runs it once. Package + resolution reads every project in the solution, so a package only a test project references is + still pinned. There is no project to choose and nothing to keep in step as projects come and go. + A single project also works, but sees only that project's packages. (SDK releases before this + behaviour ran the target once per project; there, name one project that references every package + that ships skills.) +- **Restore first.** The targets read `project.assets.json` and never restore on their own + (`PRSKL0001`). A fresh clone needs `dotnet restore` (or `just restore`) before `skills-update`. +- **Quote a multi-target `-t:`.** `"-t:UpdatePurviewSkills;FixPurviewSkills"` — unquoted, `sh` treats + the `;` as the end of the command and runs `FixPurviewSkills` as a separate, failing command. +- **Keep `*args` last** so a caller can pass properties: `just skills-update -p:PurviewSkillsDryRun=true`. +- `FixPurviewSkills` after `UpdatePurviewSkills` does nothing unless `PurviewSkillsAgents` is set, so + it is safe in the recipe either way. + +## Runner and agents belong in MSBuild, not in the recipe + +Set repository-wide choices once in `Directory.Build.props`, not as flags repeated in each recipe, +so a plain `dotnet msbuild -t:...` behaves the same as the recipe: + +```xml + + + bun + + universal + +``` + +One-off overrides go on the command line. `dotnet msbuild -p:` splits values on `,` and `;`, so +escape the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +Useful properties to pass through `*args`: + +| Property | Effect | +| -- | -- | +| `PurviewSkillsDryRun=true` | Print every CLI command; run none | +| `PurviewSkillsRunner=npx` | Launch the CLI through another runner (`bunx`, `pnpm`, `yarn`, `deno`) | +| `PurviewSkillsSource=owner/repo#ref` | `skills-install` from an explicit source with no package behind it | + +## Replacing hand-written skill scripts + +Repositories often carry their own scripts for this. The SDK targets replace them: + +| Hand-written | Replace with | +| -- | -- | +| `scripts/update-agent-skills.ts`, `"skills:update"` | `skills-update` (`UpdatePurviewSkills`) | +| `--update` / `--no-update` | Nothing: the re-add refreshes installed skills and adds new ones, so there is no separate `skills update` step to switch | +| `--dry-run` | `-p:PurviewSkillsDryRun=true` | +| `scripts/ensure-universal-skills.ts check`, `"skills:check"` | `skills-check` with `PurviewSkillsAgents=universal` | +| `scripts/ensure-universal-skills.ts fix`, `"skills:fix"` | `skills-fix` | +| a hard-coded `-a universal` | `PurviewSkillsAgents` — or leave it empty to auto-detect | + +Two behaviours differ, deliberately — mention them when migrating: + +- A source a referenced package ships is re-added **at that package's commit**, not at latest, so its + skills keep matching the package version. Other sources are re-added at latest, as before. +- Update is `skills add`, not `skills update`, so it also installs skills a source has **gained**: a + package source gets every skill its referenced version ships, and any other source is re-added with + `--skill "*"`. + +After switching, delete the scripts and their `package.json` entries, and remove `package.json` +itself if nothing else uses it. Run `just skills-update -p:PurviewSkillsDryRun=true` and compare the +printed commands with what the old script ran before deleting anything. + +## Updating an existing Justfile + +1. Read the whole `Justfile` first: match its group names, comment style, and any `set shell` + (a PowerShell shell still needs the `-t:` value quoted). +2. Keep recipe names the repository already uses; change only what they run. If `skills-check` + already means "report", rename it to `skills-recommend` rather than silently changing its meaning, + and say so. A recipe pointed at a hand-picked "small" project, to avoid running once per project, + can point at the solution instead. +3. Check nothing depends on a changing recipe: `just --list`, then search the Justfile, CI workflows + and hook configuration for the recipe name. +4. Verify: `just --list` shows the group, `just skills-update -p:PurviewSkillsDryRun=true` prints the + commands, and `just skills-check` passes or names a real problem. diff --git a/.agents/skills/purview-component-compatibility/SKILL.md b/.agents/skills/purview-component-compatibility/SKILL.md new file mode 100644 index 00000000..b017681a --- /dev/null +++ b/.agents/skills/purview-component-compatibility/SKILL.md @@ -0,0 +1,116 @@ +--- +name: purview-component-compatibility +description: >- + Use when developing, reviewing or releasing a published component - a NuGet package, or an npm + package - and choosing which dependency version to declare, when a consumer reports a version + conflict or an unexpected upgrade, or when a component's supported target frameworks change. Applies + the golden rule that a published component declares the LOWEST compatible version rather than the + latest, walks the Purview component hierarchy to find each floor, and verifies it by building + against it. Not for applications: they take the latest compatible version instead. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview component compatibility + +A published component makes a promise with every version it declares: *this is the least I require of +you*. Raise that number and every consumer is pulled up with you, whether or not they wanted it. + +**The golden rule: declare the lowest version that compiles, passes your tests, and is supported on +every target framework you claim.** The version you happen to have installed is never the answer, and +neither is the latest. + +## Which direction? + +| What you are publishing | Declare | Why | +| -- | -- | -- | +| an application, a test suite, a tool — it runs, and nobody installs it | the **latest** compatible version | you own the runtime, and a version you skip is a fix you did not take | +| a component — a NuGet or npm package somebody else installs | the **lowest** compatible version | dependency resolution takes the highest minimum in the graph, so your floor becomes theirs | + +This skill is about the second row. If the repository only ever runs, stop here and take the latest. + +## Why a floor matters + +NuGet and npm resolve a graph to the **highest** minimum among all constraints. If a package declares +`Testcontainers` 4.16 because 4.16 is what its author had installed, every consumer is forced onto 4.16, +including the one whose own constraint is 4.0 and whose runtime is pinned to it. The consumer cannot +refuse an upgrade the package never needed to ask for, and the symptom appears in *their* build, not +yours. + +## The workflow + +### 1. Name the component and its frameworks + +The floor is per target framework, not per package. Write down every `TargetFramework` the component +publishes; a multi-targeting component has a different floor on each, and the one that matters is the +oldest framework still supported, not the newest one you build against. + +### 2. Read the hierarchy, do not remember it + +The dependencies — and their minimums — are in the published `.nuspec`, not in anyone's memory: + +```bash +node scripts/dependencies.mjs Purview.Containers +node scripts/dependencies.mjs Purview.Architecture.Testing --tfm net11.0 +node scripts/dependencies.mjs Purview.Containers --walk +``` + +A bare version is a **minimum**; a bracketed one (`[1.2.3]`, `[1.0,2.0)`) is a range. The script prints +the distinction, because it is the whole rule: a minimum you raise is a minimum every consumer inherits. + +`references/purview-components.md` records which Purview package depends on which, and which target +frameworks each family supports. Where the two disagree the registry wins — that file is the map, the +`.nuspec` is the territory. + +### 3. Fix the floor, per framework + +For each dependency, find the oldest version that: + +- exposes every API the component calls (the compiler decides this, not the changelog); +- passes the component's own tests and its consumer-verification suite; +- is supported on every framework in that dependency group. + +Then declare that version. Not `latest`, not floating (`1.*`), and never the version that resolved on +your machine by accident. + +### 4. Prove the floor + +A floor nobody builds against is a guess. Add a CI leg that restores the component with every dependency +held at its declared floor and runs the suite — the ordinary leg keeps resolving the latest, which is +what a consumer will actually get. `references/lowest-compatible.md` has the mechanics. + +### 5. Keep the ceiling visible, and the floor written down + +A component's README carries a badge for the current release and a sentence naming the floor it +supports. Badges are generated from the registry, so they cannot go stale; a version written into prose +is wrong within one release. + +## Pitfalls that produce a floor nobody meant + +| Pitfall | What it looks like | The fix | +| -- | -- | -- | +| Declaring what is installed | `dotnet add package X` writes the latest; right for an application, wrong for a component | edit the version down, then prove it in step 4 | +| Transitive uplift | a dependency pulls in a newer transient package than your floor allows | override it deliberately — the persistence sample does this for a security-patched package — and comment why | +| One framework's floor | a `net10.0-windows` group needs a higher floor than `net10.0` | read each group; never average them | +| Prerelease in the graph | a family that ships prereleases depends on them at the same version | pin the family to one property so the floor moves together | +| Analyzer and source-generator packages | compile-time only, so they rarely constrain a consumer | still declare a real floor; they appear in the graph and in transitive audits | +| Floating versions | `1.*` resolves differently on every restore | banned: a floor is a number | + +## Checks before you answer + +- Is each declared version the lowest that passes, or the one that happened to be installed? +- Does every declared target framework have a floor that compiles on that framework? +- Was the floor proved by a build, or only by your machine? +- Does any page state a version number? It should be a badge or a link to the registry. +- If a floor was raised, is that a breaking change the version number admits to? + +## See also + +`references/lowest-compatible.md` for the CI mechanics and the override patterns, and +`references/purview-components.md` for the Purview hierarchy. The rule itself, and the application-side +half of it, is stated in the Engineering Playbook's Dependency Versions page; the +`purview-engineering-principles` skill summarises it as one of the eleven conventions. + diff --git a/.agents/skills/purview-component-compatibility/references/lowest-compatible.md b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md new file mode 100644 index 00000000..ccdb3b69 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md @@ -0,0 +1,93 @@ +# Proving the floor + +The rule is "declare the lowest compatible version". This file is the mechanics: how to find the floor, +how to prove it in a build, and the patterns that keep it from silently rising. + +## What "compatible" means, precisely + +A version is a valid floor when all three hold: + +1. **It compiles against every API the component uses.** The compiler decides this, not a changelog. If + you cannot say which member forced the version up, you have not found the floor. +2. **The component's own tests pass against it**, including the consumer-verification suite, which + builds a throwaway consumer rather than the component's own test project. +3. **It is supported on every target framework in that dependency group.** A floor is per group, and a + multi-targeting component can have several. + +## Finding it + +```bash +dotnet list package --outdated # what you declared against what exists +dotnet list package --include-transitive # what actually resolved +dotnet nuget why # which path pulled a version in +``` + +Then work downward: lower the declared version, build, and read the compiler. The first version that +compiles *and* passes is the floor. Do not stop at the first failure and pick the version above it — +that is how a floor ends up one minor above what the code needs. + +## Making the floor a build leg + +Keep one property per family (so a family cannot be bumped halfway) and make each conditional on a floor +switch. Nothing here is NuGet-specific magic: it is MSBuild, and central package management already +gives you the single place to put it. + +```xml + + LATEST + FLOOR + +``` + +```bash +dotnet restore -p:DependencyFloor=true +dotnet build -p:DependencyFloor=true --no-restore +dotnet test -p:DependencyFloor=true --no-build +``` + +`LATEST` and `FLOOR` are literals in the file; the point is that they are two properties, not two files. +Run the ordinary leg at `LATEST` and a second, slower leg at `FLOOR`, and give the floor leg its own CI +job so a failure names the floor rather than the build. + +Where a component multi-targets, add the framework dimension and run the pair per framework: + +```bash +for tfm in net8.0 net9.0 net10.0; do + dotnet build -p:DependencyFloor=true -f "$tfm" +done +``` + +## Transitive pins + +A floor can be raised by something you never declared. Transitive pinning puts those versions where you +can see them: + +```xml + + true + +``` + +Then an override is a deliberate line with a reason beside it — the persistence sample in this +repository does exactly that for a security-patched package the SQL client drags in. An override without +a comment is indistinguishable from an accident, and the next person deletes it. + +## The ceiling, and the API surface + +- The floor leg proves the **bottom**; the ordinary leg proves the **top**. Both, or you know neither. +- An API-compatibility check against the previous release catches the other direction of the same + mistake: a floor can stay put while the public surface grows a member that only the newest dependency + provides. `Microsoft.DotNet.ApiCompat.Task` wires that into the build; `Microsoft.DotNet.ApiCompat.Tool` + runs it from a pipeline. +- A prerelease dependency is a floor with an expiry: it cannot be consumed by anyone who has not opted + into prereleases. Say so in the package's README rather than letting the error message explain it. + +## What not to do + +| Temptation | Why it is wrong | +| -- | -- | +| `dotnet add package X` and commit | it writes the latest; correct for an application, wrong for a component | +| floating versions (`1.*`) | resolves differently on every restore, so the floor is nobody's decision | +| "it restores on my machine" | that proves one graph, on one date, with one SDK | +| raising a floor in a patch release | the consumer's build changes without their version changing | +| one floor leg for all frameworks | the framework with the lowest floor is the one nobody tested | diff --git a/.agents/skills/purview-component-compatibility/references/purview-components.md b/.agents/skills/purview-component-compatibility/references/purview-components.md new file mode 100644 index 00000000..6e13b659 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/purview-components.md @@ -0,0 +1,60 @@ +# The Purview component hierarchy + +Which Purview package depends on which, and which target frameworks each family supports, so a harvest +or a floor decision starts from the shape rather than from a memory of it. + +Every row was read from the registry with `scripts/dependencies.mjs`, which reads the published +`.nuspec`. **The registry is the authority; this file is the map.** Re-run the script before acting on +any row — a family that multi-targets changes its floors on every release. + +## The families + +| Family | Packages | Target frameworks | Depends on | +| -- | -- | -- | -- | +| Build SDK | `Purview.BuildSdk` | `netstandard2.0` (an MSBuild SDK) | nothing | +| Test-support rule sets | `Purview.Skills.Testing`, `Purview.Architecture.Testing`, `Purview.Architecture.Testing.Cli` | `net10.0`; `Architecture.Testing` also `net11.0` | `TUnit.Core`, `TUnit.Assertions`, `JsonSchema.Net` | +| Telemetry | `Purview.Telemetry.SourceGenerator` | `netstandard2.0` (an analyzer) | nothing | +| Containers | `Purview.Containers` + `.Core`, `.Docker`, `.Wsl`, `.MsSql`, `.PostgreSql` | `net10.0`; `.Wsl` adds `net10.0-windows10.0.19041` | its own family at the same version, `Microsoft.Extensions.Telemetry.Abstractions`, `Testcontainers` | +| Results | `Purview.Results`, `.AspNetCore`, `.ZodSharp`, `.ZodSharp.AspNetCore` | `net10.0`, `net11.0` | nothing; the adapters depend on the core and on ZodSharp | +| ZodSharp | `Purview.ZodSharp`, `.AspNetCore`, `.SystemTextJson`, `.NewtonsoftJson` | `net9.0`, `net10.0`, `net11.0` | `Microsoft.Extensions.Options`, `Microsoft.Extensions.DependencyInjection.Abstractions` | +| Value Objects | `Purview.ValueObjects` | `net8.0`, `net9.0`, `net10.0`, `net11.0` | nothing | +| Source Generator Framework | `Purview.SourceGeneratorFramework`, `.Testing`, `.Testing.TUnit` | `netstandard2.0` | Roslyn (`Microsoft.CodeAnalysis.CSharp`), `System.Collections.Immutable`, `System.Reflection.Metadata` | +| Aspire ResourceKit | `Purview.Aspire.ResourceKit` | `net8.0`, `net9.0`, `net10.0` | `Aspire.Hosting`, OpenTelemetry exporter and hosting, `Microsoft.Extensions.*` | +| Astroc4 | `@purview-dev/astroc4` (npm, not NuGet) | an Astro/Starlight plugin | `likec4`, `@astrojs/starlight`, `astro` as peers | + +## Reading the shape + +Three patterns are worth internalising, because each one changes what "the floor" means: + +- **A family ships at one version.** `Purview.Containers.Core` is required at the umbrella's own version, + and `Purview.Results.ZodSharp` requires `Purview.Results` and `Purview.ZodSharp` at their current ones. + Declare the family with one property, so its floor moves as a unit. +- **An analyzer has no consumer-side floor worth arguing about.** `Purview.BuildSdk`, + `Purview.Telemetry.SourceGenerator` and `Purview.SourceGeneratorFramework` target `netstandard2.0` and do + their work at compile time. They still appear in a transitive audit, so they still get a real version. +- **A multi-targeting component has a different floor per framework**, and ZodSharp shows it most + clearly: the `net11.0` group declares older `Microsoft.Extensions.*` minimums than the `net10.0` group, + because the lower framework carries the newer requirements. Reading one group and applying it to + another is how a floor is set wrong in the direction that breaks consumers. + +## What the script prints + +```text +Purview.Containers 1.0.0-prerelease.11 + net10.0: Purview.Containers.Core >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Docker >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Wsl >= 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 + net10.0: Testcontainers >= 4.16.0 + Purview.Containers.Core 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 +``` + +A bare version is a minimum; brackets would mean a range. The numbers above are what the registry said +when this page was written — the shape is the part that lasts, which is why the command is here and the +versions are not presented as targets. + +## Related components that are not Purview packages + +`AspireC4.Hosting` is published under its own name and versions on its own schedule, so it is not part of +a Purview family floor. Read it the same way, with the same script. diff --git a/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs new file mode 100644 index 00000000..3069ef62 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * Prints a package's dependency groups exactly as NuGet declares them, so the floor a component + * inherits can be read rather than guessed. + * + * The golden rule of package development is to declare the LOWEST compatible version, and that decision + * needs the real graph: which dependency, at what minimum, on which target framework. This script reads + * it from the registry - the `.nuspec` the published package carries - instead of from a table that + * would be stale by the next release. + * + * Zero dependencies: it uses the platform `fetch`, so it runs anywhere Node 18 or later does. + * + * node dependencies.mjs Purview.Containers + * node dependencies.mjs Purview.Architecture.Testing --tfm net11.0 + * node dependencies.mjs Purview.Containers --walk # recurse into Purview.* dependencies + * node dependencies.mjs Purview.Results --json + * + * An npm package is not a NuGet id; read it with `npm view versions --json`. + * + * Exit codes: 0 on success, 1 when the package, version or network is not available. + */ + +/** The NuGet flat-container root: the same host that serves the package's own `.nuspec`. */ +const REGISTRY = "https://api.nuget.org/v3-flatcontainer"; + +/** How deep `--walk` follows dependencies before it stops. */ +const MAX_DEPTH = 3; + +/** True when a version string carries a prerelease label. */ +function isPrerelease(version) { + return version.includes("-"); +} + +/** Every published version of a package, oldest first. */ +async function versionsOf(id) { + const response = await fetch(`${REGISTRY}/${id.toLowerCase()}/index.json`); + if (!response.ok) { + throw new Error(`${id} is not on nuget.org (HTTP ${response.status}).`); + } + + const body = await response.json(); + return body.versions; +} + +/** The version to describe: the one asked for, else the newest stable, else the newest anything. */ +function chooseVersion(available, requested) { + if (requested) { + const match = available.find((version) => version.toLowerCase() === requested.toLowerCase()); + if (!match) { + throw new Error(`'${requested}' is not a published version. Newest is ${available.at(-1)}.`); + } + + return match; + } + + const stable = available.filter((version) => !isPrerelease(version)); + return stable.at(-1) ?? available.at(-1); +} + +/** The package's `.nuspec`, which is what a consumer's restore actually reads. */ +async function readNuspec(id, version) { + const name = id.toLowerCase(); + const response = await fetch(`${REGISTRY}/${name}/${version.toLowerCase()}/${name}.nuspec`); + if (!response.ok) { + throw new Error(`No .nuspec for ${id} ${version} (HTTP ${response.status}).`); + } + + return response.text(); +} + +/** The value of one XML attribute on a tag, or null. */ +function attribute(tag, name) { + const match = new RegExp(`${name}\\s*=\\s*"([^"]*)"`).exec(tag); + return match ? match[1] : null; +} + +/** + * How a nuspec version string constrains a consumer: a bare version is a MINIMUM, and a bracketed one is + * a range. This is the distinction the whole rule turns on, because a minimum you raise is a minimum + * every consumer inherits. + */ +function classify(range) { + if (!range) { + return "any"; + } + + return range.startsWith("[") || range.startsWith("(") ? "range" : "minimum"; +} + +/** The dependency elements inside a fragment, normalized. */ +function dependenciesIn(fragment) { + const dependencies = []; + const pattern = /]*)\/?>/g; + let match; + + while ((match = pattern.exec(fragment)) !== null) { + const tag = match[1]; + const version = attribute(tag, "version"); + dependencies.push({ id: attribute(tag, "id"), version, constraint: classify(version) }); + } + + return dependencies; +} + +/** The dependency groups a nuspec declares, one per target framework. */ +function dependencyGroups(nuspec) { + const groups = []; + const pattern = /]*)>([\s\S]*?)<\/group>/g; + let match; + + while ((match = pattern.exec(nuspec)) !== null) { + groups.push({ + framework: attribute(match[1], "targetFramework") ?? "(any)", + dependencies: dependenciesIn(match[2]), + }); + } + + if (groups.length === 0) { + const flat = dependenciesIn(nuspec); + if (flat.length > 0) { + groups.push({ framework: "(any)", dependencies: flat }); + } + } + + return groups; +} + +/** A printable name for a target framework, spelled the way the ecosystem usually writes it. */ +function shortFramework(framework) { + return framework + .replace(/^\.NETStandard/i, "netstandard") + .replace(/^\.NETCoreApp/i, "net") + .replace(/^\.NETFramework/i, "netframework") + .toLowerCase(); +} + +/** Prints one package's groups, recursing into Purview components when asked. */ +async function describe(id, requestedVersion, options, depth, seen) { + const version = chooseVersion(await versionsOf(id), requestedVersion); + const groups = dependencyGroups(await readNuspec(id, version)); + const indent = " ".repeat(depth); + + console.log(`${indent}${id} ${version}`); + + for (const group of groups) { + const framework = shortFramework(group.framework); + if (options.tfm && framework !== options.tfm.toLowerCase()) { + continue; + } + + if (group.dependencies.length === 0) { + console.log(`${indent} ${framework}: no dependencies`); + continue; + } + + for (const dependency of group.dependencies) { + const relation = dependency.constraint === "minimum" ? ">=" : dependency.constraint; + console.log( + `${indent} ${framework}: ${dependency.id} ${relation} ${dependency.version ?? "any"}`, + ); + } + + if (options.walk && depth < MAX_DEPTH) { + for (const dependency of group.dependencies) { + const isPurview = /^purview\./i.test(dependency.id ?? ""); + if (!(options.all || isPurview) || seen.has(dependency.id.toLowerCase())) { + continue; + } + + seen.add(dependency.id.toLowerCase()); + await describe(dependency.id, null, options, depth + 1, seen); + } + } + } + + return { id, version, groups }; +} + +/** Parses the command line. An unknown argument is a mistake worth failing on. */ +function parseArguments(argv) { + const options = { packages: [], tfm: null, walk: false, all: false, json: false }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === "--walk" || argument === "-w") { + options.walk = true; + } else if (argument === "--all") { + options.all = true; + } else if (argument === "--json") { + options.json = true; + } else if (argument === "--tfm") { + options.tfm = argv[(index += 1)]; + if (!options.tfm) { + throw new Error("--tfm needs a value, e.g. --tfm net10.0."); + } + } else if (argument.startsWith("-")) { + throw new Error(`Unknown argument '${argument}'.`); + } else { + options.packages.push(argument); + } + } + + if (options.packages.length === 0) { + throw new Error( + "Name at least one package, e.g. `node dependencies.mjs Purview.Containers`.\n" + + "A package may carry a version: `Purview.Containers@1.0.0-prerelease.11`.", + ); + } + + return options; +} + +/** The command. */ +async function main() { + const options = parseArguments(process.argv.slice(2)); + const results = []; + + for (const entry of options.packages) { + const separator = entry.indexOf("@", 1); + const id = separator === -1 ? entry : entry.slice(0, separator); + const version = separator === -1 ? null : entry.slice(separator + 1); + + results.push(await describe(id, version, options, 0, new Set([id.toLowerCase()]))); + + if (!options.walk && !options.json) { + console.log(""); + } + } + + if (options.json) { + console.log(JSON.stringify(results, null, "\t")); + } +} + +main().catch((error) => { + console.error(`error: ${error.message}`); + process.exitCode = 1; +}); diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md index ba4dd9bd..24fe707c 100644 --- a/.agents/skills/purview-engineering-principles/SKILL.md +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -4,8 +4,9 @@ description: >- Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering conventions - convention over configuration, derived identity, architecture as tests, warnings as errors, proving consumer-facing claims, documentation ownership, test data builders, agent - legibility, and tiering verification by risk. Each rule names the failure it prevents and whether a - build or a reviewer enforces it. + legibility, tiering verification by risk, the version policy for applications and for published + components, and the conditional persistence rules. Each rule names the failure it prevents and + whether a build or a reviewer enforces it. metadata: product: Purview engineering playbook repository: purview-dev/engineering-playbook @@ -13,7 +14,7 @@ metadata: # Purview engineering principles -Ten rules, each with the failure it prevents and the mechanism that makes it true. The distinction +Eleven rules, each with the failure it prevents and the mechanism that makes it true. The distinction that matters throughout: **a convention a build can check is checked by the build; a convention only a reviewer can check is labelled a review obligation and never described as enforced.** A rule that claims enforcement it does not have is worse than no rule, because people stop looking. @@ -100,8 +101,9 @@ repository's own tests already are the consumer. ## 7. Documentation is a build artefact of the repository -The wiki lives in the repository it describes, in `docs/wiki`, and the repository owns its -`mkdocs.yml`, its navigation and its `edit_uri`. +The wiki lives in the repository it describes, in `docs/wiki`, as plain Markdown. The repository owns no +site configuration at all: `_Sidebar.md` is the navigation, `Home.md` is the hub, and the site that +publishes them lives in one place outside the repository. Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is wrong within one release. @@ -154,6 +156,23 @@ hand-written attribute. *At your scale:* one tier is fine until the slow tests exist. Then split by project name, not by attribute. +## 11. A version is a range, not a number + +An application takes the latest version its runtime supports. A published component declares the +**lowest** version that compiles, passes its tests and satisfies the contract it documents, on each +target framework it claims — because dependency resolution is a set of lower bounds, so the floor a +component declares becomes the floor every consumer inherits. + +A page states no release number. It shows a badge generated from the registry, or links to the +registry, so a version cannot be wrong within a release. + +*Enforced by:* a lowest-compatible CI leg that restores against the declared floor, and an +API-compatibility check per target framework. Nothing enforces the floor of a package that has no +consumer yet — that tier arrives with the first release. + +*At your scale:* with one application there is nothing to earn yet; take the latest and let +`dotnet list package --outdated` report the rest. The golden rule starts at the first published package. + ## Applying these in review Three questions catch most of it: @@ -164,8 +183,16 @@ Three questions catch most of it: 3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in `AGENTS.md` or the wiki. +## The conditional persistence rules + +Three further rules apply only when a repository actually stores data: a value known before storage is +derived with UUIDv5, every other GUID key uses one strategy per store, and EF Core is the persistence +boundary. They are not part of the eleven because a stateless CLI or a pure library must not inherit them. +`purview-persistence` owns them and stays silent when persistence is not real. + ## Related skills `purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern -guidance. +guidance, and `purview-component-compatibility` for deciding and verifying a published component's +lowest compatible dependency versions. diff --git a/.agents/skills/purview-org-harvest/SKILL.md b/.agents/skills/purview-org-harvest/SKILL.md new file mode 100644 index 00000000..2c05ea19 --- /dev/null +++ b/.agents/skills/purview-org-harvest/SKILL.md @@ -0,0 +1,197 @@ +--- +name: purview-org-harvest +description: >- + Use when a Purview repository's conventions may have fallen behind the organisation - + inventorying every public purview-dev repository at main, deciding which repository is + best-in-class for each concern before adopting anything, and recording the result in a dated + snapshot. Always reads the remote: a local clone is a cache, and it may be stale, renamed or + missing entirely. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview organisation harvest + +Seventeen public repositories, each with its own wiki, its own `skills/` folder and its own `AGENTS.md`. +They are not equally up to date, and the one you have cloned is not necessarily the one that has the +answer. Two failures follow, and this skill exists to prevent both. + +**Copying from a stale clone.** A clone is a cache with no expiry. The convention you read there may +be two months behind the one the owner now documents, and nothing about the local file says so. + +**Copying from the wrong owner.** "How do we ship?" is answered by `build`. "How do we keep a boundary +honest?" is answered by `architests`. Asking a library that does neither produces a confident answer +with no evidence, which is worse than no answer. + +So: establish membership, read at `main`, name one owner per concern, write down what you found. + +## 1. Scope + +Name two things before reading anything: + +| Input | Example | +| -- | -- | +| The repository being changed | `containers` | +| The concern in question | "how a test reaches a real database" | + +A harvest without a named concern becomes a tour, and a tour produces a list of differences nobody +acted on. The concern is what makes "best-in-class" a decision rather than a preference. + +## 2. Discover membership + +`gh repo list` is the membership authority. A catalogue, a README table, an `llms.txt` and a local +folder listing are all convenience indexes: they are maintained by hand, so they under-count, and the +omission is silent. + +```bash +gh auth status +gh repo list purview-dev --limit 200 \ + --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +Drop `isArchived` and anything that is not `PUBLIC`. **If `gh auth status` fails, stop.** Degrading to +the local clone is the exact failure this skill prevents, and it degrades silently. + +The filter denies by default: a repository must be exactly `PUBLIC` and exactly not archived to be +recorded, so a visibility you have never seen fails closed. `gh repo list` also only lists what your +token can see, which is why the token's visibility and the file's contents are two separate questions: +the section below is about the second one. + +A local clone is admissible only when its `HEAD` equals the SHA the snapshot recorded: + +```bash +git -C ../containers rev-parse HEAD +``` + +That is a speed optimisation, never a substitute. + +## 3. Read it at `main` + +Every repository is read at `main`, and the commit is recorded. The snapshot is what makes the claim +checkable a month later, when nobody remembers which commit the analysis saw. + +```bash +node scripts/inventory.mjs # drift between the organisation and the snapshot +node scripts/inventory.mjs --write # regenerate .config/org-inventory.json +node scripts/inventory.mjs --repo containers +``` + +The script is deterministic and dependency-free: it shells out to `gh`, so it reuses the CLI's own +authentication, and it runs the same on Windows and on a Linux agent — it is Node, not a shell script, +so there is no PowerShell edition and no Bash edition to keep in step. It writes +`.config/org-inventory.json` in the repository you run it from — generated, never hand-edited. + +To inspect a decision without a network call, replay a recorded harvest with `--from` and `--details`. +That is the same script, not a variant, so what you review is what runs. + +## 4. Harvest in two tiers + +Seventeen repositories is not a reading list; it is a budget. Two tiers keep it affordable. + +| Tier | Call | Answers | +| -- | -- | -- | +| 1 — shape | `gh api repos/purview-dev//git/trees/main?recursive=1`, for all seventeen | Which folders exist, how many pages the wiki has, whether `skills/` is authored, whether `AGENTS.md` exists. Cheap enough for everything. | +| 2 — content | `gh api -H 'Accept: application/vnd.github.raw' repos/purview-dev//contents/`, **only for the owner of the concern** | What the convention actually says. | + +Tier 1 tells you where the evidence is. Tier 2 reads it, in the one repository that wrote it down. + +```bash +gh api repos/purview-dev/architests/git/trees/main?recursive=1 --jq '.tree[].path' +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev/architests/contents/docs/wiki/Architecture-As-Tests.md +``` + +More invocations, and what each one is for: `references/harvest-commands.md`. + +## 5. Classify: one owner per concern + +Pick exactly one owner per concern, from `references/best-in-class.md`. Two signs the classification +is wrong: + +- **Two owners for one concern.** `build-sdk` and this playbook's `purview-engineering-principles` + both answer "review this repository". That overlap is a finding to resolve, not a tie to break by + preferring whichever you read first. +- **An owner that has never run the thing it documents.** Check the tree before trusting the page. + +## 6. Divergence register + +Write the differences down before changing anything, with an adopt-or-reject for each. A divergence +with no decision recorded is the same divergence next quarter. + +| Column | Answers | +| -- | -- | +| Concern | What is being compared | +| Owner | The repository the convention comes from, and the path it is stated at | +| Here | What this repository does now | +| Decision | Adopt, or reject with the reason | +| Mechanism | The build rule, test or review obligation that keeps it true | + +Rejecting is a legitimate outcome and is usually the right one at a smaller scale. What is not +legitimate is adopting a convention whose failure the repository cannot have. + +## 7. Adopt + +A convention arrives as a set of changes that land together, or it is a claim with no mechanism: + +```text +the page states the rule, why, how it is enforced, and what a smaller repository does instead +src/ demonstrates it, or says why it does not +the snapshot records the commit it was read at +the skill points at the owner, and never paraphrases it +``` + +**Link to the owner; do not restate it.** A paraphrase is a second source of truth, and the copy with +no owner is the one that loses. + +## 8. Verify + +```bash +just build # zero warnings +just test # architecture, skill and documentation rules +just lint-check +node scripts/inventory.mjs --print +``` + +If the harvest changed what the snapshot should record, regenerate it and commit the diff. The +snapshot being current is a review obligation rather than a build failure — a build that goes red on +a Tuesday because a clock passed is a build people disable. + +## Private repositories + +A private repository may inform a maintainer's judgement. It is never evidence, and it is never +written down. This is not a preference, and it is not a heuristic an agent gets to weigh against a +good argument. Five statements, in order of how often they are broken: + +1. A committed snapshot contains public, non-archived repositories only. The writer denies by default + and refuses to persist anything else, and a test asserts it. +2. A private repository may be read when `gh` is authenticated for it, and what it teaches may shape + a rule. +3. Nothing published names it: not the snapshot, not the page, not the skill, not a workflow, not a + config, not a commit message. Attribute the rule to "a Purview application repository", or to + nothing. +4. **If a rule cannot be justified without the private source, it is not published.** +5. **If you cannot say where a name came from, do not write the name.** A repository the snapshot + does not list is either private or gone, and both fail the same test. + +The fourth is the one an agent rationalises around, because the evidence is often excellent. A rule +whose only justification is out of reach of the people it is published to cannot be reviewed by them, +which makes it folklore with a citation they cannot follow. + +## Checks before you answer + +- Did you read membership from the organisation rather than from a catalogue or a folder? +- Is every claim about a repository true at the SHA the snapshot recorded, not at your clone? +- Did you name exactly one owner per concern, and resolve any overlap rather than duplicating it? +- Did you reject anything? A harvest with no rejections usually means nothing was understood. +- Does every change you propose have a mechanism, or is it labelled a review obligation? +- Is every repository you named public? + +## See also + +- `references/best-in-class.md` — which repository owns which concern, and where to read it +- `references/harvest-commands.md` — the `gh` invocations, and the trap in each one +- `purview-engineering-principles` — the conventions themselves, once you know which owner to read +- `purview-architecture` — the project-shape decisions a harvest most often touches diff --git a/.agents/skills/purview-org-harvest/references/best-in-class.md b/.agents/skills/purview-org-harvest/references/best-in-class.md new file mode 100644 index 00000000..9ab69ccd --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/best-in-class.md @@ -0,0 +1,77 @@ +# Best-in-class, by concern + +One owner per concern. Read the owner, not the nearest repository that happens to have a wiki. + +Every row was read from the organisation with `gh repo list purview-dev` and +`gh api repos//git/trees/main?recursive=1`. The page counts and skill counts are the shape at +the time of writing, not a promise: they are what the snapshot records, and the snapshot is +regenerated rather than trusted. + +## The matrix + +| Concern | Owner | Read it at | +| -- | -- | -- | +| Repository shape, MSBuild defaults, analyzers, skill authoring | `build-sdk` | `docs/wiki`, `templates/`, `skills/purview-buildsdk-*` | +| Pipeline, release, pack validation | `build` | `docs/wiki`, `skills/purview-build-*` | +| Architecture policy: dependency direction, naming rules | `architests` | `docs` (nine pages; `Policy-Reference.md` is the reference, `Extending.md` the how-to), `skills/purview-architests-*` | +| Local dependencies in tests | `containers` | `docs/wiki`, `skills/purview-containers-*` | +| TypeScript contracts generated from C# | `zodsharp` | `docs/wiki`, `skills/purview-zodsharp-*` | +| Logging, tracing, metrics | `telemetry-sourcegenerator` | `docs/wiki`, `skills/purview-telemetry-*` | +| Event streams and event storage | `event-sourcing` | `docs/wiki` — **no skills folder** | +| Operation outcomes without exceptions | `results` | `docs/wiki`, `skills/purview-results-*` | +| Validated identity types | `value-objects` | `docs` — **no `docs/wiki`** | +| Aspire hosting resources | `aspire-resourcekit` | `docs/wiki`, `skills/purview-resourcekit-*` | +| Architecture diagrams generated from source | `aspirec4` | `docs/wiki`, `skills/purview-aspirec4-*` | +| LikeC4 diagrams inside an Astro or Starlight site | `astroc4` | `docs/wiki`, `skills/purview-astroc4-*` — an npm package, not .NET | +| Source generator authoring | `sourcegenerator-framework` | `docs/wiki`, `skills/purview-sourcegenerator-*` | +| Skill-set design, and the dated-snapshot pattern | `pm-skills` | `skills/pm-*` — **no documentation folder at all** | +| OpenAPI generated from XML comments | `openapi-xmlcomments` | `docs/wiki`, `skills/purview-openapi-xmlcomments-*` | +| The reusable conventions themselves | `engineering-playbook` | `docs/wiki`, `skills/purview-*` | +| The public project catalogue | `purview-dev.github.io` | `src/src/data/projects/*.yml` | + +`purview-dev.github.io` is the catalogue, and a catalogue is an index rather than a membership list: +an entry is added by hand, so a repository missing from it is silent. `gh repo list` is the authority. + +## The overlaps worth knowing about + +**`build-sdk` and `engineering-playbook` both answer "review this repository".** They are not +duplicates, and the split is worth stating because a harvest will hit it immediately: + +- `build-sdk` owns *what the tooling does* — project detection, the props and targets, the analyzer + set, the packaged test-support rule sets. +- `engineering-playbook` owns *why a repository is shaped that way, and what it costs* — the rule, + the failure it prevents, and what a smaller repository does instead. + +A question about a property name goes to `build-sdk`. A question about whether a project should exist +goes here. + +**`containers` and `aspire-resourcekit` both look like "local dependencies".** `containers` owns the +test-time resource — a real SQL Server, PostgreSQL or queue — and `aspire-resourcekit` owns the +development-time orchestration. A test that needs a database is `containers`; an AppHost that needs +one is `aspire-resourcekit`. + +**`aspirec4` and `astroc4` both draw LikeC4 diagrams.** `aspirec4` owns the diagram generated from a +running Aspire resource graph; `astroc4` owns putting a `.c4` model inside an Astro or Starlight site — +the fence transform and the LikeC4 syntax highlighting. A question about the .NET host is `aspirec4`; a +question about the documentation site is `astroc4`, which is the one owner here that is not a .NET +package, so it is read for its TypeScript conventions rather than for its MSBuild ones. + +## Reading a repository you have not cloned + +Tier 1 first — the shape, for everything: + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +Then Tier 2 — the content, for the one owner: + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +`_Sidebar.md` is the fastest index of a wiki, because the repository's own documentation rule says a +page that is not in it does not exist. Where a repository publishes no wiki, its `AGENTS.md` is the +next best entry point, and where it has neither, read `skills/*/SKILL.md` — the trigger in each +`description` says what the repository thinks it is for. diff --git a/.agents/skills/purview-org-harvest/references/harvest-commands.md b/.agents/skills/purview-org-harvest/references/harvest-commands.md new file mode 100644 index 00000000..f4b46537 --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/harvest-commands.md @@ -0,0 +1,121 @@ +# Harvest commands + +Every invocation here is safe to run read-only, and each one is followed by the trap that cost +somebody time. Both shells are shown because the harvest runs on a developer's Windows machine and on +a Linux agent, and the quoting rules are not the same. + +## Membership + +```bash +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +```powershell +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef | + ConvertFrom-Json | Sort-Object name | + Format-Table name, visibility, isArchived, pushedAt +``` + +**Trap: the default limit is 30.** Without `--limit` the list is silently short, and a short list is +worse than a failed one because the omission never announces itself. + +**Trap: `gh repo list` shows what your token can see.** An organisation member sees private +repositories too; filter to `visibility -eq 'PUBLIC'` and `-not isArchived` before recording anything. + +## The SHA a convention was read at + +```bash +gh api repos/purview-dev//git/ref/heads/main --jq .object.sha +``` + +**Trap: a renamed repository resolves by redirect.** Asking for a repository under its former name +answers `purview-dev/purview-dev.github.io` and exits 0, so a stale name never fails. Compare the +returned `full_name` with the name you asked for. + +## Tier 1: the shape + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +**Trap: `truncated`.** GitHub caps a recursive tree, and the response says so with `"truncated": true` +rather than erroring. A truncated tree under-counts skills and pages, so check the flag before +believing a zero. + +**Trap: the tree lists blobs and trees.** Filter to `type == "blob"` when counting files, or a folder +whose name matches your pattern is counted as its contents. + +## Tier 2: the content + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +**Trap: `contents` returns JSON unless you ask for raw.** Without the `Accept` header the response is +a base64 `content` field, and the analysis quietly proceeds on a JSON document. + +**Trap: `contents` caps at 1000 files for a directory listing.** Use the tree API for enumerating and +`contents` for reading one file. + +## Windows PowerShell, specifically + +**Embedded double quotes are stripped from a native command's arguments.** This looks correct and is +not: + +```powershell +gh repo list purview-dev --jq '.[] | [.name, (.pushedAt // "-")] | @tsv' +``` + +`gh` receives `// -` and answers `failed to parse jq expression ... unexpected token`. Either keep the +jq program free of string literals, or do the shaping in PowerShell: + +```powershell +$repos = gh repo list purview-dev --limit 200 --json name,visibility | ConvertFrom-Json +$repos | Sort-Object name | ForEach-Object { '{0}|{1}' -f $_.name, $_.visibility } +``` + +**A filtered result is a scalar when it is one item.** `(... | Where-Object { ... }).Count` returns +nothing rather than `1`, so `.Count` in a summary line reads as empty. Wrap it: `@(...).Count`. + +## The snapshot + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs +node skills/purview-org-harvest/scripts/inventory.mjs --write +node skills/purview-org-harvest/scripts/inventory.mjs --repo containers --write +``` + +It resolves the snapshot path from `git rev-parse --show-toplevel`, so it writes into the repository +you are standing in rather than into the one the script was installed from. That is what lets another +repository adopt it unchanged. + +`--repo` merges on write, so reading one repository does not erase the other sixteen. + +## Replaying a harvest, with no network + +Two flags replace the two `gh` calls. This is how the repository's own tests exercise the script on +every platform without touching GitHub, and how a reviewer reproduces what a harvest decided: + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs \ + --from recorded-membership.json \ + --details recorded-details.json \ + --print +``` + +`--from` takes exactly what `gh repo list --json name,visibility,isArchived,pushedAt,defaultBranchRef` +printed. `--details` takes an object keyed by repository name, each with the `commitSha` a tier-1 +harvest found and the `paths` the tree contained. A repository that is in the membership and missing +from the details is an error, not a silent omission — a replay that quietly skipped a repository would +look like a clean run. + +Two behaviours worth knowing before you trust a run: + +- **A snapshot carrying a byte-order mark is accepted.** It is the one file in this workflow a person + is likely to open and re-save, and a BOM is not a corruption. +- **A snapshot containing anything that is not public and unarchived is refused**, and nothing is + written. This includes an entry a hand edit left behind, which the merge would otherwise carry + forward: delete it and re-run `--write`. diff --git a/.agents/skills/purview-org-harvest/scripts/inventory.mjs b/.agents/skills/purview-org-harvest/scripts/inventory.mjs new file mode 100644 index 00000000..affc46fa --- /dev/null +++ b/.agents/skills/purview-org-harvest/scripts/inventory.mjs @@ -0,0 +1,466 @@ +#!/usr/bin/env node +/** + * Snapshots every public, non-archived `purview-dev` repository at `main` into + * `.config/org-inventory.json`, and reports the drift between that snapshot and the organisation. + * + * The snapshot is what makes the harvest rule checkable offline: `OrgInventoryTests` reads the file + * the pipeline has, never the network. This script is the only thing that writes it, so a repository + * cannot appear in the snapshot by being remembered. + * + * Zero dependencies: it shells out to `gh` and so reuses the CLI's own authentication rather than + * asking for a token. + * + * node inventory.mjs # print the drift against the snapshot (default) + * node inventory.mjs --write # rewrite the snapshot from the organisation + * node inventory.mjs --repo containers # narrow to one repository; merges on --write + * node inventory.mjs --out # write somewhere else, e.g. while adopting the skill + * node inventory.mjs --help + * + * Exit codes: 0 on success, including when drift is found - a stale snapshot is a review obligation, + * not a red pull request for whoever happened to push next. 1 when `gh` is missing, unauthenticated, + * or the organisation cannot be read. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** The organisation this playbook describes. */ +const ORG = "purview-dev"; + +/** The branch a convention is read at. A local clone is a cache; this is the authority. */ +const BRANCH = "main"; + +/** Bumped when a field is added or its meaning changes. */ +const SCHEMA_VERSION = 1; + +/** Past this age the snapshot is reported as stale. Advisory: nothing fails on a clock. */ +const STALE_AFTER_DAYS = 30; + +/** Files a repository may put at its root to guide an agent. Recorded in this order. */ +const AGENT_GUIDANCE_FILES = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md", "llms.txt"]; + +/** Directories a published documentation site is read from, most specific first. */ +const DOCS_DIRECTORIES = ["docs/wiki", "docs"]; + +/** + * The concern each repository is best-in-class for, so a harvest reads one owner instead of sixteen + * repositories. `references/best-in-class.md` is the long form of this table and is the file to edit + * first; this map only records the answer in the snapshot. + */ +const CONCERNS = new Map([ + ["architests", ["architecture policy", "dependency and naming rules"]], + ["aspire-resourcekit", ["Aspire hosting resources"]], + ["aspirec4", ["architecture diagrams from source"]], + ["astroc4", ["architecture diagrams in an Astro site"]], + ["build", ["pipeline", "release"]], + ["build-sdk", ["repository shape", "analyzers and style", "MSBuild defaults", "skill authoring"]], + ["containers", ["local dependencies in tests"]], + ["engineering-playbook", ["the conventions themselves"]], + ["event-sourcing", ["event streams", "event storage"]], + ["openapi-xmlcomments", ["OpenAPI from XML comments"]], + ["pm-skills", ["skill-set design"]], + ["purview-dev.github.io", ["the public project catalogue"]], + ["results", ["operation outcomes"]], + ["sourcegenerator-framework", ["source generator authoring"]], + ["telemetry-sourcegenerator", ["logging", "tracing", "metrics"]], + ["value-objects", ["validated identity types"]], + ["zodsharp", ["TypeScript contracts from C#"]], +]); + +/** Runs `gh` and returns stdout, turning a CLI failure into an actionable message. */ +function gh(args) { + try { + return execFileSync("gh", args, { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const detail = (error.stderr ?? "").toString().trim() || error.message; + throw new Error( + `gh ${args.join(" ")} failed.\n${detail}\n\n` + + "This script reads the organisation, not a local clone. Run `gh auth status`, then retry.", + ); + } +} + +/** Calls one GitHub REST endpoint and parses the JSON response. */ +function api(endpoint) { + return JSON.parse(gh(["api", endpoint])); +} + +/** The root of the repository the command was run in, so the skill works from any repository. */ +function repositoryRoot() { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + throw new Error("Not inside a git repository. Run this from a clone, or pass --out ."); + } +} + +/** Membership, as the organisation reports it. The catalogue is a convenience index, not the list. */ +function readMembership(source) { + const listed = source + ? JSON.parse(readFileSync(source, "utf8")) + : JSON.parse( + gh([ + "repo", + "list", + ORG, + "--limit", + "200", + "--json", + "name,visibility,isArchived,pushedAt,defaultBranchRef", + ]), + ); + + return ( + listed + // Deny by default. The filter is "exactly public and not archived" rather than "not + // private", so a visibility value this script has never seen fails closed instead of + // falling through into a file that is committed to a public repository. + .filter((repository) => repository.visibility === "PUBLIC" && repository.isArchived === false) + .sort((left, right) => left.name.localeCompare(right.name, "en")) + ); +} + +/** The recorded commit and file list for each repository, when a run replays a previous harvest. */ +function readDetails(source) { + return new Map(Object.entries(JSON.parse(readFileSync(source, "utf8")))); +} + +/** The Markdown pages directly inside a docs directory. */ +function countPages(paths, directory) { + const prefix = `${directory}/`; + return paths.filter((path) => { + if (!path.startsWith(prefix)) { + return false; + } + + const remainder = path.slice(prefix.length); + return remainder.endsWith(".md") && !remainder.includes("/"); + }).length; +} + +/** A skill is a folder under `skills/` that carries a `SKILL.md`. */ +function authoredSkills(paths) { + const names = paths + .filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)) + .map((path) => path.split("/")[1]); + + return [...new Set(names)].sort((left, right) => left.localeCompare(right, "en")); +} + +/** Everything the snapshot records about one repository, read at `BRANCH`. */ +function describeRepository(repository, details) { + // The filter above already excluded these, and this is the second lock on the same door: a + // private repository must never reach the snapshot, so the guard is here as well as there. + if (repository.visibility !== "PUBLIC" || repository.isArchived !== false) { + throw new Error( + `${ORG}/${repository.name} is ${repository.visibility} and archived ${repository.isArchived}. ` + + "The snapshot records public, unarchived repositories only, so nothing was written.", + ); + } + + const defaultBranch = repository.defaultBranchRef?.name ?? BRANCH; + if (defaultBranch !== BRANCH) { + throw new Error( + `${ORG}/${repository.name} has default branch '${defaultBranch}', not '${BRANCH}'. ` + + "The snapshot pins one branch, so either the rule or the repository has to change.", + ); + } + + const recorded = details?.get(repository.name); + if (details && !recorded) { + throw new Error( + `${repository.name} has no recorded detail, so --details cannot describe it. ` + + "Replay a membership the details file was captured with, or run without --details.", + ); + } + + const commitSha = recorded?.commitSha ?? api(`repos/${ORG}/${repository.name}/git/ref/heads/${BRANCH}`).object.sha; + const paths = recorded?.paths ?? readTree(repository.name); + const docsDir = DOCS_DIRECTORIES.find((candidate) => + paths.some((path) => path.startsWith(`${candidate}/`)), + ); + + return { + name: repository.name, + visibility: repository.visibility.toLowerCase(), + archived: repository.isArchived, + commitSha, + pushedAt: repository.pushedAt, + docsDir: docsDir ?? null, + docsPages: docsDir ? countPages(paths, docsDir) : 0, + skills: authoredSkills(paths), + agentGuidance: AGENT_GUIDANCE_FILES.filter((file) => paths.includes(file)), + concerns: CONCERNS.get(repository.name) ?? [], + }; +} + +/** The blob paths of one repository at `BRANCH`. */ +function readTree(name) { + const tree = api(`repos/${ORG}/${name}/git/trees/${BRANCH}?recursive=1`); + + if (tree.truncated) { + process.stderr.write( + `warning: ${name}'s tree is truncated, so its skills and docs are undercounted\n`, + ); + } + + return tree.tree.filter((entry) => entry.type === "blob").map((entry) => entry.path); +} + +/** The snapshot document. */ +function snapshotOf(repositories) { + return { + schemaVersion: SCHEMA_VERSION, + org: ORG, + branch: BRANCH, + fetchedAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + repositories, + }; +} + +/** The snapshot on disk, or null when the repository has not committed one yet. */ +function readSnapshot(path) { + if (!existsSync(path)) { + return null; + } + + // The snapshot is the one file in this workflow a person is likely to open and save by hand, and + // an editor that writes a byte-order mark produces a file JSON.parse rejects with a message that + // names neither the file nor the cause. + return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")); +} + +/** A field's value in the form the drift report prints. */ +function render(value) { + if (value === null || value === undefined) { + return "none"; + } + + if (Array.isArray(value)) { + return value.length === 0 ? "none" : value.join(", "); + } + + return String(value); +} + +/** A field's value in the form the drift report compares: structured values compare as one string. */ +function fingerprint(value) { + return JSON.stringify(value ?? null); +} + +/** The fields whose change is worth reporting, in the order a reader cares about them. */ +const COMPARED_FIELDS = [ + "commitSha", + "docsDir", + "docsPages", + "skills", + "agentGuidance", + "concerns", + "visibility", + "archived", +]; + +/** The differences between the organisation and the snapshot. */ +function drift(existing, observed) { + const findings = []; + const before = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + const after = new Map(observed.map((entry) => [entry.name, entry])); + + for (const [name, entry] of after) { + if (!before.has(name)) { + findings.push(`${name}: not in the snapshot`); + continue; + } + + const previous = before.get(name); + for (const field of COMPARED_FIELDS) { + if (fingerprint(previous[field]) === fingerprint(entry[field])) { + continue; + } + + findings.push(`${name}: ${field} ${render(previous[field])} -> ${render(entry[field])}`); + } + } + + for (const name of before.keys()) { + if (!after.has(name)) { + findings.push(`${name}: in the snapshot but no longer a public repository`); + } + } + + return findings; +} + +/** How long ago the snapshot was taken, in whole days, or null when it carries no usable date. */ +function ageInDays(fetchedAt) { + const fetched = Date.parse(fetchedAt ?? ""); + return Number.isNaN(fetched) ? null : Math.floor((Date.now() - fetched) / 86_400_000); +} + +/** Parses the command line. Unknown arguments are a mistake worth failing on. */ +function parseArguments(argv) { + const options = { write: false, repos: [], out: null, from: null, details: null, help: false }; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--write") { + options.write = true; + } else if (argument === "--print") { + options.write = false; + } else if (argument === "--help" || argument === "-h") { + options.help = true; + } else if (argument === "--repo") { + const value = argv[++index]; + if (!value) { + throw new Error("--repo needs a repository name."); + } + + options.repos.push(value); + } else if (argument === "--out") { + const value = argv[++index]; + if (!value) { + throw new Error("--out needs a path."); + } + + options.out = value; + } else if (argument === "--from") { + const value = argv[++index]; + if (!value) { + throw new Error("--from needs a path to a recorded `gh repo list` response."); + } + + options.from = value; + } else if (argument === "--details") { + const value = argv[++index]; + if (!value) { + throw new Error("--details needs a path to a recorded detail file."); + } + + options.details = value; + } else { + throw new Error(`Unknown argument '${argument}'. Run with --help.`); + } + } + + return options; +} + +/** The documented usage. */ +const HELP = `Snapshot every public, non-archived ${ORG} repository at ${BRANCH}. + + node inventory.mjs print the drift between the organisation and the snapshot + node inventory.mjs --write rewrite the snapshot from the organisation + node inventory.mjs --repo narrow to one repository; merges when writing + node inventory.mjs --out write somewhere other than .config/org-inventory.json + node inventory.mjs --from replay a recorded "gh repo list --json" response instead of + asking the organisation + node inventory.mjs --details replay recorded commits and file lists, so a run touches no + network at all; pair it with --from + node inventory.mjs --help this text + +The snapshot is generated, never hand-edited. A stale snapshot is a review obligation rather than a +build failure, so drift prints and the exit code stays 0.`; + +/** Runs the command. */ +function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log(HELP); + return; + } + + const snapshotPath = options.out ?? join(repositoryRoot(), ".config", "org-inventory.json"); + const existing = readSnapshot(snapshotPath); + + let observed = readMembership(options.from); + if (options.repos.length > 0) { + const known = new Set(observed.map((repository) => repository.name)); + for (const name of options.repos) { + if (!known.has(name)) { + throw new Error( + `'${name}' is not a public, non-archived ${ORG} repository. ` + + `Run without --repo to include all ${observed.length}.`, + ); + } + } + + observed = observed.filter((repository) => options.repos.includes(repository.name)); + } + + const details = options.details ? readDetails(options.details) : null; + const described = observed.map((repository) => describeRepository(repository, details)); + + if (options.write) { + const merged = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + for (const entry of described) { + merged.set(entry.name, entry); + } + + const written = snapshotOf( + [...merged.values()].sort((left, right) => left.name.localeCompare(right.name, "en")), + ); + + // The merge carries forward whatever the file already held, which is how a hand-edited entry + // would survive a regeneration. Refuse to write a snapshot that records anything else. + const notPublic = written.repositories.filter( + (entry) => entry.visibility !== "public" || entry.archived !== false, + ); + if (notPublic.length > 0) { + throw new Error( + "Refusing to write a snapshot that records a repository which is not public and " + + `unarchived: ${notPublic.map((entry) => entry.name).join(", ")}. ` + + "Delete the entries and re-run --write.", + ); + } + + mkdirSync(dirname(snapshotPath), { recursive: true }); + writeFileSync(snapshotPath, `${JSON.stringify(written, null, "\t")}\n`, "utf8"); + console.log( + `wrote ${written.repositories.length} repositories to ${snapshotPath} at ${written.fetchedAt}`, + ); + return; + } + + const findings = drift(existing, described); + const age = ageInDays(existing?.fetchedAt); + + console.log(`${ORG}: ${observed.length} public, non-archived repositories at ${BRANCH}`); + if (existing) { + console.log( + `snapshot: ${existing.repositories?.length ?? 0} repositories, fetched ${existing.fetchedAt}` + + (age === null ? "" : ` (${age} day${age === 1 ? "" : "s"} old)`), + ); + } else { + console.log(`no snapshot at ${snapshotPath} - run with --write to create one`); + } + + if (findings.length === 0) { + console.log("no drift"); + } else { + console.log(`${findings.length} difference${findings.length === 1 ? "" : "s"}:`); + for (const finding of findings) { + console.log(` ${finding}`); + } + } + + if (age !== null && age > STALE_AFTER_DAYS) { + console.log( + `warning: the snapshot is older than ${STALE_AFTER_DAYS} days. ` + + "Re-run with --write and review the diff.", + ); + } +} + +try { + main(); +} catch (error) { + console.error(`error: ${error.message}`); + process.exitCode = 1; +} diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md index 4667fa29..7d6ad911 100644 --- a/.agents/skills/purview-persistence/SKILL.md +++ b/.agents/skills/purview-persistence/SKILL.md @@ -1,14 +1,15 @@ --- name: purview-persistence description: >- - Use when a .NET project actually persists data and you are choosing a storage approach, deciding - whether to abstract EF Core, or selecting a GUID primary-key strategy. Gives provider-specific - guidance — SQL Server and PostgreSQL differ and must not be treated alike — and stays silent for - projects that do not persist anything. + Use when a .NET project actually persists data and you are choosing a storage approach, integrating + or abstracting EF Core, deriving a well-known identifier, or selecting a GUID primary-key strategy. + Gives provider-specific guidance — SQL Server and PostgreSQL differ and must not be treated alike — + and stays silent for projects that do not persist anything. license: MIT metadata: author: Purview-Dev - version: "1.0" + version: "1.2" + repository: purview-dev/engineering-playbook --- # Purview persistence @@ -40,7 +41,39 @@ development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory provider: the in-memory provider does not enforce the constraints that break you in production. -## 3. GUID keys — only if GUIDs are keys +## 3. EF Core integration + +When EF Core is the store, keep the integration in four places: + +- **`DbContext` is the boundary.** Use it directly; do not add `IRepository` or a unit-of-work wrapper + for testability. `Purview.Containers` gives you a real database in tests. +- **Choose the provider where the context is registered.** Carry the choice as context-options data, not + a `Database.ProviderName` branch at model-build time. +- **Map value objects once.** Configure converters and comparers in the model, not per property. A + value-object key needs a converter-aware value generator. +- **Keep design time and runtime in step.** Both paths make the same provider, strategy and + `ReplaceService` choices, or the migration snapshot describes a different model. + +Migrations are code, reviewed and applied by a deliberate step; the API does not migrate at startup in +production. The full rule, composition sample and checks are in `references/ef-core-integration.md`. + +## 4. Identifiers — derived or generated + +Two decisions live here, and they are independent. Ask them in this order. + +### The deterministic-identifier gate + +Derive an identifier only when its **value must be known before storage** — a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. Then derive it with UUIDv5 over an installation-scoped namespace and configure +`ValueGeneratedNever()`, so the sequential convention below leaves it alone. A project with random keys +and no seeding has no use for UUIDv5. An outbox row that merely needs an id before `SaveChanges` takes +the generated strategy below, not a derived one. + +The full rule, catalogue, naming rules, migration caveat and tests are in +`references/well-known-identifiers.md`. + +### The generated-key decision Resolve these before recommending anything; most are answered by the repository: @@ -72,11 +105,14 @@ Use the framework or database capability when it is sufficient — `Guid.CreateV generator is a maintenance obligation and a correctness risk; introduce one only against a measured requirement. -**There is no Purview GUID generation package.** Do not reference one, and do not create one without -first establishing a concrete, measured need that the built-in options cannot meet. If such a -capability is added later, this skill is where the guidance for recommending it belongs. +**No NuGet package provides this pattern.** A reviewed generator such as `FastGuid` supplies a +store-ordered implementation, but the strategy witness, options extension, value generator with +value-converter support, and model-finalising convention are repository-local: copy them, do not depend +on a package that claims to own the wiring. Introduce it only for a measured requirement: a second +provider, or an identifier needed before `SaveChanges`. Until the second provider exists, a built-in +capability is the answer. -## 4. Value objects as keys +## 5. Value objects as keys `Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a `CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather @@ -85,7 +121,7 @@ than per property. This is about type safety at the boundary, not about the generation strategy — the two decisions are independent. -## 5. Migrations and schema +## 6. Migrations and schema - Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — not by the application at startup in production. @@ -96,7 +132,14 @@ independent. ## Checks before you answer - Have you confirmed persistence is actually required? +- If the project uses EF Core, are you recommending the `DbContext` directly rather than a repository + wrapper added for testability? +- Does the design-time path make the same provider and strategy choices as the runtime path? +- Does every generated value have one owner — application, store or derivation — and no second writer? +- If you are deriving an identifier, is its value genuinely known before storage, and is it keyed + `ValueGeneratedNever()`? - If you are recommending a GUID strategy, do you know the provider *and* its version? +- If you are proposing the strategy/convention, is there a second provider or a pre-save identifier? - Are you proposing an abstraction you can justify by something other than testability? - Have you preferred a built-in capability over a custom generator? diff --git a/.agents/skills/purview-persistence/references/ef-core-integration.md b/.agents/skills/purview-persistence/references/ef-core-integration.md new file mode 100644 index 00000000..77c75731 --- /dev/null +++ b/.agents/skills/purview-persistence/references/ef-core-integration.md @@ -0,0 +1,83 @@ +# EF Core integration + +Read this when the project actually uses EF Core. The identifier decisions are in `guid-generation.md` +and `well-known-identifiers.md`; this page is the rest of the integration. + +## The rule + +1. **`DbContext` is the boundary.** Read and write through it directly, or through a narrow query type + when a query deserves a name. Do not add `IRepository` or a unit-of-work wrapper for testability. + `DbContext` already is both, and a real container is a more faithful test than a substitute. +2. **The provider is chosen where the context is registered.** Carry the choice as context-options data; + never branch on `Database.ProviderName` while the model is being built. The model is built once and + cached, so that branch serves one provider's model to the other. +3. **Map a value object once.** Configure the converter and comparer in the model, not on each property. + The `Purview.ValueObjects` generator emits the converters when EF Core is referenced. A value-object + key also needs a converter-aware value generator, or the property receives a raw `Guid`. +4. **One owner per generated value.** The application supplies it, the store generates it, or it is + derived — never two. A derived identifier is `ValueGeneratedNever()`; a store default or computed + column is left to the store. +5. **Design time and runtime agree.** The design-time factory or host registration makes the same + provider, strategy and `ReplaceService` choices as the application. Otherwise the migration snapshot + describes a model the application does not run. +6. **The model cache key covers every input that varies the model.** Provider, strategy, tenant and + design time. EF's service-provider cache and model cache are separate; both need a key that matches + the decision. +7. **Migrations are code.** Persistence owns the definitions; a deliberate step applies them. The API + does not migrate at startup in production. +8. **Integration tests use a real store.** The in-memory provider does not enforce foreign keys, unique + indexes, computed columns or SQL semantics. Use `Purview.Containers` and a `.IntegrationTests` + project. +9. **The connection string is configuration.** Keep it out of source, out of `appsettings.json` in the + repository, and out of telemetry. A redaction test is worth more than a convention. + +## The composition + +The provider, the strategy and the service replacements belong together, where the context is +registered: + +```csharp +services.AddDbContext>((serviceProvider, options) => +{ + ConfigureStore(options, connectionString); + + options + .UseSequentialGuids() + .ReplaceService() + .ReplaceService>(); +}); + +services.AddSingleton>(); +``` + +- `ConfigureStore` is the only place that names `UseSqlServer` or `UseNpgsql`. +- `UseSequentialGuids` adds an options extension; the model reads it instead of asking about the provider. +- `IModelCacheKeyFactory` is replaced because the service-provider hash alone does not key the model + cache. +- The application-facing factory is registered separately, because EF's internal registration is not + reachable from an outbox writer or a correlation-id generator. + +## Migrations + +- Generation and application are separate: `dotnet ef migrations add` in development, a controlled + `Migrator` step in a deployment. +- A migration and `HasData` are compiled and replayed, so they need a literal GUID. A derived identifier + cannot be computed there; print the constant from the derivation and guard it with a test. +- Expect the schema to be ahead of or behind the code during a rollout, and design the change to be + compatible in both directions. +- Never let the API apply migrations at startup in production: a deploy then mutates the schema without + review and without a clean rollback. + +## Checks to write + +| Check | Shape | +| -- | -- | +| Model covers every GUID key | Each non-foreign-key GUID key is generated on add with the expected factory | +| Foreign keys are not generated | A foreign-key GUID keeps the value copied from its principal | +| Explicit configuration wins | A `ValueGeneratedNever()` key keeps its supplied value through `SaveChanges` | +| Two strategies, one context type | Different strategies produce different service providers and models | +| Design time matches runtime | Both models produce the same migration snapshot | +| Real store semantics | A unique index, a foreign key and a computed column behave as the model says in a container | + +The Engineering Playbook's *EF Core Integration* page states the same rules with the sample as evidence; +the runnable implementation is `samples/Acme.Persistence` in that repository, run with `just samples`. diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md index 432053a2..fb644bb8 100644 --- a/.agents/skills/purview-persistence/references/guid-generation.md +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -62,14 +62,22 @@ Never use `NEWSEQUENTIALID()` output as a secret. | Provider | Needs value before insert | Locality matters | Recommendation | | -- | -- | -- | -- | -| SQL Server | No | Yes | `NEWSEQUENTIALID()` (EF Core's default for `Guid` keys) | -| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator` | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` as a store default, or EF Core's client-side `SequentialGuidValueGenerator` | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | SQL Server | Either | No | `Guid.NewGuid()` | | PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | | PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | | PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| SQL Server **and** PostgreSQL, one context type | Either | Yes | A store-specific strategy carried as context-options data. One strategy per provider; see below. | | Any | Either | Either, but must be unpredictable | Random v4 | +The two-provider row is the one that needs code: an `ISequentialGuidProvider` witness, an options +extension and a model-finalising convention. Copy them from the Engineering Playbook's *GUID Key +Strategy* page. Never expect one byte-ordered generator to order for both stores. + +A value whose identity is known before storage does not belong in this table. Derive it with UUIDv5 and +key it `ValueGeneratedNever()`; see `well-known-identifiers.md`. + ## Prefer built-in capability In order of preference: @@ -77,17 +85,19 @@ In order of preference: 1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. 2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. 3. A reviewed, tested implementation of a published standard, when neither of the above is available - on your target versions. + on your target versions — `FastGuid` is the one the sample uses for store-ordered values. 4. A bespoke generator — only against a measured requirement the first three cannot meet. A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode is duplicate or badly-ordered keys found in production. -## There is no Purview GUID package +## No NuGet package provides this -No Purview library provides GUID generation today. Do not reference one and do not invent one. +No NuGet package provides the strategy/convention pattern. It is repository-local: copy the strategy +witness, options extension, value generator and model-finalising convention from the Engineering +Playbook's *GUID Key Strategy* page. Do not depend on a package that claims to own it, and do not +create one without a measured requirement the built-in options cannot meet. -If a concrete need emerges that the built-in options cannot meet — established by measurement, not by -anticipation — that is the point at which creating one is worth discussing. Until then this guidance -is the deliverable, and this page is where a recommendation for such a package would be documented. +The abstraction is earned by a second provider, or by an identifier needed before `SaveChanges` on +both — not by anticipation. Until then a built-in capability is the answer. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md index cd557ecc..603987d6 100644 --- a/.agents/skills/purview-persistence/references/postgresql.md +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -43,6 +43,49 @@ builder.Property(o => o.Id).ValueGeneratedNever(); Without that, EF Core may treat the key as store-generated and skip your value. +### Application-generated, store-ordered strategy + +When one context type serves both PostgreSQL and SQL Server, or the identifier must exist before +`SaveChanges` on both, use a store-specific strategy carried as context-options data. The PostgreSQL +implementation returns `FastGuid.NewPostgreSqlGuid()`; a model-finalising convention applies it to +every GUID key and leaves explicitly keyed derived identifiers alone. + +```csharp +public sealed class PostgreSqlSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "PostgreSql"; + + public static Guid NewGuid() => FastGuid.NewPostgreSqlGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewPostgreSqlGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.PostgreSql.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- PostgreSQL compares `uuid` in plain big-endian byte order, so both `FastGuid.NewPostgreSqlGuid()` + and `Guid.CreateVersion7()` are genuinely sequential here. `FastGuid` is also correct for SQL Server, + which is the point of the strategy abstraction. +- Set `ValueGeneratedNever()` through the convention; never let EF fetch a store-generated value that + does not exist. + +#### RFC caveat + +`FastGuid.NewPostgreSqlGuid()` values are **not** RFC 9562 UUIDs. They are ordinary GUIDs with a +PostgreSQL-ordered layout, so PostgreSQL's `uuid_extract_timestamp()` will not read them even though +they are valid `uuid` values. Use the provider's `GetTimestamp` (above) instead. If another system +needs a standard timestamp, generate `Guid.CreateVersion7()` there and accept that the layout differs. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider, not by anticipation. + ### Database-generated UUIDv7 — PostgreSQL 18+ PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have @@ -87,6 +130,7 @@ Random and unpredictable. Fine at low to moderate write volume, which covers mos | .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | | Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | | Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| One model serving PostgreSQL and SQL Server | A store-specific `FastGuid` strategy carried as context-options data — never one byte-ordered generator for both | | Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | | Low or moderate volume | v4 — the locality difference will not be your bottleneck | diff --git a/.agents/skills/purview-persistence/references/sqlserver.md b/.agents/skills/purview-persistence/references/sqlserver.md index c7282955..22629721 100644 --- a/.agents/skills/purview-persistence/references/sqlserver.md +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -27,8 +27,13 @@ ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] DEFAULT NEWSEQUENTIALID() FOR [Id]; ``` -EF Core, for a `Guid` key on SQL Server, configures this by default — `ValueGeneratedOnAdd` maps to -`NEWSEQUENTIALID()` through the default value-generation strategy. +EF Core does **not** map `ValueGeneratedOnAdd` to `NEWSEQUENTIALID()` by default. For a `Guid` key on +SQL Server the provider generates the value client-side with `SequentialGuidValueGenerator` (below). +Configure a store default explicitly when the database must own the value: + +```csharp +builder.Property(o => o.Id).HasDefaultValueSql("NEWSEQUENTIALID()"); +``` Trade-offs: @@ -53,6 +58,44 @@ builder.Property(o => o.Id) Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality while letting the application own the value. +### Application-generated, store-ordered strategy + +When one context type serves both SQL Server and PostgreSQL, or the identifier must exist before +`SaveChanges` on both, a single built-in generator is not enough: the two stores order GUIDs +differently. The answer is a store-specific strategy carried as context-options data — a +`static abstract` witness whose SQL Server implementation returns `FastGuid.NewSqlServerGuid()`, plus a +value generator and a model-finalising convention that applies it to every GUID key. + +```csharp +public sealed class SqlServerSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "SqlServer"; + + public static Guid NewGuid() => FastGuid.NewSqlServerGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewSqlServerGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.SqlServer.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- The increasing part must sit where SQL Server sorts it. The trailing six bytes dominate + `uniqueidentifier`; a byte-ordered UUIDv7 does not. +- The generator must hand the value through the property's value converter, or a `readonly record + struct OrderId` key receives a raw `Guid`. +- Apply it at convention precedence, so explicit `ValueGeneratedNever()`, a store default and a + computed column all win. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider; on one SQL Server provider `NEWSEQUENTIALID()` or EF Core's generator is enough. + ### `Guid.NewGuid()` — random Correct, portable and unpredictable; poor index locality. @@ -88,7 +131,7 @@ reaching for a sequential generator. | Situation | Use | | -- | -- | | High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | -| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | | Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | | You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | diff --git a/.agents/skills/purview-persistence/references/well-known-identifiers.md b/.agents/skills/purview-persistence/references/well-known-identifiers.md new file mode 100644 index 00000000..372d5855 --- /dev/null +++ b/.agents/skills/purview-persistence/references/well-known-identifiers.md @@ -0,0 +1,93 @@ +# Deriving well-known identifiers with UUIDv5 + +Read this only when an identifier's **value must be known before storage**: a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. A project with random keys and no seeding has no use for UUIDv5 — say nothing and +move on. An outbox row that merely needs an id before `SaveChanges` takes a generated strategy instead. + +## The rule + +1. Derive the value: `Uuid5.Create(namespace, name)` is RFC 4122 version 5 (SHA-1), byte-identical for + the same namespace and name in every process, language and restart. +2. Scope the namespace to the isolation boundary — one installation or one deployment. Never a global + constant shared by every deployment. +3. Fix the name as a canonical constant of the identity kind. Not a display name, not user data, not a + secret. +4. Once persisted, the value is authoritative. Never recompute it and update the row. +5. Key it explicitly with `ValueGeneratedNever()`, so a sequential-GUID convention never generates over + a value the application already knows. + +## The catalogue + +Write the known identities down in one place. + +| Identity | Namespace | Name | Derived when | Immutable | +| -- | -- | -- | -- | -- | +| System tenant of an installation | installation id | `system-tenant` | first bootstrap | yes | +| System principal of an installation | installation id | `system-service-principal` | first bootstrap | yes | +| Seeded reference row | installation id | `:` | seed step | yes | +| Ingested external object (optional) | installation id | `:` | first ingest | yes | + +Derive the ingested row only when the provider guarantees the external key is stable and never +recycled. If it can be recycled, keep the local key sequential and make the external key a unique +column. + +## Naming rules + +- Lowercase ASCII, hyphen-separated, no whitespace, no locale-dependent casing. +- Never from a display name, email, URL, title, or anything a user can edit. +- Never from a secret, and never as a capability token. A name is guessable. +- Renaming a name creates a new identity. Renaming a persisted one is a data migration. + +## Bootstrap sequence + +Read the persisted identity; if present it wins, and a configured value must match or the operation +fails. If absent, insert it. Derive dependants from the persisted id. Save once. A second run changes +nothing. + +The guard matters as much as the insert: + +```csharp +if (persisted is not null && persisted.Id != configured) +{ + throw new InvalidOperationException( + $"Configured installation id '{configured.Value}' does not match the persisted id '{persisted.Id.Value}'."); +} +``` + +## Migrations cannot call the helper + +`HasData` and migration SQL need a literal GUID. A small script prints ready-to-paste constants from +the same derivation, and a test asserts every printed constant equals `Uuid5.Create(...)`. Changing the +namespace or a name after a migration has shipped is a data migration, not an edit. + +## Checks to write + +| Check | Shape | +| -- | -- | +| RFC 4122 conformance | Fixed vectors for DNS/URL/OID/X500 and the empty name; exact GUIDs | +| Determinism | Same pair twice equal; different names differ | +| Version and variant bits | Byte 6 high nibble `0x5`; byte 8 high bits `10` | +| Cross-language equality | Python `uuid.uuid5` or Node `crypto` reproduces the vectors | +| Catalogue uniqueness | The catalogue's constants are distinct | +| Bootstrap idempotency | Seeding twice changes zero rows; a mismatched configured id throws | +| Explicit keying | The key is `ValueGenerated.Never`, and the sequential convention leaves it alone | + +```text +uuid5(6ba7b810-9dad-11d1-80b4-00c04fd430c8, "example.com") = cfbff0d1-9375-5685-968c-48ce8b15ae17 +uuid5(6ba7b811-9dad-11d1-80b4-00c04fd430c8, "www.example.com") = b63cdfa4-3df9-568e-97ae-006c5b8fd652 +uuid5(6ba7b812-9dad-11d1-80b4-00c04fd430c8, "1.2.3.4") = 67448b45-6d15-536b-bef7-8a78c3d10ac6 +uuid5(6ba7b814-9dad-11d1-80b4-00c04fd430c8, "CN=Bob") = 0840e90d-9ca0-5f14-970e-26294d44cb1d +``` + +The hash path is big-endian. `Guid.ToByteArray()` and `new Guid(byte[])` use a mixed-endian layout and +produce a different hash; that is the most common way a "UUIDv5" implementation is wrong. + +**UUIDv5 is not sequential.** Never use it for a high-write table's primary key. It is for a small, +known set of identities; everything else takes `guid-generation.md`. + +RFC 4122 is the version-5 specification; RFC 9562 is the current UUID specification and retains the +algorithm. The .NET BCL has no UUIDv5 API, so the helper is a small hand-written function. + +The Engineering Playbook's *Well-Known Identifiers* page carries the full helper, catalogue and +bootstrap samples, and their test shapes. diff --git a/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md b/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md new file mode 100644 index 00000000..714ddbff --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md @@ -0,0 +1,146 @@ +--- +name: purview-sourcegenerator-attribute-data-models +description: >- + Use when declaring or fixing a [Generate] attribute-data model in Purview.SourceGeneratorFramework - + mapping constructor and named arguments with [Argument]/[Property], defaults, reusing a shape with + [NestedModel], reading an ADM0001-ADM0015 diagnostic, or testing that a model reads the values an + attribute application supplies. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Attribute-data models + +`AttributeDataModelGenerator` turns a `readonly partial record struct` marked `[Generate(typeof(TAttribute))]` +into a parser for `TAttribute`'s `AttributeData`: `Exists`, `Empty`, `TargetAttribute`, the guarded +`FromAttributeData`/`TryFromAttributeData`/`AllAttributeData` overloads, and `FromAttributeDataUnchecked`. +The generated model is a value type, so it is safe to hold in an incremental pipeline (an `AttributeData` +is not). + +The one thing to keep in mind: **the generated code looks names up case-sensitively at run time, and a +name it cannot find quietly yields the default value.** A mapping mistake compiles and produces `null`/`0`. +The naming analyzer (`ADM0011`–`ADM0015`) exists to report those mistakes where they are declared; do not +suppress it without understanding the finding, and test the model against real applications. + +## Mapping each property + +Each primary-constructor parameter becomes a property, PascalCased (`message_template` → `MessageTemplate`). +Its sources are read in this order, first hit wins: + +| Declaration | Reads | +| --- | --- | +| *(no mapping attribute)* | the named argument with the property's name: `[Log(EventId = 7)]` | +| `[Property]` / `[Property(Name = "X")]` | the named argument `X` (default: the property name) | +| `[Argument("messageTemplate")]` | the constructor parameter **named** `messageTemplate` | +| `[Argument]` | the constructor parameter named after the property, camel-cased: `MessageTemplate` → `messageTemplate` | +| `[Argument(1)]` | the constructor argument at **position** 1 | +| `[Argument(...)] [Property]` | the named argument first, then the constructor argument | +| `[NestedModel]` | another generated model, read from the same attribute (below) | +| `[GenericTypeArgument]` | a generic type argument of the attribute class, as a `TypeIdentity` | +| `[Exclude]` | nothing; the parameter is left out of the model | + +Methodology: + +- **Map a constructor parameter by name, not by index.** A name survives a new overload or a reordered + parameter list; an index silently starts reading a different parameter. +- **Map both sources when the attribute accepts both.** `[Argument("name")] [Property] string? Name` reads + `[A(name: "x")]`, `[A("x")]` and `[A(Name = "x")]`. The named argument is read first because it is assigned + after the constructor runs, so it is the attribute instance's effective value. +- **Only map `[Property]` to a public settable property or field.** A property with a private or `internal` + setter can never be a named argument; read it through the constructor parameter that sets it (`ADM0012`). +- **Match case exactly.** `[Argument("MessageTemplate")]` never matches a parameter called + `messageTemplate` (`ADM0011`). +- **Give every property that might be absent a `DefaultValue`**: `[Property(DefaultValue = -1)] int EventId`. + A missing argument takes it; without one, it takes `default(T)`. `Empty` always uses `default(T)`. +- **Use `IsEnum = true` with a `string` property** when the enum type is not available to the generator; a + bare member-name default (`DefaultValue = "Inherit"`) is expanded to the fully-qualified member. +- **Never name a property `Exists`, `Empty`, `TargetAttribute`, `AllAttributeData`, `FromAttributeData`, + `TryFromAttributeData` or `FromAttributeDataUnchecked`** — they are generated (`ADM0014`, generation is + skipped). Keyword names (`Event`, `Class`) are fine. + +## Reusing a shape with `[NestedModel]` + +A `[NestedModel]` parameter is populated by the nested model's `FromAttributeDataUnchecked`, from the +**outer** attribute's arguments, with no `TargetAttribute` check. The outer attribute does not have to derive +from the nested model's target; it only has to use the same constructor parameter and property names. + +```csharp +[Generate(typeof(LogAttribute))] +readonly partial record struct LogAttributeData( + [Argument("level")] [Property(DefaultValue = 99)] int Level, + [Argument("messageTemplate")] [Property] string? MessageTemplate, + [Argument("eventId")] [Property(DefaultValue = -1)] int EventId, + [Property] string? Name +); + +// InfoAttribute has messageTemplate, eventId and Name, but no level. +[Generate(typeof(InfoAttribute))] +readonly partial record struct InfoAttributeData([NestedModel] LogAttributeData Log); +``` + +- The nested model's `Exists` is always `true`; a mapping the outer attribute does not declare (`level` + here) takes its `DefaultValue`. `ADM0013` (info) lists such mappings, so an intended absence is visible + and an accidental one is caught. +- Map nested properties **by name**. A constructor index means a different parameter on each attribute + (`ADM0015`). +- `MatchByInheritance = true` is for using a model on its own against derived attributes. A nested model + does not need it. +- Call `FromAttributeData`/`TryFromAttributeData` from generator code. `FromAttributeDataUnchecked` skips the + type check and is for nested models. + +## Diagnostics + +| Rule | Severity | Meaning and fix | +| --- | --- | --- | +| `ADM0001` | Error | The `[Generate]` target cannot be resolved. Use `typeof`, a resolvable full name, or a type-library `…FullName` constant. | +| `ADM0002` | Error | Unsupported property type (array, pointer, function pointer). | +| `ADM0003` | Error | `[Argument("name")]`/`[Argument(index)]` names no constructor parameter of the target. | +| `ADM0004` | Error | A `[NestedModel]` type is not itself a `[Generate]` model. | +| `ADM0005` | Error | A `DefaultValue` cannot be emitted for the property type. | +| `ADM0006` | Error | A non-nullable reference property has no default. Add a `DefaultValue` or make it nullable. | +| `ADM0007` | Error | `AutoDiscover` needs the `typeof` target overload. | +| `ADM0008` | Error | A `[GenericTypeArgument]` property must be `TypeIdentity`. | +| `ADM0009` | Error | An `IsEnum` property must be `string`/`string?`. | +| `ADM0010` | Error | An `ISymbol`/`Type` property is not cacheable; use `TypeIdentity` or a string. | +| `ADM0011` | Warning | A mapped name matches an attribute member only when case is ignored, so it is never read. Fix the case. | +| `ADM0012` | Warning | The mapped named argument is not a public settable member of the attribute, or a bare `[Argument]` implies a constructor parameter it lacks. Map the member that actually carries the value. | +| `ADM0013` | Info | A nested model property reads only names the outer attribute lacks, so it always takes its default. Expected for a deliberately absent value; otherwise rename to match. | +| `ADM0014` | Error | A property has a generated member's name. Rename the parameter. | +| `ADM0015` | Warning | A nested model reads a constructor index that names a different parameter on the outer attribute. Map it by name. | + +## Testing a model + +Test the attribute side and the model side separately, against what Roslyn actually produces. Use +`AttributeDataFactory` from `Purview.SourceGeneratorFramework.Testing` to compile an application, and the +`AttributeDataAssertions` from the TUnit package to pin each argument by ordinal, parameter name or named +argument, including how it was supplied (`AttributeArgumentForm`): + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; +var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\", Name = \"N\")]", options); + +await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); +await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); +await Assert.That(attribute).HasNamedArgument("Name", "N"); + +var model = InfoAttributeData.FromAttributeData(attribute); +await Assert.That(model.Log.MessageTemplate).IsEqualTo("custom {Value}"); +await Assert.That(model.Log.Level).IsEqualTo(99); +``` + +Cover, for each model: positional, by-name and named-argument applications; an application omitting every +optional argument (proves the `DefaultValue`s); for a nested model, an outer attribute that lacks a mapped +member; and the nested target's own `FromAttributeData` returning `Empty` for the outer attribute. +`FromUsage` places the application on a declaration its `AttributeUsage` allows and throws on a compile +error, so a misspelt argument name fails the test. `FromSource` reads applications from a full source. + +Generated-code assertions (`Contains("FromAttributeDataUnchecked(attributeData)")`) prove *what* the +generator emits; the tests above prove the model *reads the right values*. Prefer the latter. + +## Related skills + +`purview-sourcegenerator-testing` for the runner layer, `AttributeDataFactory` and `AttributeArguments`; +`purview-sourcegenerator-tunit-tests` for the assertion extensions; `purview-sourcegenerator-pipeline` for +holding models in an incremental pipeline; `purview-sourcegenerator-diagnostics` for the other analyzer +families. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md index ee363c97..68e79d3c 100644 --- a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md @@ -234,7 +234,7 @@ Guidelines: - 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. +- 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, 5.12 for .NET 11. - 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. diff --git a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md index 21d9522c..b80ebe74 100644 --- a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md @@ -1,7 +1,7 @@ --- name: purview-sourcegenerator-diagnostics description: >- - Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD or TLB diagnostic, + Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD, TLB or ADM diagnostic, when choosing which code fix applies, or when a source generator, diagnostic analyzer or code fix provider is silently not running at all. metadata: @@ -106,8 +106,21 @@ Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD00 ## Type-library and attribute-model diagnostics The bundled generators carry their own families: `TypeLibraryValidationAnalyzer` -(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model validation analyzers. They are -documented on their own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. +(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model analyzers. They are documented on their +own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. + +The attribute-data-model rules come from three analyzers: + +| Rules | Analyzer | Covers | +| --- | --- | --- | +| `ADM0001`–`ADM0009` | `AttributeDataModelValidationAnalyzer` | the target, property types, defaults, explicit constructor members | +| `ADM0010` | `AttributeDataModelSymbolPropertyAnalyzer` | non-cacheable `ISymbol`/`Type` properties | +| `ADM0011`–`ADM0015` | `AttributeDataModelNamingAnalyzer` | mapped names the generated code would never find, generated-member name clashes, `[NestedModel]` mappings the outer attribute does not line up with | + +Treat the naming rules as real defects: the generated code reads names case-sensitively and silently falls +back to a default, so a naming mistake never fails the build on its own. `ADM0013` is `Info` because a +nested model may deliberately read a value the outer attribute does not have; check each one. The +`purview-sourcegenerator-attribute-data-models` skill has the full table and the fix for each rule. ## Code fixes @@ -139,4 +152,5 @@ Most rules above have a fix in `Purview.SourceGeneratorFramework.CodeFixers`: ## Related skills `purview-sourcegenerator-codewriter` for the emission patterns the `CodeWriter` rules push you -towards, and `purview-sourcegenerator-testing` for proving a pipeline actually caches. +towards, `purview-sourcegenerator-testing` for proving a pipeline actually caches, and +`purview-sourcegenerator-attribute-data-models` for the `ADM` rules. diff --git a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md index a0e7f802..a34f72c4 100644 --- a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -185,10 +185,12 @@ marker is the preferred configuration. | 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | | 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | | 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | +| 5.12 | VS 2026 18.x | C# 15 / .NET 11 | -**This framework is built against Roslyn 5.0**, so a host loading it must be Roslyn 5.0 or later -(.NET 10 SDK / Visual Studio 2026 18.0). The testing packages multi-target `net8.0`–`net10.0`, and -Roslyn 5.x ships `net8.0`/`net9.0` assets, so those test targets still load the runner. +**This framework is built against Roslyn 5.9**, so a host loading it must ship Roslyn 5.9 or later +(.NET 11 SDK / Visual Studio 2026). The .NET 11 SDK ships Roslyn 5.12. The testing packages +multi-target `netstandard2.0`, `net10.0`, and `net11.0`, and Roslyn 5.9 ships `net10.0`/ +`netstandard2.0` assets, so both test targets load the runner. ## A generator project to start from diff --git a/.agents/skills/purview-sourcegenerator-testing/SKILL.md b/.agents/skills/purview-sourcegenerator-testing/SKILL.md index aaeff09e..5590a896 100644 --- a/.agents/skills/purview-sourcegenerator-testing/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-testing/SKILL.md @@ -3,8 +3,9 @@ name: purview-sourcegenerator-testing description: >- Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes or refactorings built on Purview.SourceGeneratorFramework - picking the right runner and base class, - configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, and asserting - incremental pipeline caching stage by stage. + configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, testing attribute-data + models against real AttributeData with AttributeDataFactory, and asserting incremental pipeline caching + stage by stage. metadata: product: Purview.SourceGeneratorFramework repository: purview-dev/sourcegenerator-framework @@ -167,16 +168,55 @@ an assertion, nor calling a helper that is handed the result — an extension on parameter: both hand the same instance back, so `await Assert.That(result).SomeAssertion()` and `result.AssertNoGenerationExceptions()` are not flagged. +## Testing attribute applications and attribute-data models + +A generator that reads attributes has two halves to test: what Roslyn gives it for an application, and what +its parsing (usually a generated attribute-data model) makes of that. `AttributeDataFactory` produces the +real `AttributeData` without running a generator; `AttributeArguments` describes it. + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; + +// One application. Placed on a class, or on the first declaration its AttributeUsage allows. +AttributeData attribute = AttributeDataFactory.FromUsage("[Log(\"text\", eventId: 7, Name = \"N\")]", options); + +// Every application on a type, or on one of its members. +var onMember = AttributeDataFactory.FromSource(source, "Sample.IEvents", "Raised", options); + +var arguments = AttributeArguments.From(attribute); +arguments.TryGetConstructorArgument(0, out var byOrdinal); // #0 message: "text" +arguments.TryGetConstructorArgument("eventId", out var byName); // Form = NamedParameter +arguments.TryGetNamedArgument("Name", out var named); // Form = NamedArgument +arguments.ToString(); // [LogAttribute(#0 message: "text", #1 eventId: 7 (by name), Name = "N")] + +var model = LogAttributeData.FromAttributeData(attribute); +``` + +- References and usings come from the options: `AdditionalAssemblyTypes` for the assembly declaring the + attribute, `AdditionalNamespaces` for its namespace, or declare the attribute in `AdditionalSources`. +- A compilation error throws `InvalidOperationException` listing the errors, so a misspelt parameter name + fails the test rather than producing an application with a missing argument. +- `AttributeArgumentForm` tells `Positional`, `NamedParameter` (`message: "x"`), `Defaulted` (an optional + parameter that was not written) and `NamedArgument` (`Name = "x"`) apart, which `AttributeData` alone + cannot. +- `AttributeArgument.ValueEquals` compares numbers across types, enums by value or member name, `typeof` + by `Type`/`TypeIdentity`/name, and arrays element-wise. Names are case-sensitive, as generated models are. +- The TUnit assertions over these (`HasConstructorArgument`, `HasNamedArgument`, …) are in + `purview-sourcegenerator-tunit-tests`; what to cover for a model is in + `purview-sourcegenerator-attribute-data-models`. + ## Best practices +- **Test attribute parsing against real `AttributeData`** (`AttributeDataFactory`), not only by asserting + on emitted parsing code: emitted code can look right and still read the wrong name. - **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. + framework is built against Roslyn 5.9 (`net10.0`/`netstandard2.0` assets keep a .NET 10–11 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). diff --git a/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md index bc711c59..2a3a0612 100644 --- a/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md @@ -3,7 +3,8 @@ name: purview-sourcegenerator-tunit-tests description: >- Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes or refactorings built on Purview.SourceGeneratorFramework - choosing the correct TUnit base class and - method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions, and + method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions (including + attribute-argument assertions for testing attribute-data models), and modernising existing tests. metadata: product: Purview.SourceGeneratorFramework.Testing.TUnit @@ -125,6 +126,21 @@ when awaited. ``` - **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. +- **`AttributeDataAssertions`** — on an `AttributeData` (typically from `AttributeDataFactory`): + `HasConstructorArgument(ordinal, expected)`, `HasConstructorArgument(parameterName, expected[, form])`, + `DoesNotHaveConstructorArgument(parameterName)` (defaulted or absent), `HasNamedArgument(name, expected)`, + `DoesNotHaveNamedArgument(name)`. Each returns the matched `AttributeArgument`, and a failure lists every + argument of the application with how it was supplied. Pin the attribute side with these before asserting + on the model, so a failure says which half is wrong: + ```csharp + var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\")]", options); + + await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); + await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); + await Assert.That(attribute).DoesNotHaveNamedArgument("Name"); + + await Assert.That(InfoAttributeData.FromAttributeData(attribute).Log.MessageTemplate).IsEqualTo("custom {Value}"); + ``` - **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. - **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` (whitespace-flattened string comparison). diff --git a/.agents/skills/purview-telemetry/SKILL.md b/.agents/skills/purview-telemetry/SKILL.md index 6d831731..d2f42d78 100644 --- a/.agents/skills/purview-telemetry/SKILL.md +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview telemetry @@ -120,4 +121,4 @@ Instrumentation is identical across all three rows. Only the right-hand column g ## See also - `purview-architecture` — establishing project context and scale before deciding -- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/purview-telemetry-sourcegenerator) +- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/telemetry-sourcegenerator) diff --git a/.assets/csharpier/CSharpier.Core.dll b/.assets/csharpier/CSharpier.Core.dll deleted file mode 100644 index d4f28854..00000000 Binary files a/.assets/csharpier/CSharpier.Core.dll and /dev/null differ diff --git a/.assets/csharpier/CSharpier.deps.json b/.assets/csharpier/CSharpier.deps.json deleted file mode 100644 index ba741441..00000000 --- a/.assets/csharpier/CSharpier.deps.json +++ /dev/null @@ -1,576 +0,0 @@ -{ - "runtimeTarget": { - "name": ".NETCoreApp,Version=v11.0", - "signature": "" - }, - "compilationOptions": {}, - "targets": { - ".NETCoreApp,Version=v11.0": { - "CSharpier/1.3.0": { - "dependencies": { - "CSharpier.Core": "1.3.0", - "Microsoft.Extensions.Logging": "9.0.10", - "NReco.Logging.File": "1.2.2", - "System.CommandLine": "2.0.0-beta4.22272.1", - "System.IO.Abstractions": "22.1.1", - "System.IO.Abstractions.TestingHelpers": "22.0.16", - "System.IO.Hashing": "9.0.10", - "YamlDotNet": "16.3.0", - "ini-parser-netstandard": "2.5.3" - }, - "runtime": { - "CSharpier.dll": {} - } - }, - "ini-parser-netstandard/2.5.3": { - "runtime": { - "lib/netstandard2.0/INIFileParser.dll": { - "assemblyVersion": "2.5.3.0", - "fileVersion": "2.5.3.0", - "localPath": "INIFileParser.dll" - } - } - }, - "LovettSoftware.XmlDiff/1.1.0": { - "runtime": { - "lib/net7.0/XmlDiffPatch.View.dll": { - "assemblyVersion": "1.1.0.0", - "fileVersion": "1.1.0.0", - "localPath": "XmlDiffPatch.View.dll" - }, - "lib/net7.0/XmlDiffPatch.dll": { - "assemblyVersion": "1.1.0.0", - "fileVersion": "1.1.0.0", - "localPath": "XmlDiffPatch.dll" - } - } - }, - "Microsoft.CodeAnalysis.Common/5.9.0-1.26379.116": { - "runtime": { - "lib/net10.0/Microsoft.CodeAnalysis.dll": { - "assemblyVersion": "5.9.0.0", - "fileVersion": "5.900.26.38016", - "localPath": "Microsoft.CodeAnalysis.dll" - } - }, - "resources": { - "lib/net10.0/cs/Microsoft.CodeAnalysis.resources.dll": { - "locale": "cs", - "localPath": "cs/Microsoft.CodeAnalysis.resources.dll" - }, - "lib/net10.0/de/Microsoft.CodeAnalysis.resources.dll": { - "locale": "de", - "localPath": "de/Microsoft.CodeAnalysis.resources.dll" - }, - "lib/net10.0/es/Microsoft.CodeAnalysis.resources.dll": { - "locale": "es", - "localPath": "es/Microsoft.CodeAnalysis.resources.dll" - }, - "lib/net10.0/fr/Microsoft.CodeAnalysis.resources.dll": { - "locale": "fr", - "localPath": "fr/Microsoft.CodeAnalysis.resources.dll" - }, - "lib/net10.0/it/Microsoft.CodeAnalysis.resources.dll": { - "locale": "it", - "localPath": "it/Microsoft.CodeAnalysis.resources.dll" - }, - "lib/net10.0/ja/Microsoft.CodeAnalysis.resources.dll": { - "locale": "ja", - "localPath": "ja/Microsoft.CodeAnalysis.resources.dll" - }, - "lib/net10.0/ko/Microsoft.CodeAnalysis.resources.dll": { - "locale": "ko", - "localPath": "ko/Microsoft.CodeAnalysis.resources.dll" - }, - "lib/net10.0/pl/Microsoft.CodeAnalysis.resources.dll": { - "locale": "pl", - "localPath": "pl/Microsoft.CodeAnalysis.resources.dll" - }, - "lib/net10.0/pt-BR/Microsoft.CodeAnalysis.resources.dll": { - "locale": "pt-BR", - "localPath": "pt-BR/Microsoft.CodeAnalysis.resources.dll" - }, - "lib/net10.0/ru/Microsoft.CodeAnalysis.resources.dll": { - "locale": "ru", - "localPath": "ru/Microsoft.CodeAnalysis.resources.dll" - }, - "lib/net10.0/tr/Microsoft.CodeAnalysis.resources.dll": { - "locale": "tr", - "localPath": "tr/Microsoft.CodeAnalysis.resources.dll" - }, - "lib/net10.0/zh-Hans/Microsoft.CodeAnalysis.resources.dll": { - "locale": "zh-Hans", - "localPath": "zh-Hans/Microsoft.CodeAnalysis.resources.dll" - }, - "lib/net10.0/zh-Hant/Microsoft.CodeAnalysis.resources.dll": { - "locale": "zh-Hant", - "localPath": "zh-Hant/Microsoft.CodeAnalysis.resources.dll" - } - } - }, - "Microsoft.CodeAnalysis.CSharp/5.9.0-1.26379.116": { - "dependencies": { - "Microsoft.CodeAnalysis.Common": "5.9.0-1.26379.116" - }, - "runtime": { - "lib/net10.0/Microsoft.CodeAnalysis.CSharp.dll": { - "assemblyVersion": "5.9.0.0", - "fileVersion": "5.900.26.38016", - "localPath": "Microsoft.CodeAnalysis.CSharp.dll" - } - }, - "resources": { - "lib/net10.0/cs/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "cs", - "localPath": "cs/Microsoft.CodeAnalysis.CSharp.resources.dll" - }, - "lib/net10.0/de/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "de", - "localPath": "de/Microsoft.CodeAnalysis.CSharp.resources.dll" - }, - "lib/net10.0/es/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "es", - "localPath": "es/Microsoft.CodeAnalysis.CSharp.resources.dll" - }, - "lib/net10.0/fr/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "fr", - "localPath": "fr/Microsoft.CodeAnalysis.CSharp.resources.dll" - }, - "lib/net10.0/it/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "it", - "localPath": "it/Microsoft.CodeAnalysis.CSharp.resources.dll" - }, - "lib/net10.0/ja/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "ja", - "localPath": "ja/Microsoft.CodeAnalysis.CSharp.resources.dll" - }, - "lib/net10.0/ko/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "ko", - "localPath": "ko/Microsoft.CodeAnalysis.CSharp.resources.dll" - }, - "lib/net10.0/pl/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "pl", - "localPath": "pl/Microsoft.CodeAnalysis.CSharp.resources.dll" - }, - "lib/net10.0/pt-BR/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "pt-BR", - "localPath": "pt-BR/Microsoft.CodeAnalysis.CSharp.resources.dll" - }, - "lib/net10.0/ru/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "ru", - "localPath": "ru/Microsoft.CodeAnalysis.CSharp.resources.dll" - }, - "lib/net10.0/tr/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "tr", - "localPath": "tr/Microsoft.CodeAnalysis.CSharp.resources.dll" - }, - "lib/net10.0/zh-Hans/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "zh-Hans", - "localPath": "zh-Hans/Microsoft.CodeAnalysis.CSharp.resources.dll" - }, - "lib/net10.0/zh-Hant/Microsoft.CodeAnalysis.CSharp.resources.dll": { - "locale": "zh-Hant", - "localPath": "zh-Hant/Microsoft.CodeAnalysis.CSharp.resources.dll" - } - } - }, - "Microsoft.Extensions.Configuration/8.0.0": { - "runtime": { - "lib/net8.0/Microsoft.Extensions.Configuration.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.23.53103", - "localPath": "Microsoft.Extensions.Configuration.dll" - } - } - }, - "Microsoft.Extensions.Configuration.Binder/8.0.2": { - "runtime": { - "lib/net8.0/Microsoft.Extensions.Configuration.Binder.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.724.31311", - "localPath": "Microsoft.Extensions.Configuration.Binder.dll" - } - } - }, - "Microsoft.Extensions.DependencyInjection/9.0.10": { - "runtime": { - "lib/net9.0/Microsoft.Extensions.DependencyInjection.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.1025.47515", - "localPath": "Microsoft.Extensions.DependencyInjection.dll" - } - } - }, - "Microsoft.Extensions.Logging/9.0.10": { - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.10" - }, - "runtime": { - "lib/net9.0/Microsoft.Extensions.Logging.dll": { - "assemblyVersion": "9.0.0.0", - "fileVersion": "9.0.1025.47515", - "localPath": "Microsoft.Extensions.Logging.dll" - } - } - }, - "Microsoft.Extensions.Logging.Configuration/8.0.1": { - "dependencies": { - "Microsoft.Extensions.Configuration": "8.0.0", - "Microsoft.Extensions.Configuration.Binder": "8.0.2", - "Microsoft.Extensions.Logging": "9.0.10", - "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Logging.Configuration.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1024.46610", - "localPath": "Microsoft.Extensions.Logging.Configuration.dll" - } - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions/8.0.0": { - "dependencies": { - "Microsoft.Extensions.Configuration.Binder": "8.0.2" - }, - "runtime": { - "lib/net8.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll": { - "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.23.53103", - "localPath": "Microsoft.Extensions.Options.ConfigurationExtensions.dll" - } - } - }, - "NReco.Logging.File/1.2.2": { - "dependencies": { - "Microsoft.Extensions.Logging": "9.0.10", - "Microsoft.Extensions.Logging.Configuration": "8.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" - }, - "runtime": { - "lib/net8.0/NReco.Logging.File.dll": { - "assemblyVersion": "1.2.2.0", - "fileVersion": "1.2.2.0", - "localPath": "NReco.Logging.File.dll" - } - } - }, - "System.CommandLine/2.0.0-beta4.22272.1": { - "runtime": { - "lib/net6.0/System.CommandLine.dll": { - "assemblyVersion": "2.0.0.0", - "fileVersion": "2.0.22.27201", - "localPath": "System.CommandLine.dll" - } - }, - "resources": { - "lib/net6.0/cs/System.CommandLine.resources.dll": { - "locale": "cs", - "localPath": "cs/System.CommandLine.resources.dll" - }, - "lib/net6.0/de/System.CommandLine.resources.dll": { - "locale": "de", - "localPath": "de/System.CommandLine.resources.dll" - }, - "lib/net6.0/es/System.CommandLine.resources.dll": { - "locale": "es", - "localPath": "es/System.CommandLine.resources.dll" - }, - "lib/net6.0/fr/System.CommandLine.resources.dll": { - "locale": "fr", - "localPath": "fr/System.CommandLine.resources.dll" - }, - "lib/net6.0/it/System.CommandLine.resources.dll": { - "locale": "it", - "localPath": "it/System.CommandLine.resources.dll" - }, - "lib/net6.0/ja/System.CommandLine.resources.dll": { - "locale": "ja", - "localPath": "ja/System.CommandLine.resources.dll" - }, - "lib/net6.0/ko/System.CommandLine.resources.dll": { - "locale": "ko", - "localPath": "ko/System.CommandLine.resources.dll" - }, - "lib/net6.0/pl/System.CommandLine.resources.dll": { - "locale": "pl", - "localPath": "pl/System.CommandLine.resources.dll" - }, - "lib/net6.0/pt-BR/System.CommandLine.resources.dll": { - "locale": "pt-BR", - "localPath": "pt-BR/System.CommandLine.resources.dll" - }, - "lib/net6.0/ru/System.CommandLine.resources.dll": { - "locale": "ru", - "localPath": "ru/System.CommandLine.resources.dll" - }, - "lib/net6.0/tr/System.CommandLine.resources.dll": { - "locale": "tr", - "localPath": "tr/System.CommandLine.resources.dll" - }, - "lib/net6.0/zh-Hans/System.CommandLine.resources.dll": { - "locale": "zh-Hans", - "localPath": "zh-Hans/System.CommandLine.resources.dll" - }, - "lib/net6.0/zh-Hant/System.CommandLine.resources.dll": { - "locale": "zh-Hant", - "localPath": "zh-Hant/System.CommandLine.resources.dll" - } - } - }, - "System.IO.Abstractions/22.1.1": { - "dependencies": { - "TestableIO.System.IO.Abstractions": "22.1.1", - "TestableIO.System.IO.Abstractions.Wrappers": "22.1.1" - }, - "runtime": { - "lib/net10.0/System.IO.Abstractions.dll": { - "assemblyVersion": "22.1.1.0", - "fileVersion": "22.1.1.0", - "localPath": "System.IO.Abstractions.dll" - } - } - }, - "System.IO.Abstractions.TestingHelpers/22.0.16": { - "dependencies": { - "TestableIO.System.IO.Abstractions.TestingHelpers": "22.0.16" - }, - "runtime": { - "lib/net9.0/System.IO.Abstractions.TestingHelpers.dll": { - "assemblyVersion": "22.0.16.0", - "fileVersion": "22.0.16.0", - "localPath": "System.IO.Abstractions.TestingHelpers.dll" - } - } - }, - "System.IO.Hashing/9.0.10": { - "runtime": { - "lib/net9.0/System.IO.Hashing.dll": { - "assemblyVersion": "9.0.0.10", - "fileVersion": "9.0.1025.47515", - "localPath": "System.IO.Hashing.dll" - } - } - }, - "TestableIO.System.IO.Abstractions/22.1.1": { - "dependencies": { - "Testably.Abstractions.FileSystem.Interface": "10.1.0" - }, - "runtime": { - "lib/net10.0/TestableIO.System.IO.Abstractions.dll": { - "assemblyVersion": "22.1.1.0", - "fileVersion": "22.1.1.0", - "localPath": "TestableIO.System.IO.Abstractions.dll" - } - } - }, - "TestableIO.System.IO.Abstractions.TestingHelpers/22.0.16": { - "dependencies": { - "TestableIO.System.IO.Abstractions.Wrappers": "22.1.1", - "Testably.Abstractions.FileSystem.Interface": "10.1.0" - }, - "runtime": { - "lib/net9.0/TestableIO.System.IO.Abstractions.TestingHelpers.dll": { - "assemblyVersion": "22.0.16.0", - "fileVersion": "22.0.16.0", - "localPath": "TestableIO.System.IO.Abstractions.TestingHelpers.dll" - } - } - }, - "TestableIO.System.IO.Abstractions.Wrappers/22.1.1": { - "dependencies": { - "Testably.Abstractions.FileSystem.Interface": "10.1.0" - }, - "runtime": { - "lib/net10.0/TestableIO.System.IO.Abstractions.Wrappers.dll": { - "assemblyVersion": "22.1.1.0", - "fileVersion": "22.1.1.0", - "localPath": "TestableIO.System.IO.Abstractions.Wrappers.dll" - } - } - }, - "Testably.Abstractions.FileSystem.Interface/10.1.0": { - "runtime": { - "lib/net10.0/Testably.Abstractions.FileSystem.Interface.dll": { - "assemblyVersion": "10.1.0.0", - "fileVersion": "10.1.0.0", - "localPath": "Testably.Abstractions.FileSystem.Interface.dll" - } - } - }, - "YamlDotNet/16.3.0": { - "runtime": { - "lib/net8.0/YamlDotNet.dll": { - "assemblyVersion": "16.0.0.0", - "fileVersion": "16.3.0.0", - "localPath": "YamlDotNet.dll" - } - } - }, - "CSharpier.Core/1.3.0": { - "dependencies": { - "LovettSoftware.XmlDiff": "1.1.0", - "Microsoft.CodeAnalysis.CSharp": "5.9.0-1.26379.116", - "System.IO.Abstractions": "22.1.1" - }, - "runtime": { - "CSharpier.Core.dll": { - "assemblyVersion": "1.3.0.0", - "fileVersion": "1.3.0.0" - } - } - } - } - }, - "libraries": { - "CSharpier/1.3.0": { - "type": "project", - "serviceable": false, - "sha512": "" - }, - "ini-parser-netstandard/2.5.3": { - "type": "package", - "serviceable": true, - "sha512": "sha512-Ht3UqRCqizBPbB5pvQ/yCNZIChY6Cy5nLn7TKIlJzWnf4pbqGKqDO+AY6MDTD+fXgEzzMb64Qzl4/Hy7vEqfLA==", - "path": "ini-parser-netstandard/2.5.3", - "hashPath": "ini-parser-netstandard.2.5.3.nupkg.sha512" - }, - "LovettSoftware.XmlDiff/1.1.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-5mrNvjKw0/q1lr9RHR36wdglI7PvapGn4+k+5JvLVIyevvHel2q9rkANSHZivnbdo5i4qLE1TwQeGte/RB7NEA==", - "path": "lovettsoftware.xmldiff/1.1.0", - "hashPath": "lovettsoftware.xmldiff.1.1.0.nupkg.sha512" - }, - "Microsoft.CodeAnalysis.Common/5.9.0-1.26379.116": { - "type": "package", - "serviceable": true, - "sha512": "sha512-XzTiL9Fmz7nD1J44XuHIVimwoGWzhG+DUMrZjSzk0Mk2UVe40AD7x7KBp9dj1YkMJVxn313TcOtf+ODitZAXkw==", - "path": "microsoft.codeanalysis.common/5.9.0-1.26379.116", - "hashPath": "microsoft.codeanalysis.common.5.9.0-1.26379.116.nupkg.sha512" - }, - "Microsoft.CodeAnalysis.CSharp/5.9.0-1.26379.116": { - "type": "package", - "serviceable": true, - "sha512": "sha512-sw3ejcX8W5QaWIjZf6/K5nxho9XWH3E+H6jXW/zDBTt01ah9M8P6TXo4u3Rff94szkFt+vrJF722PNgRqd+3jg==", - "path": "microsoft.codeanalysis.csharp/5.9.0-1.26379.116", - "hashPath": "microsoft.codeanalysis.csharp.5.9.0-1.26379.116.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration/8.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==", - "path": "microsoft.extensions.configuration/8.0.0", - "hashPath": "microsoft.extensions.configuration.8.0.0.nupkg.sha512" - }, - "Microsoft.Extensions.Configuration.Binder/8.0.2": { - "type": "package", - "serviceable": true, - "sha512": "sha512-7IQhGK+wjyGrNsPBjJcZwWAr+Wf6D4+TwOptUt77bWtgNkiV8tDEbhFS+dDamtQFZ2X7kWG9m71iZQRj2x3zgQ==", - "path": "microsoft.extensions.configuration.binder/8.0.2", - "hashPath": "microsoft.extensions.configuration.binder.8.0.2.nupkg.sha512" - }, - "Microsoft.Extensions.DependencyInjection/9.0.10": { - "type": "package", - "serviceable": true, - "sha512": "sha512-iEtXCkNd5XhjNJAOb/wO4IhDRdLIE2CsPxZggZQWJ/q2+sa8dmEPC393nnsiqdH8/4KV8Xn25IzgKPR1UEQ0og==", - "path": "microsoft.extensions.dependencyinjection/9.0.10", - "hashPath": "microsoft.extensions.dependencyinjection.9.0.10.nupkg.sha512" - }, - "Microsoft.Extensions.Logging/9.0.10": { - "type": "package", - "serviceable": true, - "sha512": "sha512-UBXHqE9vyptVhaFnT1R7YJKCve7TqVI10yjjUZBNGMlW2lZ4c031Slt9hxsOzWCzlpPxxIFyf1Yk4a6Iubxx7w==", - "path": "microsoft.extensions.logging/9.0.10", - "hashPath": "microsoft.extensions.logging.9.0.10.nupkg.sha512" - }, - "Microsoft.Extensions.Logging.Configuration/8.0.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-QWwTrsgOnJMmn+XUslm8D2H1n3PkP/u/v52FODtyBc/k4W9r3i2vcXXeeX/upnzllJYRRbrzVzT0OclfNJtBJA==", - "path": "microsoft.extensions.logging.configuration/8.0.1", - "hashPath": "microsoft.extensions.logging.configuration.8.0.1.nupkg.sha512" - }, - "Microsoft.Extensions.Options.ConfigurationExtensions/8.0.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==", - "path": "microsoft.extensions.options.configurationextensions/8.0.0", - "hashPath": "microsoft.extensions.options.configurationextensions.8.0.0.nupkg.sha512" - }, - "NReco.Logging.File/1.2.2": { - "type": "package", - "serviceable": true, - "sha512": "sha512-UyUIkyDiHi2HAJlmEWqeKN9/FxTF0DPNdyatzMDMTXvUpgvqBFneJ2qDtZkXRJNG8eR6jU+KsbGeMmChgUdRUg==", - "path": "nreco.logging.file/1.2.2", - "hashPath": "nreco.logging.file.1.2.2.nupkg.sha512" - }, - "System.CommandLine/2.0.0-beta4.22272.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-1uqED/q2H0kKoLJ4+hI2iPSBSEdTuhfCYADeJrAqERmiGQ2NNacYKRNEQ+gFbU4glgVyK8rxI+ZOe1onEtr/Pg==", - "path": "system.commandline/2.0.0-beta4.22272.1", - "hashPath": "system.commandline.2.0.0-beta4.22272.1.nupkg.sha512" - }, - "System.IO.Abstractions/22.1.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-nm4sNhAoN1NyiIVT6MS3EEzNjnir3PC1HE0mKGGUFg6RBW1HmRvWKuRDPkij1etOoQkpyIFmjVGcM6TpBGF3qQ==", - "path": "system.io.abstractions/22.1.1", - "hashPath": "system.io.abstractions.22.1.1.nupkg.sha512" - }, - "System.IO.Abstractions.TestingHelpers/22.0.16": { - "type": "package", - "serviceable": true, - "sha512": "sha512-0iowUSeukbt98ZDpTXdjYwssgLDmqMfCP8BHoEvV9G0mkAxNFhMwtnvLcARWFFLNN0zHVghD5ByQN16DMXxxpQ==", - "path": "system.io.abstractions.testinghelpers/22.0.16", - "hashPath": "system.io.abstractions.testinghelpers.22.0.16.nupkg.sha512" - }, - "System.IO.Hashing/9.0.10": { - "type": "package", - "serviceable": true, - "sha512": "sha512-9gv5z71xaWWmcGEs4bXdreIhKp2kYLK2fvPK5gQkgnWMYvZ8ieaxKofDjxL3scZiEYfi/yW2nJTiKV2awcWEdA==", - "path": "system.io.hashing/9.0.10", - "hashPath": "system.io.hashing.9.0.10.nupkg.sha512" - }, - "TestableIO.System.IO.Abstractions/22.1.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-EJmXIKfwvJHvSHWP+NjA213avUdofV4SmBx2aWxc3SzPkrHHVKxUMC3fSa/epzk/SVITwuU2EAntgQTvobAqwQ==", - "path": "testableio.system.io.abstractions/22.1.1", - "hashPath": "testableio.system.io.abstractions.22.1.1.nupkg.sha512" - }, - "TestableIO.System.IO.Abstractions.TestingHelpers/22.0.16": { - "type": "package", - "serviceable": true, - "sha512": "sha512-dIZ2qtrvWbilx2HGkBIp5LNZmEZT2rve2M/NEg3RAzR4WbTa/Gis1x/qRggnoqfDrZ0VnPaybJJKh6r9POQ1EQ==", - "path": "testableio.system.io.abstractions.testinghelpers/22.0.16", - "hashPath": "testableio.system.io.abstractions.testinghelpers.22.0.16.nupkg.sha512" - }, - "TestableIO.System.IO.Abstractions.Wrappers/22.1.1": { - "type": "package", - "serviceable": true, - "sha512": "sha512-J91zmSwuMY75ybtkqAFMC3vDNMkLJ/kiDDLhORITEwTKNhEwEYfxAQGv+f0jGYZg/H0YbnGcx5Ky0FgFJwrrWQ==", - "path": "testableio.system.io.abstractions.wrappers/22.1.1", - "hashPath": "testableio.system.io.abstractions.wrappers.22.1.1.nupkg.sha512" - }, - "Testably.Abstractions.FileSystem.Interface/10.1.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-0R+m7DvS2QuDcFOfDGtyGErRrgXOl4/z2tbNLX2RfvlYJjlVcKG+wMIBfZO8vxGtTNwNxffTG4ibFRwd0jkM7w==", - "path": "testably.abstractions.filesystem.interface/10.1.0", - "hashPath": "testably.abstractions.filesystem.interface.10.1.0.nupkg.sha512" - }, - "YamlDotNet/16.3.0": { - "type": "package", - "serviceable": true, - "sha512": "sha512-SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA==", - "path": "yamldotnet/16.3.0", - "hashPath": "yamldotnet.16.3.0.nupkg.sha512" - }, - "CSharpier.Core/1.3.0": { - "type": "project", - "serviceable": false, - "sha512": "" - } - } -} \ No newline at end of file diff --git a/.assets/csharpier/CSharpier.dll b/.assets/csharpier/CSharpier.dll deleted file mode 100644 index bff88c6e..00000000 Binary files a/.assets/csharpier/CSharpier.dll and /dev/null differ diff --git a/.assets/csharpier/CSharpier.exe b/.assets/csharpier/CSharpier.exe deleted file mode 100644 index b892eed9..00000000 Binary files a/.assets/csharpier/CSharpier.exe and /dev/null differ diff --git a/.assets/csharpier/CSharpier.runtimeconfig.json b/.assets/csharpier/CSharpier.runtimeconfig.json deleted file mode 100644 index ede73072..00000000 --- a/.assets/csharpier/CSharpier.runtimeconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "runtimeOptions": { - "tfm": "net11.0", - "rollForward": "Major", - "framework": { - "name": "Microsoft.NETCore.App", - "version": "11.0.0-rc.3.26522.148" - }, - "configProperties": { - "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, - "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false - } - } -} \ No newline at end of file diff --git a/.assets/csharpier/INIFileParser.dll b/.assets/csharpier/INIFileParser.dll deleted file mode 100644 index 475c57ed..00000000 Binary files a/.assets/csharpier/INIFileParser.dll and /dev/null differ diff --git a/.assets/csharpier/Microsoft.CodeAnalysis.CSharp.dll b/.assets/csharpier/Microsoft.CodeAnalysis.CSharp.dll deleted file mode 100644 index da9d4cda..00000000 Binary files a/.assets/csharpier/Microsoft.CodeAnalysis.CSharp.dll and /dev/null differ diff --git a/.assets/csharpier/Microsoft.CodeAnalysis.dll b/.assets/csharpier/Microsoft.CodeAnalysis.dll deleted file mode 100644 index 92ebd17c..00000000 Binary files a/.assets/csharpier/Microsoft.CodeAnalysis.dll and /dev/null differ diff --git a/.assets/csharpier/Microsoft.Extensions.Configuration.Binder.dll b/.assets/csharpier/Microsoft.Extensions.Configuration.Binder.dll deleted file mode 100644 index 557d6d9e..00000000 Binary files a/.assets/csharpier/Microsoft.Extensions.Configuration.Binder.dll and /dev/null differ diff --git a/.assets/csharpier/Microsoft.Extensions.Configuration.dll b/.assets/csharpier/Microsoft.Extensions.Configuration.dll deleted file mode 100644 index d3e5c229..00000000 Binary files a/.assets/csharpier/Microsoft.Extensions.Configuration.dll and /dev/null differ diff --git a/.assets/csharpier/Microsoft.Extensions.DependencyInjection.dll b/.assets/csharpier/Microsoft.Extensions.DependencyInjection.dll deleted file mode 100644 index a4b40ddb..00000000 Binary files a/.assets/csharpier/Microsoft.Extensions.DependencyInjection.dll and /dev/null differ diff --git a/.assets/csharpier/Microsoft.Extensions.Logging.Configuration.dll b/.assets/csharpier/Microsoft.Extensions.Logging.Configuration.dll deleted file mode 100644 index 7a759d7c..00000000 Binary files a/.assets/csharpier/Microsoft.Extensions.Logging.Configuration.dll and /dev/null differ diff --git a/.assets/csharpier/Microsoft.Extensions.Logging.dll b/.assets/csharpier/Microsoft.Extensions.Logging.dll deleted file mode 100644 index ed6be279..00000000 Binary files a/.assets/csharpier/Microsoft.Extensions.Logging.dll and /dev/null differ diff --git a/.assets/csharpier/Microsoft.Extensions.Options.ConfigurationExtensions.dll b/.assets/csharpier/Microsoft.Extensions.Options.ConfigurationExtensions.dll deleted file mode 100644 index cbb29a1a..00000000 Binary files a/.assets/csharpier/Microsoft.Extensions.Options.ConfigurationExtensions.dll and /dev/null differ diff --git a/.assets/csharpier/NReco.Logging.File.dll b/.assets/csharpier/NReco.Logging.File.dll deleted file mode 100644 index f3396ed9..00000000 Binary files a/.assets/csharpier/NReco.Logging.File.dll and /dev/null differ diff --git a/.assets/csharpier/System.CommandLine.dll b/.assets/csharpier/System.CommandLine.dll deleted file mode 100644 index 3c8597b6..00000000 Binary files a/.assets/csharpier/System.CommandLine.dll and /dev/null differ diff --git a/.assets/csharpier/System.IO.Abstractions.TestingHelpers.dll b/.assets/csharpier/System.IO.Abstractions.TestingHelpers.dll deleted file mode 100644 index 20f12d63..00000000 Binary files a/.assets/csharpier/System.IO.Abstractions.TestingHelpers.dll and /dev/null differ diff --git a/.assets/csharpier/System.IO.Abstractions.dll b/.assets/csharpier/System.IO.Abstractions.dll deleted file mode 100644 index d065cbe6..00000000 Binary files a/.assets/csharpier/System.IO.Abstractions.dll and /dev/null differ diff --git a/.assets/csharpier/System.IO.Hashing.dll b/.assets/csharpier/System.IO.Hashing.dll deleted file mode 100644 index 4860654d..00000000 Binary files a/.assets/csharpier/System.IO.Hashing.dll and /dev/null differ diff --git a/.assets/csharpier/TestableIO.System.IO.Abstractions.TestingHelpers.dll b/.assets/csharpier/TestableIO.System.IO.Abstractions.TestingHelpers.dll deleted file mode 100644 index a68484ea..00000000 Binary files a/.assets/csharpier/TestableIO.System.IO.Abstractions.TestingHelpers.dll and /dev/null differ diff --git a/.assets/csharpier/TestableIO.System.IO.Abstractions.Wrappers.dll b/.assets/csharpier/TestableIO.System.IO.Abstractions.Wrappers.dll deleted file mode 100644 index fde6cbd8..00000000 Binary files a/.assets/csharpier/TestableIO.System.IO.Abstractions.Wrappers.dll and /dev/null differ diff --git a/.assets/csharpier/TestableIO.System.IO.Abstractions.dll b/.assets/csharpier/TestableIO.System.IO.Abstractions.dll deleted file mode 100644 index 851bdc24..00000000 Binary files a/.assets/csharpier/TestableIO.System.IO.Abstractions.dll and /dev/null differ diff --git a/.assets/csharpier/Testably.Abstractions.FileSystem.Interface.dll b/.assets/csharpier/Testably.Abstractions.FileSystem.Interface.dll deleted file mode 100644 index bc6173ef..00000000 Binary files a/.assets/csharpier/Testably.Abstractions.FileSystem.Interface.dll and /dev/null differ diff --git a/.assets/csharpier/XmlDiffPatch.View.dll b/.assets/csharpier/XmlDiffPatch.View.dll deleted file mode 100644 index aeb1584c..00000000 Binary files a/.assets/csharpier/XmlDiffPatch.View.dll and /dev/null differ diff --git a/.assets/csharpier/XmlDiffPatch.dll b/.assets/csharpier/XmlDiffPatch.dll deleted file mode 100644 index e2859f4f..00000000 Binary files a/.assets/csharpier/XmlDiffPatch.dll and /dev/null differ diff --git a/.assets/csharpier/YamlDotNet.dll b/.assets/csharpier/YamlDotNet.dll deleted file mode 100644 index 07454d5f..00000000 Binary files a/.assets/csharpier/YamlDotNet.dll and /dev/null differ diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 3fc1c2ce..bc9bc278 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,25 +3,25 @@ "isRoot": true, "tools": { "csharpier": { - "version": "1.3.0", + "version": "1.4.0", "commands": [ "csharpier" ], "rollForward": false }, "dotnet-inspect": { - "version": "0.26.0", + "version": "0.27.0", "commands": [ "dotnet-inspect" ], "rollForward": false }, "purview.build": { - "version": "0.8.0", + "version": "0.9.0", "commands": [ "purview-build" ], "rollForward": false } } -} +} \ No newline at end of file diff --git a/.config/purview-build.json b/.config/purview-build.json index cd1f9282..89bfc486 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -3,11 +3,7 @@ "Solution": "src/ZodSharp.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", - "TestFilter": "/*/*/*/*", - // The vendored CSharpier (an unreleased build that parses C# 15 `union`), the same binary the Justfile - // lint recipes run. Without this the pipeline lints with the tool-manifest 1.3.0, which formats lambda - // arguments differently, so `just lint-fix` output fails the pipeline's lint gate. - "CSharpierPath": ".assets/csharpier/CSharpier.dll" + "TestFilter": "/*/*/*/*" }, "PackValidation": { // Explicit because Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier diff --git a/.csharpierignore b/.csharpierignore index 51db78c8..9db3faa6 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -2,6 +2,9 @@ # union declarations are excluded from formatting. Revisit once CSharpier supports C# 15 unions. src/src/ZodSharp/Unions/NativeUnion.cs src/tests/Extensions.Validation.UnitTests/Fixtures/Unions.cs +src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedUnionModels.cs +src/tests/Blazor.UnitTests/Fixtures/GeneratedModels.cs +src/tests/AspNetCore.UnitTests/Fixtures/AsyncUnionBody.cs src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/PetUnion.cs src/tests/AspNetCore.UnitTests/Fixtures/OrderOrReference.cs src/tests/TrimSafety.Verification/Models.cs diff --git a/.editorconfig b/.editorconfig index f8b30509..a9b39cb1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.3.0 - managed file, do not edit +# Purview.BuildSdk 2.0.0 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4409e70a..76e1703d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,7 +19,7 @@ jobs: # install`. Before global.json pinned a version the same gap failed later instead, with # NETSDK1045 on the net11.0 target, because the 10.0.x SDK cannot build it. # Keep in sync with `sdk.version` in global.json. - dotnet-version: "11.0.100-rc.3.26522.148" + dotnet-version: "11.0.100" run-pack: true validate-pack: true secrets: inherit @@ -37,7 +37,7 @@ jobs: # Installs exactly the SDK global.json pins, which is the only thing the first dotnet command # in the job can resolve against. Naming a floating version and a feed quality instead does # not work: `11.0.x` with quality `preview` resolves the latest *preview*-channel build, and a - # preview sorts below the pinned rc, so global.json is left unsatisfiable. + # preview sorts below the pinned GA, so global.json is left unsatisfiable. - uses: actions/setup-dotnet@v6 with: global-json-file: global.json diff --git a/AGENTS.md b/AGENTS.md index afc77128..acf844dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,15 +22,22 @@ src/ src/ ZodSharp/ Core validation library + JSON Schema export (Z.ToJsonSchema) SourceGenerators/ Compile-time [ZodSchema] generator (netstandard2.0, Roslyn) + CodeFixes/ Code fixes shipped beside the generator (netstandard2.0) SystemTextJson/ System.Text.Json integration + JSON Schema import (Z.FromJsonSchema); browser-safe Http/ HttpClient integration (browser-safe): validated bodies, ZodValidationProblemDetails reading NewtonsoftJson/ Newtonsoft.Json integration + JSON Schema import AspNetCore/ ASP.NET Core ProblemDetails integration (+ static-SSR client rules on net11) + Extensions.Validation/ Microsoft.Extensions.Validation bridge (net11) + AspNetCore.OpenApi/ OpenAPI transformers (net11) Blazor/ Blazor forms integration (net11): ZodValidator, message components, zodsharp-validation.js Examples.CLI/ Usage examples - Benchmarks/ BenchmarkDotNet performance suite + Examples.MinimalApi/ Minimal-API sample host + Examples.Blazor/ Blazor sample host, plus Examples.Blazor.Client for its WebAssembly client + Examples.Shared/ Models shared by the samples + Benchmarks/ BenchmarkDotNet performance suite (net10/net11) tests/ - *.UnitTests/ TUnit test projects (ZodSharp, SystemTextJson, NewtonsoftJson, AspNetCore, SourceGenerators) + *.UnitTests/ TUnit test projects (ZodSharp, SystemTextJson, NewtonsoftJson, Http, AspNetCore, + Extensions.Validation, Blazor, SourceGenerators, Repository) cross-platform/ Output directory shared between the C# and TS cross-platform tests src/ts/ TypeScript (Zod) schema + fixture generation; consumed by C# and vitest tests tests/ts/ Vitest cross-platform tests @@ -47,7 +54,9 @@ The build is driven by `Purview.BuildSdk`, imported via `src/Directory.Build.pro - Package identities are `Purview.ZodSharp.*`; the SDK derives `AssemblyName`/`PackageId` from `RootNamespace` unless overridden explicitly. - Package versions are managed centrally in `Directory.Packages.props` (Central Package Management). - `global.json` pins the `Purview.BuildSdk` MSBuild SDK version. -- Pack layout for all packages is validated by `purview-build.json` (`PackValidation.RequiredContent`). +- Pack layout for all packages is validated by `.config/purview-build.json` (`PackValidation.RequiredContent`). +- **.NET 11 opt-ins.** `Purview.BuildSdk` 2.0 compiles every `net11.0` target with Runtime Async (`runtime-async=on`) and the C# 15 updated memory-safety rules (`MemorySafetyRules=2`). The GA compiler still classifies the latter as a preview feature, so `LangVersion` is `preview`, and `AllowUnsafeBlocks` is on: the STJ generator emits unsafe fast paths, and the two `Unsafe.As` boundaries in the sentinel/required rules use `unsafe` blocks so the audit boundary is visible at the call site. Do not remove either without removing the tier-2 opt-in. +- **Multithreaded MSBuild is off** (`PurviewMultiThreadedBuild=false`). The .NET 11 GA WebAssembly SDK declares `ReadWasmProps` with a custom `TaskFactory`, which `-mt` refuses to execute out of process, so `Examples.Blazor.Client` cannot build in that mode. Re-enable it once `dotnet/runtime` annotates the task as multithreadable. Before changing build/SDK configuration or adding/moving projects, consult the SDK's own skills, which are committed under `.agents/skills/` (see [Installed skills](#installed-skills-agentsskills)): `purview-buildsdk-configuration`, `purview-buildsdk-project-detection`, `purview-buildsdk-project-layout`. @@ -101,6 +110,8 @@ The generator and analyzer are built with `Purview.SourceGeneratorFramework`: Validation rules follow a conventions analyzer (`ValidationRuleConventionsAnalyzer`, diagnostic `ZODSGEN042`): a source-declared rule (a type implementing `ZodSharp.Core.IValidationRule`) must expose its error identity as public `const string ErrorCode` and `const string MessageFormat` constants, so tests can assert against the rule rather than duplicating literals. New built-in rules must follow the same convention; keep `AnalyzerReleases.Shipped.md`/`AnalyzerReleases.Unshipped.md` in sync when a diagnostic is added or changed. +DataAnnotations attributes without a dedicated Zod rule are never silently dropped: `[CustomValidation]`, `[FileExtensions]`, `[EnumDataType]` and a consumer's own `ValidationAttribute` are constructed in generated fields and run through `GetValidationResult`/`GetValidationResultAsync`. Class-level attributes — including the .NET 11 `AsyncValidationAttribute` — run as object-level rules and are collected from the base chain. `[SkipValidation]` (`Microsoft.Extensions.Validation`; class, property, parameter) is honored by the generator (a property is omitted), the endpoint filter, the bridge (a class is not claimed) and client validation; on .NET 11 match the real type, not just the attribute name. `IsValidatableMember` includes `[ZodAsyncMember]`-only members so field-level validation uses `ValidateMemberAsync`. + ## Numeric rules The numeric rules are split by the constraint each family needs — pick the constraint that matches the operations, never widen a rule unnecessarily: @@ -113,11 +124,28 @@ The numeric rules are split by the constraint each family needs — pick the con Generated rule attributes carry the rule's **value** parameters as a constructor: a parameter declared without a default is a required constructor argument (so it cannot be silently omitted), a parameter with a default keeps it, and `message`/`code`/`origin` stay properties. Attribute usages for required values are positional (`[MinValue(3)]`, `[Regex("^[a-z]+$")]`); defaulted values may still be set by property name. When changing rule constructor parameters, update the affected generator tests and the wiki attribute tables. +## Native unions (.NET 11) + +`Z.NativeUnion` returns `ZodSharp.Unions.NativeUnion`, a hand-written `[Union]` type rather than a `union` declaration. That is deliberate: a declared union always boxes a value-type case through its single `object?` field, while a hand-written union can implement the language's *non-boxing access pattern* — typed per-case fields, `HasValue`, and one `TryGetValue(out T)` per case — which the compiler uses for type and null patterns. Keep all four parts of the contract: + +- `[System.Runtime.CompilerServices.Union]` on the type, and an implicit constructor per case type (the compiler synthesizes the union conversions from them). +- A public `object? Value` property (the boxing view the contract requires; hot paths must never call it). +- `HasValue` that is equivalent to `Value is not null`, and `TryGetValue` overloads that return `true` only for their own non-null case. +- `default` must mean "no value": the constructors leave a null reference/nullable case empty instead of tagging it. + +`ZodSharp.Unions.IUnion` is the resolver's own contract (it adds `Tag`); the net11 type also implements the BCL `System.Runtime.CompilerServices.IUnion`. The hand-rolled `Union`/`Union` use the same tag numbering (`0` empty, `1..3` for the cases) and return `null` from `Value` when empty, so the two union types are interchangeable at the contract level. `ZODSGEN041` and its code fix suggest the native union for every `Z.Union` on .NET 11+, reference- or value-type cases alike. + +On the wire, `NativeUnion` carries `[JsonUnion(TypeClassifier = typeof(NativeUnionTypeClassifierFactory))]`. Keep the classifier and its two guarantees: a string token under `JsonSerializerDefaults.Web` resolves to the case whose natural shape matches (not a number read from a string), and two object-shaped cases resolve through the platform's structural classifier. `ZodJsonExceptions` maps a System.Text.Json path through the active case's contract, and a generated schema exports a union member (and a closed-hierarchy member) as `oneOf`; a closed hierarchy that opts into `[JsonPolymorphic(InferClosedTypePolymorphism = true)]` also constrains `$type`. + +No shipped type is declared `closed`. The modifier is net11-only (`IsClosedTypeAttribute`), so a shared multi-target declaration would behave differently on net10.0 and net11.0, and the only non-sealed hierarchy (`ZodType`) is the extension point custom schemas derive from. `closed` is supported where it belongs: a consumer's own closed hierarchy, which the generator validates by dispatch (`DispatchKind.ClosedHierarchy`, `ZODSGEN049` when a derived type has no `[ZodSchema]`). A generated schema reads the target's whole base chain, so a case's schema validates the inherited base members as well as its own. + +A union can itself be a `[ZodSchema]` target. Its descriptor carries `IsUnionTarget`/`UnionCases`/`UnionHasValue` instead of properties; validation emits an `if`/`else if` chain in `EmitUnionDispatchChain` (non-boxing `TryGetValue` per case when available), an empty or unmatched union reports `invalid_union`, and `TryResolveMember` delegates to the active case's schema so a Blazor field or API container is the case object. A case with no validator is omitted and reported as `ZODSGEN055`; the union fails closed rather than passing. Async propagates through `AsyncSchemaIndex`, which must consider `UnionCases` as well as properties. + ## Packing and package READMEs Each package ships its own `README.md`, placed in the project's `Sdk/` folder (for example `src/src/ZodSharp/Sdk/README.md`). The SDK's `PurviewAutoSdkPack` automatically maps `Sdk/*.md` to the package root and `Sdk/buildTransitive/**` to `buildTransitive/`, and the repo-root `README.md` is skipped when a package already packs its own README. Packages also ship `purview-logo-light.png` (linked via `src/Directory.Build.props`) and the core package ships `buildTransitive/Purview.ZodSharp.props`. -Keep `purview-build.json`'s `PackValidation` requirements in sync with any packaging change. +Keep `.config/purview-build.json`'s `PackValidation` requirements in sync with any packaging change. ## Documentation (`docs/wiki`) @@ -167,7 +195,7 @@ Commits must follow [Conventional Commits](https://www.conventionalcommits.org/) - Relevant tests pass. - `just lint-check` (CSharpier) reports no formatting changes. - Generated code is deterministic and reviewable; no scope leaks in `CodeWriter` output. -- Packed packages match `purview-build.json` `PackValidation` (including per-package `README.md` and `purview-logo-light.png`, and no `ForbiddenContent`: compiler-host assemblies outside `analyzers/`, generator or code-fix assemblies under `lib/`). After changing `PackValidation`, `just verify-pack-rules` proves every forbidden-content rule still rejects a package. +- Packed packages match `.config/purview-build.json` `PackValidation` (including per-package `README.md` and `purview-logo-light.png`, and no `ForbiddenContent`: compiler-host assemblies outside `analyzers/`, generator or code-fix assemblies under `lib/`). After changing `PackValidation`, `just verify-pack-rules` proves every forbidden-content rule still rejects a package. - No build or publish output carries a compiler-host assembly (`Purview.SourceGeneratorFramework*`, `Microsoft.CodeAnalysis*`, `Purview.ZodSharp.SourceGenerators*`, `Purview.ZodSharp.CodeFixes*`): every publish recipe and CI publish job runs `scripts/assert-no-compiler-host-assemblies.ps1`, and `ReleaseGateTests` fails a workflow job that publishes without it. - Changes that affect what ships (packaging, `buildTransitive/`, package dependencies, the generator, trimming or AOT) also pass the gates that consume the real `.nupkg`: `just smoke-packed-generator` and `just smoke-packed-consumers` (Native AOT console, Blazor WebAssembly trimmed and AOT, Native AOT minimal API), plus `just wasm-size`, `just verify-trim ` and `just publish-aot-sample`. - Before a release, run the pre-release checklist in `docs/wiki/development/Release-Flow.md` ("Release gates"): `just scrub`, `just pipeline-local-release ...`, then every gate above. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce85743..dbce09c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,19 +4,57 @@ 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). +[Release Flow](docs/wiki/development/Release-Flow.md). Released versions correspond to `v` GitHub releases. -## 3.0.0-prerelease.1 +## 3.0.0-prerelease.5 ### Added +- **`[ZodSchema]` on a union type.** A `union` declaration (or a hand-written `[Union]` type) can carry the + attribute: the generated `{Type}Schema.Validate` dispatches on the active case to that case type's schema, so error + paths are the case's own with no synthetic `Value` segment. A case with `TryGetValue` (the non-boxing access + pattern) is matched without boxing; a `union` declaration without it is matched through `Value`. An empty union, or + an active case with no validator, fails as `invalid_union`. **`ZODSGEN055`** (warning) reports a case type with no + `[ZodSchema]` in this compilation or a referenced assembly. Union members gain the same behaviour without an + attribute on the union, and `[Required]`, `[RequiredZod]` and `[NonSentinel]` treat an empty union as the member's + absent/default value. Async rules on a case propagate: `RequiresAsyncValidation` is true, `ValidateAsync` awaits it, + and ASP.NET Core, Blazor, Microsoft.Extensions.Validation and options validation all await it. + +- **Native union JSON round-trip.** `NativeUnion` now carries `[JsonUnion]`, and the new + `NativeUnionTypeClassifierFactory` resolves the payloads .NET 11's token classifier cannot: a string token + under `JsonSerializerDefaults.Web` (where a number may also be read from a string) and two object-shaped cases. + `System.Text.Json` writes the active case with no wrapper or discriminator and reads it back on both the + reflection and source-generated paths, so a minimal-API union parameter works without a custom converter. + A genuinely ambiguous payload still fails as `JsonException`. See + [Unions and Discriminated Unions](docs/wiki/schemas/Unions-and-Discriminated-Unions.md#json-serialization). +- **Union and closed-hierarchy members export their case schemas.** A generated `BuildJsonSchema` now emits + `oneOf` for a member typed as a C# 15 union or a closed class instead of the empty schema; a hierarchy that + opts into `[JsonPolymorphic(InferClosedTypePolymorphism = true)]` (or registers `[JsonDerivedType]`) also + constrains the discriminator (`$type` by default) on every branch. See + [JSON Schema Export](docs/wiki/serialization/JsonSchema-Export.md#export-a-generated-zodschema). +- **The full DataAnnotations validation surface.** Every `ValidationAttribute` the generator has no dedicated + rule for now runs: `[CustomValidation]` (property or class), `[FileExtensions]`, `[EnumDataType]`, and a + consumer's own attribute are constructed in generated fields and called through + `GetValidationResult`/`GetValidationResultAsync`. Class-level sync and async attributes run as object-level + rules and are collected from the base chain; a non-multiple attribute is taken from its most derived + declaration. Attributes already mapped to a Zod rule are never run twice. See + [Source Generator DataAnnotations](docs/wiki/generator/Source-Generator-DataAnnotations.md#attributes-without-a-dedicated-rule). +- **`[DisplayName]` as a display-name fallback.** When `[Display(Name=...)]` is absent, `[DisplayName]` supplies the + display name used in generated messages, matching .NET 11's Blazor `DisplayName` component. +- **`[SkipValidation]` throughout the .NET 11 validation stack.** A class marked `[SkipValidation]` is not claimed + by the resolver (even with a registered validator); a property is omitted from generated validation, + `Paths`/`JsonNames`, JSON Schema, client rules and field-level validation; a parameter or its type is skipped by + the ZodSharp endpoint filter and the Microsoft.Extensions.Validation resolver. The endpoint filter now matches + the real .NET 11 `SkipValidationAttribute` rather than any attribute with that name. See + [Microsoft.Extensions.Validation](docs/wiki/hosting/Microsoft-Extensions-Validation.md#skipvalidation). + - **Trimming and Native AOT across every package.** Every shipping package is `IsTrimmable`/`IsAotCompatible` on every target framework and analyzer-clean; APIs that cannot be trimmed are annotated with the alternative rather than suppressed. `src/tests/TrimSafety.Verification` publishes with `PublishAot=true` (every ZodSharp assembly except NewtonsoftJson rooted, then as a plain consumer), fails on any IL warning and runs the native binary - (`just verify-trim`, CI job `verify-trim`). See [Trimming and Native AOT](docs/wiki/Trimming-and-AOT.md). + (`just verify-trim`, CI job `verify-trim`). See [Trimming and Native AOT](docs/wiki/quality/Trimming-and-AOT.md). - **`[MinCount]`, `[MaxCount]` and `[CountRange]`** (`ZodSharp.Rules`, generated from the new `MinCountRule`, `MaxCountRule` and `CountRangeRule`): trim-safe collection/dictionary count attributes. The generator validates them exactly like `[MinLength]`/`[MaxLength]`/`[Length]` on a collection (same code, origin, bounds, `count` @@ -266,6 +304,20 @@ Released versions correspond to `v` GitHub releases. `lib/net10.0` and `lib/net11.0` only (the .NET 11 integrations stay `net11.0`-only); the source generator and analyzers stay on `netstandard2.0`. The `Microsoft.Extensions.*` dependency lines for 8.x and 9.x are gone, and the net8.0-only trim suppressions were replaced by the feature guards every remaining target understands. +- **`Union` / `Union` tags and empty values.** `Tag` now uses the native union's numbering + (`0` empty, `1`–`3` for the cases) instead of starting at `0` for `T1`, and `Value` returns `null` for an empty + union (a default value, or an implicit conversion from `null`) instead of throwing; `IUnion.Value` is `object?`. + `Match`/`Switch` still throw for an empty union. The hand-rolled and net11 native unions now share one contract. +- **An empty union member no longer validates successfully.** A union member whose value is empty (the default + value, or a `null`-converted union) now fails: `missing_field` for `[Required]`, the rule's own error for + `[RequiredZod]`/`[NonSentinel]`, and `invalid_type` otherwise. A union target's `Validate` reports `invalid_union` + for an empty union. Previously the member dispatch skipped the value silently. +- **A `[ZodSchema]` derived type now validates inherited members.** The generator reads the whole base chain, so + public properties inherited from a base class are validated, exported, given `Paths`/`JsonNames` entries and + resolved by `TryResolveMember`. A model that derives from a base with a non-nullable reference-type property + therefore reports that property as required; make the property nullable, or use a request DTO that does not + inherit the infrastructure base. An overridden or hidden property is taken once, from the most derived + declaration. - **Newly annotated APIs (not binary breaks; trimmed and AOT applications now get IL2026/IL3050 at the call site).** `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` were added to: every `Purview.ZodSharp.NewtonsoftJson` API that reaches Newtonsoft serialization (`NewtonsoftJsonExtensions.DeserializeAndValidate`, @@ -416,6 +468,22 @@ Released versions correspond to `v` GitHub releases. Nothing changes at runtime. See the upgrade note. ### Fixed +- **`AlsoRunDataAnnotations` field-level fallthrough.** A field change on a property ZodSharp does not own (a + DataAnnotations-only property) now finds the next resolver's property info instead of returning no member, so + the platform's field-level validation runs for it. +- **A member covered only by `[ZodAsyncMember]` is individually validatable.** `IsValidatableMember` listed only + members with synchronous rules, so a Blazor field change on a member whose sole rule is the custom asynchronous + method fell back to validating the whole form (and re-ran the method for unrelated fields). It now lists the + `[ZodAsyncMember]` names too, so `ValidateMemberAsync` runs the method for that field alone. +- **Inherited members are validated.** The generator read only the properties declared on the `[ZodSchema]` type, + so a derived type never validated a public property inherited from its base class, and its JSON Schema never + described one. It now reads the whole base chain, taking an overridden or hidden property once from the most + derived declaration. A closed-hierarchy case therefore validates its base members too, and `Paths`, `JsonNames`, + `TryResolveMember`, JSON Schema export and the analyzer diagnostics follow the same member list. +- **System.Text.Json error paths map through a union's active case.** `ZodJsonExceptions.ToMemberPath` treated a + union contract as a scalar, so every segment below a union member kept its JSON name (`Pet.name` instead of + `Pet.Name`). It now resolves a member, index or key through the case contracts, including a collection case, + and keeps the JSON name only when several cases name the segment differently. - **Generated validators compile for member types with their own equality operators.** The generator tested for null with `value.Member == null` (and `!= null`), which binds a user-defined operator when the member type declares one. A `[Required]` `Nullable` member whose type defines `==(string?, T)` and an implicit conversion to `string` @@ -571,6 +639,12 @@ Each note names the old form, the new form, and the edit to make. - **Target frameworks.** `net8.0`/`net9.0` → `net10.0`/`net11.0` only. Retarget the consuming project to `net10.0` or later, or stay on 2.4.x. +- **Union tags.** Code that read `Union<...>.Tag` must add one (`0`→`1`, `1`→`2`, and `2`→`3` on the three-case type) + and treat `0` as empty; code that relied on `Value` throwing for an uninitialized union should test `Tag == 0` or use + `TryGetValue` instead. +- **Empty union members.** An empty union is now the member's absent value. If a member may be absent, declare it + nullable (`Pet?`); a non-nullable union member must always hold a case. A union target (`[ZodSchema]` on the union) + reports `invalid_union` for an empty union. - **Trim/AOT annotations.** The APIs listed under *Breaking behaviour changes* now raise IL2026/IL3050 in a trimmed or AOT build. Move to the named alternative: `Purview.ZodSharp.SystemTextJson` with a `JsonTypeInfo` instead of Newtonsoft; the generated `AddZodSharp{Assembly}Schemas()` or `AddZodSharpAssembly` instead of @@ -703,8 +777,8 @@ Each note names the old form, the new form, and the edit to make. - **Record JSON Schema export and import.** `Z.ToJsonSchema(Z.Record(valueSchema))` now exports the same object/`additionalProperties` shape, and `Z.FromJsonSchema` imports an object with `additionalProperties` as a record in both JSON integration packages (`additionalProperties: true` treats every member as `any`). - See [JSON Schema Export](docs/wiki/JsonSchema-Export.md#dictionaries) and - [JSON Schema Import](docs/wiki/JsonSchema-Import.md#records). + See [JSON Schema Export](docs/wiki/serialization/JsonSchema-Export.md#dictionaries) and + [JSON Schema Import](docs/wiki/serialization/JsonSchema-Import.md#records). - **`IEnumerable` properties validate their elements.** A property declared as the enumerable interface itself now resolves its element type, validates elements and exports its `items` schema, like `T[]`. @@ -742,7 +816,7 @@ Each note names the old form, the new form, and the edit to make. correlates the local time with the offset and rejects every RFC 3339 representation of both instant bounds. `Z.Date().NonSentinel()` also exports `type: "string"`, `format: "date-time"` (previously it fell back to the empty schema). See - [JSON Schema Export](docs/wiki/JsonSchema-Export.md#temporal-sentinels-and-the-datetimeoffset-wire-form). + [JSON Schema Export](docs/wiki/serialization/JsonSchema-Export.md#temporal-sentinels-and-the-datetimeoffset-wire-form). - **Optional exact UTC `DateTimeOffset` contract.** `DateTimeOffsetSerialization.Utc` narrows `[NonSentinel] DateTimeOffset` to a zero-offset wire form. Configure it per project with `Utc` (for example in @@ -863,7 +937,7 @@ release that introduced it, so `AnalyzerReleases.Shipped.md` shows `ZODSGEN037` - A new `enum` rule, applied automatically on generated schemas, plus additional explicit rules. - `IZodRule` organisation with a supporting analyzer, and `RuleMessage` is now public. - Eight new diagnostics: `ZODSGEN037`–`ZODSGEN044`, recorded in `AnalyzerReleases.Shipped.md` under - `## Release 2.0.2`. See [Source Generator Diagnostics](docs/wiki/Source-Generator-Diagnostics.md). + `## Release 2.0.2`. See [Source Generator Diagnostics](docs/wiki/generator/Source-Generator-Diagnostics.md). - **Automatic scalar value-object support.** `[ZodSchema]` now works with the automatic scalar forms `[Scalar]` and `[Scalar(typeof(TValue))]` (Purview.ValueObjects 1.0.1+), where the value-object generator declares the underlying property. ZodSharp resolves the underlying type and property name from diff --git a/Directory.Packages.props b/Directory.Packages.props index f84dd403..654eade3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,29 +2,28 @@ 5.9.0 5.9.0 - 1.76.0 - 1.2.2 - 10.0.14 + 1.83.0 + 2.0.0 + 10.0.15 $(DotnetRuntimeVersion) - 11.0.0-rc.3.26522.148 + 11.0.0 $(DotnetRuntimeVersion) - 11.0.0-rc.3.26522.148 + 11.0.0 @@ -32,27 +31,27 @@ `just test` covers both sides of the parity guarantee rather than only the C# side. --> - + - + - - + + - - + + - + - - - + + + - + diff --git a/Justfile b/Justfile index 32217dee..82be2a02 100644 --- a/Justfile +++ b/Justfile @@ -12,11 +12,7 @@ build_configuration := "Debug" artifacts_folder := "./artifacts" default_test_filter := "/*/*/*/*" -# CSharpier is vendored in .assets/ rather than taken from the tool manifest: the released 1.3.0 -# cannot parse C# 15 `union` declarations and fails with CS1001, which would fail linting on the -# four files that declare one. Build:CSharpierPath points the pipeline at the same binary. -csharpier := "dotnet .assets/csharpier/CSharpier.dll" - +csharpier := "dotnet csharpier" pipeline_tool := "dotnet purview-build" [private] diff --git a/README.md b/README.md index 70a2d796..8140c026 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,15 @@ The library is developed and maintained at [github.com/purview-dev/zodsharp](htt - **Type-safe** - Strong typing with advanced C# generics - **High performance** - Sub-microsecond validation times; see [the measurements](#performance) - **Cross-platform** - Works on .NET 10.0 and .NET 11.0 +- **Native C# unions (.NET 11)** - `Z.NativeUnion` returns a non-boxing union with exhaustive pattern matching; `Z.Union` and the hand-rolled `Union<,>` remain available on both targets +- **JSON Schema interoperability** - Export with `Z.ToJsonSchema` and import with `Z.FromJsonSchema` - **Source Generators** - Compile-time validator generation with `[ZodSchema]` attribute - **DataAnnotations Support** - Automatic validation from `[Required]`, `[StringLength]`, `[Length]`, `[MinLength]`, `[MaxLength]`, `[Range]`, `[RegularExpression]`, `[AllowedValues]`, `[DeniedValues]`, `[EmailAddress]`, etc. +- **Host integrations** - ASP.NET Core ProblemDetails, OpenAPI transformers, minimal-API and Blazor validation, `HttpClient` bodies, System.Text.Json and Newtonsoft.Json ## Installation -Install the core `Purview.ZodSharp` package, plus the optional JSON integration packages you need: +Install the core `Purview.ZodSharp` package, plus the optional JSON, HTTP, hosting and .NET 11 integration packages you need. The .NET 11 packages target `net11.0` only. ### NuGet Package Manager @@ -28,7 +31,11 @@ Install the core `Purview.ZodSharp` package, plus the optional JSON integration Install-Package Purview.ZodSharp Install-Package Purview.ZodSharp.SystemTextJson Install-Package Purview.ZodSharp.NewtonsoftJson +Install-Package Purview.ZodSharp.Http Install-Package Purview.ZodSharp.AspNetCore +Install-Package Purview.ZodSharp.Extensions.Validation +Install-Package Purview.ZodSharp.AspNetCore.OpenApi +Install-Package Purview.ZodSharp.Blazor ``` ### .NET CLI @@ -37,23 +44,33 @@ Install-Package Purview.ZodSharp.AspNetCore dotnet add package Purview.ZodSharp dotnet add package Purview.ZodSharp.SystemTextJson dotnet add package Purview.ZodSharp.NewtonsoftJson +dotnet add package Purview.ZodSharp.Http dotnet add package Purview.ZodSharp.AspNetCore +dotnet add package Purview.ZodSharp.Extensions.Validation +dotnet add package Purview.ZodSharp.AspNetCore.OpenApi +dotnet add package Purview.ZodSharp.Blazor ``` ### PackageReference ```xml - - - - + + + + + + + + ``` -- **Purview.ZodSharp** — Core validation library and source generator (`[ZodSchema]`). See its [package README](src/src/ZodSharp/Sdk/README.md). +- **Purview.ZodSharp** — Core validation library, source generator (`[ZodSchema]`) and JSON Schema export. See its [package README](src/src/ZodSharp/Sdk/README.md). - **Purview.ZodSharp.SystemTextJson** — System.Text.Json integration and JSON Schema import. See its [package README](src/src/SystemTextJson/Sdk/README.md). - **Purview.ZodSharp.NewtonsoftJson** — Newtonsoft.Json integration and JSON Schema import. See its [package README](src/src/NewtonsoftJson/Sdk/README.md). - **Purview.ZodSharp.Http** — `HttpClient` integration: validated request and response bodies, and server validation problems read back into ZodSharp errors; trim-, AOT- and WebAssembly-safe. See its [package README](src/src/Http/Sdk/README.md). - **Purview.ZodSharp.AspNetCore** — ASP.NET Core ProblemDetails integration. See its [package README](src/src/AspNetCore/Sdk/README.md). +- **Purview.ZodSharp.Extensions.Validation** — the `Microsoft.Extensions.Validation` bridge for minimal APIs and Blazor (`net11.0`, WebAssembly-safe). See its [package README](src/src/Extensions.Validation/Sdk/README.md). +- **Purview.ZodSharp.AspNetCore.OpenApi** — OpenAPI transformers for ZodSharp's constraints and error contract (`net11.0`). See its [package README](src/src/AspNetCore.OpenApi/Sdk/README.md). - **Purview.ZodSharp.Blazor** — Blazor forms validation for Server, WebAssembly and Auto render mode (`net11.0`). See its [package README](src/src/Blazor/Sdk/README.md). ## TypeScript and fixture tooling @@ -68,10 +85,22 @@ bun run generate-fixtures The fixture generator script is intentionally run via Bun rather than `npx tsx` or Node because it keeps the TypeScript workflow consistent with the repo's Bun-based setup. -## What's new in v2 +## What's new -- **Packages are published under the `Purview.*` IDs.** The core `Purview.ZodSharp` package ships the validator and the source generator, with optional `Purview.ZodSharp.SystemTextJson`, `Purview.ZodSharp.NewtonsoftJson`, and `Purview.ZodSharp.AspNetCore` integration packages. -- **Targets `net10.0` and `net11.0`** (3.0.0 dropped `net8.0` and `net9.0`; stay on 2.4.x for those). The source generator remains on `netstandard2.0` so it can run in any compiler host. +### v3 + +- **`net8.0` and `net9.0` are dropped.** Packages target `net10.0` and `net11.0`; applications on the older targets stay on the 2.4.x line. The source generator remains on `netstandard2.0` so it can run in any compiler host. +- **Native C# 15 unions (.NET 11).** `Z.NativeUnion` returns a non-boxing union (`ZodSharp.Unions.NativeUnion`) whose cases are stored in typed fields, so reference- and value-type cases are both allocation-free and pattern matching is exhaustive. `ZODSGEN041` reports (and fixes) a `Z.Union` that could use it. A union or a `closed` hierarchy can itself carry `[ZodSchema]`. +- **.NET 11 host integrations.** `Purview.ZodSharp.Extensions.Validation` registers ZodSharp as the first `Microsoft.Extensions.Validation` resolver (minimal APIs and Blazor), `Purview.ZodSharp.AspNetCore.OpenApi` describes what ZodSharp enforces in the OpenAPI document, and `Purview.ZodSharp.Blazor` adds the full-fidelity form validator. See [.NET 11 validation](#net-11-validation). +- **`Purview.ZodSharp.Http`.** Validated `HttpClient` request and response bodies, and a server validation problem read back as ZodSharp errors, WebAssembly-safe. +- **Async and service-aware rules.** `AsyncValidationAttribute`, `IAsyncValidatableObject`, `[ZodAsyncMember]` and `ValidateAsync`; generated validators use `ValidateAsync` everywhere an async rule exists, and the options validator implements `IAsyncValidateOptions`. +- **`[SkipValidation]` is honoured everywhere.** The generator (type, property and parameter), the endpoint filter, the Microsoft.Extensions.Validation bridge and client validation all skip the annotated type or member. +- **Dictionaries are maps.** An `IDictionary`/`IReadOnlyDictionary` member validates every value and exports as `additionalProperties`. +- **`DateTimeOffset` wire form.** Exported as an offset-aware pattern by default, with an optional narrower UTC contract. + +### v2 + +- **Packages are published under the `Purview.*` IDs.** The core `Purview.ZodSharp` package ships the validator and the source generator, with optional `Purview.ZodSharp.SystemTextJson`, `Purview.ZodSharp.NewtonsoftJson`, `Purview.ZodSharp.Http` and `Purview.ZodSharp.AspNetCore` integration packages. - **System.Text.Json integration.** JSON deserialize-and-validate is available for both major JSON libraries, including validating `JsonConverter` instances. - **JSON Schema interoperability.** Schemas can be exported via `Z.ToJsonSchema` and imported via `Z.FromJsonSchema`, enabling cross-language reuse with TypeScript/Zod. The import API lives in the JSON integration package's namespace (`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`); export stays in the core package. - **ASP.NET Core ProblemDetails integration.** Failed validation results convert directly to `HttpValidationProblemDetails` via `result.ToHttpValidationProblemDetails(httpContext)`, which applies the application's `ZodProblemDetailsOptions`. @@ -330,17 +359,27 @@ See the [performance README](src/src/Benchmarks/README.md) for detailed benchmar ``` src/ ├── src/ -│ ├── ZodSharp/ # Core validation library (Core, Schemas, Rules, JsonSchema) -│ ├── SourceGenerators/ # Compile-time [ZodSchema] generator -│ ├── SystemTextJson/ # System.Text.Json integration and JSON Schema import -│ ├── NewtonsoftJson/ # Newtonsoft.Json integration and JSON Schema import -│ ├── AspNetCore/ # ASP.NET Core ProblemDetails integration -│ ├── Examples.CLI/ # Usage examples -│ └── Benchmarks/ # BenchmarkDotNet performance suite -└── tests/ # TUnit test projects -``` - -The source generator itself targets `netstandard2.0` so it can run in any compiler. The library packages target `net10.0` and `net11.0`. +│ ├── ZodSharp/ # Core validation library (Core, Schemas, Rules, JsonSchema, Unions) +│ ├── SourceGenerators/ # [ZodSchema] generator, analyzers and [ErrorType] generator (netstandard2.0) +│ ├── CodeFixes/ # Code fixes for the ZODSGEN diagnostics +│ ├── SystemTextJson/ # System.Text.Json integration and JSON Schema import +│ ├── NewtonsoftJson/ # Newtonsoft.Json integration and JSON Schema import +│ ├── Http/ # HttpClient integration (browser-safe) +│ ├── AspNetCore/ # ASP.NET Core ProblemDetails integration +│ ├── Extensions.Validation/ # Microsoft.Extensions.Validation bridge (net11.0) +│ ├── AspNetCore.OpenApi/ # OpenAPI transformers (net11.0) +│ ├── Blazor/ # Blazor forms integration (net11.0) +│ ├── Examples.CLI/ # Usage examples +│ ├── Examples.MinimalApi/ # Minimal-API sample host +│ ├── Examples.Blazor*/ # Blazor sample host and client +│ ├── Examples.Shared/ # Models shared by the samples +│ └── Benchmarks/ # BenchmarkDotNet performance suite +└── tests/ + ├── *.UnitTests/ # TUnit test projects + └── cross-platform/ # Gitignored output shared with the vitest suites +``` + +The source generator targets `netstandard2.0` so it can run in any compiler host. The library packages target `net10.0` and `net11.0`; the packages marked `net11.0` above target `net11.0` only. ## Advanced Features diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index c16c8753..3f1f7d3e 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -18,14 +18,26 @@ dotnet add package Purview.ZodSharp.SystemTextJson # Newtonsoft.Json integration + JSON Schema import dotnet add package Purview.ZodSharp.NewtonsoftJson +# HttpClient integration: validated request and response bodies (WebAssembly-safe) +dotnet add package Purview.ZodSharp.Http + # ASP.NET Core ProblemDetails integration dotnet add package Purview.ZodSharp.AspNetCore + +# .NET 11 only: the platform validation bridge, OpenAPI transformers and Blazor forms +dotnet add package Purview.ZodSharp.Extensions.Validation +dotnet add package Purview.ZodSharp.AspNetCore.OpenApi +dotnet add package Purview.ZodSharp.Blazor ``` - `Purview.ZodSharp` — core library, source generator, and JSON Schema **export**. - `Purview.ZodSharp.SystemTextJson` — System.Text.Json deserialize-and-validate, validating converters, and JSON Schema **import**. - `Purview.ZodSharp.NewtonsoftJson` — Newtonsoft.Json deserialize-and-validate, validating converters, and JSON Schema **import**. +- `Purview.ZodSharp.Http` — `HttpClient` integration: validates request and response bodies, and reads a server's validation problem back as ZodSharp errors; trim-, AOT- and WebAssembly-safe. - `Purview.ZodSharp.AspNetCore` — failed validation results converted to standard `ProblemDetails` / `HttpValidationProblemDetails` payloads. +- `Purview.ZodSharp.Extensions.Validation` (`net11.0`) — registers ZodSharp as the first `Microsoft.Extensions.Validation` resolver, so minimal APIs, Blazor Server and Blazor WebAssembly validate with ZodSharp. +- `Purview.ZodSharp.AspNetCore.OpenApi` (`net11.0`) — OpenAPI transformers that describe the constraints, formats, unions and validation error contract ZodSharp enforces. +- `Purview.ZodSharp.Blazor` (`net11.0`) — `ZodValidator`, message components and static-SSR client rules for Blazor Server, WebAssembly and Auto render mode. > [!TIP] > JSON Schema import (`Z.FromJsonSchema`) is provided by whichever JSON integration package you reference, so pick one, and import its JSON Schema namespace (`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`). Export (`Z.ToJsonSchema`) lives in the core package. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index c09e65ad..f4b3fe15 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -106,7 +106,7 @@ Read these first; they are the vocabulary the reference pages assume. `Getting-S - **Zero-allocation validation** — validation rules are `readonly record struct`s and hot paths use `Span`; every valid input path validates without allocating. - **Fluent API** — `Z.String().Min(3).Max(50).Email()`, composable objects, arrays, unions, tuples, records, discriminators, and more. -- **Native C# 15 unions (.NET 11+)** — `Z.NativeUnion` returns an allocation-free native union for reference-type cases, with exhaustive pattern matching; `Z.Union` stays the zero-allocation choice for value-type cases. The analyzer reports `ZODSGEN041` and offers a code fix. +- **Native C# 15 unions (.NET 11+)** — `Z.NativeUnion` returns a native union that implements the language's non-boxing access pattern: reference-type and value-type cases are both allocation-free, and pattern matching is exhaustive. The analyzer reports `ZODSGEN041` and offers a code fix. - **Structured issues** — failures carry a machine-readable `Code`, a Zod-compatible `Category`, a typed `FullPath` (rendered `Items[0].Name`), `Origin`, typed `Lower`/`Upper` bounds, `Parameters` and the `MessageFormat` template, in addition to a human message. See [Core Concepts](concepts/Core-Concepts.md#error-fields) and [Localization](hosting/Localization.md). - **Test-friendly rule identity** — every rule exposes its code and message template as public `ErrorCode`/`MessageFormat` constants (the analyzer reports `ZODSGEN042` when one is missing), so tests assert against the rule instead of duplicating literals. `NonSentinelRule` rejects the ORM sentinel values (`Guid.Empty`, `DateTime.MinValue`/`MaxValue`, and more). - **JSON Schema interoperability** — export via `Z.ToJsonSchema` (core package) and import via `Z.FromJsonSchema` (in either JSON integration package), enabling cross-language reuse with TypeScript/Zod. diff --git a/docs/wiki/development/Contributing.md b/docs/wiki/development/Contributing.md index 74220fb6..b362946d 100644 --- a/docs/wiki/development/Contributing.md +++ b/docs/wiki/development/Contributing.md @@ -6,19 +6,31 @@ Contributions are welcome. Please open an issue or pull request against [purview ``` src/ + ZodSharp.slnx src/ - ZodSharp/ Core validation library + JSON Schema export (Z.ToJsonSchema) - SourceGenerators/ Compile-time [ZodSchema] generator (netstandard2.0, Roslyn) - SystemTextJson/ System.Text.Json integration + JSON Schema import - NewtonsoftJson/ Newtonsoft.Json integration + JSON Schema import - AspNetCore/ ASP.NET Core ProblemDetails integration - Examples.CLI/ Usage examples - Benchmarks/ BenchmarkDotNet performance suite + ZodSharp/ Core validation library + JSON Schema export (Z.ToJsonSchema) + SourceGenerators/ [ZodSchema] generator and analyzers (netstandard2.0, Roslyn) + CodeFixes/ Code fixes shipped beside the generator + SystemTextJson/ System.Text.Json integration + JSON Schema import + NewtonsoftJson/ Newtonsoft.Json integration + JSON Schema import + Http/ HttpClient integration (browser-safe) + AspNetCore/ ASP.NET Core ProblemDetails integration + Extensions.Validation/ Microsoft.Extensions.Validation bridge (net11.0) + AspNetCore.OpenApi/ OpenAPI transformers (net11.0) + Blazor/ Blazor forms integration (net11.0) + Examples.CLI/ Usage examples + Examples.MinimalApi/ Minimal-API sample host + Examples.Blazor/ Blazor sample host (+ Examples.Blazor.Client) + Examples.Shared/ Models shared by the samples + Benchmarks/ BenchmarkDotNet performance suite tests/ - *.UnitTests/ TUnit test projects -src/ts/ TypeScript (Zod) schema + fixture generation -tests/ts/ Vitest cross-platform tests -docs/wiki/ This documentation suite + *.UnitTests/ TUnit test projects + cross-platform/ Gitignored output shared with the vitest suites +src/ts/ TypeScript (Zod) schema + fixture generation +tests/ts/ Vitest cross-platform tests +skills/ Agent skills this repository publishes +.agents/skills/ Skills installed from other repositories (committed, with skills-lock.json) +docs/wiki/ This documentation suite ``` ## Commands @@ -29,7 +41,7 @@ just test # dotnet test src/ZodSharp.slnx -c Debug --treenode-filter just lint-check # dotnet csharpier check . just lint-fix # dotnet csharpier format . just pack # dotnet pack src/ZodSharp.slnx -c Debug -o artifacts -just perf-tests # dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release +just perf-tests # dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -f net10.0 ``` TypeScript tooling uses Bun: diff --git a/docs/wiki/development/Release-Flow.md b/docs/wiki/development/Release-Flow.md index 38ef76d4..35e021de 100644 --- a/docs/wiki/development/Release-Flow.md +++ b/docs/wiki/development/Release-Flow.md @@ -6,7 +6,7 @@ Purview.ZodSharp releases are driven by the shared [purview-dev/build](https://g The package version comes from `package.json` (`version` field). The current stable line is `2.4.2`, and `3.0.0` (which drops `net8.0` and `net9.0`) is in prerelease; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix). -Package identities are `Purview.ZodSharp.*` (core, SystemTextJson, NewtonsoftJson, AspNetCore). Central package management lives in `Directory.Packages.props`; package versions there are minimum requirements, not exact pins, so the resolved graph can drift. +Package identities are `Purview.ZodSharp.*` (core; SystemTextJson, NewtonsoftJson, Http and AspNetCore; and on `net11.0` Extensions.Validation, AspNetCore.OpenApi and Blazor). Central package management lives in `Directory.Packages.props`; package versions there are minimum requirements, not exact pins, so the resolved graph can drift. ## Releasing a version @@ -28,7 +28,7 @@ rather than passing. | Gate | What it proves | Local | CI | |---|---|---|---| | Build, lint and tests | The solution builds warning-free, CSharpier reports no change, and every test project passes (including the cross-platform vitest suites, through `Repository.UnitTests`). | `just build`, `just lint-check`, `just test` | `pr.yml` `build` | -| Pack and pack validation | Every package packs; package validation holds the public API to the 2.4.2 baseline; `purview-build.json` `PackValidation` finds exactly the declared content (`RequiredContent`, `RequireExplicitContent`) and none of the `ForbiddenContent`. | `just pipeline-pack-validate` | `pr.yml` `build` (`run-pack`, `validate-pack`) | +| Pack and pack validation | Every package packs; package validation holds the public API to the 2.4.2 baseline; `.config/purview-build.json` `PackValidation` finds exactly the declared content (`RequiredContent`, `RequireExplicitContent`) and none of the `ForbiddenContent`. | `just pipeline-pack-validate` | `pr.yml` `build` (`run-pack`, `validate-pack`) | | Forbidden-content rule proof | Every `ForbiddenContent` rule rejects a package: a matching file is injected into scratch copies of each package and the real `purview-build` validation must fail, per package and per rule. | `just verify-pack-rules` | `pr.yml` `packed-consumers` | | Packed generator | The IL-merged generator in the `.nupkg` loads and generates in a net10.0 consumer (it has regressed twice while the in-repo tests, which use the unmerged generator, stayed green). | `just smoke-packed-generator` | `pr.yml` `packed-generator` | | Packed consumers | Every shipping package, packed to an isolated feed and cache, works for consumers that reference packages only: a Native AOT console (generated validation, member resolution, count attributes, JSON Schema export, the validation bridge, ProblemDetails, OpenAPI options), a Blazor WebAssembly app with `` and a scalar value object carrying a type-level rule (trimmed, and `RunAOTCompilation` when `wasm-tools` is installed; `_framework` holds the ZodSharp assemblies, `_content` the static web assets) and a Native AOT minimal API (an invalid POST returns 400 `application/problem+json` with `issues`; the OpenAPI document carries `ZodValidationProblemDetails`), and a multi-project API - a `[ZodSchema]`/`[ErrorType]` class library referenced by a minimal API that references `Microsoft.AspNetCore.OpenApi` with XML documentation on (the OpenAPI document is served with both projects' schemas and XML-comment descriptions, and no documentation ID is duplicated). Every bin and publish folder is leak-scanned. | `just smoke-packed-consumers` | `pr.yml` `packed-consumers` (linux-x64, `-WasmAot Require`) | @@ -77,10 +77,10 @@ just publish-aot-sample # Native AOT minimal API sample an | `pr.yml` | pull requests against `main` | `purview-build.yml` with `run-pack: true`, `validate-pack: true` | | `release.yml` | pushes to `main` | `purview-release.yml` with `release-mode: NuGet` | -Both consume `purview-build.json`: +Both consume `.config/purview-build.json`: - `Build` — solution (`src/ZodSharp.slnx`), test root (`src/tests`), patterns (`*Tests.csproj`), filter (`/*/*/*/*`). -- `PackValidation` — requires symbol packages/files and validates `RequiredContent` per package (per-TFM DLL + XML, analyzer assemblies and `buildTransitive/Purview.ZodSharp.props` for the core package, `README.md` and `purview-logo-light.png` for every package). `ForbiddenContent` rejects, in every package, a compiler-host assembly (`Purview.SourceGeneratorFramework*`, `Microsoft.CodeAnalysis*`) anywhere NuGet hands files to an application (the root, `lib/`, `ref/`, `runtimes/`, `build*/`, `tools/`, `content*/`, `staticwebassets/`), a loose copy of either under `analyzers/`, and the generator or code fixes under `lib/`, `ref/` or `runtimes/`. +- `PackValidation` — requires symbol packages/files and validates `RequiredContent` per package (per-TFM DLL + XML, analyzer assemblies and `buildTransitive/Purview.ZodSharp.props` and `.targets` for the core package, the Blazor static web assets and JS module initializer plus its `build`/`buildTransitive` targets, the OpenAPI `buildTransitive` targets, `README.md` and `purview-logo-light.png` for every package). `ForbiddenContent` rejects, in every package, a compiler-host assembly (`Purview.SourceGeneratorFramework*`, `Microsoft.CodeAnalysis*`) anywhere NuGet hands files to an application (the root, `lib/`, `ref/`, `runtimes/`, `build*/`, `tools/`, `content*/`, `staticwebassets/`), a loose copy of either under `analyzers/`, and the generator or code fixes under `lib/`, `ref/` or `runtimes/`. - `Release.Mode` — `None` for PR/local runs. ## Pipeline commands @@ -104,7 +104,7 @@ Commits must follow [Conventional Commits](https://www.conventionalcommits.org/) - `just build` succeeds with no new warnings/errors. - Relevant tests pass (`just test`). - `just lint-check` (CSharpier) reports no formatting changes. -- Packed packages match `purview-build.json` `PackValidation`. +- Packed packages match `.config/purview-build.json` `PackValidation`. - Generated code is deterministic and reviewable. - Before a release, every gate in [Release gates](#release-gates) passes, through the - [pre-release checklist](#pre-release-checklist). \ No newline at end of file + [pre-release checklist](#pre-release-checklist). diff --git a/docs/wiki/generator/Source-Generator-DataAnnotations.md b/docs/wiki/generator/Source-Generator-DataAnnotations.md index 94ccfbd0..3acb3003 100644 --- a/docs/wiki/generator/Source-Generator-DataAnnotations.md +++ b/docs/wiki/generator/Source-Generator-DataAnnotations.md @@ -22,9 +22,29 @@ The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attrib | `[Base64String]` | reuses `Base64StringRule` | `invalid_string` | | `[Compare(otherProperty)]` | typed equality between two properties | `mismatch` | | `[Display(Name=...)]` | not validated; `Name` used as the display name in messages and `{0}` placeholders. With `ResourceType`, the localized name is read from the resource property at runtime | — | +| `[DisplayName(...)]` | fallback display name when `[Display]` is absent, matching .NET 11's Blazor `DisplayName` component | — | `[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also present. +## Attributes without a dedicated rule + +Every other `ValidationAttribute` still runs. A `[CustomValidation]` (on a property or the class), +`[FileExtensions]`, `[EnumDataType]`, or a consumer's own `ValidationAttribute` is constructed once in a generated +field and called through its own `GetValidationResult` (synchronous) or `GetValidationResultAsync` (the .NET 11 +`AsyncValidationAttribute`). The attributes in the table above are already emitted as Zod rules and are never run +twice. + +Class-level attributes run as object-level rules, in the platform's order: members, type rules, class-level +attributes, then `IValidatableObject`/`IAsyncValidatableObject` when the earlier rules passed. Because +`ValidationAttribute` is inherited, class-level attributes are collected from the base chain as well; a +non-multiple attribute is taken from its most derived declaration, while a multiple one (for example +`[CustomValidation]`) keeps every declaration. + +`[SkipValidation]` (`Microsoft.Extensions.Validation`, .NET 11; it targets classes, properties and parameters) +removes a property from generated validation, `Paths`/`JsonNames` and the exported JSON Schema, and makes a class +not claimed by the Microsoft.Extensions.Validation resolver. On a minimal-API parameter it is honored by both the +ZodSharp endpoint filter and the Microsoft.Extensions.Validation resolver. + Every generated error reports a `Category` (the Zod issue code: `invalid_type`, `too_small`, `too_big`, `invalid_format`, `invalid_value`, `custom`), an `Origin` (`string`, `number`, `array`, `enum`, …), and `Parameters` with `displayName`, `member` and `declaringType`. `[Required]` reports category `invalid_type` diff --git a/docs/wiki/generator/Source-Generator-Diagnostics.md b/docs/wiki/generator/Source-Generator-Diagnostics.md index 7eb55cb1..a651f402 100644 --- a/docs/wiki/generator/Source-Generator-Diagnostics.md +++ b/docs/wiki/generator/Source-Generator-Diagnostics.md @@ -1,6 +1,6 @@ # Source Generator Diagnostics -The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. Every diagnostic below is enabled by default; `ZODSGEN033`, `ZODSGEN037`–`ZODSGEN040`, `ZODSGEN042`, `ZODSGEN045`, `ZODSGEN046`, `ZODSGEN048`, `ZODSGEN049`, `ZODSGEN051` and `ZODSGEN052` are warnings, `ZODSGEN041`, `ZODSGEN043` and `ZODSGEN047` are informational, and the rest are errors. +The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. Every diagnostic below is enabled by default; `ZODSGEN033`, `ZODSGEN037`–`ZODSGEN040`, `ZODSGEN042`, `ZODSGEN045`, `ZODSGEN046`, `ZODSGEN048`, `ZODSGEN049`, `ZODSGEN051`, `ZODSGEN052`, `ZODSGEN053` and `ZODSGEN055` are warnings, `ZODSGEN041`, `ZODSGEN043` and `ZODSGEN047` are informational, and the rest are errors. | ID | Meaning | |---|---| @@ -15,6 +15,7 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato | ZODSGEN010 | First custom method parameter is not the model type | | ZODSGEN011 | Last custom method parameter is not `CancellationToken` | | ZODSGEN054 | A three-parameter (service-aware) custom method's second parameter is not `IServiceProvider` | +| ZODSGEN055 | (warning) A union (`union` declaration or `[Union]` type) has a case type with no `[ZodSchema]` validator in this compilation or a referenced assembly. The case is omitted from dispatch and values of that case fail as `invalid_union`; add a validator to the case type | | ZODSGEN012 | Custom method is generic | | ZODSGEN013 | Custom method must be static when defined on the model type | | ZODSGEN014 | Custom method is inaccessible from the generated validator (private/protected) | @@ -39,7 +40,7 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato | ZODSGEN038 | (warning) A hand-authored rule attribute's `[ZodRule(typeof(...))]` mapping does not address every rule declared under the name the attribute encodes (`XAttribute` → `XRule`), so some usages of the attribute resolve to no rule | | ZODSGEN039 | (warning) A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the value never reaches the reported error identity | | ZODSGEN040 | (warning) An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity | -| ZODSGEN041 | (info) A typed union (`Z.Union`) whose option types are all reference types can use the allocation-free native C# 15 union returned by `Z.NativeUnion` on .NET 11+; a code fix is offered | +| ZODSGEN041 | (info) A typed union (`Z.Union`) can use the native C# 15 union returned by `Z.NativeUnion` on .NET 11+; it implements the non-boxing access pattern (reference- and value-type cases are both allocation-free) and supports exhaustive pattern matching. A code fix is offered | | ZODSGEN042 | (warning) A validation rule (a type implementing `IValidationRule`) does not expose a public `const string ErrorCode` and a public `const string MessageFormat`, so its error identity cannot be asserted in tests without duplicating literals | | ZODSGEN043 | (info) A built-in rule marked `[ZodRule]` does not generate a validation attribute, because a constructor parameter cannot be represented as an attribute property; the rule is still reachable through the runtime/fluent API. A derived name that collides with `System.ComponentModel.DataAnnotations` is *not* skipped — the attribute is emitted under a `Zod` suffix instead | | ZODSGEN044 | A `[ZodSchema]` type that uses an automatic `[Scalar]` form (`[Scalar]` or `[Scalar(typeof(TValue))]`) cannot produce a usable schema, so the value-object generator's generated `Create` would reference a `{Type}Schema` that does not exist or cannot validate. Reported when `[ZodSchema(GenerateValidateMethod = false)]` suppresses the `Validate` method the value-object generator calls, or when the declared underlying type cannot be represented | diff --git a/docs/wiki/generator/Source-Generator.md b/docs/wiki/generator/Source-Generator.md index acf8db27..183dc652 100644 --- a/docs/wiki/generator/Source-Generator.md +++ b/docs/wiki/generator/Source-Generator.md @@ -63,7 +63,7 @@ Besides `Validate`, `Parse` and `BuildJsonSchema`, every `{TypeName}Schema` expo | `RequiresAsyncValidation` | A `const bool`: `true` when some rule in the graph only runs asynchronously (a custom async method, an `AsyncValidationAttribute`, an `IAsyncValidatableObject` target, or a nested schema that has one). | | `ValidateAsync(value, services, cancellationToken)` | Validates the whole graph asynchronously. Nested schemas are awaited and receive the same `services`; completes synchronously when `RequiresAsyncValidation` is `false`. | | `ValidateMember(value, memberName)` / `ValidateMemberAsync(value, memberName, services, cancellationToken)` | Validates one member — see [Validating a single member](#validating-a-single-member). | -| `IsValidatableMember(memberName)` | `true` when the member has rules `ValidateMember` runs. | +| `IsValidatableMember(memberName)` | `true` when the member has rules `ValidateMember` or `ValidateMemberAsync` runs, including a member covered only by `[ZodAsyncMember]`. | | `TryResolveMember(value, path, out container, out memberName)` | Walks `value` along an error path to the object that owns the failing member — see [Resolving the owning object](#resolving-the-owning-object). | `ValidateCore`, `ValidateCoreAsync`, `AppendJsonNamedPath` and the `TryResolveMember` overload with an @@ -587,6 +587,14 @@ A nullable enum property is validated only when it is not `null`. ## What is validated - Properties must be public, non-static, non-indexer. +- Properties are read from the target and its base chain, so an inherited public property is validated, exported, + given a `Paths`/`JsonNames` entry and resolved by `TryResolveMember` like a declared one. A property the target + overrides or hides is taken once, from the most derived declaration, because that is what `value.Member` binds. +- A `ValidationAttribute` without a dedicated Zod rule still runs: `[CustomValidation]`, `[FileExtensions]`, + `[EnumDataType]` and a consumer's own attribute are constructed in a generated field and called through + `GetValidationResult`/`GetValidationResultAsync`. Class-level attributes run as object-level rules and are + collected from the base chain. `[SkipValidation]` removes a property from generated validation and schema + export, and makes a class not claimed by the host resolver. - Validation is emitted for a property when it carries any DataAnnotations attribute, its type is a source-defined complex type with a nested schema, or its type is an enum (see [Automatic enum validation](#automatic-enum-validation)). - Classes, structs, and records are supported; structs do not receive `IValidateOptions` (ZODSGEN028 if requested). - Nested complex types are discovered recursively and get their own generated `{TypeName}Schema`, even when the nested type does not itself carry `[ZodSchema]`. diff --git a/docs/wiki/hosting/Microsoft-Extensions-Validation.md b/docs/wiki/hosting/Microsoft-Extensions-Validation.md index 21ef8ef4..0c97b878 100644 --- a/docs/wiki/hosting/Microsoft-Extensions-Validation.md +++ b/docs/wiki/hosting/Microsoft-Extensions-Validation.md @@ -79,7 +79,19 @@ falls through to the next resolver, which is usually the generated DataAnnotatio Generated `[ZodSchema]` validators already apply DataAnnotations attributes, so a ZodSharp type is validated by ZodSharp only. Set `AlsoRunDataAnnotations = true` to also run the next resolver that knows the type: ZodSharp runs first, and an identical `(Path, ErrorMessage)` pair is reported once. Nested ZodSharp types are not -validated twice. +validated twice. Field-level validation falls through to that next resolver for a property ZodSharp does not own, +so a DataAnnotations-only field still validates on a field change. + +## `[SkipValidation]` + +`Microsoft.Extensions.Validation`'s `[SkipValidation]` targets classes, properties and parameters (and is +inherited). It is honored throughout the bridge: + +- A class marked `[SkipValidation]` is not claimed by the ZodSharp resolver, even when a validator is registered. +- A property marked `[SkipValidation]` is omitted by the generated validator, `Paths`/`JsonNames` and JSON Schema, + and `TryFindProperty` does not offer it for field-level validation. +- A parameter marked `[SkipValidation]`, or whose type is, is not claimed by the resolver or the ZodSharp endpoint + filter. ## Options @@ -125,8 +137,8 @@ Parameters follow Microsoft.Extensions.Validation's own conventions: - An error about the value itself (a missing body, a root refinement) is keyed by the parameter name and has no container. -A nullable or optional parameter that is `null` is not validated. `[SkipValidation]` and `[FromServices]` -parameters and primitive types are never claimed by ZodSharp. +A nullable or optional parameter that is `null` is not validated. `[SkipValidation]` (on the parameter or its +type) and `[FromServices]` parameters and primitive types are never claimed by ZodSharp. ## Full-fidelity errors diff --git a/docs/wiki/quality/Cross-Platform-Interop.md b/docs/wiki/quality/Cross-Platform-Interop.md index c5df10b8..5e5bc5cd 100644 --- a/docs/wiki/quality/Cross-Platform-Interop.md +++ b/docs/wiki/quality/Cross-Platform-Interop.md @@ -75,6 +75,19 @@ the offset-equivalent bounds. The C# producer (`JsonSchema_GivenGeneratedTemporalSchemas_WritesZodParityArtifacts`) writes to `src/tests/cross-platform/temporal-parity/`; the UTC contract has its own generator tests. +Two further suites pin the generated schemas to the wire forms the browser expects: + +- **GUID/UUID parity** (`tests/ts/guid-uuid-parity.test.ts`) checks that generated `Guid`/`Guid?` schemas and their + exported JSON Schema use the canonical string form. The producer is + `JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts`, writing to `src/tests/cross-platform/guid-parity/`. +- **Client validators** (`tests/ts/client-validators.test.ts`) checks every rule the static-SSR JavaScript validators + map against the ZodSharp rule of the same name. The producer is + `ClientRules_GivenEveryMappedRule_WritesParityFixtures` (in `Blazor.UnitTests`), writing to + `src/tests/cross-platform/client-rules/`. + +Each suite fails rather than passing vacuously when its directory is empty, and the `cross-platform` CI job runs one +producer step per suite so the ordering the vitest consumer needs is explicit. + See [JSON Schema Export](../serialization/JsonSchema-Export.md) and [JSON Schema Import](../serialization/JsonSchema-Import.md). ## Directory layout @@ -83,5 +96,6 @@ See [JSON Schema Export](../serialization/JsonSchema-Export.md) and [JSON Schema src/ts/ TypeScript (Zod) schemas + fixture generation src/ts/fixtures/ generated JSON fixtures + manifest.json tests/ts/ vitest cross-platform tests -src/tests/cross-platform/ shared output directory for C#-generated JSON -``` \ No newline at end of file +src/tests/cross-platform/ gitignored output shared with the vitest suites: + output/, guid-parity/, temporal-parity/, client-rules/ +``` diff --git a/docs/wiki/quality/Performance.md b/docs/wiki/quality/Performance.md index f128d6c5..3f9cd7bb 100644 --- a/docs/wiki/quality/Performance.md +++ b/docs/wiki/quality/Performance.md @@ -5,13 +5,13 @@ Purview.ZodSharp is designed for maximum performance: validation rules are `read ## Running the benchmarks ```bash -# All suites -dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release +# All suites (the project multi-targets; pick a framework) +dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -f net10.0 # or just perf-tests # A specific suite (the `--` passes the filter to BenchmarkDotNet) -dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -- --filter "*ObjectPerformanceTests*" +dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -f net10.0 -- --filter "*ObjectPerformanceTests*" ``` Results are written to `BenchmarkDotNet.Artifacts/` (HTML, Markdown, logs) in the project directory. Use `-c Release`; the suite uses `[MemoryDiagnoser]` and a `[SimpleJob]` profile. @@ -105,18 +105,22 @@ All valid-input paths for the core schema types are zero-allocation: > [!NOTE] > The string transforms, `ZodString.ValidateSpan` (its result carries a string), and non-first union options are the allocation exceptions. Use `ZodString.IsValidSpan` for an allocation-free span check. See [Guarantees and Limitations](Guarantees-and-Limitations.md). -## Generated validators and ProblemDetails mapping +## Generated validators, unions and ProblemDetails mapping -Two suites cover the .NET 11 validation work. They have no committed results yet; run them locally: +Three suites cover the .NET 11 validation work. They have no committed results yet; run them locally: ```bash -dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -- --filter "*GeneratedValidationPerformanceTests*" -dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -- --filter "*ProblemDetailsMappingPerformanceTests*" +dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -f net11.0 -- --filter "*GeneratedValidationPerformanceTests*" +dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -f net11.0 -- --filter "*NativeUnionPerformanceTests*" +dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -f net10.0 -- --filter "*ProblemDetailsMappingPerformanceTests*" ``` - **`GeneratedValidationPerformanceTests`** - a `[ZodSchema]` model's `Validate` on valid and invalid input (baseline), field-level `ValidateMember` on valid and invalid input, and `TryResolveMember` on a nested path. +- **`NativeUnionPerformanceTests`** (`net11.0` only) - the hand-rolled `Union` against the native + `NativeUnion` when the case type is a value type. Both stay allocation-free; a regression in the + non-boxing access pattern shows up as an allocation. - **`ProblemDetailsMappingPerformanceTests`** - converting a `ZodException` to `HttpValidationProblemDetails` without a registry, with an `ErrorTypeRegistry` lookup, and with registry formatting. diff --git a/docs/wiki/schemas/Unions-and-Discriminated-Unions.md b/docs/wiki/schemas/Unions-and-Discriminated-Unions.md index 4eb5b02d..ef17d518 100644 --- a/docs/wiki/schemas/Unions-and-Discriminated-Unions.md +++ b/docs/wiki/schemas/Unions-and-Discriminated-Unions.md @@ -52,10 +52,10 @@ if (result.IsSuccess) The `Union<...>` value type (namespace `ZodSharp.Unions`) is the result of a typed union: - `Create(T1)` / `Create(T2)` (and a three-case variant) — tagged construction. -- Implicit conversions from the case types. -- `int Tag` and `object Value` (`Value` throws if uninitialized). +- Implicit conversions from the case types (a `null` reference produces an empty union). +- `int Tag` (`0` empty, `1` `T1`, `2` `T2`, and `3` for the three-case `T3`) and `object? Value` (`null` when empty). - `TryGetValue(out T1)` / `TryGetValue(out T2)`. -- `Match(Func, Func)` and `Switch(Action, Action)`. +- `Match(Func, Func)` and `Switch(Action, Action)` (they throw on an empty union). - `==` / `!=`, `Equals`, `GetHashCode`, `ToString`. ## Native unions (.NET 11+) @@ -64,25 +64,80 @@ When targeting `net11.0` or later, `Z.NativeUnion` returns a native C# 1 (`ZodSharp.Unions.NativeUnion`) instead of `Union`: ```csharp -var schema = Z.NativeUnion(Z.String().Min(1), Z.Object().Build()); -var result = schema.Validate("hello"); +var schema = Z.NativeUnion(Z.Number(), Z.Boolean()); +var result = schema.Validate(3d); if (result.IsSuccess) { - var length = result.Value switch + var described = result.Value switch { - string s => s.Length, - Dictionary o => o.Count, - _ => -1, + double n => n, + bool b => b ? 1d : 0d, + // No discard arm: both case types are handled, so the switch is exhaustive. }; } ``` -Prefer this when **both option types are reference types**: the native union is allocation-free and -supports exhaustive pattern matching. Value-type cases box, so keep using `Z.Union` (the -hand-rolled `Union`) when a case is a value type, or when you need -`Tag`/`Match`/`Switch`/`TryGetValue`/equality. The analyzer reports `ZODSGEN041` (Info) and offers a -code fix when a reference-type-only `Z.Union` can be switched to `Z.NativeUnion`. +The type is declared by hand with `[Union]` rather than with the `union` keyword, so it implements the +language's **non-boxing access pattern**. Each case is stored in its own typed field, and `TryGetValue(out T1)` +/ `TryGetValue(out T2)` / `HasValue` let pattern matching read a value-type case without boxing; the compiler +still treats it as a union (implicit conversions from each case type, and exhaustive `switch` expressions). +`Value` is only the boxing view the union contract requires, and `Tag` identifies the active case. + +Use it whenever you need a two-option union on .NET 11+: reference-type and value-type cases are both +allocation-free. Use the hand-rolled `Union` on .NET 10, or when you need `Match`/`Switch`/equality. +The analyzer reports `ZODSGEN041` (Info) and offers a code fix for any `Z.Union` built on .NET 11+. + +### JSON serialization + +A native union carries `[JsonUnion]`, so `System.Text.Json` writes the active case directly - no wrapper and no +discriminator - and reads it back. .NET 11's built-in classifier chooses a case by JSON token kind, which leaves +the two shapes a web API actually produces unresolved; `NativeUnionTypeClassifierFactory` resolves them: + +- A case whose natural JSON shape matches the token wins. Under `JsonSerializerDefaults.Web` (minimal APIs) a + number may also be read from a string, so `NativeUnion` reads `"hello"` as the string case and + `42` as the number case. +- Object-shaped cases are matched by their root property names, using the platform's structural classifier. +- Otherwise each candidate's own contract is tried; declaration order breaks a remaining tie for a scalar token. + An object or array token several cases can read, or a token no case can read, fails as `JsonException`. + +No registration is needed: the attribute is on `NativeUnion`, so a source-generated +`JsonSerializerContext` and `JsonSerializerDefaults.Web` get the same behaviour. A union whose cases share a JSON +shape - two object types with the same property names, or two numeric types - is genuinely ambiguous; give the +payload a distinct shape, or use a closed hierarchy with a discriminator +(`[JsonPolymorphic(InferClosedTypePolymorphism = true)]`). See +[System.Text.Json Integration](../serialization/SystemTextJson-Integration.md#native-unions-net-11). + +## Unions as schema targets + +A union type can itself carry `[ZodSchema]`, for either the `union` keyword or a hand-written `[Union]` struct: + +```csharp +[ZodSchema] +public readonly union Pet(Cat, Dog); +``` + +`PetSchema.Validate(pet)` dispatches on the union's active case and validates it with that case type's schema, so the +error paths are the case's own (`Name`, `Weight`) without a synthetic `Value` segment. Both forms of the language's +access pattern are understood: + +- A case with `TryGetValue(out TCase)` (the non-boxing access pattern: `NativeUnion`, hand-written unions) is matched + without boxing. +- A case without it (a `union` declaration) is matched through `Value`. + +An empty union, or a case type with no validator, fails as `invalid_union` with one `invalid_type` per option instead +of passing silently. A case type with no validator is also reported at build time as **ZODSGEN055** (warning): add +`[ZodSchema]` to that case, or reference the assembly that generated its schema. + +A union member of a class target works the same way and needs no `[ZodSchema]` on the union: the member dispatches +directly to the case schemas. `[Required]`, `[RequiredZod]` and `[NonSentinel]` treat an empty union as the member's +absent/default value — `missing_field`, the rule's own error, or `invalid_type` respectively — using `HasValue` when the +union provides it and `Value` otherwise. An empty union is never silently accepted. + +Async rules work end to end: a union whose active case has an `AsyncValidationAttribute` (or an +`IAsyncValidatableObject`) reports `RequiresAsyncValidation = true`, `ValidateAsync` awaits the case schema, +`Validate` throws, and the ASP.NET Core pipeline, Blazor forms, Microsoft.Extensions.Validation and options +validation all await it through the same generated entry points. ## Discriminated union @@ -124,6 +179,13 @@ derived type's schema. Paths do not include the union's `Value`: an error in the `[ZodSchema]`; one without is reported as `ZODSGEN049` (see [Source Generator Diagnostics](../generator/Source-Generator-Diagnostics.md)). +The generated JSON Schema follows the dispatch: a union member exports `oneOf` its case schemas, and a closed +hierarchy member exports `oneOf` its derived schemas, each branch carrying the derived type's full schema +(including inherited base members). See +[JSON Schema Export](../serialization/JsonSchema-Export.md#export-a-generated-zodschema)). System.Text.Json error +paths are mapped through the active case too, so `$.pet.name` becomes `Pet.Name` even though the union contract +has no members of its own (`ZodJsonExceptions.ToMemberPath`). + On .NET 11 the hosts understand unions too: - **Minimal APIs.** A JSON-body parameter typed as a union is validated through its active case, both by the diff --git a/docs/wiki/schemas/Validation-Rules-Reference.md b/docs/wiki/schemas/Validation-Rules-Reference.md index f8acb867..550c28e2 100644 --- a/docs/wiki/schemas/Validation-Rules-Reference.md +++ b/docs/wiki/schemas/Validation-Rules-Reference.md @@ -188,6 +188,8 @@ No default number message contains the input. Each rule still passes the value t `NullOrNonWhiteSpaceRule` is the null-tolerant counterpart: it accepts `null` but rejects an empty or whitespace-only string. It exists for nullable scalar value objects, which round-trip JSON `null`, so their schema must accept `null` while still rejecting whitespace — `[RequiredZod]` always rejects `null` and so cannot express it. Apply it to a nullable scalar type with `[NullOrNonWhiteSpace]`; a rule written against the underlying `string?` value is adapted to the value object automatically. +On a **union-typed member**, an empty union is the absent/default value: the generated validator rejects it with `missing_field` for `[Required]`, the rule's own error for `[RequiredZod]`/`[NonSentinel]`, and `invalid_type` for a non-nullable union without an explicit rule. The check is emitted by the generator using the union's `HasValue` (or `TryGetValue`), so it stays allocation-free; `NonSentinelRule` itself only knows the primitive sentinels above. Declare the member `Pet?` when an absent case is allowed. See [Unions and Discriminated Unions](Unions-and-Discriminated-Unions.md#unions-as-schema-targets). + The fluent `NonSentinel()` method is declared on `ZodType`, so it is available on every schema and closes the rule with the schema's output type. `ZodString`, `ZodDate`, and `ZodDateTimeOffset` override it with a covariant return type so the fluent chain keeps the concrete schema: ```csharp diff --git a/docs/wiki/serialization/JsonSchema-Export.md b/docs/wiki/serialization/JsonSchema-Export.md index 3ff982de..23909976 100644 --- a/docs/wiki/serialization/JsonSchema-Export.md +++ b/docs/wiki/serialization/JsonSchema-Export.md @@ -43,7 +43,7 @@ var jsonSchema = Z.ToJsonSchema>(userSchema, new ToJ ## Supported schema types -`ToJsonSchemaConverter` handles `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, `ZodUnion`, `ZodArray`, `ZodRecord`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, `ZodDiscriminatedUnion` and `ZodLazy`. +`ToJsonSchemaConverter` handles `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodDate`, `ZodDateTimeOffset`, `ZodObject`, `ZodOptional`, `ZodUnion`, `ZodArray`, `ZodRecord`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, `ZodDiscriminatedUnion` and `ZodLazy`. - Objects emit `additionalProperties: false`. - Records (`Z.Record(valueSchema)`) emit `{ "type": "object", "additionalProperties": }`. @@ -116,6 +116,15 @@ apart: property names, nested objects, native enums, nullable fields, required f switch, matching a global converter) makes them strings, and `ZodSharpEnumNamingPolicy=CamelCase` matches a `JsonNamingPolicy.CamelCase`. A member's `[JsonStringEnumMemberName]` name is used verbatim. A `[Flags]` enum exports as `type: integer`. +- **Union and closed-hierarchy members** follow the generated dispatch: a member typed as a C# 15 union exports + `oneOf` its case schemas, and a member typed as a closed class exports `oneOf` its derived schemas, each + carrying the derived type's full schema including inherited base members. A closed hierarchy that opts into + `[JsonPolymorphic(InferClosedTypePolymorphism = true)]` (or registers `[JsonDerivedType]` explicitly) also + carries the discriminator property (`$type` by default, or the configured name) on each branch, constrained to + that type's registered discriminator value - a string for an inferred or string registration, a number for an + integer one - because that is the wire form System.Text.Json writes. A process-wide + `JsonSerializerOptions.InferClosedTypePolymorphism` cannot be seen by the generator, so the type-level + attribute is what the export follows. - **Unsupported rules** are not dropped silently: a rule the exporter cannot represent is listed under the `x-zodsharp-unsupported` extension so the exported schema is visibly incomplete. diff --git a/docs/wiki/serialization/SystemTextJson-Integration.md b/docs/wiki/serialization/SystemTextJson-Integration.md index 9aa75cfa..0aedbef2 100644 --- a/docs/wiki/serialization/SystemTextJson-Integration.md +++ b/docs/wiki/serialization/SystemTextJson-Integration.md @@ -61,6 +61,15 @@ catch (ZodJsonValidationException ex) } ``` +## Native unions (.NET 11) + +`Z.NativeUnion` yields `ZodSharp.Unions.NativeUnion`, which `System.Text.Json` writes as its active +case and reads back with no wrapper or discriminator. The type carries `[JsonUnion]`, so deserialization uses +`NativeUnionTypeClassifierFactory` with no registration - including a string token under +`JsonSerializerDefaults.Web` and two object-shaped cases. See +[Unions and Discriminated Unions](../schemas/Unions-and-Discriminated-Unions.md#json-serialization) for the +classification rules and the genuinely ambiguous shapes. + ## API surface | Member | Signature | diff --git a/global.json b/global.json index b5eea8a8..ab72dd74 100644 --- a/global.json +++ b/global.json @@ -1,11 +1,10 @@ { "sdk": { - "version": "11.0.100-rc.3.26522.148", - "rollForward": "latestFeature", - "allowPrerelease": true + "version": "11.0.100", + "rollForward": "latestFeature" }, "msbuild-sdks": { - "Purview.BuildSdk": "1.3.0" + "Purview.BuildSdk": "2.0.0" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index 9447a7be..ed4c4fe6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "3.0.0-prerelease.3", + "version": "3.0.0-prerelease.5", "private": true, "license": "MIT", "author": { @@ -27,5 +27,5 @@ "engines": { "bun": ">=1.4.2" }, - "packageManager": "bun@1.4.2" + "packageManager": "bun@1.4.4" } diff --git a/skills-lock.json b/skills-lock.json index 49155c15..0194adf0 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,125 +1,149 @@ { - "version": 1, + "version": 2, "skills": { "purview-architecture": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture/SKILL.md", - "computedHash": "d194bd0f30091ffab6ef35c29582e56511d2daca32fdbb1942c50134198f9270" + "computedHash": "2500e935ff2ccb350f26ebb9be321dc9f99800121aa9412fdc597f95c2c465c8" }, "purview-architecture-testing": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture-testing/SKILL.md", - "computedHash": "5907c6bf28788b755b752041e2f0cd88222604a949988d487c25940b6284fce4" + "computedHash": "23af6d9baeb408e1e3f4034cef6539d7efa3fecb1b4da918cb539bd8dfbd5a00" }, "purview-buildsdk-adoption": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", - "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + "computedHash": "5ddad1fe7d1dc65c1b352743e5cd616d4720a33228b06faa6259572cd4a8c265" }, "purview-buildsdk-configuration": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", - "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" + "computedHash": "9b9593e093aaf07ec0a427e7874c1dd2cda35b9a13a6701d7ae10af5ebd985a9" }, "purview-buildsdk-project-detection": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", - "computedHash": "7c30062fe592105b7a2539384843759521c5a3f90ace8f7c60f0e6ae2405b0b1" + "computedHash": "9a3cf78ad4df9c335780bbbfbc9f66f7986b6293359aab19edaadb5c79f70be8" }, "purview-buildsdk-project-layout": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", - "computedHash": "101f09c92cf97e93387b926c48adab9598a0cf0a64a4932d41085ab0a0078cfa" + "computedHash": "d8b1fb6b95ae5e91f81734616f70a9f3d2d7547dc7406e4eda0f8d674ebf808b" }, "purview-buildsdk-repository-review": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", - "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + "computedHash": "babfe657a1eef5be67dc1e4ec387e9e8215fb0263956c4c926465fa27b648604" }, "purview-buildsdk-skill-installation": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", - "computedHash": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + "computedHash": "8e1dc7c0f499eb742264d5cc293784758c5f0499f849671a0be3353f91f89491" }, "purview-buildsdk-skill-tests": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", - "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" + "computedHash": "27b3520ced5c5a8009cfb47149f0fc1826246275d3ec2991667e1543b011d657" + }, + "purview-buildsdk-skills-justfile": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skills-justfile/SKILL.md", + "computedHash": "a41f7bbd28333e7f2e40cadbbdd35a75b2f626d72e53901cbc79c7325ac2e50c" }, "purview-buildsdk-testing": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-testing/SKILL.md", - "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" + "computedHash": "e7e00ede43f5ee8aa4517329e4fdbde00a5a01bd8e98ea42990c851060bcaf9e" + }, + "purview-component-compatibility": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-component-compatibility/SKILL.md", + "computedHash": "070634caa90d032e2319d6799edbee973be0f9380008917474909328e74f143b" }, "purview-engineering-principles": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-engineering-principles/SKILL.md", - "computedHash": "fd38d374b816ecf77964c41486e7c734ea34998c3f2a5538c90a2ba793905994" + "computedHash": "d1b0d2b50e635160f96d03d2790cb725242434bb28b1b47913371bb39d83215b" + }, + "purview-org-harvest": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-org-harvest/SKILL.md", + "computedHash": "040b34d9cd41f23e71dbe1500b0748793377703d1032e1135ed0a3eeeee4a7f8" }, "purview-persistence": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-persistence/SKILL.md", - "computedHash": "0523ecc210bf3571e2fccb859128a499fd80694e9d4089b377a8d39d73c028f4" + "computedHash": "28e92fa8bccc241ca864b6731148bd721f47699baf3a1a2ec07cce42ed650bae" + }, + "purview-sourcegenerator-attribute-data-models": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-attribute-data-models/SKILL.md", + "computedHash": "475bf4ef2c6e5c29e02725383f97fb0aea60243c05a2d779b03533c392c6d395" }, "purview-sourcegenerator-codewriter": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-codewriter/SKILL.md", - "computedHash": "f24ae680340784e62236d044cbc3658d5ca7bdc3cea3fc44f2bb5cf75a84f5e4" + "computedHash": "5874c7773f395a5fea123f447bc500a687760f256eb5f7646022f9fbd22cd111" }, "purview-sourcegenerator-diagnostics": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-diagnostics/SKILL.md", - "computedHash": "c3c7920701e0e688f34ddece9e448472e010e69767e8c08914638128d34a8f17" + "computedHash": "8e3571221c2a2225f3826b0c501cd28ea2a27e91defd121f45117c0e36a308fd" }, "purview-sourcegenerator-packaging": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-packaging/SKILL.md", - "computedHash": "ee2440f1e0aa0cea731eb3eae2ea3c4e9ea77502867abc21e9f0a3c61f20c640" + "computedHash": "6044272af5982bbf9750a198ca8094c17106579de36dc259712cedc166b7b2fb" }, "purview-sourcegenerator-pipeline": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-pipeline/SKILL.md", - "computedHash": "7d48394d7e3890f288949c79f040646207f34af9ae85827a63d056bae1b1b985" + "computedHash": "62835d55eab1ccf233241796d34d17bfd7b2fbe4be03424bf9a3a40d8ad2e6a7" }, "purview-sourcegenerator-testing": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-testing/SKILL.md", - "computedHash": "d88bf2353073013ff9ddf9d3fe1a2e9415eb60d319bbd053baa105f50fa3f889" + "computedHash": "ef37df157bd9a589d487e79cffdecc4160aee59ca9000aea55d7204af097e883" }, "purview-sourcegenerator-tunit-tests": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-tunit-tests/SKILL.md", - "computedHash": "14809d3727643e9a779d590b6627b3283af7b36c835e902cb80162552cb45b4f" + "computedHash": "29f966d9331a556bc3a7d5aa8ad8fe7b1a9091557d629e9c23f65ebd14e3aee7" }, "purview-telemetry": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-telemetry/SKILL.md", - "computedHash": "08ba26e1379dfa2bb826ddfd011e95ac84cd583b865f0b89673d7302b2c2c27d" + "computedHash": "f92aab10e3d2be7b24be559316537ba7d0485d0e479c67f4d5c6126f5f2fc652" }, "purview-testing-conventions": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-testing-conventions/SKILL.md", - "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" + "computedHash": "e053513db856852a23aad0e22625e1408fdd63377acb793e6d720fe37b9f7214" } } } diff --git a/skills/purview-zodsharp-aspnetcore/SKILL.md b/skills/purview-zodsharp-aspnetcore/SKILL.md index a9de1304..21052910 100644 --- a/skills/purview-zodsharp-aspnetcore/SKILL.md +++ b/skills/purview-zodsharp-aspnetcore/SKILL.md @@ -93,8 +93,10 @@ app.MapPost("/legacy", (UserDto dto) => …).WithZodSharpValidation(); endpoint filter **factory** picks the parameters once, when the endpoint is built: - Skipped: services (`[FromServices]`, keyed services, anything the container resolves), `HttpContext`, - `HttpRequest`, `HttpResponse`, `ClaimsPrincipal`, `CancellationToken`, form files, streams and - `[SkipValidation]`. `[FromBody]`/`[FromRoute]`/`[FromQuery]`/`[FromHeader]`/`[FromForm]` force a parameter in. + `HttpRequest`, `HttpResponse`, `ClaimsPrincipal`, `CancellationToken`, form files, streams, and a + `[SkipValidation]` parameter or parameter type. The filter matches the real .NET 11 + `SkipValidationAttribute`, not any same-named attribute (on .NET 10, where it does not exist, a name match is + the fallback). `[FromBody]`/`[FromRoute]`/`[FromQuery]`/`[FromHeader]`/`[FromForm]` force a parameter in. - Validators resolve through `IZodSchemaFactory.TryGetUntypedValidator(Type)`; **every** selected argument is validated with `ValidateAsync(value, RequestServices, RequestAborted)`. - Error keys follow Microsoft.Extensions.Validation: complex parameter unprefixed (`Name`), sequence diff --git a/skills/purview-zodsharp-core/SKILL.md b/skills/purview-zodsharp-core/SKILL.md index da002e69..a88170f0 100644 --- a/skills/purview-zodsharp-core/SKILL.md +++ b/skills/purview-zodsharp-core/SKILL.md @@ -140,6 +140,7 @@ See [references/error-model.md](references/error-model.md) for the error contrac | `Z.Optional(schema)` (`T : class`) / `Z.Nullable(schema)` (`T : struct`) | `ZodOptional` / `ZodNullable` | | `Z.Object()` | `ZodObjectBuilder` | | `Z.Union(params ...)` / `Z.Union(...)` | `ZodUnion` / `ZodTypedUnion` | +| `Z.NativeUnion(...)` (net11.0+) | `ZodTypedNativeUnion`, yielding the non-boxing `NativeUnion` | | `Z.DiscriminatedUnion(discriminator)` | `ZodDiscriminatedUnionBuilder` | | `Z.Intersection(left, right)` | `ZodIntersection` | | `Z.Literal(value)` (`T : IEquatable`) | `ZodLiteral` | @@ -152,6 +153,13 @@ See [references/error-model.md](references/error-model.md) for the error contrac `Enum` and `Union` are overloaded — `Enum(params string[])` versus `Enum()`, and `Union(params …)` versus `Union(…)`. +On `net11.0` and later, prefer `Z.NativeUnion`: its result is a C# 15 union declared with the +non-boxing access pattern, so reference-type **and** value-type cases stay allocation-free and an exhaustive +`switch` needs no discard arm. `Z.Union` remains the choice on `net10.0`, or when `Match`/`Switch`/ +equality is needed. On the wire it carries `[JsonUnion]`: System.Text.Json writes the active case and the +`NativeUnionTypeClassifierFactory` reads it back, including a string token under Web defaults and two +object-shaped cases (see the `purview-zodsharp-json` skill). + ```csharp var userSchema = Z.Object() .Field("name", Z.String().Min(1)) diff --git a/skills/purview-zodsharp-diagnostics/SKILL.md b/skills/purview-zodsharp-diagnostics/SKILL.md index eb584b5c..b64ada26 100644 --- a/skills/purview-zodsharp-diagnostics/SKILL.md +++ b/skills/purview-zodsharp-diagnostics/SKILL.md @@ -20,7 +20,7 @@ Two families, both enabled by default: package and need no ASP.NET Core dependency. `ZODSGEN033`, `ZODSGEN037`–`ZODSGEN040`, `ZODSGEN042`, `ZODSGEN045`, `ZODSGEN046`, `ZODSGEN048`, `ZODSGEN049`, -`ZODSGEN051`, `ZODSGEN052` and `ZODSGEN053` are warnings; `ZODSGEN041`, `ZODSGEN043` and `ZODSGEN047` are +`ZODSGEN051`, `ZODSGEN052`, `ZODSGEN053` and `ZODSGEN055` are warnings; `ZODSGEN041`, `ZODSGEN043` and `ZODSGEN047` are informational; the rest are errors. IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused. Rule identifiers are never @@ -53,6 +53,7 @@ All of these mean the method the generator was told to call cannot be called: | `ZODSGEN010` | First parameter is not the model type. | | `ZODSGEN011` | The last parameter is not `CancellationToken`. | | `ZODSGEN054` | A three-parameter (service-aware) method's second parameter is not `IServiceProvider`. | +| `ZODSGEN055` | *(warning)* A union (`union` declaration or `[Union]` type) has a case type with no `[ZodSchema]` validator in this compilation or a referenced assembly. The case is omitted from dispatch and values of that case fail as `invalid_union`; add a validator to the case type. | | `ZODSGEN012` | The method is generic. | | `ZODSGEN013` | Declared on the model type but not `static`. | | `ZODSGEN014` | Inaccessible from the generated validator (private/protected). | @@ -111,7 +112,7 @@ All of these mean the method the generator was told to call cannot be called: | ID | Meaning | | --- | --- | -| `ZODSGEN041` | *(info)* A typed `Z.Union` whose option types are all reference types can use the allocation-free native C# 15 union from `Z.NativeUnion` on .NET 11+. A code fix is offered. | +| `ZODSGEN041` | *(info)* A typed `Z.Union` can use the non-boxing native C# 15 union from `Z.NativeUnion` on .NET 11+ (reference- and value-type cases are both allocation-free). A code fix is offered. | | `ZODSGEN044` | A `[ZodSchema]` type using an automatic `[Scalar]` form (`[Scalar]` / `[Scalar(typeof(TValue))]`) cannot produce a usable schema, so the value-object generator's `Create` would reference a `{Type}Schema` that does not exist or cannot validate. Reported when `GenerateValidateMethod = false` suppresses the `Validate` it calls, or the declared underlying type cannot be represented. | | `ZODSGEN045` | A type referenced by an exported type has `GenerateJsonSchema = false`, so the cross-platform contract surface is incomplete. | | `ZODSGEN049` | *(warning)* A member is typed as a C# 15 closed class whose derived type has no `[ZodSchema]`, so a value of that derived type is not validated. Add `[ZodSchema]` to the derived type. | diff --git a/skills/purview-zodsharp-json/SKILL.md b/skills/purview-zodsharp-json/SKILL.md index 0b33e215..58938f93 100644 --- a/skills/purview-zodsharp-json/SKILL.md +++ b/skills/purview-zodsharp-json/SKILL.md @@ -115,6 +115,15 @@ return the `ZodException`'s errors under the member that held the value (`Custom back to member names through the `JsonTypeInfo`), named after that member (`Field 'CustomerEmail' ...`). `ZodJsonExceptions.TryGetErrors(exception, rootTypeInfo, out errors)` does the same for any exception chain. +### Native unions (.NET 11) + +`Z.NativeUnion` returns `ZodSharp.Unions.NativeUnion`. System.Text.Json writes the active case +with no wrapper or discriminator, and the type's `[JsonUnion]` classifier reads it back on both the reflection and +source-generated paths. It resolves a string token under `JsonSerializerDefaults.Web` (where a number may also be +read from a string) and two object-shaped cases, with no converter registration. A genuinely ambiguous shape (two +object types with the same property names, or two numeric types) fails as `JsonException`; give it a distinct +shape or use a closed hierarchy with `[JsonPolymorphic(InferClosedTypePolymorphism = true)]`. + ## Validating converters ```csharp @@ -171,9 +180,10 @@ the JSON Schema; hosts that carry them (the OpenAPI transformers) read them. (`anyOf`/`oneOf`/`allOf`), `enum`/`const`, `$defs` and draft-07 `definitions`, and the metadata keywords including OpenAPI 3.0 `nullable`. -Covered schema types: `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, -`ZodUnion`, `ZodDiscriminatedUnion` (as `oneOf`), `ZodArray`, `ZodLiteral`, `ZodNullable`, -`ZodNativeEnum`/`ZodEnum`, `ZodLazy`. String formats: `IPAddress` exports `format: ipv4`/`ipv6`, `Duration` +Covered schema types: `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodDate`, `ZodDateTimeOffset`, +`ZodObject`, `ZodOptional`, `ZodUnion`, `ZodDiscriminatedUnion` (as `oneOf`), `ZodArray`, `ZodLiteral`, +`ZodNullable`, `ZodRecord` (as an object with `additionalProperties`), `ZodNativeEnum`/`ZodEnum`, +`ZodLazy`. String formats: `IPAddress` exports `format: ipv4`/`ipv6`, `Duration` `format: duration`, `DateString` `format: date`, `DatetimeString` `format: date-time` plus its UTC pattern, `TimeString` a local-time pattern, and `Base64String` `contentEncoding: base64`. Notable behaviours: @@ -225,6 +235,10 @@ Things worth knowing: wraps that value schema in a `null` branch), and its size constraints use `minProperties`/`maxProperties`. `Z.Record(schema)` exports the same shape, and `Z.FromJsonSchema` reads an object with `additionalProperties` back as a record in both JSON integrations. +- **Union and closed-hierarchy members** export `oneOf`: a C# 15 union member exports its case schemas, and a + `closed` hierarchy member exports its derived schemas. A hierarchy that opts into + `[JsonPolymorphic(InferClosedTypePolymorphism = true)]` (or registers `[JsonDerivedType]`) also constrains + `$type` on each branch to that derived type's name. ## JSON Schema import diff --git a/skills/purview-zodsharp-source-generator/SKILL.md b/skills/purview-zodsharp-source-generator/SKILL.md index 9d359faf..e6b69728 100644 --- a/skills/purview-zodsharp-source-generator/SKILL.md +++ b/skills/purview-zodsharp-source-generator/SKILL.md @@ -15,8 +15,8 @@ metadata: # The `[ZodSchema]` source generator Mark a class, struct or record with `[ZodSchema]` and a static, zero-allocation validator is emitted -at compile time — no reflection at runtime. The attribute itself is `ZodSharp.ZodSchemaAttribute` in the` -`Purview.ZodSharp` assembly (as is `[ErrorType]`), so nothing beyond `Purview.ZodSharp` is needed and the` +at compile time — no reflection at runtime. The attribute itself is `ZodSharp.ZodSchemaAttribute` in the +`Purview.ZodSharp` assembly (as is `[ErrorType]`), so nothing beyond `Purview.ZodSharp` is needed and the generator emits no marker types into your project. ```csharp @@ -47,6 +47,7 @@ var validated = UserSchema.Parse(user); // throws ZodException on failure | --- | --- | | `{TypeName}Schema` | static partial class — the validator. Accessibility mirrors the target. Holds `Validate`, `ValidateAsync(value, services, ct)`, `RequiresAsyncValidation`, the member API below, `Parse`, `BuildJsonSchema`, and (with composition on) `ApplyAnd`, `ApplyOr`, `ApplyRefine`. Renamed by `SchemaName`. | | `{TypeName}Schema.Paths` | one `ZodPath` constant per member (`OrderSchema.Paths.Lines`) for refinements and tests | +| `{TypeName}Schema.IsValidatableMember(name)` | true when a field change can validate that member alone (synchronous rules or a `[ZodAsyncMember]` method) | | `{TypeName}Schema.JsonNames` | `Get(memberName)` returns the `[JsonPropertyName]` name, or `null`; `ToJsonNamedPath(path, namingPolicy)` renames a whole path without reflection | | `{TypeName}SchemaValidator` | `partial class` implementing `IZodSchemaValidator`, `IZodMemberValidator`, `IZodMemberResolver`, `IZodUntypedValidator`, `IZodJsonPathMapper` and `IJsonSchemaProvider` — the adapter hosts use. Primary schema only; `{SchemaName}Validator` when renamed. | | `{TypeName}Validator` | `sealed partial class : IValidateOptions<{TypeName}>` (and, on .NET 11, `IAsyncValidateOptions` when the type has async rules) — only when `IValidateOptions` support is on, and the target is a class. | @@ -90,7 +91,9 @@ validator all depend on `Validate` — `GenerateValidateMethod = false` removes ## What is validated -- Properties must be public, non-static, non-indexer. +- Properties must be public, non-static, non-indexer. They are read from the type **and its base chain**, so an + inherited public property is validated, exported and resolved like a declared one; an overridden or hidden + property is taken once, from the most derived declaration. - A property gets validation when it carries **any** DataAnnotations attribute, when its type is a source-defined complex type with a nested schema, or when its type is an enum. - Nested complex types are discovered recursively and get their own `{TypeName}Schema`, **even @@ -131,7 +134,10 @@ validator all depend on `Validate` — `GenerateValidateMethod = false` removes synchronous `Validate` of such a type throws `InvalidOperationException` (`ZODSGEN048`). - **C# 15:** a member typed as a union dispatches to the active case's schema, and a member typed as a closed class to the derived type's schema (`ZODSGEN049` when a derived type has no `[ZodSchema]`). - Paths never include the union's `Value`. + Paths never include the union's `Value`. `[ZodSchema]` on a union itself emits a schema that dispatches to + the active case, matching a case with `TryGetValue` when the union implements the non-boxing access pattern; + an empty union or a case with no validator fails as `invalid_union` (`ZODSGEN055` when a case has no + validator). `[Required]`/`[RequiredZod]`/`[NonSentinel]` treat an empty union as absent/default. - Nested validation is depth-guarded: `ZodValidationDepth.MaxDepth` (32) throws `InvalidOperationException` instead of overflowing the stack on a cycle. - Paths are typed: `Items[0].Name`, `Map[en].Value`, `Map["0"].Value` from `FullPath.ToDottedString()`. diff --git a/skills/purview-zodsharp-source-generator/references/dataannotations.md b/skills/purview-zodsharp-source-generator/references/dataannotations.md index 6a848c1b..c9a10de2 100644 --- a/skills/purview-zodsharp-source-generator/references/dataannotations.md +++ b/skills/purview-zodsharp-source-generator/references/dataannotations.md @@ -20,9 +20,23 @@ direct, typed codegen. Nothing is reflected at runtime. | `[Url]`, `[Phone]`, `[CreditCard]`, `[Base64String]` | reuse `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule` | `invalid_string` / `invalid_format` | | `[Compare(otherProperty)]` | typed equality between two properties | `mismatch` / `invalid_value` | | `[Display(Name=…)]`, `[Display(Name=…, ResourceType=…)]` | not validated; the display name in messages (`{displayName}`, `{0}`); `ResourceType` is read at runtime | — | +| `[DisplayName(…)]` | fallback display name when `[Display]` is absent (matching .NET 11's Blazor `DisplayName` component) | — | `[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also present. +## Attributes without a dedicated rule + +Any other `ValidationAttribute` still runs: `[CustomValidation]` on a property or the class, +`[FileExtensions]`, `[EnumDataType]`, and a consumer's own attribute are constructed in a generated +field and called through `GetValidationResult` (or `GetValidationResultAsync` for the .NET 11 +`AsyncValidationAttribute`). Class-level attributes are object-level rules and are collected from the +base chain; a non-multiple attribute is taken from its most derived declaration. Attributes already +mapped to a Zod rule are never run twice. + +`[SkipValidation]` (`Microsoft.Extensions.Validation`; classes, properties, parameters) removes a +class or property from generated validation, `Paths`/`JsonNames` and JSON Schema. A minimal-API +parameter or its type is skipped by the endpoint filter and the bridge. + `[RequiredZod]` — the generated `RequiredRule` attribute — mirrors `[Required]`: it is emitted *outside* the generator's non-null guard, so an absent value is reported as `missing_field` even though every other rule bound to the member is skipped for `null`. Its `AllowEmptyStrings` has the diff --git a/skills/purview-zodsharp-source-generator/references/generated-api.md b/skills/purview-zodsharp-source-generator/references/generated-api.md index 7ef1675a..32e1c443 100644 --- a/skills/purview-zodsharp-source-generator/references/generated-api.md +++ b/skills/purview-zodsharp-source-generator/references/generated-api.md @@ -93,3 +93,9 @@ has asynchronous rules, so `ValidateOnStart()` runs them. A member typed as a C# union validates the active case with that case's schema; a member typed as a closed class validates with the runtime derived type's schema. Error paths are those of the case (no `Value` segment). Each derived type of a closed class needs `[ZodSchema]` (`ZODSGEN049`). + +`[ZodSchema]` on the union itself emits a schema that dispatches to the active case; a case with +`TryGetValue` is matched without boxing (the non-boxing access pattern), otherwise through `Value`. An +empty union or an active case with no validator fails as `invalid_union` (`ZODSGEN055` when a case type has +no validator). A union member's `[Required]`, `[RequiredZod]` and `[NonSentinel]` treat an empty union as +absent/default. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 81a05d78..4471c438 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,7 +4,19 @@ listed here, so a runtime lifecycle change is an SDK bump instead of an edit in this file. net8.0 and net9.0 were dropped in 3.0.0. --> Supported + preview + + true ZodSharp false @@ -14,6 +26,15 @@ universal + + false diff --git a/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj b/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj index 7ef2c1b2..ce1d9a53 100644 --- a/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj +++ b/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj @@ -12,9 +12,6 @@ true true - - $(NoWarn);NU5104 ZodSharp.AspNetCore.OpenApi Purview.ZodSharp.AspNetCore.OpenApi Purview.ZodSharp.AspNetCore.OpenApi diff --git a/src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs b/src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs index c155ef9b..f363ccbb 100644 --- a/src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs @@ -104,8 +104,8 @@ static IEnumerable GetStatusCodes(IServiceProvider services) // Which error types an endpoint's rules can raise is not known statically, so 422 is listed only when // the registry the ProblemDetails mapping uses contains an error type that maps to it. - var registry = - services.GetService>()?.Value.Registry ?? ErrorTypeRegistry.Default; + var registry = services.GetService>()?.Value.Registry + ?? ErrorTypeRegistry.Default; if (registry.All.Any(static errorType => errorType.HttpStatus == StatusCodes.Status422UnprocessableEntity)) yield return StatusCodes.Status422UnprocessableEntity; } diff --git a/src/src/AspNetCore/ClientValidation/ZodClientValidationProvider.cs b/src/src/AspNetCore/ClientValidation/ZodClientValidationProvider.cs index 840f5ff6..ad6df55e 100644 --- a/src/src/AspNetCore/ClientValidation/ZodClientValidationProvider.cs +++ b/src/src/AspNetCore/ClientValidation/ZodClientValidationProvider.cs @@ -99,12 +99,25 @@ IReadOnlyDictionary renderedFields { List<(string, IReadOnlyList)> fields = []; var formType = editContext.Model.GetType(); + if (formType.IsDefined(typeof(Microsoft.Extensions.Validation.SkipValidationAttribute), inherit: true)) + return fields; + foreach (var (field, renderedName) in renderedFields) { if (string.IsNullOrEmpty(field.FieldName) || string.IsNullOrEmpty(renderedName)) continue; var containerType = field.Model.GetType(); + if (containerType.IsDefined(typeof(Microsoft.Extensions.Validation.SkipValidationAttribute), inherit: true)) + continue; + + var propertyInfo = containerType.GetProperty(field.FieldName, BindingFlags.Public | BindingFlags.Instance); + if ( + propertyInfo?.IsDefined(typeof(Microsoft.Extensions.Validation.SkipValidationAttribute), inherit: true) + == true + ) + continue; + List rules = []; if (_options.Schemas.TryGetValue(containerType, out var schema)) @@ -140,7 +153,10 @@ bool IsZodValidated(Type type) => static List BuildRules(Type containerType, string fieldName) { var property = containerType.GetProperty(fieldName, BindingFlags.Public | BindingFlags.Instance); - if (property is null) + if ( + property is null + || property.IsDefined(typeof(Microsoft.Extensions.Validation.SkipValidationAttribute), inherit: true) + ) return []; List rules = []; diff --git a/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs b/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs index 8a75cb71..e31e2b01 100644 --- a/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs +++ b/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs @@ -301,23 +301,13 @@ static string DescribeHandler(MethodInfo method) => static bool IsSkipped(ParameterInfo parameter, IServiceProviderIsService? isService) { var type = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; - if ( - type == typeof(HttpContext) - || type == typeof(HttpRequest) - || type == typeof(HttpResponse) - || type == typeof(ClaimsPrincipal) - || type == typeof(CancellationToken) - || type == typeof(IFormFile) - || type == typeof(IFormFileCollection) - || type == typeof(IFormCollection) - || type == typeof(Stream) - || type == typeof(PipeReader) - ) - { + if (IsIntrinsicallySkipped(type)) return true; - } var forced = false; + if (HasSkipValidation(type)) + return true; + foreach (var attribute in parameter.GetCustomAttributes(inherit: true)) { switch (attribute) @@ -333,9 +323,7 @@ static bool IsSkipped(ParameterInfo parameter, IServiceProviderIsService? isServ forced = true; break; default: - // Microsoft.Extensions.Validation's [SkipValidation], or an application's own, matched by name - // so no package reference is needed. - if (string.Equals(attribute.GetType().Name, "SkipValidationAttribute", StringComparison.Ordinal)) + if (IsSkipValidationAttribute(attribute)) return true; break; } @@ -344,6 +332,44 @@ static bool IsSkipped(ParameterInfo parameter, IServiceProviderIsService? isServ return !forced && isService?.IsService(parameter.ParameterType) == true; } + /// + /// Whether a parameter of is a framework value that is never validated by ZodSharp. + /// + static bool IsIntrinsicallySkipped(Type type) => + type == typeof(HttpContext) + || type == typeof(HttpRequest) + || type == typeof(HttpResponse) + || type == typeof(ClaimsPrincipal) + || type == typeof(CancellationToken) + || type == typeof(IFormFile) + || type == typeof(IFormFileCollection) + || type == typeof(IFormCollection) + || type == typeof(Stream) + || type == typeof(PipeReader); + + /// + /// Whether (or a base type) is marked with the platform's + /// [SkipValidation]. On .NET 11 the real type is matched; on .NET 10, where the attribute does not + /// exist, an application's own attribute with that name is honored. + /// + static bool HasSkipValidation(Type type) + { + foreach (var attribute in type.GetCustomAttributes(inherit: true)) + { + if (IsSkipValidationAttribute(attribute)) + return true; + } + + return false; + } + + static bool IsSkipValidationAttribute(object attribute) => +#if NET11_0_OR_GREATER + attribute is Microsoft.Extensions.Validation.SkipValidationAttribute; +#else + string.Equals(attribute.GetType().Name, "SkipValidationAttribute", StringComparison.Ordinal); +#endif + static bool IsBody(ParameterInfo parameter, Type validatedType, IServiceProviderIsService? isService) { foreach (var attribute in parameter.GetCustomAttributes(inherit: true)) diff --git a/src/src/AspNetCore/ZodValidationFilter.cs b/src/src/AspNetCore/ZodValidationFilter.cs index 64e8500b..27851f9f 100644 --- a/src/src/AspNetCore/ZodValidationFilter.cs +++ b/src/src/AspNetCore/ZodValidationFilter.cs @@ -31,8 +31,7 @@ public sealed class ZodValidationFilter : IEndpointFilter return await next(context).ConfigureAwait(false); #endif - var factory = - httpContext.RequestServices.GetService() + var factory = httpContext.RequestServices.GetService() ?? throw new InvalidOperationException( "No IZodSchemaFactory is registered. Call AddZodSharp (or AddZodSharpFactory) before applying WithZodSharpValidation." ); @@ -45,8 +44,7 @@ public sealed class ZodValidationFilter : IEndpointFilter if (argument is not T value) continue; - validator ??= - factory.Resolve() + validator ??= factory.Resolve() ?? throw new InvalidOperationException( $"No Zod schema validator is registered for type '{typeof(T).FullName}'." ); diff --git a/src/src/Benchmarks/NativeUnionPerformanceTests.cs b/src/src/Benchmarks/NativeUnionPerformanceTests.cs new file mode 100644 index 00000000..ef6d4cb7 --- /dev/null +++ b/src/src/Benchmarks/NativeUnionPerformanceTests.cs @@ -0,0 +1,43 @@ +#if NET11_0_OR_GREATER +using BenchmarkDotNet.Attributes; +using ZodSharp.Schemas; +using ZodSharp.Unions; + +namespace ZodSharp; + +/// +/// Compares the hand-rolled union with the .NET 11 native union when the case type is a value type. The native +/// union implements the non-boxing access pattern, so both the schema result and pattern matching stay +/// allocation-free; the hand-rolled union stores each case in its own typed field for the same reason. The +/// comparison exists so a regression (a declared union, or a missing TryGetValue) shows up as an +/// allocation in the memory column. +/// +[MemoryDiagnoser] +[SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)] +public class NativeUnionPerformanceTests +{ + readonly ZodTypedUnion _typedUnionSchema; + readonly ZodTypedNativeUnion _nativeUnionSchema; + + public NativeUnionPerformanceTests() + { + _typedUnionSchema = Z.Union(Z.Number(), Z.String()); + _nativeUnionSchema = Z.NativeUnion(Z.Number(), Z.String()); + } + + [Benchmark(Baseline = true)] + public double TypedUnion_NumberCase() => Describe(_typedUnionSchema.Validate(42d).Value); + + [Benchmark] + public double NativeUnion_NumberCase() => Describe(_nativeUnionSchema.Validate(42d).Value); + + static double Describe(in Union value) => value.TryGetValue(out double number) ? number : 0d; + + static double Describe(in NativeUnion value) => + value switch + { + double number => number, + string => 0d, + }; +} +#endif diff --git a/src/src/Benchmarks/README.md b/src/src/Benchmarks/README.md index b4902686..9d16b6e6 100644 --- a/src/src/Benchmarks/README.md +++ b/src/src/Benchmarks/README.md @@ -1,6 +1,6 @@ # Purview.ZodSharp Performance Benchmarks -BenchmarkDotNet performance suite for Purview.ZodSharp. This is a `net10.0` console application; every suite uses `[MemoryDiagnoser]` plus a `[SimpleJob]` profile so time and allocation data are captured together. +BenchmarkDotNet performance suite for Purview.ZodSharp. This is a `net10.0;net11.0` console application; every suite uses `[MemoryDiagnoser]` plus a `[SimpleJob]` profile so time and allocation data are captured together. ## Structure @@ -13,16 +13,20 @@ BenchmarkDotNet performance suite for Purview.ZodSharp. This is a `net10.0` cons | `MemoryPerformanceTests.cs` | Allocation-focused benchmarks for string, array and object validation (uses more iterations to surface allocation differences) | | `TransformPerformanceTests.cs` | Transformations — `ToLower`, `ToUpper`, `Trim`, chained transforms, and a transform combined with validation | | `UnionPerformanceTests.cs` | Union and discriminated-union validation, including invalid inputs | +| `GeneratedValidationPerformanceTests.cs` | Source-generated (`[ZodSchema]`) validation — the success path, the failure path and a nested graph | +| `NativeUnionPerformanceTests.cs` | The hand-rolled union against the .NET 11 native union when the case type is a value type (both stay allocation-free); `net11.0` only | +| `ProblemDetailsMappingPerformanceTests.cs` | Turning a thrown `ZodException` into `HttpValidationProblemDetails`, with and without an `ErrorTypeRegistry` mapping | +| `UuidPerformanceTests.cs` | UUID validation — the char-scan rule against the legacy compiled regex, plus the schema-level paths | ## How to run From the repository root: ```bash -# Run the full benchmark suite -dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release +# Run the full benchmark suite (on .NET 10; pass -f net11.0 for .NET 11) +dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -f net10.0 -# Or via just +# Or via just (Release, net10.0 unless you pass `-f net11.0`) just perf-tests ``` diff --git a/src/src/Blazor/Blazor.csproj b/src/src/Blazor/Blazor.csproj index b849fb3c..8ddde59e 100644 --- a/src/src/Blazor/Blazor.csproj +++ b/src/src/Blazor/Blazor.csproj @@ -25,13 +25,11 @@ zod;validation;schema;blazor;webassembly;forms true - - $(NoWarn);NU5104 - 11.0.0-rc.3.26522.148 + 11.0.0 diff --git a/src/src/Examples.Blazor.Client/Examples.Blazor.Client.csproj b/src/src/Examples.Blazor.Client/Examples.Blazor.Client.csproj index 20a99a49..0d4692d2 100644 --- a/src/src/Examples.Blazor.Client/Examples.Blazor.Client.csproj +++ b/src/src/Examples.Blazor.Client/Examples.Blazor.Client.csproj @@ -30,9 +30,7 @@ Directory.Packages.props picks the Microsoft.Extensions line by $(TargetFramework), which a single-targeted project has not set yet when that file is evaluated; the WebAssembly packages need the 11.x line. --> - 11.0.0-rc.3.26522.148 - - $(NoWarn);NU5104 + 11.0.0 diff --git a/src/src/Examples.Blazor/Examples.Blazor.csproj b/src/src/Examples.Blazor/Examples.Blazor.csproj index bbb3f105..6b482afc 100644 --- a/src/src/Examples.Blazor/Examples.Blazor.csproj +++ b/src/src/Examples.Blazor/Examples.Blazor.csproj @@ -13,9 +13,7 @@ false false - - $(NoWarn);NU5104 - 11.0.0-rc.3.26522.148 + 11.0.0 diff --git a/src/src/Examples.Blazor/SampleApp.cs b/src/src/Examples.Blazor/SampleApp.cs index 25fec8b8..6c2d9259 100644 --- a/src/src/Examples.Blazor/SampleApp.cs +++ b/src/src/Examples.Blazor/SampleApp.cs @@ -131,8 +131,7 @@ static async (string email, ICustomerEmailRegistry registry, CancellationToken c static Uri? SelfBaseAddress(IServiceProvider provider) { var addresses = provider.GetRequiredService().Features.Get()?.Addresses; - var address = - addresses?.FirstOrDefault(static a => a.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + var address = addresses?.FirstOrDefault(static a => a.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) ?? addresses?.FirstOrDefault(); if (address is null) return null; diff --git a/src/src/Extensions.Validation/Extensions.Validation.csproj b/src/src/Extensions.Validation/Extensions.Validation.csproj index f6be2c0d..f2f1d261 100644 --- a/src/src/Extensions.Validation/Extensions.Validation.csproj +++ b/src/src/Extensions.Validation/Extensions.Validation.csproj @@ -24,8 +24,6 @@ zod;validation;schema;blazor;minimalapi true - - $(NoWarn);NU5104 diff --git a/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs b/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs index 57a0b3cb..df8fe2b8 100644 --- a/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs +++ b/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs @@ -85,8 +85,7 @@ public string Format(in ZodValidationError error, Type? declaringType, CultureIn try { - var localizer = - _localizerProvider(resourceType, _factory) + var localizer = _localizerProvider(resourceType, _factory) ?? throw new InvalidOperationException( $"The localizer provider returned null for type '{resourceType.FullName}'." ); diff --git a/src/src/Extensions.Validation/ZodCompositeValidatableTypeInfo.cs b/src/src/Extensions.Validation/ZodCompositeValidatableTypeInfo.cs index 325e44ab..e23fc211 100644 --- a/src/src/Extensions.Validation/ZodCompositeValidatableTypeInfo.cs +++ b/src/src/Extensions.Validation/ZodCompositeValidatableTypeInfo.cs @@ -25,7 +25,17 @@ public bool TryFindProperty( string propertyName, ValidationOptions validationOptions, [NotNullWhen(true)] out IValidatablePropertyInfo? validatablePropertyInfo - ) => zod.TryFindProperty(propertyName, validationOptions, out validatablePropertyInfo); + ) + { + if (zod.TryFindProperty(propertyName, validationOptions, out validatablePropertyInfo)) + return true; + + // The next resolver (usually the generated DataAnnotations one) owns the property when ZodSharp does + // not: it has no ZodSharp rule, or it is marked [SkipValidation] for ZodSharp. Field-level validation + // then runs that resolver's property info, exactly as the whole-type pass already runs it. + var next = owner.FindNextTypeInfo(zod.ValidatedType, validationOptions); + return next is not null && next.TryFindProperty(propertyName, validationOptions, out validatablePropertyInfo); + } public void Validate(object? value, ValidateContext context) { diff --git a/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs b/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs index aaa39921..e4f6fb23 100644 --- a/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs +++ b/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs @@ -147,6 +147,12 @@ public bool TryGetValidatableParameterInfo( return null; var lookup = Nullable.GetUnderlyingType(type) ?? type; + if ( + type.IsDefined(typeof(SkipValidationAttribute), inherit: true) + || lookup.IsDefined(typeof(SkipValidationAttribute), inherit: true) + ) + return null; + if (TryGetValidator(lookup, out var validator) || (lookup != type && TryGetValidator(type, out validator))) { ZodValidatableTypeInfo zod = new(validator, Options); diff --git a/src/src/Http/Sdk/README.md b/src/src/Http/Sdk/README.md index 905101e3..be7fc50d 100644 --- a/src/src/Http/Sdk/README.md +++ b/src/src/Http/Sdk/README.md @@ -136,7 +136,7 @@ rejection on the form's fields: `editContext.AddZodErrors(errors)`, or `editCont ## Documentation -- [HttpClient Integration](https://purview.dev/docs/zodsharp/http-integration/) +- [HttpClient Integration](https://purview.dev/docs/zodsharp/hosting/http-integration/) - [Homepage](https://purview.dev/projects/zodsharp/) - [Documentation](https://purview.dev/docs/zodsharp/) diff --git a/src/src/NewtonsoftJson/Json/ZodJsonConverter.cs b/src/src/NewtonsoftJson/Json/ZodJsonConverter.cs index 981a42f6..4db46d5f 100644 --- a/src/src/NewtonsoftJson/Json/ZodJsonConverter.cs +++ b/src/src/NewtonsoftJson/Json/ZodJsonConverter.cs @@ -29,8 +29,7 @@ JsonSerializer serializer T deserialized; try { - deserialized = - token.ToObject(validationSerializer) + deserialized = token.ToObject(validationSerializer) ?? throw new JsonSerializationException("Failed to deserialize JSON", path, line, position, null); } catch (ZodNewtonsoftJsonValidationException nested) diff --git a/src/src/NewtonsoftJson/Sdk/README.md b/src/src/NewtonsoftJson/Sdk/README.md index 51ce5f67..124b3129 100644 --- a/src/src/NewtonsoftJson/Sdk/README.md +++ b/src/src/NewtonsoftJson/Sdk/README.md @@ -76,7 +76,7 @@ the original offset, and adds the UTC converter. `UseZodSharpPreserveOffsetDateT per-type/property opt-out equivalent. `PreserveOffsetDateTimeOffsetConverter` is available to opt a type or property back out of a project-wide UTC -converter. See [JSON Schema Export](https://purview.dev/docs/zodsharp/json-schema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form). +converter. See [JSON Schema Export](https://purview.dev/docs/zodsharp/serialization/json-schema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form). ## Import from JSON Schema @@ -99,7 +99,7 @@ generates code at runtime. Every API in this package that reaches Newtonsoft ser `[RequiresDynamicCode]`, so a trimmed or AOT application gets an IL2026/IL3050 warning at the call site instead of a runtime failure. Use [Purview.ZodSharp.SystemTextJson](https://www.nuget.org/packages/Purview.ZodSharp.SystemTextJson) with a source-generated `JsonSerializerContext` in those applications. See -[Trimming and Native AOT](https://purview.dev/docs/zodsharp/trimming-and-aot/). +[Trimming and Native AOT](https://purview.dev/docs/zodsharp/quality/trimming-and-aot/). ## Documentation diff --git a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md index aef89cb6..0ef3e5c1 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md @@ -92,3 +92,4 @@ | ZODSGEN052 | ZodSharp.SourceGenerator | Warning | An attribute ErrorMessage uses a placeholder the attribute does not supply, or an unbalanced brace | | ZODSGEN053 | ZodSharp.SourceGenerator | Warning | [MinLength]/[MaxLength]/[Length] on a collection member in a trimmed or AOT project; use [MinCount]/[MaxCount]/[CountRange] | | ZODSGEN054 | ZodSharp.SourceGenerator | Error | A three-parameter (service-aware) custom validation method's second parameter is not IServiceProvider | +| ZODSGEN055 | ZodSharp.SourceGenerator | Warning | A union case type has no validator, so values of that case fail as invalid_union instead of validating | diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index 6555705f..d35839f5 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -8,3 +8,4 @@ ; ZODSGEN046 now recognizes the default ImmutableArray behavior option, including value-element ; collections. Its message describes option applicability; its category and severity are unchanged. +; The rule stays recorded under the release that introduced it (2.2.0), so it is not re-listed below. diff --git a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs index 75666145..ad957df4 100644 --- a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -105,6 +105,20 @@ static class DiagnosticLibrary isEnabledByDefault: true ); + /// + /// ZODSGEN055: a union case type has no validator, so a value of that case cannot be validated. The case is + /// omitted from the union's dispatch, and an active value of that type fails as invalid_union rather + /// than passing unvalidated. + /// + public static readonly DiagnosticDescriptor UnionCaseTypeWithoutSchema = new( + id: "ZODSGEN055", + title: "Union case type has no validator", + messageFormat: "Union '{0}' has case type '{1}' with no [ZodSchema] validator in this compilation or a referenced assembly, so values of that case cannot be validated", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + public static readonly DiagnosticDescriptor CustomValidationGenericMethod = new( id: "ZODSGEN012", title: "Generic custom validation method unsupported", @@ -401,14 +415,14 @@ static class DiagnosticLibrary ); /// - /// ZODSGEN041: a typed union whose case types are all reference types can use the native C# 15 - /// union returned by Z.NativeUnion on net11+, which is allocation-free and supports - /// exhaustive pattern matching. Value-type cases are excluded because they box. + /// ZODSGEN041: a typed union can use the native C# 15 union returned by Z.NativeUnion on net11+. + /// The native union implements the language's non-boxing access pattern, so neither reference-type nor + /// value-type cases allocate, and an exhaustive switch needs no discard arm. /// public static readonly DiagnosticDescriptor NativeUnionRecommended = new( id: "ZODSGEN041", title: "Consider a native C# 15 union", - messageFormat: "'{0}' has only reference-type cases ({1}); on .NET 11+ 'Z.NativeUnion' returns a native union that is allocation-free and supports exhaustive pattern matching", + messageFormat: "'{0}' with case types ({1}) can be a native union on .NET 11+; 'Z.NativeUnion' stores each case in a typed field and supports exhaustive pattern matching without boxing", category: Category, defaultSeverity: DiagnosticSeverity.Info, isEnabledByDefault: true diff --git a/src/src/SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs b/src/src/SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs index 3ad52c72..34a13bf3 100644 --- a/src/src/SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs +++ b/src/src/SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs @@ -96,8 +96,7 @@ CancellationToken cancellationToken // `Parameters` may be supplied as a constructor argument (the canonical form) or assigned // in an object initializer. - var parametersExpression = - FindParametersArgument(creation, context, cancellationToken)?.Expression + var parametersExpression = FindParametersArgument(creation, context, cancellationToken)?.Expression ?? FindParametersExpression(creation.Initializer); if (parametersExpression is null) return []; diff --git a/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs index 33c0cc3b..843bf8f7 100644 --- a/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs +++ b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs @@ -223,8 +223,7 @@ AssemblyDefaults assemblyDefaults // Precedence, highest first: the type-level [DateTimeOffsetBehavior], the assembly-level // ZodSharpSchemaDefaults, the MSBuild property, then PreserveOffset. - var dateTimeOffsetSerialization = - schema.DateTimeOffsetSerialization + var dateTimeOffsetSerialization = schema.DateTimeOffsetSerialization ?? assemblyDefaults.DateTimeOffsetSerialization ?? settings.DateTimeOffsetSerialization; @@ -301,8 +300,7 @@ AssemblyDefaults assemblyDefaults var property = propertyResult.Value; var resolved = property.AllowNulls ?? assemblyDefaults.AllowNulls ?? settings.AllowNulls; - var arrayBehavior = - property.DefaultImmutableArrayBehavior + var arrayBehavior = property.DefaultImmutableArrayBehavior ?? assemblyDefaults.DefaultImmutableArrayBehavior ?? settings.DefaultImmutableArrayBehavior; if (property.AllowNulls == resolved && property.DefaultImmutableArrayBehavior == arrayBehavior) diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 28cf94d9..c3007500 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -126,6 +126,17 @@ CancellationToken cancellationToken typeDateTimeOffsetSerialization ); + // A union target is not an object: its synthesized Value/HasValue/Tag members are not validatable + // members, and validation dispatches to each case type's schema instead. + EquatableArray unionCases; + bool isUnionTarget; + bool unionHasValue; + (properties, isUnionTarget, unionCases, unionHasValue) = ResolveUnionTargetShape( + symbol, + properties, + externalSchemas + ); + // Type-level rules ([ZodRule]-mapped attributes on the target itself) validate the whole value // rather than a property, which is what makes a scalar value object validatable as a unit. The // generator only needs the descriptors: ZodSchemaAnalyzer resolves the same attributes and @@ -170,6 +181,11 @@ CancellationToken cancellationToken var generateParseMethod = !zodSchemaAttribute.Exists || zodSchemaAttribute.GenerateParseMethod; var validateEnumValues = !zodSchemaAttribute.Exists || zodSchemaAttribute.ValidateEnumValues; var generateJsonSchema = !zodSchemaAttribute.Exists || zodSchemaAttribute.GenerateJsonSchema; + var (typeSyncValidationAttributes, typeAsyncValidationAttributes) = + ValidationInputsResolver.GetFallbackValidationAttributes( + ValidationInputsResolver.GetTypeValidationAttributes(symbol), + isTypeLevel: true + ); schemas.Add( new( @@ -200,21 +216,19 @@ CancellationToken cancellationToken ImplementsValidatableObject = implementsValidatableObject, ImplementsAsyncValidatableObject = implementsAsyncValidatableObject, HasOptionsValidatorConflict = ValidationInputsResolver.HasOptionsValidatorConflict(symbol), + IsUnionTarget = isUnionTarget, + UnionCases = unionCases, + UnionHasValue = unionHasValue, + TypeSyncValidationAttributes = typeSyncValidationAttributes, + TypeAsyncValidationAttributes = typeAsyncValidationAttributes, } ); foreach ( - var property in symbol - .GetMembers() - .OfType() + var property in GetValidatableProperties(symbol) .Where(m => - m.DeclaredAccessibility == Accessibility.Public - && !m.IsStatic - && !m.IsIndexer - && ( - TypeHelpers.HasDataAnnotationAttribute(GetValidationAttributes(m, compilation)) - || IsPropertyWithNestedSchema(m, externalSchemas) - ) + TypeHelpers.HasDataAnnotationAttribute(GetValidationAttributes(m, compilation)) + || IsPropertyWithNestedSchema(m, externalSchemas) ) ) { @@ -228,10 +242,33 @@ var property in symbol return GeneratorResult.Create(new SchemaSet(schemas.ToImmutable())); } + /// + /// Drops the member descriptors of a union target and resolves its case schemas. A union's synthesized + /// Value/HasValue/Tag members are not validatable members: validation dispatches to the + /// case types' schemas. + /// + static ( + EquatableArray> Properties, + bool IsUnionTarget, + EquatableArray UnionCases, + bool UnionHasValue + ) ResolveUnionTargetShape( + INamedTypeSymbol symbol, + EquatableArray> properties, + ExternalSchemaResolver externalSchemas + ) + { + if (!ValidationInputsResolver.RoslynFeatures.IsUnion(symbol)) + return (properties, false, new([]), false); + + var (cases, hasValue) = ValidationInputsResolver.ResolveUnionTarget(symbol, externalSchemas); + return (new([]), true, cases, hasValue); + } + /// /// Queues the case types of a type's C# 15 union members, so they get schemas like nested types do and - /// the member can dispatch to them (Change 7). A closed hierarchy's derived types need their own - /// [ZodSchema]. + /// the member can dispatch to them (Change 7). A union that is itself a schema target queues its own case + /// types. A closed hierarchy's derived types need their own [ZodSchema]. /// static void EnqueueUnionCaseTypes( INamedTypeSymbol symbol, @@ -239,16 +276,19 @@ static void EnqueueUnionCaseTypes( Queue<(INamedTypeSymbol Symbol, bool IsPrimary)> queue ) { - foreach (var member in symbol.GetMembers()) + if (ValidationInputsResolver.RoslynFeatures.IsUnion(symbol)) + { + foreach (var caseType in ValidationInputsResolver.GetUnionCaseTypes(symbol)) + { + if (externalSchemas.IsSchemaOwnedByThisCompilation(caseType)) + queue.Enqueue((caseType, false)); + } + } + + foreach (var unionProperty in GetValidatableProperties(symbol)) { if ( - member - is not IPropertySymbol - { - DeclaredAccessibility: Accessibility.Public, - IsStatic: false - } unionProperty - || TypeHelpers.UnwrapNullableType(unionProperty.Type) is not INamedTypeSymbol unionType + TypeHelpers.UnwrapNullableType(unionProperty.Type) is not INamedTypeSymbol unionType || !ValidationInputsResolver.RoslynFeatures.IsUnion(unionType) ) { @@ -341,12 +381,7 @@ static EquatableArray> GetZodProperties( DateTimeOffsetSerialization? typeDateTimeOffsetSerialization ) { - var properties = symbol - .GetMembers() - .OfType() - .Where(property => - property.DeclaredAccessibility == Accessibility.Public && !property.IsStatic && !property.IsIndexer - ) + var properties = GetValidatableProperties(symbol) .Select(property => GetValidatablePropertyDescriptor( property, @@ -361,6 +396,45 @@ static EquatableArray> GetZodProperties( return new(properties); } + /// + /// Gets every public instance property a generated schema validates: the properties declared on + /// first, then those inherited up its base chain. A property the derived type + /// overrides or hides is taken once, from the most derived declaration, because that is what + /// value.Member binds. Any declared member with the same name hides a base property, so the name is + /// recorded before its property shape is considered. + /// + /// The schema target type. + /// The properties in validation order, without duplicate or hidden members. + internal static ImmutableArray GetValidatableProperties(INamedTypeSymbol type) + { + var properties = ImmutableArray.CreateBuilder(); + HashSet seen = [with(StringComparer.Ordinal)]; + for (var current = type; current is not null; current = current.BaseType) + { + foreach (var member in current.GetMembers()) + { + if (!seen.Add(member.Name)) + continue; + + if ( + member + is IPropertySymbol + { + DeclaredAccessibility: Accessibility.Public, + IsStatic: false, + IsIndexer: false + } property + && !ValidationInputsResolver.IsSkipValidation(property) + ) + { + properties.Add(property); + } + } + } + + return properties.ToImmutable(); + } + /// /// Gets the validation attributes that apply to , merging in the attributes of /// the primary-constructor parameter a positional record property is synthesized from. See @@ -542,8 +616,8 @@ originalPropertyType is INamedTypeSymbol var nestedSchemaType = GetNestedSchemaTypeIdentity(property, validationKind, externalSchemas); var lengthAccessor = ClassifyLengthAccessor(originalPropertyType); var validationAttributes = GetValidationAttributes(property, compilation); - var dateTimeOffsetSerialization = - ReadDateTimeOffsetSerialization(validationAttributes) ?? typeDateTimeOffsetSerialization; + var dateTimeOffsetSerialization = ReadDateTimeOffsetSerialization(validationAttributes) + ?? typeDateTimeOffsetSerialization; var allowNulls = validationKind == PropertyValidationKind.Collection ? ResolveAllowNulls(originalPropertyType, validationAttributes, typeAllowNulls) @@ -677,12 +751,18 @@ rangeAttribute with var (dispatchKind, dispatchCases) = nestedSchemaType is null ? ValidationInputsResolver.ResolveDispatch(originalPropertyType, externalSchemas) : (DispatchKind.None, new EquatableArray([])); + var unionHasValue = + dispatchKind == DispatchKind.Union + && TypeHelpers.UnwrapNullableType(originalPropertyType) is INamedTypeSymbol unionMember + && ValidationInputsResolver.HasHasValue(unionMember); var (nestedIsModern, nestedRequiresAsync) = GetNestedSchemaFeatures(property, validationKind, externalSchemas); var compareViaCompareTo = validationKind == PropertyValidationKind.Comparable && originalPropertyType is INamedTypeSymbol namedPropertyType && IsVersion(namedPropertyType); + var (syncValidationAttributes, asyncValidationAttributes) = + ValidationInputsResolver.GetFallbackValidationAttributes(validationAttributes, isTypeLevel: false); return GeneratorResult.Create( new( @@ -742,9 +822,11 @@ rangeAttribute with originalPropertyType ), ExternalNestedSchemaRequiresAsync = nestedRequiresAsync, - AsyncValidationAttributes = ValidationInputsResolver.GetAsyncValidationAttributes(validationAttributes), + SyncValidationAttributes = syncValidationAttributes, + AsyncValidationAttributes = asyncValidationAttributes, DispatchKind = dispatchKind, DispatchCases = dispatchCases, + UnionHasValue = unionHasValue, }, diagnostics.ToImmutable() ); @@ -1338,7 +1420,26 @@ static LengthAccessor ClassifyLengthAccessor(ITypeSymbol propertyType) static string GetDisplayName(ImmutableArray attributes, string propertyName) { var display = DisplayAttributeData.FromAttributeData(attributes); - return display.Exists && !string.IsNullOrEmpty(display.Name) ? display.Name! : propertyName; + if (display.Exists && !string.IsNullOrEmpty(display.Name)) + return display.Name!; + + // .NET 11's Blazor DisplayName component also supports the older [DisplayName] attribute; use it when + // [Display] is absent, so messages and the component agree. + foreach (var attribute in attributes) + { + if ( + attribute.AttributeClass is { MetadataName: "DisplayNameAttribute" } attributeClass + && attributeClass.ContainingNamespace?.ToDisplayString() == "System.ComponentModel" + && attribute.ConstructorArguments.Length > 0 + && attribute.ConstructorArguments[0].Value is string name + && !string.IsNullOrEmpty(name) + ) + { + return name; + } + } + + return propertyName; } static Location GetAttributeLocation(AttributeData? attributeData) => @@ -1410,8 +1511,7 @@ ImmutableArray.Builder diagnostics && !IsValueSetSupportedType(originalPropertyType) ) { - var valuesAttribute = - FindAttribute(attributes, "AllowedValuesAttribute") + var valuesAttribute = FindAttribute(attributes, "AllowedValuesAttribute") ?? FindAttribute(attributes, "DeniedValuesAttribute"); if (valuesAttribute is not null) AddUnsupportedDataAnnotationsUsage(propertyName, valuesAttribute, diagnostics); @@ -1586,10 +1686,7 @@ ImmutableArray.Builder diagnostics if (!compareAttribute.Exists) return; - var otherProperty = property - .ContainingType?.GetMembers(compareAttribute.OtherProperty) - .OfType() - .FirstOrDefault(); + var otherProperty = FindCompareProperty(property.ContainingType, compareAttribute.OtherProperty); if (otherProperty is null) { diagnostics.Add( @@ -1604,6 +1701,34 @@ ImmutableArray.Builder diagnostics } } + /// + /// Finds the property a [Compare] attribute references. The referenced property may be inherited, + /// so the search follows the base chain from the declaring type; only a public property is usable from the + /// generated schema. + /// + static IPropertySymbol? FindCompareProperty(INamedTypeSymbol? type, string name) + { + for (var current = type; current is not null; current = current.BaseType) + { + foreach (var member in current.GetMembers(name)) + { + if ( + member is IPropertySymbol + { + DeclaredAccessibility: Accessibility.Public, + IsStatic: false, + IsIndexer: false + } property + ) + { + return property; + } + } + } + + return null; + } + static bool IsValueSetSupportedType(ITypeSymbol propertyType) { var unwrapped = TypeHelpers.UnwrapNullableType(propertyType); diff --git a/src/src/SourceGenerators/Helpers/ValidationInputsResolver.cs b/src/src/SourceGenerators/Helpers/ValidationInputsResolver.cs index d52d9a96..bc54965f 100644 --- a/src/src/SourceGenerators/Helpers/ValidationInputsResolver.cs +++ b/src/src/SourceGenerators/Helpers/ValidationInputsResolver.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Globalization; using System.Runtime.CompilerServices; using Microsoft.CodeAnalysis; using ZodSharp.SourceGenerators.Models; @@ -15,6 +16,8 @@ static class ValidationInputsResolver { public const string DataAnnotationsNamespace = "System.ComponentModel.DataAnnotations"; + public const string ValidationNamespace = "Microsoft.Extensions.Validation"; + const string AsyncValidationAttributeName = "AsyncValidationAttribute"; const string ValidatableObjectName = "IValidatableObject"; @@ -83,6 +86,168 @@ public static bool InheritsAsyncValidationAttribute(INamedTypeSymbol type) return false; } + /// + /// Gets the DataAnnotations validation attributes the generator has no dedicated rule for, so the generated + /// validator can run them through their own GetValidationResult/GetValidationResultAsync. + /// Mapped attributes (a Zod rule) and attributes already handled as a specific rule are excluded. + /// + /// The member's or type's effective attributes. + /// + /// Whether these are class-level attributes. A class-level mapped DataAnnotations attribute is not handled by + /// a generated type rule, so it is included here; only a [ZodRule]-mapped attribute is excluded. + /// + /// The construction expressions for the synchronous and asynchronous attributes. + public static (EquatableArray Sync, EquatableArray Async) GetFallbackValidationAttributes( + ImmutableArray attributes, + bool isTypeLevel + ) + { + ImmutableArray.Builder? sync = null; + ImmutableArray.Builder? async = null; + foreach (var attribute in attributes) + { + if (attribute.AttributeClass is not { } attributeClass) + continue; + + var isAsync = InheritsAsyncValidationAttribute(attributeClass); + if (!isAsync) + { + if (!InheritsValidationAttribute(attributeClass) || CustomRuleResolver.IsRuleMapped(attributeClass)) + continue; + + // A mapped DataAnnotations attribute is already emitted as a Zod rule. [DataType] itself has no + // validation semantics; its subclasses that are not mapped (EnumDataType, FileExtensions, a + // consumer's own) fall through to their own implementation below. + if ( + !isTypeLevel + && (IsMappedDataAnnotationAttribute(attributeClass) || IsDataTypeAttribute(attributeClass)) + ) + { + continue; + } + } + + if (!TryRenderAttributeConstruction(attribute, out var expression)) + continue; + + if (isAsync) + (async ??= ImmutableArray.CreateBuilder()).Add(expression); + else + (sync ??= ImmutableArray.CreateBuilder()).Add(expression); + } + + return (sync is null ? new([]) : new(sync.ToImmutable()), async is null ? new([]) : new(async.ToImmutable())); + } + + /// + /// Determines whether derives from the DataAnnotations + /// ValidationAttribute. + /// + public static bool InheritsValidationAttribute(INamedTypeSymbol type) + { + for (var current = type; current is not null; current = current.BaseType) + { + if ( + current.MetadataName == "ValidationAttribute" + && current.ContainingNamespace?.ToDisplayString() == DataAnnotationsNamespace + ) + { + return true; + } + } + + return false; + } + + /// + /// Determines whether is a DataAnnotations attribute the generator maps to a Zod rule. + /// + static bool IsMappedDataAnnotationAttribute(INamedTypeSymbol type) => + type.ContainingNamespace?.ToDisplayString() == DataAnnotationsNamespace + && type.MetadataName + is "RequiredAttribute" + or "CompareAttribute" + or "EmailAddressAttribute" + or "CreditCardAttribute" + or "PhoneAttribute" + or "UrlAttribute" + or "StringLengthAttribute" + or "MinLengthAttribute" + or "MaxLengthAttribute" + or "RegularExpressionAttribute" + or "Base64StringAttribute" + or "DeniedValuesAttribute" + or "AllowedValuesAttribute" + or "LengthAttribute" + or "RangeAttribute"; + + /// + /// Determines whether is DataTypeAttribute itself, which has no validation + /// semantics. Its subclasses are validated (mapped ones by a Zod rule, the rest through their own + /// implementation). + /// + static bool IsDataTypeAttribute(INamedTypeSymbol type) => + type.MetadataName == "DataTypeAttribute" + && type.ContainingNamespace?.ToDisplayString() == DataAnnotationsNamespace; + + /// + /// Determines whether is excluded from Microsoft.Extensions.Validation with + /// [SkipValidation]. + /// + public static bool IsSkipValidation(ISymbol member) => + member + .GetAttributes() + .Any(attribute => + attribute.AttributeClass is { MetadataName: "SkipValidationAttribute" } attributeClass + && attributeClass.ContainingNamespace?.ToDisplayString() == ValidationNamespace + ); + + /// + /// Collects the class-level DataAnnotations validation attributes that apply to , + /// following the base chain because ValidationAttribute is inherited. A non-multiple attribute is + /// taken from its most derived declaration; a multiple one (for example [CustomValidation]) keeps + /// every declaration. + /// + public static ImmutableArray GetTypeValidationAttributes(INamedTypeSymbol type) + { + var attributes = ImmutableArray.CreateBuilder(); + HashSet seen = [with(SymbolEqualityComparer.Default)]; + for (var current = type; current is not null; current = current.BaseType) + { + foreach (var attribute in current.GetAttributes()) + { + if (attribute.AttributeClass is not { } attributeClass || !InheritsValidationAttribute(attributeClass)) + continue; + + if (!AllowsMultiple(attributeClass) && !seen.Add(attributeClass)) + continue; + + attributes.Add(attribute); + } + } + + return attributes.ToImmutable(); + } + + static bool AllowsMultiple(INamedTypeSymbol attributeClass) + { + foreach (var attribute in attributeClass.GetAttributes()) + { + if (attribute.AttributeClass is not { MetadataName: "AttributeUsageAttribute" }) + continue; + + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == "AllowMultiple" && argument.Value.Value is true) + return true; + } + + return false; + } + + return false; + } + /// /// Resolves the localized display name expression for [Display(Name = ..., ResourceType = ...)]: the /// resource type's public static string property named by Name, read directly so the lookup is @@ -155,7 +320,12 @@ public static (DispatchKind Kind, EquatableArray Cases) ResolveDis if (RoslynFeatures.IsUnion(namedType)) { - var cases = ResolveCases(GetUnionCaseTypes(namedType), externalSchemas, requireSchemaAttribute: false); + var cases = ResolveCases( + GetUnionCaseTypes(namedType), + externalSchemas, + requireSchemaAttribute: false, + unionType: namedType + ); return cases.Count == 0 ? (DispatchKind.None, cases) : (DispatchKind.Union, cases); } @@ -164,7 +334,8 @@ public static (DispatchKind Kind, EquatableArray Cases) ResolveDis var cases = ResolveCases( RoslynFeatures.GetClosedDerivedTypes(namedType, cancellationToken), externalSchemas, - requireSchemaAttribute: true + requireSchemaAttribute: true, + closedType: namedType ); return cases.Count == 0 ? (DispatchKind.None, cases) : (DispatchKind.ClosedHierarchy, cases); } @@ -172,6 +343,97 @@ public static (DispatchKind Kind, EquatableArray Cases) ResolveDis return (DispatchKind.None, new([])); } + /// + /// Resolves the case types of a union that is itself a [ZodSchema] target, so its schema dispatches to + /// each case's schema. Also reports whether the union exposes HasValue (the non-boxing access pattern). + /// + /// The union type. + /// The resolver deciding schema ownership. + /// The dispatch cases, in declaration order, and whether the union has a HasValue property. + public static (EquatableArray Cases, bool HasValue) ResolveUnionTarget( + INamedTypeSymbol union, + ExternalSchemaResolver externalSchemas + ) + { + var cases = ResolveCases( + GetUnionCaseTypes(union), + externalSchemas, + requireSchemaAttribute: false, + unionType: union + ); + return (cases, HasHasValue(union)); + } + + /// + /// Gets the case types of a union that neither this compilation generates a schema for nor a referenced + /// assembly exposes a validator for, so the analyzer can warn that values of that case are not validated. + /// + /// The union type. + /// The resolver deciding schema ownership. + /// The unresolvable case types, in declaration order. + public static ImmutableArray GetUnionCasesWithoutSchema( + INamedTypeSymbol union, + ExternalSchemaResolver externalSchemas + ) + { + var builder = ImmutableArray.CreateBuilder(); + HashSet seen = []; + foreach (var caseType in GetUnionCaseTypes(union)) + { + if (!TypeIdentity.TryCreate(caseType, out var identity) || !seen.Add(identity)) + continue; + + if (externalSchemas.IsSchemaOwnedByThisCompilation(caseType)) + continue; + + if (externalSchemas.TryGetExistingSchema(caseType, out _)) + continue; + + builder.Add(caseType); + } + + return builder.ToImmutable(); + } + + /// + /// Whether exposes a TryGetValue overload that reads + /// without boxing (the language's non-boxing access pattern). + /// + public static bool HasTryGetValue(INamedTypeSymbol union, INamedTypeSymbol caseType) + { + foreach (var member in union.GetMembers("TryGetValue")) + { + if ( + member is IMethodSymbol { IsStatic: false, Parameters.Length: 1 } method + && method.Parameters[0].RefKind == RefKind.Out + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, caseType) + ) + { + return true; + } + } + + return false; + } + + /// Whether exposes a public HasValue property. + public static bool HasHasValue(INamedTypeSymbol union) + { + foreach (var member in union.GetMembers("HasValue")) + { + if ( + member is IPropertySymbol { IsStatic: false, DeclaredAccessibility: Accessibility.Public } property + && property.Type.SpecialType == SpecialType.System_Boolean + && property.GetMethod is not null + ) + { + return true; + } + } + + return false; + } + /// /// Gets the case types of a C# 15 union: the parameter types of its single-parameter case constructors. /// @@ -257,36 +519,145 @@ static bool HasOptionsValidatorAttribute(INamedTypeSymbol type) static EquatableArray ResolveCases( ImmutableArray caseTypes, ExternalSchemaResolver? externalSchemas, - bool requireSchemaAttribute + bool requireSchemaAttribute, + INamedTypeSymbol? unionType = null, + INamedTypeSymbol? closedType = null ) { var builder = ImmutableArray.CreateBuilder(); HashSet seen = []; + var (polymorphismEnabled, discriminatorPropertyName) = closedType is null + ? (false, string.Empty) + : ReadPolymorphism(closedType); foreach (var caseType in caseTypes) { if (!TypeIdentity.TryCreate(caseType, out var caseIdentity) || !seen.Add(caseIdentity)) continue; + string? discriminator = null; + var discriminatorIsNumber = false; + if (polymorphismEnabled) + { + var (configured, configuredIsNumber) = GetDiscriminatorValue(closedType!, caseType); + discriminator = configured ?? caseType.Name; + discriminatorIsNumber = configured is not null && configuredIsNumber; + } + + var nonBoxing = unionType is not null && HasTryGetValue(unionType, caseType); var owned = externalSchemas?.IsSchemaOwnedByThisCompilation(caseType) ?? IsInSource(caseType); if (owned) { - if (requireSchemaAttribute && !ZodSchemaAttributeData.FromAttributeData(caseType, out _).Exists) + var attributeData = ZodSchemaAttributeData.FromAttributeData(caseType, out _); + if (requireSchemaAttribute && !attributeData.Exists) continue; - builder.Add(new(caseIdentity, caseIdentity with { Name = $"{caseIdentity.Name}Schema" }, true)); + builder.Add( + new( + caseIdentity, + caseIdentity with + { + Name = $"{caseIdentity.Name}Schema", + }, + true, + nonBoxing, + !attributeData.Exists || attributeData.GenerateJsonSchema, + polymorphismEnabled ? discriminatorPropertyName : null, + discriminator, + discriminatorIsNumber + ) + ); continue; } if (externalSchemas is not null && externalSchemas.TryGetExistingSchema(caseType, out var existing)) { var (isModern, _) = externalSchemas.GetExistingSchemaFeatures(caseType); - builder.Add(new(caseIdentity, existing, isModern)); + builder.Add( + new( + caseIdentity, + existing, + isModern, + nonBoxing, + true, + polymorphismEnabled ? discriminatorPropertyName : null, + discriminator, + discriminatorIsNumber + ) + ); } } return new(builder.ToImmutable()); } + /// + /// Reads whether a closed hierarchy opts into System.Text.Json polymorphism and the discriminator + /// property name it uses. A hierarchy that does not opt in has no discriminator in its wire form; a + /// process-wide JsonSerializerOptions.InferClosedTypePolymorphism is not visible to the generator, + /// so the type-level attribute is what the exported schema follows. + /// + static (bool Enabled, string PropertyName) ReadPolymorphism(INamedTypeSymbol closedType) + { + var enabled = false; + var propertyName = "$type"; + foreach (var attribute in closedType.GetAttributes()) + { + var attributeType = attribute.AttributeClass?.ToDisplayString(); + if (attributeType == "System.Text.Json.Serialization.JsonPolymorphicAttribute") + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == "TypeDiscriminatorPropertyName" && argument.Value.Value is string configured) + propertyName = configured; + + if (argument.Key == "InferClosedTypePolymorphism" && argument.Value.Value is true) + enabled = true; + } + } + else if (attributeType == "System.Text.Json.Serialization.JsonDerivedTypeAttribute") + { + // Explicit registrations make the type polymorphic without inference. + enabled = true; + } + } + + return (enabled, propertyName); + } + + /// + /// Gets the discriminator System.Text.Json writes for , from an explicit + /// [JsonDerivedType] registration, and whether it is written as a JSON number. Inferred + /// registrations use the simple type name, which the caller supplies when this returns no value. + /// + static (string? Value, bool IsNumber) GetDiscriminatorValue(INamedTypeSymbol closedType, INamedTypeSymbol caseType) + { + foreach (var attribute in closedType.GetAttributes()) + { + if ( + attribute.AttributeClass?.ToDisplayString() != "System.Text.Json.Serialization.JsonDerivedTypeAttribute" + ) + continue; + + if (attribute.ConstructorArguments.Length < 2) + continue; + + if (attribute.ConstructorArguments[0].Value is not INamedTypeSymbol derived) + continue; + + if (!SymbolEqualityComparer.Default.Equals(derived, caseType)) + continue; + + return attribute.ConstructorArguments[1].Value switch + { + string value => (value, false), + int value => (value.ToString(CultureInfo.InvariantCulture), true), + _ => (null, false), + }; + } + + return (null, false); + } + static bool IsInSource(INamedTypeSymbol type) => type.Locations.Any(static location => location.IsInSource) && type.TypeKind is TypeKind.Class or TypeKind.Struct diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index c74ef56e..df66170f 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -43,7 +43,33 @@ enum DispatchKind /// The case (or derived) type. /// The generated schema of . /// Whether the schema exposes the depth-aware and member-resolution entry points. -readonly record struct DispatchCase(TypeIdentity CaseType, TypeIdentity SchemaType, bool IsModern); +/// +/// Whether the union exposes TryGetValue(out CaseType), so the case can be tested without boxing a +/// value-type case. Always false for a closed hierarchy. +/// +/// +/// Whether the case's schema emits BuildJsonSchema; a schema can opt out with +/// [ZodSchema(GenerateJsonSchema = false)], in which case the member exports an empty branch. +/// +/// +/// The JSON property that identifies the case for a polymorphic closed hierarchy, or +/// for a union (which carries no discriminator) or a hierarchy that has not opted into polymorphism. +/// +/// The discriminator value for this case, or . +/// +/// Whether System.Text.Json writes as a JSON number (an +/// [JsonDerivedType] registered with an integer discriminator) rather than a string. +/// +readonly record struct DispatchCase( + TypeIdentity CaseType, + TypeIdentity SchemaType, + bool IsModern, + bool NonBoxing = false, + bool ExportsJsonSchema = true, + string? DiscriminatorPropertyName = null, + string? DiscriminatorValue = null, + bool DiscriminatorValueIsNumber = false +); readonly record struct SchemaSet(EquatableArray Schemas); @@ -174,11 +200,38 @@ readonly record struct ZodSchemaDescriptor( /// Whether the target implements System.ComponentModel.DataAnnotations.IAsyncValidatableObject (.NET 11). public bool ImplementsAsyncValidatableObject { get; init; } + /// + /// The construction expressions of synchronous DataAnnotations validation attributes declared on the type + /// itself that the generator has no dedicated type rule for, in declaration order. + /// + public EquatableArray TypeSyncValidationAttributes { get; init; } = new([]); + + /// + /// The construction expressions of class-level AsyncValidationAttributes (.NET 11) declared on the + /// type itself, in declaration order. + /// + public EquatableArray TypeAsyncValidationAttributes { get; init; } = new([]); + /// /// Whether a hand-authored [OptionsValidator] class named {Type}Validator exists, so the generated /// options validator is skipped (SYSLIB1205). /// public bool HasOptionsValidatorConflict { get; init; } + + /// + /// Whether the [ZodSchema] target is itself a C# 15 union (the union keyword or a hand-written + /// [Union] type). Its validation dispatches to the schemas of its case types rather than to members. + /// + public bool IsUnionTarget { get; init; } + + /// The case types a union target dispatches to, in declaration order. + public EquatableArray UnionCases { get; init; } = new([]); + + /// + /// Whether the union target exposes a public HasValue property (the non-boxing access pattern), so an + /// empty union is detected without boxing. When false, an empty union is detected through Value is null. + /// + public bool UnionHasValue { get; init; } } /// The declared type of the property, with a nullable wrapper removed. @@ -303,11 +356,24 @@ readonly record struct ZodPropertyDescriptor( /// public EquatableArray AsyncValidationAttributes { get; init; } = new([]); + /// + /// The construction expressions of synchronous DataAnnotations validation attributes declared on the member + /// that the generator has no dedicated rule for (for example [CustomValidation], + /// [FileExtensions] or a consumer's own ValidationAttribute), in declaration order. + /// + public EquatableArray SyncValidationAttributes { get; init; } = new([]); + /// How the member dispatches to a case or derived type's schema, when it does. public DispatchKind DispatchKind { get; init; } /// The cases dispatched to when is not . public EquatableArray DispatchCases { get; init; } = new([]); + + /// + /// Whether a union-typed member exposes a public HasValue property, so an empty union is detected + /// without boxing. When false, it is detected through Value is null. + /// + public bool UnionHasValue { get; init; } } [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index f84d96b1..8ec5381c 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -49,6 +49,7 @@ public sealed class ZodSchemaAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.ValidatableTypeRedundant, DiagnosticLibrary.AsyncOnlyRulesWithSyncValidate, DiagnosticLibrary.ClosedHierarchyDerivedTypeWithoutSchema, + DiagnosticLibrary.UnionCaseTypeWithoutSchema, DiagnosticLibrary.OptionsValidatorConflict, DiagnosticLibrary.UnresolvableMessagePlaceholder, ]; @@ -137,7 +138,14 @@ ImmutableHashSet schemaReachableTypes ReportCollectionBehaviorDiagnostics(context, type, typeLocation); - ReportValidationInputDiagnostics(context, type, zodSchemaData, hasIValidateOptions, typeLocation); + ReportValidationInputDiagnostics( + context, + type, + zodSchemaData, + hasIValidateOptions, + typeLocation, + externalSchemas + ); if (!hasDataAnnotations) { @@ -190,21 +198,14 @@ ImmutableHashSet schemaReachableTypes } } - foreach (var property in type.GetMembers().OfType()) + foreach (var property in SourceGenLibrary.GetValidatableProperties(type)) { // A positional record property inherits the attributes of the primary-constructor parameter it is // synthesized from, so the analyzer resolves the same effective set the generator validates with. var validationAttributes = SourceGenLibrary.GetValidationAttributes(property, context.Compilation); - if ( - property.DeclaredAccessibility != Accessibility.Public - || property.IsStatic - || property.IsIndexer - || !TypeHelpers.HasDataAnnotationAttribute(validationAttributes) - ) - { + if (!TypeHelpers.HasDataAnnotationAttribute(validationAttributes)) continue; - } _ = ZodSchemaAttributeData.FromAttributeData(type, out var schemaAttributeData); var propertyResult = SourceGenLibrary.GetValidatablePropertyDescriptor( @@ -313,6 +314,48 @@ Location typeLocation } } + /// + /// Reports ZODSGEN055 for a [ZodSchema] union target and for any union-typed member whose case type has + /// no validator: the case is omitted from dispatch, so a value of that case fails as invalid_union + /// instead of validating. + /// + static void ReportUnionCaseDiagnostics( + SymbolAnalysisContext context, + INamedTypeSymbol type, + Location typeLocation, + ExternalSchemaResolver externalSchemas + ) + { + if (ValidationInputsResolver.RoslynFeatures.IsUnion(type)) + ReportUnionCases(context, type, typeLocation, externalSchemas); + + foreach (var property in SourceGenLibrary.GetValidatableProperties(type)) + { + if ( + TypeHelpers.UnwrapNullableType(property.Type) is INamedTypeSymbol memberType + && ValidationInputsResolver.RoslynFeatures.IsUnion(memberType) + ) + { + ReportUnionCases(context, memberType, GetMemberLocation(property), externalSchemas); + } + } + } + + static void ReportUnionCases( + SymbolAnalysisContext context, + INamedTypeSymbol union, + Location location, + ExternalSchemaResolver externalSchemas + ) + { + foreach (var caseType in ValidationInputsResolver.GetUnionCasesWithoutSchema(union, externalSchemas)) + { + context.ReportDiagnostic( + Diagnostic.Create(DiagnosticLibrary.UnionCaseTypeWithoutSchema, location, union.Name, caseType.Name) + ); + } + } + /// /// Reports the diagnostics for the validation inputs the generator reads besides DataAnnotations values: /// a redundant [ValidatableType] (ZODSGEN047), async-only rules behind a synchronous @@ -324,9 +367,12 @@ static void ReportValidationInputDiagnostics( INamedTypeSymbol type, ZodSchemaAttributeData zodSchemaData, bool hasIValidateOptions, - Location typeLocation + Location typeLocation, + ExternalSchemaResolver externalSchemas ) { + ReportUnionCaseDiagnostics(context, type, typeLocation, externalSchemas); + foreach (var attribute in type.GetAttributes()) { if ( @@ -334,8 +380,7 @@ Location typeLocation && attributeClass.ContainingNamespace?.ToDisplayString() == "Microsoft.Extensions.Validation" ) { - var location = - attribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation() + var location = attribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation() ?? typeLocation; context.ReportDiagnostic( Diagnostic.Create(DiagnosticLibrary.ValidatableTypeRedundant, location, type.Name) @@ -347,11 +392,8 @@ Location typeLocation if (ValidationInputsResolver.GetValidatableObjectKinds(type).Async) asyncOnlyRules.Add("IAsyncValidatableObject"); - foreach (var property in type.GetMembers().OfType()) + foreach (var property in SourceGenLibrary.GetValidatableProperties(type)) { - if (property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer) - continue; - foreach (var attribute in SourceGenLibrary.GetValidationAttributes(property, context.Compilation)) { if ( @@ -492,11 +534,8 @@ static void ReportNestedSchemaExportDisabled( Location typeLocation ) { - foreach (var property in type.GetMembers().OfType()) + foreach (var property in SourceGenLibrary.GetValidatableProperties(type)) { - if (property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer) - continue; - if (!SourceGenLibrary.TryGetNestedSchemaType(property, externalSchemas, out var nested)) continue; @@ -544,11 +583,8 @@ ExternalSchemaResolver externalSchemas if (!reachable.Add(new TypeIdentity(symbol))) continue; - foreach (var property in symbol.GetMembers().OfType()) + foreach (var property in SourceGenLibrary.GetValidatableProperties(symbol)) { - if (property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer) - continue; - if (SourceGenLibrary.TryGetNestedSchemaType(property, externalSchemas, out var nested)) queue.Enqueue(nested); } @@ -570,11 +606,8 @@ static void ReportCollectionBehaviorDiagnostics( Location typeLocation ) { - foreach (var property in type.GetMembers().OfType()) + foreach (var property in SourceGenLibrary.GetValidatableProperties(type)) { - if (property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer) - continue; - var hasAttribute = SourceGenLibrary .GetValidationAttributes(property, context.Compilation) .Any(attribute => TypeLibrary.ZodSharp.CollectionBehaviorAttribute.Equals(attribute.AttributeClass)); @@ -672,8 +705,9 @@ string typeName } /// - /// Reports ZODSGEN041 when a typed union is constructed whose option types are all - /// reference types, so the allocation-free native union is a viable alternative on net11+. + /// Reports ZODSGEN041 when a typed union is constructed, so the native union (which implements the + /// non-boxing access pattern and therefore allocates for neither reference- nor value-type cases) is a + /// viable alternative on net11+. /// static void AnalyzeTypedUnionConstruction(OperationAnalysisContext context, INamedTypeSymbol zodTypedUnion) { @@ -688,7 +722,6 @@ static void AnalyzeTypedUnionConstruction(OperationAnalysisContext context, INam resultType is not INamedTypeSymbol named || named.TypeArguments.Length != 2 || !SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, zodTypedUnion) - || !named.TypeArguments.All(static argument => argument.IsReferenceType) ) { return; diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 22c2dab4..10a91008 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -282,7 +282,11 @@ CancellationToken cancellationToken .AsTypeReference(); var cancellationTokenReference = TypeLibraryGenerator.CancellationToken.AsTypeReference(); TypeReference zodPathReference = new(new TypeIdentity("ZodPath", TypeLibraryGenerator.ZodSharpCoreNamespace)); - var errorsType = "global::System.Collections.Immutable.ImmutableArray"; + var errorsIdentity = PurviewTypeLibrary.System.Collections.Immutable.ImmutableArray.MakeGeneric( + TypeLibrary.ZodSharp.Core.ValidationError + ); + var errorsType = errorsIdentity.AsTypeReference().RenderFullName; + var errorsTaskReference = TypeLibraryGenerator.ValueTask.MakeGeneric(errorsIdentity).AsTypeReference(); var baseType = TypeLibrary.ZodSharp.Core.IZodSchemaValidator.MakeGeneric(schema.TargetType).AsTypeReference(); var usesAdapterInstance = schema.CustomValidationMethod.Value.InvocationKind @@ -551,23 +555,101 @@ declaration with } body.NewLine(); - // An explicit interface implementation cannot carry an accessibility modifier, and the - // structured member APIs always emit one, so these stay raw text. -#pragma warning disable PSGFR19 // Prefer a structured CodeWriter statement API - body.Line( - $"global::System.Threading.Tasks.ValueTask<{errorsType}> global::ZodSharp.Core.IZodUntypedValidator.ValidateAsync(object? value, {ServiceProviderType}? services, {CancellationTokenType} cancellationToken) => {GeneratedValidation}.ToErrorsAsync({schemaName}.ValidateCoreAsync(Cast(value), services, 0, cancellationToken{instanceArgument}));" - ); - body.NewLine(); - body.Line( - $"global::System.Threading.Tasks.ValueTask<{errorsType}> global::ZodSharp.Core.IZodUntypedValidator.ValidateMemberAsync(object value, string memberName, {ServiceProviderType}? services, {CancellationTokenType} cancellationToken) => {GeneratedValidation}.ToErrorsAsync({schemaName}.ValidateMemberAsync(Cast(value), memberName, services, cancellationToken));" - ); - body.NewLine(); - body.Line( - $"bool global::ZodSharp.Core.IZodUntypedValidator.TryResolveMember(object value, {ZodPathType} path, out object? container, out string memberName) => {schemaName}.TryResolveMember(Cast(value), path, out container, out memberName);" - ); -#pragma warning restore PSGFR19 - body.NewLine(); + // An explicit interface implementation cannot carry an accessibility modifier. The member + // declaration APIs take that modifier from DefaultMethodAccessibility, so it is cleared for + // these three members: a null default omits it instead of emitting `public`. + var defaultMethodAccessibility = body.DefaultMethodAccessibility; + body.DefaultMethodAccessibility = null; + try + { + body.MethodExpression( + "global::ZodSharp.Core.IZodUntypedValidator.ValidateAsync", + errorsTaskReference, + accessibility: null, + expression => + expression.Write( + $"{GeneratedValidation}.ToErrorsAsync({schemaName}.ValidateCoreAsync(Cast(value), services, 0, cancellationToken{instanceArgument}))" + ), + declaration => + declaration with + { + IncludeGeneratedAttributes = false, + Parameters = + [ + new("value", new TypeIdentity(typeof(object)).AsTypeReference().Nullable(body)), + new( + "services", + new TypeIdentity(typeof(IServiceProvider)).AsTypeReference().Nullable(body) + ), + new("cancellationToken", cancellationTokenReference), + ], + } + ) + .NewLine(); + + body.MethodExpression( + "global::ZodSharp.Core.IZodUntypedValidator.ValidateMemberAsync", + errorsTaskReference, + accessibility: null, + expression => + expression.Write( + $"{GeneratedValidation}.ToErrorsAsync({schemaName}.ValidateMemberAsync(Cast(value), memberName, services, cancellationToken))" + ), + declaration => + declaration with + { + IncludeGeneratedAttributes = false, + Parameters = + [ + new("value", new TypeIdentity(typeof(object)).AsTypeReference()), + new("memberName", PurviewTypeLibrary.System.String), + new( + "services", + new TypeIdentity(typeof(IServiceProvider)).AsTypeReference().Nullable(body) + ), + new("cancellationToken", cancellationTokenReference), + ], + } + ) + .NewLine(); + + body.MethodExpression( + "global::ZodSharp.Core.IZodUntypedValidator.TryResolveMember", + PurviewTypeLibrary.System.Boolean, + accessibility: null, + expression => + expression.Write( + $"{schemaName}.TryResolveMember(Cast(value), path, out container, out memberName)" + ), + declaration => + declaration with + { + IncludeGeneratedAttributes = false, + Parameters = + [ + new("value", new TypeIdentity(typeof(object)).AsTypeReference()), + new("path", zodPathReference), + new( + "container", + new TypeIdentity(typeof(object)).AsTypeReference().Nullable(body) + ) + { + Modifier = ParameterModifier.Out, + }, + new("memberName", PurviewTypeLibrary.System.String.AsTypeReference()) + { + Modifier = ParameterModifier.Out, + }, + ], + } + ) + .NewLine(); + } + finally + { + body.DefaultMethodAccessibility = defaultMethodAccessibility; + } if (usesAdapterInstance) { diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs index 5f06d7e5..054d5758 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs @@ -7,19 +7,37 @@ partial class ZodSchemaGenerator { static bool RejectsDefaultArray(ZodPropertyDescriptor property) => property.IsImmutableArray - && property.DefaultImmutableArrayBehavior != Models.DefaultImmutableArrayBehavior.TreatAsEmpty; + && property.DefaultImmutableArrayBehavior != DefaultImmutableArrayBehavior.TreatAsEmpty; static string MissingValueCondition(ZodPropertyDescriptor property) { + if (property.DispatchKind == DispatchKind.Union) + return EmptyUnionCondition(property); + var value = $"value.{property.Name}"; if (!RejectsDefaultArray(property)) return $"{value} is null"; + // The collection is a non-nullable array or immutable array that rejects its default value, so the member is + // absent when the property is null or its value is the default array. return property.IsNullableValueType ? $"({value} is null || {value}.GetValueOrDefault().IsDefault)" : $"{value}.IsDefault"; } + /// + /// The condition that an optional union has no value: its non-boxing HasValue is false when the union + /// exposes the access pattern, otherwise its Value is null. A nullable union is absent when the + /// wrapper has no value or the wrapped union is empty. + /// + static string EmptyUnionCondition(ZodPropertyDescriptor property) + { + var value = property.IsNullableValueType ? $"value.{property.Name}.Value" : $"value.{property.Name}"; + var empty = property.UnionHasValue ? $"!{value}.HasValue" : $"{value}.Value is null"; + + return property.IsNullableValueType ? $"(!value.{property.Name}.HasValue || {empty})" : empty; + } + static string CollectionValueExpression(ZodPropertyDescriptor property) { var value = property.IsNullableValueType @@ -124,9 +142,7 @@ static void GenerateCollectionSizeValidations(CodeWriter writer, ZodPropertyDesc ); } -#pragma warning disable PSGFR23 // ElseIfScope is not yet available in Purview.SourceGeneratorFramework - using (writer.OpenBlockScope($"else if ({propertyLengthName} > {length.MaximumLength})")) -#pragma warning restore PSGFR23 + using (writer.ElseIfScope($"{propertyLengthName} > {length.MaximumLength}")) { WriteAddError( writer, diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs index dad1bd7f..789d56a9 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs @@ -77,13 +77,30 @@ CustomRuleDescriptor requiredRule arguments ); - if (requiredRule.GuardedScalarPropertyName is { Length: > 0 } scalarPropertyName) + if (property.DispatchKind == DispatchKind.Union) { - // The rule is closed over the scalar's unwrapped underlying value, so both an absent property and a + // A union is a struct, so the rule's own null test can never fail. An empty union is the absent + // value for the member, and the rule is reported against it directly (the same identity it would + // report for a present but rejected value). + WriteRuleFailure( + writer, + requiredRule, + ruleVariable, + identityVariable, + $"value.{propertyName}", + PathOf(property), + MemberOf(property), + GetFallbackOrigin(property), + invalidCondition: MissingValueCondition(property) + ); + return; + } + + if (requiredRule.GuardedScalarPropertyName is { Length: > 0 } scalarPropertyName) + { // The rule is closed over the scalar's unwrapped underlying value, so both an absent property and a // present scalar with an absent underlying value are missing. - var scalarExpression = property.IsNullableValueType - ? $"value.{propertyName}.Value" - : $"value.{propertyName}"; + var scalarExpression = + property.IsNullableValueType ? $"value.{propertyName}.Value" : $"value.{propertyName}"; WriteRuleFailure( writer, diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs index 1e17d4f8..9d225678 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs @@ -49,6 +49,12 @@ CancellationToken cancellationToken }, body => { + if (schema.IsUnionTarget) + { + EmitUnionJsonSchema(body, schema); + return; + } + if (schema.ScalarUnderlyingType is { } underlying) { EmitScalarJsonSchema(body, schema, underlying); @@ -79,6 +85,28 @@ CancellationToken cancellationToken writer.NewLine(); } + /// + /// Emits the JSON Schema for a union target: oneOf the case schemas, because exactly one case is + /// active. A case whose schema does not export JSON Schema is represented by an empty schema, which is the + /// same fallback the object exporter uses for a nested schema that cannot be referenced. + /// + static void EmitUnionJsonSchema(CodeWriter body, ZodSchemaDescriptor schema) + { + if (schema.UnionCases.Count == 0) + { + body.Return($"new {JsonSchemaDefinitionTypeName}()"); + return; + } + + var cases = string.Join( + ", ", + schema.UnionCases.Select(static unionCase => unionCase.SchemaType.StaticMember("BuildJsonSchema()")) + ); + body.Return( + $"new {JsonSchemaDefinitionTypeName} {{ OneOf = new global::System.Collections.Generic.List<{JsonSchemaDefinitionTypeName}> {{ {cases} }} }}" + ); + } + /// /// Emits the JSON Schema for a scalar value object: the underlying value's schema (a Guid scalar is a /// canonical Guid string, not Zod's RFC UUID format), with the scalar's own rules applied and a @@ -124,7 +152,10 @@ static void EmitJsonSchemaProperty(CodeWriter body, ZodPropertyDescriptor proper var isRequired = hasRequiredAttribute || !property.IsDeclaredNullable; var isNullable = property.IsDeclaredNullable && !hasRequiredAttribute && !hasRequiredRule; - body.Assignment("var", localName, BuildPropertySchemaExpression(property, schema)); + if (property.DispatchKind != DispatchKind.None) + EmitDispatchJsonSchema(body, localName, property); + else + body.Assignment("var", localName, BuildPropertySchemaExpression(property, schema)); EmitAttributeConstraints(body, property, localName); @@ -147,6 +178,55 @@ static void EmitJsonSchemaProperty(CodeWriter body, ZodPropertyDescriptor proper body.MethodCallOn("schema.Required", "Add", nameLiteral); } + /// + /// Emits the JSON Schema of a member that dispatches to a case or derived type's schema: oneOf + /// the case schemas, because exactly one case is active. A polymorphic closed hierarchy also constrains + /// the $type discriminator each branch carries, so the schema describes the wire form System.Text.Json + /// writes when the hierarchy opts into inferred polymorphism. + /// + static void EmitDispatchJsonSchema(CodeWriter body, string localName, ZodPropertyDescriptor property) + { + body.Assignment("var", localName, $"new {JsonSchemaDefinitionTypeName} {{ OneOf = new() }}"); + for (var i = 0; i < property.DispatchCases.Count; i++) + { + var dispatchCase = property.DispatchCases[i]; + var caseExpression = dispatchCase.ExportsJsonSchema + ? $"{dispatchCase.SchemaType.AsTypeReference().RenderFullName}.BuildJsonSchema()" + : $"new {JsonSchemaDefinitionTypeName}()"; + + if ( + dispatchCase.DiscriminatorPropertyName is not { } discriminatorName + || dispatchCase.DiscriminatorValue is null + ) + { + body.MethodCallOn($"{localName}.OneOf", "Add", caseExpression); + continue; + } + + var caseName = $"{localName}Case{i}"; + var nameLiteral = discriminatorName.StringLiteral(); + var valueExpression = dispatchCase.DiscriminatorValueIsNumber + ? dispatchCase.DiscriminatorValue + : dispatchCase.DiscriminatorValue.StringLiteral(); + body.Assignment("var", caseName, caseExpression); + body.IfBlock($"{caseName}.Properties is null", block => + block.Assignment( + $"{caseName}.Properties", + $"new global::System.Collections.Generic.Dictionary(global::System.StringComparer.Ordinal)" + ) + ); + body.IfBlock($"{caseName}.Required is null", block => + block.Assignment($"{caseName}.Required", "new global::System.Collections.Generic.List()") + ); + body.Assignment( + $"{caseName}.Properties[{nameLiteral}]", + $"new {JsonSchemaDefinitionTypeName} {{ Enum = new() {{ {valueExpression} }} }}" + ); + body.MethodCallOn($"{caseName}.Required", "Add", nameLiteral); + body.MethodCallOn($"{localName}.OneOf", "Add", caseName); + } + } + /// /// Builds the C# expression for a property's base schema (its JSON type, nested schema, enum values, or /// array items), before any rule or attribute constraint is applied. diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.OutputContext.cs b/src/src/SourceGenerators/ZodSchemaGenerator.OutputContext.cs index 92c2986b..14bb9915 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.OutputContext.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.OutputContext.cs @@ -70,6 +70,18 @@ public bool RequiresAsync(TypeIdentity schemaType) } } + if (!requiresAsync && schema.IsUnionTarget) + { + foreach (var unionCase in schema.UnionCases) + { + if (unionCase.IsModern && RequiresAsync(unionCase.SchemaType)) + { + requiresAsync = true; + break; + } + } + } + _visiting.Remove(schemaType); // Only a top-level answer is cached: an answer computed while another schema is still being visited @@ -101,6 +113,8 @@ bool MemberRequiresAsync(ZodPropertyDescriptor property) => property.AsyncValidationAttributes.Count > 0 || NestedRequiresAsync(property); static bool HasOwnAsyncRules(ZodSchemaDescriptor schema) => - schema.CustomValidationMethod.Value.HasCustomValidation || schema.ImplementsAsyncValidatableObject; + schema.CustomValidationMethod.Value.HasCustomValidation + || schema.ImplementsAsyncValidatableObject + || schema.TypeAsyncValidationAttributes.Count > 0; } } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs index 61258c1a..f84cb186 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs @@ -152,29 +152,51 @@ declaration with if (schema.TargetCanBeNull) writer.IfBlock("value is null", body => body.Return("false")); - writer.IfBlock("offset >= path.Length", body => body.Return("true")); - writer.Assignment("var", "segment", "path[offset]"); - writer.IfBlock($"segment.Kind != {SegmentKindType}.Member", body => body.Return("false")); - writer.Assignment("memberName", "segment.Name!"); - writer.Assignment("var", "last", "offset + 1 >= path.Length"); + if (schema.IsUnionTarget) + { + // A union target owns no members of its own: the path is relative to the active case, whose + // schema resolves it (so a Blazor field or an API container is the case object, not the union). + for (var i = 0; i < schema.UnionCases.Count; i++) + { + var unionCase = schema.UnionCases[i]; + var caseName = CodeGenHelpers.GetLocalIdentifier(unionCase.CaseType.Name, $"Case{i}"); + var condition = UnionCaseCondition(unionCase, "value", caseName); + using (i == 0 ? writer.IfBlockScope(condition) : writer.ElseIfScope(condition)) + { + writer.Return( + $"{unionCase.SchemaType.StaticMember("TryResolveMember")}({caseName}, path, offset, out container, out memberName)" + ); + } + } - if (walked.Count > 0) + writer.Return("false"); + } + else { - using (writer.OpenBlockScope("switch (memberName)")) + writer.IfBlock("offset >= path.Length", body => body.Return("true")); + writer.Assignment("var", "segment", "path[offset]"); + writer.IfBlock($"segment.Kind != {SegmentKindType}.Member", body => body.Return("false")); + writer.Assignment("memberName", "segment.Name!"); + writer.Assignment("var", "last", "offset + 1 >= path.Length"); + + if (walked.Count > 0) { - foreach (var property in walked) + using (writer.OpenBlockScope("switch (memberName)")) { - writer.Line($"case {property.Name.StringLiteral()}:"); - using (writer.OpenBlockScope()) + foreach (var property in walked) { - writer.IfBlock("last", body => body.Return("true")); - WriteResolveMember(writer, property); + writer.Line($"case {property.Name.StringLiteral()}:"); + using (writer.OpenBlockScope()) + { + writer.IfBlock("last", body => body.Return("true")); + WriteResolveMember(writer, property); + } } } } - } - writer.Return("last"); + writer.Return("last"); + } } writer.NewLine(); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs index 46cb9c7a..104582c4 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs @@ -149,6 +149,54 @@ CancellationToken cancellationToken } ); } + + for (var i = 0; i < property.SyncValidationAttributes.Count; i++) + { + writer.Field( + new FieldDeclarationOptions( + SyncValidationAttributeFieldName(property.Name, i), + new TypeIdentity("ValidationAttribute", "System.ComponentModel.DataAnnotations") + ) + { + Accessibility = TypeDeclarationAccessibility.Private, + IsStatic = true, + IsReadOnly = true, + Initializer = property.SyncValidationAttributes[i], + } + ); + } + } + + for (var i = 0; i < schema.TypeSyncValidationAttributes.Count; i++) + { + writer.Field( + new FieldDeclarationOptions( + TypeValidationAttributeFieldName(i), + new TypeIdentity("ValidationAttribute", "System.ComponentModel.DataAnnotations") + ) + { + Accessibility = TypeDeclarationAccessibility.Private, + IsStatic = true, + IsReadOnly = true, + Initializer = schema.TypeSyncValidationAttributes[i], + } + ); + } + + for (var i = 0; i < schema.TypeAsyncValidationAttributes.Count; i++) + { + writer.Field( + new FieldDeclarationOptions( + TypeAsyncValidationAttributeFieldName(i), + new TypeIdentity("AsyncValidationAttribute", "System.ComponentModel.DataAnnotations") + ) + { + Accessibility = TypeDeclarationAccessibility.Private, + IsStatic = true, + IsReadOnly = true, + Initializer = schema.TypeAsyncValidationAttributes[i], + } + ); } if ( @@ -169,6 +217,12 @@ CancellationToken cancellationToken writer.NewLine(); } + static string SyncValidationAttributeFieldName(string member, int index) => $"ValidationAttribute_{member}_{index}"; + + static string TypeValidationAttributeFieldName(int index) => $"TypeValidationAttribute_{index}"; + + static string TypeAsyncValidationAttributeFieldName(int index) => $"TypeAsyncValidationAttribute_{index}"; + static string GetDeclaringTypeName(ZodSchemaDescriptor schema) { var rendered = schema.TargetType.AsTypeReference().ToString(); @@ -200,6 +254,7 @@ static bool HasMemberRules(ZodPropertyDescriptor property, bool validateEnumValu || Exists(attributes.DeniedValues, static values => values.Exists) || Exists(attributes.Compare, static compare => compare.Exists) || property.CustomRules.Count > 0 + || property.SyncValidationAttributes.Count > 0 || property.AsyncValidationAttributes.Count > 0 || HasNestedValidation(property) ) @@ -275,10 +330,12 @@ static IEnumerable GetValidatedMembers(ZodSchemaDescripto /// Whether the synchronous Validate cannot run: the type has async-only rules (defect G23). static bool HasAsyncOnlyRules(ZodSchemaDescriptor schema) => schema.ImplementsAsyncValidatableObject + || schema.TypeAsyncValidationAttributes.Count > 0 || GetValidatedMembers(schema).Any(static property => property.AsyncValidationAttributes.Count > 0); static bool HasTypeValidation(ZodSchemaDescriptor schema) => schema.TypeRules.Count > 0 + || schema.TypeSyncValidationAttributes.Count > 0 || (schema.ImplementsValidatableObject && !schema.ImplementsAsyncValidatableObject) || schema.RefinementHook.Value.IsImplemented; @@ -365,11 +422,21 @@ CancellationToken cancellationToken writer.Assignment(ErrorsListReference(writer), "errors", "null"); - foreach (var property in GetValidatedMembers(schema)) - writer.MethodCall(CodeGenHelpers.GetMemberMethodName(property.Name), MemberArguments(property, "true")); + if (schema.IsUnionTarget) + { + GenerateUnionTargetDispatch(writer, schema, async: false, outputContext: null); + } + else + { + foreach (var property in GetValidatedMembers(schema)) + writer.MethodCall( + CodeGenHelpers.GetMemberMethodName(property.Name), + MemberArguments(property, "true") + ); - if (HasTypeValidation(schema)) - writer.MethodCall("ValidateType", "value", "ref errors", "services: null"); + if (HasTypeValidation(schema)) + writer.MethodCall("ValidateType", "value", "ref errors", "services: null"); + } writer.Return($"{GeneratedValidation}.ToResult(value, errors)"); } @@ -482,26 +549,35 @@ declaration with writer.MethodCall("cancellationToken.ThrowIfCancellationRequested"); writer.Assignment(ErrorsListReference(writer), "errors", "null"); - foreach (var property in GetValidatedMembers(schema)) + if (schema.IsUnionTarget) { - var nestedAsync = outputContext.NestedRequiresAsync(property); - writer.MethodCall( - CodeGenHelpers.GetMemberMethodName(property.Name), - MemberArguments(property, nestedAsync ? "false" : "true") - ); - - if (nestedAsync || property.AsyncValidationAttributes.Count > 0) + GenerateUnionTargetDispatch(writer, schema, async: true, outputContext); + } + else + { + foreach (var property in GetValidatedMembers(schema)) { - writer.Assignment( - "errors", - $"await {CodeGenHelpers.GetMemberAsyncMethodName(property.Name)}(value, errors, services, depth, {(nestedAsync ? "true" : "false")}, cancellationToken).ConfigureAwait(false)" + var nestedAsync = outputContext.NestedRequiresAsync(property); + writer.MethodCall( + CodeGenHelpers.GetMemberMethodName(property.Name), + MemberArguments(property, nestedAsync ? "false" : "true") ); + + if (nestedAsync || property.AsyncValidationAttributes.Count > 0) + { + writer.Assignment( + "errors", + $"await {CodeGenHelpers.GetMemberAsyncMethodName(property.Name)}(value, errors, services, depth, {(nestedAsync ? "true" : "false")}, cancellationToken).ConfigureAwait(false)" + ); + } } } if (HasTypeValidation(schema)) writer.MethodCall("ValidateType", "value", "ref errors", "services"); + WriteTypeAsyncValidationAttributes(writer, schema); + if (schema.ImplementsAsyncValidatableObject) WriteAsyncValidatableObject(writer, schema); @@ -612,6 +688,7 @@ static void GenerateValidateTypeMethod(CodeWriter writer, ZodSchemaDescriptor sc ) { GenerateTypeRuleValidations(writer, schema); + WriteTypeSyncValidationAttributes(writer, schema); if (schema.ImplementsValidatableObject && !schema.ImplementsAsyncValidatableObject) { @@ -642,6 +719,64 @@ static void GenerateValidateTypeMethod(CodeWriter writer, ZodSchemaDescriptor sc writer.NewLine(); } + /// + /// Emits the class-level synchronous DataAnnotations validation attributes the generator has no type rule + /// for (for example a class-level [CustomValidation] or a consumer's own ValidationAttribute). + /// + static void WriteTypeSyncValidationAttributes(CodeWriter writer, ZodSchemaDescriptor schema) + { + if (schema.TypeSyncValidationAttributes.Count == 0) + return; + + writer.Assignment( + "var", + "validationContext", + $"{GeneratedValidation}.CreateValidationContext(value, {schema.TargetType.Name.StringLiteral()}, services)" + ); + for (var i = 0; i < schema.TypeSyncValidationAttributes.Count; i++) + { + var resultName = $"TypeAttributeResult{i}"; + writer.Assignment( + "var", + resultName, + $"{TypeValidationAttributeFieldName(i)}.GetValidationResult(value, validationContext)" + ); + writer.Assignment( + "errors", + $"{GeneratedValidation}.WithDataAnnotationsResult(errors, {resultName}, EmptyPath, {TypeMemberFieldName})" + ); + } + } + + /// + /// Emits the class-level AsyncValidationAttributes, awaited after the synchronous type validation and + /// before IAsyncValidatableObject, in the platform's order. + /// + static void WriteTypeAsyncValidationAttributes(CodeWriter writer, ZodSchemaDescriptor schema) + { + if (schema.TypeAsyncValidationAttributes.Count == 0) + return; + + writer.Assignment( + "var", + "validationContext", + $"{GeneratedValidation}.CreateValidationContext(value, {schema.TargetType.Name.StringLiteral()}, services)" + ); + for (var i = 0; i < schema.TypeAsyncValidationAttributes.Count; i++) + { + var resultName = $"TypeAsyncAttributeResult{i}"; + writer.Assignment( + "var", + resultName, + $"await {TypeAsyncValidationAttributeFieldName(i)}.GetValidationResultAsync(value, validationContext, cancellationToken).ConfigureAwait(false)" + ); + writer.Assignment( + "errors", + $"{GeneratedValidation}.WithDataAnnotationsResult(errors, {resultName}, EmptyPath, {TypeMemberFieldName})" + ); + } + } + static void GenerateZodRefinement(ZodSchemaDescriptor schema, CodeWriter method) { var hook = schema.RefinementHook.Value; @@ -690,6 +825,11 @@ CancellationToken cancellationToken var asyncMembers = schema.CustomValidationMethod.Value.HasCustomValidation ? schema.CustomValidationMethod.Value.AsyncMembers : new([]); + var validatableMemberNames = members + .Select(static member => member.Name) + .Concat(asyncMembers) + .Distinct(StringComparer.Ordinal) + .ToArray(); foreach (var property in members) { @@ -702,14 +842,14 @@ CancellationToken cancellationToken } writer.XmlSummary( - "Gets whether a member has rules that ValidateMember runs. Matching is ordinal, on the C# member name." + "Gets whether a member has rules that ValidateMember or ValidateMemberAsync runs. Matching is ordinal, on the C# member name." ); writer.XmlParam("memberName", "The C# member name."); writer.XmlReturn("True when the member is individually validatable."); writer.Line( - members.Count == 0 + validatableMemberNames.Length == 0 ? "public static bool IsValidatableMember(string memberName) => false;" - : $"public static bool IsValidatableMember(string memberName) => memberName is {string.Join(" or ", members.Select(static member => member.Name.StringLiteral()))};" + : $"public static bool IsValidatableMember(string memberName) => memberName is {string.Join(" or ", validatableMemberNames.Select(static name => name.StringLiteral()))};" ); writer.NewLine(); @@ -935,9 +1075,12 @@ static void GeneratePropertyValidation(CodeWriter writer, ZodPropertyDescriptor // A rule that rejects an absent value (IRequiredRule) stands in for [Required]: it is emitted outside // the non-null guard, so a missing value fails instead of being skipped. var hasRequiredRule = TryGetRequiredRule(property, out var requiredRule); + CustomRuleDescriptor nonSentinelRule = default; + var nonSentinelOnUnion = + property.DispatchKind == DispatchKind.Union && TryGetNonSentinelRule(property, out nonSentinelRule); if ( - (property.CanBeNull || RejectsDefaultArray(property)) + (property.CanBeNull || RejectsDefaultArray(property) || property.DispatchKind == DispatchKind.Union) && attributes.Required.ShouldProcess && attributes.Required.Value.Exists ) @@ -976,7 +1119,16 @@ static void GeneratePropertyValidation(CodeWriter writer, ZodPropertyDescriptor block = writer.ElseScope(); GenerateValueSetValidations(writer, property, emitEnumRule); } - else if (!property.IsDeclaredNullable && (property.CanBeNull || RejectsDefaultArray(property))) + else if (nonSentinelOnUnion) + { + GenerateNonSentinelUnionValidation(writer, property, nonSentinelRule); + block = writer.ElseScope(); + GenerateValueSetValidations(writer, property, emitEnumRule); + } + else if ( + !property.IsDeclaredNullable + && (property.CanBeNull || RejectsDefaultArray(property) || property.DispatchKind == DispatchKind.Union) + ) { // A non-nullable reference type rejects null — the exported JSON Schema marks it required — so // the rules below run only for a present value. This keeps null acceptance consistent with the @@ -1000,7 +1152,7 @@ static void GeneratePropertyValidation(CodeWriter writer, ZodPropertyDescriptor GenerateValueSetValidations(writer, property, emitEnumRule); if (property.IsDeclaredNullable) block = writer.IfBlockScope( - RejectsDefaultArray(property) + RejectsDefaultArray(property) || property.DispatchKind == DispatchKind.Union ? $"!({MissingValueCondition(property)})" : $"value.{propertyName} is not null" ); @@ -1010,6 +1162,37 @@ static void GeneratePropertyValidation(CodeWriter writer, ZodPropertyDescriptor block?.Dispose(); GenerateCompareValidation(writer, property); + GenerateFallbackValidationAttributes(writer, property); + } + + /// + /// Emits the synchronous DataAnnotations validation attributes the generator has no dedicated rule for, so + /// a [CustomValidation], [FileExtensions], [EnumDataType] or a consumer's own + /// ValidationAttribute runs exactly as the platform's Validator would run it. + /// + static void GenerateFallbackValidationAttributes(CodeWriter writer, ZodPropertyDescriptor property) + { + if (property.SyncValidationAttributes.Count == 0) + return; + + writer.Assignment( + "var", + "validationContext", + $"{GeneratedValidation}.CreateValidationContext(value, {MemberOf(property)}.DisplayName, null, {property.Name.StringLiteral()})" + ); + for (var i = 0; i < property.SyncValidationAttributes.Count; i++) + { + var resultName = CodeGenHelpers.GetLocalIdentifier(property.Name, $"AttributeResult{i}"); + writer.Assignment( + "var", + resultName, + $"{SyncValidationAttributeFieldName(property.Name, i)}.GetValidationResult(value.{property.Name}, validationContext)" + ); + writer.Assignment( + "errors", + $"{GeneratedValidation}.WithDataAnnotationsResult(errors, {resultName}, {PathOf(property)}, {MemberOf(property)})" + ); + } } static string ForMemberMessage(string errorExpression, string memberExpression, MessageTemplate message) => @@ -1034,6 +1217,59 @@ or PropertyValidationKind.Enum _ => "global::ZodSharp.Core.ZodTypeNames.Unknown", }; + /// + /// Finds a NonSentinelRule<T> among a union member's custom rules. An empty union is the default + /// value of a union, so the rule must reject it explicitly: the rule itself cannot see an empty union + /// through SentinelValues<T>. + /// + static bool TryGetNonSentinelRule(ZodPropertyDescriptor property, out CustomRuleDescriptor sentinelRule) + { + foreach (var rule in property.CustomRules) + { + if ((rule.AdaptedFrom ?? rule.RuleType) is { Name: "NonSentinelRule", Namespace: "ZodSharp.Rules" }) + { + sentinelRule = rule; + return true; + } + } + + sentinelRule = default; + return false; + } + + /// + /// Emits the failure of a NonSentinelRule<T> bound to a union member when the union is empty, + /// using the rule's own error identity. The rest of the member's rules run only for a non-empty union, so + /// the rule is not evaluated a second time. + /// + static void GenerateNonSentinelUnionValidation( + CodeWriter writer, + ZodPropertyDescriptor property, + CustomRuleDescriptor sentinelRule + ) + { + var ruleVariable = CodeGenHelpers.GetLocalIdentifier(property.Name, "UnionSentinelRule"); + var identityVariable = EmitRuleInstance( + writer, + sentinelRule, + CodeGenHelpers.GetLocalIdentifier(property.Name, "UnionSentinelRuleInner"), + ruleVariable, + $"({string.Join(", ", sentinelRule.Arguments)})" + ); + + WriteRuleFailure( + writer, + sentinelRule, + ruleVariable, + identityVariable, + $"value.{property.Name}", + PathOf(property), + MemberOf(property), + GetFallbackOrigin(property), + invalidCondition: MissingValueCondition(property) + ); + } + /// /// Finds the rule that rejects an absent value (IRequiredRule) among the property's custom rules, /// when the property can be null. @@ -1045,7 +1281,7 @@ static bool TryGetRequiredRule(ZodPropertyDescriptor property, out CustomRuleDes { requiredRule = default; - if (!property.CanBeNull && !RejectsDefaultArray(property)) + if (!property.CanBeNull && !RejectsDefaultArray(property) && property.DispatchKind != DispatchKind.Union) return false; foreach (var rule in property.CustomRules) @@ -1198,10 +1434,23 @@ string resultName /// static void GenerateDispatchValidation(CodeWriter writer, ZodPropertyDescriptor property) { - var dispatched = DispatchValueExpression(property); using (writer.IfBlockScope("recurse")) { - using (writer.OpenBlockScope($"switch ({dispatched})")) + if (property.DispatchKind == DispatchKind.Union) + { + EmitUnionDispatchChain( + writer, + property.DispatchCases, + UnionAccessExpression(property), + PathOf(property), + MemberOf(property), + async: false, + outputContext: null + ); + return; + } + + using (writer.OpenBlockScope($"switch ({DispatchValueExpression(property)})")) { for (var i = 0; i < property.DispatchCases.Count; i++) { @@ -1229,6 +1478,148 @@ static void GenerateDispatchValidation(CodeWriter writer, ZodPropertyDescriptor } } + /// + /// Emits the case dispatch for a schema whose target is itself a union (the union keyword or a + /// hand-written [Union] type): the active case is validated by that case type's schema, and an empty + /// or unmatched union reports invalid_union. + /// + static void GenerateUnionTargetDispatch( + CodeWriter writer, + ZodSchemaDescriptor schema, + bool async, + SchemaGenerationOutputContext? outputContext + ) + { + if (schema.UnionCases.Count == 0) + { + EmitUnionEmptyError(writer, schema.UnionCases, "EmptyPath", TypeMemberFieldName); + return; + } + + EmitUnionDispatchChain( + writer, + schema.UnionCases, + "value", + "EmptyPath", + TypeMemberFieldName, + async, + outputContext + ); + } + + /// + /// The expression that reads the union a member holds: a nullable value type is unwrapped first, so the + /// Value/TryGetValue members are reachable. + /// + static string UnionAccessExpression(ZodPropertyDescriptor property) => + property.IsNullableValueType ? $"value.{property.Name}.Value" : $"value.{property.Name}"; + + /// + /// The condition that matches a union's active case: TryGetValue when the union implements the + /// non-boxing access pattern, otherwise a type test on Value. + /// + static string UnionCaseCondition(DispatchCase unionCase, string accessExpression, string caseName) => + unionCase.NonBoxing + ? $"{accessExpression}.TryGetValue(out {unionCase.CaseType.AsTypeReference()} {caseName})" + : $"{accessExpression}.Value is {unionCase.CaseType.AsTypeReference()} {caseName}"; + + /// + /// Emits an if/else if chain that dispatches a union's active case to that case's schema. + /// A case with the non-boxing access pattern is tested with TryGetValue; otherwise the case is + /// tested through Value. An empty or unmatched union reports invalid_union, so a union with a + /// case that has no validator fails closed rather than passing silently. + /// + static void EmitUnionDispatchChain( + CodeWriter writer, + EquatableArray cases, + string accessExpression, + string pathExpression, + string memberExpression, + bool async, + SchemaGenerationOutputContext? outputContext + ) + { + for (var i = 0; i < cases.Count; i++) + { + var unionCase = cases[i]; + var caseName = CodeGenHelpers.GetLocalIdentifier(unionCase.CaseType.Name, $"Case{i}"); + var condition = UnionCaseCondition(unionCase, accessExpression, caseName); + + using (i == 0 ? writer.IfBlockScope(condition) : writer.ElseIfScope(condition)) + { + var resultName = CodeGenHelpers.GetLocalIdentifier(unionCase.CaseType.Name, $"Case{i}Result"); + var caseIsAsync = + async && unionCase.IsModern && outputContext!.AsyncIndex.RequiresAsync(unionCase.SchemaType); + + if (caseIsAsync) + { + writer.Assignment("var", resultName, NestedValidateAsyncCall(unionCase.SchemaType, caseName)); + writer.Assignment( + "errors", + $"{GeneratedValidation}.WithNested(errors, {resultName}, {pathExpression})" + ); + } + else + { + writer.Assignment( + "var", + resultName, + NestedValidateCall(unionCase.SchemaType, unionCase.IsModern, caseName) + ); + if (async) + { + writer.Assignment( + "errors", + $"{GeneratedValidation}.WithNested(errors, {resultName}, {pathExpression})" + ); + } + else + { + writer.MethodCall($"{GeneratedValidation}.AddNested", "ref errors", resultName, pathExpression); + } + } + } + } + + using (writer.ElseScope()) + EmitUnionEmptyError(writer, cases, pathExpression, memberExpression); + } + + /// + /// Emits the invalid_union error for a union whose contents are empty or match no case that has a + /// validator. Each option's error is an invalid_type for its case type, matching the runtime union + /// schema's failure. + /// + static void EmitUnionEmptyError( + CodeWriter writer, + EquatableArray cases, + string pathExpression, + string memberExpression + ) + { + const string immutableArrayType = "global::System.Collections.Immutable.ImmutableArray"; + const string validationErrorType = "global::ZodSharp.Core.ValidationError"; + const string zodIssuesType = "global::ZodSharp.Core.ZodIssues"; + + var unionErrors = + cases.Count == 0 + ? $"{immutableArrayType}<{immutableArrayType}<{validationErrorType}>>.Empty" + : $"{immutableArrayType}.Create({string.Join(", ", cases.Select(static unionCase => + $"{immutableArrayType}.Create({zodIssuesType}.InvalidType({unionCase.CaseType.Name.StringLiteral()}, null))" + ))})"; + var options = + cases.Count == 0 + ? "null" + : $"new string[] {{ {string.Join(", ", cases.Select(static unionCase => unionCase.CaseType.Name.StringLiteral()))} }}"; + var error = $"{zodIssuesType}.InvalidUnion({unionErrors}, {options}, {pathExpression})"; + + writer.MethodCall( + $"{GeneratedValidation}.Add", + "ref errors", + $"{GeneratedValidation}.ForMember({error}, {memberExpression})" + ); + } + /// /// The expression dispatched on: a union's active case (Value), or the member value itself for a /// closed hierarchy. @@ -1393,6 +1784,20 @@ static void WriteAsyncDispatch( ZodPropertyDescriptor property ) { + if (property.DispatchKind == DispatchKind.Union) + { + EmitUnionDispatchChain( + writer, + property.DispatchCases, + UnionAccessExpression(property), + PathOf(property), + MemberOf(property), + async: true, + outputContext + ); + return; + } + using (writer.OpenBlockScope($"switch ({DispatchValueExpression(property)})")) { for (var i = 0; i < property.DispatchCases.Count; i++) diff --git a/src/src/SystemTextJson/Json/JsonKinds.cs b/src/src/SystemTextJson/Json/JsonKinds.cs index 53825ee7..94a0e9c0 100644 --- a/src/src/SystemTextJson/Json/JsonKinds.cs +++ b/src/src/SystemTextJson/Json/JsonKinds.cs @@ -57,6 +57,7 @@ public static string OfValueKind(JsonValueKind kind) => /// /// The contract. /// The Zod type name, or . + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] public static string? OfContract(JsonTypeInfo typeInfo) => typeInfo.Kind switch { diff --git a/src/src/SystemTextJson/Json/Serialization/SystemTextJson/UtcDateTimeOffsetConverter.cs b/src/src/SystemTextJson/Json/Serialization/SystemTextJson/UtcDateTimeOffsetConverter.cs index 4364a12a..bcca3a24 100644 --- a/src/src/SystemTextJson/Json/Serialization/SystemTextJson/UtcDateTimeOffsetConverter.cs +++ b/src/src/SystemTextJson/Json/Serialization/SystemTextJson/UtcDateTimeOffsetConverter.cs @@ -29,8 +29,7 @@ public sealed class UtcDateTimeOffsetConverter : JsonConverter /// public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var text = - reader.GetString() + var text = reader.GetString() ?? throw new JsonException("DateTimeOffset values must be a JSON string under the ZodSharp UTC contract."); if (!HasUtcSuffix(text)) diff --git a/src/src/SystemTextJson/Json/ZodJsonConverter.cs b/src/src/SystemTextJson/Json/ZodJsonConverter.cs index 0062665e..787f295b 100644 --- a/src/src/SystemTextJson/Json/ZodJsonConverter.cs +++ b/src/src/SystemTextJson/Json/ZodJsonConverter.cs @@ -37,8 +37,7 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial T deserialized; try { - deserialized = - JsonSerializer.Deserialize(document.RootElement, TypeInfoFor(options)) + deserialized = JsonSerializer.Deserialize(document.RootElement, TypeInfoFor(options)) ?? throw new JsonException("Failed to deserialize JSON"); } catch (ZodJsonValidationException nested) diff --git a/src/src/SystemTextJson/Sdk/README.md b/src/src/SystemTextJson/Sdk/README.md index 5f1e2470..0f2a77af 100644 --- a/src/src/SystemTextJson/Sdk/README.md +++ b/src/src/SystemTextJson/Sdk/README.md @@ -92,7 +92,7 @@ var options = new JsonSerializerOptions ``` `PreserveOffsetDateTimeOffsetConverter` is available to opt a type or property back out of a project-wide UTC -converter. See [JSON Schema Export](https://purview.dev/docs/zodsharp/json-schema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form). +converter. See [JSON Schema Export](https://purview.dev/docs/zodsharp/serialization/json-schema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form). ## Import from JSON Schema diff --git a/src/src/ZodSharp/CollectionBehaviorAttribute.cs b/src/src/ZodSharp/CollectionBehaviorAttribute.cs index 0426aef0..3e426207 100644 --- a/src/src/ZodSharp/CollectionBehaviorAttribute.cs +++ b/src/src/ZodSharp/CollectionBehaviorAttribute.cs @@ -39,7 +39,7 @@ public sealed class CollectionBehaviorAttribute : Attribute /// /// Overrides the assembly's /// and the ZodSharpDefaultImmutableArrayBehavior MSBuild property. Unset options defer to the - /// next level; the built-in default is . + /// next level; the built-in default is . /// public DefaultImmutableArrayBehavior DefaultImmutableArrayBehavior { get; set; } } diff --git a/src/src/ZodSharp/Core/ZodJsonExceptions.cs b/src/src/ZodSharp/Core/ZodJsonExceptions.cs index b513ac5a..4f36cf35 100644 --- a/src/src/ZodSharp/Core/ZodJsonExceptions.cs +++ b/src/src/ZodSharp/Core/ZodJsonExceptions.cs @@ -169,25 +169,20 @@ static ZodPathSegment Step(ref JsonTypeInfo? current, ZodPathSegment segment) var info = current!; current = null; +#if NET11_0_OR_GREATER + if (info.Kind == JsonTypeInfoKind.Union) + return StepUnion(ref current, info, segment); +#endif + if (info.Kind == JsonTypeInfoKind.Object && segment.Kind == ZodPathSegmentKind.Member) { - var comparison = info.Options.PropertyNameCaseInsensitive - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - foreach (var property in info.Properties) - { - if (!string.Equals(property.Name, segment.Name, comparison)) - continue; - - current = Resolve(info.Options, property.PropertyType); - // The contract's attribute provider is the property or field it binds (source-generated contracts - // record it too), so its name is the member name; a custom contract may not set it. - return ZodPathSegment.Member( - property.AttributeProvider is MemberInfo member ? member.Name : property.Name - ); - } + if (!TryFindProperty(info, segment.Name, out var property)) + return segment; - return segment; + current = Resolve(info.Options, property.PropertyType); + // The contract's attribute provider is the property or field it binds (source-generated contracts + // record it too), so its name is the member name; a custom contract may not set it. + return ZodPathSegment.Member(property.AttributeProvider is MemberInfo member ? member.Name : property.Name); } if (info.Kind == JsonTypeInfoKind.Enumerable && segment.Kind == ZodPathSegmentKind.Index) @@ -209,6 +204,131 @@ static ZodPathSegment Step(ref JsonTypeInfo? current, ZodPathSegment segment) return segment; } + /// + /// Maps one segment through a union's case contracts. A union carries no members of its own, so the + /// segment belongs to the active case: a member name is looked up in every object-shaped case, and an + /// index or key is resolved through the single collection or dictionary case. A segment several cases + /// describe with different member names is kept as its JSON name, because the active case is not known + /// from the path alone. + /// +#if NET11_0_OR_GREATER + static ZodPathSegment StepUnion(ref JsonTypeInfo? current, JsonTypeInfo union, ZodPathSegment segment) + { + var unionCases = union.UnionCases; + if (unionCases is null || unionCases.Count == 0) + return segment; + + if (segment.Kind == ZodPathSegmentKind.Index) + { + JsonTypeInfo? element = null; + var matches = 0; + foreach (var unionCase in unionCases) + { + var caseInfo = Resolve(union.Options, unionCase.CaseType); + if (caseInfo?.Kind != JsonTypeInfoKind.Enumerable) + continue; + + element = Resolve(union.Options, caseInfo.ElementType); + matches++; + } + + if (matches == 1) + current = element; + + return segment; + } + + if (segment.Kind != ZodPathSegmentKind.Member) + return segment; + + string? name = null; + Type? type = null; + var matchedObject = false; + var sameName = true; + var sameType = true; + + foreach (var unionCase in unionCases) + { + var caseInfo = Resolve(union.Options, unionCase.CaseType); + if (caseInfo?.Kind != JsonTypeInfoKind.Object || !TryFindProperty(caseInfo, segment.Name, out var property)) + continue; + + var memberName = property.AttributeProvider is MemberInfo member ? member.Name : property.Name; + if (!matchedObject) + { + name = memberName; + type = property.PropertyType; + } + else + { + sameName &= string.Equals(memberName, name, StringComparison.Ordinal); + sameType &= type == property.PropertyType; + } + + matchedObject = true; + } + + if (!matchedObject) + return StepUnionDictionary(ref current, union, unionCases, segment); + + if (!sameName) + return segment; + + // Every case that describes the segment names it the same; continue into its type only when the + // cases agree on that type, otherwise the next segment has no single contract to follow. + current = sameType ? Resolve(union.Options, type) : null; + return ZodPathSegment.Member(name!); + } + + /// + /// Maps an object member through a union whose only describing case is a dictionary: the member is one + /// of the dictionary's keys. + /// + static ZodPathSegment StepUnionDictionary( + ref JsonTypeInfo? current, + JsonTypeInfo union, + IList unionCases, + ZodPathSegment segment + ) + { + JsonTypeInfo? element = null; + var matches = 0; + foreach (var unionCase in unionCases) + { + var caseInfo = Resolve(union.Options, unionCase.CaseType); + if (caseInfo?.Kind != JsonTypeInfoKind.Dictionary) + continue; + + element = Resolve(union.Options, caseInfo.ElementType); + matches++; + } + + if (matches != 1) + return segment; + + current = element; + return ZodPathSegment.Key(segment.Name ?? string.Empty); + } +#endif + + static bool TryFindProperty(JsonTypeInfo info, string? jsonName, out JsonPropertyInfo property) + { + var comparison = info.Options.PropertyNameCaseInsensitive + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + foreach (var candidate in info.Properties) + { + if (!string.Equals(candidate.Name, jsonName, comparison)) + continue; + + property = candidate; + return true; + } + + property = null!; + return false; + } + static JsonTypeInfo? Resolve(JsonSerializerOptions options, Type? type) { if (type is null) @@ -247,6 +367,9 @@ static bool IsAtOwnMember(in ValidationError error) if (path.IsRoot) return true; + // The error is at the value object's own member when it has one member segment, and the error's display name + // is that member's name. A deeper error, or one with its own display name, keeps it. The display name is the one the serializer shows in a JsonException, which + // is the same as the one ZodSharp generates for a scalar value object's error. return path.Length == 1 && path[0].Kind == ZodPathSegmentKind.Member && error.Parameters is { } parameters diff --git a/src/src/ZodSharp/Rules/NonSentinelRule.cs b/src/src/ZodSharp/Rules/NonSentinelRule.cs index cab02583..b54a76ad 100644 --- a/src/src/ZodSharp/Rules/NonSentinelRule.cs +++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs @@ -304,35 +304,65 @@ static class SentinelValues public static bool IsSentinel(in T value) { if (typeof(T) == typeof(Guid)) - return Unsafe.As(ref Unsafe.AsRef(in value)) == Guid.Empty; + { + unsafe + { + return Unsafe.As(ref Unsafe.AsRef(in value)) == Guid.Empty; + } + } if (typeof(T) == typeof(DateTime)) { - var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + DateTime typed; + unsafe + { + typed = Unsafe.As(ref Unsafe.AsRef(in value)); + } + return typed == DateTime.MinValue || typed == DateTime.MaxValue; } if (typeof(T) == typeof(DateTimeOffset)) { - var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + DateTimeOffset typed; + unsafe + { + typed = Unsafe.As(ref Unsafe.AsRef(in value)); + } + return typed == DateTimeOffset.MinValue || typed == DateTimeOffset.MaxValue; } if (typeof(T) == typeof(DateOnly)) { - var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + DateOnly typed; + unsafe + { + typed = Unsafe.As(ref Unsafe.AsRef(in value)); + } + return typed == DateOnly.MinValue || typed == DateOnly.MaxValue; } if (typeof(T) == typeof(TimeOnly)) { - var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + TimeOnly typed; + unsafe + { + typed = Unsafe.As(ref Unsafe.AsRef(in value)); + } + return typed == TimeOnly.MinValue || typed == TimeOnly.MaxValue; } if (typeof(T) == typeof(string)) { - var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + string? typed; + unsafe + { + typed = Unsafe.As(ref Unsafe.AsRef(in value)); + } + return string.IsNullOrWhiteSpace(typed); } @@ -349,11 +379,24 @@ public static bool IsSentinel(in T value) public static string Describe(in T value) { if (typeof(T) == typeof(Guid)) - return Unsafe.As(ref Unsafe.AsRef(in value)) == Guid.Empty ? "an empty GUID" : Raw(in value); + { + Guid typed; + unsafe + { + typed = Unsafe.As(ref Unsafe.AsRef(in value)); + } + + return typed == Guid.Empty ? "an empty GUID" : Raw(in value); + } if (typeof(T) == typeof(DateTime)) { - var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + DateTime typed; + unsafe + { + typed = Unsafe.As(ref Unsafe.AsRef(in value)); + } + return typed == DateTime.MinValue ? "DateTime.MinValue" : typed == DateTime.MaxValue ? "DateTime.MaxValue" : Raw(in value); @@ -361,7 +404,12 @@ public static string Describe(in T value) if (typeof(T) == typeof(DateTimeOffset)) { - var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + DateTimeOffset typed; + unsafe + { + typed = Unsafe.As(ref Unsafe.AsRef(in value)); + } + return typed == DateTimeOffset.MinValue ? "DateTimeOffset.MinValue" : typed == DateTimeOffset.MaxValue ? "DateTimeOffset.MaxValue" : Raw(in value); @@ -369,7 +417,12 @@ public static string Describe(in T value) if (typeof(T) == typeof(DateOnly)) { - var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + DateOnly typed; + unsafe + { + typed = Unsafe.As(ref Unsafe.AsRef(in value)); + } + return typed == DateOnly.MinValue ? "DateOnly.MinValue" : typed == DateOnly.MaxValue ? "DateOnly.MaxValue" : Raw(in value); @@ -377,7 +430,12 @@ public static string Describe(in T value) if (typeof(T) == typeof(TimeOnly)) { - var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + TimeOnly typed; + unsafe + { + typed = Unsafe.As(ref Unsafe.AsRef(in value)); + } + return typed == TimeOnly.MinValue ? "TimeOnly.MinValue" : typed == TimeOnly.MaxValue ? "TimeOnly.MaxValue" : Raw(in value); @@ -385,7 +443,12 @@ public static string Describe(in T value) if (typeof(T) == typeof(string)) { - var typed = Unsafe.As(ref Unsafe.AsRef(in value)); + string? typed; + unsafe + { + typed = Unsafe.As(ref Unsafe.AsRef(in value)); + } + return typed switch { null => "null", diff --git a/src/src/ZodSharp/Rules/RequiredRule.cs b/src/src/ZodSharp/Rules/RequiredRule.cs index 70788209..d3843a1e 100644 --- a/src/src/ZodSharp/Rules/RequiredRule.cs +++ b/src/src/ZodSharp/Rules/RequiredRule.cs @@ -77,7 +77,12 @@ public bool IsValid(in T value) { if (typeof(T) == typeof(string)) { - var text = Unsafe.As(ref Unsafe.AsRef(in value)); + string? text; + unsafe + { + text = Unsafe.As(ref Unsafe.AsRef(in value)); + } + if (text is null) return false; diff --git a/src/src/ZodSharp/Schemas/ZodTuple.cs b/src/src/ZodSharp/Schemas/ZodTuple.cs index a91027dc..7f93e14b 100644 --- a/src/src/ZodSharp/Schemas/ZodTuple.cs +++ b/src/src/ZodSharp/Schemas/ZodTuple.cs @@ -36,12 +36,9 @@ public class ZodTuple(IZodSchema schema1, IZodSchema sch return ValidationResult<(T1, T2)>.Failure(lengthError); List errors = []; - T1 v1; - T2 v2; - ZodTupleElements.Validate(schema1, value[0], 0, errors, out v1); - - ZodTupleElements.Validate(schema2, value[1], 1, errors, out v2); + ZodTupleElements.Validate(schema1, value[0], 0, errors, out var v1); + ZodTupleElements.Validate(schema2, value[1], 1, errors, out var v2); return errors.Count > 0 ? ValidationResult<(T1, T2)>.Failure(errors) @@ -78,15 +75,10 @@ public class ZodTuple(IZodSchema schema1, IZodSchema return ValidationResult<(T1, T2, T3)>.Failure(lengthError); List errors = []; - T1 v1; - T2 v2; - T3 v3; - - ZodTupleElements.Validate(schema1, value[0], 0, errors, out v1); - - ZodTupleElements.Validate(schema2, value[1], 1, errors, out v2); - ZodTupleElements.Validate(schema3, value[2], 2, errors, out v3); + ZodTupleElements.Validate(schema1, value[0], 0, errors, out var v1); + ZodTupleElements.Validate(schema2, value[1], 1, errors, out var v2); + ZodTupleElements.Validate(schema3, value[2], 2, errors, out var v3); return errors.Count > 0 ? ValidationResult<(T1, T2, T3)>.Failure(errors) diff --git a/src/src/ZodSharp/Schemas/ZodTypedNativeUnion.cs b/src/src/ZodSharp/Schemas/ZodTypedNativeUnion.cs index 8bde5b10..534a4aea 100644 --- a/src/src/ZodSharp/Schemas/ZodTypedNativeUnion.cs +++ b/src/src/ZodSharp/Schemas/ZodTypedNativeUnion.cs @@ -12,9 +12,11 @@ namespace ZodSharp.Schemas; /// The first option's type. /// The second option's type. /// -/// Only available when targeting net11.0 or later. Prefer this over -/// when both option types are reference types: the native union -/// is allocation-free and supports exhaustive pattern matching, whereas value-type cases box. +/// Only available when targeting net11.0 or later. The union is a hand-written +/// that implements the language's non-boxing access pattern, so prefer it +/// over for either reference- or value-type option types: neither allocates, +/// and both support exhaustive pattern matching. Use only when the +/// surface (Match, Switch, equality) is required. /// /// The first option schema. /// The second option schema. diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index e68bf3f8..83510db4 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -13,6 +13,7 @@ This package is the core library. It also ships the `[ZodSchema]` source generat - [Purview.ZodSharp.AspNetCore](https://www.nuget.org/packages/Purview.ZodSharp.AspNetCore) [![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.AspNetCore.svg)](https://www.nuget.org/packages/Purview.ZodSharp.AspNetCore) - [Purview.ZodSharp.Extensions.Validation](https://www.nuget.org/packages/Purview.ZodSharp.Extensions.Validation) (.NET 11) [![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.Extensions.Validation.svg)](https://www.nuget.org/packages/Purview.ZodSharp.Extensions.Validation) - [Purview.ZodSharp.AspNetCore.OpenApi](https://www.nuget.org/packages/Purview.ZodSharp.AspNetCore.OpenApi) (.NET 11) [![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.AspNetCore.OpenApi.svg)](https://www.nuget.org/packages/Purview.ZodSharp.AspNetCore.OpenApi) +- [Purview.ZodSharp.Blazor](https://www.nuget.org/packages/Purview.ZodSharp.Blazor) (.NET 11) [![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.Blazor.svg)](https://www.nuget.org/packages/Purview.ZodSharp.Blazor) ## Installation @@ -64,21 +65,35 @@ culture through `IZodMessageFormatter` (`DefaultZodMessageFormatter` is included When targeting `net11.0` or later, `Z.NativeUnion` returns a native C# 15 union instead of the hand-rolled `ZodSharp.Unions.Union`: ```csharp -var schema = Z.NativeUnion(Z.String().Min(1), Z.Object().Build()); -var result = schema.Validate("hello"); +var schema = Z.NativeUnion(Z.Number(), Z.Boolean()); +var result = schema.Validate(3d); if (result.IsSuccess) { - var length = result.Value switch + var described = result.Value switch { - string s => s.Length, - Dictionary o => o.Count, - _ => -1, + double number => number, + bool flag => flag ? 1d : 0d, + // no discard arm: every case type is handled }; } ``` -Use it when **both option types are reference types**: the native union is allocation-free and supports exhaustive pattern matching. Value-type cases box, so keep `Z.Union` when a case is a value type, or when you need `Tag`/`Match`/`Switch`/`TryGetValue`/equality. The analyzer reports `ZODSGEN041` and offers a code fix when a reference-type-only `Z.Union` can be switched to `Z.NativeUnion`. +The native union is declared by hand with `[Union]` and implements the language's **non-boxing access pattern**: +each case is stored in a typed field, and `TryGetValue`/`HasValue` let pattern matching read a value-type case without +boxing. Reference- and value-type cases are both allocation-free, and an exhaustive `switch` needs no discard arm. Use +the hand-rolled `Union` on .NET 10, or when you need `Match`/`Switch`/equality. The analyzer reports +`ZODSGEN041` and offers a code fix for any `Z.Union` on .NET 11+. + +`System.Text.Json` reads and writes it as the active case, with no wrapper or discriminator. The type carries +`[JsonUnion]`, so the `NativeUnionTypeClassifierFactory` resolves a string token under Web defaults (where a number +may also be read from a string) and two object-shaped cases without a converter registration. + +A union can itself be a `[ZodSchema]` target: `PetSchema.Validate(pet)` dispatches to the active case's schema, +matches a case with `TryGetValue` without boxing, and reports `invalid_union` for an empty union. A case type with no +validator is reported as `ZODSGEN055`, and `[Required]`/`[RequiredZod]`/`[NonSentinel]` treat an empty union as the +member's absent value. Asynchronous rules on a case run through `ValidateAsync` everywhere (ASP.NET Core, Blazor, +Microsoft.Extensions.Validation and options). ## Source generator @@ -217,7 +232,7 @@ public partial record struct AssetId } ``` -`[NotEmpty]` on a `Guid` property emits `NotEmptyRule`; on an `int` property it emits `NotEmptyRule`. See the [Custom Rules](https://purview.dev/docs/zodsharp/custom-rules/) page for the full precedence rules, and [Value Objects Integration](https://purview.dev/docs/zodsharp/value-objects-integration/) for the scalar value-object walkthrough and the error code / message definitions. +`[NotEmpty]` on a `Guid` property emits `NotEmptyRule`; on an `int` property it emits `NotEmptyRule`. See the [Custom Rules](https://purview.dev/docs/zodsharp/schemas/custom-rules/) page for the full precedence rules, and [Value Objects Integration](https://purview.dev/docs/zodsharp/schemas/value-objects-integration/) for the scalar value-object walkthrough and the error code / message definitions. ### One attribute for a primitive and a scalar value object @@ -321,7 +336,7 @@ public readonly partial record struct AssetId } ``` -The generated validator runs the rule against the value object (`NotEmptyRule`) and reports `Code`, `Message`, and `Origin` with an empty path. A rule written against the underlying value (for example `NonSentinelRule`) is instead adapted automatically when applied to a `[Scalar]` type, so one rule serves every scalar backed by the same primitive. A rule attribute on a type that gets no schema is ignored, and the analyzer warns (`ZODSGEN033`) rather than failing silently. See [Value Objects Integration](https://purview.dev/docs/zodsharp/value-objects-integration/) for the full `[Scalar]` walkthrough and the error code / message definitions. +The generated validator runs the rule against the value object (`NotEmptyRule`) and reports `Code`, `Message`, and `Origin` with an empty path. A rule written against the underlying value (for example `NonSentinelRule`) is instead adapted automatically when applied to a `[Scalar]` type, so one rule serves every scalar backed by the same primitive. A rule attribute on a type that gets no schema is ignored, and the analyzer warns (`ZODSGEN033`) rather than failing silently. See [Value Objects Integration](https://purview.dev/docs/zodsharp/schemas/value-objects-integration/) for the full `[Scalar]` walkthrough and the error code / message definitions. `Purview.ValueObjects` 1.0.1 (or later) adds an **automatic** scalar form where the value-object generator declares the underlying property: `[Scalar]` or `[Scalar(typeof(TValue))]` (for example `[Scalar]` / `[Scalar(typeof(Guid))]`). The property is invisible to ZodSharp, so the schema is generated from the attribute and supports **type-level** rules only (property-level DataAnnotations have no host — use the manual `[Scalar]` form when you need them). A nullable reference scalar is written `[Scalar(Nullable = true)]` (or `[Scalar(typeof(string), Nullable = true)]`) and round-trips JSON `null`; `[RequiredZod]` still rejects `null`, so use the shipped null-tolerant `[NullOrNonWhiteSpace]` (`NullOrNonWhiteSpaceRule`) to accept `null` while rejecting whitespace. A suppressed `Validate` method on a scalar schema is reported as `ZODSGEN044`. @@ -408,7 +423,7 @@ patterns. The `DateTimeOffset` pattern is offset-aware, so the default preserves `Utc` (or `[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)]`) and the JSON integration package's `UtcDateTimeOffsetConverter`. See the -[JSON Schema Export](https://purview.dev/docs/zodsharp/json-schema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form) +[JSON Schema Export](https://purview.dev/docs/zodsharp/serialization/json-schema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form) documentation. ## Documentation diff --git a/src/src/ZodSharp/Unions/IUnion.cs b/src/src/ZodSharp/Unions/IUnion.cs index 644ccec1..b1344863 100644 --- a/src/src/ZodSharp/Unions/IUnion.cs +++ b/src/src/ZodSharp/Unions/IUnion.cs @@ -5,26 +5,27 @@ namespace ZodSharp.Unions; /// /// /// -/// ZodSharp's (and higher-arity overloads) implement this -/// interface to provide a common, non-boxing discriminated-union surface that mirrors -/// the .NET 11 union keyword's IUnion contract. +/// ZodSharp's (and higher-arity overloads) implement this interface to provide a +/// common, non-boxing discriminated-union surface, and so does the net11.0-only +/// NativeUnion<T1, T2>. /// /// -/// On .NET 11 and later, the BCL defines System.Runtime.CompilerServices.IUnion; -/// this local marker is forward-compatible — consumer code written against -/// today will work identically when a net11.0 target is added. +/// On .NET 11 and later the BCL also defines System.Runtime.CompilerServices.IUnion; the native union +/// implements both, because the runtime's contract exposes only Value while ZodSharp's resolver also +/// needs to follow the active case without unboxing it. /// /// public interface IUnion { /// - /// Gets the boxed value of the union. Prefer the non-boxing + /// Gets the boxed value of the union, or when the union is empty. Prefer the non-boxing /// TryGetValue overloads in performance-critical paths. /// - object Value { get; } + object? Value { get; } /// - /// Gets the zero-based tag identifying which case of the union is active. + /// Gets the tag identifying which case of the union is active: 0 when the union is empty, then + /// 1, 2, … for the case types in declaration order. /// int Tag { get; } } diff --git a/src/src/ZodSharp/Unions/NativeUnion.cs b/src/src/ZodSharp/Unions/NativeUnion.cs index 41389dc5..e7f0a720 100644 --- a/src/src/ZodSharp/Unions/NativeUnion.cs +++ b/src/src/ZodSharp/Unions/NativeUnion.cs @@ -1,21 +1,145 @@ #if NET11_0_OR_GREATER #pragma warning disable CA1815 // Native unions do not synthesize equality; the schema result does not need it. +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; + namespace ZodSharp.Unions; /// -/// A native C# 15 union of two types, produced by Z.NativeUnion<T1, T2>. +/// A non-boxing native C# 15 union of two types, produced by Z.NativeUnion<T1, T2>. /// /// The first case type. /// The second case type. /// /// -/// Only available when targeting net11.0 or later, where the C# 15 union keyword and -/// exist. The compiler lowers this to a struct -/// with a single backing field: reference-type cases are allocation-free, while -/// value-type cases box. Prefer the hand-rolled when a case is a value -/// type, or when Tag/Match/Switch/equality are required. +/// Only available when targeting net11.0 or later, where the C# 15 +/// and exist. The union is +/// declared by hand rather than with the union keyword so it can implement the language's +/// non-boxing access pattern: and the +/// overloads let the compiler test a type pattern without boxing a value-type case, and the value is stored in +/// a typed field rather than a single field. +/// +/// +/// The compiler still treats this as a union type: it synthesizes the implicit conversions from +/// and , and an exhaustive switch over the case types +/// needs no discard arm. is only the boxing view the union contract requires; pattern +/// matching and never call it. /// /// -public readonly union NativeUnion(T1, T2); +[Union] +[JsonUnion(TypeClassifier = typeof(NativeUnionTypeClassifierFactory))] +public readonly struct NativeUnion : IUnion, System.Runtime.CompilerServices.IUnion +{ + /// No case is active (the default value, or a null reference/nullable case value). + const byte None = 0; + + /// The first case type is active. + const byte First = 1; + + /// The second case type is active. + const byte Second = 2; + + readonly T1? _value1; + readonly T2? _value2; + readonly byte _tag; + + /// + /// Whether a case can hold . Read before the null test so a + /// non-nullable value-type case never evaluates (and therefore never boxes) value is null. + /// + static class CaseTraits + { + public static readonly bool CanBeNull = + !typeof(T).IsValueType || Nullable.GetUnderlyingType(typeof(T)) is not null; + } + + /// + /// Creates a union holding a value. + /// + /// The value. A null reference or nullable value leaves the union empty. + public NativeUnion(T1 value) + { + if (CaseTraits.CanBeNull && value is null) + return; + + _value1 = value; + _tag = First; + } + + /// + /// Creates a union holding a value. + /// + /// The value. A null reference or nullable value leaves the union empty. + public NativeUnion(T2 value) + { + if (CaseTraits.CanBeNull && value is null) + return; + + _value2 = value; + _tag = Second; + } + + /// + /// Gets the zero-based tag identifying which case is active: 0 for an empty union, 1 for + /// and 2 for . + /// + public int Tag => _tag; + + /// + /// Gets the boxed value of the active case, or when the union is empty. Pattern + /// matching and are allocation-free; prefer them on hot paths. + /// + public object? Value => + _tag switch + { + First => _value1, + Second => _value2, + _ => null, + }; + + /// + /// Gets whether the union holds a non-null value. The compiler uses this for a null pattern, so the + /// union never has to touch to answer it. + /// + public bool HasValue => _tag != None; + + /// + /// Gets the value without boxing. + /// + /// The value when the first case is active. + /// when the union holds a . + public bool TryGetValue([NotNullWhen(true)] out T1? value) + { + if (_tag == First) + { + value = _value1!; + return true; + } + + value = default; + return false; + } + + /// + /// Gets the value without boxing. + /// + /// The value when the second case is active. + /// when the union holds a . + public bool TryGetValue([NotNullWhen(true)] out T2? value) + { + if (_tag == Second) + { + value = _value2!; + return true; + } + + value = default; + return false; + } + + /// Returns a string representation of the active value. + public override string ToString() => Value?.ToString() ?? ""; +} #pragma warning restore CA1815 #endif diff --git a/src/src/ZodSharp/Unions/NativeUnionTypeClassifierFactory.cs b/src/src/ZodSharp/Unions/NativeUnionTypeClassifierFactory.cs new file mode 100644 index 00000000..10d3be46 --- /dev/null +++ b/src/src/ZodSharp/Unions/NativeUnionTypeClassifierFactory.cs @@ -0,0 +1,224 @@ +#if NET11_0_OR_GREATER +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace ZodSharp.Unions; + +/// +/// Chooses the active case of a when System.Text.Json reads a JSON value +/// that more than one case could accept. +/// +/// +/// +/// The .NET 11 serializer classifies a union case by the JSON token's kind. That is enough for an +/// int | string union with the default serializer settings, but not for the two shapes an HTTP API +/// produces: a string token under (where a number can also be read +/// from a string) and two object-shaped cases. Applying this factory through +/// lets the serializer read payloads in +/// minimal APIs and other web hosts. +/// +/// +/// Classification is deterministic: the case whose natural JSON shape matches the token wins (a +/// case for a string token before a number read from a string); object-shaped cases use +/// the platform's structural classifier (root property names); otherwise each candidate's own contract is +/// tried. A payload that no case, or more than one object-shaped case, can claim is left to the serializer, +/// which reports it as a - the same behaviour as a compiler-declared union. +/// +/// +public sealed class NativeUnionTypeClassifierFactory : JsonTypeClassifierFactory +{ + /// + public override bool CanClassify(JsonTypeClassifierContext context) + { + ArgumentNullException.ThrowIfNull(context); + return context.Kind == JsonTypeClassifierKind.Union; + } + + /// + public override JsonTypeClassifier CreateJsonClassifier( + JsonTypeClassifierContext context, + JsonSerializerOptions options + ) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(options); + + var unionCases = context.UnionCases; + var caseTypes = new Type[unionCases.Count]; + for (var i = 0; i < unionCases.Count; i++) + caseTypes[i] = unionCases[i].CaseType; + + var structural = TryCreateStructuralClassifier(context, options); + + return (ref reader) => Classify(ref reader, caseTypes, options, structural); + } + + /// + /// Creates the platform's structural classifier for object-shaped cases, or when + /// the union has no object-shaped case (or the mix is one it cannot describe). + /// + static JsonTypeClassifier? TryCreateStructuralClassifier( + JsonTypeClassifierContext context, + JsonSerializerOptions options + ) + { + try + { + JsonUnionTypeStructuralClassifier factory = new(); + return factory.CanClassify(context) ? factory.CreateJsonClassifier(context, options) : null; + } + catch (Exception exception) + when (exception is InvalidOperationException or NotSupportedException or ArgumentException) + { + // The structural classifier rejects mixes it cannot describe (for example an object beside a + // dictionary). Fall back to the token and contract checks below rather than failing the union. + return null; + } + } + + static Type? Classify( + ref Utf8JsonReader reader, + Type[] caseTypes, + JsonSerializerOptions options, + JsonTypeClassifier? structural + ) + { + var token = reader.TokenType; + + // A null payload never reaches the classifier: the serializer selects the first nullable case, or + // the default union value when no case accepts null. + + // Object-shaped cases are the one shape the platform can distinguish by property names. Let its + // classifier decide first; it reports zero or multiple matches with a precise JsonException. + if (token == JsonTokenType.StartObject && structural is not null) + return structural(ref reader); + + List natural = [with(caseTypes.Length)]; + foreach (var caseType in caseTypes) + { + if (IsNaturalType(caseType, token, options)) + natural.Add(caseType); + } + + if (natural.Count == 1) + return natural[0]; + + IReadOnlyList candidates = natural.Count > 0 ? natural : caseTypes; + List readable = [with(candidates.Count)]; + foreach (var caseType in candidates) + { + if (CanRead(ref reader, caseType, options)) + readable.Add(caseType); + } + + if (readable.Count == 1) + return readable[0]; + + // A scalar token has no structure left to disambiguate by, so declaration order is the stable + // tie-break. An object or array token that several cases can read is genuinely ambiguous. + if (readable.Count > 1 && token is not (JsonTokenType.StartObject or JsonTokenType.StartArray)) + return readable[0]; + + // Let the serializer report the ambiguity or lack of a match as a JsonException, which is the same + // behaviour as a compiler-declared union. + return null; + } + + static bool IsNaturalType(Type caseType, JsonTokenType token, JsonSerializerOptions options) + { + if (token is JsonTokenType.StartObject or JsonTokenType.StartArray) + { + var kind = KindOf(caseType, options); + return token == JsonTokenType.StartObject + ? kind is JsonTypeInfoKind.Object or JsonTypeInfoKind.Dictionary + : kind == JsonTypeInfoKind.Enumerable; + } + + return IsNaturalScalar(caseType, token, options); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + static bool IsNaturalScalar(Type caseType, JsonTokenType token, JsonSerializerOptions options) => + token switch + { + JsonTokenType.Number => IsNumeric(caseType) || (caseType.IsEnum && !EnumReadsStrings(caseType, options)), + JsonTokenType.String => caseType == typeof(string) + || caseType == typeof(char) + || caseType == typeof(Guid) + || caseType == typeof(DateTime) + || caseType == typeof(DateTimeOffset) + || caseType == typeof(DateOnly) + || caseType == typeof(TimeOnly) + || caseType == typeof(TimeSpan) + || caseType == typeof(Uri) + || caseType == typeof(byte[]) + || (caseType.IsEnum && EnumReadsStrings(caseType, options)), + JsonTokenType.True or JsonTokenType.False => caseType == typeof(bool), + _ => false, + }; + + static bool IsNumeric(Type type) => + Type.GetTypeCode(type) + is TypeCode.Byte + or TypeCode.SByte + or TypeCode.Int16 + or TypeCode.UInt16 + or TypeCode.Int32 + or TypeCode.UInt32 + or TypeCode.Int64 + or TypeCode.UInt64 + or TypeCode.Single + or TypeCode.Double + or TypeCode.Decimal + || type == typeof(Half) + || type == typeof(Int128) + || type == typeof(UInt128) + || type == typeof(System.Numerics.BFloat16) + || type == typeof(System.Numerics.Decimal32) + || type == typeof(System.Numerics.Decimal64) + || type == typeof(System.Numerics.Decimal128); + + static bool EnumReadsStrings(Type type, JsonSerializerOptions options) + { + try + { + return options.GetTypeInfo(type).Converter is JsonStringEnumConverter; + } + catch (Exception exception) + when (exception is InvalidOperationException or NotSupportedException or ArgumentException) + { + return false; + } + } + + static JsonTypeInfoKind KindOf(Type type, JsonSerializerOptions options) + { + try + { + return options.GetTypeInfo(type).Kind; + } + catch (Exception exception) + when (exception is InvalidOperationException or NotSupportedException or ArgumentException) + { + return JsonTypeInfoKind.None; + } + } + + static bool CanRead(ref Utf8JsonReader reader, Type caseType, JsonSerializerOptions options) + { + try + { + var typeInfo = options.GetTypeInfo(caseType); + var probe = reader; + JsonSerializer.Deserialize(ref probe, typeInfo); + return true; + } + catch (Exception exception) + when (exception is JsonException or InvalidOperationException or NotSupportedException or ArgumentException) + { + return false; + } + } +} +#endif diff --git a/src/src/ZodSharp/Unions/Union.cs b/src/src/ZodSharp/Unions/Union.cs index 18e98833..87e9c274 100644 --- a/src/src/ZodSharp/Unions/Union.cs +++ b/src/src/ZodSharp/Unions/Union.cs @@ -16,8 +16,10 @@ namespace ZodSharp.Unions; /// , or . /// /// -/// The public surface mirrors the C# 15 union keyword so that consumer code -/// remains identical when a net11.0 target with native union support is added. +/// uses the same numbering as the net11.0-only NativeUnion<T1, T2>: +/// 0 is an empty union, 1 is and 2 is +/// . A default value (or an implicit conversion from a +/// reference) is therefore empty, and returns rather than throwing. /// /// public readonly struct Union : IUnion, IEquatable> @@ -38,7 +40,7 @@ namespace ZodSharp.Unions; "CA1000:Do not declare static members on generic types", Justification = "Factory methods are intentionally placed on the generic union type." )] - public static Union Create(T1 value) => new(value, default, 0); + public static Union Create(T1 value) => new(value, default, 1); /// Creates a union holding a value. [SuppressMessage( @@ -46,28 +48,28 @@ namespace ZodSharp.Unions; "CA1000:Do not declare static members on generic types", Justification = "Factory methods are intentionally placed on the generic union type." )] - public static Union Create(T2 value) => new(default, value, 1); + public static Union Create(T2 value) => new(default, value, 2); /// Implicitly converts a to the union. [return: NotNullIfNotNull(nameof(value))] public static implicit operator Union(T1? value) => - value is null ? default : new Union(value, default, 0); + value is null ? default : new Union(value, default, 1); /// Implicitly converts a to the union. [return: NotNullIfNotNull(nameof(value))] public static implicit operator Union(T2? value) => - value is null ? default : new Union(default, value, 1); + value is null ? default : new Union(default, value, 2); /// public int Tag { get; } /// - public object Value => + public object? Value => Tag switch { - 0 => _value1!, - 1 => _value2!, - _ => throw new InvalidOperationException("Union is in an uninitialized state."), + 1 => _value1, + 2 => _value2, + _ => null, }; /// @@ -76,7 +78,7 @@ public static implicit operator Union(T2? value) => /// if the union holds a . public bool TryGetValue([NotNullWhen(true)] out T1? value) { - if (Tag == 0) + if (Tag == 1) { value = _value1!; return true; @@ -92,7 +94,7 @@ public bool TryGetValue([NotNullWhen(true)] out T1? value) /// if the union holds a . public bool TryGetValue([NotNullWhen(true)] out T2? value) { - if (Tag == 1) + if (Tag == 2) { value = _value2!; return true; @@ -105,6 +107,7 @@ public bool TryGetValue([NotNullWhen(true)] out T2? value) /// /// Exhaustively maps the union to a result value. /// + /// The union is empty. public TResult Match(Func case1, Func case2) { if (case1 is null) @@ -112,18 +115,19 @@ public TResult Match(Func case1, Func case2) if (case2 is null) throw new ArgumentNullException(nameof(case2)); - // Use a switch expression to return the result of the appropriate case function based on the active tag. + // The default case is unreachable because the union is never empty when Match is called, but the compiler cannot see that. return Tag switch { - 0 => case1(_value1!), - 1 => case2(_value2!), - _ => throw new InvalidOperationException("Union is in an uninitialized state."), + 1 => case1(_value1!), + 2 => case2(_value2!), + _ => throw new InvalidOperationException("Union is empty."), }; } /// /// Exhaustively executes an action for the active case. /// + /// The union is empty. public void Switch(Action case1, Action case2) { if (case1 is null) @@ -133,14 +137,14 @@ public void Switch(Action case1, Action case2) switch (Tag) { - case 0: + case 1: case1(_value1!); break; - case 1: + case 2: case2(_value2!); break; default: - throw new InvalidOperationException("Union is in an uninitialized state."); + throw new InvalidOperationException("Union is empty."); } } @@ -149,8 +153,8 @@ public bool Equals(Union other) => Tag == other.Tag && Tag switch { - 0 => EqualityComparer.Default.Equals(_value1, other._value1), - 1 => EqualityComparer.Default.Equals(_value2, other._value2), + 1 => EqualityComparer.Default.Equals(_value1, other._value1), + 2 => EqualityComparer.Default.Equals(_value2, other._value2), _ => true, }; @@ -161,13 +165,13 @@ public bool Equals(Union other) => public override int GetHashCode() => Tag switch { - 0 => _value1?.GetHashCode() ?? 0, - 1 => _value2?.GetHashCode() ?? 0, + 1 => _value1?.GetHashCode() ?? 0, + 2 => _value2?.GetHashCode() ?? 0, _ => 0, }; /// Returns a string representation of the active value. - public override string ToString() => Value?.ToString() ?? ""; + public override string ToString() => Value?.ToString() ?? ""; /// Equality operator. public static bool operator ==(Union left, Union right) => left.Equals(right); @@ -203,7 +207,7 @@ public override int GetHashCode() => "CA1000:Do not declare static members on generic types", Justification = "Factory methods are intentionally placed on the generic union type." )] - public static Union Create(T1 value) => new(value, default, default, 0); + public static Union Create(T1 value) => new(value, default, default, 1); /// Creates a union holding a value. [SuppressMessage( @@ -211,7 +215,7 @@ public override int GetHashCode() => "CA1000:Do not declare static members on generic types", Justification = "Factory methods are intentionally placed on the generic union type." )] - public static Union Create(T2 value) => new(default, value, default, 1); + public static Union Create(T2 value) => new(default, value, default, 2); /// Creates a union holding a value. [SuppressMessage( @@ -219,40 +223,40 @@ public override int GetHashCode() => "CA1000:Do not declare static members on generic types", Justification = "Factory methods are intentionally placed on the generic union type." )] - public static Union Create(T3 value) => new(default, default, value, 2); + public static Union Create(T3 value) => new(default, default, value, 3); /// Implicitly converts a to the union. [return: NotNullIfNotNull(nameof(value))] public static implicit operator Union(T1? value) => - value is null ? default : new Union(value, default, default, 0); + value is null ? default : new Union(value, default, default, 1); /// Implicitly converts a to the union. [return: NotNullIfNotNull(nameof(value))] public static implicit operator Union(T2? value) => - value is null ? default : new Union(default, value, default, 1); + value is null ? default : new Union(default, value, default, 2); /// Implicitly converts a to the union. [return: NotNullIfNotNull(nameof(value))] public static implicit operator Union(T3? value) => - value is null ? default : new Union(default, default, value, 2); + value is null ? default : new Union(default, default, value, 3); /// public int Tag { get; } /// - public object Value => + public object? Value => Tag switch { - 0 => _value1!, - 1 => _value2!, - 2 => _value3!, - _ => throw new InvalidOperationException("Union is in an uninitialized state."), + 1 => _value1, + 2 => _value2, + 3 => _value3, + _ => null, }; /// Non-boxing attempt to retrieve the value. public bool TryGetValue([NotNullWhen(true)] out T1? value) { - if (Tag == 0) + if (Tag == 1) { value = _value1!; return true; @@ -265,7 +269,7 @@ public bool TryGetValue([NotNullWhen(true)] out T1? value) /// Non-boxing attempt to retrieve the value. public bool TryGetValue([NotNullWhen(true)] out T2? value) { - if (Tag == 1) + if (Tag == 2) { value = _value2!; return true; @@ -278,7 +282,7 @@ public bool TryGetValue([NotNullWhen(true)] out T2? value) /// Non-boxing attempt to retrieve the value. public bool TryGetValue([NotNullWhen(true)] out T3? value) { - if (Tag == 2) + if (Tag == 3) { value = _value3!; return true; @@ -289,6 +293,7 @@ public bool TryGetValue([NotNullWhen(true)] out T3? value) } /// Exhaustively maps the union to a result value. + /// The union is empty. public TResult Match(Func case1, Func case2, Func case3) { if (case1 is null) @@ -298,17 +303,18 @@ public TResult Match(Func case1, Func case2, if (case3 is null) throw new ArgumentNullException(nameof(case3)); - // Use a switch expression to return the result of the appropriate case function based on the active tag. + // The default case is unreachable because the union is never empty when Match is called, but the compiler cannot see that. return Tag switch { - 0 => case1(_value1!), - 1 => case2(_value2!), - 2 => case3(_value3!), - _ => throw new InvalidOperationException("Union is in an uninitialized state."), + 1 => case1(_value1!), + 2 => case2(_value2!), + 3 => case3(_value3!), + _ => throw new InvalidOperationException("Union is empty."), }; } /// Exhaustively executes an action for the active case. + /// The union is empty. public void Switch(Action case1, Action case2, Action case3) { if (case1 is null) @@ -320,17 +326,17 @@ public void Switch(Action case1, Action case2, Action case3) switch (Tag) { - case 0: + case 1: case1(_value1!); break; - case 1: + case 2: case2(_value2!); break; - case 2: + case 3: case3(_value3!); break; default: - throw new InvalidOperationException("Union is in an uninitialized state."); + throw new InvalidOperationException("Union is empty."); } } @@ -339,9 +345,9 @@ public bool Equals(Union other) => Tag == other.Tag && Tag switch { - 0 => EqualityComparer.Default.Equals(_value1, other._value1), - 1 => EqualityComparer.Default.Equals(_value2, other._value2), - 2 => EqualityComparer.Default.Equals(_value3, other._value3), + 1 => EqualityComparer.Default.Equals(_value1, other._value1), + 2 => EqualityComparer.Default.Equals(_value2, other._value2), + 3 => EqualityComparer.Default.Equals(_value3, other._value3), _ => true, }; @@ -352,14 +358,14 @@ public bool Equals(Union other) => public override int GetHashCode() => Tag switch { - 0 => _value1?.GetHashCode() ?? 0, - 1 => _value2?.GetHashCode() ?? 0, - 2 => _value3?.GetHashCode() ?? 0, + 1 => _value1?.GetHashCode() ?? 0, + 2 => _value2?.GetHashCode() ?? 0, + 3 => _value3?.GetHashCode() ?? 0, _ => 0, }; - /// - public override string ToString() => Value?.ToString() ?? ""; + /// Returns a string representation of the active value. + public override string ToString() => Value?.ToString() ?? ""; /// Equality operator. public static bool operator ==(Union left, Union right) => left.Equals(right); diff --git a/src/src/ZodSharp/Z.cs b/src/src/ZodSharp/Z.cs index ab0d8a7a..0b16005f 100644 --- a/src/src/ZodSharp/Z.cs +++ b/src/src/ZodSharp/Z.cs @@ -94,7 +94,7 @@ public static ZodTypedUnion Union(IZodSchema option1, IZ /// /// Creates a typed union schema of two options that yields a native C# 15 /// on success. Equivalent to - /// but allocation-free for reference-type cases and pattern-matchable. Only available on + /// but allocation-free for both reference- and value-type cases, and pattern-matchable. Only available on /// net11.0 and later. /// /// The first option's type. diff --git a/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs b/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs index 54bc6776..30bd3ade 100644 --- a/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs +++ b/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs @@ -23,7 +23,7 @@ public sealed class ZodSharpSchemaDefaultsAttribute : Attribute /// /// Overrides ZodSharpDefaultImmutableArrayBehavior in MSBuild; a property's /// takes precedence. The built-in default is - /// . + /// . /// public DefaultImmutableArrayBehavior DefaultImmutableArrayBehavior { get; set; } diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/AsyncUnionBody.cs b/src/tests/AspNetCore.UnitTests/Fixtures/AsyncUnionBody.cs new file mode 100644 index 00000000..905233c6 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Fixtures/AsyncUnionBody.cs @@ -0,0 +1,46 @@ +#if NET11_0_OR_GREATER +using System.ComponentModel.DataAnnotations; +using ValidationResult = System.ComponentModel.DataAnnotations.ValidationResult; + +namespace ZodSharp.AspNetCore.Fixtures; + +/// Rejects the value taken only asynchronously. +public sealed class AsyncTakenAttribute : AsyncValidationAttribute +{ + /// + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) => + throw new InvalidOperationException("Validated asynchronously."); + + /// + protected override async Task IsValidAsync( + object? value, + ValidationContext validationContext, + CancellationToken cancellationToken + ) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + + return value is "taken" ? new ValidationResult("Name is taken") : ValidationResult.Success; + } +} + +/// A union case with an asynchronous rule. +#pragma warning disable ZODSGEN048 // The endpoint validates asynchronously; the diagnostic is expected for the fixture. +[ZodSchema] +public class AsyncUnionCat +{ + [AsyncTaken] + public string? Name { get; set; } +} +#pragma warning restore ZODSGEN048 + +/// +/// A union target used as a minimal-API request body. The cases have different JSON value kinds (object and +/// string) so System.Text.Json can pick one; the string case has no validator, which is why ZODSGEN055 is off. +/// +#pragma warning disable ZODSGEN055 // The string case is not validated; only the object case is exercised. +[ZodSchema] +public readonly union AsyncPet(AsyncUnionCat, string); +#pragma warning restore ZODSGEN055 +#endif diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/OrderOrReference.cs b/src/tests/AspNetCore.UnitTests/Fixtures/OrderOrReference.cs index 305a9744..70447658 100644 --- a/src/tests/AspNetCore.UnitTests/Fixtures/OrderOrReference.cs +++ b/src/tests/AspNetCore.UnitTests/Fixtures/OrderOrReference.cs @@ -5,5 +5,5 @@ namespace ZodSharp.AspNetCore.Fixtures; /// A C# 15 union body whose case ZodSharp validates; the union itself has no validator. /// The cases have different JSON value kinds (object and string), so System.Text.Json can pick the case. /// -public union OrderOrReference(OrderRequest, string); +public readonly union OrderOrReference(OrderRequest, string); #endif diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/StrictValueObjects.cs b/src/tests/AspNetCore.UnitTests/Fixtures/StrictValueObjects.cs index b1ff83b2..32fe747b 100644 --- a/src/tests/AspNetCore.UnitTests/Fixtures/StrictValueObjects.cs +++ b/src/tests/AspNetCore.UnitTests/Fixtures/StrictValueObjects.cs @@ -28,7 +28,7 @@ public readonly partial record struct EmailAddress public EmailAddress(string value) => Value = value; [Required] - [System.ComponentModel.DataAnnotations.EmailAddress] + [EmailAddress] public string Value { get; } } diff --git a/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs b/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs index fa204b78..e262760c 100644 --- a/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs +++ b/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs @@ -396,6 +396,89 @@ await Assert } #if NET11_0_OR_GREATER + [Microsoft.Extensions.Validation.SkipValidation] + public sealed class SkippedRequest + { + public string? Email { get; set; } + } + + sealed class SkippedRequestValidator : IZodSchemaValidator + { + public ValidationResult Validate(SkippedRequest value) => + ValidationResult.Failure( + ValidationError.Create("should_not_run", "must not run", ZodPath.Root) + ); + + public ValueTask> ValidateAsync( + SkippedRequest value, + CancellationToken cancellationToken = default + ) => new(Validate(value)); + } + + [Test] + public async Task WithZodSharpValidation_GivenSkipValidationOnTheParameterType_SkipsIt( + CancellationToken cancellationToken + ) + { + // Arrange — the platform attribute targets the class too; the filter must match the real .NET 11 type, + // not merely a type whose name ends in SkipValidationAttribute. + await using var app = await TestApp.StartAsync( + services => + { + services.AddZodSharp(options => + options.ConfigureFactory = factory => factory.Register(new SkippedRequestValidator()) + ); + services.AddZodSharpProblemDetails(options => options.Registry = new ErrorTypeRegistry()); + }, + app => + app.MapPost("/skipped", (SkippedRequest request) => TypedResults.Ok("handled")) + .WithZodSharpValidation(), + cancellationToken + ); + using StringContent content = new("""{"email":"x"}""", System.Text.Encoding.UTF8, "application/json"); + + // Act + using var response = await app.Client.PostAsync( + new Uri("/skipped", UriKind.Relative), + content, + cancellationToken + ); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(body).Contains("handled"); + } + + [Test] + public async Task WithZodSharpValidation_GivenAsyncRuleInUnionBody_AwaitsTheRule( + CancellationToken cancellationToken + ) + { + // Arrange — the endpoint filter is a separate path from the Microsoft.Extensions.Validation pipeline; + // it must also await a union case's asynchronous rule and pass the request services. + await using var app = await TestApp.StartAsync( + services => + { + services.AddZodSharp(options => + options.ConfigureFactory = factory => factory.Register(new AsyncPetSchemaValidator()) + ); + services.AddZodSharpProblemDetails(options => options.Registry = new ErrorTypeRegistry()); + }, + app => app.MapPost("/pets", (AsyncPet body) => TypedResults.Ok()).WithZodSharpValidation(), + cancellationToken + ); + using StringContent content = new("""{"name":"taken"}""", System.Text.Encoding.UTF8, "application/json"); + + // Act + using var response = await app.Client.PostAsync(new Uri("/pets", UriKind.Relative), content, cancellationToken); + var json = await response.Content.ReadAsStringAsync(cancellationToken); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(json).Contains("Name is taken"); + } + [Test] public async Task WithZodSharpValidation_GivenBindingFailureAndPassThrough_KeepsTheBindersResponse( CancellationToken cancellationToken diff --git a/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs b/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs index 60eab152..f6292e76 100644 --- a/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs +++ b/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs @@ -459,6 +459,39 @@ public async Task Pipeline_GivenUnionBody_ValidatesActiveCaseWithZodSharp(Cancel await Assert.That(root.GetProperty("errors").TryGetProperty("Value.Items[0].Quantity", out _)).IsTrue(); } + [Test] + public async Task Pipeline_GivenUnionTargetBodyWithAsyncCase_AwaitsTheCaseValidation( + CancellationToken cancellationToken + ) + { + // Arrange - AsyncPet is a [ZodSchema] union target; its active case's rule only runs asynchronously. + await using var app = await TestApp.StartAsync( + services => + { + services.AddZodSharp(options => + options.ConfigureFactory = factory => factory.Register(new AsyncPetSchemaValidator()) + ); + services.AddZodSharpValidation(); + services.AddZodSharpProblemDetails(options => options.Registry = new ErrorTypeRegistry()); + services.AddValidation(); + }, + app => app.MapPost("/pets", (AsyncPet body) => TypedResults.Ok()), + cancellationToken + ); + using StringContent content = new("""{"name":"taken"}""", System.Text.Encoding.UTF8, "application/json"); + + // Act + using var response = await app.Client.PostAsync(new Uri("/pets", UriKind.Relative), content, cancellationToken); + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var document = JsonDocument.Parse(json); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(json).Contains("Name is taken"); + var errors = document.RootElement.GetProperty("errors"); + await Assert.That(errors.GetRawText()).Contains("Name"); + } + [Test] public async Task Pipeline_GivenUnionBodyWithoutApplicationAddValidation_StillValidatesActiveCase( CancellationToken cancellationToken diff --git a/src/tests/Blazor.UnitTests/Blazor.UnitTests.csproj b/src/tests/Blazor.UnitTests/Blazor.UnitTests.csproj index 7c2f08ae..7359a28f 100644 --- a/src/tests/Blazor.UnitTests/Blazor.UnitTests.csproj +++ b/src/tests/Blazor.UnitTests/Blazor.UnitTests.csproj @@ -12,8 +12,8 @@ Directory.Packages.props picks package lines by $(TargetFramework), which a single-targeted project has not set yet when that file is evaluated; the .NET 11 component and host packages need the 11.x lines. --> - 11.0.0-rc.3.26522.148 - 11.0.0-rc.3.26522.148 + 11.0.0 + 11.0.0 $(NoWarn);CA1506 diff --git a/src/tests/Blazor.UnitTests/Fixtures/GeneratedModels.cs b/src/tests/Blazor.UnitTests/Fixtures/GeneratedModels.cs index 39f52616..723355ef 100644 --- a/src/tests/Blazor.UnitTests/Fixtures/GeneratedModels.cs +++ b/src/tests/Blazor.UnitTests/Fixtures/GeneratedModels.cs @@ -60,3 +60,57 @@ public class GeneratedLine [Range(1, 100)] public int Quantity { get; set; } = 1; } + +/// Rejects the value taken only asynchronously. +public sealed class AsyncTakenAttribute : AsyncValidationAttribute +{ + /// + protected override System.ComponentModel.DataAnnotations.ValidationResult? IsValid( + object? value, + ValidationContext validationContext + ) => throw new InvalidOperationException("Validated asynchronously."); + + /// + protected override async Task IsValidAsync( + object? value, + ValidationContext validationContext, + CancellationToken cancellationToken + ) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + + return value is "taken" + ? new System.ComponentModel.DataAnnotations.ValidationResult("Name is taken") + : System.ComponentModel.DataAnnotations.ValidationResult.Success; + } +} + +/// A union case whose rule runs only asynchronously. +#pragma warning disable ZODSGEN048 // The form validates asynchronously; the diagnostic is expected for the fixture. +[ZodSchema] +public class GeneratedCat +{ + [AsyncTaken] + public string? Name { get; set; } +} +#pragma warning restore ZODSGEN048 + +/// A synchronous union case. +[ZodSchema] +public class GeneratedDog +{ + [Range(1, 100)] + public int Weight { get; set; } = 1; +} + +/// A union target: validation dispatches to the active case's schema. +[ZodSchema] +public readonly union GeneratedPet(GeneratedCat, GeneratedDog); + +/// A form model holding a union target member. +[ZodSchema] +public class GeneratedOwner +{ + public GeneratedPet Pet { get; set; } +} diff --git a/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs b/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs index bf487cbb..59e51362 100644 --- a/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs +++ b/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs @@ -225,6 +225,73 @@ public async Task DataAnnotationsValidator_GivenValidGeneratedModel_IsValid() await Assert.That(valid).IsTrue(); } + [Test] + public async Task DataAnnotationsValidator_GivenAsyncOnlyMemberFieldChange_IsPendingThenShowsTheError() + { + // Arrange - the member's only rule is the AsyncValidationAttribute, so the member API must still + // report it as individually validatable for the field-change path. + GeneratedCat cat = new() { Name = "taken" }; + await using var context = CreateContext(); + EditContext editContext = new(cat); + var form = context.Render(editContext, FormHost.DataAnnotations()); + FieldIdentifier field = new(cat, nameof(GeneratedCat.Name)); + + // Act + await form.ChangeAsync(editContext, field); + var pending = editContext.IsValidationPending(field); + await FormHost.WaitUntilAsync(() => !editContext.IsValidationPending(field), "resolved"); + + // Assert + await Assert.That(pending).IsTrue(); + await Assert.That(editContext.IsValidationFaulted(field)).IsFalse(); + await Assert.That(editContext.GetValidationMessages(field)).IsEquivalentTo(["Name is taken"]); + } + + [Test] + public async Task DataAnnotationsValidator_GivenAsyncRuleOnTheForm_AwaitsItAndPlacesTheError( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange - route 1: the platform's DataAnnotationsValidator must await the root type info's async + // validation, not call the synchronous Validate that throws for an async graph. + GeneratedCat cat = new() { Name = "taken" }; + GeneratedOwner owner = new() { Pet = cat }; + await using var context = CreateContext(); + EditContext editContext = new(owner); + var form = context.Render(editContext, FormHost.DataAnnotations()); + + // Act + var valid = await form.ValidateAsync(editContext); + + // Assert + await Assert.That(valid).IsFalse(); + await Assert.That(editContext.IsValidationFaulted()).IsFalse(); + FieldIdentifier field = new(cat, nameof(GeneratedCat.Name)); + await Assert.That(editContext.GetValidationMessages(field)).IsEquivalentTo(["Name is taken"]); + } + + [Test] + public async Task ZodValidator_GivenUnionMemberWithAsyncCase_AwaitsItAndPlacesTheErrorOnTheCase() + { + // Arrange - the owner's Pet member is a union target whose active case has an async-only rule. + GeneratedCat cat = new() { Name = "taken" }; + GeneratedOwner owner = new() { Pet = cat }; + await using var context = CreateContext(); + EditContext editContext = new(owner); + var form = context.Render(editContext, FormHost.Zod()); + + // Act + var valid = await form.ValidateAsync(editContext); + + // Assert + await Assert.That(valid).IsFalse(); + await Assert.That(editContext.IsValidationFaulted()).IsFalse(); + FieldIdentifier field = new(cat, nameof(GeneratedCat.Name)); + await Assert.That(editContext.GetValidationMessages(field)).IsEquivalentTo(["Name is taken"]); + } + static BunitContext CreateContext() => FormHost.CreateContext(static factory => Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) diff --git a/src/tests/Blazor.UnitTests/Ssr/StaticSsrTests.cs b/src/tests/Blazor.UnitTests/Ssr/StaticSsrTests.cs index 1e1be325..b0d5014f 100644 --- a/src/tests/Blazor.UnitTests/Ssr/StaticSsrTests.cs +++ b/src/tests/Blazor.UnitTests/Ssr/StaticSsrTests.cs @@ -196,8 +196,8 @@ static Task StartAsync(CancellationToken cancellationToken) => /// static string MessageAfter(IHtmlDocument document, string name) { - var input = - document.QuerySelector($"[name='{name}']") ?? throw new InvalidOperationException($"No input '{name}'."); + var input = document.QuerySelector($"[name='{name}']") + ?? throw new InvalidOperationException($"No input '{name}'."); var sibling = input.NextElementSibling; var text = string.Empty; while (sibling is not null && sibling.ClassList.Contains("validation-message")) @@ -216,8 +216,7 @@ internal sealed record Rule(string Name, string Message, Dictionary internal static Dictionary> ReadRules(IDocument document) { - var element = - document.QuerySelector("blazor-client-validation-data") + var element = document.QuerySelector("blazor-client-validation-data") ?? throw new InvalidOperationException("No client validation payload was rendered."); using var json = JsonDocument.Parse(element.GetAttribute("data-rules") ?? "{}"); diff --git a/src/tests/Examples.Blazor.IntegrationTests/CustomerAPIClientTests.cs b/src/tests/Examples.Blazor.IntegrationTests/CustomerAPIClientTests.cs index 23e3f3cc..3f8a5ecc 100644 --- a/src/tests/Examples.Blazor.IntegrationTests/CustomerAPIClientTests.cs +++ b/src/tests/Examples.Blazor.IntegrationTests/CustomerAPIClientTests.cs @@ -183,7 +183,9 @@ sealed class ClientHost : IAsyncDisposable readonly SampleAppFactory _factory = new(); readonly WebApplicationFactory _configured; readonly AsyncServiceScope _scope; +#pragma warning disable IDE0032 // Use auto property int _posts; +#pragma warning restore IDE0032 // Use auto property public ClientHost() { diff --git a/src/tests/Examples.Blazor.IntegrationTests/Examples.Blazor.IntegrationTests.csproj b/src/tests/Examples.Blazor.IntegrationTests/Examples.Blazor.IntegrationTests.csproj index 7ad158b7..f076ae6c 100644 --- a/src/tests/Examples.Blazor.IntegrationTests/Examples.Blazor.IntegrationTests.csproj +++ b/src/tests/Examples.Blazor.IntegrationTests/Examples.Blazor.IntegrationTests.csproj @@ -9,10 +9,10 @@ Directory.Packages.props picks package lines by $(TargetFramework), which a single-targeted project has not set yet when that file is evaluated; the .NET 11 host packages need the 11.x lines. --> - 11.0.0-rc.3.26522.148 - 11.0.0-rc.3.26522.148 + 11.0.0 + 11.0.0 - $(NoWarn);CA1506;NU5104 + $(NoWarn);CA1506 diff --git a/src/tests/Examples.Blazor.IntegrationTests/SampleAppFactory.cs b/src/tests/Examples.Blazor.IntegrationTests/SampleAppFactory.cs index 1cd253db..c2dfe8bc 100644 --- a/src/tests/Examples.Blazor.IntegrationTests/SampleAppFactory.cs +++ b/src/tests/Examples.Blazor.IntegrationTests/SampleAppFactory.cs @@ -45,8 +45,7 @@ public static async Task PostFormAsync( CancellationToken cancellationToken ) { - var token = - page.QuerySelector("input[name='__RequestVerificationToken']")?.GetAttribute("value") + var token = page.QuerySelector("input[name='__RequestVerificationToken']")?.GetAttribute("value") ?? throw new InvalidOperationException("The form rendered no antiforgery token."); using FormUrlEncodedContent content = new([ @@ -70,8 +69,8 @@ static async Task ParseAsync(HttpResponseMessage response, Cancel /// public static string MessageAfter(IDocument document, string name) { - var input = - document.QuerySelector($"[name='{name}']") ?? throw new InvalidOperationException($"No input '{name}'."); + var input = document.QuerySelector($"[name='{name}']") + ?? throw new InvalidOperationException($"No input '{name}'."); var text = string.Empty; for ( var sibling = input.NextElementSibling; @@ -94,8 +93,7 @@ public sealed record ClientRule(string Name, string Message, IReadOnlyDictionary /// public static Dictionary> ReadClientRules(IDocument document) { - var element = - document.QuerySelector("blazor-client-validation-data") + var element = document.QuerySelector("blazor-client-validation-data") ?? throw new InvalidOperationException("No client validation payload was rendered."); using var json = JsonDocument.Parse(element.GetAttribute("data-rules") ?? "{}"); diff --git a/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedUnionModels.cs b/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedUnionModels.cs new file mode 100644 index 00000000..0c890d3f --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedUnionModels.cs @@ -0,0 +1,48 @@ +#if NET11_0_OR_GREATER +using System.ComponentModel.DataAnnotations; +using ValidationResult = System.ComponentModel.DataAnnotations.ValidationResult; + +namespace ZodSharp.Extensions.Validation.Fixtures; + +/// Rejects the value taken only asynchronously, so the sync path must not silently pass it. +public sealed class AsyncTakenAttribute : AsyncValidationAttribute +{ + /// + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) => + throw new InvalidOperationException("Validated asynchronously."); + + /// + protected override async Task IsValidAsync( + object? value, + ValidationContext validationContext, + CancellationToken cancellationToken + ) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + + return value is "taken" ? new ValidationResult("Name is taken") : ValidationResult.Success; + } +} + +/// A union case with an asynchronous rule. +#pragma warning disable ZODSGEN048 // The fixture is validated asynchronously; the diagnostic is the point of the test. +[ZodSchema] +public class UnionCat +{ + [AsyncTaken] + public string? Name { get; set; } +} +#pragma warning restore ZODSGEN048 + +/// A union case with only synchronous rules. +[ZodSchema] +public class UnionDog +{ + public int Weight { get; set; } = 1; +} + +/// A union target whose first case validates asynchronously. +[ZodSchema] +public readonly union GeneratedPet(UnionCat, UnionDog); +#endif diff --git a/src/tests/Extensions.Validation.UnitTests/Fixtures/Models.cs b/src/tests/Extensions.Validation.UnitTests/Fixtures/Models.cs index c3c794c5..d5924977 100644 --- a/src/tests/Extensions.Validation.UnitTests/Fixtures/Models.cs +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/Models.cs @@ -66,3 +66,13 @@ public sealed class Envelope public Order Order { get; set; } = new(); } + +/// +/// A type the platform excludes from validation with [SkipValidation]; a registered ZodSharp validator must +/// not claim it. +/// +[SkipValidation] +public sealed class SkippedModel +{ + public string? Name { get; set; } +} diff --git a/src/tests/Extensions.Validation.UnitTests/ZodCompositeTypeInfoTests.cs b/src/tests/Extensions.Validation.UnitTests/ZodCompositeTypeInfoTests.cs new file mode 100644 index 00000000..065a3699 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/ZodCompositeTypeInfoTests.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.Extensions.Validation.Fixtures; +using static ZodSharp.Extensions.Validation.Fixtures.TestHelpers; + +namespace ZodSharp.Extensions.Validation; + +/// +/// AlsoRunDataAnnotations composes ZodSharp with the platform's generated resolver. Field-level +/// validation must fall through to that resolver for a property ZodSharp does not own. +/// +public class ZodCompositeTypeInfoTests +{ + [Test] + public async Task TryFindProperty_GivenAlsoRunDataAnnotations_FindsTheNextResolversProperty( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — the ZodSharp validator has no member-level validation, so only DataAnnotations can validate + // the Name field on its own. + ServiceCollection services = new(); + services.AddValidation(); + services.AddZodSharpFactory(static factory => factory.Register(new ScriptedValidator())); + services.AddZodSharpValidation(static options => options.AlsoRunDataAnnotations = true); + using var provider = services.BuildServiceProvider(); + var typeInfo = provider.GetTypeInfo(); + var validationOptions = provider.GetValidationOptions(); + var context = provider.CreateContext(); + + // Act + var found = typeInfo.TryFindProperty(nameof(DualModel.Name), validationOptions, out var property); + if (property is not null) + await property.ValidateAsync(new DualModel(), context, CancellationToken.None); + + // Assert + await Assert.That(found).IsTrue(); + await Assert.That(context.Single().ErrorMessage).IsEqualTo("DataAnnotations: Name is required."); + } +} diff --git a/src/tests/Extensions.Validation.UnitTests/ZodValidatableInfoResolverSkipTests.cs b/src/tests/Extensions.Validation.UnitTests/ZodValidatableInfoResolverSkipTests.cs new file mode 100644 index 00000000..d64f9ba7 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/ZodValidatableInfoResolverSkipTests.cs @@ -0,0 +1,26 @@ +using ZodSharp.Extensions.Validation.Fixtures; +using static ZodSharp.Extensions.Validation.Fixtures.TestHelpers; + +namespace ZodSharp.Extensions.Validation; + +/// +/// Microsoft.Extensions.Validation's [SkipValidation] on a type (it also targets properties and +/// parameters) removes it from the resolver chain, even when a ZodSharp validator is registered. +/// +public class ZodValidatableInfoResolverSkipTests +{ + [Test] + public async Task Resolver_GivenTypeWithSkipValidation_DoesNotClaimIt(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + using var provider = CreateProvider(static factory => factory.Register(new ScriptedValidator())); + + // Act + var claimed = provider.GetValidationOptions().TryGetValidatableTypeInfo(typeof(SkippedModel), out _); + + // Assert + await Assert.That(claimed).IsFalse(); + } +} diff --git a/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoGeneratedSchemaTests.cs b/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoGeneratedSchemaTests.cs index ac17bf0e..cb047079 100644 --- a/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoGeneratedSchemaTests.cs +++ b/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoGeneratedSchemaTests.cs @@ -94,6 +94,28 @@ CancellationToken cancellationToken await Assert.That(errors.Length).IsEqualTo(2); } + [Test] + public async Task ValidateAsync_GivenGeneratedUnionTargetWithAsyncCase_AwaitsTheCaseSchema( + CancellationToken cancellationToken + ) + { + // Arrange - the union target dispatches to UnionCatSchema, whose AsyncTaken rule only runs asynchronously. + GeneratedPet pet = new UnionCat { Name = "taken" }; + ServiceCollection services = new(); + services.AddZodSharpFactory(static factory => factory.Register(new GeneratedPetSchemaValidator())); + services.AddZodSharpValidation(); + using var provider = services.BuildServiceProvider(); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(pet, context, cancellationToken); + + // Assert + var error = context.Single(); + await Assert.That(error.Path).IsEqualTo("Name"); + await Assert.That(error.ErrorMessage).IsEqualTo("Name is taken"); + } + static ServiceProvider CreatePersonProvider() { ServiceCollection services = new(); diff --git a/src/tests/Http.UnitTests/Fixtures/StrictValueObjects.cs b/src/tests/Http.UnitTests/Fixtures/StrictValueObjects.cs index fcddeef7..2abcf37e 100644 --- a/src/tests/Http.UnitTests/Fixtures/StrictValueObjects.cs +++ b/src/tests/Http.UnitTests/Fixtures/StrictValueObjects.cs @@ -28,7 +28,7 @@ public readonly partial record struct EmailAddress public EmailAddress(string value) => Value = value; [Required] - [System.ComponentModel.DataAnnotations.EmailAddress] + [EmailAddress] public string Value { get; } } diff --git a/src/tests/Repository.UnitTests/DocumentationWikiTests.cs b/src/tests/Repository.UnitTests/DocumentationWikiTests.cs index 08c4df72..00502c5b 100644 --- a/src/tests/Repository.UnitTests/DocumentationWikiTests.cs +++ b/src/tests/Repository.UnitTests/DocumentationWikiTests.cs @@ -225,7 +225,7 @@ CancellationToken cancellationToken { // Arrange var wikiFiles = PageFiles().Append(SidebarFile).Append(HomeFile).ToArray(); - List broken = new(); + List broken = []; // Act foreach (var file in wikiFiles) @@ -266,7 +266,7 @@ await Assert public async Task Pages_GivenEveryPage_OpenWithASingleH1Title(CancellationToken cancellationToken) { // Arrange - List problems = new(); + List problems = []; // Act foreach (var page in PageFiles()) @@ -292,12 +292,13 @@ public async Task Pages_GivenEveryPage_OpenWithASingleH1Title(CancellationToken /// Every page in the wiki, as a path relative to docs/wiki with / separators. static string[] PageFiles() => - Directory - .GetFiles(WikiFolder.Value, "*.md", SearchOption.AllDirectories) - .Select(RelativeToWiki) - .Where(path => path is not SidebarFile and not HomeFile) - .Order(StringComparer.Ordinal) - .ToArray(); + [ + .. Directory + .GetFiles(WikiFolder.Value, "*.md", SearchOption.AllDirectories) + .Select(RelativeToWiki) + .Where(path => path is not SidebarFile and not HomeFile) + .Order(StringComparer.Ordinal), + ]; static string RelativeToWiki(string absolutePath) => Path.GetRelativePath(WikiFolder.Value, absolutePath).Replace('\\', '/'); @@ -308,12 +309,13 @@ static string RelativeToWiki(string absolutePath) => /// is a repository path and is dropped. /// static string[] PageTargets(string markdown, string fromPath) => - MarkdownLinkTargets(StripCode(markdown)) - .Select(target => target.Split('#', 2)[0].Split('?', 2)[0]) - .Where(IsRelativeMarkdownLink) - .Select(target => ResolveRelative(fromPath, target)) - .OfType() - .ToArray(); + [ + .. MarkdownLinkTargets(StripCode(markdown)) + .Select(target => target.Split('#', 2)[0].Split('?', 2)[0]) + .Where(IsRelativeMarkdownLink) + .Select(target => ResolveRelative(fromPath, target)) + .OfType(), + ]; /// Resolves a link the way a browser does, or null when it points outside the wiki folder. static string? ResolveRelative(string fromPath, string target) @@ -323,7 +325,7 @@ static string[] PageTargets(string markdown, string fromPath) => return null; } - List segments = new(); + List segments = []; var fromSegments = fromPath.Split('/'); foreach (var segment in fromSegments[..^1]) { @@ -389,7 +391,7 @@ static string StripCode(string markdown) } static string[] MarkdownLinkTargets(string markdown) => - LinkPattern().Matches(markdown).Select(match => match.Groups[1].Value.Trim()).ToArray(); + [.. LinkPattern().Matches(markdown).Select(match => match.Groups[1].Value.Trim())]; /// /// Whether a link target names a page: relative, not anchored, and neither an external URL nor a repository diff --git a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs index 13ad59fb..720b3ab6 100644 --- a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs +++ b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs @@ -97,6 +97,7 @@ public partial class AnalyzerReleaseTrackingTests "ZODSGEN052", "ZODSGEN053", "ZODSGEN054", + "ZODSGEN055", ]; /// diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.NativeUnion.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.NativeUnion.cs index a5f16b11..d47c3c52 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.NativeUnion.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.NativeUnion.cs @@ -29,7 +29,7 @@ public static void Build() } [Test] - public async Task NativeUnion_GivenAValueTypeCase_DoesNotProduceZODSGEN041(CancellationToken cancellationToken) + public async Task NativeUnion_GivenAValueTypeCase_AlsoProducesZODSGEN041(CancellationToken cancellationToken) { const string source = """ using ZodSharp; @@ -48,7 +48,7 @@ public static void Build() var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.NativeUnionRecommended); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.NativeUnionRecommended); } } #endif diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.ValidationInputs.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.ValidationInputs.cs index 69b8b235..d3f87387 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.ValidationInputs.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.ValidationInputs.cs @@ -73,6 +73,34 @@ public class Drawing await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ClosedHierarchyDerivedTypeWithoutSchema); } + [Test] + public async Task Union_GivenCaseTypeWithoutSchema_ProducesZODSGEN055(CancellationToken cancellationToken) + { + // Arrange — int and string come from a referenced assembly and have no ZodSharp validator. + const string source = """ + namespace Testing + { + public union IdOrName(int, string); + + [ZodSchema] + public class Holder + { + public IdOrName Value { get; set; } + } + } + """; + + // Act + var result = await AnalyzeAsync( + source, + new ZodAnalyzerTestOptions { LanguageVersion = Microsoft.CodeAnalysis.CSharp.LanguageVersion.Preview }, + cancellationToken + ); + + // Assert — one warning for each case that cannot be validated. + await Assert.That(result).HasDiagnostics(DiagnosticLibrary.UnionCaseTypeWithoutSchema, 2); + } + [Test] public async Task OptionsValidator_GivenHandAuthoredOptionsValidator_ProducesZODSGEN050( CancellationToken cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorCacheTests.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorCacheTests.cs index 8dbec309..565467ab 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorCacheTests.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorCacheTests.cs @@ -131,4 +131,60 @@ public async Task JsonPropertyName_GivenAddedToOneType_ReRunsOnlyThatType(Cancel await Assert.That(second[AttributeStage]).Contains(StepReason.Modified); await Assert.That(second[AttributeStage].Count(IsCachedOrUnchanged)).IsEqualTo(1); } + + const string EntityBaseSource = """ + using System.ComponentModel.DataAnnotations; + + namespace Testing; + + public class Entity + { + [Required] + public string? Id { get; set; } + } + """; + + const string EntityBaseWithExtraRuleSource = """ + using System.ComponentModel.DataAnnotations; + + namespace Testing; + + public class Entity + { + [Required] + public string? Id { get; set; } + + [Range(1, 10)] + public int Version { get; set; } + } + """; + + const string DerivedEntitySource = """ + using ZodSharp; + + namespace Testing; + + [ZodSchema] + public sealed class Order : Entity + { + } + """; + + [Test] + public async Task BaseTypeChange_GivenNewInheritedRule_ReRunsTheDerivedType(CancellationToken cancellationToken) + { + // Arrange — the derived declaration is untouched, but the members it validates changed. + IncrementalRunInput[] inputs = + [ + new([EntityBaseSource, DerivedEntitySource]), + new([EntityBaseWithExtraRuleSource, DerivedEntitySource]), + ]; + + // Act + var result = await GenerateIncrementalAsync(inputs, cancellationToken: cancellationToken); + + // Assert + var second = StepReasons(result.Runs[1]); + await Assert.That(second[AttributeStage]).Contains(StepReason.Modified); + } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs index 1faaf161..e0a97bc1 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs @@ -52,6 +52,50 @@ CancellationToken ct } """; + [Test] + public async Task IsValidatableMember_GivenOnlyAZodAsyncMember_ReturnsTrue(CancellationToken cancellationToken) + { + // Arrange — the member has no synchronous rules, but ValidateMemberAsync runs the custom method that + // [ZodAsyncMember] ties to it, so a host's field-level validation must offer it. + const string source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + [ZodSchema] + public class Audit + { + public string? Handle { get; set; } + + [ZodAsyncMember(nameof(Handle))] + internal static ValueTask> CustomValidationAsync( + Audit value, + IServiceProvider services, + CancellationToken ct + ) => ValueTask.FromResult(ValidationResult.Success(value)); + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var schemaType = assembly.GetType("Testing.AuditSchema")!; + + // Act + var isValidatable = schemaType.GetMethod("IsValidatableMember")!.Invoke(null, ["Handle"]); + + // Assert + await Assert.That((bool)isValidatable!).IsTrue(); + } + [Test] public async Task ValidateAsync_GivenServiceAwareMethod_HandsTheCallersServicesToIt( CancellationToken cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Inheritance.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Inheritance.cs new file mode 100644 index 00000000..8f118ab2 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Inheritance.cs @@ -0,0 +1,333 @@ +using ZodSharp.Core; +using ZodSharp.JsonSchema; +using ZodSharp.SourceGenerators.Infra; +using static ZodSharp.SourceGenerators.Infra.GeneratedSchemaRuntime; + +namespace ZodSharp.SourceGenerators; + +/// +/// A generated schema validates inherited members too: a derived type is the value the caller passes, and +/// validation, paths, field resolution, JSON Schema export and closed-hierarchy dispatch all read the same +/// member list. A property the derived type overrides or hides is taken once, from the most derived +/// declaration. +/// +partial class ZodSchemaGeneratorTests +{ + [Test] + public async Task Validate_GivenInheritedRequiredProperty_ReportsTheBaseMember(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public class BaseEntity + { + [Required] + public string? Id { get; set; } + } + + [ZodSchema] + public sealed class Order : BaseEntity + { + [Range(1, 10)] + public int Quantity { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var order = Create(assembly, "Testing.Order", ("Id", null), ("Quantity", 5)); + + // Act + var errors = RunValidate(assembly, "Testing.OrderSchema", order); + var memberErrors = RunValidateMember(assembly, "Testing.OrderSchema", order, "Id"); + var resolved = RunTryResolveMember( + assembly, + "Testing.OrderSchema", + order, + ZodPath.From(ZodPathSegment.Member("Id")), + out var container, + out var memberName + ); + + // Assert — the base member is validated on the derived value, under its own name; the member and + // resolution APIs see it too, so a host's field-level validation and error placement work for an + // inherited field. + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("missing_field"); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Id"); + await Assert.That(memberErrors.Length).IsEqualTo(1); + await Assert.That(memberErrors[0].FullPath.ToDottedString()).IsEqualTo("Id"); + await Assert.That(resolved).IsTrue(); + await Assert.That(ReferenceEquals(container, order)).IsTrue(); + await Assert.That(memberName).IsEqualTo("Id"); + } + + [Test] + public async Task JsonSchema_GivenInheritedProperty_ExportsItOnTheDerivedSchema(CancellationToken cancellationToken) + { + // Arrange — the base type does not need its own [ZodSchema]: the derived schema validates the value + // and describes the members it reads. + const string source = """ + using System.ComponentModel.DataAnnotations; + using System.Text.Json.Serialization; + using ZodSharp; + + namespace Testing + { + public class BaseEntity + { + [Required] + [JsonPropertyName("entity_id")] + public string? Id { get; set; } + } + + [ZodSchema] + public sealed class Order : BaseEntity + { + public int Quantity { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var schemaType = assembly.GetType("Testing.OrderSchema")!; + + // Act + var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert — an inherited property's attributes (here [JsonPropertyName]) are read from the base + // declaration, so its wire name and requiredness match a declared member. + await Assert.That(definition.Properties!.ContainsKey("entity_id")).IsTrue(); + await Assert.That(definition.Properties!.ContainsKey("quantity")).IsTrue(); + await Assert.That(definition.Properties!["entity_id"].Type).IsEqualTo("string"); + await Assert.That(definition.Required!).Contains("entity_id"); + await Assert.That(definition.Required!).Contains("quantity"); + } + + [Test] + public async Task Validate_GivenOverriddenBaseProperty_ValidatesItOnce(CancellationToken cancellationToken) + { + // Arrange — the override is the member `value.Name` binds, so the base declaration must not add a + // second rule (and must not be validated under the base property's attributes). + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public class BaseEntity + { + public virtual string? Name { get; set; } + } + + [ZodSchema] + public sealed class Person : BaseEntity + { + [MinLength(3)] + public override string? Name { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var person = Create(assembly, "Testing.Person", ("Name", "ab")); + + // Act + var errors = RunValidate(assembly, "Testing.PersonSchema", person); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("too_small"); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Name"); + } + + [Test] + public async Task Validate_GivenHiddenBaseProperty_UsesTheDerivedPropertyOnly(CancellationToken cancellationToken) + { + // Arrange — the base member's rule must not be applied to the new member that hides it. + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public class BaseEntity + { + [MinLength(10)] + public string? Name { get; set; } + } + + [ZodSchema] + public sealed class Alias : BaseEntity + { + public new string? Name { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var alias = Create(assembly, "Testing.Alias", ("Name", "short")); + + // Act + var errors = RunValidate(assembly, "Testing.AliasSchema", alias); + + // Assert + await Assert.That(errors).IsEmpty(); + } + + [Test] + public async Task Validate_GivenGenericBaseProperty_UsesTheSubstitutedType(CancellationToken cancellationToken) + { + // Arrange — BaseValue exposes a string Value, so the string rule applies. + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public class BaseValue + { + [MinLength(3)] + public T Value { get; set; } = default!; + } + + [ZodSchema] + public sealed class TextValue : BaseValue + { + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var value = Create(assembly, "Testing.TextValue", ("Value", "ab")); + + // Act + var errors = RunValidate(assembly, "Testing.TextValueSchema", value); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("too_small"); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Value"); + } + + [Test] + public async Task Validate_GivenInheritedNestedProperty_GeneratesAndValidatesTheNestedSchema( + CancellationToken cancellationToken + ) + { + // Arrange — the nested type has no [ZodSchema]; the inherited property must make the generator queue + // and emit its secondary schema, just as a declared property would. + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public class BaseEnvelope + { + public Address Address { get; set; } = new(); + } + + public class Address + { + [Required] + public string? City { get; set; } + } + + [ZodSchema] + public sealed class Envelope : BaseEnvelope + { + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var address = Create(assembly, "Testing.Address", ("City", null)); + var envelope = Create(assembly, "Testing.Envelope", ("Address", address)); + + // Act + var errors = RunValidate(assembly, "Testing.EnvelopeSchema", envelope); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("missing_field"); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Address.City"); + } + + [Test] + public async Task Validate_GivenCompareToInheritedProperty_ComparesAgainstTheBaseMember( + CancellationToken cancellationToken + ) + { + // Arrange — the referenced property is inherited, so the Compare target search has to follow the base + // chain; the generated code reads value.Password from the base type. + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public class Credentials + { + public string? Password { get; set; } + } + + [ZodSchema] + public sealed class Signup : Credentials + { + [Compare(nameof(Password))] + public string? ConfirmPassword { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var signup = Create(assembly, "Testing.Signup", ("Password", "secret"), ("ConfirmPassword", "different")); + + // Act + var errors = RunValidate(assembly, "Testing.SignupSchema", signup); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("ConfirmPassword"); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Net11.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Net11.cs index 4da9b28d..b6c3eb5f 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Net11.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Net11.cs @@ -3,6 +3,8 @@ #if NET11_0_OR_GREATER using Microsoft.CodeAnalysis.CSharp; using ZodSharp.Core; +using ZodSharp.JsonSchema; +using ZodSharp.Rules; #endif namespace ZodSharp.SourceGenerators; @@ -359,5 +361,524 @@ public class Drawing await Assert.That(errors.Length).IsEqualTo(1); await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Shape.Radius"); } + + [Test] + public async Task UnionTarget_GivenZodSchemaOnUnion_DispatchesToTheActiveCaseSchema( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Cat + { + [MinLength(2)] + public string? Name { get; set; } + } + + [ZodSchema] + public class Dog + { + [Range(1, 100)] + public int Weight { get; set; } = 1; + } + + [ZodSchema] + public readonly union Pet(Cat, Dog); + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var petType = assembly.GetType("Testing.Pet")!; + var invalidCat = Activator.CreateInstance(petType, Create(assembly, "Testing.Cat", ("Name", "A")))!; + var validDog = Activator.CreateInstance(petType, Create(assembly, "Testing.Dog", ("Weight", 5)))!; + + // Act + var invalidErrors = RunValidate(assembly, "Testing.PetSchema", invalidCat); + var validErrors = RunValidate(assembly, "Testing.PetSchema", validDog); + + // Assert — the case's error is reported under its own path, without a synthetic union member. + await Assert.That(invalidErrors.Length).IsEqualTo(1); + await Assert.That(invalidErrors[0].Code).IsEqualTo("too_small"); + await Assert.That(invalidErrors[0].FullPath.ToDottedString()).IsEqualTo("Name"); + await Assert.That(validErrors.IsDefaultOrEmpty).IsTrue(); + } + + [Test] + public async Task UnionTarget_GivenEmptyUnion_ReportsInvalidUnion(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Cat + { + public string? Name { get; set; } + } + + [ZodSchema] + public class Dog + { + public int Weight { get; set; } + } + + [ZodSchema] + public readonly union Pet(Cat, Dog); + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var empty = Activator.CreateInstance(assembly.GetType("Testing.Pet")!)!; + + // Act + var errors = RunValidate(assembly, "Testing.PetSchema", empty); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("invalid_union"); + await Assert.That(errors[0].GetUnionErrors().Length).IsEqualTo(2); + } + + [Test] + public async Task UnionTarget_GivenAsyncCase_RequiresAsyncValidationAndAwaitsIt(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using System.Threading; + using System.Threading.Tasks; + using ZodSharp; + + namespace Testing + { + public sealed class TakenAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) => + throw new System.NotSupportedException("Validated asynchronously."); + + protected override Task IsValidAsync( + object? value, + ValidationContext validationContext, + CancellationToken cancellationToken + ) => Task.FromResult(new ValidationResult("Name is taken")); + } + + [ZodSchema] + public class Cat + { + [Taken] + public string? Name { get; set; } + } + + [ZodSchema] + public class Dog + { + public int Weight { get; set; } + } + + [ZodSchema] + public readonly union Pet(Cat, Dog); + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var cat = Activator.CreateInstance(assembly.GetType("Testing.Pet")!, Create(assembly, "Testing.Cat"))!; + var requiresAsync = (bool)GetStaticField(assembly, "Testing.PetSchema", "RequiresAsyncValidation")!; + + // Act + var asyncErrors = await RunValidationAsync(assembly, "Testing.PetSchema", cat, null, cancellationToken); + var syncException = Unwrap(() => RunValidate(assembly, "Testing.PetSchema", cat)); + + // Assert + await Assert.That(requiresAsync).IsTrue(); + await Assert.That(asyncErrors.Length).IsEqualTo(1); + await Assert.That(asyncErrors[0].Message).IsEqualTo("Name is taken"); + await Assert.That(asyncErrors[0].FullPath.ToDottedString()).IsEqualTo("Name"); + await Assert.That(syncException).IsTypeOf(); + } + + [Test] + public async Task UnionMember_GivenRequiredAndEmptyUnion_ReportsMissingField(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Cat + { + public string? Name { get; set; } + } + + [ZodSchema] + public class Dog + { + public int Weight { get; set; } + } + + public readonly union Pet(Cat, Dog); + + [ZodSchema] + public class Owner + { + [Required] + public Pet Pet { get; set; } + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var owner = Create(assembly, "Testing.Owner"); + + // Act + var errors = RunValidate(assembly, "Testing.OwnerSchema", owner); + + // Assert — an empty union is the member's absent value even though the union is a struct. + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("missing_field"); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Pet"); + } + + [Test] + public async Task UnionMember_GivenNonSentinelAndEmptyUnion_ReportsTheSentinelRule( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using ZodSharp; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public class Cat + { + public string? Name { get; set; } + } + + [ZodSchema] + public class Dog + { + public int Weight { get; set; } + } + + public readonly union Pet(Cat, Dog); + + [ZodSchema] + public class Owner + { + [NonSentinel] + public Pet Pet { get; set; } + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var owner = Create(assembly, "Testing.Owner"); + + // Act + var errors = RunValidate(assembly, "Testing.OwnerSchema", owner); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo(NonSentinelRule.ErrorCode); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Pet"); + } + + [Test] + public async Task UnionTarget_GivenHandWrittenUnionWithTryGetValue_DispatchesWithoutBoxing( + CancellationToken cancellationToken + ) + { + // Arrange - a [Union] struct that implements the non-boxing access pattern. + const string source = """ + using System.ComponentModel.DataAnnotations; + using System.Runtime.CompilerServices; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Cat + { + [MinLength(2)] + public string? Name { get; set; } + } + + [ZodSchema] + [Union] + public struct Pet + { + readonly object? _value; + + public Pet(Cat value) => _value = value; + + public object? Value => _value; + + public bool HasValue => _value is not null; + + public bool TryGetValue(out Cat value) + { + if (_value is Cat cat) + { + value = cat; + return true; + } + + value = null!; + return false; + } + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var generated = string.Join( + "\n", + result + .DriverResult.Results.SelectMany(static run => run.GeneratedSources) + .Select(static source => source.SourceText.ToString()) + ); + var assembly = result.CompilationResult.Assembly!; + var petType = assembly.GetType("Testing.Pet")!; + var invalidCat = Activator.CreateInstance(petType, Create(assembly, "Testing.Cat", ("Name", "A")))!; + var empty = Activator.CreateInstance(petType)!; + + // Act + var errors = RunValidate(assembly, "Testing.PetSchema", invalidCat); + var emptyErrors = RunValidate(assembly, "Testing.PetSchema", empty); + + // Assert — the generated dispatch uses TryGetValue (no boxing) and still reports the case's error. + await Assert.That(generated).Contains("TryGetValue(out global::Testing.Cat"); + await Assert.That(generated).DoesNotContain(".Value is global::Testing.Cat"); + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("too_small"); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Name"); + await Assert.That(emptyErrors[0].Code).IsEqualTo("invalid_union"); + } + + [Test] + public async Task JsonSchema_GivenMemberTypedAsUnion_ExportsOneOfTheCaseSchemas(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using ZodSharp; + + namespace Testing + { + public union Pet(Cat, Dog); + + [ZodSchema] + public class Cat + { + public string? Name { get; set; } + } + + [ZodSchema] + public class Dog + { + public string? Breed { get; set; } + } + + [ZodSchema] + public class Owner + { + public Pet Pet { get; set; } + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var schemaType = assembly.GetType("Testing.OwnerSchema")!; + + // Act + var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert — the member dispatches to the case schemas, so its schema is one of them, not the empty schema. + var pet = definition.Properties!["pet"]; + await Assert.That(pet.OneOf).IsNotNull(); + await Assert.That(pet.OneOf!.Count).IsEqualTo(2); + await Assert.That(pet.OneOf![0].Properties!.ContainsKey("name")).IsTrue(); + await Assert.That(pet.OneOf![1].Properties!.ContainsKey("breed")).IsTrue(); + } + + [Test] + public async Task JsonSchema_GivenMemberTypedAsClosedHierarchy_ExportsOneOfWithTheDiscriminator( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Text.Json.Serialization; + using ZodSharp; + + namespace Testing + { + [JsonPolymorphic(InferClosedTypePolymorphism = true)] + public closed class Shape + { + public string Id { get; set; } = ""; + } + + [ZodSchema] + public sealed class Circle : Shape + { + public double Radius { get; set; } + } + + [ZodSchema] + public sealed class Square : Shape + { + public double Side { get; set; } + } + + [ZodSchema] + public class Drawing + { + public Shape Shape { get; set; } = null!; + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var schemaType = assembly.GetType("Testing.DrawingSchema")!; + + // Act + var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert — inferred polymorphism writes "$type", so each branch constrains it to the derived type. + var shape = definition.Properties!["shape"]; + await Assert.That(shape.OneOf).IsNotNull(); + await Assert.That(shape.OneOf!.Count).IsEqualTo(2); + var circle = shape.OneOf!.Single(candidate => candidate.Properties!.ContainsKey("radius")); + await Assert.That(circle.Properties!.ContainsKey("id")).IsTrue(); + await Assert.That(circle.Required!).Contains("id"); + await Assert.That(circle.Properties!["$type"].Enum!).Contains("Circle"); + await Assert.That(circle.Required!).Contains("$type"); + var square = shape.OneOf!.Single(candidate => candidate.Properties!.ContainsKey("side")); + await Assert.That(square.Properties!.ContainsKey("id")).IsTrue(); + await Assert.That(square.Properties!["$type"].Enum!).Contains("Square"); + } + + [Test] + public async Task ClosedHierarchy_GivenDerivedCaseWithInvalidBaseProperty_ValidatesTheBaseProperty( + CancellationToken cancellationToken + ) + { + // Arrange — the derived case's schema validates the inherited member too, under the case's own path. + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public closed class Shape + { + [Required] + public string? Id { get; set; } + } + + [ZodSchema] + public sealed class Circle : Shape + { + [Range(1, 10)] + public int Radius { get; set; } + } + + [ZodSchema] + public class Drawing + { + public Shape Shape { get; set; } = null!; + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var circle = Create(assembly, "Testing.Circle", ("Id", null), ("Radius", 5)); + var drawing = Create(assembly, "Testing.Drawing", ("Shape", circle)); + + // Act + var errors = RunValidate(assembly, "Testing.DrawingSchema", drawing); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("missing_field"); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Shape.Id"); + } + + [Test] + public async Task JsonSchema_GivenClosedHierarchyWithExplicitDerivedTypes_UsesTheConfiguredDiscriminators( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Text.Json.Serialization; + using ZodSharp; + + namespace Testing + { + [JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] + [JsonDerivedType(typeof(Circle), "circle")] + [JsonDerivedType(typeof(Square), 2)] + public closed class Shape + { + } + + [ZodSchema] + public sealed class Circle : Shape + { + public double Radius { get; set; } + } + + [ZodSchema] + public sealed class Square : Shape + { + public double Side { get; set; } + } + + [ZodSchema] + public class Drawing + { + public Shape Shape { get; set; } = null!; + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var schemaType = assembly.GetType("Testing.DrawingSchema")!; + + // Act + var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert — a string discriminator stays a string, and an integer one is written as a JSON number. + var shape = definition.Properties!["shape"]; + var circle = shape.OneOf!.Single(candidate => candidate.Properties!.ContainsKey("radius")); + await Assert.That(circle.Properties!["kind"].Enum!).Contains("circle"); + var square = shape.OneOf!.Single(candidate => candidate.Properties!.ContainsKey("side")); + await Assert.That(square.Properties!["kind"].Enum!).Contains(2); + await Assert.That(square.Properties!["kind"].Enum!).DoesNotContain("2"); + } #endif } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationAttributes.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationAttributes.cs new file mode 100644 index 00000000..298242e9 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationAttributes.cs @@ -0,0 +1,376 @@ +using ZodSharp.JsonSchema; +using ZodSharp.SourceGenerators.Infra; +using static ZodSharp.SourceGenerators.Infra.GeneratedSchemaRuntime; + +namespace ZodSharp.SourceGenerators; + +/// +/// The DataAnnotations surface beyond the dedicated Zod rules: class-level attributes, unmapped +/// ValidationAttributes (for example [CustomValidation] and [FileExtensions]), the +/// Microsoft.Extensions.Validation [SkipValidation], and class-level async attributes. +/// +partial class ZodSchemaGeneratorTests +{ + [Test] + public async Task ValidationAttribute_GivenDisplayNameWithoutDisplay_UsesItInMessages( + CancellationToken cancellationToken + ) + { + // Arrange — .NET 11's Blazor DisplayName component supports [DisplayName]; the generated message should + // use it when [Display] is absent. + const string source = """ + using System.ComponentModel; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed class Model + { + [DisplayName("Production Date")] + [Required] + public string? ProductionDate { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var model = Create(assembly, "Testing.Model", ("ProductionDate", null)); + + // Act + var errors = RunValidate(assembly, "Testing.ModelSchema", model); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Message).IsEqualTo("Production Date is required"); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("ProductionDate"); + } + + [Test] + public async Task ValidationAttribute_GivenClassLevelSyncAttribute_Runs(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [AttributeUsage(AttributeTargets.Class)] + public sealed class ClassFailureAttribute : ValidationAttribute + { + public override bool IsValid(object? value) => false; + } + + [ClassFailure] + [ZodSchema] + public sealed class Model + { + public string? Name { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var model = Create(assembly, "Testing.Model", ("Name", "ok")); + + // Act + var errors = RunValidate(assembly, "Testing.ModelSchema", model); + var asyncErrors = await RunValidationAsync( + assembly, + "Testing.ModelSchema", + model, + services: null, + cancellationToken + ); + + // Assert — a class-level ValidationAttribute is an object-level rule, run in both paths. + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo(""); + await Assert.That(asyncErrors.Length).IsEqualTo(1); + } + + [Test] + public async Task ValidationAttribute_GivenPropertyCustomValidation_Runs(CancellationToken cancellationToken) + { + // Arrange — [CustomValidation] is a ValidationAttribute the generator has no dedicated rule for. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public static class Rules + { + public static ValidationResult? Check(object? value, ValidationContext context) => + value is "bad" ? new ValidationResult("Name is bad") : ValidationResult.Success; + } + + [ZodSchema] + public sealed class Model + { + [CustomValidation(typeof(Rules), nameof(Rules.Check))] + public string? Name { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var model = Create(assembly, "Testing.Model", ("Name", "bad")); + + // Act + var errors = RunValidate(assembly, "Testing.ModelSchema", model); + var asyncErrors = await RunValidationAsync( + assembly, + "Testing.ModelSchema", + model, + services: null, + cancellationToken + ); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Name"); + await Assert.That(errors[0].Message).IsEqualTo("Name is bad"); + await Assert.That(asyncErrors.Length).IsEqualTo(1); + await Assert.That(asyncErrors[0].FullPath.ToDottedString()).IsEqualTo("Name"); + } + + [Test] + public async Task ValidationAttribute_GivenPropertyFileExtensions_Runs(CancellationToken cancellationToken) + { + // Arrange — a DataTypeAttribute subclass the generator has no dedicated rule for. + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed class Model + { + [FileExtensions(Extensions = "txt")] + public string? FileName { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var model = Create(assembly, "Testing.Model", ("FileName", "a.exe")); + + // Act + var errors = RunValidate(assembly, "Testing.ModelSchema", model); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("FileName"); + } + + [Test] + public async Task SkipValidation_GivenProperty_IsOmittedFromValidationAndSchema(CancellationToken cancellationToken) + { + // Arrange — the .NET 11 attribute is declared here so the test runs on every TFM. + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + using Microsoft.Extensions.Validation; + using ZodSharp; + + namespace Microsoft.Extensions.Validation + { + [AttributeUsage(AttributeTargets.All, Inherited = true)] + public sealed class SkipValidationAttribute : Attribute { } + } + + namespace Testing + { + [ZodSchema] + public sealed class Model + { + [SkipValidation] + [MinLength(10)] + public string? Skipped { get; set; } + + [MinLength(2)] + public string? Kept { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var model = Create(assembly, "Testing.Model", ("Skipped", "x"), ("Kept", "ok")); + var schemaType = assembly.GetType("Testing.ModelSchema")!; + + // Act + var errors = RunValidate(assembly, "Testing.ModelSchema", model); + var definition = (JsonSchemaDefinition)schemaType.GetMethod("BuildJsonSchema")!.Invoke(null, null)!; + + // Assert — the skipped member is neither validated nor exported. + await Assert.That(errors).IsEmpty(); + await Assert.That(definition.Properties!.ContainsKey("skipped")).IsFalse(); + await Assert.That(definition.Properties!.ContainsKey("kept")).IsTrue(); + } + +#if NET11_0_OR_GREATER + [Test] + public async Task AsyncValidationAttribute_GivenClassLevelAttribute_RequiresAsyncAndRuns( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using System.Threading; + using System.Threading.Tasks; + using ZodSharp; + + namespace Testing + { + [AttributeUsage(AttributeTargets.Class)] + public sealed class ClassAsyncAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext context) => + throw new System.InvalidOperationException("Asynchronous only."); + + protected override async Task IsValidAsync( + object? value, + ValidationContext context, + CancellationToken cancellationToken + ) + { + await Task.Yield(); + return new ValidationResult("Class async rule failed"); + } + } + + [ClassAsync] + [ZodSchema] + public sealed class Model + { + public string? Name { get; set; } + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var model = Create(assembly, "Testing.Model", ("Name", "ok")); + var schemaType = assembly.GetType("Testing.ModelSchema")!; + + // Act + var requiresAsync = (bool)schemaType.GetField("RequiresAsyncValidation")!.GetRawConstantValue()!; + var sync = Assert.Throws(() => + RunValidate(assembly, "Testing.ModelSchema", model) + ); + var errors = await RunValidationAsync( + assembly, + "Testing.ModelSchema", + model, + services: null, + cancellationToken + ); + + // Assert + await Assert.That(requiresAsync).IsTrue(); + await Assert.That(sync).IsNotNull(); + await Assert.That(sync!.InnerException).IsTypeOf(); + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Message).IsEqualTo("Class async rule failed"); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo(""); + } + + [Test] + public async Task AsyncValidationAttribute_GivenInheritedClassAttribute_RequiresAsyncAndRuns( + CancellationToken cancellationToken + ) + { + // Arrange — ValidationAttribute is inherited, so a base class-level async rule applies to the derived + // [ZodSchema] target. + const string source = """ + using System.ComponentModel.DataAnnotations; + using System.Threading; + using System.Threading.Tasks; + using ZodSharp; + + namespace Testing + { + [AttributeUsage(AttributeTargets.Class, Inherited = true)] + public sealed class ClassAsyncAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext context) => + throw new System.InvalidOperationException("Asynchronous only."); + + protected override async Task IsValidAsync( + object? value, + ValidationContext context, + CancellationToken cancellationToken + ) + { + await Task.Yield(); + return new ValidationResult("Inherited async rule failed"); + } + } + + [ClassAsync] + public class BaseModel + { + } + + [ZodSchema] + public sealed class Model : BaseModel + { + public string? Name { get; set; } + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var model = Create(assembly, "Testing.Model", ("Name", "ok")); + var schemaType = assembly.GetType("Testing.ModelSchema")!; + + // Act + var requiresAsync = (bool)schemaType.GetField("RequiresAsyncValidation")!.GetRawConstantValue()!; + var errors = await RunValidationAsync( + assembly, + "Testing.ModelSchema", + model, + services: null, + cancellationToken + ); + + // Assert + await Assert.That(requiresAsync).IsTrue(); + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Message).IsEqualTo("Inherited async rule failed"); + } +#endif +} diff --git a/src/tests/SystemTextJson.UnitTests/Json/NativeUnionSerializationTests.cs b/src/tests/SystemTextJson.UnitTests/Json/NativeUnionSerializationTests.cs new file mode 100644 index 00000000..f317f8de --- /dev/null +++ b/src/tests/SystemTextJson.UnitTests/Json/NativeUnionSerializationTests.cs @@ -0,0 +1,161 @@ +#if NET11_0_OR_GREATER +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using ZodSharp.Unions; + +namespace ZodSharp.Json; + +/// +/// System.Text.Json reads and writes a transparently, and the classifier +/// applied to it resolves the token shapes .NET 11's default classifier cannot: a string read as a number +/// under , and two object-shaped cases. +/// +public class NativeUnionSerializationTests +{ + static JsonSerializerOptions WebOptions() => + new(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + + [Test] + public async Task Deserialize_GivenIntAndStringUnionUnderWebDefaults_SelectsTheStringCase( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — ASP.NET Core's JsonSerializerDefaults.Web lets a number be read from a string, so the + // platform's token classifier sees a string token claimed by both cases. + var options = WebOptions(); + + // Act + var union = JsonSerializer.Deserialize>("\"hello\"", options); + + // Assert + await Assert.That(union.Tag).IsEqualTo(2); + await Assert.That(union.TryGetValue(out string? value)).IsTrue(); + await Assert.That(value).IsEqualTo("hello"); + } + + [Test] + public async Task Deserialize_GivenIntAndStringUnionAndNumber_SelectsTheIntCase(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + var options = WebOptions(); + + // Act + var union = JsonSerializer.Deserialize>("42", options); + + // Assert + await Assert.That(union.Tag).IsEqualTo(1); + await Assert.That(union.TryGetValue(out int value)).IsTrue(); + await Assert.That(value).IsEqualTo(42); + } + + [Test] + public async Task Deserialize_GivenTwoObjectCases_SelectsTheCaseWhoseMembersMatch( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + var options = WebOptions(); + + // Act + var cat = JsonSerializer.Deserialize>("""{ "name": "Rex" }""", options); + var dog = JsonSerializer.Deserialize>("""{ "breed": "Husky" }""", options); + + // Assert + await Assert.That(cat.TryGetValue(out UnionCat? catValue)).IsTrue(); + await Assert.That(catValue!.Name).IsEqualTo("Rex"); + await Assert.That(dog.TryGetValue(out UnionDog? dogValue)).IsTrue(); + await Assert.That(dogValue!.Breed).IsEqualTo("Husky"); + } + + [Test] + public async Task Deserialize_GivenAmbiguousObjectCases_ThrowsJsonException(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — both cases declare the same JSON member, so no classifier can choose between them. + var options = WebOptions(); + + // Act + var exception = Assert.Throws(() => + JsonSerializer.Deserialize>("""{ "name": "Rex" }""", options) + ); + + // Assert + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task Serialize_GivenNativeUnion_WritesTheActiveCase(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + var options = WebOptions(); + + // Act + var text = JsonSerializer.Serialize>("hello", options); + var number = JsonSerializer.Serialize>(42, options); + + // Assert — no wrapper or discriminator is written. + await Assert.That(text).IsEqualTo("\"hello\""); + await Assert.That(number).IsEqualTo("42"); + } + + [Test] + public async Task Deserialize_GivenSourceGeneratedMetadata_UsesTheSameClassifier( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — proves the classifier works on the trim- and AOT-safe source-generated path, not only + // through the reflection resolver. + var context = NativeUnionJsonContext.Default; + + // Act + var text = JsonSerializer.Deserialize>("\"hello\"", context.Options); + var dog = JsonSerializer.Deserialize>( + """{ "breed": "Husky" }""", + context.Options + ); + + // Assert + await Assert.That(text.TryGetValue(out string? value)).IsTrue(); + await Assert.That(value).IsEqualTo("hello"); + await Assert.That(dog.TryGetValue(out UnionDog? dogValue)).IsTrue(); + await Assert.That(dogValue!.Breed).IsEqualTo("Husky"); + } +} + +public sealed class UnionCat +{ + public string Name { get; set; } = ""; +} + +public sealed class UnionDog +{ + public string Breed { get; set; } = ""; +} + +public sealed class AmbiguousCat +{ + public string Name { get; set; } = ""; +} + +public sealed class AmbiguousDog +{ + public string Name { get; set; } = ""; +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(NativeUnion))] +[JsonSerializable(typeof(NativeUnion))] +sealed partial class NativeUnionJsonContext : JsonSerializerContext; +#endif diff --git a/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj b/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj index fbef804f..c630e3e2 100644 --- a/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj +++ b/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj @@ -49,7 +49,7 @@ Directory.Packages.props picks the Microsoft.Extensions line by $(TargetFramework), which a single-targeted project has not set yet when that file is evaluated; Purview.ZodSharp.Blazor's component packages need the 11.x line. --> - 11.0.0-rc.3.26522.148 + 11.0.0 diff --git a/src/tests/ZodSharp.UnitTests/Core/ZodJsonExceptionsUnionTests.cs b/src/tests/ZodSharp.UnitTests/Core/ZodJsonExceptionsUnionTests.cs new file mode 100644 index 00000000..92ff2c06 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Core/ZodJsonExceptionsUnionTests.cs @@ -0,0 +1,94 @@ +#if NET11_0_OR_GREATER +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using ZodSharp.Unions; + +namespace ZodSharp.Core; + +/// +/// System.Text.Json reports a path in JSON names. A union member is not a member of the union contract, so +/// the segments below it belong to the active case; the mapper resolves them through the case contracts. +/// +public class ZodJsonExceptionsUnionTests +{ + static JsonTypeInfo Envelope() + { + JsonSerializerOptions options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + + return options.GetTypeInfo(); + } + + [Test] + public async Task ToMemberPath_GivenMemberEveryCaseShares_MapsItToItsMemberName(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var path = ZodJsonExceptions.ToMemberPath(ZodPath.ParseJsonPath("$.pet.name"), Envelope()); + + // Assert + await Assert.That(path.ToDottedString()).IsEqualTo("Pet.Name"); + } + + [Test] + public async Task ToMemberPath_GivenMemberOnlyOneCaseDeclares_MapsItToTheMatchingMemberName( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var path = ZodJsonExceptions.ToMemberPath(ZodPath.ParseJsonPath("$.pet.breed"), Envelope()); + + // Assert + await Assert.That(path.ToDottedString()).IsEqualTo("Pet.Breed"); + } + + [Test] + public async Task ToMemberPath_GivenUnionCollectionCase_MapsThroughTheElementContract( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var path = ZodJsonExceptions.ToMemberPath(ZodPath.ParseJsonPath("$.pets[0].name"), Envelope()); + + // Assert + await Assert.That(path.ToDottedString()).IsEqualTo("Pets[0].Name"); + } + + [Test] + public async Task ToMemberPath_GivenMemberNoCaseDeclares_KeepsTheJsonName(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var path = ZodJsonExceptions.ToMemberPath(ZodPath.ParseJsonPath("$.pet.unknown"), Envelope()); + + // Assert + await Assert.That(path.ToDottedString()).IsEqualTo("Pet.unknown"); + } + + public sealed class UnionEnvelope + { + public NativeUnion Pet { get; set; } + + public NativeUnion, string> Pets { get; set; } + } + + public sealed class UnionCat + { + public string Name { get; set; } = ""; + } + + public sealed class UnionDog + { + public string Breed { get; set; } = ""; + } +} +#endif diff --git a/src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs b/src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs index e5d4ce52..e1f137d2 100644 --- a/src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs +++ b/src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs @@ -68,7 +68,11 @@ public async Task Format_GivenFormatSpecifier_UsesInvariantCultureByDefault() try { // Act - var message = ZodMessageFormat.Format("{0:0.0}", [1.5], provider: null); + var message = ZodMessageFormat.Format( /*lang=json*/ + "{0:0.0}", + [1.5], + provider: null + ); // Assert await Assert.That(message).IsEqualTo("1.5"); diff --git a/src/tests/ZodSharp.UnitTests/Schemas/UnionTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/UnionTests.cs index c13730e8..31b15f75 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/UnionTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/UnionTests.cs @@ -15,7 +15,7 @@ public async Task Union_GivenT1Value_CreateStoresT1() var isInt = union.TryGetValue(out int _); // Assert - await Assert.That(union.Tag).IsEqualTo(0); + await Assert.That(union.Tag).IsEqualTo(1); await Assert.That(isString).IsTrue(); await Assert.That(s).IsEqualTo("hello"); await Assert.That(isInt).IsFalse(); @@ -32,7 +32,7 @@ public async Task Union_GivenT2Value_CreateStoresT2() var isInt = union.TryGetValue(out int i); // Assert - await Assert.That(union.Tag).IsEqualTo(1); + await Assert.That(union.Tag).IsEqualTo(2); await Assert.That(isInt).IsTrue(); await Assert.That(i).IsEqualTo(42); await Assert.That(isString).IsFalse(); @@ -45,7 +45,7 @@ public async Task Union_GivenImplicitConversionFromT1_StoresT1() Union union = "world"; // Act & Assert - await Assert.That(union.Tag).IsEqualTo(0); + await Assert.That(union.Tag).IsEqualTo(1); await Assert.That(union.TryGetValue(out string? s)).IsTrue(); await Assert.That(s).IsEqualTo("world"); } @@ -57,7 +57,7 @@ public async Task Union_GivenImplicitConversionFromT2_StoresT2() Union union = 99; // Act & Assert - await Assert.That(union.Tag).IsEqualTo(1); + await Assert.That(union.Tag).IsEqualTo(2); await Assert.That(union.TryGetValue(out int i)).IsTrue(); await Assert.That(i).IsEqualTo(99); } @@ -143,7 +143,7 @@ public async Task Union3_GivenT3Value_StoresT3() var union = Union.Create(true); // Act & Assert - await Assert.That(union.Tag).IsEqualTo(2); + await Assert.That(union.Tag).IsEqualTo(3); await Assert.That(union.TryGetValue(out bool b)).IsTrue(); await Assert.That(b).IsTrue(); } @@ -170,7 +170,7 @@ public async Task Union_GivenIUnion_CastAndAccessValue() #pragma warning restore CA1859 // Use concrete types when possible for improved performance // Act & Assert - await Assert.That(sut.Tag).IsEqualTo(0); + await Assert.That(sut.Tag).IsEqualTo(1); await Assert.That(sut.Value).IsEqualTo("hello"); } } diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodDiscriminatedUnionTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodDiscriminatedUnionTests.cs index d9cae925..d9efcdf7 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodDiscriminatedUnionTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodDiscriminatedUnionTests.cs @@ -105,6 +105,37 @@ public async Task DiscriminatedUnion_GivenRepeatedValidation_KeepsRoutingToMatch await Assert.That(second.IsSuccess).IsTrue(); } + [Test] + [NotInParallel] + public async Task DiscriminatedUnion_GivenReflectionEnabled_ReadsAnUnregisteredPocoDiscriminator( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange - the fallback for a POCO the union was not told about sits behind the + // ZodSharp.Reflection.IsSupported feature switch, which a trimmed or AOT application turns off. + const string reflectionSwitch = "ZodSharp.Reflection.IsSupported"; + var hadSwitch = AppContext.TryGetSwitch(reflectionSwitch, out var previous); + AppContext.SetSwitch(reflectionSwitch, true); + + try + { + var union = Z.DiscriminatedUnion("kind").Option("circle", new ObjectPassThroughSchema()).Build(); + + // Act + var result = union.Validate(new UnregisteredWitness()); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + } + finally + { + // AppContext cannot unset a switch: restore the value, or leave the effective default (enabled). + AppContext.SetSwitch(reflectionSwitch, hadSwitch ? previous : true); + } + } + static ZodDiscriminatedUnion CreateUnion() { ObjectPassThroughSchema userSchema = new(); @@ -117,6 +148,12 @@ sealed record UnionUser(string Type, string Name); sealed record UnionAdmin(string Type, string Name, string[] Permissions); + /// A POCO the union is not told about, so its discriminator is read by the reflection fallback. + sealed class UnregisteredWitness + { + public string Kind { get; set; } = "circle"; + } + sealed class ObjectPassThroughSchema : IZodSchema { public ValidationResult Validate(object value) => ValidationResult.Success(value); diff --git a/src/tests/ZodSharp.UnitTests/Unions/NativeUnionTests.cs b/src/tests/ZodSharp.UnitTests/Unions/NativeUnionTests.cs new file mode 100644 index 00000000..298ffec1 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Unions/NativeUnionTests.cs @@ -0,0 +1,114 @@ +#if NET11_0_OR_GREATER +namespace ZodSharp.Unions; + +/// +/// Coverage for the hand-written native union: the C# 15 language contract (implicit conversions, pattern +/// matching, exhaustiveness) plus the non-boxing access pattern that the declaration exists for. +/// +public class NativeUnionTests +{ + [Test] + public async Task NativeUnion_GivenValueTypeCases_PatternMatchesWithoutBoxing(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + NativeUnion warmup = 42; + _ = Describe(warmup); // Warm the JIT so the measurement covers the conversion and the switch. + + // Act + var before = GC.GetAllocatedBytesForCurrentThread(); + NativeUnion union = 42; // implicit union conversion runs the case constructor + var described = Describe(union); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + // Assert + await Assert.That(described).IsEqualTo(42); + await Assert.That(allocated).IsEqualTo(0); + + // Every case type is handled and there is no discard arm, so losing exhaustiveness is a compile error. + static int Describe(NativeUnion value) => + value switch + { + int number => number, + bool flag => flag ? 1 : 0, + }; + } + + [Test] + public async Task NativeUnion_GivenSecondCase_TryGetValueAndTagIdentifyTheCase(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + NativeUnion union = 7; + + // Act + var foundInt = union.TryGetValue(out int number); + var foundString = union.TryGetValue(out string? text); + + // Assert + await Assert.That(foundInt).IsTrue(); + await Assert.That(number).IsEqualTo(7); + await Assert.That(foundString).IsFalse(); + await Assert.That(text).IsNull(); + await Assert.That(union.Tag).IsEqualTo(2); + await Assert.That(union.HasValue).IsTrue(); + } + + [Test] + public async Task NativeUnion_GivenNullReferenceCase_ReportsNoValue(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + // A default union reached through an array element, the way one reaches a default-initialized field. + var union = (new NativeUnion[1])[0]; + + // Act + var hasValue = union.HasValue; + var value = union.Value; + var matchedNull = union is null; + + // Assert + await Assert.That(hasValue).IsFalse(); + await Assert.That(value).IsNull(); + await Assert.That(matchedNull).IsTrue(); + await Assert.That(union.Tag).IsEqualTo(0); + } + + [Test] + public async Task NativeUnion_GivenDefault_HasNoValue(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + NativeUnion union = default; + + // Act + var hasValue = union.HasValue; + + // Assert + await Assert.That(hasValue).IsFalse(); + await Assert.That(union.Value).IsNull(); + } + + [Test] + public async Task NativeUnionSchema_GivenValueTypeCases_UnwrapsWithoutBoxing(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var schema = Z.NativeUnion(Z.Number(), Z.Boolean()); + + // Act + var result = schema.Validate(3d); + var described = result.IsSuccess + ? result.Value switch + { + double number => number, + bool flag => flag ? 1d : 0d, + } + : -1d; + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(described).IsEqualTo(3d); + } +} +#endif