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/// The email regex the authoring pipeline (Zod v4 `z.email()`) emits
62/// alongside `format: "email"`. `pattern` is admitted ONLY as this
63/// format's companion and ONLY byte-equal to this constant (human
64/// decision, recorded §7).
65///
66/// A future authoring-library bump that changes the emitted regex will
67/// fail compile loudly here. That is the DESIGNED behavior, not a bug:
68/// the alternative is the silent cross-target drift this gate closes.
69/// Remediation is a deliberate constant update in the same PR as the bump.
70const 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,}$";
71
72#[derive(Debug, Clone, PartialEq)]
73pub enum PropertyKind {
74    String {
75        min_length: Option<u64>,
76        max_length: Option<u64>,
77        format: Option<String>,
78        enum_values: Option<Vec<String>>,
79        default: Option<String>,
80    },
81    Integer {
82        default: Option<i64>,
83    },
84    Number {
85        default: Option<f64>,
86    },
87    Boolean {
88        default: Option<bool>,
89    },
90}
91
92impl PropertyKind {
93    /// Whether the property carries a declared default (any type).
94    #[must_use]
95    pub fn has_default(&self) -> bool {
96        match self {
97            Self::String { default, .. } => default.is_some(),
98            Self::Integer { default } => default.is_some(),
99            Self::Number { default } => default.is_some(),
100            Self::Boolean { default } => default.is_some(),
101        }
102    }
103}
104
105#[derive(Debug, Clone, PartialEq)]
106pub struct Property {
107    pub name: String,
108    pub kind: PropertyKind,
109    pub required: bool,
110}
111
112#[derive(Debug, Clone)]
113pub struct ContractSchema {
114    pub contract_name: String,
115    pub properties: Vec<Property>,
116}
117
118pub fn parse(contract_name: &str, schema_json: &str) -> Result<ContractSchema, CompileError> {
119    let value: serde_json::Value =
120        serde_json::from_str(schema_json).map_err(|error| CompileError::InvalidSchema {
121            message: error.to_string(),
122        })?;
123
124    require_identifier("contract name", contract_name)?;
125    reject_unrepresentable(&value)?;
126    reject_unknown_top_level_keywords(&value)?;
127    require_strict_object(&value)?;
128
129    let required = required_names(&value)?;
130    let properties = parse_properties(&value, &required)?;
131
132    Ok(ContractSchema {
133        contract_name: contract_name.to_owned(),
134        properties,
135    })
136}
137
138/// Walks the schema keyword-aware: keys of a schema object are keywords and
139/// are matched against the unrepresentable list, but the keys of a
140/// `properties` map are user-defined property NAMES — data, not structure —
141/// so only their values (subschemas) are walked (S1).
142fn reject_unrepresentable(value: &serde_json::Value) -> Result<(), CompileError> {
143    match value {
144        serde_json::Value::Object(map) => {
145            for (key, child) in map {
146                if let Some((construct, alternative)) = UNREPRESENTABLE
147                    .iter()
148                    .find(|(construct, _)| *construct == key)
149                {
150                    return Err(CompileError::Unrepresentable {
151                        construct: (*construct).to_owned(),
152                        alternatives: (*alternative).to_owned(),
153                    });
154                }
155                if key == "properties" {
156                    if let Some(properties) = child.as_object() {
157                        for subschema in properties.values() {
158                            reject_unrepresentable(subschema)?;
159                        }
160                        continue;
161                    }
162                }
163                reject_unrepresentable(child)?;
164            }
165        }
166        serde_json::Value::Array(entries) => {
167            for entry in entries {
168                reject_unrepresentable(entry)?;
169            }
170        }
171        _ => {}
172    }
173    Ok(())
174}
175
176/// F26: the top-level counterpart to the property allowlist. Runs AFTER
177/// `reject_unrepresentable` so the named constructs keep their
178/// `Unrepresentable` variant and their specific alternatives.
179///
180/// `parse` has already parsed the document, and a non-object top level is
181/// rejected by `require_strict_object`; the empty-map case simply finds
182/// no unknown key.
183fn reject_unknown_top_level_keywords(value: &serde_json::Value) -> Result<(), CompileError> {
184    for key in value.as_object().into_iter().flatten().map(|(key, _)| key) {
185        if !TOP_LEVEL_KEYWORDS.contains(&key.as_str()) {
186            return Err(CompileError::Unrepresentable {
187                construct: format!("top-level keyword '{key}'"),
188                alternatives: format!(
189                    "the subset carries {}; annotation keywords are not \
190                     emitted to any target, so carrying them would drift \
191                     the bindings — remove it, or propose it as a \
192                     widening step",
193                    TOP_LEVEL_KEYWORDS.join(", ")
194                ),
195            });
196        }
197    }
198    Ok(())
199}
200
201/// F26: a property keyword the parser does not consume would reach
202/// typify through the raw-JSON feed and shape the Rust binding ONLY.
203///
204/// Called from `parse_string_property` after its `type` check, so `spec`
205/// is always an object here.
206fn reject_unknown_property_keywords(
207    name: &str,
208    spec: &serde_json::Value,
209) -> Result<(), CompileError> {
210    for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
211        if !PROPERTY_KEYWORDS.contains(&key.as_str()) {
212            return Err(CompileError::Unrepresentable {
213                construct: format!("keyword '{key}' on property '{name}'"),
214                alternatives: format!(
215                    "the subset carries {}; remove it, or propose it as a \
216                     widening step",
217                    PROPERTY_KEYWORDS.join(", ")
218                ),
219            });
220        }
221    }
222    Ok(())
223}
224
225/// F26(b), human decision: `pattern` is admitted ONLY as the email
226/// format's companion, byte-equal to what the authoring pipeline emits.
227/// Zod/Pydantic/SQL carry no pattern handling, so any other pattern
228/// would be enforced in Rust alone — the exact drift this closes.
229fn validate_pattern(
230    name: &str,
231    spec: &serde_json::Value,
232    format: Option<&str>,
233) -> Result<(), CompileError> {
234    let Some(value) = spec.get("pattern") else {
235        return Ok(());
236    };
237    let Some(pattern) = value.as_str() else {
238        return Err(invalid_property_keyword(name, "'pattern' must be a string"));
239    };
240    if format == Some("email") && pattern == AUTHORING_EMAIL_PATTERN {
241        return Ok(());
242    }
243    Err(CompileError::Unrepresentable {
244        construct: format!("'pattern' on property '{name}'"),
245        alternatives: "format 'email', or propose pattern support as a widening step".to_owned(),
246    })
247}
248
249fn require_strict_object(value: &serde_json::Value) -> Result<(), CompileError> {
250    if value.get("type").and_then(serde_json::Value::as_str) != Some("object") {
251        return Err(CompileError::InvalidSchema {
252            message: "top-level schema must be an object type".to_owned(),
253        });
254    }
255    if value.get("additionalProperties") != Some(&serde_json::Value::Bool(false)) {
256        return Err(CompileError::InvalidSchema {
257            message: "additionalProperties must be false (strictness is mandatory, charter N2)"
258                .to_owned(),
259        });
260    }
261    Ok(())
262}
263
264fn required_names(value: &serde_json::Value) -> Result<Vec<String>, CompileError> {
265    let Some(required) = value.get("required") else {
266        return Ok(Vec::new());
267    };
268    let Some(entries) = required.as_array() else {
269        return Err(invalid_keyword("required", "must be an array of strings"));
270    };
271    entries
272        .iter()
273        .map(|entry| {
274            entry
275                .as_str()
276                .map(str::to_owned)
277                .ok_or_else(|| invalid_keyword("required", "entries must all be strings"))
278        })
279        .collect()
280}
281
282fn parse_properties(
283    value: &serde_json::Value,
284    required: &[String],
285) -> Result<Vec<Property>, CompileError> {
286    let Some(map) = value
287        .get("properties")
288        .and_then(serde_json::Value::as_object)
289    else {
290        return Err(CompileError::InvalidSchema {
291            message: "schema declares no properties".to_owned(),
292        });
293    };
294    let mut properties = Vec::new();
295    for (name, spec) in map {
296        require_identifier("property name", name)?;
297        let kind = parse_property(name, spec)?;
298        let required = required.contains(name);
299        reject_required_with_default(name, &kind, required)?;
300        properties.push(Property {
301            name: name.clone(),
302            kind,
303            required,
304        });
305    }
306    Ok(properties)
307}
308
309/// `required` means the caller MUST send the key; `default` means fill it
310/// when omitted — emitted together every target silently demotes the field
311/// to optional (S5 human decision, option a: loud rejection, never a quiet
312/// winner).
313fn reject_required_with_default(
314    name: &str,
315    kind: &PropertyKind,
316    required: bool,
317) -> Result<(), CompileError> {
318    if required && kind.has_default() {
319        return Err(CompileError::InvalidSchema {
320            message: format!(
321                "property '{name}' is both required and has a default; \
322                 choose one: required (caller must send it) or \
323                 default (caller may omit it)"
324            ),
325        });
326    }
327    Ok(())
328}
329
330/// Names are emitted verbatim into TS, Python, Rust, and SQL identifiers
331/// (S2): only `^[A-Za-z_][A-Za-z0-9_]*$` is portable across all four
332/// without renaming machinery, which is out of scope by decision.
333fn require_identifier(role: &str, name: &str) -> Result<(), CompileError> {
334    let mut chars = name.chars();
335    let valid = chars
336        .next()
337        .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
338        && chars.all(|rest| rest.is_ascii_alphanumeric() || rest == '_');
339    if valid {
340        return Ok(());
341    }
342    Err(CompileError::InvalidSchema {
343        message: format!(
344            "{role} '{name}' is not a portable identifier; \
345             names must match [A-Za-z_][A-Za-z0-9_]* to emit into all targets"
346        ),
347    })
348}
349
350fn parse_property(name: &str, spec: &serde_json::Value) -> Result<PropertyKind, CompileError> {
351    match spec.get("type").and_then(serde_json::Value::as_str) {
352        Some("string") => parse_string_property(name, spec),
353        Some(scalar @ ("integer" | "number" | "boolean")) => {
354            parse_scalar_property(name, spec, scalar)
355        }
356        other => Err(CompileError::Unrepresentable {
357            construct: format!("property '{name}' of type {other:?}"),
358            alternatives: "the subset carries string, integer, number, and boolean \
359                           properties; arrays and objects land in a later widening step"
360                .to_owned(),
361        }),
362    }
363}
364
365/// R6 step 2: bare scalars with an optional TYPED default; every other
366/// keyword rejects by name against `SCALAR_KEYWORDS` (F26 bijectivity).
367fn parse_scalar_property(
368    name: &str,
369    spec: &serde_json::Value,
370    scalar: &str,
371) -> Result<PropertyKind, CompileError> {
372    for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
373        if !SCALAR_KEYWORDS.contains(&key.as_str()) {
374            return Err(CompileError::Unrepresentable {
375                construct: format!("keyword '{key}' on {scalar} property '{name}'"),
376                alternatives: format!(
377                    "the scalar widening step carries {}; remove it, or \
378                     propose it as a widening step",
379                    SCALAR_KEYWORDS.join(", ")
380                ),
381            });
382        }
383    }
384    let default = spec.get("default");
385    match scalar {
386        "integer" => {
387            let parsed = typed_default(name, default, scalar, serde_json::Value::as_i64)?;
388            if let Some(val) = parsed {
389                // 2^53 - 1 is the largest integer that survives a round-trip
390                // through an IEEE 754 double without change.  Zod's `.default(…)`
391                // emits an IEEE double, so a schema default beyond this emits a
392                // different value in TS — the silent-lossy class this project
393                // rejects by policy.
394                let max_safe = 9_007_199_254_740_991_i64;
395                let min_safe = -9_007_199_254_740_991_i64;
396                if val > max_safe || val < min_safe {
397                    return Err(CompileError::InvalidSchema {
398                        message: format!(
399                            "integer default {val} on property '{name}' exceeds the \
400                             safe range for JavaScript number binding (±2^53-1); \
401                             the Zod target would emit a different value"
402                        ),
403                    });
404                }
405            }
406            Ok(PropertyKind::Integer { default: parsed })
407        }
408        "number" => Ok(PropertyKind::Number {
409            default: typed_default(name, default, scalar, serde_json::Value::as_f64)?,
410        }),
411        _ => Ok(PropertyKind::Boolean {
412            default: typed_default(name, default, scalar, serde_json::Value::as_bool)?,
413        }),
414    }
415}
416
417fn typed_default<T>(
418    name: &str,
419    value: Option<&serde_json::Value>,
420    scalar: &str,
421    extract: impl Fn(&serde_json::Value) -> Option<T>,
422) -> Result<Option<T>, CompileError> {
423    let Some(value) = value else {
424        return Ok(None);
425    };
426    extract(value).map(Some).ok_or_else(|| {
427        invalid_property_keyword(
428            name,
429            &format!("'default' must be a {scalar} for a {scalar} property"),
430        )
431    })
432}
433
434fn parse_string_property(
435    name: &str,
436    spec: &serde_json::Value,
437) -> Result<PropertyKind, CompileError> {
438    let type_name = spec.get("type").and_then(serde_json::Value::as_str);
439    if type_name != Some("string") {
440        return Err(CompileError::Unrepresentable {
441            construct: format!("property '{name}' of type {type_name:?}"),
442            alternatives: "Phase 1 subset carries string properties; widen in a later phase"
443                .to_owned(),
444        });
445    }
446    reject_unknown_property_keywords(name, spec)?;
447    let format = parse_format(name, spec)?;
448    validate_pattern(name, spec, format.as_deref())?;
449    let enum_values = parse_enum(name, spec)?;
450    let default = parse_default(name, spec)?;
451    if let (Some(values), Some(value)) = (&enum_values, &default) {
452        if !values.contains(value) {
453            return Err(invalid_property_keyword(
454                name,
455                "'default' must be one of the declared 'enum' values",
456            ));
457        }
458    }
459    Ok(PropertyKind::String {
460        min_length: spec.get("minLength").and_then(serde_json::Value::as_u64),
461        max_length: spec.get("maxLength").and_then(serde_json::Value::as_u64),
462        format,
463        enum_values,
464        default,
465    })
466}
467
468fn parse_format(name: &str, spec: &serde_json::Value) -> Result<Option<String>, CompileError> {
469    let Some(value) = spec.get("format") else {
470        return Ok(None);
471    };
472    let Some(format) = value.as_str() else {
473        return Err(invalid_keyword("format", "must be a string"));
474    };
475    if !SUPPORTED_FORMATS.contains(&format) {
476        return Err(CompileError::Unrepresentable {
477            construct: format!("format '{format}' on property '{name}'"),
478            alternatives: "format 'email', or omit 'format'".to_owned(),
479        });
480    }
481    Ok(Some(format.to_owned()))
482}
483
484fn parse_enum(name: &str, spec: &serde_json::Value) -> Result<Option<Vec<String>>, CompileError> {
485    let Some(value) = spec.get("enum") else {
486        return Ok(None);
487    };
488    let Some(entries) = value.as_array() else {
489        return Err(invalid_property_keyword(name, "'enum' must be an array"));
490    };
491    entries
492        .iter()
493        .map(|entry| {
494            entry
495                .as_str()
496                .map(str::to_owned)
497                .ok_or_else(|| invalid_property_keyword(name, "'enum' entries must all be strings"))
498        })
499        .collect::<Result<Vec<_>, _>>()
500        .map(Some)
501}
502
503fn parse_default(name: &str, spec: &serde_json::Value) -> Result<Option<String>, CompileError> {
504    let Some(value) = spec.get("default") else {
505        return Ok(None);
506    };
507    value.as_str().map(str::to_owned).map(Some).ok_or_else(|| {
508        invalid_property_keyword(name, "'default' must be a string for a string property")
509    })
510}
511
512fn invalid_keyword(keyword: &str, detail: &str) -> CompileError {
513    CompileError::InvalidSchema {
514        message: format!("'{keyword}' {detail}"),
515    }
516}
517
518fn invalid_property_keyword(name: &str, detail: &str) -> CompileError {
519    CompileError::InvalidSchema {
520        message: format!("property '{name}' keyword {detail}"),
521    }
522}