Skip to content

JSON Schema support for .NET prototype - #113

Draft
chrsmith wants to merge 16 commits into
mainfrom
chrsmith/dotnet-bug-bash
Draft

JSON Schema support for .NET prototype#113
chrsmith wants to merge 16 commits into
mainfrom
chrsmith/dotnet-bug-bash

Conversation

@chrsmith

Copy link
Copy Markdown
Contributor

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-gen bug bash caller I created, using a schema provided by someone else on the team.

chrsmith and others added 16 commits August 4, 2026 13:00
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant