pushkin-compiler 0.2.1

Schema compiler for pushkin: canonical JSON Schema emission and generated bindings
Documentation
//! Remediation pass V1 / finding F26 (HIGH): the front gate is BIJECTIVE.
//!
//! Before this suite the parser rejected the named unrepresentable
//! constructs but silently TOLERATED every keyword it did not consume
//! (`title`, `description`, `examples`, `$defs`, and above all
//! `pattern`). Pre-swap that was a silent drop in all four targets;
//! post-swap `targets/rust.rs` feeds typify the RAW schema JSON, so a
//! tolerated-but-unparsed keyword shapes the Rust binding ONLY — silent
//! cross-target drift (spec §5.1 "never silently dropped"; Phase 1 exit
//! "drift-free bindings").
//!
//! The gate now allowlists keywords explicitly at both levels: anything
//! it does not consume is a loud rejection naming the keyword and the
//! alternative. `pattern` is admitted ONLY as the email format's
//! companion, byte-equal to the authoring pipeline's emitted regex.
//!
//! Committed per the red-locally/commit-green protocol; read-only
//! hereafter (charter §4.1, N10).

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

const ALL_TARGETS: [Target; 4] = [Target::Zod, Target::Pydantic, Target::Rust, Target::Sql];

/// The canonical contract as the authoring pipeline emits it, copied
/// byte-for-byte from `schemas/user.schema.json` (including the `pattern`
/// companion on `email`).
const CANONICAL: &str = include_str!("../../../schemas/user.schema.json");

/// The regex the Phase 6 task-2 suite previously inlined: a
/// hand-simplified variant that is NOT byte-equal to the authoring
/// pipeline's output. Reused here as the wrong-regex fixture (V1 step 4)
/// — a real, previously-passing production input, provenance preserved.
const RETIRED_DIVERGENT_PATTERN: &str = r"^[A-Za-z0-9_+-]+@([A-Za-z0-9-]+\.)+[A-Za-z]{2,}$";

fn request(schema_json: &str, target: Target) -> CompileRequest {
    CompileRequest {
        contract_name: "user".to_owned(),
        schema_json: schema_json.to_owned(),
        target,
        epoch: 1,
    }
}

fn top_level_with(extra_key: &str, extra_value: &str) -> String {
    format!(
        r#"{{
          "type": "object",
          "{extra_key}": {extra_value},
          "properties": {{ "name": {{ "type": "string" }} }},
          "required": ["name"],
          "additionalProperties": false
        }}"#
    )
}

fn property_with(extra_key: &str, extra_value: &str) -> String {
    format!(
        r#"{{
          "type": "object",
          "properties": {{
            "name": {{ "type": "string", "{extra_key}": {extra_value} }}
          }},
          "required": ["name"],
          "additionalProperties": false
        }}"#
    )
}

// --- the committed canonical still compiles, unchanged ---

#[test]
fn committed_canonical_compiles_for_every_target() {
    for target in ALL_TARGETS {
        assert!(
            compile(&request(CANONICAL, target)).is_ok(),
            "the committed canonical contract must compile for {target:?}"
        );
    }
}

#[test]
fn committed_canonical_emission_is_byte_identical_twice() {
    for target in ALL_TARGETS {
        let first = compile(&request(CANONICAL, target)).unwrap().content;
        let second = compile(&request(CANONICAL, target)).unwrap().content;
        assert_eq!(
            first, second,
            "emission must stay deterministic for {target:?}"
        );
    }
}

// --- tolerated-but-ignored keywords are now loud, named rejections ---

