feat: find and fix every schema that degrades to serde_json::Value (#62, #65) - #67
Merged
Conversation
`serde_json::Value` in generated code means one of two things: the schema
declared an unconstrained value, or the generator dropped type information the
schema carried. Only the second is a defect, and nothing distinguished them —
across the corpus there are 13k untyped positions and no way to rank them.
Carry the reason in the IR. `SchemaType::Untyped { shape, reason }` replaces
the stringly-typed `Primitive { rust_type: "serde_json::Value" }` fallbacks, so
the census is derived from the types that get generated rather than recorded as
analysis runs: a schema referenced by fifty properties counts fifty times, and
a pruned one counts zero. Recording it on the side got both of those wrong, by
47% corpus-wide.
`--report-untyped` groups a spec's untyped fields by reason and marks each
faithful or recoverable; `--json` emits them with paths for corpus tooling.
A normalization pass converts any fallback that still builds its type from a
TypeMapper string, reporting it as `Unclassified` — a visible gap in the
taxonomy rather than a missing count. The corpus currently has none.
Corpus-wide, of 10,619 findings: opaque-object 5,488, any-schema 3,080,
untyped-additional-properties 1,376 — all faithful — against 675 recoverable,
dominated by unions (592).
The census accounts for ~81% of untyped positions in generated output. The
rest come from the generator's own render fallbacks, which this does not yet
see: a single-branch `allOf` around a scalar generates `serde_json::Value`
while the census reports the spec as fully typed. That is the next seam.
Refs #62
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
The untyped census showed 3,347 of 13,226 untyped fields as recoverable —
schemas that said enough to type and were typed as `serde_json::Value` anyway.
This takes that to 6.
The largest by far was a single idiom. OData spells a nullable reference
`anyOf: [$ref, {type: object, nullable: true}]`, and reading the second branch
literally makes the union unrepresentable, costing the first branch its type:
2,127 fields in Microsoft Graph. An empty nullable object is a null marker, so
those become `Option<T>`.
The next largest was structural rather than semantic. A struct field can hold a
primitive, a reference, an array, or a tuple; an inline object, union, enum, or
merged `allOf` has to be generated as its own item. Analysis understood those
schemas and left them in field positions, where the generator — with nothing it
could write — emitted `serde_json::Value`. They are now hoisted to named types,
in property, array-element, and tuple-element positions alike.
The rest were narrower gaps, each traced to a real document:
- `allOf` with one member is that member (Box hangs a description off a scalar
this way), and array items never handled `allOf` at all (Asana);
- a `$ref` to any local pointer now resolves, not just component schemas
(PagerDuty references a parameter's schema and one member of a composition);
- `type: null` is `()` rather than unknown (Discord);
- `oneOf: []` alongside a real `type` takes that type (Discord), a union of one
branch is that branch (gcore), branches differing only in `pattern` or
`format` share their wire type (Runway, gcore), branches that only alternate
`required` describe the object beside them (Cloudflare), and pointer branches
are expanded before the union is built (PagerDuty).
Each of these has a regression test naming the spec it came from, asserting
both the generated type and that the census no longer reports the field. The
negative cases are pinned too: a nullable branch that constrains something
stays a union, and an unconstrained schema stays `serde_json::Value`.
What remains is one shape — a base object combined with a variant union — which
needs a generated form the crate does not have. Filed as #65.
Refs #62
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
`scripts/untyped-census.sh` rewrites tests/conformance/untyped-report.md, which counts every generated field carrying `serde_json::Value` across the corpus and groups them by why — faithful where the schema declared an unconstrained value, recoverable where the generator dropped type information the schema had. Checking it in makes the corpus delta of a typing change visible in review the way the conformance reports already do, and `--check` fails when it is stale. The baseline stands at 8,795 untyped fields, 6 of them recoverable, all one shape (#65). Refs #62 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
Adds the surface tests the typing work did not yet pin: - `--report-untyped` and its `--json` form, which is what scripts/untyped-census.sh consumes; - the composition-member pointer form (`.../Tag/allOf/0`), alongside the parameter-schema form already covered; - an unresolvable reference still degrading to a reported finding rather than failing the document, now that pointer resolution runs first; - a nullable `$ref` branch staying a real union alternative, which the null-marker rule must not swallow; - `items: true`, the other half of boolean `items`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
Typing fields that used to be `serde_json::Value` exposed two latent gaps in the corpus compile gate. Stripe stopped compiling. A hoisted union declared no dependencies, so the cycle `Quote -> QuotesResourceFromQuote -> QuotesResourceFromQuoteQuote -> Quote` was invisible to recursion detection and the generated enum came out infinitely sized. The `Value` had been breaking that cycle by accident. Synthesized types now derive their dependencies from the type they hold, which also generates a struct that was previously referenced by a union variant and never emitted. OpenAI stopped compiling. An extensible enum is now the type of `CreateTranslationRequest.model`, and multipart form fields render values through `Display`, which those enums did not implement. They now expose `as_str`, `Display`, and `AsRef<str>` — the same surface generated string enums already had, and the serializer goes through `as_str` rather than repeating the match. Snapshots: four show the new enum surface, one now includes a struct that was referenced but missing, and two differ only in item order, which the dependency edges changed. Refs #62 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
`anyOf: [X, {type: object, nullable: true}]` is ambiguous. OData emits it for
every navigation property meaning "X, or null", and reading the second branch
literally there costs X its type — that is what the previous commit fixed. But
read literally the branch says "or any object", and the corpus has 33 fields
written as `anyOf: [{type: string, nullable: true}, {type: object, nullable:
true}]`, where the objects are real. Typing those as `Option<String>` would
compile and then fail on a payload the schema plainly allows, which is the
failure mode 0.12.3 fixed for nullability.
The empty-object spelling is now read as a null marker only beside a `$ref`,
where the intent is not in doubt; `type: "null"` still says so on its own and
needs no sibling. Microsoft Graph keeps all 2,127 of its narrowings, the 33
scalar cases stay honest, and the corpus recoverable count is unchanged at 6.
Refs #62
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
`{properties: {...}, anyOf: [A, B]}` says "these fields, and one of these
shapes". Reading only the union loses the declared fields and reading only the
object loses the alternatives, so the generator did neither and emitted
`serde_json::Value` — the last shape the census still called recoverable.
The object now generates its struct and the union its own enum, held in a
`#[serde(flatten)]` field. Both halves round-trip: a payload carrying the
declared fields plus a variant's fields deserializes into the struct and its
variant, and re-serializes to the same JSON, verified against a compiled
scratch crate rather than asserted on generated text.
The same shape occurs one level down, where the named-schema check could not
see it: OpenAI's `tool_resources.file_search` is an inline object whose `anyOf`
only alternates which of its own fields are required. Property positions now
run both checks.
Corpus recoverable untyped fields: 6 to 0.
Closes #65
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
A struct holding a flattened variant cannot derive `Default` — the untagged enum has no default, and choosing one would invent data the same way a required field would. The generated request builder has the same problem: it constructs the struct field by field and has no variant to supply. Both are now skipped when a variant is present, which is what Cloudflare, GitHub, LaunchDarkly, and Lithic needed to compile again. Refs #65 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
Summary
serde_json::Valuein generated code means one of two things: the schemadeclared an unconstrained value, or the generator dropped type information the
schema carried. Nothing distinguished them, so nobody could say which of the
corpus's untyped fields were defects.
This adds the instrument, then uses it. Corpus-wide recoverable untyped fields
go from 3,347 to zero.
The instrument
SchemaType::Untyped { shape, reason }carries the reason in the IR, so thecensus is derived from the types that actually get generated rather than
recorded as analysis runs — a schema referenced by fifty properties counts fifty
times, and a pruned one counts zero. (Recording on the side got both wrong, by
47%.)
scripts/untyped-census.shruns it over the corpus and rewritestests/conformance/untyped-report.md, so a typing change shows its delta inreview;
--checkfails when the baseline is stale.A normalization pass reports anything that reaches the IR still stringly-typed
as
Unclassified— a visible gap in the taxonomy rather than a missing count.The corpus has none.
What it found, and what this fixes
Two root causes dominated:
anyOf: [$ref, {type: object, nullable: true}], and reading the second branchliterally makes the union unrepresentable, costing the first branch its type:
2,127 fields in Microsoft Graph alone. Those become
Option<T>.array, or a tuple; an inline object, union, enum, or merged
allOfhas to begenerated as its own item. Analysis understood those schemas and left them in
field positions, where the generator emitted
serde_json::Value. They are nowhoisted to named types — in property, array-element, and tuple-element
positions alike.
The rest were narrower gaps, each traced to a real document: single-member
allOf(Box),allOfinside array items (Asana),$refto any local JSONPointer (PagerDuty),
type: null→()(Discord),oneOf: []beside a realtype(Discord), single-branch unions (gcore), branches differing only inpattern/format(Runway, gcore), branches that only alternaterequired(Cloudflare), and pointer branches expanded before the union is built
(PagerDuty).
Where I deliberately stopped narrowing
{type: object, nullable: true}is read as a null marker only beside a$ref. Read literally it also says "or any object", and 33 corpus fields areanyOf: [{type: string, nullable: true}, {type: object, nullable: true}]wherethe objects are real — typing those as
Option<String>would compile and thenfail on a payload the schema allows. Microsoft Graph keeps all 2,127 of its
narrowings; those 33 stay
serde_json::Valueand are reported as faithful.The last shape standing was a base object combined with a variant union —
{properties: {...}, anyOf: [A, B]}, "these fields, and one of these shapes".Reading only the union loses the fields and reading only the object loses the
alternatives, so the generator did neither. It now generates the struct with the
union in a
#[serde(flatten)]field, and both halves round-trip byte-identically(verified against a compiled scratch crate, since
flattenhas sharp edges).That closes #65.
Generated compatibility
point. 4,404 fields across the corpus move from
serde_json::Valueto areal type. Consumers of those fields will need to update; the wire format is
unchanged.
narrowed types identically —
Option<T>for a nullable reference,()fortype: null(which isnullon the wire), tuples as arrays.implement
Display/AsRef<str>and exposeas_str, matching what generatedstring enums already had — needed because a multipart field can now be an
enum.
items(Boolean subschemas (
true/false) fail to parse outsideitems#63). Everything the census can attribute is now typed.Validation
tests/recoverable_typing_test.rscovers every pattern above, each namingthe spec it came from, asserting both the generated type and that the
census no longer reports the field. Negative cases are pinned as hard as
positive ones: a nullable branch that constrains something stays a union,
a nullable object beside a scalar is read literally, and an unconstrained
schema stays
serde_json::Value.tests/untyped_census_test.rscovers thetaxonomy;
tests/untyped_report_cli_test.rscovers the CLI surface.enum surface, one now includes a struct that was previously referenced by
a union variant and never generated, and two differ only in item order,
which the dependency edges changed.
cargo fmt --checkcargo clippy --all-features -- -D warningscargo test --all-featuresRUSTDOCFLAGS=-Dwarnings cargo doc --no-deps --all-featuresscripts/install-smoke.shfor packaging/dependency changes.scripts/spec-compile.shfor generator changes(full corpus).
The corpus gate earned its keep repeatedly here. Typing these fields exposed
three latent problems it caught and this branch fixes: a hoisted union that
declared no dependencies made a reference cycle invisible to recursion detection
(Stripe generated an infinitely-sized enum); extensible enums lacked the
Displaythat multipart form fields render through (OpenAI stopped compiling);and a struct that flattens a variant can derive neither
Defaultnor a requestbuilder, since both would have to invent a variant (Cloudflare, GitHub,
LaunchDarkly, Lithic).
Notes for reviewers
Smallest useful review path:
UntypedReasonandSchemaType::renders_inlinein
src/analysis.rs(the taxonomy and the capability it mirrors), thenhoist_inline_property_type(the structural fix), thennon_null_variantinsrc/openapi.rs(the narrowing, and its limit).🤖 Generated with Claude Code
https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry