Skip to main content

pushkin_compiler/
schema.rs

1//! Parsing + constrained-subset validation of the canonical JSON Schema.
2//! The subset is deliberately small in Phase 1: strict objects with string
3//! properties (length/format/enum constraints). Everything else is rejected
4//! by name with alternatives.
5
6use crate::CompileError;
7
8/// Constructs the Phase 1 subset does not carry, with the alternative named
9/// in the rejection (spec §5.1 — never silently dropped).
10const UNREPRESENTABLE: &[(&str, &str)] = &[
11    ("patternProperties", "declare explicit 'properties' instead"),
12    (
13        "prefixItems",
14        "declare a named object; tuples do not round-trip to SQL",
15    ),
16    (
17        "$ref",
18        "inline the definition; cross-schema refs land in a later phase",
19    ),
20    ("allOf", "flatten the composition into one object"),
21    ("anyOf", "split into separate contracts"),
22    ("oneOf", "split into separate contracts"),
23    ("not", "express the constraint positively"),
24];
25
26const SUPPORTED_FORMATS: &[&str] = &["email"];
27
28/// Keywords the parser consumes at the top level. The gate is BIJECTIVE
29/// (F26): a keyword outside this set is rejected by name, never
30/// tolerated-and-ignored. Tolerating an unconsumed keyword was a silent
31/// DROP before the typify swap and, after it, silent cross-target DRIFT
32/// — `targets/rust.rs` feeds typify the RAW schema JSON, so a keyword
33/// the parser ignores still shapes the Rust binding alone.
34const TOP_LEVEL_KEYWORDS: &[&str] = &[
35    "$schema",
36    "$comment",
37    "type",
38    "properties",
39    "required",
40    "additionalProperties",
41];
42
43/// Keywords the parser consumes on a STRING property subschema (F26).
44const PROPERTY_KEYWORDS: &[&str] = &[
45    "type",
46    "minLength",
47    "maxLength",
48    "format",
49    "enum",
50    "default",
51    "pattern",
52];
53
54/// Keywords the parser consumes on a scalar (integer/number/boolean)
55/// property (R6 step 2). Bijective like `PROPERTY_KEYWORDS`: range
56/// keywords, `multipleOf`, string-only keywords, and anything unknown
57/// reject by name — a tolerated keyword would reach typify's raw-JSON
58/// feed and shape the Rust binding alone (F26).
59const SCALAR_KEYWORDS: &[&str] = &["type", "default"];
60
61/// Keywords admitted on an `array` property (R6 step 3). Bijective like
62/// `SCALAR_KEYWORDS`: cardinality keywords (`minItems`/`maxItems`/
63/// `uniqueItems`) and `default` reject by name.
64///
65/// Cardinality is excluded because typify has no such constraint on
66/// `Vec<T>` — Zod, Pydantic and Postgres could all express it, so
67/// admitting it would leave Rust silently under-enforcing what the other
68/// three check (F26). `default` is deferred to its own widening step.
69const ARRAY_KEYWORDS: &[&str] = &["type", "items"];
70
71/// Keywords admitted on an array's `items` (R6 step 3): the element type
72/// and nothing else.
73///
74/// Constrained elements are the load-bearing exclusion. Zod, Pydantic and
75/// Rust can each express a constrained element type; SQL cannot — a
76/// per-element `enum` or length bound is not a column constraint, it needs
77/// a `CHECK` over `unnest`. Admitting them would enforce the contract in
78/// three targets and not the fourth.
79const ARRAY_ITEM_KEYWORDS: &[&str] = &["type"];
80
81/// The email regex the authoring pipeline (Zod v4 `z.email()`) emits
82/// alongside `format: "email"`. `pattern` is admitted ONLY as this
83/// format's companion and ONLY byte-equal to this constant (human
84/// decision, recorded §7).
85///
86/// A future authoring-library bump that changes the emitted regex will
87/// fail compile loudly here. That is the DESIGNED behavior, not a bug:
88/// the alternative is the silent cross-target drift this gate closes.
89/// Remediation is a deliberate constant update in the same PR as the bump.
90const AUTHORING_EMAIL_PATTERN: &str = r"^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$";
91
92#[derive(Debug, Clone, PartialEq)]
93pub enum PropertyKind {
94    String {
95        min_length: Option<u64>,
96        max_length: Option<u64>,
97        format: Option<String>,
98        enum_values: Option<Vec<String>>,
99        default: Option<String>,
100    },
101    Integer {
102        default: Option<i64>,
103    },
104    Number {
105        default: Option<f64>,
106    },
107    Boolean {
108        default: Option<bool>,
109    },
110    /// R6 step 3: an array of bare scalars. The element is an
111    /// `ArrayElement`, not a boxed `PropertyKind`, so the type itself
112    /// cannot express a nested array, an array of objects, or a
113    /// constrained element — D1's admitted shape is structural rather
114    /// than merely validated.
115    Array {
116        element: ArrayElement,
117    },
118}
119
120/// The element type of an admitted array (R6 step 3). Deliberately a
121/// closed enum of the four scalars with no payload: a constrained or
122/// compound element is unrepresentable here by construction, so a future
123/// widening step must widen this type on purpose rather than by accident.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum ArrayElement {
126    String,
127    Integer,
128    Number,
129    Boolean,
130}
131
132impl PropertyKind {
133    /// Whether the property carries a declared default (any type).
134    #[must_use]
135    pub fn has_default(&self) -> bool {
136        match self {
137            Self::String { default, .. } => default.is_some(),
138            Self::Integer { default } => default.is_some(),
139            Self::Number { default } => default.is_some(),
140            Self::Boolean { default } => default.is_some(),
141            // D1 admits no array default in this step; the front gate
142            // rejects `default` on an array property by name.
143            Self::Array { .. } => false,
144        }
145    }
146}
147
148#[derive(Debug, Clone, PartialEq)]
149pub struct Property {
150    pub name: String,
151    pub kind: PropertyKind,
152    pub required: bool,
153}
154
155#[derive(Debug, Clone)]
156pub struct ContractSchema {
157    pub contract_name: String,
158    pub properties: Vec<Property>,
159}
160
161pub fn parse(contract_name: &str, schema_json: &str) -> Result<ContractSchema, CompileError> {
162    let value: serde_json::Value =
163        serde_json::from_str(schema_json).map_err(|error| CompileError::InvalidSchema {
164            message: error.to_string(),
165        })?;
166
167    require_identifier("contract name", contract_name)?;
168    reject_unrepresentable(&value)?;
169    reject_unknown_top_level_keywords(&value)?;
170    require_strict_object(&value)?;
171
172    let required = required_names(&value)?;
173    let properties = parse_properties(&value, &required)?;
174
175    Ok(ContractSchema {
176        contract_name: contract_name.to_owned(),
177        properties,
178    })
179}
180
181/// Walks the schema keyword-aware: keys of a schema object are keywords and
182/// are matched against the unrepresentable list, but the keys of a
183/// `properties` map are user-defined property NAMES — data, not structure —
184/// so only their values (subschemas) are walked (S1).
185fn reject_unrepresentable(value: &serde_json::Value) -> Result<(), CompileError> {
186    match value {
187        serde_json::Value::Object(map) => {
188            for (key, child) in map {
189                if let Some((construct, alternative)) = UNREPRESENTABLE
190                    .iter()
191                    .find(|(construct, _)| *construct == key)
192                {
193                    return Err(CompileError::Unrepresentable {
194                        construct: (*construct).to_owned(),
195                        alternatives: (*alternative).to_owned(),
196                    });
197                }
198                if key == "properties" {
199                    if let Some(properties) = child.as_object() {
200                        for subschema in properties.values() {
201                            reject_unrepresentable(subschema)?;
202                        }
203                        continue;
204                    }
205                }
206                reject_unrepresentable(child)?;
207            }
208        }
209        serde_json::Value::Array(entries) => {
210            for entry in entries {
211                reject_unrepresentable(entry)?;
212            }
213        }
214        _ => {}
215    }
216    Ok(())
217}
218
219/// F26: the top-level counterpart to the property allowlist. Runs AFTER
220/// `reject_unrepresentable` so the named constructs keep their
221/// `Unrepresentable` variant and their specific alternatives.
222///
223/// `parse` has already parsed the document, and a non-object top level is
224/// rejected by `require_strict_object`; the empty-map case simply finds
225/// no unknown key.
226fn reject_unknown_top_level_keywords(value: &serde_json::Value) -> Result<(), CompileError> {
227    for key in value.as_object().into_iter().flatten().map(|(key, _)| key) {
228        if !TOP_LEVEL_KEYWORDS.contains(&key.as_str()) {
229            return Err(CompileError::Unrepresentable {
230                construct: format!("top-level keyword '{key}'"),
231                alternatives: format!(
232                    "the subset carries {}; annotation keywords are not \
233                     emitted to any target, so carrying them would drift \
234                     the bindings — remove it, or propose it as a \
235                     widening step",
236                    TOP_LEVEL_KEYWORDS.join(", ")
237                ),
238            });
239        }
240    }
241    Ok(())
242}
243
244/// F26: a property keyword the parser does not consume would reach
245/// typify through the raw-JSON feed and shape the Rust binding ONLY.
246///
247/// Called from `parse_string_property` after its `type` check, so `spec`
248/// is always an object here.
249fn reject_unknown_property_keywords(
250    name: &str,
251    spec: &serde_json::Value,
252) -> Result<(), CompileError> {
253    for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
254        if !PROPERTY_KEYWORDS.contains(&key.as_str()) {
255            return Err(CompileError::Unrepresentable {
256                construct: format!("keyword '{key}' on property '{name}'"),
257                alternatives: format!(
258                    "the subset carries {}; remove it, or propose it as a \
259                     widening step",
260                    PROPERTY_KEYWORDS.join(", ")
261                ),
262            });
263        }
264    }
265    Ok(())
266}
267
268/// F26(b), human decision: `pattern` is admitted ONLY as the email
269/// format's companion, byte-equal to what the authoring pipeline emits.
270/// Zod/Pydantic/SQL carry no pattern handling, so any other pattern
271/// would be enforced in Rust alone — the exact drift this closes.
272fn validate_pattern(
273    name: &str,
274    spec: &serde_json::Value,
275    format: Option<&str>,
276) -> Result<(), CompileError> {
277    let Some(value) = spec.get("pattern") else {
278        return Ok(());
279    };
280    let Some(pattern) = value.as_str() else {
281        return Err(invalid_property_keyword(name, "'pattern' must be a string"));
282    };
283    if format == Some("email") && pattern == AUTHORING_EMAIL_PATTERN {
284        return Ok(());
285    }
286    Err(CompileError::Unrepresentable {
287        construct: format!("'pattern' on property '{name}'"),
288        alternatives: "format 'email', or propose pattern support as a widening step".to_owned(),
289    })
290}
291
292fn require_strict_object(value: &serde_json::Value) -> Result<(), CompileError> {
293    if value.get("type").and_then(serde_json::Value::as_str) != Some("object") {
294        return Err(CompileError::InvalidSchema {
295            message: "top-level schema must be an object type".to_owned(),
296        });
297    }
298    if value.get("additionalProperties") != Some(&serde_json::Value::Bool(false)) {
299        return Err(CompileError::InvalidSchema {
300            message: "additionalProperties must be false (strictness is mandatory, charter N2)"
301                .to_owned(),
302        });
303    }
304    Ok(())
305}
306
307fn required_names(value: &serde_json::Value) -> Result<Vec<String>, CompileError> {
308    let Some(required) = value.get("required") else {
309        return Ok(Vec::new());
310    };
311    let Some(entries) = required.as_array() else {
312        return Err(invalid_keyword("required", "must be an array of strings"));
313    };
314    entries
315        .iter()
316        .map(|entry| {
317            entry
318                .as_str()
319                .map(str::to_owned)
320                .ok_or_else(|| invalid_keyword("required", "entries must all be strings"))
321        })
322        .collect()
323}
324
325fn parse_properties(
326    value: &serde_json::Value,
327    required: &[String],
328) -> Result<Vec<Property>, CompileError> {
329    let Some(map) = value
330        .get("properties")
331        .and_then(serde_json::Value::as_object)
332    else {
333        return Err(CompileError::InvalidSchema {
334            message: "schema declares no properties".to_owned(),
335        });
336    };
337    let mut properties = Vec::new();
338    for (name, spec) in map {
339        require_identifier("property name", name)?;
340        let kind = parse_property(name, spec)?;
341        let required = required.contains(name);
342        reject_required_with_default(name, &kind, required)?;
343        reject_non_required_array(name, &kind, required)?;
344        properties.push(Property {
345            name: name.clone(),
346            kind,
347            required,
348        });
349    }
350    Ok(properties)
351}
352
353/// `required` means the caller MUST send the key; `default` means fill it
354/// when omitted — emitted together every target silently demotes the field
355/// to optional (S5 human decision, option a: loud rejection, never a quiet
356/// winner).
357fn reject_required_with_default(
358    name: &str,
359    kind: &PropertyKind,
360    required: bool,
361) -> Result<(), CompileError> {
362    if required && kind.has_default() {
363        return Err(CompileError::InvalidSchema {
364            message: format!(
365                "property '{name}' is both required and has a default; \
366                 choose one: required (caller must send it) or \
367                 default (caller may omit it)"
368            ),
369        });
370    }
371    Ok(())
372}
373
374/// R6 step 3 / charter Addendum A (D8): an array property must be `required`.
375///
376/// typify emits a bare `Vec<T>` whether or not the property is required —
377/// byte-identical output for both — so an absent key and `[]` are
378/// indistinguishable in the Rust binding. Zod (`.optional()`), Pydantic
379/// (`Optional[list[T]] = None`) and SQL (nullable column) all preserve the
380/// difference, so admitting optional arrays would enforce the contract in
381/// three targets and silently lose it in the fourth.
382///
383/// Narrowing the step is the ruled remedy: every alternative shapes typify's
384/// feed or post-processes its output, which is the second normalization path
385/// F26 refused. Optional arrays are deferred to their own widening step.
386fn reject_non_required_array(
387    name: &str,
388    kind: &PropertyKind,
389    required: bool,
390) -> Result<(), CompileError> {
391    if !required && matches!(kind, PropertyKind::Array { .. }) {
392        return Err(CompileError::InvalidSchema {
393            message: format!(
394                "array property '{name}' must be required; mark it required, \
395                 or propose optional arrays as their own widening step"
396            ),
397        });
398    }
399    Ok(())
400}
401
402/// Names are emitted verbatim into TS, Python, Rust, and SQL identifiers
403/// (S2): only `^[A-Za-z_][A-Za-z0-9_]*$` is portable across all four
404/// without renaming machinery, which is out of scope by decision.
405fn require_identifier(role: &str, name: &str) -> Result<(), CompileError> {
406    let mut chars = name.chars();
407    let valid = chars
408        .next()
409        .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
410        && chars.all(|rest| rest.is_ascii_alphanumeric() || rest == '_');
411    if valid {
412        return Ok(());
413    }
414    Err(CompileError::InvalidSchema {
415        message: format!(
416            "{role} '{name}' is not a portable identifier; \
417             names must match [A-Za-z_][A-Za-z0-9_]* to emit into all targets"
418        ),
419    })
420}
421
422fn parse_property(name: &str, spec: &serde_json::Value) -> Result<PropertyKind, CompileError> {
423    match spec.get("type").and_then(serde_json::Value::as_str) {
424        Some("string") => parse_string_property(name, spec),
425        Some(scalar @ ("integer" | "number" | "boolean")) => {
426            parse_scalar_property(name, spec, scalar)
427        }
428        Some("array") => parse_array_property(name, spec),
429        other => Err(CompileError::Unrepresentable {
430            construct: format!("property '{name}' of type {other:?}"),
431            alternatives: "the subset carries string, integer, number, and boolean \
432                           properties, and arrays of those scalars; objects land in \
433                           a later widening step"
434                .to_owned(),
435        }),
436    }
437}
438
439/// R6 step 3: an array of bare scalars. `items` is required and admits
440/// exactly one keyword, `type`; the array itself admits only `type` and
441/// `items` (F26 bijectivity — see `ARRAY_KEYWORDS`/`ARRAY_ITEM_KEYWORDS`).
442fn parse_array_property(
443    name: &str,
444    spec: &serde_json::Value,
445) -> Result<PropertyKind, CompileError> {
446    for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
447        if !ARRAY_KEYWORDS.contains(&key.as_str()) {
448            return Err(CompileError::Unrepresentable {
449                construct: format!("keyword '{key}' on array property '{name}'"),
450                alternatives: format!(
451                    "the array widening step carries {}; remove it, or propose \
452                     it as a widening step",
453                    ARRAY_KEYWORDS.join(", ")
454                ),
455            });
456        }
457    }
458
459    let Some(items) = spec.get("items") else {
460        return Err(CompileError::Unrepresentable {
461            construct: format!("array property '{name}' without 'items'"),
462            alternatives: format!(
463                "an array needs an element type: {}; write \
464                 {{\"type\": \"array\", \"items\": {{\"type\": \"string\"}}}}",
465                ARRAY_KEYWORDS.join(", ")
466            ),
467        });
468    };
469
470    for key in items.as_object().into_iter().flatten().map(|(key, _)| key) {
471        if !ARRAY_ITEM_KEYWORDS.contains(&key.as_str()) {
472            return Err(CompileError::Unrepresentable {
473                construct: format!("keyword '{key}' on the items of array property '{name}'"),
474                alternatives: format!(
475                    "array items carry {} only — a per-element constraint is not \
476                     a SQL column constraint, so admitting it would enforce the \
477                     contract in three targets and not the fourth",
478                    ARRAY_ITEM_KEYWORDS.join(", ")
479                ),
480            });
481        }
482    }
483
484    let element = match items.get("type").and_then(serde_json::Value::as_str) {
485        Some("string") => ArrayElement::String,
486        Some("integer") => ArrayElement::Integer,
487        Some("number") => ArrayElement::Number,
488        Some("boolean") => ArrayElement::Boolean,
489        other => {
490            return Err(CompileError::Unrepresentable {
491                construct: format!("array property '{name}' with items of type {other:?}"),
492                alternatives: "array items carry string, integer, number, or boolean; \
493                               nested arrays and arrays of objects land in a later \
494                               widening step"
495                    .to_owned(),
496            });
497        }
498    };
499
500    Ok(PropertyKind::Array { element })
501}
502
503/// R6 step 2: bare scalars with an optional TYPED default; every other
504/// keyword rejects by name against `SCALAR_KEYWORDS` (F26 bijectivity).
505fn parse_scalar_property(
506    name: &str,
507    spec: &serde_json::Value,
508    scalar: &str,
509) -> Result<PropertyKind, CompileError> {
510    for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
511        if !SCALAR_KEYWORDS.contains(&key.as_str()) {
512            return Err(CompileError::Unrepresentable {
513                construct: format!("keyword '{key}' on {scalar} property '{name}'"),
514                alternatives: format!(
515                    "the scalar widening step carries {}; remove it, or \
516                     propose it as a widening step",
517                    SCALAR_KEYWORDS.join(", ")
518                ),
519            });
520        }
521    }
522    let default = spec.get("default");
523    match scalar {
524        "integer" => {
525            let parsed = typed_default(name, default, scalar, serde_json::Value::as_i64)?;
526            if let Some(val) = parsed {
527                // 2^53 - 1 is the largest integer that survives a round-trip
528                // through an IEEE 754 double without change.  Zod's `.default(…)`
529                // emits an IEEE double, so a schema default beyond this emits a
530                // different value in TS — the silent-lossy class this project
531                // rejects by policy.
532                let max_safe = 9_007_199_254_740_991_i64;
533                let min_safe = -9_007_199_254_740_991_i64;
534                if val > max_safe || val < min_safe {
535                    return Err(CompileError::InvalidSchema {
536                        message: format!(
537                            "integer default {val} on property '{name}' exceeds the \
538                             safe range for JavaScript number binding (±2^53-1); \
539                             the Zod target would emit a different value"
540                        ),
541                    });
542                }
543            }
544            Ok(PropertyKind::Integer { default: parsed })
545        }
546        "number" => Ok(PropertyKind::Number {
547            default: typed_default(name, default, scalar, serde_json::Value::as_f64)?,
548        }),
549        _ => Ok(PropertyKind::Boolean {
550            default: typed_default(name, default, scalar, serde_json::Value::as_bool)?,
551        }),
552    }
553}
554
555fn typed_default<T>(
556    name: &str,
557    value: Option<&serde_json::Value>,
558    scalar: &str,
559    extract: impl Fn(&serde_json::Value) -> Option<T>,
560) -> Result<Option<T>, CompileError> {
561    let Some(value) = value else {
562        return Ok(None);
563    };
564    extract(value).map(Some).ok_or_else(|| {
565        invalid_property_keyword(
566            name,
567            &format!("'default' must be a {scalar} for a {scalar} property"),
568        )
569    })
570}
571
572fn parse_string_property(
573    name: &str,
574    spec: &serde_json::Value,
575) -> Result<PropertyKind, CompileError> {
576    let type_name = spec.get("type").and_then(serde_json::Value::as_str);
577    if type_name != Some("string") {
578        return Err(CompileError::Unrepresentable {
579            construct: format!("property '{name}' of type {type_name:?}"),
580            alternatives: "Phase 1 subset carries string properties; widen in a later phase"
581                .to_owned(),
582        });
583    }
584    reject_unknown_property_keywords(name, spec)?;
585    let format = parse_format(name, spec)?;
586    validate_pattern(name, spec, format.as_deref())?;
587    let enum_values = parse_enum(name, spec)?;
588    let default = parse_default(name, spec)?;
589    if let (Some(values), Some(value)) = (&enum_values, &default) {
590        if !values.contains(value) {
591            return Err(invalid_property_keyword(
592                name,
593                "'default' must be one of the declared 'enum' values",
594            ));
595        }
596    }
597    Ok(PropertyKind::String {
598        min_length: spec.get("minLength").and_then(serde_json::Value::as_u64),
599        max_length: spec.get("maxLength").and_then(serde_json::Value::as_u64),
600        format,
601        enum_values,
602        default,
603    })
604}
605
606fn parse_format(name: &str, spec: &serde_json::Value) -> Result<Option<String>, CompileError> {
607    let Some(value) = spec.get("format") else {
608        return Ok(None);
609    };
610    let Some(format) = value.as_str() else {
611        return Err(invalid_keyword("format", "must be a string"));
612    };
613    if !SUPPORTED_FORMATS.contains(&format) {
614        return Err(CompileError::Unrepresentable {
615            construct: format!("format '{format}' on property '{name}'"),
616            alternatives: "format 'email', or omit 'format'".to_owned(),
617        });
618    }
619    Ok(Some(format.to_owned()))
620}
621
622fn parse_enum(name: &str, spec: &serde_json::Value) -> Result<Option<Vec<String>>, CompileError> {
623    let Some(value) = spec.get("enum") else {
624        return Ok(None);
625    };
626    let Some(entries) = value.as_array() else {
627        return Err(invalid_property_keyword(name, "'enum' must be an array"));
628    };
629    entries
630        .iter()
631        .map(|entry| {
632            entry
633                .as_str()
634                .map(str::to_owned)
635                .ok_or_else(|| invalid_property_keyword(name, "'enum' entries must all be strings"))
636        })
637        .collect::<Result<Vec<_>, _>>()
638        .map(Some)
639}
640
641fn parse_default(name: &str, spec: &serde_json::Value) -> Result<Option<String>, CompileError> {
642    let Some(value) = spec.get("default") else {
643        return Ok(None);
644    };
645    value.as_str().map(str::to_owned).map(Some).ok_or_else(|| {
646        invalid_property_keyword(name, "'default' must be a string for a string property")
647    })
648}
649
650fn invalid_keyword(keyword: &str, detail: &str) -> CompileError {
651    CompileError::InvalidSchema {
652        message: format!("'{keyword}' {detail}"),
653    }
654}
655
656fn invalid_property_keyword(name: &str, detail: &str) -> CompileError {
657    CompileError::InvalidSchema {
658        message: format!("property '{name}' keyword {detail}"),
659    }
660}