pushkin 0.1.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 2 conformance: the escalation ladder in the deny envelope
//! (addendum §5/§7). attempt N/3 counters from the Phase 1 event log,
//! firmer framing at 2, the STOP shape at 3, waiver pointer addressed to
//! the human. Committed first, read-only hereafter.

use assert_cmd::Command;
use std::fs;

const MANIFEST: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"

[[contracts]]
name = "user"
source = "contracts/user.zod.ts"
emit = ["zod"]

[[mappings]]
glob = "app/api/**/*.ts"
contracts = ["user"]
require = "boundary-validation"

[gates]
suppression_comments = "deny"
protected_paths = ["pushkin.toml", ".claude/settings.json", "schemas/**"]
"#;

const NONCONFORMING: &str = "export async function POST(req: Request) {\n  const body = await req.json();\n  return Response.json({ name: body.name });\n}\n";

fn repo() -> std::io::Result<tempfile::TempDir> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST)?;
    Ok(dir)
}

fn payload(session: &str) -> String {
    serde_json::json!({
        "session_id": session,
        "tool_name": "Write",
        "tool_input": { "file_path": "app/api/users/route.ts", "content": NONCONFORMING }
    })
    .to_string()
}

fn deny_reason(dir: &tempfile::TempDir, session: &str) -> Option<String> {
    let output = Command::cargo_bin("pushkin")
        .ok()?
        .current_dir(dir.path())
        .args(["hook", "claude"])
        .write_stdin(payload(session))
        .output()
        .ok()?;
    let stdout = String::from_utf8_lossy(&output.stdout);
    let json: serde_json::Value = serde_json::from_str(&stdout).ok()?;
    Some(
        json["hookSpecificOutput"]["permissionDecisionReason"]
            .as_str()?
            .to_owned(),
    )
}

#[test]
fn attempt_counter_rendered_in_deny() {
    let dir = repo().unwrap();
    let first = deny_reason(&dir, "ladder-count").unwrap();
    assert!(
        first.contains("attempt 1/3"),
        "first denial carries attempt 1/3: {first}"
    );
}

#[test]
fn attempt_two_firmer_framing() {
    let dir = repo().unwrap();
    let _ = deny_reason(&dir, "ladder-two").unwrap();
    let second = deny_reason(&dir, "ladder-two").unwrap();
    assert!(second.contains("attempt 2/3"), "{second}");
    assert!(
        second.contains("Do not retry the same write"),
        "second denial escalates the framing: {second}"
    );
}

#[test]
fn attempt_three_stop_shape_and_escalation_event() {
    let dir = repo().unwrap();
    let _ = deny_reason(&dir, "ladder-stop").unwrap();
    let _ = deny_reason(&dir, "ladder-stop").unwrap();
    let third = deny_reason(&dir, "ladder-stop").unwrap();
    assert!(
        third.contains("STOP"),
        "third denial changes shape entirely: {third}"
    );
    assert!(
        third.contains("Report the blocker to the human"),
        "hand-back instruction required: {third}"
    );

    // The escalation is on the record (addendum §7/§8).
    let db = dir.path().join(".pushkin/events.db");
    let conn = rusqlite::Connection::open(db).unwrap();
    let escalations: u64 = conn
        .query_row(
            "SELECT COUNT(*) FROM events WHERE rule = 'pushkin.escalation'",
            [],
            |row| row.get(0),
        )
        .unwrap();
    assert_eq!(
        escalations, 1,
        "attempt 3 emits exactly one escalation event"
    );
}

#[test]
fn waiver_cmd_present_and_addressed_to_human() {
    let dir = repo().unwrap();
    let reason = deny_reason(&dir, "ladder-waiver").unwrap();
    assert!(
        reason.contains("pushkin waive contract.boundary.unvalidated_input"),
        "waiver command pointer required: {reason}"
    );
    assert!(
        reason.contains("human"),
        "waiver text is addressed to the human at the keyboard: {reason}"
    );
}

#[test]
fn ladder_resets_per_session() {
    let dir = repo().unwrap();
    let _ = deny_reason(&dir, "session-a").unwrap();
    let _ = deny_reason(&dir, "session-a").unwrap();
    let fresh = deny_reason(&dir, "session-b").unwrap();
    assert!(
        fresh.contains("attempt 1/3"),
        "a new session starts back at attempt 1: {fresh}"
    );
}