Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"rollForward": false
},
"purview.build": {
"version": "0.5.4",
"version": "0.7.0",
"commands": [
"purview-build"
],
Expand Down
8 changes: 5 additions & 3 deletions .config/purview-build.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
"Solution": "src/ZodSharp.slnx",
"TestRoot": "src/tests",
"TestPatterns": "*Tests.csproj",
"TestFilter": "/*/*/*/*[Category=Unit]"
"TestFilter": "/*/*/*/*"
},
"PackValidation": {
// Explicit because Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier
// versions forced this check on and could not be configured off, so these packages have
// always been validated against it; declaring it keeps that behaviour.
"RequiredCompilerFlags": ["optimization=release"],
"RequiredCompilerFlags": [
"optimization=release"
],
"RequireSymbolPackage": true,
"RequireSymbolFiles": true,
"RequiredContent": {
Expand Down Expand Up @@ -50,4 +52,4 @@
"Release": {
"Mode": "None"
}
}
}
26 changes: 21 additions & 5 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
# Purview.BuildSdk 1.1.0 - managed file, do not edit
#
# This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version
# on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is
# yours, and is never touched.
#
# To add your own rules, create a second .editorconfig in a subdirectory - `src/.editorconfig` is the
# usual choice - without `root = true`. EditorConfig applies the nearest file last, so your settings win
# over these, and nothing you write is lost when this file is updated.
#
# To stop the SDK updating this file, set one of:
# <PurviewEditorConfigAutoUpdate>false</PurviewEditorConfigAutoUpdate> keep this file as it is
# <PurviewRepoBootstrapMode>Never</PurviewRepoBootstrapMode> opt out of all bootstrapping
# <BootstrapEditorConfigToRepoRoot>false</BootstrapEditorConfigToRepoRoot>
#
# Deleting the first line also works: without the marker the SDK treats the file as yours. You then own
# every rule in it, including the ones a future SDK version would have fixed.
#
# See https://github.com/purview-dev/build-sdk#editorconfig for the full description.

root = true

# Base Configuration - General Settings, File Formatting, and Naming Conventions
Expand Down Expand Up @@ -360,12 +380,8 @@ dotnet_style_require_accessibility_modifiers = omit_if_default:warning
# Code-block preferences
csharp_prefer_braces = when_multiline:suggestion
csharp_prefer_simple_using_statement = false:silent
csharp_prefer_system_threading_lock = true
dotnet_style_prefer_collection_expression = true:suggestion

# Enable foreach explicit cast preference
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:warning

# Prefer System.Threading.Lock
csharp_prefer_system_threading_lock = true:warning

Expand Down Expand Up @@ -497,7 +513,7 @@ dotnet_style_prefer_auto_properties = true:warning
dotnet_style_prefer_compound_assignment = true:warning
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:warning
dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning
dotnet_style_prefer_inferred_tuple_names = true:warning
dotnet_style_prefer_simplified_boolean_expressions = false:silent
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,19 @@ jobs:

- run: bun install --frozen-lockfile

# There are TWO producers of the fixtures vitest reads, in different assemblies, namespaces and
# There are THREE producers of the fixtures vitest reads, in different assemblies, namespaces and
# classes, so they cannot be selected by one treenode filter:
#
# tests/ts/cross-platform.test.ts <- src/tests/cross-platform/output/
# ZodSharp.Json/*CrossPlatformTests (SystemTextJson and
# NewtonsoftJson unit tests)
# tests/ts/guid-uuid-parity.test.ts <- src/tests/cross-platform/guid-parity/
# ZodSharp.SourceGenerators/ZodSchemaGeneratorTests
# tests/ts/temporal-parity.test.ts <- src/tests/cross-platform/temporal-parity/
# ZodSharp.SourceGenerators/ZodSchemaGeneratorTests
#
# Both run as separate steps rather than one OR-ed filter: `a|b` is not honoured as a top-level
# alternation by the treenode filter — it silently selects a different, broader set and skips the
# They run as separate steps rather than one OR-ed filter: `a|b` is not honoured as a top-level
# alternation by the treenode filter — it silently selects a different, broader set and skips a
# parity producer, which is how the parity artifacts came to be missing in CI while this job stayed
# green. Keep a step per producer, and keep each filter anchored to its class.
- name: Run the C# cross-platform tests (writes cross-platform/output)
Expand All @@ -72,6 +74,12 @@ jobs:
--treenode-filter
'/*/ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts'

- name: Run the C# temporal parity test (writes cross-platform/temporal-parity)
run: >-
dotnet test --solution src/ZodSharp.slnx --configuration Debug
--treenode-filter
'/*/ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedTemporalSchemas_WritesZodParityArtifacts'

- name: Run the TypeScript/Zod cross-platform tests
run: bun run test

Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ on:
push:
branches: [main]

permissions:
contents: write
# The shared release workflow pushes the same packages to nuget.org and, when
# PURVIEW_PUBLISH_GITHUB_PACKAGES=true, to GitHub Packages. A reusable workflow cannot grant
# more than its caller, so the permission is declared here.
packages: write

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,15 @@ Keep `purview-build.json`'s `PackValidation` requirements in sync with any packa

Schemas and fixtures shared between TypeScript/Zod and C#/Purview.ZodSharp live under `src/ts/`. The fixture generator (`bun run generate-fixtures`) writes JSON fixtures and a `manifest.json` that the C# tests consume. Keep the TS `UserSchema` and the C# `CrossPlatformUserSchema` in sync.

`src/tests/cross-platform/` is gitignored, so it starts empty on a clean checkout and in CI: **the C# producers must run before vitest**, in the same workspace. There are **two** producer/consumer pairs, in different assemblies, namespaces and classes:
`src/tests/cross-platform/` is gitignored, so it starts empty on a clean checkout and in CI: **the C# producers must run before vitest**, in the same workspace. There are **three** producer/consumer pairs, in different assemblies, namespaces and classes:

| Vitest consumer | Reads | C# producer |
| --- | --- | --- |
| `tests/ts/cross-platform.test.ts` | `src/tests/cross-platform/output/` | `ZodSharp.Json/*CrossPlatformTests` (the SystemTextJson and NewtonsoftJson unit tests) |
| `tests/ts/guid-uuid-parity.test.ts` | `src/tests/cross-platform/guid-parity/` | `ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts` |
| `tests/ts/temporal-parity.test.ts` | `src/tests/cross-platform/temporal-parity/` | `ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedTemporalSchemas_WritesZodParityArtifacts` |

`just test` runs the whole suite, so it covers both producers. The `cross-platform` job in `.github/workflows/pr.yml` selects them with treenode filters instead, and therefore needs **one step per producer** — a single `a|b` filter is not honoured as a top-level alternation and silently selects a broader set while skipping the parity producer. Adding a producer means adding a step. Both vitest suites fail loudly rather than passing vacuously when their directory is empty, which is the backstop for exactly this mistake.
`just test` runs the whole suite, so it covers all three producers. The `cross-platform` job in `.github/workflows/pr.yml` selects them with treenode filters instead, and therefore needs **one step per producer** — a single `a|b` filter is not honoured as a top-level alternation and silently selects a broader set while skipping a parity producer. Adding a producer means adding a step. All vitest suites fail loudly rather than passing vacuously when their directory is empty, which is the backstop for exactly this mistake.

`src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs` additionally drives the vitest suites **from the .NET test run**, as child processes through CliWrap, so `just test` alone proves both halves of the guarantee and a TypeScript-side regression fails a local run. It is a deliberate duplicate of the CI job, not a replacement — the job stays the authoritative gate because it controls producer ordering and installs bun from scratch. Three rules keep the duplication honest:

Expand Down
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,54 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

Released versions correspond to `v<version>` GitHub releases.

## 2.3.0

### Added

- **Temporal `[NonSentinel]` JSON Schema mappings.** `NonSentinelRule<DateOnly>`, `<TimeOnly>`, `<DateTime>`
and `<DateTimeOffset>` now export exact `pattern` constraints that reject the System.Text.Json wire forms of
their bounds, so no temporal rule appears under `x-zodsharp-unsupported`. The `DateTime` mapping covers the
deterministic `Unspecified`, `Utc` and zero-offset forms. The `DateTimeOffset` pattern is offset-aware: it
correlates the local time with the offset and rejects every RFC 3339 representation of both instant bounds.
`Z.Date().NonSentinel()` also exports `type: "string"`, `format: "date-time"` (previously it fell back to
the empty schema). See
[JSON Schema Export](docs/wiki/JsonSchema-Export.md#temporal-sentinels-and-the-datetimeoffset-wire-form).
- **Optional exact UTC `DateTimeOffset` contract.** `DateTimeOffsetSerialization.Utc` narrows
`[NonSentinel] DateTimeOffset` to a zero-offset wire form. Configure it per project with
`<ZodSharpDateTimeOffsetSerialization>Utc</ZodSharpDateTimeOffsetSerialization>` (for example in
`Directory.Build.props`), per assembly with
`[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = ...)]`, or per type/property with
`[DateTimeOffsetBehavior(...)]`. Precedence is property, type, assembly, MSBuild, then `PreserveOffset`.
- **`UtcDateTimeOffsetConverter` and `PreserveOffsetDateTimeOffsetConverter`** in both JSON integration
packages (`ZodSharp.Json.Serialization.SystemTextJson` and `...NewtonsoftJson`). The UTC converter writes
UTC and rejects a value without an explicit `Z`/zero offset (or with a non-zero offset) on read, which is what
keeps it in agreement with the UTC schema; the
preserve-offset converter supports overriding a project-wide UTC converter. The Newtonsoft converters
require `DateParseHandling.None` so they can inspect the original offset;
`UseZodSharpUtcDateTimeOffset()` / `UseZodSharpPreserveOffsetDateTimeOffset()` configure both together.
- `NonSentinelRule<T>.DateTimeOffsetUtcJsonSchemaPattern` and a
`JsonSchemaRules.Apply(schema, rule, DateTimeOffsetSerialization)` overload for manual schemas.
- **`Z.DateTimeOffset()` and runtime manual-schema options.** `ZodDateTimeOffset` is the fluent counterpart to
a generated `DateTimeOffset` property, with the same `NonSentinel()` override.
`ToJsonSchemaOptions.DateTimeOffsetSerialization` lets a hand-built schema select the default offset-aware
mapping or the narrower UTC contract. `JsonSchemaRules.TimeOnlyPattern` is the local-time base pattern used
for a generated `TimeOnly` property.

### Changed

- `NonSentinelRule<T>` no longer reports `x-zodsharp-unsupported` for a type whose sentinel it does not
inspect. Such a rule always passes, so an unconstrained schema is complete.
- A generated `TimeOnly` property now exports a local-time `pattern` instead of `format: time`. JSON Schema's
`format: time` requires an RFC 3339 offset, which System.Text.Json never writes for `TimeOnly`; strict
consumers previously rejected every value.

### Known limitations

- A `DateTimeKind.Local` sentinel can carry a machine-specific offset; use `Unspecified` or `Utc` for a
portable `DateTime` contract.
- The optional UTC contract deliberately rejects non-UTC input, including otherwise valid timestamps, because
normalizing it on read would reintroduce the offset-equivalence gap.

## 2.2.0

### Added
Expand Down
12 changes: 2 additions & 10 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -1,17 +1,9 @@
<Project>
<PropertyGroup>
<!-- Set explicitly rather than relying on auto-detection. -->
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
<!--
Keep compiler-facing Roslyn packages aligned with the minimum supported by
Purview.SourceGeneratorFramework. Roslyn 5.x uses the .NET 8 Immutable baseline,
allowing the generator assembly to be referenced by net8.0-net10.0 test projects.
-->
<RoslynCompilerVersion>5.9.0</RoslynCompilerVersion>
<RoslynAnalyzersVersion>5.9.0</RoslynAnalyzersVersion>
<TUnitVersion>1.75.0</TUnitVersion>
<PurviewSourceGenFramework>1.1.1</PurviewSourceGenFramework>
<PurviewSourceGenFramework>1.2.0</PurviewSourceGenFramework>
<DotnetRuntimeVersion>10.0.13</DotnetRuntimeVersion>
<!--
Microsoft.Extensions.Options and ...DependencyInjection.Abstractions are real dependencies of
Expand Down Expand Up @@ -54,7 +46,7 @@
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="$(RoslynAnalyzersVersion)" />
<PackageVersion Include="TUnit" Version="$(TUnitVersion)" />
<PackageVersion Include="TUnit.Mocks" Version="$(TUnitVersion)" />
<PackageVersion Include="Purview.Skills.Testing" Version="1.0.6" />
<PackageVersion Include="Purview.Skills.Testing" Version="1.1.0" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="System.Text.Json" Version="$(DotnetRuntimeVersion)" />
<PackageVersion Include="BenchmarkDotNet" Version="0.16.0-preview.2" />
Expand Down
6 changes: 6 additions & 0 deletions docs/wiki/Cross-Platform-Interop.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ The same interop goal is available without fixtures via JSON Schema:

A generated collection maps to Zod's array element type: `T[]` / `List<T>` ↔ `z.array(T)`, and a nullable element (`T?[]`, `List<T?>`, or a property that allows nulls via `[CollectionBehavior(AllowNulls = true)]`) ↔ `z.array(T.nullable())`. A collection whose elements may be null exports `items.anyOf: [ …, { "type": "null" } ]`, and both `z.fromJSONSchema` and `Z.FromJsonSchema` read that shape back. Null elements are rejected by default, so a generated `T[]` matches `z.array(T)` exactly — see [Collection null items](Source-Generator.md#collection-null-items).

The temporal parity suite (`tests/ts/temporal-parity.test.ts`) proves the same loop for `[NonSentinel]` on
`DateOnly`, `TimeOnly`, `DateTime`, and `DateTimeOffset` under the default preserve-offset contract, including
the offset-equivalent bounds. The C# producer
(`JsonSchema_GivenGeneratedTemporalSchemas_WritesZodParityArtifacts`) writes to
`src/tests/cross-platform/temporal-parity/`; the UTC contract has its own generator tests.

See [JSON Schema Export](JsonSchema-Export.md) and [JSON Schema Import](JsonSchema-Import.md).

## Directory layout
Expand Down
1 change: 1 addition & 0 deletions docs/wiki/Fluent-Schema-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
| `Boolean()` | `Z.Boolean()` | `ZodBoolean` |
| `Null()` | `Z.Null()` | `ZodNull` |
| `Date()` | `Z.Date()` | `ZodDate` |
| `DateTimeOffset()` | `Z.DateTimeOffset()` | `ZodDateTimeOffset` |
| `BigInt()` | `Z.BigInt()` | `ZodBigInt` |
| `Array<T>` | `Z.Array<T>(IZodSchema<T, T> elementSchema)` | `ZodArray<T>` |
| `Optional<T>` | `Z.Optional<T>(IZodSchema<T, T> schema)` — `T : class` | `ZodOptional<T>` |
Expand Down
18 changes: 18 additions & 0 deletions docs/wiki/Guarantees-and-Limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ Rules evaluated by the base `Validate` pipeline emit Zod-compatible codes: `too_

`ZodEnum` (string values) and `ZodNativeEnum<TEnum>` validate against defined members; they do not parse or convert values.

### Temporal sentinel export

`[NonSentinel]` exports exact `pattern` constraints for `DateOnly`, `TimeOnly`, the deterministic `DateTime`
wire forms (`Unspecified`, `Utc`, or a zero offset), and `DateTimeOffset`. The `DateTimeOffset` pattern is
offset-aware: it correlates the local time with the offset and rejects every RFC 3339 representation of the
instant bounds, including offset-equivalent forms, without an `x-zodsharp-unsupported` marker.

A `DateTimeKind.Local` value can carry an arbitrary machine offset, and System.Text.Json converts an
offset-bearing `DateTime` into the receiver's time zone, so that form is not portable; use `Unspecified`,
`Utc`, or `DateTimeOffset` (whose sentinel pattern is exact) for a cross-platform contract.
`DateTimeOffsetSerialization.Utc` is an optional narrower contract that requires a zero offset and a
converter that rejects non-UTC input on read; see
[JSON Schema Export](JsonSchema-Export.md#temporal-sentinels-and-the-datetimeoffset-wire-form).

`TimeOnly` is exported as a local-time `pattern` instead of `format: time`. JSON Schema's `format: time` is an
RFC 3339 full-time and requires an offset, while System.Text.Json writes `TimeOnly` as a local
`HH:mm:ss[.fffffff]`; a strict `format: time` consumer would reject every value.

### JSON Schema import scope

`Z.FromJsonSchema` supports **local** `$ref` (`#/...`) references only; external `$ref` targets throw a `NotSupportedException` that names the unsupported reference. The integration packages' `JsonSchemaSerializerOptions` read and write the JSON Schema keyword names (`$schema`, `$id`, `$ref`, `$defs`), so exported definitions round-trip; the core `JsonSchemaDefinition` type itself stays free of serializer annotations. Import types live in package-specific namespaces (`ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson`).
Expand Down
Loading
Loading