pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Zod-source authoring emission (spec §4.2: "Zod authoring compiles via a
//! bundled JS runtime"). Runs Bun to evaluate the contract source and emit
//! JSON Schema 2020-12; the emitted file is the canonical artifact (N1) that
//! binding generation consumes. Deterministic: stable key order via Bun's
//! JSON.stringify + fixed epoch injection.

use anyhow::{bail, Context, Result};

use super::external;

/// The emission script evaluated by Bun. Kept as a compile-time constant so
/// the CLI stays a single self-contained binary (no runtime asset lookup).
/// Inputs arrive via PUSHKIN_* env vars: `bun -e` does not forward argv.
const EMIT_SCRIPT: &str = r#"
const sourcePath = process.env.PUSHKIN_SOURCE;
const exportName = process.env.PUSHKIN_EXPORT;
const epoch = process.env.PUSHKIN_EPOCH;
const module = await import(sourcePath);
const schema = module[exportName];
if (schema === undefined) {
  console.error(`pushkin: export '${exportName}' not found in ${sourcePath}`);
  process.exit(3);
}
const { z } = await import("zod");
const jsonSchema = z.toJSONSchema(schema, { target: "draft-2020-12", io: "input" });
const withHeader = {
  $schema: "https://json-schema.org/draft/2020-12/schema",
  $comment: `GENERATED by pushkin compile from ${sourcePath.split("/").pop()} — do not hand-edit. pushkin-epoch: ${epoch}`,
  ...jsonSchema,
};
console.log(JSON.stringify(withHeader, null, 2));
"#;

/// Emits the canonical JSON Schema for one Zod contract source.
///
/// Fails loudly (charter conduct §4.4) when Bun is absent, the source does
/// not evaluate, or the named export is missing — never a silent fallback.
pub fn emit_schema_from_zod(source: &str, export_name: &str, epoch: u32) -> Result<String> {
    let absolute = std::fs::canonicalize(source)
        .with_context(|| format!("contract source '{source}' not found"))?;

    let output = external::bun_emit(
        EMIT_SCRIPT,
        &[
            ("PUSHKIN_SOURCE", &absolute.to_string_lossy()),
            ("PUSHKIN_EXPORT", export_name),
            ("PUSHKIN_EPOCH", &epoch.to_string()),
        ],
    )
    .with_context(|| format!("Zod authoring emission failed for '{source}'"))?;

    if !output.status.success() {
        bail!(
            "Zod authoring emission failed for '{source}' (export {export_name}): {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    let mut schema = String::from_utf8(output.stdout).context("emitted schema is not UTF-8")?;
    if !schema.ends_with('\n') {
        schema.push('\n');
    }
    Ok(schema)
}

/// `user` → `UserCreateSchema` (the Phase 0/pipeline naming convention).
pub fn export_name_for(contract: &str) -> String {
    let mut pascal = String::new();
    for part in contract.split(['-', '_']) {
        let mut chars = part.chars();
        if let Some(first) = chars.next() {
            pascal.extend(first.to_uppercase());
            pascal.push_str(chars.as_str());
        }
    }
    format!("{pascal}CreateSchema")
}