diff --git a/.agents/skills/project-placement-defaults/SKILL.md b/.agents/skills/project-placement-defaults/SKILL.md deleted file mode 100644 index 1c4ff1c..0000000 --- a/.agents/skills/project-placement-defaults/SKILL.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -name: project-placement-defaults -description: "Use when creating, moving, or splitting projects in a repository that uses Purview.BuildSdk, especially for src/tests placement, test suffix naming, namespace alignment, and automatic project-reference behavior." ---- - -# Project placement defaults for Purview.BuildSdk - -Use this skill whenever a task asks to add, move, split, or create a project in a repository that uses `Purview.BuildSdk` and you need placement, naming, and reference decisions to remain consistent with the SDK's automatic conventions. - -## Core principle - -Preserve the host repository's existing layout first; only introduce new structure when no established pattern exists. In repositories that use `Purview.BuildSdk`, prefer layouts that let the SDK's naming and auto-reference rules work without extra overrides. - -Treat naming and placement as configuration, not decoration. - -## Placement heuristics - -Use the repository's current structure as the source of truth, with these Purview-friendly defaults: - -1. Prefer source projects under `src/`. -2. Prefer test projects under `tests/`. -3. Place new projects beside similar projects (same language, layer, and test type). -4. Keep one test type per project by default. -5. Keep shared helper projects in explicit shared/shared-testing locations when those concepts exist. - -When a repo has no clear structure, use these conservative defaults because they align well with the SDK's automatic project-reference search paths: - -- Source/library projects under `src/` -- Test projects under `tests/` -- Integration/end-to-end tests in explicit sibling projects/folders such as `tests/Api.IntegrationTests/` or `tests/Api.E2ETests/` - -## Naming rules - -The SDK relies heavily on project names. - -- Keep the `.csproj` filename equal to its containing directory name unless `DisableProjectFileNamingConventionCheck=true` is explicitly used. -- Use the common conventional test suffixes by default: `.UnitTests`, `.IntegrationTests`, `.E2ETests`, `.FunctionalTests`, `.ContractTests`. -- Use other supported `*Tests` suffixes only when the test type itself carries important operational meaning. -- Keep shared helper projects on the SDK's exact recognized names when you want shared behavior: - - Shared projects: `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, `SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` - - Shared testing projects: `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, `SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, `SharedTestingHelpers` -- Do not invent near-miss names if you expect the SDK to classify the project automatically. - -## Test-type boundaries - -Separate tests by behavior and dependency scope: - -- **Unit tests**: isolate logic with minimal external dependencies. -- **Integration tests**: verify behavior across component boundaries (I/O, framework integration, build/evaluation behavior). -- **End-to-end/system tests**: verify full workflow behavior across the assembled system. - -If specialized test categories exist (for example, analyzer diagnostics vs code-fix integration), keep category-specific tests in distinct projects/folders. - -The detected test type also becomes the baseline test category. Additional categories remain available and -should be added when they improve discoverability. - -The SDK recognizes many test suffixes, including `Unit`, `Integration`, `E2E`, `EndToEnd`, `Acceptance`, `Functional`, `Performance`, `Load`, `Smoke`, `Stress`, `Regression`, `Security`, `Chaos`, `Scenario`, `System`, `Threat`, `BlackBox`, `WhiteBox`, `Accessibility`, `Interactive`, `Environment`, `Architecture`, and `Contract`. - -## Naming and namespace defaults - -Align identities with existing repository conventions: - -- Project names should follow prevailing patterns in sibling projects. -- Test project names should clearly indicate scope/type with recognized test suffixes. -- `NamespacePrefix` should remain the root identity source for the repo. -- `RootNamespace` usually flows from the logical project identity generated by the SDK; avoid custom namespace overrides unless required. -- `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — or to the full logical project name when suffix-stripping removed a segment (e.g. `Shared`, `ServiceDefaults`) — so a project's package/assembly identity follows its namespace and stays distinct, unless the repo explicitly overrides `AssemblyName`/`PackageId`/`RootNamespace` or opts out via `EnableAssemblyNameGeneration=false`. -- When moving files between projects, update namespaces so they match the destination project's conventions. - -Do not invent a new naming scheme when an existing one is already in use. - -## Project defaults - -When creating a new project: - -1. Match the SDK/project style used by sibling projects. -2. Reuse central dependency/version management if present. -3. Add only dependencies required for the project's scope. -4. Add the project to the repository solution/workspace entry point. -5. Keep configuration consistent with neighboring projects (target frameworks, nullable, analyzers, warnings). - -When working in a Purview-based repo, also assume: - -- `TargetFramework` defaults to `net10.0` if not otherwise set, or `netstandard2.0` when the project explicitly declares `IsRoslynComponent=true`. -- Test projects receive framework packages and coverage defaults from the SDK. -- Standard test projects receive `TUnit`, `TUnit.Mocks`, `Bogus`, and Microsoft.Testing.Platform wiring by default. -- Non-test projects receive SourceLink and telemetry defaults unless explicitly opted out. - -## Test readability defaults - -When organizing tests: - -- Prefer one subject-focused `{SubjectName}Tests` class per owned subject. -- Use `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` for subject-based test methods. -- Treat constructors, properties, operators, conversions, and validation hooks as valid subjects. -- Allow broader suite names for non-subject-based tests such as build, generator, workflow, package, or - full-system suites. -- Use TUnit display names and categories to keep large suites readable. - -## Move/split workflow checklist - -When splitting or relocating tests/projects: - -1. Create destination project/folder using established layout patterns. -2. Move files physically. -3. Update namespaces/imports/references for the destination. -4. Verify the destination project name still produces the intended `TestingType`, `TargetProjectName`, and `RootNamespace`. -5. Remove stale dependencies from the source project. -6. Update solution/workspace membership and project references. -6. Run build and relevant tests. - -## Automatic project-reference behavior to preserve - -The SDK automatically searches for project references based on naming and placement. - -- Test projects probe for their target project in these relative locations: - - `../$(TargetProjectName)/$(TargetProjectName).csproj` - - `../../$(TargetProjectName)/$(TargetProjectName).csproj` - - `../src/$(TargetProjectName)/$(TargetProjectName).csproj` - - `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` -- Non-test projects automatically look for sibling shared projects via `../Shared*/Shared*.csproj`. -- Test projects automatically look for sibling shared-testing projects via `../SharedTesting*/SharedTesting*.csproj`. - -If you move projects away from these conventions, be prepared to add explicit project references. - -## Guardrails - -- Prefer minimal, targeted diffs. -- Avoid cross-cutting renames unrelated to the move/split intent. -- Keep test intent unchanged while relocating. -- If structure is ambiguous, infer from nearest sibling projects and document the assumption in the change summary. -- When in doubt, preserve compatibility with the SDK's automatic naming, namespace, and project-reference behavior. diff --git a/.agents/skills/sdk-configuration-reference/SKILL.md b/.agents/skills/sdk-configuration-reference/SKILL.md deleted file mode 100644 index 5dd68c6..0000000 --- a/.agents/skills/sdk-configuration-reference/SKILL.md +++ /dev/null @@ -1,204 +0,0 @@ ---- -name: sdk-configuration-reference -description: "Use when configuring Purview.BuildSdk through Directory.Build.props or a .csproj, especially for NamespacePrefix, version detection, testing framework selection, telemetry, repo bootstrapping, and embedded agent-skill settings." ---- - -# Purview.BuildSdk configuration reference - -Use this skill when a task asks what can be configured in `Purview.BuildSdk`, where a property must be set, or which defaults the SDK applies automatically. - -## First rule: know where a property must be set - -Set repo-wide bootstrap properties **before** importing the SDK in `Directory.Build.props` when the value must affect `Sdk.props` evaluation. - -Common pre-import properties: - -- `NamespacePrefix` -- `UsePackageJsonVersion` -- `RootPackageJson` -- Repo-wide testing framework selection properties when you want every project to inherit them - -If a property changes behavior in `Sdk.targets` instead, it can usually be set later (for example in a project file), but prefer repo-wide defaults in `Directory.Build.props` unless the scenario is intentionally project-specific. - -## Version detection settings - -These properties control package/app version resolution from `package.json`: - -- `UsePackageJsonVersion` — default `true`; supported values: `true`, `false`, `Strict` -- `RootPackageJson` — explicit path to the `package.json` to read -- `EnableVersionDetectionCache` — default `true`; enables local caching of resolved version data -- `VersionDetectionCacheFile` — optional explicit cache file path -- `VersionDetectionLogEnabled` — default `false`; set to `true` to log the detected package version - -Behavior rules: - -1. If `RootPackageJson` is set, the SDK uses that path. -2. Otherwise it tries to discover the repo root from CI variables, `.git`, or a nearby `package.json`. -3. When version detection succeeds, both `Version` and `PackageVersion` are set from the `version` field. -4. `UsePackageJsonVersion=Strict` should be treated as “fail if discovery/resolution cannot succeed”. - -## Core identity and build settings - -These are the most important configurable properties exposed by the SDK: - -- `NamespacePrefix` — required unless `DisableNamespacePrefixCheck=true` -- `DisableNamespacePrefixCheck` — default `false` -- `DisablePurviewStylePolicyValidation` — default `false`; set to `true` to stop the build failing (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy (`dotnet_style_require_accessibility_modifiers` other than `omit_if_default`), hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` private instance field naming rule, or adds the accessibility rules to `NoWarn`. Entries the SDK injects itself (the test-context rule set for test/shared-testing projects, `CA1515` for Aspire hosts and CLI apps) are ignored -- `TargetFramework` — defaults to `net10.0` when neither `TargetFramework` nor `TargetFrameworks` is set; projects explicitly declaring `IsRoslynComponent=true` default to `netstandard2.0` -- `IsRoslynComponent` — when explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, compiler-generated output under the framework-specific intermediate directory, telemetry exclusion, and `PrivateAssets=all` applied to `Microsoft.CodeAnalysis.*` / `Microsoft.CodeAnalysis.Analyzers` references. Packable Roslyn components automatically pack the built analyzer assembly and PDB into `analyzers/dotnet/cs/`; a pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true` -- `IsRoslynComponentOnly` — defaults to `true` for Roslyn components and creates an analyzer-only package: it sets `IncludeBuildOutput=false`, `IncludeSymbols=false`, and packages the portable PDB alongside the analyzer under `analyzers/dotnet/cs/`. Set it to `false` for a dual-role Roslyn component that uses normal library symbol packaging. -- `PackProjectReferencedSourceGenerators` — default `true`; packable projects automatically include analyzer `ProjectReference` outputs and runtime dependencies under `analyzers/dotnet/cs/`. Set it to `false` globally or use `Pack="false"` on one analyzer reference to opt out. -- `EnableAssemblyNameGeneration` — default `true`; when `true`, `AssemblyName` and default `PackageId` follow the fully evaluated `RootNamespace` (or the full logical project name when suffix-stripping removed a segment, e.g. `Shared`/`ServiceDefaults`). Set `false` before the SDK import to use the standard project-name behaviour -- `PurviewSharedTestingOutputType` — default `Library`; forced onto `IsSharedTestingProject` projects (with `IsTestProject`/`IsTestingPlatformApplication` cleared), because the test packages otherwise flip them into an executable test host. Set it to `Exe` before the SDK import to keep that package-driven shape -- `PurviewTestContextNoWarn` — default `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707`; the production API-surface rules exempted in test and shared-testing projects. Test projects keep the strict style contract (`IDE0040`, field naming, formatting, `IDE1006`), but are context aware: public test classes/fixtures, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Override before the SDK import to narrow or extend the set -- `DisablePurviewTestContextRuleSet` — default `false`; set to `true` to make test and shared-testing projects enforce the production API-surface rules as well -- `DisableProjectFileNamingConventionCheck` — default `false`; disables the directory-name/file-name match validation -- `DisableGenerateAssemblyInfoClass` — default `false`; disables generated `AssemblyInfo` -- `DisableAutoInternalsVisibleTo` — default `false`; disables automatic friend assembly generation -- `AutoIncludeUsings` — default `true`; controls SDK-added global usings -- `SourceLinkPackageName` — default `Microsoft.SourceLink.GitHub` -- `DisableSourceLink` — default `false` - -## Telemetry and package-related settings - -- `ExcludePurviewTelemetry` — default `false`; removes `Purview.Telemetry.SourceGenerator` -- `ExcludeMSTelemetryExtension` — default `false`; removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — when `ExcludePurviewTelemetry=true` the whole telemetry group is skipped anyway -- `IsPackable` — defaults to `false` if not set elsewhere -- `PackageTags`, `IncludeSource`, `IncludeSymbols`, `PublishRepositoryUrl`, `SymbolPackageFormat` — standard pack-related settings the SDK participates in for packable projects -- Packable-project defaults (only applied when the consuming project has not supplied a value): `GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs are delivered through the `.snupkg`; the normal `.nupkg` does not receive PDB files unless the project opts in explicitly. Roslyn-component-only packages default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/` instead -- If the repo root is discoverable, the repository-root `README.md` is packed automatically (and registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` was not configured explicitly - -## Test framework settings - -The SDK supports opinionated testing defaults and validation. - -Primary settings: - -- `TestingFramework` — default `TUnit`; supported values: `TUnit`, `Xunit`, `None` -- `SubstituteFramework` — default `TUnitMocks`; supported values: `TUnitMocks`, `NSubstitute`, `None` -- `TestDataFramework` — default `Bogus`; supported values: `Bogus`, `None` - -Default outcome for standard test projects: - -- `TUnit` -- `TUnit.Mocks` -- `Bogus` -- Microsoft.Testing.Platform integration - -Specialised packages such as `TUnit.Aspire` and `Testcontainers` are not automatic defaults; they remain -explicit choices based on the project's purpose. - -Related toggles and derived settings: - -- `CollectCoverage` — defaults to `true` for detected test projects -- `EnableStaticNativeInstrumentation` — defaults to `false` for test projects -- `EnableDynamicNativeInstrumentation` — defaults to `false` for test projects -- `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` — enabled automatically for TUnit test projects - -## Repo bootstrap and developer-experience settings - -These settings control the SDK’s repo-level helper file bootstrapping: - -- `DisableAutoCopySdkFiles` — default `false`; master switch for SDK-managed repo file copying -- `BootstrapEditorConfigToRepoRoot` — default `true` -- `RepositoryEditorConfigFilePath` — optional override for the destination `.editorconfig` -- `BootstrapGlobalJsonToRepoRoot` — default `true` -- `RepositoryGlobalJsonFilePath` — optional override for the destination `global.json` -- `PurviewBuildSdkVersionForGlobalJson` — defaults to detected SDK package version, fallback `1.0.0` -- `PurviewAutoSdkPack` — default `true`; when `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths -- `EnableAgentFolderInPackage` — default `true`; mirrors the bundled `.agents/**` folder from the SDK NuGet package into the consuming repo’s `.agents/` -- `AgentPackDestinationFolder` — default `.agents`; repo-relative destination folder that receives mirrored agent content as `$(AgentPackDestinationFolder)/**` -- `PurviewAgentFolderSourcePath` — overrides the folder that provides the bundled `.agents` content (defaults to the package-level `.agents` folder beside `Sdk/`) -- `PurviewAgentFolderCopyRetries` — default `3`; copy attempts per file before a failure is reported -- `PurviewAgentFolderCopyRetryDelayMilliseconds` — default `500`; base delay between copy attempts -- `PurviewAgentFolderCopyFailureAsError` — default `true`; when `false`, a copy that still fails after every retry is a warning instead of an error -- `PurviewAgentSyncManifestPath` — overrides the change-detection manifest (default `/.purview/agent-sync.cache`) used to skip unchanged agent content -- `PurviewSuppressCopyRetryWarnings` — default `true`; demotes built-in copy task retry notices (`MSB3026`) to messages. Set to `false` to see every retry attempt - -## Shared repository copy behaviour - -`.agents` content, `.editorconfig` and `global.json` live in one repository-wide location but are written -by every project, so parallel builds race for the same destinations. The SDK therefore: - -1. Skips unchanged content using the `.purview/agent-sync.cache` manifest (fingerprint + content hash per file), so repeat builds touch nothing. This also detects an in-place package republish that keeps the same version. -2. Stages every write into a temporary file in the destination folder and renames it into place, so readers never see partial content and writers cannot interleave. -3. Retries quietly — retry attempts are low-importance messages, `MSB3026` notices are demoted — and only reports a copy that still fails after `Purview*CopyRetries` attempts, as an error by default. -4. Treats "another project already wrote identical content" as success, so a lost race is a no-op instead of a failure. - -`PurviewRepoBootstrapMode` (`IfMissing` default, or `Always`/`WarnOnDrift`/`Never`) controls whether -existing `.editorconfig`/`global.json` files may be overwritten or reported as drifted. - -**Hard requirement:** This SDK must pack the contents of `Sdk/` into the NuGet package so that downstream consumers of `Purview.BuildSdk` receive the same `Sdk/**` files. The `PurviewAutoSdkPack` feature (default `true`) is the mechanism that delivers this for standard consuming projects. When a project is packable, the SDK automatically adds `Sdk/**/*` as package content with the correct root-level paths: - -- `Sdk/.agents/**` → `.agents/**` -- `Sdk/.github/**` → `.github/**` -- `Sdk/build/**` → `build/**` -- `Sdk/buildTransitive/**` → `buildTransitive/**` -- `Sdk/buildMultiTargeting/**` → `buildMultiTargeting/**` -- `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.jpg`, etc. → package root -- everything else under `Sdk/` → `Sdk/` - -The SDK injects a `.gitignore` file into each second-level folder under `Sdk/.agents` during packaging with the following content: - -```text[.gitignore] -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore -``` - -This lets consuming repos keep the agent folder structure discoverable while ignoring the copied content in Git. - -## Important derived properties you can inspect - -When explaining SDK behavior, prefer these derived values over guessing: - -- `PurviewLogicalProjectName` -- `PurviewNamespacePrefix` -- `PurviewProjectShortName` -- `PurviewTestType` -- `PurviewSharedTestingOutputType` (default `Library`; `Exe` keeps the test packages' executable/test-host shape) -- `PurviewTestContextNoWarn` (production API-surface rules exempted in test/shared-testing projects) -- `PurviewPolicyExemptNoWarn` (SDK-injected `NoWarn` entries that `ValidatePurviewStylePolicy` accepts) -- `RootNamespace` -- `AssemblyName` -- `PackageVersion` -- `TestingType` -- `TargetProjectName` -- `RepoRoot` -- `RootPackageJson` - -## Compiler-visible properties - -The SDK exports many properties for analyzers and source generators through `build_property.`. When authoring analyzers or generators, prefer those exported properties instead of re-deriving SDK behavior manually. - -Especially relevant exported properties include: - -- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` -- `NamespacePrefix`, `DisableNamespacePrefixCheck` -- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` -- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` -- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass` -- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` -- `TestingType`, `TargetProjectName` -- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` -- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` -- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` -- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` -- `PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopyRetries`, `PurviewAgentFolderCopyRetryDelayMilliseconds`, `PurviewAgentFolderCopyFailureAsError`, `PurviewAgentSyncManifestPath`, `PurviewSuppressCopyRetryWarnings` -- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile` - -## Guidance for edits - -When changing SDK configuration: - -1. Preserve existing defaults unless the task explicitly changes product behavior. -2. Keep README, SDK property declarations, validation, and any shipped skills aligned. -3. If you add a new user-facing property, update both the configuration docs and the bundled skills. -4. If the property affects import-time behavior, document that it must be set before the SDK import. -5. Keep repository policy guidance aligned with the engineering-principles documentation, and keep low-level - property explanations aligned with the wiki reference pages. diff --git a/.agents/skills/sdk-engineering-principles/SKILL.md b/.agents/skills/sdk-engineering-principles/SKILL.md deleted file mode 100644 index c5c3140..0000000 --- a/.agents/skills/sdk-engineering-principles/SKILL.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -name: sdk-engineering-principles -description: "Use when creating or rationalising a repository that uses Purview.BuildSdk and you need the policy-level conventions for project placement, naming, namespace identity, test categories, and large-suite readability." ---- - -# Purview.BuildSdk engineering principles - -Use this skill when the question is not just "what property does the SDK set?" but "how should this -repository be structured so the SDK can work predictably?" - -## First principle - -Treat naming, placement, and test structure as configuration. - -The SDK infers namespaces, identities, categories, package wiring, and project references from a small set -of conventions. The more a repository follows those conventions, the less it needs bespoke overrides. - -## Canonical defaults - -- Prefer a `src/` + `tests/` split for new repositories. -- Keep solution entry points under `src/{SolutionName}.slnx`. -- Keep source projects under `src/src/{ProjectName}/{ProjectName}.csproj`. -- Keep test projects under `src/tests/{ProjectName}.{TestType}Tests/{ProjectName}.{TestType}Tests.csproj`. -- Keep the `.csproj` filename equal to its containing directory name. - -## Identity rules - -- `NamespacePrefix` is the root identity source. -- Use short project names; let the SDK apply the prefix. -- `RootNamespace` is the canonical code identity by default. -- `AssemblyName` and `PackageId` usually follow the resolved project identity. -- When suffix stripping would collapse distinct artifacts, `AssemblyName` and `PackageId` keep the fuller - logical identity. - -Examples: - -- `NamespacePrefix=Aspire`, project `Hosting` -> `Aspire.Hosting` -- `NamespacePrefix=Acme.Sales.RegionalPipeline`, project `Identity.API` -> - `Acme.Sales.RegionalPipeline.Identity.API` -- `NamespacePrefix=Acme.Sales.RegionalPipeline`, project `Identity.Core` -> `RootNamespace` - `Acme.Sales.RegionalPipeline.Identity`, but a distinct assembly/package identity that keeps `Core` - -## Common test project types - -Prefer these by default: - -- `UnitTests` -- `IntegrationTests` -- `E2ETests` -- `FunctionalTests` -- `ContractTests` - -The SDK supports more suffixes, but use them only when the test type itself is important enough to carry in -the project name. - -## Test categories - -- The detected test type becomes the baseline category automatically. -- Additional categories are allowed. -- Add more categories when they improve discoverability for large suites. - -## Default test stack - -Standard test projects receive these by default: - -- `TUnit` -- `TUnit.Mocks` -- `Bogus` -- Microsoft.Testing.Platform integration - -Specialized additions remain explicit: - -- `TUnit.Aspire` for Aspire lifecycle/AppHost-backed integration tests -- `Testcontainers` for container-backed integration tests - -## Test readability rules - -For subject-based tests: - -- Prefer `{SubjectName}Tests` for the class. -- Prefer `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` for methods. -- Treat methods, constructors, properties, operators, conversions, and validation hooks as valid subjects. - -For non-subject-based suites: - -- Broader names are allowed when they are more truthful and readable. -- Use TUnit display names, categories, and data-driven metadata to keep the suite navigable. - -## When to use this skill vs others - -- Use this skill for policy, structure, naming, and repository-shape questions. -- Use `sdk-project-behavior-and-detection` for "why did the SDK classify this project this way?" -- Use `sdk-configuration-reference` for property-level questions. -- Use `project-placement-defaults` when physically creating or moving projects. diff --git a/.agents/skills/sdk-project-behavior-and-detection/SKILL.md b/.agents/skills/sdk-project-behavior-and-detection/SKILL.md deleted file mode 100644 index 9961d00..0000000 --- a/.agents/skills/sdk-project-behavior-and-detection/SKILL.md +++ /dev/null @@ -1,214 +0,0 @@ ---- -name: sdk-project-behavior-and-detection -description: "Use when explaining why Purview.BuildSdk classified a project as test, shared, CLI, web, Aspire host, or container, or when reasoning about auto-added packages, project references, namespaces, and naming conventions." ---- - -# Purview.BuildSdk project behavior and detection - -Use this skill when a task asks **why** the SDK applied a behavior automatically, or when adding/moving projects in a repo that relies on the SDK's naming and project-type inference. - -## Project-type detection rules - -The SDK infers behavior from project names, project contents, and SDK declarations. - -### Test detection - -A project is treated as a test project when its name ends with `*Test` or `*Tests` and the suffix before `Test(s)` matches a supported testing type such as: - -- `Unit` -- `Integration` -- `E2E` -- `EndToEnd` -- `Acceptance` -- `Functional` -- `Performance` -- `Load` -- `Smoke` -- `Stress` -- `Regression` -- `Security` -- `Chaos` -- `Scenario` -- `System` -- `Threat` -- `BlackBox` -- `WhiteBox` -- `Accessibility` -- `Interactive` -- `Environment` -- `Architecture` -- `Contract` - -Derived properties: - -- `IsTestProject=true` -- `TestingType=` -- `PurviewTestType=Tests` -- `TargetProjectName=` - -### Shared project detection - -The SDK recognizes shared project names exactly. These are not generic substring matches. - -Shared project names: - -- `Shared` -- `SharedFramework` -- `SharedInfrastructure` -- `SharedInfra` -- `SharedUtilities` -- `SharedUtils` -- `SharedLibrary` -- `SharedLib` -- `SharedHelpers` - -Shared testing project names: - -- `SharedTestingFramework` -- `SharedTestingInfrastructure` -- `SharedTestingInfra` -- `SharedTestingUtilities` -- `SharedTestingUtils` -- `SharedTestingLibrary` -- `SharedTestingLib` -- `SharedTestingHelpers` - -Derived flags: - -- `IsSharedProject` -- `IsSharedTestingProject` - -### SDK/content-based detection - -- `IsSdkProject` / `SdkProjectName` come from parsing the project/import `Sdk="..."` declaration -- `IsWebSdkProject=true` for `Microsoft.NET.Sdk.Web` -- `IsWorkerSdkProject=true` for `Microsoft.NET.Sdk.Worker` -- `IsAspireHostProject=true` when the SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk` -- `IsContainerProject=true` when `Dockerfile`, `dockerfile`, or `Dockerfile.dev` exists in the project directory -- `IsCLIProject=true` when the project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, or `QuickStarts` - -## Namespace and identity behavior - -The SDK derives the project identity from `NamespacePrefix` and the project name. - -Key behavior: - -1. `PurviewLogicalProjectName` is built from `NamespacePrefix` plus the project name, with deduplication when the project name already starts with the namespace tail. -2. `RootNamespace` defaults to `PurviewLogicalProjectName`. -3. Known suffixes are stripped from `RootNamespace`, including shared/shared-testing names and common segments like `Core`, `EF`, `Shared`, `ClientShared`, and `ServiceDefaults`. -4. Test suffixes are removed from `RootNamespace`, so `Acme.Api.UnitTests` still maps back to `Acme.Api`. -5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` (the canonical default public name) — except when suffix-stripping removed a segment of the logical project name (e.g. `Shared` or `ServiceDefaults`), in which case they use the full `PurviewLogicalProjectName` so those assemblies/packages stay distinct from their parent. Test/shared-testing projects keep their detected suffix in `AssemblyName`/`PackageId` so test assemblies stay distinct. Explicit `AssemblyName`/`PackageId`/`RootNamespace` values always win. -6. The naming defaults are applied during `Sdk.props` evaluation (before the Microsoft SDK computes `TargetName`), so the compiled output name always matches `AssemblyName`. -7. The SDK ships `Purview.BuildSdk.Analyzers` and adds it as an `` item to every C# project, so its rules (PDS0002 Extensions namespace, PDS0003 explicit types with target-typed `new()`, PDS0004 correct acronym capitalization) surface in both command-line builds and Visual Studio. The code-fix assembly ships beside it and is referenced as an `` item inside Visual Studio so the IDE discovers its code fixes. `PDS0004` follows .NET naming guidance for well-known framework spellings (`Sql`, `Guid`, `Uuid`, `Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, ...) and exempts them by default — `Db` is exempt so `DbContext`/`DbConnection`/`DbSet` are never flagged (re-enable via `acronym_map = Db:DB`); a small set — `Api`, `Ai`, `Ui`, `Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh` — is still renamed to uppercase. Members mandated by a contract (interface implementations, base-class overrides) are never renamed. Customise via `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), `.acronym_map` (segment renames, e.g. `Sql:SQL`), and `.allowed_identifiers` (brand names exempted by exact name or word-boundary prefix, first match wins — e.g. `CosmosDb` covers `CosmosDbServer`). All three merge with and override the shipped defaults. The shipped `.editorconfig` treats generated content (`Migrations/`, `*.g.cs`, `Generated/`, `*.Designer.cs`, `obj/`/`bin/`) as `generated_code = true`, and suppresses namespace-conflict diagnostics under `Extensions/` so no `#pragma` suppressions are needed there. A VS code refactoring (`Split extensions class into one class per receiver type`) is offered on static extensions classes that target multiple receiver types: it splits them into one `Extensions` class per receiver (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes `HostApplicationBuilderExtensions`) and places each under `Extensions//` so the PDS0002 convention stays satisfied. A companion refactoring (`Move extensions class to conventional location`) is offered on single-receiver extensions classes that are misplaced: it re-paths the file to `Extensions//`, fixes the namespace to the receiver's namespace, and updates `using` directives in other referencing documents so the move compiles. - -Do not hand-author alternate namespace conventions unless the repository explicitly opts out of the SDK defaults. - -## Automatic project references - -The SDK adds project references based on layout conventions. - -### Non-test projects - -For ordinary non-test, non-shared projects, it automatically looks for sibling shared projects: - -- `../Shared*/Shared*.csproj` - -It also removes accidental self/shared-testing matches. - -### Test projects - -For detected test projects, it attempts these target-project paths in order when they exist: - -- `../$(TargetProjectName)/$(TargetProjectName).csproj` -- `../../$(TargetProjectName)/$(TargetProjectName).csproj` -- `../src/$(TargetProjectName)/$(TargetProjectName).csproj` -- `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` - -It also adds sibling shared-testing project references via: - -- `../SharedTesting*/SharedTesting*.csproj` - -This is why consistent naming and placement matter so much in repos that use the SDK. - -## Automatic framework/package behavior - -### For non-test C# projects - -- Adds SourceLink unless `DisableSourceLink=true` -- Adds Purview telemetry packages unless `ExcludePurviewTelemetry=true` -- Generates documentation files (`GenerateDocumentationFile=true`) unless explicitly disabled -- Generates `InternalsVisibleTo` attributes unless `DisableAutoInternalsVisibleTo=true` - -### For packable projects - -- Defaults `GenerateDocumentationFile`, `IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, `IncludeSource`, and `DebugType=portable` — only when the consuming project has not supplied a value -- Delivers portable PDBs via the `.snupkg`; the normal `.nupkg` does not receive PDBs unless the project opts in explicitly -- Packs the repository-root `README.md` (registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` is unset; skips when a README is already being packed -- Non-packable projects (including web apps) default `WarnOnPackingNonPackableProject=false` so solution-wide pack operations skip them silently - -### For Roslyn component (analyzer/source-generator) projects - -- Defaults a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, and SourceLink with `EmbedUntrackedSources=true` -- `IsRoslynComponentOnly` defaults to `true`, excluding normal build output (`IncludeBuildOutput=false`) and setting `IncludeSymbols=false`; no `.symbols.nupkg` or `.snupkg` is produced and the analyzer PDB ships inside the main `.nupkg` under `analyzers/dotnet/cs/` beside the analyzer assembly (`PurviewPackAnalyzerPdb=true`). Set it to `false` for a dual-role component that uses normal library symbol packaging. -- Packable Roslyn components automatically pack the built analyzer assembly (and PDB) into `analyzers/dotnet/cs/`; `SymbolPackageFormat` defaults to the modern `snupkg` if symbols are explicitly opted into -- `Microsoft.CodeAnalysis.*` and `Microsoft.CodeAnalysis.Analyzers` references are defaulted to `PrivateAssets=all` (development-only dependencies) so they never leak into the packed nuspec -- A pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack of a packable Roslyn component if `LangVersion`, `Nullable`, `TreatWarningsAsErrors`, or `EnforceExtendedAnalyzerRules` is missing; opt out with `DisableRoslynCompilerDefaultsValidation=true` -- `ContinuousIntegrationBuild` is set only by real CI environment variables — packability alone never forces SourceLink's CI-mode dirty-repository checks - -### For test and shared-testing projects - -- Applies test-friendly `NoWarn` defaults -- Marks projects as not packable/publishable -- Adds substitute/test-data/testing packages based on `SubstituteFramework`, `TestDataFramework`, and `TestingFramework` -- For TUnit test projects, enables Microsoft.Testing.Platform integration properties automatically -- For shared-testing projects, skips the runnable test package and marks them with a skip/category pattern appropriate to the selected test framework - -For the default configuration, standard test projects receive: - -- `TUnit` -- `TUnit.Mocks` -- `Bogus` -- Microsoft.Testing.Platform integration - -Specialized testing dependencies such as `TUnit.Aspire` and `Testcontainers` are still explicit additions by -project purpose. - -### For special project types - -- CLI projects default to `OutputType=Exe` and include `appsettings*.json` as content -- Container projects enable `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, and container tooling package references -- Web SDK projects get `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces` unless marked as a separate web-project mode -- Aspire host projects default to `OutputType=Exe` - -## How to reason about surprising behavior - -If the SDK “did something unexpected”, inspect these values first: - -- `MSBuildProjectName` -- `NamespacePrefix` -- `PurviewLogicalProjectName` -- `RootNamespace` -- `TestingType` -- `TargetProjectName` -- `SdkProjectName` -- `IsTestProject` -- `IsSharedProject` -- `IsSharedTestingProject` -- `IsContainerProject` -- `IsCLIProject` -- `IsWebSdkProject` -- `IsAspireHostProject` - -Prefer explaining behavior from these computed properties rather than from assumptions about folder names alone. - -## Guidance for structural changes - -When adding or moving projects in a repo using this SDK: - -1. Keep the `.csproj` filename equal to its containing directory name unless the repo explicitly disables that validation. -2. Preserve established `src/` and `tests/`-style layouts whenever possible. -3. Use test project suffixes intentionally so auto-detection and auto-references work. -4. Keep shared helpers in exact shared/shared-testing names if you want the corresponding SDK behavior. -5. If you change a naming rule in the SDK, update the README and the shipped skills together. -6. If the question is really about repository policy rather than one computed property, point the user to the - engineering-principles documentation first, then explain the specific SDK mechanics. diff --git a/.agents/skills/source-generator-codewriter-modernization/SKILL.md b/.agents/skills/source-generator-codewriter-modernization/SKILL.md deleted file mode 100644 index 8501ca4..0000000 --- a/.agents/skills/source-generator-codewriter-modernization/SKILL.md +++ /dev/null @@ -1,324 +0,0 @@ ---- -name: source-generator-codewriter-modernization -description: "Use when implementing, reviewing, or refactoring C# source generators and analysers in Purview.SourceGeneratorFramework. Covers CodeWriter/XmlCommentWriter-style emission, incremental pipeline design, value equality, and Roslyn best practices." ---- - -# Source generator CodeWriter modernization - -Use this skill for any work involving C# source generators, analysers, or generated output in `Purview.SourceGeneratorFramework`. It combines CodeWriter/XmlCommentWriter emission guidance with the incremental-source-generator and analyser best practices that ship with the framework. - -## Required implementation pattern - -When creating source output, favor this shape: - -1. Build immutable, value-equatable pipeline values first. -2. Register source output. -3. Inside the callback: `var writer = generationContext.CreateCodeWriter();` -4. Write header/usings/namespace. -5. Write structured types and members using declaration options records. -6. Add source once per output artifact. - -### CodeWriter lifetime: created in the output callback, never in pipeline state - -The line between "fine" and "forbidden" is the **incremental-cache boundary**, not the act of -creating a writer or handing it to a helper. - -**Fine — output-scoped use inside a `RegisterSourceOutput` callback.** Create the writer inside the -callback and pass it to any emitter/helper methods called from that same callback. It may be held in -local variables, passed as a parameter, or wrapped in a short-lived output context: - -```csharp -context.RegisterSourceOutput( - targets.CombineWithContext(contextProvider), - static (spc, pair) => - { - var (model, generationContext) = pair; - var writer = generationContext.CreateCodeWriter(); - EmitHeader(writer, model); - EmitType(writer, model); - spc.AddSource($"{model.Name}.g.cs", writer.ToString()); - } -); -``` - -**Forbidden — persisting the writer across the incremental-cache boundary.** Do not create it earlier -in the pipeline and pass it down, and do not store it anywhere Roslyn caches or another callback can -observe it: - -- Do not create a `CodeWriter` in a provider stage and carry it through the pipeline. -- Do not store a `CodeWriter` as a property or field on `GenerationContext` or a custom context. -- Do not return a `CodeWriter` (or an object holding one) from an incremental provider. -- Do not cache or reuse a writer for a later callback or a different output. - -A cached writer can retain previously written source, mix output from concurrently running -callbacks, and defeat scope tracking. A writer created in the callback and used only within that -callback is safe and expected. - -## Source generator & analyser best practices - -Apply the following rules to every generator, analyser, and refactor. - -### 1. Core principles - -- **Analyser for validation; generator for generation.** -- **Syntax for syntax, symbols for declarations, operations for executable semantics.** -- **Use `ForAttributeWithMetadataName` for attribute-driven generators.** -- **Remove Roslyn objects from the incremental pipeline as early as possible.** -- **Every value crossing a pipeline boundary must have meaningful value equality.** -- **Prefer many small incremental stages over one large transform.** -- **Keep broad inputs such as `Compilation` away from downstream generation.** -- **Generate deterministic output.** -- **Compile against the oldest Roslyn API version containing the functionality you need.** -- **Test caching, not just generated text.** - -The guiding principle for an incremental generator is: - -> Extract semantic information once, convert it into a small value model, and make everything downstream operate only on that value model. - -### 2. Analyser vs source generator - -Use a `DiagnosticAnalyser` when the question is: - -> Is the source code valid according to this library's rules? - -Use an `IIncrementalGenerator` when the question is: - -> Given valid source code, what source should be generated? - -| Requirement | Prefer | -| --- | --- | -| Require a class to be `partial` | Analyser | -| Require an attribute on a declaration | Analyser | -| Validate a method signature | Analyser | -| Reject unsupported property types | Analyser | -| Detect invalid attribute arguments | Analyser | -| Detect unsupported API usage | Analyser | -| Offer an automatic fix | Analyser + `CodeFixProvider` | -| Generate members for a marked class | Incremental generator | -| Generate serializers/validators/mappers | Incremental generator | -| Generate a registry from discovered types | Incremental generator | -| Read a schema file and generate C# | Incremental generator | -| Internal generation failure | Generator diagnostic | - -### 3. Choosing an analyser action - -Use the narrowest API that represents the concept being analysed: - -- `RegisterSyntaxNodeAction` — exact source syntax (e.g., modifier presence). -- `RegisterSymbolAction` — declaration semantics (e.g., attributes, interfaces, accessibility). -- `RegisterOperationAction` — executable behaviour (e.g., invocation, assignment, object creation). -- `RegisterOperationBlockStart/EndAction` — stateful method analysis. -- `RegisterSymbolStart/EndAction` — type-wide analysis across members. -- `RegisterCompilationStartAction` — resolve known framework symbols once. -- `RegisterAdditionalFileAction` — analyse `AdditionalFiles`. -- Avoid `RegisterSyntaxTreeAction`, `RegisterSemanticModelAction`, and compilation-end actions unless genuinely necessary. - -### 4. Syntax vs symbol vs operation - -Decision tree: - -1. Does exact source spelling/structure matter? → **Syntax** -2. Otherwise, is it a declaration? → **Symbol** -3. Otherwise, is it executable behaviour? → **Operation** - -Use `SymbolEqualityComparer.Default.Equals(...)` when comparing symbols. - -### 5. Analyser best practices - -- Enable concurrent execution with `context.EnableConcurrentExecution()`. -- Explicitly configure generated-code analysis with `context.ConfigureGeneratedCodeAnalysis(...)`. -- Resolve known framework/library symbols once in a `RegisterCompilationStartAction`. -- Prefer narrow registrations over scanning entire syntax trees or compilations. -- Treat diagnostic IDs as public contracts and maintain release tracking files when publishing public diagnostics. - -### 6. Incremental generator golden rules - -Implement `IIncrementalGenerator`. Simply implementing it is not enough; the pipeline must be incremental. - -> **Pipeline values must be immutable and value-equatable.** - -Never keep these in persistent pipeline models: - -| Type | Verdict | -| --- | --- | -| `ISymbol` / `INamedTypeSymbol` / `IMethodSymbol` / `IPropertySymbol` | Never retain | -| `Compilation` | Do not propagate | -| `SemanticModel` | Do not propagate | -| `IOperation` | Do not propagate | -| `SyntaxTree` | Do not propagate | -| `SyntaxNode` | Remove ASAP | -| `Location` | Remove ASAP | -| `AdditionalText` | Project immediately | -| `T[]` / `List` | Avoid | -| `ImmutableArray` | Wrap with sequence equality | - -Use immutable records and `EquatableArray` (sequence equality) for collection members. - -### 7. Designing the pipeline - -Pipeline shape: - -```text -Roslyn Input - ↓ -Cheap discovery - ↓ -Semantic extraction - ↓ -Small equatable model - ↓ -Validation/transformation - ↓ -Generation model - ↓ -Source output -``` - -Guidelines: - -- Project the semantic transform as the boundary where Roslyn objects disappear. -- Prefer `static` callbacks to avoid capturing generator state. -- Honour cancellation tokens. -- Split transformations into many small stages. -- Keep syntax predicates cheap. -- Avoid indirect discovery (every interface implementation, every subclass, entire-compilation scans). - -### 8. Syntax discovery - -- Prefer `context.SyntaxProvider.ForAttributeWithMetadataName(...)` for attribute-driven generators. -- Use `context.SyntaxProvider.CreateSyntaxProvider(...)` only when syntax itself is the trigger and there is no marker attribute. -- The predicate must be cheap; do not walk the tree or do semantic work in it. - -### 9. `Collect`, `Combine`, and invalidation - -- `Collect()` turns per-item outputs into one aggregate. Changing any item invalidates the aggregate. -- Use `Collect()` only for genuinely global output: registries, lookups, duplicate detection, aggregate switches. -- Prefer per-item `RegisterSourceOutput`. -- `Combine()` is correct when the output depends on two providers. -- Avoid `models.Combine(context.CompilationProvider)` — project the compilation to a tiny capability fact first. -- Use `.WithComparer(...)` only when logical equality differs from the default. - -### 10. Diagnostics - -- Prefer a separate `DiagnosticAnalyser` for normal user validation. -- Use generator diagnostics only for malformed additional files, generator-only configuration, conflicting output, or failures that cannot be expressed by an analyser. -- Report diagnostics on the most useful user-authored `Location`. -- Do not keep `Location` in long-lived pipeline models. - -### 11. Output generation - -- Output must be deterministic: no timestamps, random GUIDs, process IDs, machine paths, culture-dependent output, or unordered dictionary output. -- Hint names must be deterministic, unique, and stable. -- Prefer text generation or `CodeWriter` over building Roslyn syntax trees just to stringify them. -- Use `RegisterPostInitializationOutput` for constant source such as marker attributes. -- Add generated source once per output artifact. - -### 12. Testing incrementally - -- Snapshot-testing generated source is not enough. -- Test first execution, cached second execution, unrelated changes remaining cached, per-target invalidation, deletion, renaming, global options, additional files, and global registry invalidation. -- Use `GeneratorDriverOptions` with `trackIncrementalGeneratorSteps: true` and inspect reasons: `New`, `Modified`, `Unchanged`, `Cached`, `Removed`. - -### 13. Roslyn version compatibility and packaging - -- The `Microsoft.CodeAnalysis.*` version used to compile the analyser/generator sets the minimum compiler-host requirement. -- The consumer's `TargetFramework` does not determine analyser compatibility. -- Choose the oldest Roslyn version that contains the APIs you need. -- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10. -- Ship one `netstandard2.0` analyser/generator binary unless you have a deliberate multi-version strategy. -- Do not mistake multi-targeting for automatic analyser asset selection. -- Use `PrivateAssets="all"` for Roslyn development dependencies. -- Enable `EnforceExtendedAnalyzerRules` and investigate `RSxxxx` diagnostics before suppressing them. - -### 14. Recommended project configuration - -A generator project should normally include: - -```xml - - netstandard2.0 - latest - enable - true - false - true - true - - - - - - - - - - -``` - -## Preferred API map for CodeWriter - -### File and namespace - -- `AutoGeneratedHeader(...)` -- `Using(...)` -- `FileScopedNamespace(...)` or `BlockNamespace(...)` -- `OpenPragmasScope(...)` for warning suppression scopes - -### Types and members - -- Types: `Class`, `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum`, `Delegate` -- Members: `Method`, `MethodScope`, `Property`, `Field`, `Constructor` -- Attributes: `AttributeDeclarationOptions`, `AttributeArgumentOptions` -- Type syntax: `TypeReference` (nullable/generic/array/pointer-safe composition) - -### XML documentation - -Use `XmlCommentWriter` extension methods on `CodeWriter`: - -- `XmlSummary(...)`, `XmlParam(...)`, `XmlTypeParam(...)`, `XmlReturn(...)`, `XmlRemarks(...)`, `XmlExample(...)` -- `XmlCode(...)` / `XmlCodeBlock(...)` -- `XmlList(...)`, `XmlSeeAlso(...)`, `XmlException(...)` - -Static helpers: `CodeWriter.XmlInlineCode(...)`, `CodeWriter.XmlSee(...)`, `CodeWriter.XmlParamRef(...)`, `CodeWriter.XmlText(...)`. - -## Refactoring guide: string/StringBuilder -> CodeWriter - -Apply this checklist in order: - -1. **Move emission boundaries** — replace giant string assembly with phases: header, namespace, type, members. -2. **Replace manual braces/indentation** — use `using` scopes (`ClassScope`, `MethodScope`, `OpenBlockScope`, `IndentedScope`). -3. **Replace handwritten signatures** — use declaration option records. -4. **Replace raw XML lines** — use XML extension methods (`XmlSummary`, `XmlParam`, etc.). -5. **Normalize type strings** — use `TypeReference`. -6. **Preserve semantics and ordering** — generated members and diagnostics must remain equivalent. -7. **Validate scope safety** — keep or enable `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` for tests/dev. - -## Anti-patterns to remove during refactors - -- `StringBuilder.AppendLine("public class ...")` for declarations that can be structured. -- Manually writing `{` / `}` around methods and types where scope APIs exist. -- Hard-coded nullable type suffixes and generic syntax in arbitrary strings when `TypeReference` is available. -- Raw XML tag string composition when XML extension methods can enforce consistency. -- Creating a `CodeWriter` before the output callback and passing it through the pipeline. -- Storing a `CodeWriter` on `GenerationContext`, a custom context, or any incremental pipeline model. -- Sharing one `CodeWriter` across multiple generated outputs or callbacks. -- Keeping Roslyn objects, `CodeWriter`, or mutable state in incremental pipeline models. - -## Review checklist for pull requests - -- Generated declarations use structured APIs for types and members. -- XML docs use XML extension methods rather than raw `///` fragments. -- `CodeWriter` is created inside each output callback and never persists in pipeline state; passing it - to helper methods within that callback is expected. -- Header and generated attributes are deterministic and consistent. -- Existing diagnostics, generated member names, and public behavior are preserved. -- Roslyn objects are removed from pipeline models; `EquatableArray` is used for collections. -- Per-target output is preferred over collected global output unless global knowledge is required. -- `CompilationProvider` is not casually combined into output. -- Deterministic hint names and source text are used. -- Incremental caching behavior is tested, not just generated text. - -## See also - -- `agents/source-generator-framework-writer.agent.md` — specialist agent for `Purview.SourceGeneratorFramework` emitter authoring. -- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks. diff --git a/.agents/skills/source-generator-testing/SKILL.md b/.agents/skills/source-generator-testing/SKILL.md deleted file mode 100644 index 2a56544..0000000 --- a/.agents/skills/source-generator-testing/SKILL.md +++ /dev/null @@ -1,364 +0,0 @@ ---- -name: source-generator-testing -description: "Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes, or refactorings in a Purview.SourceGeneratorFramework repository — picking the right runner/base, configuring options, querying produced code with CodeQuery, and asserting incremental caching." ---- - -# Testing source generators, analyzers, code fixes and refactorings - -Use this skill whenever a task involves authoring, fixing, or modernising tests for Roslyn components -(generators, diagnostic analyzers, code fix providers, refactoring providers) built with -`Purview.SourceGeneratorFramework`. It covers the framework-agnostic test runner layer and the -`CodeQuery` syntax-lookup API. For the TUnit base classes and assertion extensions, also load the -`sdk` package's `tunit-test-authoring` skill. - -## Picking the right runner - -| Roslyn type | Runner | -|---|---| -| `IIncrementalGenerator` / `ISourceGenerator` | `SourceGeneratorTestRunner` | -| `DiagnosticAnalyzer` | `DiagnosticAnalyzerTestRunner` | -| `CodeFixProvider` | `CodeFixTestRunner` (single) | -| — | `CodeFixTestRunner.RunFixAllAsync` (project-wide) | -| `CodeRefactoringProvider` | `RefactoringTestRunner` | - -TUnit projects should prefer the matching base class instead (see `tunit-test-authoring`): -`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, -`TUnitRefactoringTestBase`. - -## Result types - -- `DriverRunResult` (generator) — `DriverResult`, `AllSyntaxTrees`/`PrimarySyntaxTrees`, - `CompilationResult.Compilation`, `GetGeneratedTree`, `GetSource`, `GetTypeByMetadataName`, `LogEntries`. -- `AnalyzerTestResult` — `Diagnostics`, `Compilation`. -- `CodeFixTestResult` — `Diagnostics`, `CodeActions`, `FixedSource`, `Compilation`, `ChangedSolution`. -- `CodeFixFixAllResult` — `Diagnostics`, `CodeActions`, `FixedSources`, `ChangedSolution`. -- `RefactorTestResult` — `CodeActions`, `FixedSources`, `ChangedSolution`, `Compilation`. - -Use `DriverRunResultExtensions` (`AssertNoCompilationErrors`, `AssertNoGenerationExceptions`, -`AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, …) for quick checks, but prefer -`CodeQuery` for structural assertions. - -## Querying produced code with `CodeQuery` - -Every result exposes a `CodeQuery` via extensions in `CodeQueryResultExtensions`: - -```csharp -result.Generated() // DriverRunResult: generated trees (default, generated-first) -result.Output() // DriverRunResult: entire output compilation (user + generated) -analyzerResult.Code() // AnalyzerTestResult: input compilation -codeFixResult.Code() // CodeFixTestResult: input compilation -codeFixResult.FixedCode() // CodeFixTestResult: parsed fixed source (or post-fix solution) -fixAllResult.FixedCode() // CodeFixFixAllResult: post-fix documents -refactorResult.FixedCode() // RefactorTestResult: post-refactor documents -``` - -Every `Get` has an accompanying `Has` (bool) and `TryGet` (out): `GetMethod`/`HasMethod`/`TryGetMethod`, -`GetClass`, `GetStruct`, `GetInterface`, `GetEnum`, `GetDelegate`, `GetRecord`, `GetProperty`, -`GetField`, `GetConstructor`, `GetNamespace`, `GetTypeDeclaration`, plus generic `Get`/`Has` -and `GetSyntaxTree`/`HasSyntaxTree`. `Get` throws `SyntaxNotFoundException` when nothing matches. - -Type lookups accept an optional generic arity — `GetClass(name, arity)` / `HasClass(name, arity)` — and the -`TypeReference`/`TypeIdentity` overloads match arity automatically from the identity, so -`new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching the -non-generic `ResourceDefinition`. - -Every `Get` returns a `CodeQueryResult` — the matched syntax node (`Node`) plus a query scoped to it -(`Query`), with implicit conversions to both the node and the scoped query. Use `.Node` for direct syntax -access, or chain member queries (the originating query is carried by the result, so it is not passed again). - -Types can be matched against `TypeReference`/`TypeIdentity`, resolved through the compilation's semantic -model (nullable value types are significant, so `int?` never matches `int`): - -```csharp -result.Generated().HasMethod("DoWork", TypeReference.Create(), TypeReference.Create().Nullable(), complexType); -result.Generated().HasReturnType("Compute", TypeReference.Create()); -result.Generated().GetMethod("Format").HasParameters(TypeReference.Create(), objectReference); -``` - -When a test expects a nullable annotation, prefer the test-only `query.MakeNullable(type)` extension (on a -`CodeQuery`, accepting a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's -compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trigger the -`PSGFR16` context-overload suggestion (tests have no generation context to pass). - -Member chaining from a type declaration (`MemberQueryExtensions`): - -```csharp -var service = result.Generated().GetClass("ServiceCollectionExtensions"); // or GetClass(name, "Namespace") -service.HasProperty("Count", intType); -service.HasIndexer(stringType, intType); -service.HasMethod("Add", intType, complexType); -service.HasMethodReturnType("Add", stringType); -service.HasConstructor(stringType); -service.HasAttribute("SomeAttribute"); -``` - -Node-inspection checks on scoped results: - -```csharp -result.Generated().GetClass("Service").HasAccessibility(Accessibility.Public); // resolves C# defaults -result.Generated().GetClass("Service").GetProperty("Name").HasSetterAccessibility(Accessibility.Private); -result.Generated().GetClass("ResourceDefinition", 1).HasGenericTypeParameters("TResourceType"); -result.Generated().GetClass("ResourceDefinition", 1).HasBaseType(new TypeIdentity("ResourceDefinition", ns)); -result.Generated().GetClass("Service").HasNestedType("Builder"); // GetNestedType(...) to fetch -result.Generated().GetClass("Service").IsInNamespace("Example.Models"); // IsInGlobalNamespace() for no namespace -result.Generated().IsInNamespace(cls.Node, "Example.Models"); // or on the query directly -``` - -## Configuring options and a reusable starting point - -`SourceGeneratorTestOptions` is the base record. Common knobs: - -- `AdditionalNamespaces` / `IncludeDefaultNamespaces` — namespaces prepended to test source. -- `AdditionalAssemblyTypes` / `AdditionalReferences` — assemblies referenced by the test compilation - (use `AdditionalAssemblyTypes = [typeof(SomeType)]` to pull in a whole assembly). -- `AdditionalSources` — extra source files added to every run. -- `AnalyzerConfigOptions` — `build_property.*` values; keys without the prefix are also exposed as MSBuild - properties. -- `DisableSourceGeneratorPropertyName` / `DisableSourceGeneratorValue` — generator disable toggle. -- `NullableContextOptions`, `OutputKind`, `LanguageVersion`, `CompileToAssembly`. -- `ValidateCodeWriterScopes`, `EnableLogging`. -- `ExcludeGeneratedSourceHintNames` — hides generated marker trees from `PrimarySyntaxTrees`. - -**Easy starting point recipe.** Derive an options record that seeds the namespaces and assemblies your -generator needs, so every test gets a working compilation with no boilerplate: - -```csharp -public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions -{ - public MyGeneratorTestOptions() - { - AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); - AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( - typeof(SomeDependencyType), - typeof(TypeIdentity) // the framework's Shared assembly, when needed - ); - DisableSourceGeneratorPropertyName = PropertyLibrary.DisableMyGenerator; - } -} -``` - -`Compile()` returns a copy with `CompileToAssembly = true`, preserving the derived options type. Use the -base class hooks `OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` to mutate sources/options per run (for -example to append a marker attribute source via `WithAdditionalSources`). - -When `CompileToAssembly` is enabled, emission is fully in-memory. On .NET 8+ the assembly loads into a -collectible `AssemblyLoadContext`, so the result is `IDisposable` — `using var result = ...` unloads it and -keeps repeated runs from polluting the default context. `result.CompilationResult.Assembly` is the runnable -assembly (generated code can execute); `result.CompilationResult.Metadata` / `.MetadataAssembly` give a -metadata-only reflection view (types, members, attributes) over the emitted assembly without executing code. - -## Best practices - -- **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with - `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. -- **Generator references**: to use a generated type in the test project AND pass the generator type to a - runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal - reference. -- **Multi-target**: build generators against the Roslyn version that supports the test matrix; the - framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep - `System.Collections.Immutable` version pinned to the shared one. -- **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes - undisposed `CodeWriter` scopes fail tests. -- **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces). - -## Incremental cache testing (`RunIncrementalAsync`) - -To prove the pipeline caches correctly stage-by-stage, use `SourceGeneratorTestRunner.RunIncrementalAsync` -(or `GenerateIncrementalAsync` on the TUnit base). It runs a sequence of source sets over a **single shared -`GeneratorDriver`** and captures each run's `TrackedSteps`, keyed by tracking name. The canonical reference -implementation is `IncrementalPipelineCacheTests` in `SourceGeneratorShared.UnitTests`; the end-to-end -generator variant is `ServiceRegistrationCacheTests` in -`SourceGeneratorFramework.ExampleGenerator.UnitTests`. Copy the pattern into your own test project — do -not expect the source repo's files locally. - -### What is being asserted and why - -Roslyn reports one `IncrementalStepRunReason` per step output on each run: - -- `New` — the step ran for the first time. -- `Modified` — the step ran and produced a different value than the previous run. -- `Unchanged` — the step ran but produced the same value. -- `Cached` — the step was skipped and its previous result reused from the incremental cache. - -A pipeline is "caching correctly" when an unchanged input keeps every stage `Cached`/`Unchanged`, and a -targeted change marks **only** the stages whose inputs actually changed `Modified` while unrelated stages -stay `Cached`. If a generator accidentally leaks `Compilation`, `SemanticModel`, `ISymbol`, -`SyntaxNode`, or `Location` into a pipeline model, unrelated stages will report `Modified`/`New` on rerun — -these tests fail the build and catch the regression. - -### The four scenarios every cache test should cover - -1. **First run → all `New`.** Nothing can be cached on the first run; this confirms every stage is tracked - under the expected name. -2. **Identical rerun → all `Cached`/`Unchanged`.** `RunIncrementalAsync(sources, ...)` runs the same source - set twice for exactly this case. This is the strongest "it caches" proof. -3. **Source-only change → only the source/attribute stage `Modified`.** Changing an attributed class must - mark `ForAttribute_*` (and downstream output) `Modified` while property/config stages stay `Cached`. -4. **Property-only change → only the property/configuration stage `Modified`.** Toggling an MSBuild - property (via `IncrementalRunInput.AnalyzerConfig`) must mark `GetMSBuildPropertyValue_*` / - `GetGenerationConfiguration` / `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. - -### How the runner makes this possible - -`RunIncrementalAsync` creates **one** driver, enables incremental step tracking, and **reuses the same -`Compilation` instance for identical source sets** (keyed by prepared source text). Without that reuse, -Roslyn would see a fresh compilation on the second run and report stages `Modified`/`New` even though the -sources are byte-identical — the "cached" assertion would fail. - -### The `StepReasons` helper - -`IncrementalCacheRun.Steps` is `ImmutableDictionary>` -keyed by tracking name. Flatten each step's `Outputs` into the reasons list so assertions read cleanly: - -```csharp -static ImmutableDictionary> StepReasons(IncrementalCacheRun run) -{ - var builder = ImmutableDictionary.CreateBuilder>(); - foreach (var pair in run.Steps) - builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))]; - return builder.ToImmutable(); -} -``` - -### Framework pipeline stage names - -- `GetMSBuildPropertyValue_{Property}` — `IncrementalPipeline.PropertyValueProvider`. -- `GetGenerationConfiguration` — `IncrementalPipeline.GenerationContextValueProvider`. -- `GetGenerationContext_{Capabilities}` — e.g. `GetGenerationContext_EmptyCapabilities`. -- `ForAttribute_{AttributeType}` — `IncrementalPipeline.ForAttributeWithMetadataName`. - -The framework reference (`IncrementalPipelineCacheTests`, framework-agnostic runner) shows all four -scenarios against `TestGenerator`/`DiagnosticTestGenerator`: - -```csharp -using System.Collections.Immutable; -using Purview.SourceGeneratorFramework.TestGenerators; -using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; - -public class IncrementalPipelineCacheTests -{ - const string AttributedSource = """ - [TestAttribute] - public partial class MyClass { } - """; - const string ChangedAttributedSource = """ - [TestAttribute] - public partial class AnotherClass { } - """; - const string TestAttributeSource = """ - [System.AttributeUsage(System.AttributeTargets.Class)] - public sealed class TestAttribute : System.Attribute { } - """; - - static SourceGeneratorTestOptions CreateOptions() => - new SourceGeneratorTestOptions() - .WithAdditionalSources(TestAttributeSource) - .WithExcludeGeneratedSourceHintNames("TestAttribute"); - - static ImmutableDictionary> StepReasons(IncrementalCacheRun run) { /* as above */ } - - [Test] - public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) - { - var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( - [new IncrementalRunInput([AttributedSource])], - CreateOptions(), - cancellationToken); - - var reasons = StepReasons(result.Runs[0]); - await Assert.That(reasons).IsNotEmpty(); - await Assert.That(reasons.Values.SelectMany(r => r).All(r => r == StepReason.New)).IsTrue(); - } - - [Test] - public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) - { - var result = await new SourceGeneratorTestRunner() - .RunIncrementalAsync([AttributedSource], CreateOptions(), cancellationToken); - - var second = StepReasons(result.Runs[1]); - await Assert.That(second.Values.SelectMany(r => r).All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); - } - - [Test] - public async Task SourceChange_MarksAttributeStageModified_PropertyStagesStayCached(CancellationToken cancellationToken) - { - var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( - [new IncrementalRunInput([AttributedSource]), new IncrementalRunInput([ChangedAttributedSource])], - CreateOptions(), - cancellationToken); - - var second = StepReasons(result.Runs[1]); - await Assert.That(second["ForAttribute_TestAttribute"]).Contains(StepReason.Modified); - await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"].All(r => r == StepReason.Cached)).IsTrue(); - } - - [Test] - public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) - { - var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( - [ - new IncrementalRunInput([AttributedSource]), - new IncrementalRunInput([AttributedSource], [("build_property.DisableTestGenerator", "true")]), - ], - CreateOptions(), - cancellationToken); - - var second = StepReasons(result.Runs[1]); - await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"]).Contains(StepReason.Modified); - await Assert.That(second["ForAttribute_TestAttribute"].All(r => r == StepReason.Cached)).IsTrue(); - } -} -``` - -### End-to-end generator variant (`GenerateIncrementalAsync`) - -TUnit tests derive from the base class and call `GenerateIncrementalAsync` (which wires the -`OnBeforeRun`/`OnBeforeRunAsync` hooks and the derived options). `ServiceRegistrationCacheTests` in -`SourceGeneratorFramework.ExampleGenerator.UnitTests` is the reference: - -```csharp -public class ServiceRegistrationCacheTests - : TUnitSourceGeneratorTestBase -{ - const string Source = """ - namespace Test; - - [GenerateService] - public class MyService { } - """; - - [Test] - public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) - { - var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); - - var second = StepReasons(result.Runs[1]); - string[] frameworkStages = - [ - "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", - "GetGenerationConfiguration", - "GetGenerationContext_EmptyCapabilities", - "ForAttribute_GenerateServiceAttribute", - ]; - await Assert.That( - frameworkStages.All(stage => - second.TryGetValue(stage, out var reasons) - && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); - } -} -``` - -**Why the example filters to `frameworkStages`:** `ServiceRegistrationGenerator` emits its own -`GenerateServiceAttribute` via post-initialization output, which is regenerated as a new `SyntaxTree` each -run. That makes Roslyn's *internal* `ForAttributeWithMetadataName` `Compilation` step legitimately report -`Modified` on an identical rerun. Asserting on the framework-named stages (which stay `Cached`/`Unchanged`) -is the meaningful check. If your generator does not depend on its own post-init output, the stricter -"every tracked step is `Cached`/`Unchanged`" assertion (as in the framework `IncrementalPipelineCacheTests`) -is correct. - -Per-run MSBuild-property changes are supplied with `new IncrementalRunInput(sources, [("build_property.X", "value")])`. - -## License - -This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/tunit-test-authoring/SKILL.md b/.agents/skills/tunit-test-authoring/SKILL.md deleted file mode 100644 index fa58cd8..0000000 --- a/.agents/skills/tunit-test-authoring/SKILL.md +++ /dev/null @@ -1,214 +0,0 @@ ---- -name: tunit-test-authoring -description: "Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes, or refactorings in a Purview.SourceGeneratorFramework repository — choosing the correct base class and method, customising options, using the assertion extensions, and modernising existing tests." ---- - -# TUnit test authoring for Roslyn components - -Use this skill whenever a task involves authoring, fixing, or modernising **TUnit** tests for Roslyn -components built with `Purview.SourceGeneratorFramework`. It tells you which base class to derive from, -which method to call, how to customise options with an easy starting point, and how to use the TUnit -assertion extensions. For the framework-agnostic runner layer and the `CodeQuery` API, also load the -`sdk` package's `source-generator-testing` skill. - -## Base class → method matrix - -| Roslyn type | Base class | Method to call | -|---|---|---| -| `IIncrementalGenerator` / `ISourceGenerator` | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` | -| `DiagnosticAnalyzer` | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` | -| `CodeFixProvider` (single fix) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` | -| `CodeFixProvider` (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` | -| `CodeRefactoringProvider` | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` | - -For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`. - -Framework-agnostic equivalents (no TUnit): `SourceGeneratorTestRunner`, `DiagnosticAnalyzerTestRunner`, -`CodeFixTestRunner`, `RefactoringTestRunner`. - -## Easy starting point: derive your options record - -Create a test-options record that seeds the namespaces and assemblies your component needs, then pass it -to every test via `new MyTestOptions()`: - -```csharp -public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions -{ - public MyGeneratorTestOptions() - { - AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); - AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( - typeof(SomeDependencyType), - typeof(TypeIdentity) // framework Shared assembly, when needed - ); - DisableSourceGeneratorPropertyName = "DisableMyGenerator"; - } -} - -public class MyGeneratorTests : TUnitSourceGeneratorTestBase -{ - [Test] - public async Task GeneratesExpectedSource(CancellationToken ct) => - await GenerateAsync("...source...", ct); -} -``` - -Use the base hooks to customise per-run: `OnBeforeRun`/`OnBeforeRunAsync` (mutate sources/options, e.g. -`options.WithAdditionalSources(markerAttributeSource)`) and `OnAfterRun`/`OnAfterRunAsync`. Use -`options.Compile()` to opt into `CompileToAssembly` while preserving the derived options type. - -For code fixes/refactorings, select a specific registered action via `CodeFixTestOptions.EquivalenceKey` -or `CodeActionIndex`, and `RefactorTestOptions.Span`/`NodeSelector` (e.g. -`NodeSelector = query => query.GetMethod("M")`). - -## TUnit assertion extensions - -All assertion extensions live under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions` -(globally imported by the package's props). `Assert.That(...)` calls are terminal and **return the value** -when awaited. - -- **`CodeQueryAssertions`** — return `CodeQueryResult` (the matched node via `.Node`, plus a query - scoped to it via `.Query`): `HasGeneratedMethod` (optionally with `TypeReference[]` parameter types), - `HasGeneratedMethodReturnType`, `HasGeneratedClass` (by name or `TypeReference`/`TypeIdentity` identity), - `HasGeneratedProperty`, `HasGeneratedField`, `HasGeneratedSyntaxTree`; `HasFixedMethod` for code-fix and - refactor results. The assertions operate on a `CodeQuery` directly, so pass any query — `result.Generated()` - (generated trees), `result.Output()` (whole compilation), or `result.FixedCode()` — or use the convenience - overloads on the test result types, which query the relevant code for you. -- **Scoped member chaining** — `HasPropertyOfType(name, type)`, `HasFieldOfType(name, type)`, - `HasMethodOfType(name, TypeReference[])`, `HasConstructorOfType(TypeReference[])`, and - `HasAttributeOfType(name)` chain from a scoped `CodeQueryResult` (e.g. the result of `HasGeneratedClass`) - and return the matched member. They are named to avoid colliding with the bool `Has*` predicates in - `MemberQueryExtensions`. -- **Fluent `.And` chains** — the node-producing assertions (`HasGeneratedClass`, `HasPropertyOfType`, - `HasMethodOfType`, `HasNestedType`) move the chain onto the matched node, so further assertions can be - appended with `.And`. Node-inspection assertions — `WithAccessibility`, `WithGetterAccessibility`, - `WithSetterAccessibility`, `WithBaseType`, `WithGenericTypeParameter(s)`, `IsInNamespace`, - `IsInGlobalNamespace` — keep the node on the chain: - ```csharp - var method = await Assert.That(query) - .HasGeneratedClass("Service") - .And.HasNestedType("Builder") - .And.WithAccessibility(Accessibility.Private) - .And.HasMethodOfType("Build", []); - ``` - Generic types are matched by arity — `HasGeneratedClass(name, arity)` or a `TypeIdentity` with arity — - so `new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` - without matching the non-generic `ResourceDefinition`. -- **Nullable expected types in tests** — use the test-only `query.MakeNullable(type)` extension (on a - `CodeQuery`). It resolves the annotation against the query's compilation and, unlike - `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trip the `PSGFR16` context-overload - suggestion, since tests have no generation context to pass. - ```csharp - CodeQueryResult method = await Assert.That(result.Generated()).HasGeneratedMethod("DoWork", [intType, nullableInt]); - CodeQueryResult cls = await Assert.That(result.Generated()).HasGeneratedClass("Service"); - await Assert.That(cls.HasProperty("Count", intType)).IsTrue(); // chained member query - await Assert.That(cls.Node.Identifier.ValueText).IsEqualTo("Service"); // direct syntax access - await Assert.That(result.FixedCode()).HasFixedMethod("DoWork"); // code-fix / refactor results - - var query = result.Generated(); - var attributeClass = await Assert.That(query).HasGeneratedClass(hostKitAttribute); - await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String)); - ``` -- **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, - `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. -- **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. -- **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` - (whitespace-flattened string comparison). - -For structural assertions (members, signatures, namespaces) prefer `result.Generated()` + -`Get/Has/TryGet` from `CodeQuery` (see `source-generator-testing`). - -## Incremental cache tests (`GenerateIncrementalAsync`) - -`TUnitSourceGeneratorTestBase` exposes `GenerateIncrementalAsync`, which mirrors `RunIncrementalAsync` but -also wires the base class hooks (`OnBeforeRun`/`OnBeforeRunAsync`) and your derived options record. Use it to -prove the pipeline caches stage-by-stage. The reference is `ServiceRegistrationCacheTests` in -`SourceGeneratorFramework.ExampleGenerator.UnitTests`; the framework-agnostic twin with a full walkthrough is -in the `source-generator-testing` skill. - -Why the tests look the way they do: - -- `GenerateIncrementalAsync([Source])` runs the **same source twice** on a single shared driver. The first - run reports every stage `New`; the second must report `Cached`/`Unchanged` for unchanged stages — that is - the core "it caches" proof. -- `GenerateIncrementalAsync([new IncrementalRunInput([Source]), new IncrementalRunInput([changed])])` runs two - **different** source sets, so a source-only change must mark `ForAttribute_*` `Modified` while - property/configuration stages stay `Cached`. -- `new IncrementalRunInput([Source], [("build_property.X", "value")])` toggles an MSBuild property for one - run only, so a property-only change must mark `GetMSBuildPropertyValue_*`/`GetGenerationConfiguration`/ - `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. -- The `StepReasons(IncrementalCacheRun)` helper flattens each tracked step's `Outputs` into a - `ImmutableDictionary>` so assertions can address a stage - by name (see `source-generator-testing` for the helper body). -- If the generator's pipeline depends on its own post-initialization output (a self-referencing generated - attribute), Roslyn's internal `ForAttributeWithMetadataName` `Compilation` step is legitimately `Modified` - on rerun; assert on the framework-named stages (e.g. `ForAttribute_GenerateServiceAttribute`, - `GetGenerationConfiguration`, `GetGenerationContext_EmptyCapabilities`) rather than every tracked step. - -```csharp -public class ServiceRegistrationCacheTests - : TUnitSourceGeneratorTestBase -{ - const string Source = """ - namespace Test; - - [GenerateService] - public class MyService { } - """; - - [Test] - public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) - { - var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); - - var second = StepReasons(result.Runs[1]); - string[] frameworkStages = - [ - "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", - "GetGenerationConfiguration", - "GetGenerationContext_EmptyCapabilities", - "ForAttribute_GenerateServiceAttribute", - ]; - await Assert.That( - frameworkStages.All(stage => - second.TryGetValue(stage, out var reasons) - && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); - } - - [Test] - public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) - { - var result = await GenerateIncrementalAsync( - [ - new IncrementalRunInput([Source]), - new IncrementalRunInput([Source], [(PropertyLibrary.EmitServiceRegistrationInfo, "true")]), - ], - cancellationToken: cancellationToken); - - var second = StepReasons(result.Runs[1]); - await Assert.That(second["GetMSBuildPropertyValue_EmitServiceRegistrationInfo"]).Contains(StepReason.Modified); - await Assert.That(second["ForAttribute_GenerateServiceAttribute"].All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); - } -} -``` - -Use `using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason;` and your own stage names. - -## Modernising existing tests - -When converting legacy tests that do `result.GetGeneratedTree(...)` + `string.Contains(...)`: - -1. Replace tree-lookup + string matching with `result.Generated().GetClass/GetMethod/GetProperty(...)` and - the `Has*`/`TryGet*` family. -2. Replace signature string checks with `TypeReference` parameter/return-type matching. -3. Replace `Assert.That(text).Contains("...")` with the terminal assertion extensions that return nodes. -4. Verify options use a derived record (namespaces + assemblies) rather than repeating `AdditionalNamespaces` - per test. -5. Add a stage-by-stage cache test if the component has an incremental pipeline — first run `New`, - identical rerun `Cached`/`Unchanged`, and targeted changes mark only the affected stage `Modified`. - Use the inlined examples in this skill and in `source-generator-testing`'s "Incremental cache testing" - section, swapping in your own generator and stage names. - -## License - -This project is licensed under the MIT license. \ No newline at end of file diff --git a/purview-build.json b/.config/purview-build.json similarity index 100% rename from purview-build.json rename to .config/purview-build.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b3b030c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dotnet.defaultSolution": "src/ZodSharp.slnx" +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 966e644..7c646a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,7 @@ been published to NuGet. ## Unreleased > **This release contains breaking changes.** See [Breaking changes](#breaking-changes) below before -> upgrading from `2.0.0`. The analyzer catalogue records them under `2.1.0`; `package.json` still carries the -> prerelease version and is set at release time. +> upgrading from `2.0.0`. The analyzer catalogue records them under `2.0.2`. > > Note the changes below are consumer-visible: a rule's public `Code` constant was renamed to `ErrorCode`, > and rule constructor signatures changed, so attribute usages and code referencing those constants may not @@ -48,10 +47,21 @@ been published to NuGet. attribute, and expanded downstream rule generation for `Purview.ValueObjects` consumers. - A new `enum` rule, applied automatically on generated schemas, plus additional explicit rules. - `IZodRule` organisation with a supporting analyzer, and `RuleMessage` is now public. -- Seven new diagnostics: `ZODSGEN037`–`ZODSGEN043`, recorded in `AnalyzerReleases.Shipped.md` under - `## Release 2.1.0`. See [Source Generator Diagnostics](docs/wiki/Source-Generator-Diagnostics.md). - `AnalyzerReleases.Unshipped.md` is now empty; the next diagnostic added goes there and moves across when - it ships. +- 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). +- **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 + the attribute, generates the `{Type}Schema` (and validator adapter) without the member being visible, and + adapts type-level `[RequiredZod]`/`[ZodRule]` rules to the underlying value. Nullable scalars + (`Nullable = true`, or a `T?` value type) accept and round-trip JSON `null`. Property-level DataAnnotations + have no host on the generated property; use Zod-native type-level rules or the manual `[Scalar]` form. +- **`NullOrNonWhiteSpaceRule` / `[NullOrNonWhiteSpace]`.** A null-tolerant string rule that accepts `null` + but rejects an empty or whitespace-only value, for nullable scalar value objects where `[RequiredZod]` + (which always rejects `null`) cannot express the check. +- **`ZODSGEN044`.** A `[ZodSchema]` type using an automatic `[Scalar]` form that cannot produce a usable + schema (a suppressed `Validate` method, or an unrepresentable underlying type) is reported instead of + leaving a dangling `{Type}Schema` reference. Recorded in `AnalyzerReleases.Unshipped.md`. ### Changed — trimming and Native AOT diff --git a/docs/wiki/Source-Generator-DataAnnotations.md b/docs/wiki/Source-Generator-DataAnnotations.md index 84cca0d..18908c8 100644 --- a/docs/wiki/Source-Generator-DataAnnotations.md +++ b/docs/wiki/Source-Generator-DataAnnotations.md @@ -96,4 +96,4 @@ See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full The same pipeline honours custom rules exposed as validation attributes. Mark the attribute with `[ZodRule(typeof(MyRule))]` (or mark the rule itself with `[ZodRule]` to have the attribute generated), and properties annotated with it are validated through the rule. See [Custom Rules](Custom-Rules.md). -Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[Emoji]`, `[Xid]`, `[Ksuid]`, `[Duration]`, `[Guid]`, `[Cidr]`, `[NonSentinel]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, `[Odd]`, `[Enum]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, and `[DeniedValuesZod]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file +Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[Emoji]`, `[Xid]`, `[Ksuid]`, `[Duration]`, `[Guid]`, `[Cidr]`, `[NonSentinel]`, `[NullOrNonWhiteSpace]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, `[Odd]`, `[Enum]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, and `[DeniedValuesZod]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`). See [Built-in attributes](Custom-Rules.md#built-in-attributes-shipped-with-purviewzodsharp). \ No newline at end of file diff --git a/docs/wiki/Source-Generator-Diagnostics.md b/docs/wiki/Source-Generator-Diagnostics.md index ad965bb..bf6965a 100644 --- a/docs/wiki/Source-Generator-Diagnostics.md +++ b/docs/wiki/Source-Generator-Diagnostics.md @@ -41,6 +41,7 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato | ZODSGEN041 | (info) A typed union (`Z.Union`) whose option types are all reference types can use the allocation-free native C# 15 union returned by `Z.NativeUnion` on .NET 11+; a code fix is offered | | ZODSGEN042 | (warning) A validation rule (a type implementing `IValidationRule`) does not expose a public `const string ErrorCode` and a public `const string MessageFormat`, so its error identity cannot be asserted in tests without duplicating literals | | ZODSGEN043 | (info) A built-in rule marked `[ZodRule]` does not generate a validation attribute, because a constructor parameter cannot be represented as an attribute property; the rule is still reachable through the runtime/fluent API. A derived name that collides with `System.ComponentModel.DataAnnotations` is *not* skipped — the attribute is emitted under a `Zod` suffix instead | +| 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 | IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused; rule identifiers are never renumbered or re-used. diff --git a/docs/wiki/Validation-Rules-Reference.md b/docs/wiki/Validation-Rules-Reference.md index 6b2acc0..849fbad 100644 --- a/docs/wiki/Validation-Rules-Reference.md +++ b/docs/wiki/Validation-Rules-Reference.md @@ -178,9 +178,12 @@ Behaviour notes: | Rule | Constructor | Fluent method | Code | Message format | |---|---|---|---|---| | `NonSentinelRule` | `(string? message)` | `NonSentinel()` | `invalid_value` | `Value is a sentinel value, but got {0}` | +| `NullOrNonWhiteSpaceRule` | `(string? message, string? code)` | — | `invalid_string` | `Value must be null or non-whitespace, but got '{0}'` | `NonSentinelRule` rejects the framework default/boundary values an ORM commonly stores to mean "no value": `Guid.Empty`; `DateTime`, `DateTimeOffset`, `DateOnly`, and `TimeOnly` `MinValue`/`MaxValue`; and `null`/empty/whitespace strings. Types without a known sentinel always pass, so the rule never rejects a type it does not understand. It detects sentinels with a `typeof(T)` dispatch and reinterprets the value in place, so no boxing occurs. +`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. + 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` and `ZodDate` override it with a covariant return type so the fluent chain keeps the concrete schema: ```csharp @@ -280,6 +283,7 @@ Each built-in rule that can be expressed as an attribute ships a generated `Vali | `[Int]` | `IntRule` | closes the open generic with the member type | | `[Uri(UriKind.…)]` | `UriRule` | the `UriKind` is required | | `[NonSentinel]` | `NonSentinelRule` | closes the open generic with the member/scalar type | +| `[NullOrNonWhiteSpace]` | `NullOrNonWhiteSpaceRule` | accepts `null` but rejects empty/whitespace-only strings (for nullable scalar value objects) | | `[Enum]` | `EnumRule` | closes the open generic with the enum member type; excludes only the members the rule receives | | `[MinValue(…)]` | `MinValueRule` | the type-parameter bound is a `double`; the rule closes with the member type | | `[MaxValue(…)]` | `MaxValueRule` | as above | diff --git a/docs/wiki/Value-Objects-Integration.md b/docs/wiki/Value-Objects-Integration.md index 2660493..b02dd3b 100644 --- a/docs/wiki/Value-Objects-Integration.md +++ b/docs/wiki/Value-Objects-Integration.md @@ -70,6 +70,36 @@ so `AssetId` is `IScalarValueObject`. > [!NOTE] > The `ScalarRuleAdapter` type is emitted into your compilation by the **Purview.ValueObjects** generator whenever the project references both `Purview.ValueObjects` and `Purview.ZodSharp`. Do not declare it yourself. If the value-objects generator is disabled (`DisableValueObjectsSourceGenerator`), the adapter is not emitted and the generated validator will not compile. +## Automatic scalar forms + +`Purview.ValueObjects` 1.0.1 (or later) also supports an **automatic** form, where the value-object generator declares the underlying property for you. Both spellings are equivalent: + +```csharp +[Scalar] [Scalar(typeof(Guid))] +[Scalar(Nullable = true)] [Scalar(typeof(string), Nullable = true)] +[Scalar] [Scalar(typeof(int?))] +[Scalar("Id")] [Scalar(typeof(Guid), "Id")] +``` + +A nullable reference type cannot be a generic attribute argument (`[Scalar]` reports `CS8970`) or a `typeof` operand (`typeof(string?)` reports `CS8639`), so a nullable reference scalar is written with `Nullable = true`. Nullable value types are written directly (`[Scalar]` / `[Scalar(typeof(int?))]`). A nullable scalar round-trips JSON `null`, so its schema accepts `null`. + +The property is emitted by the value-object generator, which ZodSharp cannot observe, so the automatic form has no source member for ZodSharp to read. ZodSharp resolves the underlying type from the attribute instead, so the schema is generated exactly as for the manual form: + +```csharp +[Scalar] +[ZodSchema] +[NonSentinel(Message = "InstallationId must not be empty.")] +public readonly partial record struct InstallationId { } +``` + +Because there is no property to carry DataAnnotations, the automatic form supports **type-level** rules only: + +- `[RequiredZod]`, `[NullOrNonWhiteSpace]`, `[NonSentinel]`, and any custom `[ZodRule(typeof(...))]`-mapped attribute applied to the type are read from the type and adapted to the scalar's underlying value, reporting an **empty path** exactly as for the manual form. +- A **property-level DataAnnotation has no host** on the generated property, so it is never observed. Use a Zod-native type-level rule, or switch to the manual `[Scalar]` form when property-level DataAnnotations are required. +- `[RequiredZod]` always rejects `null`; use `[NullOrNonWhiteSpace]` (or a custom null-tolerant rule) when a nullable scalar must accept `null` while rejecting whitespace. + +`[ZodSchema(GenerateValidateMethod = false)]` is incompatible with a scalar form, because the value-object generator's generated `Create` always calls `{Type}Schema.Validate(instance)`; ZodSharp reports **`ZODSGEN044`** rather than leaving a dangling reference, and it also reports it when the declared underlying type cannot be represented. + ## Two ways to attach a rule to a scalar There are two shapes, and the generator picks the right one from the rule's own constraints: @@ -294,6 +324,7 @@ await Assert.That( | 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. | | ZODSGEN042 | Warning | A rule does not expose public `const string ErrorCode` / `MessageFormat` constants. | +| ZODSGEN044 | Error | A `[ZodSchema]` type uses an automatic `[Scalar]` form but cannot produce a usable schema: the generated `Validate` method is suppressed, or the declared underlying type cannot be represented. | Common causes: diff --git a/global.json b/global.json index 2811ad4..bb960c5 100644 --- a/global.json +++ b/global.json @@ -5,9 +5,9 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.3" + "Purview.BuildSdk": "1.0.4" }, "test": { "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +} diff --git a/package.json b/package.json index c5954ea..b95f01f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.1.1", + "version": "2.0.2", "private": true, "license": "MIT", "author": { diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 543f2ce..d7712be 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -9,9 +9,6 @@ false true https://github.com/purview-dev/zodsharp - - - $(NoWarn);CA1031;CA1307;CA1510; diff --git a/src/src/NewtonsoftJson/NewtonsoftJson.csproj b/src/src/NewtonsoftJson/NewtonsoftJson.csproj index ddfe724..6c45985 100644 --- a/src/src/NewtonsoftJson/NewtonsoftJson.csproj +++ b/src/src/NewtonsoftJson/NewtonsoftJson.csproj @@ -3,11 +3,11 @@ false ZodSharp true - Purview.ZodSharp.NewtonsoftJson true + Purview.ZodSharp.NewtonsoftJson Purview.ZodSharp.NewtonsoftJson A high-performance C# port of Zod schema validation library. Features zero-allocation validation, struct-based rules, fluent API, and source generator support for maximum performance. zod;validation;schema;json diff --git a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md index 6d05900..8f41bac 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md @@ -47,7 +47,7 @@ | ZODSASP100 | ZodSharp.SourceGenerator | Error | Unhandled exception in the ErrorType source generator | ZODSASP101 | ZodSharp.SourceGenerator | Error | ErrorType Parameters could not be extracted -## Release 2.1.0 +## Release 2.0.2 ### New Rules @@ -60,3 +60,4 @@ | ZODSGEN041 | ZodSharp.SourceGenerator | Info | A typed union whose option types are all reference types can use the native C# 15 union returned by Z.NativeUnion on net11+ (allocation-free, exhaustive pattern matching) | | ZODSGEN042 | ZodSharp.SourceGenerator | Warning | A validation rule does not expose its error identity as public const ErrorCode/MessageFormat constants, so tests cannot assert against the rule without duplicating literals | | ZODSGEN043 | ZodSharp.SourceGenerator | Info | A built-in rule marked [ZodRule] does not generate a validation attribute because a constructor parameter cannot be represented as an attribute property (a derived name that collides with System.ComponentModel.DataAnnotations is emitted under a "Zod" suffix instead) | +| ZODSGEN044 | ZodSharp.SourceGenerator | Error | A [ZodSchema] type that uses an automatic [Scalar] form ([Scalar] or [Scalar(typeof(TValue))]) cannot produce a usable schema, because the generated Validate method is suppressed or the underlying type cannot be represented | diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index b4e9c02..32d0340 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -262,9 +262,112 @@ out INamedTypeSymbol resolved } /// - /// Gets the underlying value type of a scalar value object: a type marked with the Purview.ValueObjects - /// [Scalar] attribute that exposes a public property for the underlying value. The interface - /// implementation is contributed by another generator, so the property is the source-visible contract. + /// Describes the Purview.ValueObjects [Scalar] attribute applied to a type: the manual form + /// (the author declares the underlying property) or one of the automatic forms + /// ([Scalar<TValue>] or [Scalar(typeof(TValue))], where the value-object generator + /// declares it and ZodSharp cannot see the member). + /// + /// The name of the property holding the underlying value (default Value). + /// Whether the value-object generator owns the underlying property. + /// The declared underlying type for an automatic form; otherwise . + /// Whether the automatic form declares the property as a nullable reference type. + internal readonly record struct ScalarAttributeInfo( + string PropertyName, + bool IsAutomatic, + ITypeSymbol? ValueType, + bool Nullable + ); + + /// + /// Finds the Purview.ValueObjects [Scalar] attribute applied to , generic + /// or non-generic. Detection is by metadata name, so it does not require a reference to the value-object + /// runtime. + /// + /// The type to inspect. + /// The parsed attribute, when one is applied. + /// when the type is a scalar value object. + /// + /// The underlying type comes from the generic type argument ([Scalar<TValue>]) or from the + /// typeof(...) constructor argument ([Scalar(typeof(TValue))]); neither selects the manual + /// form. Nullable is declared on the shared ScalarOptionsAttribute base, so it is read by + /// name and applies to both automatic forms. + /// + internal static bool TryGetScalarAttribute(INamedTypeSymbol type, out ScalarAttributeInfo info) + { + foreach (var attribute in type.GetAttributes()) + { + if ( + attribute.AttributeClass + is not { Name: TypeLibraryGenerator.ScalarAttributeName, Arity: <= 1 } attributeClass + ) + { + continue; + } + + if ( + attributeClass.ContainingNamespace.ToDisplayString() + != TypeLibraryGenerator.ValueObjectsSerializationNamespace + ) + { + continue; + } + + var propertyName = "Value"; + ITypeSymbol? valueType = null; + var parameters = attribute.AttributeConstructor?.Parameters ?? default; + + for (var index = 0; index < attribute.ConstructorArguments.Length; index++) + { + var argument = attribute.ConstructorArguments[index]; + + if (argument.Kind == TypedConstantKind.Type && argument.Value is ITypeSymbol declaredType) + { + valueType = declaredType; + continue; + } + + // The property name is mapped by the constructor parameter's name, because the automatic + // `[Scalar(typeof(T), "Name")]` form carries the type as the first argument. When the + // constructor (and therefore the parameter names) is unavailable, the first string argument is + // treated as the property name. + if (argument.Value is string name && !string.IsNullOrWhiteSpace(name)) + { + var parameterName = + !parameters.IsDefaultOrEmpty && index < parameters.Length ? parameters[index].Name : null; + + if (parameterName is null || string.Equals(parameterName, "propertyName", StringComparison.Ordinal)) + { + propertyName = name; + } + } + } + + // The generic form carries the underlying type as its type argument. + if (valueType is null && attributeClass.Arity == 1) + valueType = attributeClass.TypeArguments[0]; + + var nullable = false; + foreach (var pair in attribute.NamedArguments) + { + if (pair.Key == "Nullable" && pair.Value.Value is bool isNullable) + { + nullable = isNullable; + break; + } + } + + info = new(propertyName, valueType is not null, valueType, nullable); + return true; + } + + info = default; + return false; + } + + /// + /// Gets the underlying value type of a scalar value object. The manual form exposes a public property + /// for the value; the automatic forms declare the type in the attribute and the value-object generator + /// owns the property, so the type is read from the attribute instead. /// /// The type to inspect. /// The scalar's underlying value type, when it is a scalar. @@ -273,14 +376,10 @@ static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymbol valueT { valueType = null!; - if (type is not INamedTypeSymbol named) - return false; - - var propertyName = GetScalarPropertyName(named); - if (propertyName is null) + if (type is not INamedTypeSymbol named || !TryGetScalarAttribute(named, out var scalar)) return false; - foreach (var member in named.GetMembers(propertyName)) + foreach (var member in named.GetMembers(scalar.PropertyName)) { if (member is IPropertySymbol { IsStatic: false, DeclaredAccessibility: Accessibility.Public } property) { @@ -289,38 +388,51 @@ static bool TryGetScalarUnderlyingValue(ITypeSymbol type, out ITypeSymbol valueT } } - return false; + if (scalar.ValueType is null) + return false; + + valueType = AnnotateScalarValueType(scalar); + return true; } /// - /// Gets the name of the member holding a scalar's underlying value: the [Scalar] attribute's - /// propertyName argument ([Scalar("Id")]), defaulting to Value. + /// Gets the underlying type and property name of an automatic scalar value object + /// ([Scalar<TValue>] or [Scalar(typeof(TValue))]), where the value-object generator + /// declares the property. Used to synthesize the schema's property without the member being visible. /// /// The type to inspect. - /// The property name, or when the type is not a scalar. - static string? GetScalarPropertyName(INamedTypeSymbol type) + /// The property the value-object generator declares. + /// The declared underlying type, with a nullable reference annotation applied. + /// when uses an automatic scalar form. + internal static bool TryGetAutomaticScalar(ITypeSymbol type, out string propertyName, out ITypeSymbol valueType) { - foreach (var attribute in type.GetAttributes()) - { - if (attribute.AttributeClass?.ToDisplayString() != TypeLibraryGenerator.ScalarAttributeFullName) - continue; - - if ( - attribute.ConstructorArguments.Length == 1 - && attribute.ConstructorArguments[0].Value is string name - && !string.IsNullOrWhiteSpace(name) - ) - { - return name; - } + propertyName = string.Empty; + valueType = null!; - // The attribute is present but no property name was supplied, so the default is used. - return "Value"; + if ( + type is not INamedTypeSymbol named + || !TryGetScalarAttribute(named, out var scalar) + || scalar.ValueType is null + ) + { + return false; } - return null; + propertyName = scalar.PropertyName; + valueType = AnnotateScalarValueType(scalar); + return true; } + /// + /// Applies the automatic form's Nullable option as a nullable reference annotation, so the + /// adapted rule and the synthesized schema property match the value-object generator's + /// IScalarValueObject<TSelf, string?> construction. + /// + static ITypeSymbol AnnotateScalarValueType(ScalarAttributeInfo scalar) => + scalar.Nullable && scalar.ValueType is { IsReferenceType: true } referenceType + ? referenceType.WithNullableAnnotation(NullableAnnotation.Annotated) + : scalar.ValueType!; + /// /// Builds the Purview.ValueObjects.ScalarRuleAdapter<TSelf, TValue, TRule> identity the /// adapted rule is emitted as. The value argument is carried as a TypeReference rather than a diff --git a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs index 7b0188d..a93d3d0 100644 --- a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -435,4 +435,20 @@ static class DiagnosticLibrary defaultSeverity: DiagnosticSeverity.Info, isEnabledByDefault: true ); + + /// + /// ZODSGEN044: a [ZodSchema] type that also uses an automatic scalar form + /// ([Scalar<TValue>] 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 for a suppressed Validate method and for an underlying + /// type that cannot be represented. + /// + public static readonly DiagnosticDescriptor AutomaticScalarSchemaUnavailable = new( + id: "ZODSGEN044", + title: "Automatic scalar schema cannot be generated", + messageFormat: "The [ZodSchema] type '{0}' uses an automatic [Scalar] form, but a schema cannot be generated for it: {1}", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); } diff --git a/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs b/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs index b0aeb9e..d1669df 100644 --- a/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs +++ b/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs @@ -60,7 +60,10 @@ public bool IsSchemaOwnedByThisCompilation(INamedTypeSymbol type) => /// when an existing, accessible schema was found. public bool TryGetExistingSchema(INamedTypeSymbol type, out TypeIdentity schemaType) { - if (type.ContainingAssembly?.Identity is not { } assembly) + // Only a class or struct can own a generated schema. Rejecting non-schema-shaped types up front keeps + // an error/type-parameter symbol (for example the element of a List) from reaching the + // TypeIdentity constructor below, which throws for symbols it cannot represent. + if (!HasSchemaShape(type) || type.ContainingAssembly?.Identity is not { } assembly) { schemaType = default; return false; diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index a52e13d..36e873d 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -171,6 +171,7 @@ CancellationToken cancellationToken var schema = target with { Name = schemaName }; var targetCanBeNull = TypeHelpers.CanBeNull(symbol); var properties = GetZodProperties(symbol, externalSchemas); + properties = AddAutomaticScalarProperty(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 @@ -322,12 +323,121 @@ ExternalSchemaResolver externalSchemas return new(properties); } + /// + /// Adds the schema property for an automatic scalar value object + /// ([Scalar<TValue>] / [Scalar(typeof(TValue))]), whose underlying member is + /// emitted by the value-object generator and is therefore invisible here. The property is synthesized + /// from the attribute so the schema reads value.{PropertyName}, which resolves once the + /// value-object generator's partial is merged. + /// + /// The schema target type. + /// The properties discovered from source. + /// The resolver that decides schema ownership. + /// The properties with the synthesized scalar property prepended, when applicable. + static EquatableArray> AddAutomaticScalarProperty( + INamedTypeSymbol symbol, + EquatableArray> properties, + ExternalSchemaResolver externalSchemas + ) + { + if (!CustomRuleResolver.TryGetAutomaticScalar(symbol, out var propertyName, out var valueType)) + return properties; + + // A declared member means the value-object generator does not own the property (the manual form, or + // a consumer that declared it by mistake); the discovered descriptor already covers it. + foreach (var member in symbol.GetMembers(propertyName)) + { + if (member is IPropertySymbol { IsStatic: false, DeclaredAccessibility: Accessibility.Public }) + return properties; + } + + var declaredType = CreateTypeIdentity(valueType); + if (declaredType is null) + return properties; + + var propertyType = declaredType.Value; + var originalPropertyType = valueType; + var isNullableValueType = false; + if ( + originalPropertyType is INamedTypeSymbol + { + OriginalDefinition.SpecialType: SpecialType.System_Nullable_T + } nullableType + ) + { + var unwrappedType = CreateTypeIdentity(nullableType.TypeArguments[0]); + if (unwrappedType is null) + return properties; + + propertyType = unwrappedType.Value; + originalPropertyType = nullableType.TypeArguments[0]; + isNullableValueType = true; + } + + var validationKind = GetPropertyValidationKind(propertyType, originalPropertyType, externalSchemas); + var enumType = TypeHelpers.UnwrapNullableType(originalPropertyType) as INamedTypeSymbol; + var isEnum = enumType is { TypeKind: TypeKind.Enum }; + var isFlagsEnum = isEnum && HasFlagsAttribute(enumType!); + + ZodPropertyDescriptor descriptor = new( + propertyType, + propertyName, + propertyName, + TypeHelpers.CanBeNull(valueType), + isNullableValueType, + isEnum, + isFlagsEnum, + isEnum && !isFlagsEnum ? GetIgnoredEnumMembers(enumType!) : default, + validationKind, + null, + false, + null, + new(string.Empty, string.Empty, false), + false, + EmptyValidationAttributes, + new([]) + ); + + return new([GeneratorResult.Create(descriptor), .. properties]); + } + + /// + /// The empty for a synthesized property: the automatic scalar form + /// has no source member, so it carries no DataAnnotations. + /// + static readonly ValidationAttributes EmptyValidationAttributes = new( + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty, + GeneratorResult.Empty + ); + internal static GeneratorResult GetValidatablePropertyDescriptor( IPropertySymbol property, ExternalSchemaResolver? externalSchemas = null ) { - var propertyType = CreateTypeIdentity(property.Type); + var declaredType = CreateTypeIdentity(property.Type); + + // The property type cannot be represented (an unresolved/error type, a type parameter, dynamic, a + // pointer, ...). The compiler already reports the underlying problem, so skip the property rather than + // fail the whole generation pass and add a second, noisier error. + if (declaredType is null) + return GeneratorResult.Empty; + + var propertyType = declaredType.Value; var originalPropertyType = property.Type; var propertyCanBeNull = TypeHelpers.CanBeNull(originalPropertyType); var isNullableValueType = false; @@ -338,7 +448,11 @@ originalPropertyType is INamedTypeSymbol } nullableType ) { - propertyType = new(nullableType.TypeArguments[0]); + var unwrappedType = CreateTypeIdentity(nullableType.TypeArguments[0]); + if (unwrappedType is null) + return GeneratorResult.Empty; + + propertyType = unwrappedType.Value; originalPropertyType = nullableType.TypeArguments[0]; isNullableValueType = true; } @@ -499,17 +613,38 @@ rangeAttribute with ); } - static TypeIdentity CreateTypeIdentity(ITypeSymbol typeSymbol) + /// + /// Creates the TypeIdentity for a symbol, returning for symbols that + /// cannot be represented (unresolved error types, type parameters, dynamic, pointers, ...). + /// + /// + /// Generator pipelines routinely encounter such symbols — an unresolved property type in a compilation that + /// is already failing to build is the common case — so this must not throw. The caller skips the property + /// instead, leaving the compiler's own error as the single, clear signal. + /// + static TypeIdentity? CreateTypeIdentity(ITypeSymbol typeSymbol) { if (typeSymbol is IArrayTypeSymbol arrayType) { var elementIdentity = CreateTypeIdentity(arrayType.ElementType); - return new TypeIdentity($"{elementIdentity.Name}[]", elementIdentity.Namespace); + return elementIdentity is null + ? null + : new TypeIdentity($"{elementIdentity.Value.Name}[]", elementIdentity.Value.Namespace); } - return new TypeIdentity(typeSymbol); + return TypeIdentity.TryCreate(typeSymbol, out var identity) ? identity : null; } + /// + /// Determines whether an automatic scalar's underlying type can be represented as a schema property. + /// Mirrors so the analyzer and the generator agree on which automatic + /// scalar types can produce a schema. + /// + /// The scalar's declared underlying type. + /// when the type can be represented. + internal static bool CanRepresentScalarValueType(ITypeSymbol valueType) => + CreateTypeIdentity(TypeHelpers.UnwrapNullableType(valueType)) is not null; + static PropertyValidationKind GetPropertyValidationKind( TypeIdentity propertyType, ITypeSymbol originalType, @@ -600,13 +735,13 @@ static EquatableArray GetIgnoredEnumMembers(INamedTypeSymbol enumType) foreach (var iface in namedType.AllInterfaces) { if (TypeHelpers.Implements(iface, TypeLibrary.System.Collections.Generic.IEnumerable)) - return new TypeIdentity(iface.TypeArguments[0]); + return CreateTypeIdentity(iface.TypeArguments[0]); } return namedType.IsGenericType && TypeHelpers.Implements(namedType, TypeLibrary.System.Collections.Generic.IEnumerable) - ? new TypeIdentity(namedType.TypeArguments[0]) + ? CreateTypeIdentity(namedType.TypeArguments[0]) : null; } diff --git a/src/src/SourceGenerators/Helpers/TypeLibrary.cs b/src/src/SourceGenerators/Helpers/TypeLibrary.cs index bae32a2..75eb1fa 100644 --- a/src/src/SourceGenerators/Helpers/TypeLibrary.cs +++ b/src/src/SourceGenerators/Helpers/TypeLibrary.cs @@ -21,7 +21,11 @@ static partial class TypeLibraryGenerator // generator emits into the consuming compilation; the adapter is referenced by name, not by a package. public const string ValueObjectsNamespace = "Purview.ValueObjects"; - public const string ScalarAttributeFullName = ValueObjectsNamespace + ".Serialization.ScalarAttribute"; + public const string ValueObjectsSerializationNamespace = ValueObjectsNamespace + ".Serialization"; + + // Both the manual [Scalar] attribute and the automatic [Scalar] / [Scalar(typeof(TValue))] + // attributes share this name; they are distinguished by arity and by their constructor arguments. + public const string ScalarAttributeName = "ScalarAttribute"; public const string ScalarRuleAdapterName = "ScalarRuleAdapter"; diff --git a/src/src/SourceGenerators/SourceGenerators.csproj b/src/src/SourceGenerators/SourceGenerators.csproj index 9f0b6ee..f874b0e 100644 --- a/src/src/SourceGenerators/SourceGenerators.csproj +++ b/src/src/SourceGenerators/SourceGenerators.csproj @@ -2,13 +2,6 @@ true Purview.ZodSharp.SourceGenerators - - diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index 7d4c531..9a0d3d2 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -42,6 +42,7 @@ public sealed class ZodSchemaAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.UnusedRuleAttributeArgument, DiagnosticLibrary.RuleAttributeWithoutSchema, DiagnosticLibrary.NativeUnionRecommended, + DiagnosticLibrary.AutomaticScalarSchemaUnavailable, ]; public override ImmutableArray SupportedDiagnostics => SupportedDiagnosticsList; @@ -117,6 +118,8 @@ ImmutableHashSet schemaReachableTypes var typeLocation = GetTypeLocation(type); + ReportAutomaticScalarDiagnostics(context, type, zodSchemaData, typeLocation); + // Type-level rules validate the whole value. The generator resolves the same attributes to emit those // validations, so a rule that resolves to nothing is reported here rather than dropped silently. ReportTypeRuleDiagnostics(context, type, typeLocation); @@ -232,6 +235,59 @@ static void ReportTypeRuleDiagnostics(SymbolAnalysisContext context, INamedTypeS } } + /// + /// Reports ZODSGEN044 when a [ZodSchema] type uses an automatic scalar form + /// ([Scalar<TValue>] or [Scalar(typeof(TValue))]) that cannot produce a usable schema. + /// The value-object generator always emits a Create that calls + /// {Type}Schema.Validate(instance), so both a suppressed Validate method and an underlying + /// type that cannot be represented leave a dangling reference. + /// + /// The analysis context the diagnostic is reported to. + /// The schema target type. + /// The resolved [ZodSchema] attribute data. + /// The location to report against. + static void ReportAutomaticScalarDiagnostics( + SymbolAnalysisContext context, + INamedTypeSymbol type, + ZodSchemaAttributeData zodSchemaData, + Location typeLocation + ) + { + if ( + !CustomRuleResolver.TryGetScalarAttribute(type, out var scalar) + || !scalar.IsAutomatic + || scalar.ValueType is null + ) + { + return; + } + + if (!zodSchemaData.GenerateValidateMethod) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.AutomaticScalarSchemaUnavailable, + typeLocation, + type.Name, + "[ZodSchema(GenerateValidateMethod = false)] suppresses the Validate method the value-object generator calls" + ) + ); + return; + } + + if (!SourceGenLibrary.CanRepresentScalarValueType(scalar.ValueType)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.AutomaticScalarSchemaUnavailable, + typeLocation, + type.Name, + $"its underlying type '{scalar.ValueType.ToDisplayString()}' cannot be represented" + ) + ); + } + } + static Location GetTypeLocation(INamedTypeSymbol type) { foreach (var location in type.Locations) diff --git a/src/src/SystemTextJson/SystemTextJson.csproj b/src/src/SystemTextJson/SystemTextJson.csproj index cb6cf35..ff786ec 100644 --- a/src/src/SystemTextJson/SystemTextJson.csproj +++ b/src/src/SystemTextJson/SystemTextJson.csproj @@ -3,11 +3,11 @@ true false ZodSharp - Purview.ZodSharp.SystemTextJson true + Purview.ZodSharp.SystemTextJson Purview.ZodSharp.SystemTextJson A high-performance C# port of Zod schema validation library. Features zero-allocation validation, struct-based rules, fluent API, and source generator support for maximum performance. zod;validation;schema;json diff --git a/src/src/ZodSharp/Core/ZodType.cs b/src/src/ZodSharp/Core/ZodType.cs index 04604a0..c92dd3a 100644 --- a/src/src/ZodSharp/Core/ZodType.cs +++ b/src/src/ZodSharp/Core/ZodType.cs @@ -14,11 +14,9 @@ public abstract class ZodType : IZodSchema, IO { static readonly string[] EmptyPath = []; - ImmutableArray> _rules = []; - // Read by the JSON Schema exporter, which previously reached this by walking BaseType.BaseType and // reflecting on the field name — fragile, and invisible to the trimmer. - internal ImmutableArray> AppliedRules => _rules; + internal ImmutableArray> AppliedRules { get; private set; } = []; /// public virtual bool IsOptional => false; @@ -43,7 +41,7 @@ public ValidationResult Validate(TInput value) var validatedValue = parseResult.Value; - var rulesCount = _rules.Length; + var rulesCount = AppliedRules.Length; if (rulesCount == 0) { return ValidationResult.Success(validatedValue); @@ -51,7 +49,7 @@ public ValidationResult Validate(TInput value) List? errors = null; - foreach (var rule in _rules) + foreach (var rule in AppliedRules) { if (!rule.IsValid(validatedValue)) { @@ -99,7 +97,7 @@ public ValueTask> ValidateAsync( public virtual ZodType AddRule(IValidationRule rule) { ArgumentNullException.ThrowIfNull(rule); - _rules = _rules.Add(rule); + AppliedRules = AppliedRules.Add(rule); return this; } @@ -183,7 +181,7 @@ static string ResolveCode(IValidationRule rule) => /// /// Gets the number of rules accumulated on this schema. /// - protected int RuleCount => _rules.Length; + protected int RuleCount => AppliedRules.Length; /// /// Sets the description of this schema. diff --git a/src/src/ZodSharp/Expressions/CompiledValidator.cs b/src/src/ZodSharp/Expressions/CompiledValidator.cs index 9d91931..f8fb913 100644 --- a/src/src/ZodSharp/Expressions/CompiledValidator.cs +++ b/src/src/ZodSharp/Expressions/CompiledValidator.cs @@ -78,9 +78,8 @@ public static Func CompileParser(IZodSchema schema) // cannot keep a schema (or its closure over user state) alive. static class ValidatorCache { - internal static readonly ConditionalWeakTable, Func>> Validators = - new(); + internal static readonly ConditionalWeakTable, Func>> Validators = []; - internal static readonly ConditionalWeakTable, Func> Parsers = new(); + internal static readonly ConditionalWeakTable, Func> Parsers = []; } } diff --git a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs index ae6e757..34f516e 100644 --- a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs @@ -15,10 +15,8 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the bound and {1} the offending value. public const string MessageFormat = "Value must be greater than or equal to {0}, but got {1}"; - readonly T _minValue; - // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. - internal T MinValue => _minValue; + internal T MinValue { get; } readonly string _message; /// @@ -29,7 +27,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public GreaterThanOrEqualRule(T minValue, string? message = null, string? code = null) { - _minValue = minValue; + MinValue = minValue; _message = message.Or(MessageFormat); Code = code.Or(ErrorCode); } @@ -39,14 +37,14 @@ public GreaterThanOrEqualRule(T minValue, string? message = null, string? code = /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in T value) => value.CompareTo(_minValue) >= 0; + public bool IsValid(in T value) => value.CompareTo(MinValue) >= 0; /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _minValue, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, MinValue, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs index 4564832..29aaf32 100644 --- a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs @@ -15,10 +15,8 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the bound and {1} the offending value. public const string MessageFormat = "Value must be less than or equal to {0}, but got {1}"; - readonly T _maxValue; - // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. - internal T MaxValue => _maxValue; + internal T MaxValue { get; } readonly string _message; /// @@ -29,7 +27,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public LessThanOrEqualRule(T maxValue, string? message = null, string? code = null) { - _maxValue = maxValue; + MaxValue = maxValue; _message = message.Or(MessageFormat); Code = code.Or(ErrorCode); } @@ -39,14 +37,14 @@ public LessThanOrEqualRule(T maxValue, string? message = null, string? code = nu /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in T value) => value.CompareTo(_maxValue) <= 0; + public bool IsValid(in T value) => value.CompareTo(MaxValue) <= 0; /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _maxValue, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, MaxValue, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/MaxLengthRule.cs b/src/src/ZodSharp/Rules/MaxLengthRule.cs index fcd7f95..3c71f43 100644 --- a/src/src/ZodSharp/Rules/MaxLengthRule.cs +++ b/src/src/ZodSharp/Rules/MaxLengthRule.cs @@ -13,10 +13,8 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the maximum length and {1} the actual length. public const string MessageFormat = "String must be at most {0} characters long, but got {1}"; - readonly int _maxLength; - // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. - internal int MaxLength => _maxLength; + internal int MaxLength { get; } readonly string _message; /// @@ -27,7 +25,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public MaxLengthRule(int maxLength, string? message = null, string? code = null) { - _maxLength = maxLength; + MaxLength = maxLength; _message = message.Or(MessageFormat); Code = code.Or(ErrorCode); } @@ -37,14 +35,14 @@ public MaxLengthRule(int maxLength, string? message = null, string? code = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in string value) => value.LengthOrDefault() <= _maxLength; + public bool IsValid(in string value) => value.LengthOrDefault() <= MaxLength; /// /// Validates that the span meets the maximum length requirement without materialising a string. /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(ReadOnlySpan value) => value.Length <= _maxLength; + public bool IsValid(ReadOnlySpan value) => value.Length <= MaxLength; /// /// Gets the error message for a failed validation. @@ -52,7 +50,7 @@ public MaxLengthRule(int maxLength, string? message = null, string? code = null) /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => - RuleMessage.Format(_message ?? MessageFormat, _maxLength, value.LengthOrDefault()); + RuleMessage.Format(_message ?? MessageFormat, MaxLength, value.LengthOrDefault()); /// /// Gets the error message for a failed span validation. @@ -60,7 +58,7 @@ public string GetErrorMessage(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - RuleMessage.Format(_message ?? MessageFormat, _maxLength, value.Length); + RuleMessage.Format(_message ?? MessageFormat, MaxLength, value.Length); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/MaxValueRule.cs b/src/src/ZodSharp/Rules/MaxValueRule.cs index fa61c35..afab6a2 100644 --- a/src/src/ZodSharp/Rules/MaxValueRule.cs +++ b/src/src/ZodSharp/Rules/MaxValueRule.cs @@ -15,10 +15,8 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the bound and {1} the offending value. public const string MessageFormat = "Value must be at most {0}, but got {1}"; - readonly T _maxValue; - // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. - internal T MaxValue => _maxValue; + internal T MaxValue { get; } readonly string _message; /// @@ -29,7 +27,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public MaxValueRule(T maxValue, string? message = null, string? code = null) { - _maxValue = maxValue; + MaxValue = maxValue; _message = message.Or(MessageFormat); Code = code.Or(ErrorCode); } @@ -39,14 +37,14 @@ public MaxValueRule(T maxValue, string? message = null, string? code = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in T value) => value.CompareTo(_maxValue) <= 0; + public bool IsValid(in T value) => value.CompareTo(MaxValue) <= 0; /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _maxValue, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, MaxValue, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/MinLengthRule.cs b/src/src/ZodSharp/Rules/MinLengthRule.cs index ed0d041..986d7a3 100644 --- a/src/src/ZodSharp/Rules/MinLengthRule.cs +++ b/src/src/ZodSharp/Rules/MinLengthRule.cs @@ -13,10 +13,8 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the minimum length and {1} the actual length. public const string MessageFormat = "String must be at least {0} characters long, but got {1}"; - readonly int _minLength; - // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. - internal int MinLength => _minLength; + internal int MinLength { get; } readonly string _message; /// @@ -27,7 +25,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public MinLengthRule(int minLength, string? message = null, string? code = null) { - _minLength = minLength; + MinLength = minLength; _message = message.Or(MessageFormat); Code = code.Or(ErrorCode); } @@ -37,14 +35,14 @@ public MinLengthRule(int minLength, string? message = null, string? code = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in string value) => value.LengthOrDefault() >= _minLength; + public bool IsValid(in string value) => value.LengthOrDefault() >= MinLength; /// /// Validates that the span meets the minimum length requirement without materialising a string. /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(ReadOnlySpan value) => value.Length >= _minLength; + public bool IsValid(ReadOnlySpan value) => value.Length >= MinLength; /// /// Gets the error message for a failed validation. @@ -52,7 +50,7 @@ public MinLengthRule(int minLength, string? message = null, string? code = null) /// The value that failed validation /// The error message public string GetErrorMessage(in string value) => - RuleMessage.Format(_message ?? MessageFormat, _minLength, value.LengthOrDefault()); + RuleMessage.Format(_message ?? MessageFormat, MinLength, value.LengthOrDefault()); /// /// Gets the error message for a failed span validation. @@ -60,7 +58,7 @@ public string GetErrorMessage(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - RuleMessage.Format(_message ?? MessageFormat, _minLength, value.Length); + RuleMessage.Format(_message ?? MessageFormat, MinLength, value.Length); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/MinValueRule.cs b/src/src/ZodSharp/Rules/MinValueRule.cs index 9cd405d..34c755a 100644 --- a/src/src/ZodSharp/Rules/MinValueRule.cs +++ b/src/src/ZodSharp/Rules/MinValueRule.cs @@ -15,10 +15,9 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the bound and {1} the offending value. public const string MessageFormat = "Value must be at least {0}, but got {1}"; - readonly T _minValue; - // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. - internal T MinValue => _minValue; + internal T MinValue { get; } + readonly string _message; /// @@ -29,7 +28,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public MinValueRule(T minValue, string? message = null, string? code = null) { - _minValue = minValue; + MinValue = minValue; _message = message.Or(MessageFormat); Code = code.Or(ErrorCode); } @@ -39,14 +38,14 @@ public MinValueRule(T minValue, string? message = null, string? code = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in T value) => value.CompareTo(_minValue) >= 0; + public bool IsValid(in T value) => value.CompareTo(MinValue) >= 0; /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _minValue, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, MinValue, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/MultipleOfRule.cs b/src/src/ZodSharp/Rules/MultipleOfRule.cs index 1a1bd33..afca4a8 100644 --- a/src/src/ZodSharp/Rules/MultipleOfRule.cs +++ b/src/src/ZodSharp/Rules/MultipleOfRule.cs @@ -22,10 +22,8 @@ namespace ZodSharp.Rules; /// const double RelativeTolerance = 1e-12; - readonly T _divisor; - // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. - internal T Divisor => _divisor; + internal T Divisor { get; } readonly string _message; /// @@ -39,7 +37,7 @@ public MultipleOfRule(T divisor, string? message = null, string? code = null) if (divisor == T.Zero) throw new ArgumentException("Divisor cannot be zero", nameof(divisor)); - _divisor = divisor; + Divisor = divisor; _message = message.Or(MessageFormat); Code = code.Or(ErrorCode); } @@ -60,9 +58,9 @@ public bool IsValid(in T value) if (!T.IsFinite(value)) return false; - var remainder = T.Abs(value % _divisor); - var distance = T.Min(remainder, T.Abs(_divisor) - remainder); - var tolerance = T.CreateSaturating(RelativeTolerance) * T.Max(T.Abs(_divisor), T.Abs(value)); + var remainder = T.Abs(value % Divisor); + var distance = T.Min(remainder, T.Abs(Divisor) - remainder); + var tolerance = T.CreateSaturating(RelativeTolerance) * T.Max(T.Abs(Divisor), T.Abs(value)); return distance <= tolerance; } @@ -71,7 +69,7 @@ public bool IsValid(in T value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, _divisor, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, Divisor, value); /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); diff --git a/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs b/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs new file mode 100644 index 0000000..9edb38f --- /dev/null +++ b/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs @@ -0,0 +1,63 @@ +namespace ZodSharp.Rules; + +/// +/// Validation rule that accepts a string but rejects one that is empty or +/// whitespace-only. +/// +/// +/// +/// This is the null-tolerant counterpart to (which treats a null string as +/// a sentinel) and (which always rejects null). A nullable scalar value object +/// round-trips JSON null, so its schema must accept null while still rejecting a whitespace-only +/// value; the predicate is value is null || !string.IsNullOrWhiteSpace(value). +/// +/// +/// A rule written against the underlying string? value is adapted to a scalar value object by the +/// ScalarRuleAdapter the value-object generator emits, so [NullOrNonWhiteSpace] validates a +/// [Scalar<string>(Nullable = true)] (or [Scalar(typeof(string), Nullable = true)]) type +/// as a unit. +/// +/// +[Core.ZodRule] +public readonly record struct NullOrNonWhiteSpaceRule : Core.IValidationRule, Core.IZodRule +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "invalid_string"; + + /// Gets the message format; {0} is the offending value. + public const string MessageFormat = "Value must be null or non-whitespace, but got '{0}'"; + + readonly string _message; + + /// + /// Initializes a new instance of the struct. + /// + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public NullOrNonWhiteSpaceRule(string? message = null, string? code = null) + { + _message = message.Or(MessageFormat); + Code = code.Or(ErrorCode); + } + + /// + /// Validates that the value is or contains non-whitespace characters. + /// + /// The value to validate + /// True if valid, false otherwise + public bool IsValid(in string? value) => value is null || !string.IsNullOrWhiteSpace(value); + + /// + /// Gets the error message for a failed validation. + /// + /// The value that failed validation + /// The error message + public string GetErrorMessage(in string? value) => RuleMessage.Format(_message ?? MessageFormat, value); + + /// Gets the error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.Origin => null; +} diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index bbad56f..ca5bf3c 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -37,11 +37,10 @@ namespace ZodSharp.Rules; /// public static readonly TimeSpan DefaultMatchTimeout = TimeSpan.FromSeconds(2); - readonly Regex _pattern; readonly string _message; // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. - internal Regex Pattern => _pattern; + internal Regex Pattern { get; } /// /// Initializes a new instance of the RegexRule struct. @@ -51,7 +50,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public RegexRule(Regex pattern, string? message = null, string? code = null) { - _pattern = pattern ?? throw new ArgumentNullException(nameof(pattern)); + Pattern = pattern ?? throw new ArgumentNullException(nameof(pattern)); _message = message.Or(MessageFormat); Code = code.Or(ErrorCode); } @@ -74,7 +73,7 @@ public bool IsValid(in string value) { try { - return _pattern.IsMatch(value); + return Pattern.IsMatch(value); } catch (RegexMatchTimeoutException) { @@ -94,7 +93,7 @@ public bool IsValid(ReadOnlySpan value) { try { - return _pattern.IsMatch(value); + return Pattern.IsMatch(value); } catch (RegexMatchTimeoutException) { diff --git a/src/src/ZodSharp/Schemas/ZodArray.cs b/src/src/ZodSharp/Schemas/ZodArray.cs index 80c88f9..2103a67 100644 --- a/src/src/ZodSharp/Schemas/ZodArray.cs +++ b/src/src/ZodSharp/Schemas/ZodArray.cs @@ -132,6 +132,7 @@ protected override ValidationResult ParseInternal(T[] value) ); } + // No length failure. return null; } diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 1c8ccd8..d424812 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -274,6 +274,8 @@ 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. +`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`. + ## Error factory `ErrorType` lets you define a user-facing error (code, optional category, description, optional diff --git a/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/AddZodSharpExtensionsTests.cs b/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/AddZodSharpExtensionsTests.cs index aac656b..a63eb34 100644 --- a/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/AddZodSharpExtensionsTests.cs +++ b/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/AddZodSharpExtensionsTests.cs @@ -39,7 +39,7 @@ public async Task AddZodSharp_AutoRegistersGeneratedValidators_FromConfiguredAss services.AddZodSharp(static opts => opts.ScanAssemblies.Add(typeof(AddZodSharpExtensionsTests).Assembly)); var provider = services.BuildServiceProvider(); var factory = provider.GetRequiredService(); - await Assert.That(factory.IsRegistered()).IsTrue(); + await Assert.That(factory.IsRegistered()).IsTrue(); } [Test] @@ -55,7 +55,7 @@ public async Task AddZodSharpAssembly_RegistersGeneratedValidators_FromSpecified var factory = provider.GetRequiredService(); // Assert - await Assert.That(factory.IsRegistered()).IsTrue(); + await Assert.That(factory.IsRegistered()).IsTrue(); } [Test] @@ -72,7 +72,7 @@ public async Task AddZodSharpAssembly_CalledMultipleTimes_RemainsAdditive() var factory = provider.GetRequiredService(); // Assert - await Assert.That(factory.IsRegistered()).IsTrue(); + await Assert.That(factory.IsRegistered()).IsTrue(); await Assert.That(factory.IsRegistered()).IsTrue(); } @@ -89,7 +89,7 @@ public async Task AddZodSharpAssembly_WorksWhenCalledBeforeAddZodSharp() var factory = provider.GetRequiredService(); // Assert - await Assert.That(factory.IsRegistered()).IsTrue(); + await Assert.That(factory.IsRegistered()).IsTrue(); } [Test] @@ -149,7 +149,7 @@ public async Task AddZodSharp_CalledTwice_OnlyFirstConfigurationIsApplied() services.AddZodSharp(); var provider = services.BuildServiceProvider(); var factory = provider.GetRequiredService(); - await Assert.That(factory.IsRegistered()).IsTrue(); + await Assert.That(factory.IsRegistered()).IsTrue(); } [Test] @@ -175,7 +175,7 @@ public async Task AddZodSharp_CalledTwice_OnlyFirstConfigureIsApplied_ButAssembl // Assert await Assert.That(factory.IsRegistered()).IsTrue(); await Assert.That(factory.IsRegistered()).IsFalse(); - await Assert.That(factory.IsRegistered()).IsTrue(); + await Assert.That(factory.IsRegistered()).IsTrue(); await Assert.That(factory.IsRegistered()).IsTrue(); } @@ -190,6 +190,6 @@ public async Task AddZodSharp_AfterAddZodSharpFactory_DoesNotReplaceFactory() var provider = services.BuildServiceProvider(); var factory = provider.GetRequiredService(); await Assert.That(factory.IsRegistered()).IsTrue(); - await Assert.That(factory.IsRegistered()).IsFalse(); + await Assert.That(factory.IsRegistered()).IsFalse(); } } diff --git a/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/SampleDiDto.cs b/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/SampleDiDto.cs index 6cc3ec4..d0689ae 100644 --- a/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/SampleDiDto.cs +++ b/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/SampleDiDto.cs @@ -2,15 +2,15 @@ namespace ZodSharp; -sealed class SampleDiDto +sealed class SampleDIDto { public string? Name { get; set; } } -sealed class SampleDiDtoSchemaValidator : IZodSchemaValidator +sealed class SampleDIDtoSchemaValidator : IZodSchemaValidator { - public ValidationResult Validate(SampleDiDto value) => ValidationResult.Success(value); + public ValidationResult Validate(SampleDIDto value) => ValidationResult.Success(value); - public ValueTask> ValidateAsync(SampleDiDto value, CancellationToken _ = default) => + public ValueTask> ValidateAsync(SampleDIDto value, CancellationToken _ = default) => new(Validate(value)); } diff --git a/src/tests/AspNetCore.UnitTests/Properties/AssemblyInfo.cs b/src/tests/AspNetCore.UnitTests/Properties/AssemblyInfo.cs index f768fe3..9808044 100644 --- a/src/tests/AspNetCore.UnitTests/Properties/AssemblyInfo.cs +++ b/src/tests/AspNetCore.UnitTests/Properties/AssemblyInfo.cs @@ -1 +1 @@ -[assembly: ZodSharp.Core.ZodSchemaGenerated(typeof(ZodSharp.SampleDiDto), typeof(ZodSharp.SampleDiDtoSchemaValidator))] +[assembly: ZodSharp.Core.ZodSchemaGenerated(typeof(SampleDIDto), typeof(SampleDIDtoSchemaValidator))] diff --git a/src/tests/JsonInterop.UnitTests/JsonInterop.UnitTests.csproj b/src/tests/JsonInterop.UnitTests/JsonInterop.UnitTests.csproj index b46dbe0..5972a78 100644 --- a/src/tests/JsonInterop.UnitTests/JsonInterop.UnitTests.csproj +++ b/src/tests/JsonInterop.UnitTests/JsonInterop.UnitTests.csproj @@ -1,7 +1,7 @@ - ZodSharp Purview.ZodSharp.JsonInterop.UnitTests + ZodSharp $(NoWarn);CA1849;CA1002;CA2227; diff --git a/src/tests/NewtonsoftJson.UnitTests/NewtonsoftJson.UnitTests.csproj b/src/tests/NewtonsoftJson.UnitTests/NewtonsoftJson.UnitTests.csproj index d5dbb2f..251e51f 100644 --- a/src/tests/NewtonsoftJson.UnitTests/NewtonsoftJson.UnitTests.csproj +++ b/src/tests/NewtonsoftJson.UnitTests/NewtonsoftJson.UnitTests.csproj @@ -1,7 +1,7 @@  + Purview.ZodSharp.NewtonsoftJson.UnitTests ZodSharp $(NoWarn);CA1849;CA1002;CA2227; - Purview.ZodSharp.NewtonsoftJson.UnitTests diff --git a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs index b9d36e2..56bdf5a 100644 --- a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs +++ b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs @@ -9,8 +9,8 @@ namespace ZodSharp.SourceGenerators; /// (RS2000/RS2001), which report at build time when a diagnostic is missing from either file. /// /// -/// ZODSGEN037–ZODSGEN043 moved into a ## Release 2.1.0 block, so the unshipped file is -/// now empty. The next diagnostic added goes into the unshipped file and moves across when it ships. +/// ZODSGEN037–ZODSGEN044 are recorded under a ## Release 2.0.2 block. The unshipped +/// file declares no rules; the next diagnostic added goes there and moves across when it ships. /// public partial class AnalyzerReleaseTrackingTests { @@ -63,7 +63,7 @@ public partial class AnalyzerReleaseTrackingTests "ZODSASP003", "ZODSASP100", "ZODSASP101", - // Release 2.1.0. + // Release 2.0.2. "ZODSGEN037", "ZODSGEN038", "ZODSGEN039", @@ -71,6 +71,7 @@ public partial class AnalyzerReleaseTrackingTests "ZODSGEN041", "ZODSGEN042", "ZODSGEN043", + "ZODSGEN044", ]; /// @@ -90,7 +91,7 @@ public async Task ShippedReleases_GivenEveryRelease_DeclareEveryDiagnosticId(Can // Assert — each release block the catalogue has published, and the full rule set across them. await Assert.That(markdown).Contains("## Release 2.0.0"); - await Assert.That(markdown).Contains("## Release 2.1.0"); + await Assert.That(markdown).Contains("## Release 2.0.2"); await Assert.That(SortedRuleIds(ruleIds)).IsEqualTo(SortedRuleIds(ExpectedShippedRuleIds)); } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.AutomaticScalar.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.AutomaticScalar.cs new file mode 100644 index 0000000..866fe4c --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.AutomaticScalar.cs @@ -0,0 +1,163 @@ +using ZodSharp.SourceGenerators.Helpers; + +namespace ZodSharp.SourceGenerators; + +public partial class ZodSchemaAnalyzerTests +{ + /// + /// Stand-in Purview.ValueObjects scalar attributes (both automatic forms, with Nullable on the + /// shared base), because this repository does not reference the value-objects package. + /// + const string AutomaticScalarAnalyzerPrelude = """ + using System; + + namespace Purview.ValueObjects.Serialization + { + public abstract class ScalarOptionsAttribute : Attribute + { + protected ScalarOptionsAttribute(string propertyName) => PropertyName = propertyName; + + public string PropertyName { get; } + + public bool Nullable { get; init; } + } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute : ScalarOptionsAttribute + { + public ScalarAttribute(string propertyName = "Value") : base(propertyName) { } + + public ScalarAttribute(Type valueType, string propertyName = "Value") : base(propertyName) => + ValueType = valueType; + + public Type? ValueType { get; } + } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute(string propertyName = "Value") + : ScalarOptionsAttribute(propertyName); + } + """; + + [Test] + public async Task AutomaticScalar_GivenSuppressedValidateMethod_ProducesZODSGEN044( + CancellationToken cancellationToken + ) + { + // Arrange — the value-object generator's Create always calls {Type}Schema.Validate, so suppressing + // the Validate method leaves a dangling reference. + const string source = + AutomaticScalarAnalyzerPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema(GenerateValidateMethod = false)] + public readonly partial record struct InstallationId { } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AutomaticScalarSchemaUnavailable); + } + + [Test] + public async Task AutomaticScalar_GivenTypeofFormWithSuppressedValidateMethod_ProducesZODSGEN044( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + AutomaticScalarAnalyzerPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(typeof(System.Guid))] + [ZodSchema(GenerateValidateMethod = false)] + public readonly partial record struct InstallationId { } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AutomaticScalarSchemaUnavailable); + } + + [Test] + public async Task AutomaticScalar_GivenUnrepresentableUnderlyingType_ProducesZODSGEN044( + CancellationToken cancellationToken + ) + { + // Arrange — the underlying type is unresolved, so no schema property can be generated for it. + const string source = + AutomaticScalarAnalyzerPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(typeof(MissingValueType))] + [ZodSchema] + public readonly partial record struct Weird { } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AutomaticScalarSchemaUnavailable); + } + + [Test] + public async Task AutomaticScalar_GivenValidAutomaticForm_ProducesNoZODSGEN044(CancellationToken cancellationToken) + { + // Arrange + const string source = + AutomaticScalarAnalyzerPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + public readonly partial record struct InstallationId { } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.AutomaticScalarSchemaUnavailable); + } + + [Test] + public async Task AutomaticScalar_GivenManualFormWithSuppressedValidateMethod_ProducesNoZODSGEN044( + CancellationToken cancellationToken + ) + { + // Arrange — the diagnostic is scoped to the automatic forms. + const string source = + AutomaticScalarAnalyzerPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar("Value")] + [ZodSchema(GenerateValidateMethod = false)] + public readonly partial record struct Email + { + public string Value { get; init; } = string.Empty; + } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.AutomaticScalarSchemaUnavailable); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs new file mode 100644 index 0000000..161d174 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs @@ -0,0 +1,418 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +/// +/// Tests for the automatic scalar forms ([Scalar<TValue>] and +/// [Scalar(typeof(TValue))]), where the underlying property is emitted by the value-object +/// generator and is therefore invisible to the ZodSharp generator. The schema must be generated from the +/// attribute, and type-level rules must be adapted to the scalar's underlying value. +/// +partial class ZodSchemaGeneratorTests +{ + /// + /// Stand-in Purview.ValueObjects types. This repository does not reference the value-objects package, so + /// the attribute contracts (both automatic forms, with Nullable on the shared base) and the + /// scalar rule adapter are declared here. In a real consumer the adapter is emitted by the value-object + /// generator. + /// + const string AutomaticScalarPrelude = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + using ZodSharp.Rules; + + namespace Purview.ValueObjects.Serialization + { + public abstract class ScalarOptionsAttribute : Attribute + { + protected ScalarOptionsAttribute(string propertyName) => PropertyName = propertyName; + + public string PropertyName { get; } + + public bool Nullable { get; init; } + } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute : ScalarOptionsAttribute + { + public ScalarAttribute(string propertyName = "Value") : base(propertyName) { } + + public ScalarAttribute(Type valueType, string propertyName = "Value") : base(propertyName) => + ValueType = valueType; + + public Type? ValueType { get; } + } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute(string propertyName = "Value") + : ScalarOptionsAttribute(propertyName); + } + + namespace Purview.ValueObjects + { + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + + // Stands in for the adapter the value-object generator emits into a real consumer. + public readonly record struct ScalarRuleAdapter(TRule Rule) + : IValidationRule + where TSelf : IScalarValueObject + where TRule : IValidationRule + { + public bool IsValid(in TSelf value) => Rule.IsValid(value.Value); + + public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); + } + } + """; + + [Test] + public async Task AutomaticScalar_GivenGenericAttribute_AdaptsTypeLevelRuleToUnderlyingValue( + CancellationToken cancellationToken + ) + { + // Arrange — no declared member: the value-object generator owns `Value`. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [NonSentinel(Message = "InstallationId must not be empty.")] + [ZodSchema] + public readonly partial record struct InstallationId { } + } + """; + + // Act + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("InstallationIdSchema"); + + // Assert — the rule is closed over the underlying Guid and adapted to the value object. + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule("); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::Purview.ValueObjects.ScalarRuleAdapter>(" + ); + await Assert.That(generated).ContainsGeneratedCode(".IsValid(value)"); + await Assert.That(generated).ContainsGeneratedCode("EmptyPath"); + + // The property is synthesized from the attribute (name defaults to Value). + await Assert.That(generated).ContainsGeneratedCode("ImmutableArray.Create(\"Value\")"); + } + + [Test] + public async Task AutomaticScalar_GivenTypeofAttribute_AdaptsTypeLevelRuleToUnderlyingValue( + CancellationToken cancellationToken + ) + { + // Arrange — the non-generic automatic form, which is not a generic attribute. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(typeof(Guid))] + [NonSentinel(Message = "InstallationId must not be empty.")] + [ZodSchema] + public readonly partial record struct InstallationId { } + } + """; + + // Act + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("InstallationIdSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode("new global::ZodSharp.Rules.NonSentinelRule("); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::Purview.ValueObjects.ScalarRuleAdapter>(" + ); + await Assert.That(generated).ContainsGeneratedCode("EmptyPath"); + } + + [Test] + public async Task AutomaticScalar_GivenNullableReference_ClosesAdapterOverNullableString( + CancellationToken cancellationToken + ) + { + // Arrange — [Scalar(Nullable = true)] round-trips JSON null, so the schema's string rule must + // accept null. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + [NullOrNonWhiteSpace(Message = "Nickname must be null or non-whitespace.")] + [ZodSchema] + public readonly partial record struct Nickname { } + } + """; + + // Act + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("NicknameSchema"); + + // Assert — the wrapped rule and the adapter's TValue keep the nullable annotation. + await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.NullOrNonWhiteSpaceRule("); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::Purview.ValueObjects.ScalarRuleAdapter(" + ); + } + + [Test] + public async Task AutomaticScalar_GivenTypeofNullableReference_ClosesAdapterOverNullableString( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(typeof(string), Nullable = true)] + [NullOrNonWhiteSpace(Message = "Nickname must be null or non-whitespace.")] + [ZodSchema] + public readonly partial record struct Nickname { } + } + """; + + // Act + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("NicknameSchema"); + + // Assert + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::Purview.ValueObjects.ScalarRuleAdapter(" + ); + } + + [Test] + public async Task AutomaticScalar_GivenNullableValueType_EmitsSchemaThatAcceptsNull( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + public readonly partial record struct Score { } + } + """; + + // Act + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("ScoreSchema"); + + // Assert — the synthesized nullable property is guarded, so null is accepted. + await Assert.That(generated).ContainsGeneratedCode("value.Value != null"); + await Assert.That(generated).ContainsGeneratedCode("ImmutableArray.Create(\"Value\")"); + } + + [Test] + public async Task AutomaticScalar_GivenCustomPropertyName_SynthesizesThatProperty( + CancellationToken cancellationToken + ) + { + // Arrange — both automatic spellings with a custom property name. + const string genericSource = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar("Id")] + [NonSentinel] + [ZodSchema] + public readonly partial record struct TenantId { } + } + """; + const string typeofSource = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(typeof(Guid), "Id")] + [NonSentinel] + [ZodSchema] + public readonly partial record struct TenantId { } + } + """; + + // Act + var genericResult = await GenerateAsync( + genericSource, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + var typeofResult = await GenerateAsync( + typeofSource, + ZodSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + var generic = genericResult.GetSource("TenantIdSchema"); + var typeofGenerated = typeofResult.GetSource("TenantIdSchema"); + + // Assert — the synthesized property is named Id for both forms. + await Assert.That(generic).ContainsGeneratedCode("ImmutableArray.Create(\"Id\")"); + await Assert.That(typeofGenerated).ContainsGeneratedCode("ImmutableArray.Create(\"Id\")"); + } + + [Test] + public async Task AutomaticScalar_GivenRequiredZod_ReportsMissingFieldThroughAdaptedRule( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + [RequiredZod] + [ZodSchema] + public readonly partial record struct Name { } + } + """; + + // Act + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("NameSchema"); + + // Assert — [RequiredZod] is closed over the underlying string? and adapted, with an empty path. + await Assert.That(generated).ContainsGeneratedCode("new global::ZodSharp.Rules.RequiredRule("); + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::Purview.ValueObjects.ScalarRuleAdapter>(" + ); + await Assert.That(generated).ContainsGeneratedCode("EmptyPath"); + } + + [Test] + public async Task AutomaticScalar_GivenCustomRule_ReportsCodeMessageAndOrigin(CancellationToken cancellationToken) + { + // Arrange — a hand-authored [ZodRule]-mapped rule written against the underlying value. + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + public readonly record struct NonEmptyRule(string? Message = null) + : IValidationRule, IZodRule + where T : IEquatable + { + public bool IsValid(in T value) => !value.Equals(default(T)!); + + public string GetErrorMessage(in T value) => Message ?? "Value must not be empty."; + + string? IZodRule.Code => "invalid_value"; + + string? IZodRule.Origin => "value_object"; + } + + [ZodRule(typeof(NonEmptyRule<>))] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] + public sealed class NonEmptyAttribute : ValidationAttribute + { + public string? Message { get; set; } + } + + [Purview.ValueObjects.Serialization.Scalar] + [NonEmpty(Message = "Id must not be empty.")] + [ZodSchema] + public readonly partial record struct ExternalId { } + } + """; + + // Act + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("ExternalIdSchema"); + + // Assert — the wrapped rule owns the identity, not the adapter. + await Assert + .That(generated) + .ContainsGeneratedCode( + "new global::Purview.ValueObjects.ScalarRuleAdapter>(" + ); + await Assert + .That(generated) + .ContainsGeneratedCode( + "((global::ZodSharp.Core.IZodRule)externalIdCustomRuleInner0).Code ?? \"validation_failed\"" + ); + await Assert.That(generated).ContainsGeneratedCode("EmptyPath, origin:"); + } + + [Test] + public async Task AutomaticScalar_GivenGenericAttributeWithDeclaredProperty_ValidatesAtRuntime( + CancellationToken cancellationToken + ) + { + // Arrange — the property is declared so the harness can compile the generated schema (the real + // automatic form gets it from the value-object generator, which this harness does not run). + const string source = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [NonSentinel(Message = "InstallationId must not be empty.")] + [ZodSchema] + public readonly partial record struct InstallationId + : Purview.ValueObjects.IScalarValueObject + { + public Guid Value { get; init; } + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.InstallationId")!; + var validate = assembly.GetType("Testing.InstallationIdSchema")!.GetMethod("Validate")!; + + var emptyResult = validate.Invoke(null, [Activator.CreateInstance(modelType)!])!; + var validInstance = Activator.CreateInstance(modelType)!; + modelType.GetProperty("Value")!.SetValue(validInstance, Guid.NewGuid()); + var validResult = validate.Invoke(null, [validInstance])!; + + // Assert + await Assert.That((bool)emptyResult.GetType().GetProperty("IsSuccess")!.GetValue(emptyResult)!).IsFalse(); + + var errors = (System.Collections.Immutable.ImmutableArray) + emptyResult.GetType().GetProperty("Errors")!.GetValue(emptyResult)!; + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo(Rules.NonSentinelRule.ErrorCode); + await Assert.That(errors[0].Message).IsEqualTo("InstallationId must not be empty."); + await Assert.That(errors[0].Path.Length).IsEqualTo(0); + + await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs new file mode 100644 index 0000000..e915338 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.UnresolvableProperties.cs @@ -0,0 +1,118 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + /// + /// An unresolved property type (the compilation is already failing with CS0246) must not make the generator + /// throw and surface as CS8785. The property is skipped; the compiler's own error stays the single signal. + /// + [Test] + public async Task UnresolvedPropertyType_GivenMissingType_GeneratesSchemaWithoutFailingGeneration( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public partial class Broken + { + [Required] + public MissingType Value { get; init; } = null!; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("BrokenSchema"); + + // Assert + driverResult.AssertNoGenerationExceptions(); + await Assert.That(generated).IsNotNull(); + await Assert.That(generated).ContainsGeneratedCode("class BrokenSchema"); + await Assert.That(generated).DoesNotContain("value.Value"); + } + + /// + /// A dynamic property is not a resolvable named type either; it is dropped rather than crashing the + /// pipeline, and the rest of the schema is still emitted. + /// + [Test] + public async Task DynamicProperty_GivenNonRepresentableType_IsOmittedFromSchema(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public partial class WithDynamic + { + [Required] + public string Name { get; init; } = string.Empty; + + public dynamic Payload { get; set; } = null!; + } + } + """; + + // Act + var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var generated = driverResult.GetSource("WithDynamicSchema"); + + // Assert + driverResult.AssertNoGenerationExceptions(); + await Assert.That(generated).IsNotNull(); + await Assert.That(generated).ContainsGeneratedCode("value.Name"); + await Assert.That(generated).DoesNotContain("Payload"); + await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + } + + /// + /// A collection whose element type cannot be resolved still runs the collection path (the collection itself is + /// representable), so the nested-schema lookup has to tolerate the error element too. + /// + [Test] + public async Task CollectionOfUnresolvedType_GivenMissingElementType_GeneratesSchemaWithoutFailingGeneration( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public partial class BrokenCollection + { + public List Items { get; init; } = []; + } + } + """; + + // Act + var driverResult = await GenerateAsync(source, ZodSourceGeneratorTestOptions.NoValidation, cancellationToken); + var generated = driverResult.GetSource("BrokenCollectionSchema"); + + // Assert + driverResult.AssertNoGenerationExceptions(); + await Assert.That(generated).IsNotNull(); + await Assert.That(generated).ContainsGeneratedCode("class BrokenCollectionSchema"); + await Assert.That(generated).ContainsGeneratedCode("value.Items"); + } +} diff --git a/src/tests/SystemTextJson.UnitTests/SystemTextJson.UnitTests.csproj b/src/tests/SystemTextJson.UnitTests/SystemTextJson.UnitTests.csproj index c200a93..7f616ea 100644 --- a/src/tests/SystemTextJson.UnitTests/SystemTextJson.UnitTests.csproj +++ b/src/tests/SystemTextJson.UnitTests/SystemTextJson.UnitTests.csproj @@ -1,7 +1,7 @@  + Purview.ZodSharp.SystemTextJson.UnitTests ZodSharp $(NoWarn);CA1849;CA1002;CA2227; - Purview.ZodSharp.SystemTextJson.UnitTests diff --git a/src/tests/ZodSharp.UnitTests/Rules/NullOrNonWhiteSpaceRuleTests.cs b/src/tests/ZodSharp.UnitTests/Rules/NullOrNonWhiteSpaceRuleTests.cs new file mode 100644 index 0000000..bd2e674 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/NullOrNonWhiteSpaceRuleTests.cs @@ -0,0 +1,94 @@ +using System.Globalization; +using System.Text; + +namespace ZodSharp.Rules; + +public class NullOrNonWhiteSpaceRuleTests +{ + [Test] + [Arguments(null)] + [Arguments("value")] + public async Task IsValid_GivenNullOrNonWhitespace_ReturnsTrue(string? value) + { + // Arrange + NullOrNonWhiteSpaceRule rule = new(); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + [Arguments("")] + [Arguments(" ")] + public async Task IsValid_GivenEmptyOrWhitespace_ReturnsFalse(string value) + { + // Arrange + NullOrNonWhiteSpaceRule rule = new(); + + // Act + var isValid = rule.IsValid(value); + + // Assert + await Assert.That(isValid).IsFalse(); + } + + [Test] + public async Task GetErrorMessage_GivenNoCustomMessage_FormatsDefaultMessage() + { + // Arrange + NullOrNonWhiteSpaceRule rule = new(); + + // Act + var message = rule.GetErrorMessage(" "); + var expected = string.Format( + CultureInfo.CurrentCulture, + CompositeFormat.Parse(NullOrNonWhiteSpaceRule.MessageFormat), + " " + ); + + // Assert + await Assert.That(message).IsEqualTo(expected); + } + + [Test] + public async Task GetErrorMessage_GivenCustomMessage_ReturnsCustomMessage() + { + // Arrange + NullOrNonWhiteSpaceRule rule = new("Nickname must be null or non-whitespace."); + + // Act + var message = rule.GetErrorMessage(" "); + + // Assert + await Assert.That(message).IsEqualTo("Nickname must be null or non-whitespace."); + } + + [Test] + public async Task Code_GivenNoOverride_ReturnsErrorCode() + { + // Arrange + NullOrNonWhiteSpaceRule rule = new(); + + // Act + var code = rule.Code; + + // Assert + await Assert.That(code).IsEqualTo(NullOrNonWhiteSpaceRule.ErrorCode); + } + + [Test] + public async Task Code_GivenOverride_ReturnsOverride() + { + // Arrange + NullOrNonWhiteSpaceRule rule = new(code: "custom_code"); + + // Act + var code = rule.Code; + + // Assert + await Assert.That(code).IsEqualTo("custom_code"); + } +} diff --git a/src/tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj b/src/tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj index 52f3ead..a0781d7 100644 --- a/src/tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj +++ b/src/tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj @@ -1,7 +1,7 @@  - $(NoWarn);CA1308;CA1849; Purview.ZodSharp.UnitTests + $(NoWarn);CA1308;CA1849;