JSON Schema support for .NET prototype - #113
Draft
chrsmith wants to merge 16 commits into
Draft
Conversation
The .NET example test suites have never been compiled by CI, so they drifted out of sync with the generated output they exercise. They currently fail to build with four errors. Both breakages come from #73, which updated the Go tests alongside the generated output but left .NET's behind: - `request-id` is marked `@nexus.omit` in workflow-service.wit, so #73 correctly stopped emitting `RequestId`. Drop the stale initializer and assertion. - #73 folded `workflow` into `StartWorkflowOptions` and removed the generic `StartWorkflowAsync<TWorkflow, TResult>(Expression<...>, options)` overload. Update the compile check to the current single-argument API. No generated code changes; these are test-side fixes only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI installs Python, Node, Go and Java toolchains and runs each of their sample suites, but had no .NET step at all. As a result neither `samples/dotnet/tests/` nor `advanced/samples/dotnet/tests/` was ever compiled, which is how the latter came to be broken (repaired in the previous commit). Add an explicit `setup-dotnet` step and run both suites. The SDK pin also removes a hidden dependency: `tests/generate_dotnet.rs` already shells out to `dotnet build`, and until now relied on whatever SDK the runner happened to preinstall. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
samples/dotnet/README.md told readers to regenerate with `cargo build-json-examples --lang dotnet`, which fails outright — `build_json_examples` rejects Dotnet. Replace it with the generate invocations that actually reproduce the checked-in output byte-for-byte. More importantly, the page described these models as if they carried the same guarantees as the other targets. They do not: constraint keywords are parsed and planned, then dropped with no enforcement and no diagnostic. Add a Known gaps table and a warning, and note why the suites pass anyway (the wire fixtures hold only valid payloads, so nothing here tests rejection). Also correct the mode description — definitions mode does emit the NexusRpc service interface, so "no service/endpoint scaffolding" was wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The .NET JSON-Schema backend emits no constraint validator. Assertion keywords
survive parsing and planning, then vanish at render time with no output and no
diagnostic, so `{"blockId":"b","order":-5}` is rejected by Go, Java, Python and
TypeScript and accepted by the generated C#. PRINCIPLES.md says the generator
should reject loudly rather than emit something subtly wrong; today it does
neither.
Add `json_schema::dotnet_coverage`, which walks the planned schemas and reports
each unenforced construct as a generation warning naming the keyword and the
members carrying it. Warnings are emitted in both generation modes, unlike the
existing stub-binding warnings — the JSON-Schema samples are generated in
definitions mode, which is exactly where the missing validator matters.
The classifier is deliberately narrow. `maxProperties`, `const`, `default`,
open/closed objects and typed maps are honored by the backend and are not
reported, and `oneOf` is only reported when it is a real sum type — the
`[<branch>, {"type": "null"}]` nullable spelling lowers correctly. `chat`
consequently generates clean, which a test pins.
This is a stepping stone, not the fix. `showcase` currently reports 20 distinct
gaps; a test pins that exact set so it can only shrink deliberately, and each
keyword is removed from the list as its enforcement lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other JSON-Schema target emits a schema-independent runtime alongside its
models — definitions.go, definitions.ts, _definitions.py, and Java's
ValidationException/Violation/SpecNumbers classes. .NET emitted none, so there
was no way to report more than one constraint failure at a time.
Add `json_schema::dotnet_definitions`, emitting `Definitions.cs` once per package
with `Violation { Path, Reason }` and an aggregating
`ValidationException : JsonException` over `IReadOnlyList<Violation>`. Deriving
from JsonException rather than AggregateException keeps handlers that already
catch System.Text.Json failures working, and lets a Nexus handler map the family
to BAD_REQUEST. The message format matches Go's `ValidationError.Error()`
verbatim, so one payload reads the same across targets.
The runtime is declared in the longest namespace prefix common to every emitted
model namespace: the input's own namespace for a single-input package
(`NexGen.ChatService` for chat), the shared root for a multi-input one
(`NexGen.Generated` for kb, whose leaves span `NexGen.Generated.Kb` and
`NexGen.Generated.Content.Block`). C# resolves that implicitly from any
descendant namespace.
Emission is gated on the tree actually having JSON-Schema models, so WIT/proto
output — which carries its own support files and no validator — is byte-identical
and its snapshots are untouched.
No generated model consumes the runtime yet; wiring the per-class helpers into it
is the next commit. Document the .NET rows in generated-file-layout.md, and cover
the P11 aggregation contract in samples/dotnet/tests/SharedRuntimeChecks.cs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every generated model carried its own private copy of ReadOptionalValue, ReadJsonValue, ReadJsonInteger and RejectNull — roughly 90 lines duplicated per class, which is most of the bulk in a generated Models.cs and four places to fix any one bug. Hoist them into an internal `JsonRuntime` static class in Definitions.cs and call through it. Net effect on the checked-in samples is 656 lines deleted against 260 added, with the emitted API surface unchanged: same properties, same attributes, same exceptions, and the wire fixtures round-trip identically. `ReadOptionalValue` was an instance method reading `AdditionalProperties`, so it now takes the bag as its first argument. The 2^53-1 cap moves to a named `MaxSafeInteger` constant instead of being re-declared inline per class. Models resolve `JsonRuntime` implicitly whenever the runtime sits in their own or an enclosing namespace, which covers both sample shapes. For the case where divergent `@nexus.namespace` overrides leave it somewhere unrelated, the runtime namespace is threaded through `generate_leaf` so Models.cs can emit an explicit `using`. WIT/proto output gets `None` and is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First slice of the .NET constraint validator. `minimum`, `maximum`,
`exclusiveMinimum`, `exclusiveMaximum` and `multipleOf` were parsed, planned, and
then dropped; `{"blockId":"b","order":-5}` deserialized cleanly in C# while Go,
Java, Python and TypeScript all rejected it.
Generated models now carry two entry points, so the contract holds in both wire
directions:
- `IJsonOnDeserialized.OnDeserialized` calls `Validate()`, so an inbound payload
cannot enter the process in a forbidden shape.
- `Validate()` is public, so the service binding can check an outbound value built
in code.
Both funnel through `CollectViolations(List<Violation>, string path)`, which takes
a path prefix so a nested model will report `page.blocks.order` rather than a bare
`order` once nesting lands. Violations aggregate into one ValidationException
rather than throwing on the first.
Reason strings match Go's wording exactly — `must be >= 0, got -5`,
`must be > 0, got 0`, `must be a multiple of 3, got 4` — verified against
samples/go/showcase/showcase.go, so one payload produces one diagnostic across
targets. Numbers render through `JsonRuntime.FormatNumber` under
CultureInfo.InvariantCulture so a locale's decimal separator can never leak into a
message. Bounds are held as serde_json::Number so an integral bound emits without
a spurious `.0`.
The `allOf`-merged interval on showcase's `Widget.size` emits as both bounds,
confirming the loader flattens allOf before the backend sees it.
Coverage warnings drop from 20 keywords to 16; the pinned test in
tests/generate_dotnet.rs is updated to match, which is what flagged the two
dotnet_coverage unit tests that had been asserting `minimum` still warns.
Remaining divergence, recorded in the README rather than fixed here: Go reports an
out-of-range integer as an aggregated Violation reading `exceeds ±(2^53-1) integer
cap`, .NET as a non-aggregated JsonException with no path. That belongs with the
spec-number helpers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds minLength, maxLength and pattern, generalizing the constraint machinery from
numeric-only to a per-member set of numeric bounds, length bounds and an optional
compiled pattern. Coverage warnings drop from 16 keywords to 13.
Two places where the obvious C# spelling would have been quietly wrong:
- **`$` → `\z`.** .NET's Regex treats `$` as "end of input, *or* immediately
before a final newline", so `^[A-Z]{2,4}$` matches "ABCD\n" — a value the
contract forbids and Go rejects. The loader already exposes
`pattern::rewrite_end_anchor` for exactly this; Python uses `\Z` and Java `\z`,
and .NET needs the same treatment. Go and JS keep `$`.
- **Length counts code points.** `string.Length` counts UTF-16 code units, so 12
astral characters would score 24 and be rejected against `maxLength: 12`.
`JsonRuntime.CodePointCount` matches Go's utf8.RuneCountInString and Java's
codePointCount, including counting an unpaired surrogate as one.
Both are covered by tests that fail if the naive spelling is restored.
Patterns compile once into a `private static readonly Regex` per member.
RegexOptions.CultureInvariant keeps character classes off the ambient locale;
backtracking safety continues to come from the loader's RE2 gate, which rejects
lookaround and backreferences outright, so this matches Java's plain
Pattern.compile rather than opting into NonBacktracking and pinning the generated
code to net7.0+.
Adds `showcase` as a .NET sample. Neither chat nor kb declares a single string
constraint, so without it this work would have had no runtime coverage at all —
which is much of why the .NET backend drifted in the first place.
`pattern` reason text follows Java, which shares the `\z` rewrite and so reports
the rewritten expression. Go quotes via `%q` and keeps `$`, so those two already
differ; matching Java is the closest parity available. Recorded in the README.
The dotnet_coverage unit tests now assert against `contentMediaType` /
`dependentSchemas` — real gap entries that no planned phase implements — so they
stop needing an edit every time a keyword gains enforcement. The churn belongs in
tests/generate_dotnet.rs, which exists to track the current set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds minItems, maxItems, uniqueItems and the contains/minContains/maxContains occurrence window. Coverage warnings drop from 13 keywords to 7. Semantics follow Go element-for-element: - `uniqueItems` reports one violation per *later* occurrence, each against the index where the value was first seen — so ["a","a","a"] yields two violations (1→0 and 2→0), not one per pair. A test pins that. - `contains` counts only matching elements, so minContains/maxContains bound the match count rather than the array length. - `contains` with no `minContains` means "at least one", per the spec default. `contains` is lowered only for a bare `const` branch. Matching an arbitrary subschema per element would require the validator to be reentrant over element values, which is a larger change than this slice; `const` is what the corpus uses and all Go emits. Rather than let the unsupported shape pass silently, the coverage classifier is now shape-aware: a non-`const` `contains` still warns, and minContains/maxContains warn with it, reported as "enforced only for a `const` branch, and this one is not". Same treatment as the `oneOf` nullable-wrapper check. `constraint_clr_type` now returns String and resolves an array member to its `IReadOnlyList<T>` shape so the null-guard pattern match binds against the stored value. Also worth noting for anyone reproducing the sample regeneration: `set -- $a` inside a `for` loop does not word-split in zsh, so a loop of that shape silently passes an empty `--output`. The regeneration commands in the README are individual invocations for that reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds minProperties, dependentRequired and propertyNames, and moves maxProperties
onto the same footing. Coverage warnings drop from 7 keywords to 4 — only
`oneOf`, `enum`, `format` and `contentEncoding` remain.
These differ from every constraint so far by being checked against the **wire
member set** rather than a single member's value, so they render at the top level
of CollectViolations rather than inside a member guard, and their violations carry
the containing path with no member segment appended (matching Go, which reports
them with an empty path).
Recovering "how many members did the payload carry" needed care. Go reads it off
the raw map at unmarshal time and consequently leaves ContactGo.Validate() empty —
the count is not recoverable from its parsed struct. The .NET shape does recover it
exactly: `[JsonRequired]` guarantees every required property was present, and every
optional or unknown member lands in the extension bag, so the count is
`<required count> + AdditionalProperties.Count`. That makes minProperties and
maxProperties hold on the serialize side too, which Go's arrangement cannot do.
maxProperties previously threw a bare
`JsonException("maxProperties: at most 50 entries")` from OnDeserialized. It now
aggregates with everything else and reads `must have at most 50 properties, got
51`, matching Go. Behavior change covered by a test.
Two keywords are lowered only for the shapes the corpus exercises, with the
coverage classifier made shape-aware rather than letting the rest pass silently:
- `propertyNames` applies to map-shaped objects, whose bag holds every member. On
an object with declared properties the keyword also governs those declared
names, which the bag does not carry.
- Its reason interpolates the offending key — `invalid property name "x": must have
length <= 8, got 12` — duplicating what the path already says, because that is
what keeps the text identical to Go's `%q` form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the last diagnostic divergence in the covered constraint set. Go reports
an out-of-range integer as `Violation{"priority", "exceeds ±(2^53-1) integer
cap"}`; .NET threw a bare `JsonException("expected integer")` from the read path,
with no member path and no aggregation.
The cap is a contract constraint, not a parse error, so it moves into the
validator. `ReadJsonInteger` no longer rejects values past 2^53-1 — it reads them
exactly via TryGetInt64 and lets CollectViolations report them. Reading through
double, as before, would have rounded the value away before the validator saw it.
Two subtleties the samples caught:
- `1.0` is a valid integer per JSON Schema, and TryGetInt64 rejects the
decimal-point spelling. A guarded double fallback restores it, bounded to the
range where the conversion is exact. The pre-existing
IntegerFieldsFollowJsonSchemaNumberSemantics test covers this.
- A non-integral or non-numeric value stays a plain JsonException. It is a type
error, not a constraint violation.
The cap now keys off the resolved CLR type rather than a second, independently
computed type predicate. Two predicates that could disagree had emitted
`if (IdOrName is string idOrNameValue) { if (idOrNameValue < -IntegerCap ...` for
showcase's `oneOf: [string, integer]` member — comparing a string to a long, which
would not have compiled. A genuine sum type is no longer capped at the container
level; Go puts that cap on the integer branch type, which arrives with oneOf
support.
Members with a closed value set (`const`, `enum`) carry no cap check, since
membership already bounds them — the same members Go skips. The cap-checked set is
now exactly Go's eight: count, fontSize, level, priority, retries, size, step, zip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds `enum` membership checking over the three scalar shapes showcase declares: string (`status`), integer (`tier`) and number (`scale`). Coverage warnings drop to 3 — only `oneOf`, `format` and `contentEncoding` remain. Membership is validated rather than modeled as a C# `enum` type. The member keeps its wire type and a value outside the set becomes a ValidationException. A real C# enum would read better at the call site, but it does not survive contact with the corpus: `scale` is `enum: [1.5, 2.5]`, and C# enums cannot have floating-point members. Wire values are also not constrained to be valid C# identifiers, and .NET has no `x-dotnet-enum-names` escape hatch because it is not part of the P15 identifier subset. Noted in the README as an ergonomics follow-up rather than smuggled in here. Reason text matches Go exactly, including the quoting split: a string value is quoted like Go's `%q` (`got "retired"`) while numbers are bare like `%v` (`got 4`). The admitted set renders as Go prints it — quoted strings, bare numbers, comma-separated with no spaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
showcase's `Shape` — a closed `Circle | Square` sum type tagged by a shared required `kind` const — generated as a class with **no members at all**. Both branches were dropped and any payload round-tripped as an empty object. This was the worst of the silently-wrong output the .NET backend produced. It now lowers to an abstract base class with a private-protected constructor (closing the hierarchy the way `oneOf` means it), the branch classes deriving from it, and a JsonConverter that reads the tag and routes. Validate() and CollectViolations are abstract on the base and overridden by each branch — emitted even for a branch with no constraints of its own — so a caller holding a `Shape` can validate it without knowing which branch it is. The converter is hand-rolled rather than `[JsonPolymorphic]`. System.Text.Json's built-in discriminator is a metadata property distinct from the model's own members; here the tag *is* a declared member, and the two mechanisms collide. All four of Go's diagnostics are reproduced verbatim, including that the expected tag list uses a comma-space separator here while the `enum` list uses none. A branch shared between two unions cannot be modeled with single inheritance, so that case leaves every union unlowered rather than emitting code that will not compile. The coverage classifier now resolves `$ref` branches instead of pattern-matching on shape. That turned out to be necessary, not fastidious: the loader rewrites showcase's inline `oneOf: [string, integer]` into `$ref`s to synthesized types, so it reaches the classifier looking structurally identical to `Circle | Square`. An "all branches are $ref" test silently stopped reporting it — under-reporting a real gap, which is the exact failure this module exists to prevent. Following the refs separates the union that is lowered from the two that are not (scalar branches, or objects with no shared discriminator); both are pinned by tests. Disjoint-kind scalar unions still degrade to `object` and remain a reported gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is unreviewed GenAI slop, and not intended to be merged as-is. It's just a dump of what Claude came up with when given the task to support .NET via the JSON Schema frontend.
The last few commits were the
nex-genbug bash caller I created, using a schema provided by someone else on the team.