Repository navigation
fix(schema): terminate recursive schema generation, stabilize JSON keys across targets - #41
Merged
Merged
Conversation
`schemaForRecord` is defined in terms of its fields' schemas, so a record
that reaches itself had no base case and recursed until the stack blew:
type Tree = { Label: string; Children: Tree list }
jsonSchema (auto<Tree> ()) // stack overflow
Thread a `visited` chain of record FullNames from the root and truncate a
record already on that chain to `{"type": "object", "title": "Tree"}`.
The chain is root-to-node, not a global accumulator, so sibling fields of
the same type each still expand fully — the diamond test pins that.
Truncation rather than `$ref`/`$defs`: this library's schemas feed LLM
tool-call validators, which handle flat schemas far better than refs. The
cost is that a recursive type's schema is lossy past the first cycle.
Public signatures are unchanged — the recursive pair is now private behind
`schemaForType`/`schemaForRecord` wrappers that seed an empty chain.
On BEAM this was masked until Fable 5.8.1: `typeof<Tree>` killed the
compiler (fable-compiler/Fable#4766) before the emitter was reached. It has
always been reachable on .NET, Python and JS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PropertyInfo.Name` does not report the same string on every Fable target, and what it reports has changed over time: .NET, JS AirTemperature always BEAM air_temperature until Fable 5.8.1 (Fable#4766) Python air_temperature until Fable 5.14.0 (Fable#4852) `lowerFirstTransform` — backing `dump`, `validateJson` and `validateMap`, which key off the field name directly instead of going through CaseRules — lowercased the first character and returned an all-lowercase name unchanged. So it tracked whichever spelling the compiler produced: Python emitted `air_temperature` where BEAM, JS and .NET emitted `airTemperature`. `applyCaseRule` already solved this by pivoting through canonical PascalCase, but the two key paths did not share the pivot. Hoist it into `Casing.fs` (compiled before `Schema.fs`, which compiles before `Json`) and route both through it. A JSON key is now a function of the F# field name alone — identical on all four targets and stable across the Fable upgrade. BREAKING on Python for `dump`/`validateJson`/`validateMap` with multi-word fields: keys become `airTemperature`, not `air_temperature`. Fable 5.14.0 changes those keys anyway via Fable#4852; this lands the same wire format one Fable version earlier so it does not shift under consumers mid-upgrade. The snake_case reverse is lossy for adjacent capitals (`HTTPStatus` -> `httpstatus` -> `Httpstatus`); only Fable#4852 recovers those. Tests cover multi-word keys over dump/validateJson/validateMap plus a round-trip — every existing record in TestSchema.fs was single-word, where the transform is a no-op and could not catch the drift. 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.
Two reflection-driven defects, both invisible to the existing suite because every
record it exercised was either non-recursive or single-word.
1. Recursive types blew the stack
schemaForRecordis defined in terms of its fields' schemas, so a record thatreaches itself had no base case:
schemaForType/schemaForRecordnow thread avisitedchain of recordFullNames from the root, and a record already on that chain truncates to{"type": "object", "title": "Tree"}instead of descending again.The chain is root-to-node, not a global accumulator — sibling fields of the
same type each still expand fully. The diamond test is what pins that; a global
visited set would truncate
Right.Truncation rather than
$ref/$defs: these schemas feed LLM tool-callvalidators, which handle flat schemas far better than refs. The cost is that a
recursive type's schema is lossy past the first cycle.
Public signatures are unchanged — the recursive pair went private behind
wrappers that seed an empty chain.
On BEAM this was masked until Fable 5.8.1, where
typeof<Tree>killed thecompiler (Fable#4766)
before the emitter was reached. It has always been reachable on .NET, Python
and JS.
2. JSON keys tracked the backend, not the F# field name
PropertyInfo.Namedoes not report the same string on every Fable target, andwhat it reports has changed over time:
AirTemperatureAirTemperatureAirTemperaturelowerFirstTransform— backingdump/validateJson/validateMap, whichkey off the field name directly instead of going through
CaseRules—lowercased the first character and returned an all-lowercase name unchanged. So
it tracked whichever spelling the compiler produced: Python emitted
air_temperaturewhere BEAM, JS and .NET emittedairTemperature, despite thefunction's own doc comment promising stability across targets.
applyCaseRulealready solved this by pivoting through canonical PascalCase, butthe two key paths didn't share the pivot. It's hoisted into
Casing.fs(compiled before
Schema.fs, which compiles beforeJson) and both now routethrough it. A JSON key is a function of the F# field name alone.
Breaking on Python
dump/validateJson/validateMapwith multi-word fields now emitairTemperature, notair_temperature. Fable 5.14.0 changes those keys anywayvia #4852 — this lands the
same wire format one Fable version earlier, so the format doesn't shift under
consumers mid-upgrade. The
CaseRulescodec API is unaffected;applyCaseRulealready normalized.
The snake_case reverse is lossy for adjacent capitals (
HTTPStatus→httpstatus→Httpstatus); only #4852 recovers those.Tests
still optional in
required), mutual recursion truncating at the second hop,and the diamond that pins
visitedto the path.dump,validateJson,validateMap, and adump→validateJsonround-trip. Each guards the two spellings the backendsdrifted to. These three fail on
mainunder Fable 5.13.0.Also refreshes the stale comments that asserted the old BEAM snake_case
behaviour (
Schema.fs,TypedJson.fs,CLAUDE.md) — they were what made memisread the Python side in the first place.
just test: BEAM 174/174 · Python 174/174 · JS 173 + 1 skipped · .NET 170 + 4skipped. Fantomas clean.
🤖 Generated with Claude Code