diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index d51c9f03..9edd0c00 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -17,7 +17,7 @@
"rollForward": false
},
"purview.build": {
- "version": "0.5.4",
+ "version": "0.7.0",
"commands": [
"purview-build"
],
diff --git a/.config/purview-build.json b/.config/purview-build.json
index 4d10b720..332e7fa1 100644
--- a/.config/purview-build.json
+++ b/.config/purview-build.json
@@ -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": {
@@ -50,4 +52,4 @@
"Release": {
"Mode": "None"
}
-}
+}
\ No newline at end of file
diff --git a/.editorconfig b/.editorconfig
index 120d5094..b5117897 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -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:
+# false keep this file as it is
+# Never opt out of all bootstrapping
+# false
+#
+# 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
@@ -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
@@ -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
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index c37a5651..db498c65 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -48,7 +48,7 @@ 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/
@@ -56,9 +56,11 @@ jobs:
# 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)
@@ -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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7f7d2163..59b20ce6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index eb31747e..75dc4e29 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3c7debe..2cd5ba13 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,54 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
Released versions correspond to `v` GitHub releases.
+## 2.3.0
+
+### Added
+
+- **Temporal `[NonSentinel]` JSON Schema mappings.** `NonSentinelRule`, ``, ``
+ and `` 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
+ `Utc` (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.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` 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
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ea8b4032..d7c56a01 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,17 +1,9 @@
-
- true
- true
-
5.9.0
5.9.0
1.75.0
- 1.1.1
+ 1.2.0
10.0.13
true
- 2.0.2
+ 2.2.0
diff --git a/src/src/Benchmarks/Directory.Build.props b/src/src/Benchmarks/Directory.Build.props
index 8bcb67aa..e30aea60 100644
--- a/src/src/Benchmarks/Directory.Build.props
+++ b/src/src/Benchmarks/Directory.Build.props
@@ -16,4 +16,9 @@
BenchmarkDotNet's generated build output. The Benchmarks.csproj is fully
self-contained and does not need the SDK.
-->
+
+
+ true
+ true
+
diff --git a/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/NewtonsoftJsonSerializationExtensions.cs b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/NewtonsoftJsonSerializationExtensions.cs
new file mode 100644
index 00000000..255526cf
--- /dev/null
+++ b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/NewtonsoftJsonSerializationExtensions.cs
@@ -0,0 +1,42 @@
+using Newtonsoft.Json;
+
+namespace ZodSharp.Json.Serialization.NewtonsoftJson;
+
+///
+/// Configuration helpers for the DateTimeOffset converters.
+///
+///
+/// Newtonsoft.Json parses date-like strings into by default, which discards the
+/// offset before a converter sees it. These helpers set
+/// to together with the converter, so the converter can inspect and
+/// enforce the original offset.
+///
+public static class NewtonsoftJsonSerializationExtensions
+{
+ ///
+ /// Configures the serializer for the UTC DateTimeOffset contract.
+ ///
+ /// The settings to update.
+ /// The same settings instance, for chaining.
+ public static JsonSerializerSettings UseZodSharpUtcDateTimeOffset(this JsonSerializerSettings settings)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ settings.DateParseHandling = DateParseHandling.None;
+ settings.Converters.Add(new UtcDateTimeOffsetConverter());
+ return settings;
+ }
+
+ ///
+ /// Configures the serializer to preserve DateTimeOffset offsets, including as a per-type/property
+ /// override of a global UTC converter.
+ ///
+ /// The settings to update.
+ /// The same settings instance, for chaining.
+ public static JsonSerializerSettings UseZodSharpPreserveOffsetDateTimeOffset(this JsonSerializerSettings settings)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ settings.DateParseHandling = DateParseHandling.None;
+ settings.Converters.Add(new PreserveOffsetDateTimeOffsetConverter());
+ return settings;
+ }
+}
diff --git a/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/PreserveOffsetDateTimeOffsetConverter.cs b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/PreserveOffsetDateTimeOffsetConverter.cs
new file mode 100644
index 00000000..4ce8c990
--- /dev/null
+++ b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/PreserveOffsetDateTimeOffsetConverter.cs
@@ -0,0 +1,61 @@
+using System.Globalization;
+using Newtonsoft.Json;
+
+namespace ZodSharp.Json.Serialization.NewtonsoftJson;
+
+///
+/// Serializes with its original offset.
+///
+///
+///
+/// Newtonsoft.Json already preserves the offset by default. This converter exists so a type or property
+/// that overrides a project-wide with
+/// [DateTimeOffsetBehavior(DateTimeOffsetSerialization.PreserveOffset)] can also override the
+/// serializer converter explicitly, for example with [JsonConverter].
+///
+///
+/// Like the UTC converter, this one needs DateParseHandling = DateParseHandling.None so it can read
+/// the original string and preserve its offset.
+///
+///
+[System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Design",
+ "CA1062:Validate arguments of public methods",
+ Justification = "Called by Newtonsoft.Json with non-null arguments."
+)]
+public sealed class PreserveOffsetDateTimeOffsetConverter : JsonConverter
+{
+ ///
+ public override bool CanConvert(Type objectType) =>
+ objectType == typeof(DateTimeOffset) || objectType == typeof(DateTimeOffset?);
+
+ ///
+ public override object? ReadJson(
+ JsonReader reader,
+ Type objectType,
+ object? existingValue,
+ JsonSerializer serializer
+ )
+ {
+ if (reader.TokenType == JsonToken.Null)
+ return null;
+
+ return DateTimeOffset.Parse(
+ UtcDateTimeOffsetConverter.ReadRawString(reader),
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.RoundtripKind
+ );
+ }
+
+ ///
+ public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
+ {
+ if (value is null)
+ {
+ writer.WriteNull();
+ return;
+ }
+
+ writer.WriteValue(((DateTimeOffset)value).ToString("O", CultureInfo.InvariantCulture));
+ }
+}
diff --git a/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/UtcDateTimeOffsetConverter.cs b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/UtcDateTimeOffsetConverter.cs
new file mode 100644
index 00000000..10f929f1
--- /dev/null
+++ b/src/src/NewtonsoftJson/Json/Serialization/NewtonsoftJson/UtcDateTimeOffsetConverter.cs
@@ -0,0 +1,88 @@
+using System.Globalization;
+using Newtonsoft.Json;
+
+namespace ZodSharp.Json.Serialization.NewtonsoftJson;
+
+///
+/// Serializes as UTC and rejects a non-UTC offset on read.
+///
+///
+///
+/// Pair this converter with a UTC JSON Schema contract, for example
+/// [assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)]
+/// or the ZodSharpDateTimeOffsetSerialization MSBuild property. The schema requires a Z or
+/// zero offset, so rejecting a non-UTC value here keeps the serializer and the schema in agreement.
+///
+///
+/// Newtonsoft.Json parses date-like strings into by default, which discards the
+/// offset before a converter sees it. Configure the serializer with
+/// DateParseHandling = DateParseHandling.None so this converter can inspect the original string; the
+/// converter reports a clear exception when that setting is missing.
+///
+///
+[System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Design",
+ "CA1062:Validate arguments of public methods",
+ Justification = "Called by Newtonsoft.Json with non-null arguments."
+)]
+public sealed class UtcDateTimeOffsetConverter : JsonConverter
+{
+ ///
+ public override bool CanConvert(Type objectType) =>
+ objectType == typeof(DateTimeOffset) || objectType == typeof(DateTimeOffset?);
+
+ ///
+ public override object? ReadJson(
+ JsonReader reader,
+ Type objectType,
+ object? existingValue,
+ JsonSerializer serializer
+ )
+ {
+ if (reader.TokenType == JsonToken.Null)
+ return null;
+
+ var text = ReadRawString(reader);
+ if (!HasUtcSuffix(text))
+ {
+ throw new JsonSerializationException(
+ "DateTimeOffset values must carry an explicit UTC designator ('Z' or a zero offset) under the "
+ + "ZodSharp UTC contract."
+ );
+ }
+
+ var value = DateTimeOffset.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
+
+ return value.Offset == TimeSpan.Zero
+ ? value
+ : throw new JsonSerializationException(
+ $"DateTimeOffset values must be UTC under the ZodSharp UTC contract, but got offset "
+ + $"'{value.Offset}'."
+ );
+ }
+
+ ///
+ public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
+ {
+ if (value is null)
+ {
+ writer.WriteNull();
+ return;
+ }
+
+ writer.WriteValue(((DateTimeOffset)value).ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
+ }
+
+ static bool HasUtcSuffix(string text) =>
+ text.EndsWith('Z')
+ || text.EndsWith("+00:00", StringComparison.Ordinal)
+ || text.EndsWith("-00:00", StringComparison.Ordinal);
+
+ internal static string ReadRawString(JsonReader reader) =>
+ reader.Value as string
+ ?? throw new JsonSerializationException(
+ "DateTimeOffset values must be read with JsonSerializerSettings.DateParseHandling set to "
+ + "DateParseHandling.None so the offset can be inspected; Newtonsoft.Json parsed the value "
+ + "before the converter could see it."
+ );
+}
diff --git a/src/src/NewtonsoftJson/Sdk/README.md b/src/src/NewtonsoftJson/Sdk/README.md
index 71d6909e..92c3cc35 100644
--- a/src/src/NewtonsoftJson/Sdk/README.md
+++ b/src/src/NewtonsoftJson/Sdk/README.md
@@ -45,6 +45,26 @@ var converter = userSchema.CreateValidatingConverter();
var value = JsonConvert.DeserializeObject(json, converter);
```
+## UTC DateTimeOffset contract
+
+The default `PreserveOffset` schema is exact and needs no converter. When the generated JSON Schema uses
+`DateTimeOffsetSerialization.Utc`, add the matching converter so the serializer writes UTC and rejects a value
+without an explicit `Z`/zero offset (or with a non-zero offset) on read:
+
+```csharp
+using Newtonsoft.Json;
+using ZodSharp.Json.Serialization.NewtonsoftJson;
+
+var settings = new JsonSerializerSettings().UseZodSharpUtcDateTimeOffset();
+```
+
+The helper sets `DateParseHandling = DateParseHandling.None`, which is required so the converter can inspect
+the original offset, and adds the UTC converter. `UseZodSharpPreserveOffsetDateTimeOffset()` is the
+per-type/property opt-out equivalent.
+
+`PreserveOffsetDateTimeOffsetConverter` is available to opt a type or property back out of a project-wide UTC
+converter. See [JSON Schema Export](https://purview.dev/docs/zodsharp/json-schema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form).
+
## Import from JSON Schema
```csharp
diff --git a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs
index 04bc8092..30cd6c9f 100644
--- a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs
+++ b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs
@@ -18,6 +18,12 @@ static class AttributeGenHelper
///
internal const string AllowNullsPropertyName = "AllowNulls";
+ ///
+ /// The name of the DateTimeOffsetSerialization property on
+ /// [assembly: ZodSharpSchemaDefaults(...)].
+ ///
+ internal const string DateTimeOffsetSerializationPropertyName = "DateTimeOffsetSerialization";
+
public static IEnumerable<(string HintName, SourceText SourceText)> GenerateMarkers()
{
yield return (nameof(ZodSchemaAttribute), ZodSchemaAttribute());
diff --git a/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs
index 2c70e659..2d67488a 100644
--- a/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs
+++ b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs
@@ -18,14 +18,18 @@ internal readonly record struct AutoDetectSettings(
bool GenerateJsonSchemas,
bool EnumAsString,
EnumJsonNamingPolicy EnumNamingPolicy,
- bool AllowNulls
+ bool AllowNulls,
+ DateTimeOffsetSerialization DateTimeOffsetSerialization
);
///
/// The assembly-level schema defaults declared by [assembly: ZodSharpSchemaDefaults(...)]. A null
/// value defers to the corresponding MSBuild property.
///
- internal readonly record struct AssemblyDefaults(bool? AllowNulls);
+ internal readonly record struct AssemblyDefaults(
+ bool? AllowNulls,
+ DateTimeOffsetSerialization? DateTimeOffsetSerialization
+ );
public static AutoDetectSettings Read(AnalyzerConfigOptionsProvider provider)
{
@@ -71,7 +75,18 @@ out var allowNullsValue
&& bool.TryParse(allowNullsValue, out var allowNullsEnabled)
&& allowNullsEnabled;
- return new(autoGenerate, suffixes, generateJsonSchemas, enumAsString, enumNamingPolicy, allowNulls);
+ // Offsets are preserved by default. Utc is the only opt-in value; anything else keeps the default.
+ var dateTimeOffsetSerialization = ReadDateTimeOffsetSerialization(provider);
+
+ return new(
+ autoGenerate,
+ suffixes,
+ generateJsonSchemas,
+ enumAsString,
+ enumNamingPolicy,
+ allowNulls,
+ dateTimeOffsetSerialization
+ );
}
///
@@ -88,12 +103,43 @@ public static AssemblyDefaults ReadAssemblyDefaults(Compilation compilation)
if (!TypeLibrary.ZodSharp.ZodSharpSchemaDefaultsAttribute.Equals(attribute.AttributeClass))
continue;
- return attribute.TryGetNamedArgument(AttributeGenHelper.AllowNullsPropertyName, out var allowNulls)
- ? new(allowNulls)
- : new(null);
+ var allowNulls = attribute.TryGetNamedArgument(
+ AttributeGenHelper.AllowNullsPropertyName,
+ out var allow
+ )
+ ? allow
+ : (bool?)null;
+
+ DateTimeOffsetSerialization? dateTimeOffsetSerialization = null;
+ foreach (var argument in attribute.NamedArguments)
+ {
+ if (
+ argument.Key == AttributeGenHelper.DateTimeOffsetSerializationPropertyName
+ && argument.Value.Value is int serialization
+ )
+ {
+ dateTimeOffsetSerialization = (DateTimeOffsetSerialization)serialization;
+ }
+ }
+
+ return new(allowNulls, dateTimeOffsetSerialization);
}
- return new(null);
+ return new(null, null);
+ }
+
+ static DateTimeOffsetSerialization ReadDateTimeOffsetSerialization(AnalyzerConfigOptionsProvider provider)
+ {
+ var value = provider.GlobalOptions.TryGetValue(
+ $"build_property:{PropertyLibrary.DateTimeOffsetSerializationProperty}",
+ out var configured
+ )
+ ? configured?.Trim()
+ : null;
+
+ return string.Equals(value, nameof(DateTimeOffsetSerialization.Utc), StringComparison.OrdinalIgnoreCase)
+ ? DateTimeOffsetSerialization.Utc
+ : DateTimeOffsetSerialization.PreserveOffset;
}
static bool ReadEnumSerializationAsString(AnalyzerConfigOptionsProvider provider) =>
@@ -159,6 +205,19 @@ AssemblyDefaults assemblyDefaults
changed = true;
}
+ // Precedence, highest first: the type-level [DateTimeOffsetBehavior], the assembly-level
+ // ZodSharpSchemaDefaults, the MSBuild property, then PreserveOffset.
+ var dateTimeOffsetSerialization =
+ schema.DateTimeOffsetSerialization
+ ?? assemblyDefaults.DateTimeOffsetSerialization
+ ?? settings.DateTimeOffsetSerialization;
+
+ if (schema.DateTimeOffsetSerialization != dateTimeOffsetSerialization)
+ {
+ schema = schema with { DateTimeOffsetSerialization = dateTimeOffsetSerialization };
+ changed = true;
+ }
+
if (
settings.AutoGenerate
&& settings.Suffixes.Count > 0
@@ -179,6 +238,16 @@ AssemblyDefaults assemblyDefaults
changed = true;
}
+ var resolvedDateTimeOffset = ResolveDateTimeOffsetSerialization(
+ schema.Properties,
+ dateTimeOffsetSerialization
+ );
+ if (resolvedDateTimeOffset is { } dateTimeOffsetProperties)
+ {
+ schema = schema with { Properties = dateTimeOffsetProperties };
+ changed = true;
+ }
+
builder.Add(changed ? GeneratorResult.Create(schema) : result);
}
@@ -229,4 +298,45 @@ AssemblyDefaults assemblyDefaults
return changed ? new(builder.ToImmutable()) : null;
}
+
+ ///
+ /// Resolves each property's DateTimeOffset wire form against the containing type's resolved setting.
+ ///
+ /// The schema's properties.
+ /// The type/assembly/MSBuild setting already resolved for the schema.
+ /// The rewritten properties, or when no property changed.
+ static EquatableArray>? ResolveDateTimeOffsetSerialization(
+ EquatableArray> properties,
+ DateTimeOffsetSerialization schemaSerialization
+ )
+ {
+ if (properties.Count == 0)
+ return null;
+
+ var changed = false;
+ var builder = ImmutableArray.CreateBuilder>(properties.Count);
+ foreach (var propertyResult in properties)
+ {
+ if (!propertyResult.ShouldProcess)
+ {
+ builder.Add(propertyResult);
+ continue;
+ }
+
+ var property = propertyResult.Value;
+ var resolved = property.DateTimeOffsetSerialization ?? schemaSerialization;
+ if (property.DateTimeOffsetSerialization == resolved)
+ {
+ builder.Add(propertyResult);
+ continue;
+ }
+
+ builder.Add(
+ GeneratorResult.Create(property with { DateTimeOffsetSerialization = resolved })
+ );
+ changed = true;
+ }
+
+ return changed ? new(builder.ToImmutable()) : null;
+ }
}
diff --git a/src/src/SourceGenerators/Helpers/PropertyLibrary.cs b/src/src/SourceGenerators/Helpers/PropertyLibrary.cs
index bfef5d91..22224de0 100644
--- a/src/src/SourceGenerators/Helpers/PropertyLibrary.cs
+++ b/src/src/SourceGenerators/Helpers/PropertyLibrary.cs
@@ -17,4 +17,6 @@ static class PropertyLibrary
public const string EnumNamingPolicyProperty = "ZodSharpEnumNamingPolicy";
public const string AllowNullsProperty = "ZodSharpAllowNulls";
+
+ public const string DateTimeOffsetSerializationProperty = "ZodSharpDateTimeOffsetSerialization";
}
diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs
index fdaa51d6..42c2015d 100644
--- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs
+++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs
@@ -102,8 +102,20 @@ CancellationToken cancellationToken
var schema = target with { Name = schemaName };
var targetCanBeNull = TypeHelpers.CanBeNull(symbol);
var typeAllowNulls = ReadAllowNulls(attribute);
- var properties = GetZodProperties(symbol, externalSchemas, compilation, typeAllowNulls);
- properties = AddAutomaticScalarProperty(symbol, properties, externalSchemas);
+ var typeDateTimeOffsetSerialization = ReadDateTimeOffsetSerialization(symbol.GetAttributes());
+ var properties = GetZodProperties(
+ symbol,
+ externalSchemas,
+ compilation,
+ typeAllowNulls,
+ typeDateTimeOffsetSerialization
+ );
+ properties = AddAutomaticScalarProperty(
+ symbol,
+ properties,
+ externalSchemas,
+ typeDateTimeOffsetSerialization
+ );
// 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
@@ -170,7 +182,8 @@ CancellationToken cancellationToken
scalarUnderlyingCanBeNull,
generateJsonSchema,
false,
- default
+ default,
+ typeDateTimeOffsetSerialization
)
);
@@ -281,7 +294,8 @@ static EquatableArray> GetZodProperties(
INamedTypeSymbol symbol,
ExternalSchemaResolver externalSchemas,
Compilation compilation,
- bool? typeAllowNulls
+ bool? typeAllowNulls,
+ DateTimeOffsetSerialization? typeDateTimeOffsetSerialization
)
{
var properties = symbol
@@ -291,7 +305,13 @@ static EquatableArray> GetZodProperties(
property.DeclaredAccessibility == Accessibility.Public && !property.IsStatic && !property.IsIndexer
)
.Select(property =>
- GetValidatablePropertyDescriptor(property, externalSchemas, compilation, typeAllowNulls)
+ GetValidatablePropertyDescriptor(
+ property,
+ externalSchemas,
+ compilation,
+ typeAllowNulls,
+ typeDateTimeOffsetSerialization
+ )
)
.ToImmutableArray();
@@ -318,11 +338,13 @@ Compilation compilation
/// The schema target type.
/// The properties discovered from source.
/// The resolver that decides schema ownership.
+ /// The containing type's DateTimeOffset wire-form override.
/// The properties with the synthesized scalar property prepended, when applicable.
static EquatableArray> AddAutomaticScalarProperty(
INamedTypeSymbol symbol,
EquatableArray> properties,
- ExternalSchemaResolver externalSchemas
+ ExternalSchemaResolver externalSchemas,
+ DateTimeOffsetSerialization? typeDateTimeOffsetSerialization
)
{
if (!CustomRuleResolver.TryGetAutomaticScalar(symbol, out var propertyName, out var valueType))
@@ -386,7 +408,8 @@ originalPropertyType is INamedTypeSymbol
null,
true,
false,
- new([])
+ new([]),
+ typeDateTimeOffsetSerialization
);
return new([GeneratorResult.Create(descriptor), .. properties]);
@@ -419,7 +442,8 @@ internal static GeneratorResult GetValidatablePropertyDes
IPropertySymbol property,
ExternalSchemaResolver? externalSchemas,
Compilation compilation,
- bool? typeAllowNulls
+ bool? typeAllowNulls,
+ DateTimeOffsetSerialization? typeDateTimeOffsetSerialization = null
)
{
var declaredType = CreateTypeIdentity(property.Type);
@@ -471,6 +495,8 @@ originalPropertyType is INamedTypeSymbol
var nestedSchemaType = GetNestedSchemaTypeIdentity(property, validationKind, externalSchemas);
var lengthAccessor = ClassifyLengthAccessor(originalPropertyType);
var validationAttributes = GetValidationAttributes(property, compilation);
+ var dateTimeOffsetSerialization =
+ ReadDateTimeOffsetSerialization(validationAttributes) ?? typeDateTimeOffsetSerialization;
var allowNulls =
validationKind == PropertyValidationKind.Collection
? ResolveAllowNulls(originalPropertyType, validationAttributes, typeAllowNulls)
@@ -629,7 +655,8 @@ rangeAttribute with
GetJsonPropertyName(property),
NestedSchemaExportsJsonSchema(property, validationKind, externalSchemas),
enumSerializedAsString,
- enumJsonNames
+ enumJsonNames,
+ dateTimeOffsetSerialization
),
diagnostics.ToImmutable()
);
@@ -884,6 +911,43 @@ attribute is not null
? allow
: null;
+ ///
+ /// Reads the [DateTimeOffsetBehavior] override from a type's or property's attributes, or
+ /// when absent.
+ ///
+ /// The effective attributes to inspect.
+ /// The declared serialization, or to defer to the next level.
+ static DateTimeOffsetSerialization? ReadDateTimeOffsetSerialization(ImmutableArray attributes)
+ {
+ foreach (var attribute in attributes)
+ {
+ if (!TypeLibrary.ZodSharp.DateTimeOffsetBehaviorAttribute.Equals(attribute.AttributeClass))
+ continue;
+
+ foreach (var constructorArgument in attribute.ConstructorArguments)
+ {
+ if (constructorArgument.Value is int serialization)
+ return (DateTimeOffsetSerialization)serialization;
+
+ if (constructorArgument.Value is { } boxed)
+ return (DateTimeOffsetSerialization)
+ Convert.ToInt32(boxed, System.Globalization.CultureInfo.InvariantCulture);
+ }
+
+ foreach (var argument in attribute.NamedArguments)
+ {
+ if (argument.Key == "Serialization" && argument.Value.Value is int namedSerialization)
+ {
+ return (DateTimeOffsetSerialization)namedSerialization;
+ }
+ }
+
+ break;
+ }
+
+ return null;
+ }
+
///
/// Resolves the property/type-level override for whether a collection may contain null elements.
///
diff --git a/src/src/SourceGenerators/Helpers/TypeLibrary.cs b/src/src/SourceGenerators/Helpers/TypeLibrary.cs
index 3e864c1a..f4f66e3d 100644
--- a/src/src/SourceGenerators/Helpers/TypeLibrary.cs
+++ b/src/src/SourceGenerators/Helpers/TypeLibrary.cs
@@ -57,6 +57,9 @@ static partial class TypeLibraryGenerator
[TypeRef(ZodSharpNamespace)]
static readonly TypeIdentity CollectionBehaviorAttribute = default;
+ [TypeRef(ZodSharpNamespace)]
+ static readonly TypeIdentity DateTimeOffsetBehaviorAttribute = default;
+
[TypeRef(ZodSharpNamespace)]
static readonly TypeIdentity ZodSharpSchemaDefaultsAttribute = default;
diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs
index aa57bac8..032f679f 100644
--- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs
+++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs
@@ -41,6 +41,20 @@ enum EnumJsonNamingPolicy
CamelCase,
}
+///
+/// The DateTimeOffset wire form the generator assumes, mirroring the public
+/// ZodSharp.DateTimeOffsetSerialization enum. The generator cannot reference the core assembly, so the
+/// values are mapped by their numeric identity.
+///
+enum DateTimeOffsetSerialization
+{
+ /// The wire form keeps the value's offset.
+ PreserveOffset = 0,
+
+ /// The wire form is UTC: a Z or zero offset.
+ Utc = 1,
+}
+
readonly record struct LengthAccessor(string LengthExpression, string Origin, bool IsSupported);
/// This is the target of the attribute, the type where the attribute was defined.
@@ -88,6 +102,11 @@ enum EnumJsonNamingPolicy
/// How a string-serialized enum's member names are transformed, set by the assembly-level
/// ZodSharpEnumNamingPolicy switch.
///
+///
+/// The type-level [DateTimeOffsetBehavior] override for the wire form assumed by JSON Schema
+/// generation, or when unset. The generation-settings pass resolves it against the
+/// assembly/MSBuild default.
+///
readonly record struct ZodSchemaDescriptor(
TypeIdentity TargetType,
TypeIdentity SchemaType,
@@ -109,7 +128,8 @@ readonly record struct ZodSchemaDescriptor(
bool ScalarUnderlyingCanBeNull,
bool GenerateJsonSchema,
bool EnumAsString,
- EnumJsonNamingPolicy EnumNamingPolicy
+ EnumJsonNamingPolicy EnumNamingPolicy,
+ DateTimeOffsetSerialization? DateTimeOffsetSerialization = null
);
/// The declared type of the property, with a nullable wrapper removed.
@@ -158,6 +178,10 @@ EnumJsonNamingPolicy EnumNamingPolicy
/// For a non-flags enum, the JSON names of the allowed members, in declaration order: the member name, or its
/// [JsonStringEnumMemberName] value when declared. Used when the enum is serialized as a string.
///
+///
+/// The property-level [DateTimeOffsetBehavior] override, or when unset. The
+/// generation-settings pass resolves it against the type and assembly/MSBuild defaults.
+///
readonly record struct ZodPropertyDescriptor(
TypeIdentity PropertyType,
string Name,
@@ -180,7 +204,8 @@ readonly record struct ZodPropertyDescriptor(
string? JsonPropertyName,
bool NestedSchemaExportsJsonSchema,
bool EnumSerializedAsString,
- EquatableArray EnumJsonNames
+ EquatableArray EnumJsonNames,
+ DateTimeOffsetSerialization? DateTimeOffsetSerialization = null
);
[System.Diagnostics.CodeAnalysis.SuppressMessage(
diff --git a/src/src/SourceGenerators/SourceGenerators.csproj b/src/src/SourceGenerators/SourceGenerators.csproj
index 34190656..5f7544a1 100644
--- a/src/src/SourceGenerators/SourceGenerators.csproj
+++ b/src/src/SourceGenerators/SourceGenerators.csproj
@@ -20,6 +20,7 @@
+
diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs
index dc35bd1d..11cccba6 100644
--- a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs
+++ b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs
@@ -89,7 +89,7 @@ static void EmitScalarJsonSchema(CodeWriter body, ZodSchemaDescriptor schema, Ty
body.Assignment("var", "inner", BuildBaseSchemaExpression(underlying));
foreach (var rule in schema.TypeRules)
- body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", "inner", BuildRuleInstanceExpression(rule));
+ EmitRuleJsonSchema(body, rule, "inner", schema.DateTimeOffsetSerialization);
foreach (var property in schema.Properties)
{
@@ -98,7 +98,7 @@ static void EmitScalarJsonSchema(CodeWriter body, ZodSchemaDescriptor schema, Ty
EmitAttributeConstraints(body, property.Value, "inner");
foreach (var rule in property.Value.CustomRules)
- body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", "inner", BuildRuleInstanceExpression(rule));
+ EmitRuleJsonSchema(body, rule, "inner", property.Value.DateTimeOffsetSerialization);
}
body.Return(
@@ -125,7 +125,7 @@ static void EmitJsonSchemaProperty(CodeWriter body, ZodPropertyDescriptor proper
EmitAttributeConstraints(body, property, localName);
foreach (var rule in property.CustomRules)
- body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", localName, BuildRuleInstanceExpression(rule));
+ EmitRuleJsonSchema(body, rule, localName, property.DateTimeOffsetSerialization);
if (isNullable)
{
@@ -227,14 +227,23 @@ static string BuildBaseSchemaExpression(TypeIdentity type)
if (IsWellKnown(type, "System", "Guid"))
return $"new {JsonSchemaDefinitionTypeName} {{ Type = \"string\", Pattern = global::ZodSharp.Rules.GuidRule.JsonSchemaPattern }}";
- if (type.SpecialType == SpecialType.System_DateTime || IsWellKnown(type, "System", "DateTimeOffset"))
+ if (
+ type.SpecialType == SpecialType.System_DateTime
+ || IsWellKnown(type, "System", "DateTime")
+ || IsWellKnown(type, "System", "DateTimeOffset")
+ )
+ {
return Schema("string", "date-time");
+ }
if (IsWellKnown(type, "System", "DateOnly"))
return Schema("string", "date");
+ // format: time is an RFC 3339 full-time with an offset, while System.Text.Json writes TimeOnly as a
+ // local HH:mm:ss[.fffffff] value. Express the local lexical space as a pattern so strict consumers do
+ // not reject every TimeOnly payload.
if (IsWellKnown(type, "System", "TimeOnly"))
- return Schema("string", "time");
+ return $"new {JsonSchemaDefinitionTypeName} {{ Type = \"string\", Pattern = {JsonSchemaRulesTypeName}.TimeOnlyPattern }}";
if (IsWellKnown(type, "System", "TimeSpan"))
return Schema("string", "duration");
@@ -284,6 +293,42 @@ or SpecialType.System_Int64
static bool IsFloatingType(SpecialType specialType) =>
specialType is SpecialType.System_Single or SpecialType.System_Double or SpecialType.System_Decimal;
+ ///
+ /// Emits the JSON Schema application of a custom rule. NonSentinelRule<T> receives the
+ /// resolved DateTimeOffset wire form so the rule can choose between the exact UTC mapping and the
+ /// offset-preserving mapping that retains the unsupported marker; every other rule uses the normal
+ /// two-argument overload.
+ ///
+ static void EmitRuleJsonSchema(
+ CodeWriter body,
+ CustomRuleDescriptor rule,
+ string localName,
+ DateTimeOffsetSerialization? dateTimeOffsetSerialization
+ )
+ {
+ if (IsNonSentinelRule(rule))
+ {
+ body.MethodCallOn(
+ JsonSchemaRulesTypeName,
+ "Apply",
+ localName,
+ BuildRuleInstanceExpression(rule),
+ RenderDateTimeOffsetSerialization(dateTimeOffsetSerialization)
+ );
+ return;
+ }
+
+ body.MethodCallOn(JsonSchemaRulesTypeName, "Apply", localName, BuildRuleInstanceExpression(rule));
+ }
+
+ static bool IsNonSentinelRule(CustomRuleDescriptor rule) =>
+ (rule.AdaptedFrom ?? rule.RuleType) is { Name: "NonSentinelRule", Namespace: "ZodSharp.Rules" };
+
+ static string RenderDateTimeOffsetSerialization(DateTimeOffsetSerialization? serialization) =>
+ serialization == DateTimeOffsetSerialization.Utc
+ ? "global::ZodSharp.DateTimeOffsetSerialization.Utc"
+ : "global::ZodSharp.DateTimeOffsetSerialization.PreserveOffset";
+
static string BuildRuleInstanceExpression(CustomRuleDescriptor rule)
{
// A scalar rule adapter wraps a rule written against the scalar's underlying value; the wrapped rule
diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.cs b/src/src/SourceGenerators/ZodSchemaGenerator.cs
index 56fbf37e..eeac98b4 100644
--- a/src/src/SourceGenerators/ZodSchemaGenerator.cs
+++ b/src/src/SourceGenerators/ZodSchemaGenerator.cs
@@ -47,10 +47,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
SchemaGenerationOutputContext outputContext = new(model.Context, schema.Value);
BuildSchema(outputContext, spc, schema.Value.IsPrimary);
}
- catch (CodeWriterScopeValidationException)
- {
- throw;
- }
+ // CodeWriterScopeValidationException was caught here and rethrown, to fail loudly on a
+ // generator bug. It reached the consumer as CS8785 instead, which discards every file this
+ // generator produced for the compilation and buries the real cause under the 'type or
+ // namespace not found' errors that follow (PSGFR44). It now takes the same path as any
+ // other failure: the exception message lists every open scope, so the diagnostic is no less
+ // loud, and the rest of the generated output still arrives.
catch (Exception ex)
{
var diagnostic = ReportableDiagnostic.Create(
diff --git a/src/src/SystemTextJson/Json/Serialization/SystemTextJson/PreserveOffsetDateTimeOffsetConverter.cs b/src/src/SystemTextJson/Json/Serialization/SystemTextJson/PreserveOffsetDateTimeOffsetConverter.cs
new file mode 100644
index 00000000..42f570b8
--- /dev/null
+++ b/src/src/SystemTextJson/Json/Serialization/SystemTextJson/PreserveOffsetDateTimeOffsetConverter.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ZodSharp.Json.Serialization.SystemTextJson;
+
+///
+/// Serializes with its original offset.
+///
+///
+/// System.Text.Json already preserves the offset by default. This converter exists so a type or property
+/// that overrides a project-wide with
+/// [DateTimeOffsetBehavior(DateTimeOffsetSerialization.PreserveOffset)] can also override the
+/// serializer converter explicitly, for example with [JsonConverter].
+///
+[System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Design",
+ "CA1062:Validate arguments of public methods",
+ Justification = "Called by System.Text.Json with non-null arguments."
+)]
+public sealed class PreserveOffsetDateTimeOffsetConverter : JsonConverter
+{
+ ///
+ public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ reader.GetDateTimeOffset();
+
+ ///
+ public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) =>
+ writer.WriteStringValue(value);
+}
diff --git a/src/src/SystemTextJson/Json/Serialization/SystemTextJson/UtcDateTimeOffsetConverter.cs b/src/src/SystemTextJson/Json/Serialization/SystemTextJson/UtcDateTimeOffsetConverter.cs
new file mode 100644
index 00000000..4364a12a
--- /dev/null
+++ b/src/src/SystemTextJson/Json/Serialization/SystemTextJson/UtcDateTimeOffsetConverter.cs
@@ -0,0 +1,61 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ZodSharp.Json.Serialization.SystemTextJson;
+
+///
+/// Serializes as UTC and rejects a non-UTC offset on read.
+///
+///
+///
+/// Pair this converter with a UTC JSON Schema contract, for example
+/// [assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)]
+/// or the ZodSharpDateTimeOffsetSerialization MSBuild property. The schema requires a Z or
+/// zero offset, so rejecting a non-UTC value here keeps the serializer and the schema in agreement.
+///
+///
+/// Writing normalizes the instant to UTC and emits +00:00. The converter adds no reflection and is
+/// safe under trimming and Native AOT.
+///
+///
+[System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Design",
+ "CA1062:Validate arguments of public methods",
+ Justification = "Called by System.Text.Json with non-null arguments."
+)]
+public sealed class UtcDateTimeOffsetConverter : JsonConverter
+{
+ ///
+ public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var text =
+ reader.GetString()
+ ?? throw new JsonException("DateTimeOffset values must be a JSON string under the ZodSharp UTC contract.");
+
+ if (!HasUtcSuffix(text))
+ {
+ throw new JsonException(
+ "DateTimeOffset values must carry an explicit UTC designator ('Z' or a zero offset) under the "
+ + "ZodSharp UTC contract."
+ );
+ }
+
+ var value = DateTimeOffset.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
+ return value.Offset == TimeSpan.Zero
+ ? value
+ : throw new JsonException(
+ $"DateTimeOffset values must be UTC under the ZodSharp UTC contract, but got offset "
+ + $"'{value.Offset}'."
+ );
+ }
+
+ ///
+ public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) =>
+ writer.WriteStringValue(value.ToUniversalTime());
+
+ static bool HasUtcSuffix(string text) =>
+ text.EndsWith('Z')
+ || text.EndsWith("+00:00", StringComparison.Ordinal)
+ || text.EndsWith("-00:00", StringComparison.Ordinal);
+}
diff --git a/src/src/SystemTextJson/Sdk/README.md b/src/src/SystemTextJson/Sdk/README.md
index 37528d15..d179c8e7 100644
--- a/src/src/SystemTextJson/Sdk/README.md
+++ b/src/src/SystemTextJson/Sdk/README.md
@@ -43,6 +43,24 @@ var options = new JsonSerializerOptions { Converters = { converter } };
var value = JsonSerializer.Deserialize(json, options);
```
+## UTC DateTimeOffset contract
+
+The default `PreserveOffset` schema is exact and needs no converter. When the generated JSON Schema uses
+`DateTimeOffsetSerialization.Utc`, add the matching converter so the serializer writes UTC and rejects a value
+without an explicit `Z`/zero offset (or with a non-zero offset) on read:
+
+```csharp
+using ZodSharp.Json.Serialization.SystemTextJson;
+
+var options = new JsonSerializerOptions
+{
+ Converters = { new UtcDateTimeOffsetConverter() },
+};
+```
+
+`PreserveOffsetDateTimeOffsetConverter` is available to opt a type or property back out of a project-wide UTC
+converter. See [JSON Schema Export](https://purview.dev/docs/zodsharp/json-schema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form).
+
## Import from JSON Schema
```csharp
diff --git a/src/src/ZodSharp/DateTimeOffsetBehaviorAttribute.cs b/src/src/ZodSharp/DateTimeOffsetBehaviorAttribute.cs
new file mode 100644
index 00000000..976a3e23
--- /dev/null
+++ b/src/src/ZodSharp/DateTimeOffsetBehaviorAttribute.cs
@@ -0,0 +1,41 @@
+namespace ZodSharp;
+
+///
+/// Overrides the assumed for a type or a single
+/// property (or positional record parameter).
+///
+///
+///
+/// The setting only shapes the JSON Schema the [ZodSchema] generator exports; it does not change how
+/// values are serialized. matches the serializer's
+/// normal behavior and exports an exact offset-aware sentinel pattern.
+/// is a narrower contract; pair it with the integration
+/// package's UTC converter, which writes UTC and rejects a non-UTC offset on read.
+///
+///
+/// Precedence, highest first: this attribute on the property (or positional parameter), this attribute on
+/// the containing type, [assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = ...)], the
+/// ZodSharpDateTimeOffsetSerialization MSBuild property (for example in Directory.Build.props),
+/// and finally .
+///
+///
+/// A [Scalar<DateTimeOffset>] owns its schema, so apply the attribute to the scalar type rather
+/// than to a property whose type is that scalar.
+///
+///
+///
+/// Initializes a new instance of the class.
+///
+/// The DateTimeOffset wire form assumed for JSON Schema generation.
+[AttributeUsage(
+ AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property | AttributeTargets.Parameter,
+ AllowMultiple = false,
+ Inherited = false
+)]
+public sealed class DateTimeOffsetBehaviorAttribute(DateTimeOffsetSerialization serialization) : Attribute
+{
+ ///
+ /// Gets the DateTimeOffset wire form assumed for JSON Schema generation.
+ ///
+ public DateTimeOffsetSerialization Serialization { get; } = serialization;
+}
diff --git a/src/src/ZodSharp/DateTimeOffsetSerialization.cs b/src/src/ZodSharp/DateTimeOffsetSerialization.cs
new file mode 100644
index 00000000..47b1547d
--- /dev/null
+++ b/src/src/ZodSharp/DateTimeOffsetSerialization.cs
@@ -0,0 +1,33 @@
+namespace ZodSharp;
+
+///
+/// Controls the JSON wire form assumed for values when the
+/// [ZodSchema] source generator exports JSON Schema.
+///
+///
+///
+/// This setting affects schema generation only; it does not serialize anything by itself. A
+/// contract must be paired with a converter that writes UTC and rejects non-UTC input, such as the
+/// integration package's UtcDateTimeOffsetConverter, so the schema and the serializer agree.
+///
+///
+/// The default, , leaves the offset in the wire form. The exported
+/// [NonSentinel] pattern is offset-aware, so it rejects every RFC 3339 representation of the
+/// instant-based bounds. is a narrower contract: the schema requires a zero offset and is
+/// paired with a converter that rejects non-UTC input.
+///
+///
+public enum DateTimeOffsetSerialization
+{
+ ///
+ /// The wire form keeps the value's offset. This is the default and works with the serializer's normal
+ /// DateTimeOffset behavior.
+ ///
+ PreserveOffset = 0,
+
+ ///
+ /// The wire form is UTC: a Z or zero offset. The schema rejects every other offset and is paired
+ /// with a converter that also rejects non-UTC input.
+ ///
+ Utc = 1,
+}
diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs
index 7d669bfc..b7f1e64a 100644
--- a/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs
+++ b/src/src/ZodSharp/JsonSchema/JsonSchemaRules.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using ZodSharp.Rules;
namespace ZodSharp.JsonSchema;
@@ -38,6 +39,18 @@ public interface IJsonSchemaRule
///
public static class JsonSchemaRules
{
+ ///
+ /// The JSON Schema pattern for the System.Text.Json wire form
+ /// (HH:mm:ss[.fffffff], with no offset).
+ ///
+ ///
+ /// JSON Schema's format: time is an RFC 3339 full-time and therefore requires an offset. A
+ /// System.Text.Json is a local time and never carries one, so a strict
+ /// format: time consumer rejects every value. The local lexical space is expressed as this pattern
+ /// instead, which a standards-based consumer can evaluate without a format assertion.
+ ///
+ public const string TimeOnlyPattern = "^(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d{1,7})?$";
+
///
/// Applies to when it implements
/// ; otherwise records the rule as unsupported so the exported schema is
@@ -59,6 +72,38 @@ public static void Apply(JsonSchemaDefinition schema, object rule)
MarkUnsupported(schema, rule.GetType().Name);
}
+ ///
+ /// Applies with the wire-form assumption a DateTimeOffset sentinel mapping
+ /// needs; every other rule ignores .
+ ///
+ /// The schema the rule constrains.
+ /// The rule instance.
+ ///
+ /// The wire form assumed for a value.
+ ///
+ ///
+ /// The generated BuildJsonSchema calls this overload for so the
+ /// rule itself owns both the default and the UTC mapping. A rule that does not describe its own JSON
+ /// Schema constraint is still reported as unsupported.
+ ///
+ public static void Apply(
+ JsonSchemaDefinition schema,
+ object rule,
+ DateTimeOffsetSerialization dateTimeOffsetSerialization
+ )
+ {
+ ArgumentNullException.ThrowIfNull(schema);
+ ArgumentNullException.ThrowIfNull(rule);
+
+ if (rule is NonSentinelRule dateTimeOffsetRule)
+ {
+ dateTimeOffsetRule.ApplyTo(schema, dateTimeOffsetSerialization);
+ return;
+ }
+
+ Apply(schema, rule);
+ }
+
///
/// Records in .
///
diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs
index 6b280ac4..3477ff02 100644
--- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs
+++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs
@@ -21,7 +21,7 @@ public static class ToJsonSchemaConverter
public static JsonSchemaDefinition Convert(IZodSchema schema, ToJsonSchemaOptions? options = null)
{
options ??= new ToJsonSchemaOptions();
- ConversionContext context = new();
+ ConversionContext context = new() { DateTimeOffsetSerialization = options.DateTimeOffsetSerialization };
var result = ConvertSchema(schema, context);
@@ -51,6 +51,8 @@ sealed class ConversionContext
public Dictionary Defs { get; } = [];
public int Counter { get; set; }
+
+ public DateTimeOffsetSerialization DateTimeOffsetSerialization { get; init; }
}
static JsonSchemaDefinition ConvertSchema(object schema, ConversionContext ctx)
@@ -77,8 +79,13 @@ static JsonSchemaDefinition ConvertSchema(object schema, ConversionContext ctx)
var result = schema switch
{
- ZodString zodString => ConvertString(zodString),
- ZodNumber zodNumber => ConvertNumber(zodNumber),
+ ZodString zodString => ConvertString(zodString, ctx),
+ ZodNumber zodNumber => ConvertNumber(zodNumber, ctx),
+ // A DateTime is the RFC 3339 date-time string System.Text.Json writes. The schema is expressed
+ // here rather than in ConvertGeneric so a rule that contributes a pattern (for example
+ // NonSentinelRule) has the typed schema to apply it to.
+ ZodDate zodDate => ConvertDate(zodDate, ctx),
+ ZodDateTimeOffset zodDateTimeOffset => ConvertDateTimeOffset(zodDateTimeOffset, ctx),
ZodBoolean => new JsonSchemaDefinition { Type = "boolean" },
ZodNull => new JsonSchemaDefinition { Type = "null" },
ZodObject zodObject => ConvertObject(zodObject, ctx),
@@ -98,7 +105,7 @@ static JsonSchemaDefinition ConvertSchema(object schema, ConversionContext ctx)
return result;
}
- static JsonSchemaDefinition ConvertString(ZodString schema)
+ static JsonSchemaDefinition ConvertString(ZodString schema, ConversionContext ctx)
{
JsonSchemaDefinition result = new() { Type = "string" };
@@ -139,7 +146,7 @@ static JsonSchemaDefinition ConvertString(ZodString schema)
break;
default:
- ApplyUnmappedRule(result, rule);
+ ApplyUnmappedRule(result, rule, ctx.DateTimeOffsetSerialization);
break;
}
}
@@ -153,7 +160,33 @@ static JsonSchemaDefinition ConvertString(ZodString schema)
return result;
}
- static JsonSchemaDefinition ConvertNumber(ZodNumber schema)
+ static JsonSchemaDefinition ConvertDate(ZodDate schema, ConversionContext ctx)
+ {
+ JsonSchemaDefinition result = new() { Type = "string", Format = "date-time" };
+
+ foreach (var rule in schema.AppliedRules)
+ ApplyUnmappedRule(result, rule, ctx.DateTimeOffsetSerialization);
+
+ if (schema.Description != null)
+ result.Description = schema.Description;
+
+ return result;
+ }
+
+ static JsonSchemaDefinition ConvertDateTimeOffset(ZodDateTimeOffset schema, ConversionContext ctx)
+ {
+ JsonSchemaDefinition result = new() { Type = "string", Format = "date-time" };
+
+ foreach (var rule in schema.AppliedRules)
+ ApplyUnmappedRule(result, rule, ctx.DateTimeOffsetSerialization);
+
+ if (schema.Description != null)
+ result.Description = schema.Description;
+
+ return result;
+ }
+
+ static JsonSchemaDefinition ConvertNumber(ZodNumber schema, ConversionContext ctx)
{
JsonSchemaDefinition result = new() { Type = "number" };
@@ -189,7 +222,7 @@ static JsonSchemaDefinition ConvertNumber(ZodNumber schema)
break;
default:
- ApplyUnmappedRule(result, rule);
+ ApplyUnmappedRule(result, rule, ctx.DateTimeOffsetSerialization);
break;
}
}
@@ -322,8 +355,18 @@ static JsonSchemaDefinition ConvertGeneric(object schema, ConversionContext ctx)
/// contributes it; anything else is recorded as unsupported so the omission is visible rather than
/// silently weakening the exported schema.
///
- static void ApplyUnmappedRule(JsonSchemaDefinition schema, object rule)
+ static void ApplyUnmappedRule(
+ JsonSchemaDefinition schema,
+ object rule,
+ DateTimeOffsetSerialization dateTimeOffsetSerialization
+ )
{
+ if (rule is NonSentinelRule dateTimeOffsetRule)
+ {
+ dateTimeOffsetRule.ApplyTo(schema, dateTimeOffsetSerialization);
+ return;
+ }
+
if (rule is IJsonSchemaRule mapped)
{
mapped.ApplyTo(schema);
diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaOptions.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaOptions.cs
index 89dbac6b..36129e43 100644
--- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaOptions.cs
+++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaOptions.cs
@@ -20,4 +20,15 @@ public sealed class ToJsonSchemaOptions
/// Custom title for the schema.
///
public string? Title { get; set; }
+
+ ///
+ /// Gets or sets the wire form assumed for a sentinel mapping.
+ ///
+ ///
+ /// The generated BuildJsonSchema path resolves this from
+ /// and the assembly/MSBuild defaults. This option is the
+ /// runtime equivalent for a hand-built fluent schema, which has no compile-time metadata to read.
+ ///
+ public DateTimeOffsetSerialization DateTimeOffsetSerialization { get; set; } =
+ DateTimeOffsetSerialization.PreserveOffset;
}
diff --git a/src/src/ZodSharp/Rules/NonSentinelRule.cs b/src/src/ZodSharp/Rules/NonSentinelRule.cs
index 3e923ea1..df75c3ca 100644
--- a/src/src/ZodSharp/Rules/NonSentinelRule.cs
+++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs
@@ -1,4 +1,6 @@
+using System.Globalization;
using System.Runtime.CompilerServices;
+using System.Text;
namespace ZodSharp.Rules;
@@ -25,6 +27,12 @@ namespace ZodSharp.Rules;
/// without rejecting that type's entire value range. Detection uses a typeof(T) dispatch and
/// reinterprets the value in place, so no boxing occurs on the validation path.
///
+///
+/// The JSON Schema mapping applies to the same types. uses an offset-aware
+/// pattern that rejects every RFC 3339 representation of the instant-based bounds; a UTC-only contract is
+/// available as a narrower alternative. See and the overload
+/// of .
+///
///
[Core.ZodRule]
public readonly record struct NonSentinelRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule
@@ -41,6 +49,103 @@ namespace ZodSharp.Rules;
public const string GuidJsonSchemaPattern =
"^(?!00000000-0000-0000-0000-000000000000$)[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
+ ///
+ /// The JSON Schema pattern that rejects and
+ /// in their System.Text.Json wire form (yyyy-MM-dd).
+ ///
+ public const string DateOnlyJsonSchemaPattern = "^(?!0001-01-01$)(?!9999-12-31$)";
+
+ ///
+ /// The JSON Schema pattern that rejects and
+ /// in their System.Text.Json wire form (HH:mm:ss[.fffffff]).
+ ///
+ public const string TimeOnlyJsonSchemaPattern = "^(?!00:00(?::00(?:\\.0+)?)?$)(?!23:59:59\\.9999999\\d*$)";
+
+ ///
+ /// The JSON Schema pattern that rejects the deterministic System.Text.Json wire forms of
+ /// and : no offset, Z, or a zero
+ /// offset. A non-zero offset is intentionally not rejected — System.Text.Json converts such a value into
+ /// the receiver's local time zone, so it no longer represents the sentinel ticks.
+ ///
+ public const string DateTimeJsonSchemaPattern =
+ "^(?!0001-01-01T00:00:00(?:\\.0+)?(?:Z|[+-]00:00)?$)"
+ + "(?!9999-12-31T23:59:59\\.9999999\\d*(?:Z|[+-]00:00)?$)";
+
+ ///
+ /// The JSON Schema pattern that rejects every RFC 3339 representation of
+ /// and , whatever offset the
+ /// wire form carries.
+ ///
+ ///
+ ///
+ /// DateTimeOffset equality compares instants, and RFC 3339 permits the same instant at different offsets.
+ /// For the minimum, the offset must be non-negative and the local wall clock equals the offset
+ /// (0001-01-01T05:30:00+05:30); for the maximum, the local wall clock is
+ /// 23:59:59.9999999 minus the offset (9999-12-31T18:29:59.9999999-05:30).
+ ///
+ ///
+ /// The pattern correlates the local time with the offset using a backreference for the minimum and two
+ /// lookaheads for the maximum (the hour and minute relations are independent). It is therefore exact for
+ /// every offset System.Text.Json can write, not just the zero-offset form.
+ ///
+ ///
+ public static readonly string DateTimeOffsetJsonSchemaPattern = BuildDateTimeOffsetJsonSchemaPattern();
+
+ ///
+ /// The JSON Schema pattern for a UTC-only contract: it requires a
+ /// Z or zero offset and rejects and
+ /// .
+ ///
+ ///
+ /// Because every accepted string has a zero offset, it denotes exactly one instant, so this pattern is
+ /// an exact representation of the instant-based sentinel check. Pair it with a converter that also
+ /// rejects non-UTC input; see .
+ ///
+ public const string DateTimeOffsetUtcJsonSchemaPattern =
+ "^(?=.*(?:Z|[+-]00:00)$)"
+ + "(?!0001-01-01T00:00:00(?:\\.0+)?(?:Z|[+-]00:00)$)"
+ + "(?!9999-12-31T23:59:59\\.9999999\\d*(?:Z|[+-]00:00)$)";
+
+ static string BuildDateTimeOffsetJsonSchemaPattern()
+ {
+ // Maximum: local time = 23:59:59.9999999 minus the offset. The hour relation
+ // (local 23-offsetHour) and the minute relation (local 59-offsetMinute) are independent, so two
+ // lookaheads correlate them without enumerating every offset pair.
+ StringBuilder maxHours = new("9999-12-31T23:[0-5]\\d:59\\.9999999\\d*(?:Z|[+-]00:00|-00:[0-5]\\d)");
+ for (var offsetHour = 1; offsetHour <= 14; offsetHour++)
+ {
+ maxHours.Append(
+ CultureInfo.InvariantCulture,
+ $"|9999-12-31T{TwoDigits(23 - offsetHour)}:[0-5]\\d:59\\.9999999\\d*-{TwoDigits(offsetHour)}:[0-5]\\d"
+ );
+ }
+
+ StringBuilder maxMinutes = new("9999-12-31T[0-2]\\d:59:59\\.9999999\\d*(?:Z|[+-]00:00|-[0-2]\\d:00)");
+ for (var offsetMinute = 1; offsetMinute <= 59; offsetMinute++)
+ {
+ maxMinutes.Append(
+ CultureInfo.InvariantCulture,
+ $"|9999-12-31T[0-2]\\d:{TwoDigits(59 - offsetMinute)}:59\\.9999999\\d*-[0-2]\\d:{TwoDigits(offsetMinute)}"
+ );
+ }
+
+ // Minimum: local time = the offset, so numbered backreferences require both components to match. The
+ // offset cannot be negative (it would fall below DateTimeOffset.MinValue); zero-offset forms may be
+ // written as Z or -00:00 as well as +00:00 (which the backreferences cover). Numbered rather than named
+ // groups keep the pattern usable by JSON Schema validators built on regex engines that do not support
+ // named backreferences.
+ return "^(?!"
+ + "(?=(?:"
+ + maxHours
+ + ")$)(?=(?:"
+ + maxMinutes
+ + ")$))"
+ + "(?!0001-01-01T(0\\d|1[0-4]):([0-5]\\d):00(?:\\.0+)?\\+\\1:\\2"
+ + "|0001-01-01T00:00:00(?:\\.0+)?(?:Z|-00:00))";
+ }
+
+ static string TwoDigits(int value) => value.ToString("00", CultureInfo.InvariantCulture);
+
readonly string _message;
///
@@ -61,18 +166,43 @@ public NonSentinelRule(string? message = null, string? code = null)
/// True if valid, false otherwise
public bool IsValid(in T value) => !SentinelValues.IsSentinel(value);
+ ///
+ /// Applies the JSON Schema constraint that rejects the value's sentinel, assuming the default
+ /// wire form.
+ ///
+ /// The schema for the value being validated.
+ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) =>
+ ApplyTo(schema, DateTimeOffsetSerialization.PreserveOffset);
+
///
/// Applies the JSON Schema constraint that rejects the value's sentinel, where the wire form is known.
///
/// The schema for the value being validated.
+ ///
+ /// The wire form assumed for a value. It is ignored for every other type.
+ ///
///
+ ///
/// A string sentinel (null/empty/whitespace) becomes minLength: 1 plus a non-whitespace pattern; a
- /// GUID sentinel () becomes a not: { const: ... } against the all-zero
- /// UUID. A date/time sentinel depends on the exact serialized form, which the exporter cannot know here,
- /// so it is reported through instead of
- /// being guessed or dropped.
+ /// GUID sentinel () becomes a canonical-Guid pattern that rejects the all-zero
+ /// UUID. , and become patterns that
+ /// reject the System.Text.Json wire forms of their bounds; those mappings are exact for the deterministic
+ /// wire forms.
+ ///
+ ///
+ /// compares instants, and RFC 3339 permits offset-equivalent
+ /// representations. uses an offset-aware
+ /// pattern that correlates the local time with the offset and rejects every such representation of
+ /// and .
+ /// is the narrower alternative: the schema requires a zero
+ /// offset, so the same bounds reduce to a shorter pattern.
+ ///
+ ///
+ /// A type with no known sentinel always passes, so it contributes no constraint and is not reported as
+ /// unsupported.
+ ///
///
- public void ApplyTo(JsonSchema.JsonSchemaDefinition schema)
+ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema, DateTimeOffsetSerialization dateTimeOffsetSerialization)
{
ArgumentNullException.ThrowIfNull(schema);
@@ -93,7 +223,37 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema)
return;
}
- JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(NonSentinelRule<>));
+ if (typeof(T) == typeof(DateOnly))
+ {
+ JsonSchema.JsonSchemaRules.ConstrainPattern(schema, DateOnlyJsonSchemaPattern);
+ return;
+ }
+
+ if (typeof(T) == typeof(TimeOnly))
+ {
+ JsonSchema.JsonSchemaRules.ConstrainPattern(schema, TimeOnlyJsonSchemaPattern);
+ return;
+ }
+
+ if (typeof(T) == typeof(DateTime))
+ {
+ JsonSchema.JsonSchemaRules.ConstrainPattern(schema, DateTimeJsonSchemaPattern);
+ return;
+ }
+
+ if (typeof(T) == typeof(DateTimeOffset))
+ {
+ if (dateTimeOffsetSerialization == DateTimeOffsetSerialization.Utc)
+ {
+ JsonSchema.JsonSchemaRules.ConstrainPattern(schema, DateTimeOffsetUtcJsonSchemaPattern);
+ return;
+ }
+
+ JsonSchema.JsonSchemaRules.ConstrainPattern(schema, DateTimeOffsetJsonSchemaPattern);
+ return;
+ }
+
+ // No built-in sentinel is known for T, so IsValid always passes and the schema needs no constraint.
}
///
diff --git a/src/src/ZodSharp/Schemas/ZodDateTimeOffset.cs b/src/src/ZodSharp/Schemas/ZodDateTimeOffset.cs
new file mode 100644
index 00000000..7380a8ec
--- /dev/null
+++ b/src/src/ZodSharp/Schemas/ZodDateTimeOffset.cs
@@ -0,0 +1,35 @@
+using ZodSharp.Core;
+using ZodSharp.Rules;
+
+namespace ZodSharp.Schemas;
+
+///
+/// Schema for validation.
+///
+///
+/// This is the fluent counterpart to a generated [ZodSchema] property. Its JSON Schema mapping is an
+/// RFC 3339 date-time string, and exports the offset-aware sentinel pattern.
+///
+public class ZodDateTimeOffset : ZodType
+{
+ ///
+ /// Parses and validates a value.
+ ///
+ /// The value to validate.
+ /// A successful validation result.
+ protected override ValidationResult ParseInternal(DateTimeOffset value) =>
+ ValidationResult.Success(value);
+
+ ///
+ /// Adds a non-sentinel validation that rejects and
+ /// .
+ ///
+ /// Optional error message.
+ /// Optional error code override.
+ /// This schema for method chaining.
+ public override ZodDateTimeOffset NonSentinel(string? message = null, string? code = null)
+ {
+ AddRule(new NonSentinelRule(message, code));
+ return this;
+ }
+}
diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md
index 999b285f..a4c6c195 100644
--- a/src/src/ZodSharp/Sdk/README.md
+++ b/src/src/ZodSharp/Sdk/README.md
@@ -351,6 +351,15 @@ The `Purview.ZodSharp.AspNetCore` package offers `AddZodSharp` with assembly aut
var jsonSchema = Z.ToJsonSchema(userSchema, new ToJsonSchemaOptions { Title = "User" });
```
+Generated schemas export `[NonSentinel]` for `DateOnly`, `TimeOnly`, `DateTime`, and `DateTimeOffset` as exact
+patterns. The `DateTimeOffset` pattern is offset-aware, so the default preserves offsets without an
+`x-zodsharp-unsupported` marker. An optional narrower UTC contract is available with
+`Utc` (or
+`[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)]`) and the
+JSON integration package's `UtcDateTimeOffsetConverter`. See the
+[JSON Schema Export](https://purview.dev/docs/zodsharp/json-schema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form)
+documentation.
+
## Documentation
- [Homepage](https://purview.dev/projects/zodsharp/)
diff --git a/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props b/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props
index 2ec4a361..5d32d293 100644
--- a/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props
+++ b/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props
@@ -21,5 +21,8 @@
Assembly-wide default for whether collection properties may contain null elements; false (the default) rejects them.
+
+ Assembly-wide DateTimeOffset wire form assumed by JSON Schema generation; Utc requires a zero offset and makes the non-sentinel mapping exact, PreserveOffset (the default) keeps the value's offset.
+
diff --git a/src/src/ZodSharp/Z.cs b/src/src/ZodSharp/Z.cs
index f2be543f..ab0d8a7a 100644
--- a/src/src/ZodSharp/Z.cs
+++ b/src/src/ZodSharp/Z.cs
@@ -37,6 +37,15 @@ public static class Z
///
public static ZodDate Date() => new();
+ ///
+ /// Creates a schema.
+ ///
+ ///
+ /// The wire form is an RFC 3339 date-time string. Use NonSentinel() to reject
+ /// and .
+ ///
+ public static ZodDateTimeOffset DateTimeOffset() => new();
+
///
/// Creates a 64-bit integer (bigint) schema.
/// Equivalent to Zod's z.bigint().
diff --git a/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs b/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs
index 7ad458ad..fef5674a 100644
--- a/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs
+++ b/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs
@@ -30,4 +30,24 @@ public sealed class ZodSharpSchemaDefaultsAttribute : Attribute
/// parameter type, so the unset state is the absence of the named argument rather than a null value.
///
public bool AllowNulls { get; set; }
+
+ ///
+ /// Gets or sets the assembly-wide default for the JSON wire form assumed for
+ /// values.
+ ///
+ ///
+ ///
+ /// This is the assembly-level default for schema generation. It overrides the
+ /// ZodSharpDateTimeOffsetSerialization MSBuild property, and a type or property can override it
+ /// with . When neither this property nor the MSBuild
+ /// property is set, applies.
+ ///
+ ///
+ /// The setting only shapes the exported JSON Schema.
+ /// exports an offset-aware sentinel pattern that matches the serializer's normal behavior.
+ /// is narrower and must be paired with the integration
+ /// package's UTC converter so the serializer and the schema agree.
+ ///
+ ///
+ public DateTimeOffsetSerialization DateTimeOffsetSerialization { get; set; }
}
diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs
index 634896f9..2b69a1d7 100644
--- a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs
+++ b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs
@@ -162,4 +162,70 @@ public async Task Parse_GivenAllOfPatternBranches_EnforcesEveryBranch(Cancellati
await Assert.That(schema.Validate("00000000-0000-0000-0000-000000000000").IsSuccess).IsFalse();
await Assert.That(schema.Validate("not-a-guid").IsSuccess).IsFalse();
}
+
+ [Test]
+ public async Task JsonSchemaSerializerOptions_GivenTemporalPattern_WritesPatternAndUnsupportedKeyword()
+ {
+ // Arrange
+ JsonSchemaDefinition definition = new()
+ {
+ Type = "string",
+ Format = "date-time",
+ Pattern = NonSentinelRule.DateTimeOffsetJsonSchemaPattern,
+ UnsupportedRules = [nameof(NonSentinelRule<>)],
+ };
+
+ // Act
+ var json = Newtonsoft.Json.JsonConvert.SerializeObject(definition, JsonSchemaSerializerOptions.Default);
+
+ // Assert
+ await Assert.That(json).Contains("\"pattern\"");
+ await Assert.That(json).Contains("\"x-zodsharp-unsupported\"");
+ await Assert.That(json).Contains(nameof(NonSentinelRule<>));
+ }
+
+ [Test]
+ public async Task Parse_GivenDefaultDateTimeOffsetPattern_RejectsOffsetEquivalentBounds(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ JsonSchemaDefinition definition = new()
+ {
+ Type = "string",
+ Pattern = NonSentinelRule.DateTimeOffsetJsonSchemaPattern,
+ };
+
+ // Act
+ var schema = FromJsonSchemaParser.Parse(definition);
+
+ // Assert
+ await Assert.That(schema.Validate("2024-01-02T03:04:05+05:30").IsSuccess).IsTrue();
+ await Assert.That(schema.Validate("0001-01-01T05:30:00+05:30").IsSuccess).IsFalse();
+ await Assert.That(schema.Validate("9999-12-31T18:29:59.9999999-05:30").IsSuccess).IsFalse();
+ }
+
+ [Test]
+ public async Task Parse_GivenUtcTemporalPattern_RejectsBoundsAndNonUtcOffsets(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ JsonSchemaDefinition definition = new()
+ {
+ Type = "string",
+ Pattern = NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern,
+ };
+
+ // Act
+ var schema = FromJsonSchemaParser.Parse(definition);
+
+ // Assert
+ await Assert.That(schema.Validate("2024-01-02T03:04:05Z").IsSuccess).IsTrue();
+ await Assert.That(schema.Validate("0001-01-01T00:00:00Z").IsSuccess).IsFalse();
+ await Assert.That(schema.Validate("0001-01-01T05:30:00+05:30").IsSuccess).IsFalse();
+ await Assert.That(schema.Validate("2024-01-02T03:04:05+05:30").IsSuccess).IsFalse();
+ }
}
diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/Serialization/NewtonsoftJson/DateTimeOffsetConverterTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/Serialization/NewtonsoftJson/DateTimeOffsetConverterTests.cs
new file mode 100644
index 00000000..6bf71978
--- /dev/null
+++ b/src/tests/NewtonsoftJson.UnitTests/Json/Serialization/NewtonsoftJson/DateTimeOffsetConverterTests.cs
@@ -0,0 +1,140 @@
+using Newtonsoft.Json;
+
+namespace ZodSharp.Json.Serialization.NewtonsoftJson;
+
+public class DateTimeOffsetConverterTests
+{
+ static readonly DateTimeOffset OffsetValue = new(2024, 1, 2, 3, 4, 5, TimeSpan.FromHours(5.5));
+
+ static JsonSerializerSettings UtcSettings() =>
+ new() { DateParseHandling = DateParseHandling.None, Converters = { new UtcDateTimeOffsetConverter() } };
+
+ static JsonSerializerSettings PreserveSettings() =>
+ new()
+ {
+ DateParseHandling = DateParseHandling.None,
+ Converters = { new PreserveOffsetDateTimeOffsetConverter() },
+ };
+
+ [Test]
+ public async Task UtcConverter_GivenNonZeroOffset_WritesUtc()
+ {
+ // Act
+ var json = JsonConvert.SerializeObject(OffsetValue, UtcSettings());
+
+ // Assert
+ await Assert.That(json).IsEqualTo("\"2024-01-01T21:34:05.0000000+00:00\"");
+ }
+
+ [Test]
+ public async Task UtcConverter_GivenUtcInput_ReadsWithZeroOffset()
+ {
+ // Act
+ var value = JsonConvert.DeserializeObject("\"2024-01-02T03:04:05Z\"", UtcSettings());
+
+ // Assert
+ await Assert.That(value.Offset).IsEqualTo(TimeSpan.Zero);
+ await Assert.That(value).IsEqualTo(new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero));
+ }
+
+ [Test]
+ public async Task UtcConverter_GivenNonUtcInput_Throws()
+ {
+ // Act
+ var exception = Assert.Throws(() =>
+ JsonConvert.DeserializeObject("\"2024-01-02T03:04:05+05:30\"", UtcSettings())
+ );
+
+ // Assert
+ await Assert.That(exception.Message).Contains("UTC");
+ }
+
+ [Test]
+ public async Task UtcConverter_GivenNoOffsetDesignator_Throws()
+ {
+ // Act — an offset of zero is not enough: the JSON Schema requires an explicit Z/zero-offset suffix.
+ var exception = Assert.Throws(() =>
+ JsonConvert.DeserializeObject("\"2024-01-02T03:04:05\"", UtcSettings())
+ );
+
+ // Assert
+ await Assert.That(exception.Message).Contains("UTC designator");
+ }
+
+ [Test]
+ public async Task UtcConverter_GivenDateParseHandlingNotDisabled_ReportsTheRequiredSetting()
+ {
+ // Arrange — without DateParseHandling.None, Newtonsoft parses the offset away before the converter.
+ JsonSerializerSettings settings = new() { Converters = { new UtcDateTimeOffsetConverter() } };
+
+ // Act
+ var exception = Assert.Throws(() =>
+ JsonConvert.DeserializeObject("\"2024-01-02T03:04:05+05:30\"", settings)
+ );
+
+ // Assert
+ await Assert.That(exception.Message).Contains("DateParseHandling");
+ }
+
+ [Test]
+ public async Task UtcConverter_GivenNullableNull_ReturnsNull()
+ {
+ // Act
+ var value = JsonConvert.DeserializeObject("null", UtcSettings());
+
+ // Assert
+ await Assert.That(value).IsNull();
+ }
+
+ [Test]
+ public async Task SettingsHelper_GivenUtcContract_ConfiguresDateParseHandlingAndConverter()
+ {
+ // Act
+ var settings = new JsonSerializerSettings().UseZodSharpUtcDateTimeOffset();
+ var value = JsonConvert.DeserializeObject("\"2024-01-02T03:04:05Z\"", settings);
+
+ // Assert
+ await Assert.That(settings.DateParseHandling).IsEqualTo(DateParseHandling.None);
+ await Assert.That(settings.Converters.Any(converter => converter is UtcDateTimeOffsetConverter)).IsTrue();
+ await Assert.That(value.Offset).IsEqualTo(TimeSpan.Zero);
+ }
+
+ [Test]
+ public async Task SettingsHelper_GivenUtcContract_LeavesDateTimeDeserializationWorking()
+ {
+ // Act — DateParseHandling.None must not break a normal DateTime property.
+ var settings = new JsonSerializerSettings().UseZodSharpUtcDateTimeOffset();
+ var value = JsonConvert.DeserializeObject("\"2024-01-02T03:04:05Z\"", settings);
+
+ // Assert
+ await Assert.That(value).IsEqualTo(new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc));
+ }
+
+ [Test]
+ public async Task SettingsHelper_GivenPreserveOffsetContract_ConfiguresDateParseHandlingAndConverter()
+ {
+ // Act
+ var settings = new JsonSerializerSettings().UseZodSharpPreserveOffsetDateTimeOffset();
+ var value = JsonConvert.DeserializeObject("\"2024-01-02T03:04:05+05:30\"", settings);
+
+ // Assert
+ await Assert.That(settings.DateParseHandling).IsEqualTo(DateParseHandling.None);
+ await Assert
+ .That(settings.Converters.Any(converter => converter is PreserveOffsetDateTimeOffsetConverter))
+ .IsTrue();
+ await Assert.That(value.Offset).IsEqualTo(TimeSpan.FromHours(5.5));
+ }
+
+ [Test]
+ public async Task PreserveOffsetConverter_GivenNonZeroOffset_RoundTripsTheOffset()
+ {
+ // Act
+ var json = JsonConvert.SerializeObject(OffsetValue, PreserveSettings());
+ var value = JsonConvert.DeserializeObject(json, PreserveSettings());
+
+ // Assert
+ await Assert.That(json).IsEqualTo("\"2024-01-02T03:04:05.0000000+05:30\"");
+ await Assert.That(value.Offset).IsEqualTo(TimeSpan.FromHours(5.5));
+ await Assert.That(value).IsEqualTo(OffsetValue);
+ }
+}
diff --git a/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs b/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs
index abdc9ec6..aaf0be2d 100644
--- a/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs
+++ b/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs
@@ -73,6 +73,7 @@ public static IEnumerable> Suites() =>
[
() => new VitestSuite("tests/ts/cross-platform.test.ts", "src/tests/cross-platform/output"),
() => new VitestSuite("tests/ts/guid-uuid-parity.test.ts", "src/tests/cross-platform/guid-parity"),
+ () => new VitestSuite("tests/ts/temporal-parity.test.ts", "src/tests/cross-platform/temporal-parity"),
];
[Test]
diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporal.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporal.cs
new file mode 100644
index 00000000..f0de9a77
--- /dev/null
+++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporal.cs
@@ -0,0 +1,360 @@
+using System.Collections.Immutable;
+using ZodSharp.JsonSchema;
+using ZodSharp.Rules;
+using ZodSharp.SourceGenerators.Infra;
+
+namespace ZodSharp.SourceGenerators;
+
+public partial class ZodSchemaGeneratorTests
+{
+ const string TemporalHolderSource = """
+ using System;
+ using ZodSharp.Rules;
+
+ namespace Testing
+ {
+ [ZodSchema]
+ public sealed class TemporalHolder
+ {
+ [NonSentinel]
+ public DateTimeOffset ObservedAt { get; set; }
+
+ [NonSentinel]
+ public DateTime Timestamp { get; set; }
+
+ [NonSentinel]
+ public DateOnly Day { get; set; }
+
+ [NonSentinel]
+ public TimeOnly Time { get; set; }
+
+ [NonSentinel]
+ public DateTimeOffset? Optional { get; set; }
+ }
+ }
+ """;
+
+ [Test]
+ public async Task JsonSchema_GivenNonSentinelTemporalProperties_ExportsExactPatterns(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Act
+ var definition = await BuildTemporalDefinitionAsync(
+ TemporalHolderSource,
+ "TemporalHolderSchema",
+ new ZodSourceGeneratorTestOptions().Compile(),
+ cancellationToken
+ );
+
+ // Assert — the deterministic types are exact and carry no marker.
+ var timestamp = definition.Properties!["timestamp"];
+ await Assert.That(timestamp.Format).IsEqualTo("date-time");
+ await Assert.That(timestamp.Pattern).IsEqualTo(NonSentinelRule.DateTimeJsonSchemaPattern);
+ await Assert.That(timestamp.UnsupportedRules).IsNull();
+
+ var day = definition.Properties!["day"];
+ await Assert.That(day.Format).IsEqualTo("date");
+ await Assert.That(day.Pattern).IsEqualTo(NonSentinelRule.DateOnlyJsonSchemaPattern);
+ await Assert.That(day.UnsupportedRules).IsNull();
+
+ var time = definition.Properties!["time"];
+ await Assert.That(time.Format).IsNull();
+ await Assert.That(time.Pattern).IsNull();
+ await Assert.That(time.AllOf!).Count().IsEqualTo(2);
+ await Assert.That(time.AllOf!.Select(branch => branch.Pattern)).Contains(JsonSchemaRules.TimeOnlyPattern);
+ await Assert
+ .That(time.AllOf!.Select(branch => branch.Pattern))
+ .Contains(NonSentinelRule.TimeOnlyJsonSchemaPattern);
+ await Assert.That(time.UnsupportedRules).IsNull();
+
+ // Assert — the default PreserveOffset mapping is offset-aware and exact.
+ var observedAt = definition.Properties!["observedAt"];
+ await Assert.That(observedAt.Format).IsEqualTo("date-time");
+ await Assert
+ .That(observedAt.Pattern)
+ .IsEqualTo(NonSentinelRule.DateTimeOffsetJsonSchemaPattern);
+ await Assert.That(observedAt.UnsupportedRules).IsNull();
+
+ // Assert — the nullable DateTimeOffset keeps the null branch.
+ await Assert.That(definition.Required!).DoesNotContain("optional");
+ var optional = definition.Properties!["optional"].AnyOf!;
+ await Assert.That(optional.Any(candidate => candidate.Type == "null")).IsTrue();
+ var optionalText = optional.Single(candidate => candidate.Type == "string");
+ await Assert.That(optionalText.UnsupportedRules).IsNull();
+ }
+
+ [Test]
+ public async Task JsonSchema_GivenMsBuildUtc_ExportsTheExactDateTimeOffsetPatternWithoutAMarker(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange — the Directory.Build.props shape.
+ ZodSourceGeneratorTestOptions options = new()
+ {
+ CompileToAssembly = true,
+ AnalyzerConfigOptions = ImmutableDictionary.Empty.Add(
+ "build_property:ZodSharpDateTimeOffsetSerialization",
+ "Utc"
+ ),
+ };
+
+ // Act
+ var definition = await BuildTemporalDefinitionAsync(
+ TemporalHolderSource,
+ "TemporalHolderSchema",
+ options,
+ cancellationToken
+ );
+
+ // Assert
+ var observedAt = definition.Properties!["observedAt"];
+ await Assert
+ .That(observedAt.Pattern)
+ .IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern);
+ await Assert.That(observedAt.UnsupportedRules).IsNull();
+
+ var optional = definition.Properties!["optional"].AnyOf!.Single(candidate => candidate.Type == "string");
+ await Assert
+ .That(optional.Pattern)
+ .IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern);
+ await Assert.That(optional.UnsupportedRules).IsNull();
+ }
+
+ [Test]
+ public async Task JsonSchema_GivenAssemblyUtc_OverridesTheMsBuildDefault(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange — the MSBuild default is PreserveOffset; the assembly opts this compilation into Utc.
+ const string source = """
+ using System;
+ using ZodSharp;
+ using ZodSharp.Rules;
+
+ [assembly: ZodSharpSchemaDefaults(
+ DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)]
+
+ namespace Testing
+ {
+ [ZodSchema]
+ public sealed class Stamp
+ {
+ [NonSentinel]
+ public DateTimeOffset At { get; set; }
+ }
+ }
+ """;
+
+ // Act
+ var definition = await BuildTemporalDefinitionAsync(
+ source,
+ "StampSchema",
+ new ZodSourceGeneratorTestOptions().Compile(),
+ cancellationToken
+ );
+
+ // Assert
+ var at = definition.Properties!["at"];
+ await Assert.That(at.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern);
+ await Assert.That(at.UnsupportedRules).IsNull();
+ }
+
+ [Test]
+ public async Task JsonSchema_GivenAssemblyPreserveOffset_OverridesTheMsBuildUtcDefault(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ const string source = """
+ using System;
+ using ZodSharp;
+ using ZodSharp.Rules;
+
+ [assembly: ZodSharpSchemaDefaults(
+ DateTimeOffsetSerialization = DateTimeOffsetSerialization.PreserveOffset)]
+
+ namespace Testing
+ {
+ [ZodSchema]
+ public sealed class Stamp
+ {
+ [NonSentinel]
+ public DateTimeOffset At { get; set; }
+ }
+ }
+ """;
+ ZodSourceGeneratorTestOptions options = new()
+ {
+ CompileToAssembly = true,
+ AnalyzerConfigOptions = ImmutableDictionary.Empty.Add(
+ "build_property:ZodSharpDateTimeOffsetSerialization",
+ "Utc"
+ ),
+ };
+
+ // Act
+ var definition = await BuildTemporalDefinitionAsync(source, "StampSchema", options, cancellationToken);
+
+ // Assert
+ var at = definition.Properties!["at"];
+ await Assert.That(at.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetJsonSchemaPattern);
+ await Assert.That(at.UnsupportedRules).IsNull();
+ }
+
+ [Test]
+ public async Task JsonSchema_GivenTypeAndPropertyOverrides_UsesTheMostSpecificSetting(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange — the project is Utc; the type keeps Utc for ObservedAt but opts Optional back out.
+ const string source = """
+ using System;
+ using ZodSharp;
+ using ZodSharp.Rules;
+
+ namespace Testing
+ {
+ [ZodSchema]
+ [DateTimeOffsetBehavior(DateTimeOffsetSerialization.Utc)]
+ public sealed class Stamp
+ {
+ [NonSentinel]
+ public DateTimeOffset ObservedAt { get; set; }
+
+ [NonSentinel]
+ [DateTimeOffsetBehavior(DateTimeOffsetSerialization.PreserveOffset)]
+ public DateTimeOffset? Optional { get; set; }
+ }
+ }
+ """;
+ ZodSourceGeneratorTestOptions options = new()
+ {
+ CompileToAssembly = true,
+ AnalyzerConfigOptions = ImmutableDictionary.Empty.Add(
+ "build_property:ZodSharpDateTimeOffsetSerialization",
+ "Utc"
+ ),
+ };
+
+ // Act
+ var definition = await BuildTemporalDefinitionAsync(source, "StampSchema", options, cancellationToken);
+
+ // Assert — the type-level Utc wins for ObservedAt even though the MSBuild default is also Utc.
+ var observedAt = definition.Properties!["observedAt"];
+ await Assert
+ .That(observedAt.Pattern)
+ .IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern);
+ await Assert.That(observedAt.UnsupportedRules).IsNull();
+
+ // Assert — the property-level PreserveOffset wins for Optional.
+ var optionalText = definition.Properties!["optional"].AnyOf!.Single(candidate => candidate.Type == "string");
+ await Assert
+ .That(optionalText.Pattern)
+ .IsEqualTo(NonSentinelRule.DateTimeOffsetJsonSchemaPattern);
+ await Assert.That(optionalText.UnsupportedRules).IsNull();
+ }
+
+ [Test]
+ public async Task JsonSchema_GivenPositionalParameterBehavior_MergesTheOverrideOntoTheProperty(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange — the project is Utc; the positional parameter opts this property back out.
+ const string source = """
+ using System;
+ using ZodSharp;
+ using ZodSharp.Rules;
+
+ namespace Testing
+ {
+ [ZodSchema]
+ public sealed record Stamp(
+ [NonSentinel]
+ [DateTimeOffsetBehavior(DateTimeOffsetSerialization.PreserveOffset)]
+ DateTimeOffset At);
+ }
+ """;
+ ZodSourceGeneratorTestOptions options = new()
+ {
+ CompileToAssembly = true,
+ AnalyzerConfigOptions = ImmutableDictionary.Empty.Add(
+ "build_property:ZodSharpDateTimeOffsetSerialization",
+ "Utc"
+ ),
+ };
+
+ // Act
+ var definition = await BuildTemporalDefinitionAsync(source, "StampSchema", options, cancellationToken);
+
+ // Assert
+ var at = definition.Properties!["at"];
+ await Assert.That(at.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetJsonSchemaPattern);
+ await Assert.That(at.UnsupportedRules).IsNull();
+ }
+
+ [Test]
+ public async Task JsonSchema_GivenUtcScalarWithNonSentinel_ExportsTheExactPatternWithoutAMarker(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ const string source =
+ AutomaticScalarPrelude
+ + """
+ namespace Testing
+ {
+ [Purview.ValueObjects.Serialization.Scalar]
+ [ZodSchema]
+ [DateTimeOffsetBehavior(DateTimeOffsetSerialization.Utc)]
+ [NonSentinel]
+ public partial record struct ObservedAt
+ : Purview.ValueObjects.IScalarValueObject
+ {
+ public DateTimeOffset Value { get; init; }
+ }
+ }
+ """;
+
+ // Act
+ var definition = await BuildTemporalDefinitionAsync(
+ source,
+ "ObservedAtSchema",
+ new ZodSourceGeneratorTestOptions().Compile(),
+ cancellationToken
+ );
+
+ // Assert
+ await Assert.That(definition.Type).IsEqualTo("string");
+ await Assert.That(definition.Format).IsEqualTo("date-time");
+ await Assert
+ .That(definition.Pattern)
+ .IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern);
+ await Assert.That(definition.UnsupportedRules).IsNull();
+ }
+
+ async Task BuildTemporalDefinitionAsync(
+ string source,
+ string schemaTypeName,
+ ZodSourceGeneratorTestOptions options,
+ CancellationToken cancellationToken
+ )
+ {
+ using var driverResult = await GenerateAsync(source, options, cancellationToken);
+ var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull();
+ return (JsonSchemaDefinition)
+ assembly.GetType($"Testing.{schemaTypeName}")!.GetMethod("BuildJsonSchema")!.Invoke(null, null)!;
+ }
+}
diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporalParity.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporalParity.cs
new file mode 100644
index 00000000..5bb7bde8
--- /dev/null
+++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporalParity.cs
@@ -0,0 +1,170 @@
+using ZodSharp.SourceGenerators.Infra;
+
+namespace ZodSharp.SourceGenerators;
+
+public partial class ZodSchemaGeneratorTests
+{
+ const string TemporalParityOutputRelativePath = "src/tests/cross-platform/temporal-parity";
+
+ ///
+ /// The default PreserveOffset model. The exported DateTimeOffset pattern is offset-aware, so the schema
+ /// is exact and carries no x-zodsharp-unsupported marker.
+ ///
+ const string TemporalParitySource = """
+ using System;
+ using ZodSharp.Rules;
+
+ namespace Testing
+ {
+ [ZodSchema]
+ public sealed class TemporalHolder
+ {
+ [NonSentinel]
+ public DateTimeOffset ObservedAt { get; set; }
+
+ [NonSentinel]
+ public DateTime Timestamp { get; set; }
+
+ [NonSentinel]
+ public DateOnly Day { get; set; }
+
+ [NonSentinel]
+ public TimeOnly Time { get; set; }
+
+ [NonSentinel]
+ public DateTimeOffset? Optional { get; set; }
+ }
+ }
+ """;
+
+ [Test]
+ public async Task JsonSchema_GivenGeneratedTemporalSchemas_WritesZodParityArtifacts(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange — the default PreserveOffset cases. The offset-aware pattern is exact, so the
+ // offset-equivalent bounds are rejected without a UTC converter or an unsupported marker.
+ using var driverResult = await GenerateAsync(
+ TemporalParitySource,
+ new ZodSourceGeneratorTestOptions().Compile(),
+ cancellationToken
+ );
+ var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull();
+ var outputDir = Path.Combine("..", "..", "..", "..", "..", "..", TemporalParityOutputRelativePath);
+
+ // Act & Assert
+ await WriteParityArtifactAsync(
+ assembly,
+ "TemporalHolder",
+ [
+ (
+ "valid-utc",
+ TemporalPayload(
+ "2024-01-02T03:04:05Z",
+ "2024-01-02T03:04:05Z",
+ "2024-01-02",
+ "12:34:56",
+ "2024-01-03T00:00:00+00:00"
+ ),
+ true
+ ),
+ (
+ "valid-offset",
+ TemporalPayload(
+ "2024-01-02T03:04:05+05:30",
+ "2024-01-02T03:04:05Z",
+ "2024-01-02",
+ "12:34:56.1234567",
+ null
+ ),
+ true
+ ),
+ (
+ "min-utc",
+ TemporalPayload(
+ "0001-01-01T00:00:00+00:00",
+ "2024-01-02T03:04:05Z",
+ "2024-01-02",
+ "12:34:56",
+ null
+ ),
+ false
+ ),
+ (
+ "max-utc",
+ TemporalPayload(
+ "9999-12-31T23:59:59.9999999Z",
+ "2024-01-02T03:04:05Z",
+ "2024-01-02",
+ "12:34:56",
+ null
+ ),
+ false
+ ),
+ (
+ "min-offset-equivalent",
+ TemporalPayload(
+ "0001-01-01T05:30:00+05:30",
+ "2024-01-02T03:04:05Z",
+ "2024-01-02",
+ "12:34:56",
+ null
+ ),
+ false
+ ),
+ (
+ "max-offset-equivalent",
+ TemporalPayload(
+ "9999-12-31T18:29:59.9999999-05:30",
+ "2024-01-02T03:04:05Z",
+ "2024-01-02",
+ "12:34:56",
+ null
+ ),
+ false
+ ),
+ (
+ "nullable-min",
+ TemporalPayload(
+ "2024-01-02T03:04:05Z",
+ "2024-01-02T03:04:05Z",
+ "2024-01-02",
+ "12:34:56",
+ "0001-01-01T00:00:00Z"
+ ),
+ false
+ ),
+ (
+ "dateonly-min",
+ TemporalPayload("2024-01-02T03:04:05Z", "2024-01-02T03:04:05Z", "0001-01-01", "12:34:56", null),
+ false
+ ),
+ (
+ "datetime-min",
+ TemporalPayload("2024-01-02T03:04:05Z", "0001-01-01T00:00:00Z", "2024-01-02", "12:34:56", null),
+ false
+ ),
+ (
+ "timeonly-min",
+ TemporalPayload("2024-01-02T03:04:05Z", "2024-01-02T03:04:05Z", "2024-01-02", "00:00:00", null),
+ false
+ ),
+ ],
+ outputDir,
+ cancellationToken
+ );
+ }
+
+ static string TemporalPayload(string observedAt, string timestamp, string day, string time, string? optional) =>
+ $$"""
+ {
+ "observedAt": "{{observedAt}}",
+ "timestamp": "{{timestamp}}",
+ "day": "{{day}}",
+ "time": "{{time}}",
+ "optional": {{(optional is null ? "null" : $"\"{optional}\"")}}
+ }
+ """;
+}
diff --git a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs
index 622184d8..7b2ef5e8 100644
--- a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs
+++ b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs
@@ -175,4 +175,70 @@ public async Task Parse_GivenAllOfPatternBranches_EnforcesEveryBranch(Cancellati
await Assert.That(schema.Validate("00000000-0000-0000-0000-000000000000").IsSuccess).IsFalse();
await Assert.That(schema.Validate("not-a-guid").IsSuccess).IsFalse();
}
+
+ [Test]
+ public async Task JsonSchemaSerializerOptions_GivenTemporalPattern_WritesPatternAndUnsupportedKeyword()
+ {
+ // Arrange
+ JsonSchemaDefinition definition = new()
+ {
+ Type = "string",
+ Format = "date-time",
+ Pattern = NonSentinelRule.DateTimeOffsetJsonSchemaPattern,
+ UnsupportedRules = [nameof(NonSentinelRule<>)],
+ };
+
+ // Act
+ var json = System.Text.Json.JsonSerializer.Serialize(definition, JsonSchemaSerializerOptions.Default);
+
+ // Assert
+ await Assert.That(json).Contains("\"pattern\"");
+ await Assert.That(json).Contains("\"x-zodsharp-unsupported\"");
+ await Assert.That(json).Contains(nameof(NonSentinelRule<>));
+ }
+
+ [Test]
+ public async Task Parse_GivenDefaultDateTimeOffsetPattern_RejectsOffsetEquivalentBounds(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ JsonSchemaDefinition definition = new()
+ {
+ Type = "string",
+ Pattern = NonSentinelRule.DateTimeOffsetJsonSchemaPattern,
+ };
+
+ // Act
+ var schema = FromJsonSchemaParser.Parse(definition);
+
+ // Assert
+ await Assert.That(schema.Validate("2024-01-02T03:04:05+05:30").IsSuccess).IsTrue();
+ await Assert.That(schema.Validate("0001-01-01T05:30:00+05:30").IsSuccess).IsFalse();
+ await Assert.That(schema.Validate("9999-12-31T18:29:59.9999999-05:30").IsSuccess).IsFalse();
+ }
+
+ [Test]
+ public async Task Parse_GivenUtcTemporalPattern_RejectsBoundsAndNonUtcOffsets(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ JsonSchemaDefinition definition = new()
+ {
+ Type = "string",
+ Pattern = NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern,
+ };
+
+ // Act
+ var schema = FromJsonSchemaParser.Parse(definition);
+
+ // Assert
+ await Assert.That(schema.Validate("2024-01-02T03:04:05Z").IsSuccess).IsTrue();
+ await Assert.That(schema.Validate("0001-01-01T00:00:00Z").IsSuccess).IsFalse();
+ await Assert.That(schema.Validate("0001-01-01T05:30:00+05:30").IsSuccess).IsFalse();
+ await Assert.That(schema.Validate("2024-01-02T03:04:05+05:30").IsSuccess).IsFalse();
+ }
}
diff --git a/src/tests/SystemTextJson.UnitTests/Json/Serialization/SystemTextJson/DateTimeOffsetConverterTests.cs b/src/tests/SystemTextJson.UnitTests/Json/Serialization/SystemTextJson/DateTimeOffsetConverterTests.cs
new file mode 100644
index 00000000..ddeabea3
--- /dev/null
+++ b/src/tests/SystemTextJson.UnitTests/Json/Serialization/SystemTextJson/DateTimeOffsetConverterTests.cs
@@ -0,0 +1,81 @@
+using System.Text.Json;
+
+namespace ZodSharp.Json.Serialization.SystemTextJson;
+
+public class DateTimeOffsetConverterTests
+{
+ static readonly DateTimeOffset OffsetValue = new(2024, 1, 2, 3, 4, 5, TimeSpan.FromHours(5.5));
+
+ static JsonSerializerOptions UtcOptions() => new() { Converters = { new UtcDateTimeOffsetConverter() } };
+
+ static JsonSerializerOptions PreserveOptions() =>
+ new() { Converters = { new PreserveOffsetDateTimeOffsetConverter() } };
+
+ [Test]
+ public async Task UtcConverter_GivenNonZeroOffset_WritesUtc()
+ {
+ // Act
+ var json = JsonSerializer.Serialize(OffsetValue, UtcOptions());
+
+ // Assert
+ await Assert.That(json).IsEqualTo("\"2024-01-01T21:34:05+00:00\"");
+ }
+
+ [Test]
+ public async Task UtcConverter_GivenUtcInput_ReadsWithZeroOffset()
+ {
+ // Act
+ var value = JsonSerializer.Deserialize("\"2024-01-02T03:04:05Z\"", UtcOptions());
+
+ // Assert
+ await Assert.That(value.Offset).IsEqualTo(TimeSpan.Zero);
+ await Assert.That(value).IsEqualTo(new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero));
+ }
+
+ [Test]
+ public async Task UtcConverter_GivenNonUtcInput_Throws()
+ {
+ // Act
+ var exception = Assert.Throws(() =>
+ JsonSerializer.Deserialize("\"2024-01-02T03:04:05+05:30\"", UtcOptions())
+ );
+
+ // Assert
+ await Assert.That(exception.Message).Contains("UTC");
+ }
+
+ [Test]
+ public async Task UtcConverter_GivenNoOffsetDesignator_Throws()
+ {
+ // Act — an offset of zero is not enough: the JSON Schema requires an explicit Z/zero-offset suffix.
+ var exception = Assert.Throws(() =>
+ JsonSerializer.Deserialize("\"2024-01-02T03:04:05\"", UtcOptions())
+ );
+
+ // Assert
+ await Assert.That(exception.Message).Contains("UTC designator");
+ }
+
+ [Test]
+ public async Task UtcConverter_GivenNullableNull_ReturnsNull()
+ {
+ // Act
+ var value = JsonSerializer.Deserialize("null", UtcOptions());
+
+ // Assert
+ await Assert.That(value).IsNull();
+ }
+
+ [Test]
+ public async Task PreserveOffsetConverter_GivenNonZeroOffset_RoundTripsTheOffset()
+ {
+ // Act
+ var json = JsonSerializer.Serialize(OffsetValue, PreserveOptions());
+ var value = JsonSerializer.Deserialize(json, PreserveOptions());
+
+ // Assert
+ await Assert.That(json).IsEqualTo("\"2024-01-02T03:04:05+05:30\"");
+ await Assert.That(value.Offset).IsEqualTo(TimeSpan.FromHours(5.5));
+ await Assert.That(value).IsEqualTo(OffsetValue);
+ }
+}
diff --git a/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaTemporalSentinelTests.cs b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaTemporalSentinelTests.cs
new file mode 100644
index 00000000..00c65e24
--- /dev/null
+++ b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaTemporalSentinelTests.cs
@@ -0,0 +1,303 @@
+using System.Text.RegularExpressions;
+using ZodSharp.Rules;
+
+namespace ZodSharp.JsonSchema;
+
+///
+/// Coverage for the temporal JSON Schema mappings contributed by .
+///
+///
+/// The DateTimeOffset case is deliberately split in two: preserving offsets cannot express the instant-based
+/// bounds, so the rule reports itself as unsupported and only rejects the canonical zero-offset forms; a UTC
+/// contract is exact because every accepted string denotes one instant.
+///
+public class ToJsonSchemaTemporalSentinelTests
+{
+ [Test]
+ public async Task ApplyTo_GivenDateOnly_ExportsPatternThatRejectsBothBounds(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ JsonSchemaDefinition schema = new() { Type = "string", Format = "date" };
+
+ // Act
+ JsonSchemaRules.Apply(schema, new NonSentinelRule());
+
+ // Assert
+ await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateOnlyJsonSchemaPattern);
+ await Assert.That(schema.UnsupportedRules).IsNull();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-01")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "9999-12-31")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-02")).IsTrue();
+ await Assert.That(Matches(schema.Pattern!, "9999-12-30")).IsTrue();
+ await Assert.That(Matches(schema.Pattern!, "2024-01-02")).IsTrue();
+ }
+
+ [Test]
+ public async Task ApplyTo_GivenTimeOnly_ExportsPatternThatRejectsBothBounds(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ JsonSchemaDefinition schema = new() { Type = "string", Format = "time" };
+
+ // Act
+ JsonSchemaRules.Apply(schema, new NonSentinelRule());
+
+ // Assert — System.Text.Json writes "00:00:00" and "23:59:59.9999999".
+ await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.TimeOnlyJsonSchemaPattern);
+ await Assert.That(schema.UnsupportedRules).IsNull();
+ await Assert.That(Matches(schema.Pattern!, "00:00")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "00:00:00")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "00:00:00.000")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "23:59:59.9999999")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "00:00:00.0000001")).IsTrue();
+ await Assert.That(Matches(schema.Pattern!, "23:59:59.999999")).IsTrue();
+ await Assert.That(Matches(schema.Pattern!, "12:34:56")).IsTrue();
+ }
+
+ [Test]
+ public async Task TimeOnlyPattern_GivenLocalWireForm_AcceptsValuesAndRejectsOffsets(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Act & Assert — System.Text.Json never writes an offset for TimeOnly.
+ await Assert.That(Matches(JsonSchemaRules.TimeOnlyPattern, "00:00:00")).IsTrue();
+ await Assert.That(Matches(JsonSchemaRules.TimeOnlyPattern, "12:34:56")).IsTrue();
+ await Assert.That(Matches(JsonSchemaRules.TimeOnlyPattern, "23:59:59.9999999")).IsTrue();
+ await Assert.That(Matches(JsonSchemaRules.TimeOnlyPattern, "12:34:56Z")).IsFalse();
+ await Assert.That(Matches(JsonSchemaRules.TimeOnlyPattern, "24:00:00")).IsFalse();
+ await Assert.That(Matches(JsonSchemaRules.TimeOnlyPattern, "12:60:00")).IsFalse();
+ }
+
+ [Test]
+ public async Task ApplyTo_GivenDateTime_ExportsPatternThatRejectsDeterministicBounds(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ JsonSchemaDefinition schema = new() { Type = "string", Format = "date-time" };
+
+ // Act
+ JsonSchemaRules.Apply(schema, new NonSentinelRule());
+
+ // Assert — the no-offset (Unspecified) and Z/zero-offset (Utc) wire forms are the deterministic ones.
+ await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateTimeJsonSchemaPattern);
+ await Assert.That(schema.UnsupportedRules).IsNull();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00Z")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00.000+00:00")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "9999-12-31T23:59:59.9999999")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "9999-12-31T23:59:59.9999999Z")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00.0000001Z")).IsTrue();
+ await Assert.That(Matches(schema.Pattern!, "2024-01-02T03:04:05Z")).IsTrue();
+ }
+
+ [Test]
+ public async Task DateTimePattern_GivenNonZeroOffset_LeavesTheReceiverShiftedValueUnconstrained(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange — System.Text.Json converts an offset-bearing DateTime into the receiver's local time
+ // zone, so the string no longer denotes the sentinel ticks.
+ var pattern = NonSentinelRule.DateTimeJsonSchemaPattern;
+
+ // Assert
+ await Assert.That(Matches(pattern, "0001-01-01T00:00:00-05:30")).IsTrue();
+ await Assert.That(Matches(pattern, "9999-12-31T23:59:59.9999999+05:30")).IsTrue();
+ await Assert.That(Matches(pattern, "0001-01-01T00:00:00Z")).IsFalse();
+ }
+
+ [Test]
+ public async Task ApplyTo_GivenDateTimeOffsetWithPreservedOffsets_RejectsEveryOffsetEquivalentBound(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ JsonSchemaDefinition schema = new() { Type = "string", Format = "date-time" };
+
+ // Act
+ JsonSchemaRules.Apply(schema, new NonSentinelRule());
+
+ // Assert — the default mapping is exact and carries no unsupported marker.
+ await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetJsonSchemaPattern);
+ await Assert.That(schema.UnsupportedRules).IsNull();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00+00:00")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "9999-12-31T23:59:59.9999999Z")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-01T05:30:00+05:30")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-01T14:00:00+14:00")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "9999-12-31T18:29:59.9999999-05:30")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "9999-12-31T09:59:59.9999999-14:00")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-01T05:30:00.0000001+05:30")).IsTrue();
+ await Assert.That(Matches(schema.Pattern!, "9999-12-31T18:29:59.9999998-05:30")).IsTrue();
+ await Assert.That(Matches(schema.Pattern!, "2024-01-02T03:04:05+05:30")).IsTrue();
+ }
+
+ [Test]
+ public async Task DateTimeOffsetPattern_GivenEveryOffset_RejectsEachBoundAndAcceptsTheAdjacentTick(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ var pattern = NonSentinelRule.DateTimeOffsetJsonSchemaPattern;
+ var rejectedBounds = 0;
+ var acceptedAdjacentValues = 0;
+
+ // Act
+ for (var offsetHour = 0; offsetHour <= 14; offsetHour++)
+ {
+ for (var offsetMinute = 0; offsetMinute <= 59; offsetMinute++)
+ {
+ var minOffset = $"+{offsetHour:00}:{offsetMinute:00}";
+ var minBound = $"0001-01-01T{offsetHour:00}:{offsetMinute:00}:00{minOffset}";
+ var minAdjacent = $"0001-01-01T{offsetHour:00}:{offsetMinute:00}:00.0000001{minOffset}";
+ if (!Matches(pattern, minBound))
+ rejectedBounds++;
+ if (Matches(pattern, minAdjacent))
+ acceptedAdjacentValues++;
+
+ var localHour = 23 - offsetHour;
+ var localMinute = 59 - offsetMinute;
+ var maxOffset = offsetHour == 0 && offsetMinute == 0 ? "+00:00" : $"-{offsetHour:00}:{offsetMinute:00}";
+ var maxBound = $"9999-12-31T{localHour:00}:{localMinute:00}:59.9999999{maxOffset}";
+ var maxAdjacent = $"9999-12-31T{localHour:00}:{localMinute:00}:59.9999998{maxOffset}";
+ if (!Matches(pattern, maxBound))
+ rejectedBounds++;
+ if (Matches(pattern, maxAdjacent))
+ acceptedAdjacentValues++;
+ }
+ }
+
+ // Assert
+ await Assert.That(rejectedBounds).IsEqualTo(1800);
+ await Assert.That(acceptedAdjacentValues).IsEqualTo(1800);
+ }
+
+ [Test]
+ public async Task ApplyTo_GivenDateTimeOffsetWithUtcContract_IsExactAndHasNoMarker(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ JsonSchemaDefinition schema = new() { Type = "string", Format = "date-time" };
+
+ // Act
+ JsonSchemaRules.Apply(schema, new NonSentinelRule(), DateTimeOffsetSerialization.Utc);
+
+ // Assert — every accepted string has a zero offset, so both offset-equivalent bounds are rejected.
+ await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern);
+ await Assert.That(schema.UnsupportedRules).IsNull();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00Z")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-01T00:00:00+00:00")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "0001-01-01T05:30:00+05:30")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "9999-12-31T23:59:59.9999999Z")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "9999-12-31T18:29:59.9999999-05:30")).IsFalse();
+ await Assert.That(Matches(schema.Pattern!, "2024-01-02T03:04:05Z")).IsTrue();
+ await Assert.That(Matches(schema.Pattern!, "2024-01-02T03:04:05.123+00:00")).IsTrue();
+ await Assert.That(Matches(schema.Pattern!, "2024-01-02T03:04:05+05:30")).IsFalse();
+ }
+
+ [Test]
+ public async Task ApplyTo_GivenTypeWithoutKnownSentinel_AddsNoConstraintAndNoMarker(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ JsonSchemaDefinition schema = new() { Type = "integer" };
+
+ // Act
+ JsonSchemaRules.Apply(schema, new NonSentinelRule());
+
+ // Assert — IsValid always passes for an unknown type, so an unconstrained schema is complete.
+ await Assert.That(schema.Pattern).IsNull();
+ await Assert.That(schema.UnsupportedRules).IsNull();
+ }
+
+ [Test]
+ public async Task DateTimeOffsetSchema_GivenSentinelAndOrdinaryValues_ValidatesByInstant(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Arrange
+ var schema = Z.DateTimeOffset().NonSentinel();
+
+ // Act
+ var min = schema.Validate(DateTimeOffset.MinValue);
+ var offsetEquivalent = schema.Validate(new DateTimeOffset(1, 1, 1, 5, 30, 0, TimeSpan.FromHours(5.5)));
+ var ordinary = schema.Validate(new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.FromHours(5.5)));
+
+ // Assert
+ await Assert.That(min.IsSuccess).IsFalse();
+ await Assert.That(offsetEquivalent.IsSuccess).IsFalse();
+ await Assert.That(ordinary.IsSuccess).IsTrue();
+ }
+
+ [Test]
+ public async Task ToJsonSchema_GivenManualDateTimeOffsetWithUtcOption_UsesTheExactUtcPattern(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Act
+ var schema = Z.ToJsonSchema(
+ Z.DateTimeOffset().NonSentinel(),
+ new ToJsonSchemaOptions { DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc }
+ );
+
+ // Assert
+ await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetUtcJsonSchemaPattern);
+ await Assert.That(schema.UnsupportedRules).IsNull();
+ }
+
+ [Test]
+ public async Task ToJsonSchema_GivenManualDateTimeOffsetWithDefaultOption_UsesTheExactOffsetAwarePattern(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Act
+ var schema = Z.ToJsonSchema(Z.DateTimeOffset().NonSentinel());
+
+ // Assert
+ await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateTimeOffsetJsonSchemaPattern);
+ await Assert.That(schema.UnsupportedRules).IsNull();
+ }
+
+ [Test]
+ public async Task ToJsonSchema_GivenFluentDateWithNonSentinel_ExportsDateTimeFormatAndPattern(
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Act
+ var schema = Z.ToJsonSchema(Z.Date().NonSentinel());
+
+ // Assert
+ await Assert.That(schema.Type).IsEqualTo("string");
+ await Assert.That(schema.Format).IsEqualTo("date-time");
+ await Assert.That(schema.Pattern).IsEqualTo(NonSentinelRule.DateTimeJsonSchemaPattern);
+ await Assert.That(schema.UnsupportedRules).IsNull();
+ }
+
+ static bool Matches(string pattern, string value) => Regex.IsMatch(value, pattern);
+}
diff --git a/tests/ts/temporal-parity.test.ts b/tests/ts/temporal-parity.test.ts
new file mode 100644
index 00000000..3d73f7cd
--- /dev/null
+++ b/tests/ts/temporal-parity.test.ts
@@ -0,0 +1,65 @@
+/**
+ * Temporal cross-platform parity: C# (Purview.ZodSharp) -> Zod.
+ *
+ * The C# producer compiles a `[ZodSchema]` model with the default PreserveOffset contract and writes each
+ * exported schema with the outcome of the C# validator pipeline. This suite imports the schema with Zod's
+ * `z.fromJSONSchema()` and checks that both sides agree, including the offset-equivalent bounds
+ * (`0001-01-01T05:30:00+05:30` and `9999-12-31T18:29:59.9999999-05:30`) and the local `TimeOnly` wire form.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync, readdirSync } from "node:fs";
+import { resolve } from "node:path";
+import { z } from "zod";
+
+const parityDir = resolve("src", "tests", "cross-platform", "temporal-parity");
+
+interface ParityCase {
+ name: string;
+ value: string;
+ valid: boolean;
+}
+
+interface ParityArtifact {
+ name: string;
+ schema: unknown;
+ cases: ParityCase[];
+}
+
+let artifacts: ParityArtifact[] = [];
+try {
+ artifacts = readdirSync(parityDir)
+ .filter((file) => file.endsWith(".json"))
+ .map((file) => JSON.parse(readFileSync(resolve(parityDir, file), "utf-8")) as ParityArtifact);
+} catch {
+ // The directory does not exist until the C# tests run. Handled below.
+}
+
+describe("Temporal export matches C# validation under z.fromJSONSchema", () => {
+ if (artifacts.length === 0) {
+ // Do NOT pass silently: an empty directory means this suite verified nothing.
+ it("has C# parity artifacts to validate", () => {
+ expect.fail(
+ `No parity artifacts found in ${parityDir}. ` +
+ "Run the C# tests first (`just test`) so they write their output, then re-run `bun run test`.",
+ );
+ });
+ }
+
+ for (const artifact of artifacts) {
+ // The UTC contract is exact, so the schema must not carry the incomplete-rule marker.
+ it(`${artifact.name} has no x-zodsharp-unsupported marker`, () => {
+ expect(JSON.stringify(artifact.schema)).not.toContain("x-zodsharp-unsupported");
+ });
+
+ const zodSchema = z.fromJSONSchema(artifact.schema as never);
+
+ for (const testCase of artifact.cases) {
+ it(`${artifact.name} / ${testCase.name} — matches C#`, () => {
+ const result = zodSchema.safeParse(JSON.parse(testCase.value));
+ expect(result.success, `${artifact.name}/${testCase.name} (${testCase.value})`).toBe(
+ testCase.valid,
+ );
+ });
+ }
+ }
+});