pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Remediation (lefthook floor) L1/L2: `check --staged` and `check --json`.
//!
//! The scope fix (spec §12, §17). The installed pre-commit floor ran a
//! Stop-payload WHOLE-REPO sweep, so a commit could be blocked by a
//! pre-existing violation in a file the author never touched — and,
//! symmetrically, a commit could pass while its own staged content was
//! never examined (observed live on this repo, 2026-08-14).
//!
//! `--staged` gates the git INDEX, not the working tree: the commit ships
//! what is staged. Committed first, read-only hereafter (charter §4.1, N10).
//!
//! Helpers return `Result` and tests propagate with `?` — the house
//! pattern for satisfying the workspace's `unwrap_used`/`expect_used`
//! deny without a suppression (N9).

use assert_cmd::Command;
use std::fs;
use std::path::Path;
use std::process::Command as StdCommand;

type TestResult = Result<(), Box<dyn std::error::Error>>;

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]
protected_paths = ["pushkin.toml"]
"#;

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

const CONFORMING: &str = "import { UserCreateSchema } from \"../../../contracts/user.zod\";\n\n\
export async function POST(req: Request) {\n\
  const body = UserCreateSchema.parse(await req.json());\n\
  return Response.json(body);\n\
}\n";

const ROUTE: &str = "app/api/users/route.ts";

/// A real git repo: `--staged` reads the index, so these tests cannot use
/// a bare temp dir the way the stdin-mode suites do.
fn git_repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST)?;
    for args in [
        ["init", "--initial-branch=main"].as_slice(),
        ["config", "user.email", "test@example.invalid"].as_slice(),
        ["config", "user.name", "Pushkin Test"].as_slice(),
        ["config", "commit.gpgsign", "false"].as_slice(),
    ] {
        git(dir.path(), args)?;
    }
    Ok(dir)
}

fn git(dir: &Path, args: &[&str]) -> TestResult {
    let output = StdCommand::new("git")
        .args(args)
        .current_dir(dir)
        .output()?;
    assert!(
        output.status.success(),
        "git {args:?}: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    Ok(())
}

fn write_at(dir: &Path, relative: &str, content: &str) -> TestResult {
    let full = dir.join(relative);
    if let Some(parent) = full.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(full, content)?;
    Ok(())
}

/// Stages `content` at `relative` in one step (the common setup shape).
fn stage(dir: &Path, relative: &str, content: &str) -> TestResult {
    write_at(dir, relative, content)?;
    git(dir, &["add", relative])
}

/// One committed baseline so the index has history to diverge from.
fn commit_all(dir: &Path, message: &str) -> TestResult {
    git(dir, &["add", "-A"])?;
    git(dir, &["commit", "-m", message])
}

/// The verdict: exit code, stdout, stderr.
struct Verdict {
    code: Option<i32>,
    stdout: String,
    stderr: String,
}

/// Runs `pushkin check` with the given flags and NO stdin attached, so a
/// regression that reads stdin in `--staged` mode blocks and fails loudly
/// rather than hanging the suite.
fn check(dir: &Path, args: &[&str]) -> Result<Verdict, Box<dyn std::error::Error>> {
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .arg("check")
        .args(args)
        .write_stdin("")
        .output()?;
    Ok(Verdict {
        code: output.status.code(),
        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
    })
}

// ---------- L1: --staged scope ----------

#[test]
fn staged_nonconforming_mapped_file_blocks() -> TestResult {
    let dir = git_repo()?;
    stage(dir.path(), ROUTE, NONCONFORMING)?;

    let verdict = check(dir.path(), &["--staged"])?;
    assert_eq!(
        verdict.code,
        Some(2),
        "staged violation blocks: {}",
        verdict.stderr
    );
    assert!(
        verdict.stderr.contains(ROUTE),
        "envelope names the offending file: {}",
        verdict.stderr
    );
    Ok(())
}

#[test]
fn preexisting_violation_in_untouched_file_does_not_block_a_clean_change() -> TestResult {
    // THE finding: the whole-repo sweep blocked commits over violations
    // the author never touched. Scope is the staged set, nothing else.
    let dir = git_repo()?;
    write_at(dir.path(), "app/api/legacy/route.ts", NONCONFORMING)?;
    commit_all(dir.path(), "seed pre-existing violation")?;

    // A clean, conforming change elsewhere in the same mapped glob.
    stage(dir.path(), ROUTE, CONFORMING)?;

    let verdict = check(dir.path(), &["--staged"])?;
    assert_eq!(
        verdict.code,
        Some(0),
        "untouched pre-existing violation must not block this commit: {}",
        verdict.stderr
    );
    Ok(())
}

#[test]
fn nothing_staged_allows_without_reading_stdin() -> TestResult {
    let dir = git_repo()?;
    write_at(dir.path(), ROUTE, NONCONFORMING)?;
    commit_all(dir.path(), "seed")?;
    // Working-tree edit, deliberately NOT staged.
    write_at(dir.path(), ROUTE, NONCONFORMING)?;

    let verdict = check(dir.path(), &["--staged"])?;
    assert_eq!(
        verdict.code,
        Some(0),
        "empty staged set is an allow: {}",
        verdict.stderr
    );
    Ok(())
}

#[test]
fn index_is_truth_when_index_and_worktree_diverge() -> TestResult {
    let dir = git_repo()?;
    // Violation staged, then FIXED in the working tree: the commit still
    // ships the bad index content, so this must block.
    stage(dir.path(), ROUTE, NONCONFORMING)?;
    write_at(dir.path(), ROUTE, CONFORMING)?;

    let verdict = check(dir.path(), &["--staged"])?;
    assert_eq!(
        verdict.code,
        Some(2),
        "index is truth — staged violation blocks: {}",
        verdict.stderr
    );
    Ok(())
}

#[test]
fn clean_index_allows_even_when_worktree_is_dirty() -> TestResult {
    let dir = git_repo()?;
    // The inverse: conforming content staged, violation only in the tree.
    stage(dir.path(), ROUTE, CONFORMING)?;
    write_at(dir.path(), ROUTE, NONCONFORMING)?;

    let verdict = check(dir.path(), &["--staged"])?;
    assert_eq!(
        verdict.code,
        Some(0),
        "unstaged worktree violation is not this commit's problem: {}",
        verdict.stderr
    );
    Ok(())
}

#[test]
fn staged_deletion_is_skipped() -> TestResult {
    let dir = git_repo()?;
    write_at(dir.path(), ROUTE, CONFORMING)?;
    commit_all(dir.path(), "seed")?;
    git(dir.path(), &["rm", ROUTE])?;

    let verdict = check(dir.path(), &["--staged"])?;
    assert_eq!(
        verdict.code,
        Some(0),
        "a deleted path has no content to gate: {}",
        verdict.stderr
    );
    Ok(())
}

#[test]
fn unmapped_staged_files_are_ignored() -> TestResult {
    let dir = git_repo()?;
    stage(dir.path(), "README.md", "# not a mapped glob\n")?;

    let verdict = check(dir.path(), &["--staged"])?;
    assert_eq!(
        verdict.code,
        Some(0),
        "only manifest-mapped files are gated: {}",
        verdict.stderr
    );
    Ok(())
}

#[test]
fn staged_decision_is_appended_to_the_event_log() -> TestResult {
    // N7: every gate decision is an append-only event, this path included.
    let dir = git_repo()?;
    stage(dir.path(), ROUTE, NONCONFORMING)?;

    let verdict = check(dir.path(), &["--staged"])?;
    assert_eq!(verdict.code, Some(2));
    assert!(
        dir.path().join(".pushkin/events.db").exists(),
        "the staged decision must reach the event log"
    );
    Ok(())
}

// ---------- L2: --json envelope ----------

#[test]
fn json_flag_emits_the_uniform_envelope_on_stdout() -> TestResult {
    let dir = git_repo()?;
    stage(dir.path(), ROUTE, NONCONFORMING)?;

    let verdict = check(dir.path(), &["--staged", "--json"])?;
    assert_eq!(verdict.code, Some(2), "exit codes are unchanged by --json");

    let envelope: serde_json::Value = serde_json::from_str(&verdict.stdout)?;
    assert_eq!(
        envelope.get("decision").and_then(serde_json::Value::as_str),
        Some("block"),
        "decision matches the verdict: {}",
        verdict.stdout
    );
    let violations = envelope
        .get("violations")
        .and_then(serde_json::Value::as_array)
        .ok_or("violations[] present")?;
    assert!(
        !violations.is_empty(),
        "block carries violations: {}",
        verdict.stdout
    );
    assert!(
        envelope.get("durationMs").is_some(),
        "§8.3 duration field present: {}",
        verdict.stdout
    );
    Ok(())
}

#[test]
fn json_allow_envelope_is_wellformed() -> TestResult {
    let dir = git_repo()?;
    stage(dir.path(), ROUTE, CONFORMING)?;

    let verdict = check(dir.path(), &["--staged", "--json"])?;
    assert_eq!(verdict.code, Some(0));
    let envelope: serde_json::Value = serde_json::from_str(&verdict.stdout)?;
    assert_eq!(
        envelope.get("decision").and_then(serde_json::Value::as_str),
        Some("allow"),
        "{}",
        verdict.stdout
    );
    assert_eq!(
        envelope
            .get("violations")
            .and_then(serde_json::Value::as_array)
            .map(Vec::len),
        Some(0),
        "{}",
        verdict.stdout
    );
    Ok(())
}

#[test]
fn json_composes_with_the_stdin_write_mode() -> TestResult {
    // --json is orthogonal to input mode: the stdin ladder still works.
    let dir = git_repo()?;
    let payload = r#"{"tool_name":"Write","tool_input":{"file_path":"app/api/users/route.ts","content":"export async function POST(req: Request) {\n  const body = await req.json();\n  return Response.json({ name: body.name });\n}\n"}}"#;

    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir.path())
        .args(["check", "--json"])
        .write_stdin(payload)
        .output()?;

    assert_eq!(output.status.code(), Some(2));
    let stdout = String::from_utf8_lossy(&output.stdout);
    let envelope: serde_json::Value = serde_json::from_str(&stdout)?;
    assert_eq!(
        envelope.get("decision").and_then(serde_json::Value::as_str),
        Some("block"),
        "{stdout}"
    );
    Ok(())
}