pushkin-compiler 0.2.0

Schema compiler for pushkin: canonical JSON Schema emission and generated bindings
Documentation
//! Remediation pass V1 / finding F34 (MEDIUM): integer defaults outside
//! safe-JS range reject loudly.
//!
//! An integer `default` is the sole path by which the compiler authors an
//! integer literal into generated code. i64, BIGINT, and Python `int` hold
//! it exactly; the Zod binding is an IEEE double, so a default above 2^53
//! emits a DIFFERENT VALUE in TS with no error — the silent-lossy class
//! this project rejects by policy.
//!
//! The front gate now rejects integer defaults outside ±(2^53 - 1) with
//! the property name, the offending value, and the target that cannot
//! represent it.  Same shape as `reject_required_with_default`.
//!
//! Committed per the red-locally/commit-green protocol; read-only
//! hereafter (charter §4.1, N10). Expected committed-test edits: NONE.

use pushkin_compiler::{compile, CompileError, CompileRequest, Target};

/// 2^53 - 1: the largest integer that survives a round-trip through an
/// IEEE 754 double without change.  Beyond this, Zod's `.default(…)`
/// emits a different value than the schema declared.
const SAFE_JS_INTEGER_MAX: i64 = 9_007_199_254_740_991;
const NEG_SAFE_JS_INTEGER_MAX: i64 = -9_007_199_254_740_991;

/// The first integer that CANNOT be represented in an IEEE 754 double.
const FIRST_UNSAFE: i64 = 9_007_199_254_740_992; // 2^53
const NEG_FIRST_UNSAFE: i64 = -9_007_199_254_740_992; // -2^53

fn emit(schema: &str, target: Target) -> Result<String, CompileError> {
    compile(&CompileRequest {
        contract_name: "integer_default_range".to_owned(),
        schema_json: schema.to_owned(),
        target,
        epoch: 1,
    })
    .map(|output| output.content)
}

fn make_schema(default_expr: &str) -> String {
    format!(
        r#"{{
  "type": "object",
  "properties": {{
    "big": {{ "type": "integer", "default": {default_expr} }}
  }},
  "additionalProperties": false
}}"#
    )
}

/// A valid default within the safe-JS range must compile on all targets.
#[test]
fn safe_max_integer_default_compiles_all_targets() {
    let schema = make_schema(&SAFE_JS_INTEGER_MAX.to_string());
    for target in [Target::Zod, Target::Pydantic, Target::Rust, Target::Sql] {
        let out = emit(&schema, target);
        assert!(
            out.is_ok(),
            "{target:?} must accept integer default {SAFE_JS_INTEGER_MAX}: {out:?}"
        );
    }
}

/// A valid default at the negative safe boundary must compile on all targets.
#[test]
fn safe_min_integer_default_compiles_all_targets() {
    let schema = make_schema(&NEG_SAFE_JS_INTEGER_MAX.to_string());
    for target in [Target::Zod, Target::Pydantic, Target::Rust, Target::Sql] {
        let out = emit(&schema, target);
        assert!(
            out.is_ok(),
            "{target:?} must accept integer default {NEG_SAFE_JS_INTEGER_MAX}: {out:?}"
        );
    }
}

/// The first integer above the safe range must reject on every target,
///
/// The error message must name the property and state the constraint.
#[test]
fn positive_too_large_integer_default_rejects() {
    let schema = make_schema(&FIRST_UNSAFE.to_string());
    for target in [Target::Zod, Target::Pydantic, Target::Rust, Target::Sql] {
        let err = emit(&schema, target).expect_err("integer default above 2^53 must reject");
        let msg = err.to_string();
        assert!(
            msg.contains("big") && msg.contains(FIRST_UNSAFE.to_string().as_str()),
            "{target:?} rejection must name property 'big' and value {FIRST_UNSAFE}: {msg}"
        );
    }
}

/// The first integer below the negative safe range must reject on every
/// target.
///
/// The error message must name the property and state the constraint.
#[test]
fn negative_too_large_integer_default_rejects() {
    let schema = make_schema(&NEG_FIRST_UNSAFE.to_string());
    for target in [Target::Zod, Target::Pydantic, Target::Rust, Target::Sql] {
        let err = emit(&schema, target).expect_err("integer default below -2^53 must reject");
        let msg = err.to_string();
        assert!(
            msg.contains("big") && msg.contains(NEG_FIRST_UNSAFE.to_string().as_str()),
            "{target:?} rejection must name property 'big' and value {NEG_FIRST_UNSAFE}: {msg}"
        );
    }
}

/// The existing scalar widening contract still compiles byte-identically
/// (the new check must not regress the existing positive cases).
#[test]
fn existing_widening_contract_still_compiles() {
    const SCHEMA: &str = r#"{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "age": { "type": "integer", "default": 30 },
    "ratio": { "type": "number", "default": 0.5 },
    "active": { "type": "boolean", "default": true },
    "score": { "type": "number" },
    "count": { "type": "integer" }
  },
  "required": ["score"],
  "additionalProperties": false
}"#;
    for target in [Target::Zod, Target::Pydantic, Target::Rust, Target::Sql] {
        let out = emit(SCHEMA, target);
        assert!(
            out.is_ok(),
            "{target:?} must still compile the widening schema: {out:?}"
        );
    }
}