Skip to main content

submilli_engine/typechecker/
json_schema.rs

1//! JSON Schema emitter for `submilli:llm` typed output.
2//!
3//! `llm.call<T>()` sends a JSON Schema for `T` to the provider so the model is
4//! constrained to the shape the program asked for. The schema is advisory —
5//! every response is still verified by our own structural check — so the job
6//! here is to describe `T` in the narrow dialect every provider agrees on, and
7//! to refuse at compile time anything that dialect cannot carry.
8//!
9//! **Every definition is inlined; `$ref`/`$defs` are never emitted.** Google's
10//! provider strips `$ref`, silently degrading the referenced subschema to "any"
11//! — the response then validates against nothing. A recursive type has no
12//! finite inlining, so it is a compile error here rather than a ref.
13//!
14//! The emitter carries its **own** gate rather than deferring to
15//! `unsupported_cast_target_reason`. The two agree on most variants but differ
16//! where it matters: the cast gate accepts `Unknown` (the runtime widen is a
17//! no-op), while a schema for `unknown` could only be `{}` — the same silent
18//! degeneration to "any" that `$ref` is rejected for. See [`SchemaReject`].
19
20use serde_json::{Value, json};
21
22use crate::types::Type;
23
24/// One-level expander for recursion back-edges, mirroring
25/// [`json_strategy::AliasExpander`](super::json_strategy::AliasExpander).
26/// Given a [`Type::AliasRef`] it returns the alias body (args substituted); for
27/// any other type it returns `ty.peel()` cloned. Lets the emitter resolve a
28/// named alias without owning the type namespace.
29pub type AliasExpander<'a> = &'a dyn Fn(&Type) -> Type;
30
31/// Identity expander — leaves `AliasRef` unexpanded, so any back-edge is
32/// reported as an unresolvable alias. Used by call sites that hold no type
33/// namespace (unit tests, already-expanded types).
34fn no_expand(ty: &Type) -> Type {
35    ty.peel().clone()
36}
37
38/// A type that has no JSON Schema in the safe subset.
39///
40/// `path` names the offending field so the diagnostic points at the thing the
41/// author must change, not merely at the type as a whole (R8). It is a
42/// dotted/bracketed path from the root — `"items[0].handler"` — and is empty
43/// when the root type itself is unrepresentable.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct SchemaReject {
46    pub path: String,
47    pub reason: &'static str,
48    /// The walk hit a [`Type::Error`] — a type that already failed to resolve
49    /// and already reported its own diagnostic. There is still no schema to
50    /// emit, so this is an `Err`, but the caller must stay silent rather than
51    /// cascade a second diagnostic onto the same mistake. This is the one
52    /// reject a caller suppresses; `unsupported_cast_target_reason` reaches the
53    /// same conclusion by returning `None` for `Type::Error`.
54    pub cascading: bool,
55}
56
57impl SchemaReject {
58    /// `"field `items[0].handler`: <reason>"`, or just the reason at the root.
59    /// Callers embed this in the diagnostic message.
60    pub fn describe(&self) -> String {
61        if self.path.is_empty() {
62            self.reason.to_string()
63        } else {
64            format!("field `{}`: {}", self.path, self.reason)
65        }
66    }
67}
68
69/// Emit an inlined JSON Schema for `ty`, or name the first field that has none.
70///
71/// Callers pass a type that has already been through
72/// `reduce_interfaces_to_shapes`, so a surviving [`Type::InterfaceRef`] means
73/// an interface that could not be expanded — method-bearing (nominal) or
74/// recursive — and is rejected.
75pub fn json_schema(ty: &Type) -> Result<Value, SchemaReject> {
76    json_schema_with(ty, &no_expand)
77}
78
79/// Like [`json_schema`] but resolves recursion back-edges ([`Type::AliasRef`])
80/// one level through `expand`. A back-edge that expands to a type already open
81/// on the walk is a genuine cycle and is rejected.
82pub fn json_schema_with(ty: &Type, expand: AliasExpander<'_>) -> Result<Value, SchemaReject> {
83    walk(ty, expand, &mut String::new(), &mut Vec::new())
84}
85
86/// `seen` holds the alias back-edges currently open on this path; re-entering
87/// one is the recursion that cannot be inlined. Same cycle-guard shape as
88/// `unsupported_cast_target_reason` and `reduce_interfaces_to_shapes`, but the
89/// verdict is the opposite: they treat a re-encounter as supported (codegen
90/// emits a recursive validator), while an inlined schema has no such escape.
91fn walk(
92    ty: &Type,
93    expand: AliasExpander<'_>,
94    path: &mut String,
95    seen: &mut Vec<Type>,
96) -> Result<Value, SchemaReject> {
97    let peeled = ty.peel();
98    match peeled {
99        Type::Number => Ok(json!({ "type": "number" })),
100        Type::NumberLiteral(n) => Ok(json!({ "const": n.0 })),
101        Type::String => Ok(json!({ "type": "string" })),
102        Type::TemplateLiteral { .. } => {
103            if contains_number_domain(peeled) {
104                return Err(reject(
105                    path,
106                    "numeric template substitutions cannot be represented faithfully in this JSON Schema dialect",
107                ));
108            }
109            let pattern = crate::property_keys::pattern(peeled)
110                .ok_or_else(|| reject(path, "unsupported template substitution in schema"))?;
111            Ok(json!({ "type": "string", "pattern": format!("^(?:{pattern})$(?![\\s\\S])") }))
112        }
113        Type::StringLiteral(s) => Ok(json!({ "const": s })),
114        Type::Boolean => Ok(json!({ "type": "boolean" })),
115        Type::BooleanLiteral(b) => Ok(json!({ "const": b })),
116        Type::Null => Ok(json!({ "type": "null" })),
117        Type::Object { fields, index } => {
118            // `BTreeMap` iteration is field-name order, so `properties` and
119            // `required` come out canonical without sorting here.
120            let mut properties = serde_json::Map::new();
121            let mut required = Vec::new();
122            for (name, field) in fields {
123                let restore = path.len();
124                push_field(path, name);
125                properties.insert(name.clone(), walk(&field.ty, expand, path, seen)?);
126                path.truncate(restore);
127                // An optional field is absent from `required`. It is *not* also
128                // `| undefined`: that widening is the *read* type of the field, a
129                // different thing from whether the provider must emit the key.
130                if !field.optional {
131                    required.push(Value::String(name.clone()));
132                }
133            }
134            let mut schema = json!({
135                "type": "object",
136                "properties": Value::Object(properties),
137                "required": Value::Array(required),
138                "additionalProperties": false,
139            });
140            if let Some(index) = index {
141                let value = walk(&index.value, expand, path, seen)?;
142                if matches!(index.key.peel(), Type::String) {
143                    schema["additionalProperties"] = value;
144                } else if contains_number_domain(&index.key) {
145                    return Err(reject(
146                        path,
147                        "number index signatures require canonical JavaScript number keys, which this JSON Schema dialect cannot express; use Record<string, V> for structured output",
148                    ));
149                } else {
150                    let pattern = crate::property_keys::pattern(&index.key)
151                        .ok_or_else(|| reject(path, "unsupported index key domain in schema"))?;
152                    let mut patterns = serde_json::Map::new();
153                    patterns.insert(format!("^(?:{pattern})$(?![\\s\\S])"), value);
154                    schema["patternProperties"] = Value::Object(patterns);
155                    schema["additionalProperties"] = Value::Bool(true);
156                }
157            }
158            Ok(schema)
159        }
160        Type::Array(elem) => {
161            let restore = path.len();
162            path.push_str("[]");
163            let items = walk(elem, expand, path, seen)?;
164            path.truncate(restore);
165            Ok(json!({ "type": "array", "items": items }))
166        }
167        Type::Tuple(elems) => {
168            let mut items = Vec::with_capacity(elems.len());
169            for (i, elem) in elems.iter().enumerate() {
170                let restore = path.len();
171                path.push_str(&format!("[{i}]"));
172                items.push(walk(elem, expand, path, seen)?);
173                path.truncate(restore);
174            }
175            let len = elems.len();
176            Ok(json!({
177                "type": "array",
178                "prefixItems": Value::Array(items),
179                "items": false,
180                "minItems": elems.required_len(),
181                "maxItems": len,
182            }))
183        }
184        Type::Union(members) => {
185            let mut any_of = Vec::with_capacity(members.len());
186            for member in members {
187                if matches!(member.peel(), Type::Undefined | Type::Void) {
188                    continue;
189                }
190                any_of.push(walk(member, expand, path, seen)?);
191            }
192            match any_of.len() {
193                0 => Err(reject(
194                    path,
195                    "undefined has no JSON value; include a JSON-representable type",
196                )),
197                1 => any_of
198                    .pop()
199                    .ok_or_else(|| reject(path, "missing JSON union schema")),
200                _ => Ok(json!({ "anyOf": Value::Array(any_of) })),
201            }
202        }
203        Type::Undefined => Err(reject(
204            path,
205            "undefined has no JSON value; include a JSON-representable type",
206        )),
207        // A recursion back-edge: expand once and describe the body inline.
208        // Unlike the cast gate, a re-encounter is fatal — inlining a cycle
209        // does not terminate, and `$ref` is not an option (see module docs).
210        Type::AliasRef { .. } => {
211            if seen.iter().any(|s| s == peeled) {
212                return Err(reject(path, RECURSIVE));
213            }
214            let expanded = expand(peeled);
215            if matches!(expanded.peel(), Type::AliasRef { .. }) {
216                return Err(reject(path, RECURSIVE));
217            }
218            seen.push(peeled.clone());
219            let schema = walk(&expanded, expand, path, seen);
220            seen.pop();
221            schema
222        }
223        // Already-reported failure: still no schema, but the caller stays quiet.
224        Type::Error => Err(SchemaReject {
225            cascading: true,
226            ..reject(path, "this type failed to resolve")
227        }),
228        Type::Alias { ty, .. } | Type::Refined { ty, .. } | Type::Readonly(ty) => {
229            walk(ty, expand, path, seen)
230        }
231        // An unconstrained schema would silently accept anything, unlike an `as unknown` widening.
232        Type::Unknown => Err(reject(
233            path,
234            "`unknown` has no JSON Schema — an unconstrained schema would let the model return anything; name the shape you expect",
235        )),
236        Type::NonPrimitive => Err(reject(
237            path,
238            "`object` has no JSON Schema — it admits any object, array or function; name the shape you expect",
239        )),
240        Type::Function { .. } => Err(reject(path, "functions have no JSON form")),
241        Type::BigInt | Type::BigIntLiteral(_) => Err(reject(
242            path,
243            "`bigint` has no JSON number form — use `number` or a `string` encoding",
244        )),
245        Type::Uint8Array => Err(reject(
246            path,
247            "`Uint8Array` has no JSON form — use a `string` encoding such as base64",
248        )),
249        Type::ClassRef { .. } => Err(reject(
250            path,
251            "class types are nominal — use an object type or a data interface",
252        )),
253        Type::InterfaceRef { .. } => Err(reject(
254            path,
255            "this interface has no data shape — method-bearing interfaces are nominal, and recursive ones cannot be inlined",
256        )),
257        Type::NumberEnum { .. } | Type::StringEnum { .. } => Err(reject(
258            path,
259            "enums need their variants resolved — use a union of literals",
260        )),
261        Type::TypeVar(_)
262        | Type::GenericParam { .. }
263        | Type::KeyOf(_)
264        | Type::IndexedAccess { .. } => Err(reject(
265            path,
266            "generic type parameters are erased — the schema is emitted at compile time",
267        )),
268        Type::Never => Err(reject(path, "`never` has no values")),
269        Type::Void => Err(reject(path, "`void` is not a value type")),
270    }
271}
272
273/// The recursion verdict, shared by the back-edge and no-expander cases.
274const RECURSIVE: &str = "recursive types have no finite JSON Schema — \
275                         providers drop `$ref`, so every definition must be inlined";
276
277fn reject(path: &str, reason: &'static str) -> SchemaReject {
278    SchemaReject {
279        path: path.to_string(),
280        reason,
281        cascading: false,
282    }
283}
284
285/// Append `name` to the field path: `"a.b"`, but `"a"` at the root and
286/// `"items[].id"` after an array step (which already ends in a bracket).
287fn push_field(path: &mut String, name: &str) {
288    if !path.is_empty() {
289        path.push('.');
290    }
291    path.push_str(name);
292}
293
294fn contains_number_domain(ty: &Type) -> bool {
295    match ty.peel() {
296        Type::Number | Type::BigInt => true,
297        Type::TemplateLiteral {
298            substitutions: members,
299            ..
300        }
301        | Type::Union(members) => members.iter().any(contains_number_domain),
302        _ => false,
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use std::collections::BTreeMap;
309
310    use super::*;
311    use crate::mangle;
312    use crate::types::{LiteralF64, ObjectField, Package};
313
314    fn obj(fields: &[(&str, Type, bool)]) -> Type {
315        Type::Object {
316            index: None,
317            fields: fields
318                .iter()
319                .map(|(name, ty, optional)| {
320                    (
321                        (*name).to_string(),
322                        if *optional {
323                            ObjectField::optional(ty.clone())
324                        } else {
325                            ObjectField::required(ty.clone())
326                        },
327                    )
328                })
329                .collect::<BTreeMap<_, _>>(),
330        }
331    }
332
333    fn s_lit(s: &str) -> Type {
334        Type::StringLiteral(s.to_string())
335    }
336
337    fn alias_ref(name: &str) -> Type {
338        Type::AliasRef {
339            mangled: mangle::prelude(name),
340            package: Package::prelude(),
341            name: name.to_string(),
342            args: Vec::new(),
343        }
344    }
345
346    #[test]
347    fn template_schema_preserves_adjacent_holes_and_surrogate_literals() {
348        let mut prefix = "id_".to_string();
349        crate::literal_units::push_lone_surrogate(&mut prefix, 0xd800);
350        let template = Type::TemplateLiteral {
351            parts: vec![prefix, String::new(), "end".into()],
352            substitutions: vec![Type::String, Type::String],
353        };
354        let schema = json_schema(&template).unwrap();
355        assert_eq!(
356            schema["pattern"],
357            r"^(?:\u0069\u0064\u005f\ud800[\s\S][\s\S]*\u0065\u006e\u0064)$(?![\s\S])"
358        );
359        let record = Type::Object {
360            fields: Default::default(),
361            index: Some(crate::IndexSignature {
362                string_keyof_includes_number: false,
363                key: Box::new(template),
364                value: Box::new(Type::Boolean),
365                readonly: false,
366            }),
367        };
368        let schema = json_schema(&record).unwrap();
369        assert_eq!(schema["patternProperties"].as_object().unwrap().len(), 1);
370        assert_eq!(schema["additionalProperties"], true);
371    }
372
373    #[test]
374    fn numeric_domains_refuse_inexact_schema_patterns() {
375        let template = Type::TemplateLiteral {
376            parts: vec!["id_".into(), String::new()],
377            substitutions: vec![Type::Number],
378        };
379        assert!(
380            json_schema(&template)
381                .unwrap_err()
382                .reason
383                .contains("faithfully")
384        );
385        let record = Type::Object {
386            fields: Default::default(),
387            index: Some(crate::IndexSignature {
388                string_keyof_includes_number: false,
389                key: Box::new(Type::Number),
390                value: Box::new(Type::Boolean),
391                readonly: false,
392            }),
393        };
394        assert!(
395            json_schema(&record)
396                .unwrap_err()
397                .reason
398                .contains("canonical")
399        );
400    }
401
402    #[test]
403    fn flat_object() {
404        let schema = json_schema(&obj(&[
405            ("age", Type::Number, false),
406            ("name", Type::String, false),
407        ]))
408        .expect("emits");
409        assert_eq!(
410            schema,
411            json!({
412                "type": "object",
413                "properties": {
414                    "age": { "type": "number" },
415                    "name": { "type": "string" },
416                },
417                "required": ["age", "name"],
418                "additionalProperties": false,
419            })
420        );
421    }
422
423    #[test]
424    fn nested_object() {
425        let inner = obj(&[("city", Type::String, false)]);
426        let schema = json_schema(&obj(&[("address", inner, false)])).expect("emits");
427        assert_eq!(
428            schema["properties"]["address"]["properties"]["city"],
429            json!({ "type": "string" })
430        );
431        assert_eq!(schema["properties"]["address"]["required"], json!(["city"]));
432    }
433
434    #[test]
435    fn optional_field_is_omitted_from_required_and_is_not_nullable() {
436        let schema = json_schema(&obj(&[
437            ("id", Type::String, false),
438            ("note", Type::String, true),
439        ]))
440        .expect("emits");
441        assert_eq!(schema["required"], json!(["id"]));
442        // Present in `properties`, and as a bare string — the `| undefined` widening
443        // is the read type of the field, not its schema.
444        assert_eq!(schema["properties"]["note"], json!({ "type": "string" }));
445    }
446
447    #[test]
448    fn undefined_projection_preserves_property_requiredness() {
449        let maybe_string = Type::union(vec![Type::String, Type::Undefined]);
450        let schema = json_schema(&obj(&[
451            ("required", maybe_string.clone(), false),
452            ("optional", maybe_string, true),
453            (
454                "nullable",
455                Type::union(vec![Type::String, Type::Null, Type::Undefined]),
456                false,
457            ),
458        ]))
459        .expect("JSON-representable branches remain");
460        assert_eq!(schema["required"], json!(["nullable", "required"]));
461        assert_eq!(schema["properties"]["required"], json!({"type":"string"}));
462        assert_eq!(schema["properties"]["optional"], json!({"type":"string"}));
463        assert_eq!(
464            schema["properties"]["nullable"],
465            json!({"anyOf":[{"type":"string"},{"type":"null"}]})
466        );
467    }
468
469    #[test]
470    fn undefined_only_schema_reports_the_property_path() {
471        let error = json_schema(&obj(&[("missing", Type::Undefined, true)]))
472            .expect_err("undefined is not JSON");
473        assert_eq!(error.path, "missing");
474        assert!(error.reason.contains("undefined has no JSON value"));
475        assert!(json_schema(&Type::Undefined).is_err());
476    }
477
478    #[test]
479    fn optional_tuple_schema_keeps_lengths_and_excludes_null() {
480        let tuple = Type::Tuple(crate::types::TupleType {
481            elements: vec![
482                Type::Number,
483                Type::union(vec![Type::String, Type::Undefined]),
484            ],
485            optional: 1,
486        });
487        let schema = json_schema(&tuple).expect("optional tuple schema");
488        assert_eq!(schema["minItems"], json!(1));
489        assert_eq!(schema["maxItems"], json!(2));
490        assert_eq!(schema["prefixItems"][1], json!({"type":"string"}));
491        assert_eq!(schema["items"], json!(false));
492    }
493
494    #[test]
495    fn array_and_tuple() {
496        let arr = json_schema(&Type::Array(Box::new(Type::Number))).expect("emits");
497        assert_eq!(arr["type"], json!("array"));
498        assert_eq!(arr["items"], json!({ "type": "number" }));
499
500        let tup = json_schema(&Type::tuple(vec![Type::String, Type::Boolean])).expect("emits");
501        assert_eq!(tup["type"], json!("array"));
502        assert_eq!(
503            tup["prefixItems"],
504            json!([{ "type": "string" }, { "type": "boolean" }])
505        );
506        assert_eq!(tup["minItems"], json!(2));
507        assert_eq!(tup["maxItems"], json!(2));
508    }
509
510    #[test]
511    fn union_becomes_any_of() {
512        let ty = Type::union(vec![Type::String, Type::Null]);
513        let schema = json_schema(&ty).expect("emits");
514        let any_of = schema["anyOf"].as_array().expect("anyOf array");
515        assert_eq!(any_of.len(), 2);
516        assert!(any_of.contains(&json!({ "type": "null" })));
517        assert!(any_of.contains(&json!({ "type": "string" })));
518    }
519
520    #[test]
521    fn literals_become_const() {
522        assert_eq!(
523            json_schema(&s_lit("red")).expect("emits"),
524            json!({ "const": "red" })
525        );
526        assert_eq!(
527            json_schema(&Type::NumberLiteral(LiteralF64(3.0))).expect("emits"),
528            json!({ "const": 3.0 })
529        );
530    }
531
532    #[test]
533    fn reduced_interface_matches_the_literal_object_type() {
534        // `reduce_interfaces_to_shapes` runs before the walk, so a data-only
535        // interface arrives here already as a `Type::Object`. Emitting the same
536        // schema for both is what makes that pre-pass sufficient.
537        let shape = obj(&[("id", Type::String, false), ("n", Type::Number, true)]);
538        let literal = obj(&[("id", Type::String, false), ("n", Type::Number, true)]);
539        assert_eq!(
540            json_schema(&shape).expect("emits"),
541            json_schema(&literal).expect("emits")
542        );
543    }
544
545    #[test]
546    fn recursive_alias_is_rejected_rather_than_emitting_a_ref() {
547        // `type Node = { next: Node | null }`. Google's provider strips `$ref`,
548        // so a recursive type must fail at compile time instead.
549        let node = alias_ref("Node");
550        let body = obj(&[("next", Type::union(vec![node.clone(), Type::Null]), false)]);
551        let expand = |ty: &Type| {
552            if matches!(ty.peel(), Type::AliasRef { name, .. } if name == "Node") {
553                body.clone()
554            } else {
555                ty.peel().clone()
556            }
557        };
558        let err = json_schema_with(&node, &expand).expect_err("rejects");
559        assert_eq!(err.path, "next");
560        assert!(err.reason.contains("recursive"), "reason: {}", err.reason);
561    }
562
563    #[test]
564    fn unexpandable_alias_ref_is_rejected() {
565        let err = json_schema(&alias_ref("Missing")).expect_err("rejects");
566        assert!(err.reason.contains("recursive"), "reason: {}", err.reason);
567    }
568
569    #[test]
570    fn function_field_is_rejected_and_the_message_names_the_field() {
571        // This shape compiles today under the cast gate, which returns `None`
572        // for `Function`. The schema gate must name `handler`.
573        let handler = Type::Function {
574            params: vec![Type::Number],
575            ret: Box::new(Type::Number),
576            predicate: None,
577            has_rest: false,
578            optional: 0,
579        };
580        let err = json_schema(&obj(&[
581            ("handler", handler, false),
582            ("id", Type::String, false),
583        ]))
584        .expect_err("rejects");
585        assert_eq!(err.path, "handler");
586        assert!(
587            err.describe().contains("field `handler`"),
588            "{}",
589            err.describe()
590        );
591        assert!(err.reason.contains("function"), "reason: {}", err.reason);
592    }
593
594    #[test]
595    fn nested_field_path_is_built_through_objects_and_arrays() {
596        let handler = Type::Function {
597            params: Vec::new(),
598            ret: Box::new(Type::Void),
599            predicate: None,
600            has_rest: false,
601            optional: 0,
602        };
603        let item = obj(&[("run", handler, false)]);
604        let ty = obj(&[("items", Type::Array(Box::new(item)), false)]);
605        let err = json_schema(&ty).expect_err("rejects");
606        assert_eq!(err.path, "items[].run");
607    }
608
609    #[test]
610    fn tuple_element_path_names_its_index() {
611        let ty = obj(&[("pair", Type::tuple(vec![Type::String, Type::BigInt]), false)]);
612        let err = json_schema(&ty).expect_err("rejects");
613        assert_eq!(err.path, "pair[1]");
614    }
615
616    #[test]
617    fn bigint_and_uint8array_have_distinct_reasons() {
618        let big = json_schema(&obj(&[("n", Type::BigInt, false)])).expect_err("rejects");
619        let bytes = json_schema(&obj(&[("b", Type::Uint8Array, false)])).expect_err("rejects");
620        assert_eq!(big.path, "n");
621        assert_eq!(bytes.path, "b");
622        assert!(big.reason.contains("bigint"), "reason: {}", big.reason);
623        assert!(
624            bytes.reason.contains("Uint8Array"),
625            "reason: {}",
626            bytes.reason
627        );
628        assert_ne!(big.reason, bytes.reason);
629    }
630
631    #[test]
632    fn nested_unknown_is_rejected_though_the_cast_gate_accepts_it() {
633        // The cast gate returns `None` for `Unknown`. If the schema emitter
634        // deferred to it, this would emit `{}` and constrain nothing — the very
635        // degeneration to "any" that inlining exists to avoid.
636        let err = json_schema(&obj(&[("payload", Type::Unknown, false)])).expect_err("rejects");
637        assert_eq!(err.path, "payload");
638        assert!(err.reason.contains("unknown"), "reason: {}", err.reason);
639    }
640
641    #[test]
642    fn an_already_failed_type_rejects_without_asking_for_a_second_diagnostic() {
643        // `Type::Error` means a diagnostic was already reported for this type.
644        // There is still no schema, but the caller must not stack a second
645        // complaint onto the same mistake.
646        let err = json_schema(&obj(&[("bad", Type::Error, false)])).expect_err("rejects");
647        assert_eq!(err.path, "bad");
648        assert!(
649            err.cascading,
650            "an already-failed type is a cascading reject"
651        );
652        // Every other reject wants to be reported.
653        let real = json_schema(&obj(&[("n", Type::BigInt, false)])).expect_err("rejects");
654        assert!(!real.cascading, "a genuine rejection is reportable");
655    }
656
657    #[test]
658    fn emitted_schema_uses_only_the_safe_subset_of_keys() {
659        const SAFE: &[&str] = &[
660            "type",
661            "properties",
662            "required",
663            "additionalProperties",
664            "items",
665            "prefixItems",
666            "minItems",
667            "maxItems",
668            "anyOf",
669            "const",
670        ];
671
672        fn assert_safe(value: &Value, safe: &[&str]) {
673            match value {
674                Value::Object(map) => {
675                    for (key, child) in map {
676                        assert!(safe.contains(&key.as_str()), "unsafe schema key `{key}`");
677                        // `properties` keys are user field names, not keywords.
678                        if key == "properties" {
679                            for nested in map[key].as_object().expect("properties object").values()
680                            {
681                                assert_safe(nested, safe);
682                            }
683                        } else {
684                            assert_safe(child, safe);
685                        }
686                    }
687                }
688                Value::Array(items) => items.iter().for_each(|i| assert_safe(i, safe)),
689                _ => {}
690            }
691        }
692
693        let ty = obj(&[
694            ("flag", Type::Boolean, true),
695            ("kind", Type::union(vec![s_lit("a"), s_lit("b")]), false),
696            (
697                "rows",
698                Type::Array(Box::new(obj(&[("v", Type::Number, false)]))),
699                false,
700            ),
701            ("span", Type::tuple(vec![Type::Number, Type::Number]), false),
702        ]);
703        assert_safe(&json_schema(&ty).expect("emits"), SAFE);
704    }
705
706    #[test]
707    fn emission_is_deterministic() {
708        let ty = obj(&[
709            ("zeta", Type::String, false),
710            ("alpha", Type::Number, true),
711            ("mid", Type::Array(Box::new(Type::Boolean)), false),
712        ]);
713        let first = serde_json::to_string(&json_schema(&ty).expect("emits")).expect("serializes");
714        for _ in 0..8 {
715            let again =
716                serde_json::to_string(&json_schema(&ty).expect("emits")).expect("serializes");
717            assert_eq!(first, again, "schema emission must be byte-identical");
718        }
719    }
720}