#[test]
fn annotation_keywords_are_rejected_by_name_at_top_level() {
    for (keyword, value) in [
        ("title", r#""UserCreate""#),
        ("description", r#""a user""#),
        ("examples", r#"[{"name":"Ada"}]"#),
        ("$defs", r#"{"Helper":{"type":"string"}}"#),
    ] {
        let schema = top_level_with(keyword, value);
        let message = compile(&request(&schema, Target::Rust))
            .unwrap_err()
            .to_string();
        assert!(
            message.contains(keyword),
            "rejection must name the keyword '{keyword}': {message}"
        );
    }
}

#[test]
fn annotation_keywords_are_rejected_by_name_at_property_level() {
    for (keyword, value) in [
        ("title", r#""Name""#),
        ("description", r#""the name""#),
        ("examples", r#"["Ada"]"#),
    ] {
        let schema = property_with(keyword, value);
        let message = compile(&request(&schema, Target::Rust))
            .unwrap_err()
            .to_string();
        assert!(
            message.contains(keyword),
            "rejection must name the keyword '{keyword}': {message}"
        );
    }
}

#[test]
fn an_authored_title_can_never_be_silently_clobbered() {
    // rust.rs injects the deterministic root type name into `title`. With
    // the bijective gate an AUTHORED title is rejected before emission,
    // so the injection can never overwrite user intent silently.
    let schema = top_level_with("title", r#""AuthoredName""#);
    let message = compile(&request(&schema, Target::Rust))
        .unwrap_err()
        .to_string();
    assert!(message.contains("title"), "must name 'title': {message}");
}

// --- pattern: admitted only as the email format's companion ---

#[test]
fn pattern_without_email_format_is_rejected_with_the_alternative() {
    let schema = property_with("pattern", r#""^[a-z]+$""#);
    let message = compile(&request(&schema, Target::Rust))
        .unwrap_err()
        .to_string();
    assert!(
        message.contains("pattern"),
        "must name 'pattern': {message}"
    );
    assert!(
        message.contains("email"),
        "must offer format 'email' as the alternative: {message}"
    );
    assert!(
        message.contains("widening"),
        "must point at the widening-step path: {message}"
    );
}

#[test]
fn pattern_with_email_format_but_wrong_regex_is_rejected() {
    // The fixture is the regex the task-2 suite previously inlined: a
    // real, previously-passing production input that is NOT byte-equal to
    // the authoring pipeline's output.
    let schema = format!(
        r#"{{
          "type": "object",
          "properties": {{
            "email": {{
              "type": "string",
              "format": "email",
              "pattern": {}
            }}
          }},
          "required": ["email"],
          "additionalProperties": false
        }}"#,
        serde_json::Value::String(RETIRED_DIVERGENT_PATTERN.to_owned())
    );
    let message = compile(&request(&schema, Target::Rust))
        .unwrap_err()
        .to_string();
    assert!(
        message.contains("pattern"),
        "must name 'pattern': {message}"
    );
}

#[test]
fn the_authoring_pipelines_email_companion_is_accepted() {
    // Exactly the canonical `email` property, byte-for-byte.
    let canonical: serde_json::Value = serde_json::from_str(CANONICAL).unwrap();
    let email = &canonical["properties"]["email"];
    let schema = format!(
        r#"{{
          "type": "object",
          "properties": {{ "email": {email} }},
          "required": ["email"],
          "additionalProperties": false
        }}"#
    );
    for target in ALL_TARGETS {
        assert!(
            compile(&request(&schema, target)).is_ok(),
            "the email format's pinned pattern companion must be accepted for {target:?}"
        );
    }
}

#[test]
fn a_non_string_pattern_is_rejected_by_keyword() {
    // The `pattern` value must be a string before it can be compared to
    // the pinned constant. Without this the malformed-value path would
    // be the one branch of the new gate no test reaches.
    let schema = property_with("pattern", "42");
    let message = compile(&request(&schema, Target::Rust))
        .unwrap_err()
        .to_string();
    assert!(
        message.contains("pattern"),
        "must name 'pattern': {message}"
    );
    assert!(
        message.contains("string"),
        "must say the value has to be a string: {message}"
    );
}

// --- the gate stays keyword-aware, and prior rejections keep their class ---

#[test]
fn property_names_matching_allowlisted_keywords_still_compile() {
    // S1: keys of a `properties` map are user-defined NAMES — data, not
    // structure. A property literally named `title` or `pattern` is fine.
    let schema = r#"{
      "type": "object",
      "properties": {
        "title": { "type": "string" },
        "pattern": { "type": "string" },
        "description": { "type": "string" }
      },
      "required": ["title"],
      "additionalProperties": false
    }"#;
    for target in ALL_TARGETS {
        assert!(
            compile(&request(schema, target)).is_ok(),
            "property names are data, not keywords ({target:?})"
        );
    }
}

// --- cross-target: no emitter output changes for the current contract ---

#[test]
fn no_emitter_output_changes_for_the_committed_contract() {
    // The allowlist is a GATE, not a transform: for the contract as
    // authored today, every target's bytes are exactly what the
    // committed generated artifacts already carry.
    for (target, artifact) in [
        (Target::Zod, "../../generated/user.zod.gen.ts"),
        (Target::Pydantic, "../../generated/user_models.gen.py"),
        (Target::Rust, "../../generated/user.gen.rs"),
        (Target::Sql, "../../generated/user.gen.sql"),
    ] {
        let emitted = compile(&CompileRequest {
            contract_name: "user".to_owned(),
            schema_json: CANONICAL.to_owned(),
            target,
            epoch: artifact_epoch(artifact).unwrap(),
        })
        .unwrap()
        .content;
        let committed = read_artifact(artifact).unwrap();
        assert_eq!(
            emitted, committed,
            "emission drifted from the committed artifact for {target:?}"
        );
    }
}

fn read_artifact(relative: &str) -> Result<String, std::io::Error> {
    let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(relative);
    std::fs::read_to_string(path)
}

/// The committed artifacts are stamped with the manifest epoch; reading it
/// back keeps the comparison about EMISSION rather than about the epoch.
fn artifact_epoch(artifact: &str) -> Option<u32> {
    read_artifact(artifact).ok()?.lines().find_map(|line| {
        line.split("pushkin-epoch:")
            .nth(1)
            .and_then(|rest| rest.split_whitespace().next())
            .and_then(|value| value.parse().ok())
    })
}