pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! The Stop verdict is the same on both gate surfaces (F82, ADR-0009 Option A).
//!
//! Authorized by `docs/charters/2026-08-21-f82-stop-floor-parity.md`, executing
//! `docs/decisions/0009-stop-verdict-same-on-both-surfaces.md`.
//!
//! **The divergence this pins.** At Stop, `pushkin hook <agent>` ran the
//! opted-in on-stop floor (`on_stop = true` commands), turned a failure into a
//! blocking `floor.<name>` violation, and applied waivers; `pushkin check` on
//! the same payload swept the repo bare — no floor, no waivers. Same project,
//! same moment, opposite verdicts (measured 2026-08-21: `hook` block on
//! `floor.guard`, `check` allow). The integration guide promises "one verdict
//! everywhere"; this file is what makes that promise checkable for Stop.
//!
//! **Why these rows live here.** The gate-dispatch conformance corpus declares
//! no `[floor]` table, so the on-stop floor is always empty there and the
//! divergence was structurally invisible to it. That suite is committed and
//! read-only (N10), so the floor-armed rows are a NEW file — the same move
//! `stop_encoding.rs` and `nested_manifest_waiver_parity.rs` made.
//!
//! **Every non-effect assertion proves its trigger.** An "allow" from `check`
//! is only evidence of parity if the floor actually RAN on `check`: the green
//! case uses a command that leaves a marker file, and the waiver case first
//! asserts the un-waived block before asserting the waived allow.
//!
//! Fixtures use `git` (already a test dependency) and `sh -c` for the marker —
//! portable across the macOS and Linux runners this repo tests on.

use assert_cmd::Command;
use serde_json::Value;
use std::fs;
use std::path::Path;

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

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

[gates]
"#;

const RED_ON_STOP: &str = r#"
[[floor.commands]]
name = "guard"
run = ["git", "definitely-not-a-verb"]
scope = "whole_repo"
inputs = "repo"
on_stop = true
"#;

/// Green, and it leaves evidence that it ran.
const GREEN_ON_STOP_WITH_MARKER: &str = r#"
[[floor.commands]]
name = "guard"
run = ["sh", "-c", "touch ran.marker"]
scope = "whole_repo"
inputs = "repo"
on_stop = true
"#;

const STOP_PAYLOAD: &str = r#"{"session_id":"s-f82","stop_hook_active":true}"#;

fn repo(floor: &str) -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), format!("{BASE}{floor}"))?;
    Ok(dir)
}

struct Run {
    code: i32,
    stdout: String,
}

fn bin(dir: &Path) -> Result<Command, Box<dyn std::error::Error>> {
    let mut command = Command::cargo_bin("pushkin")?;
    command.current_dir(dir).env("PUSHKIN_DAEMON", "off");
    Ok(command)
}

fn check_stop(dir: &Path) -> Result<Run, Box<dyn std::error::Error>> {
    let output = bin(dir)?
        .args(["check", "--json"])
        .write_stdin(STOP_PAYLOAD.to_owned())
        .output()?;
    Ok(Run {
        code: output.status.code().unwrap_or(-1),
        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
    })
}

fn hook_stop(dir: &Path) -> Result<Run, Box<dyn std::error::Error>> {
    let output = bin(dir)?
        .args(["hook", "claude"])
        .write_stdin(STOP_PAYLOAD.to_owned())
        .output()?;
    Ok(Run {
        code: output.status.code().unwrap_or(-1),
        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
    })
}

/// The envelope's decision and the rule ids it carries.
fn envelope(run: &Run) -> Result<(String, Vec<String>), Box<dyn std::error::Error>> {
    let json: Value = serde_json::from_str(&run.stdout)?;
    let decision = json
        .get("decision")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_owned();
    let rules = json
        .get("violations")
        .and_then(Value::as_array)
        .map(|violations| {
            violations
                .iter()
                .filter_map(|v| v.get("rule").and_then(Value::as_str))
                .map(str::to_owned)
                .collect()
        })
        .unwrap_or_default();
    Ok((decision, rules))
}

fn waive(dir: &Path, rule: &str) -> Result<(), Box<dyn std::error::Error>> {
    let out = bin(dir)?
        .args([
            "waive",
            rule,
            "--path",
            "**",
            "--ttl",
            "2h",
            "--reason",
            "f82 parity test",
        ])
        .output()?;
    assert!(
        out.status.success(),
        "waive must succeed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    Ok(())
}

#[test]
fn check_and_hook_agree_on_a_red_on_stop_command() -> TestResult {
    let dir = repo(RED_ON_STOP)?;

    let hook = hook_stop(dir.path())?;
    let hook_json: Value = serde_json::from_str(&hook.stdout)?;
    assert_eq!(
        hook_json.get("decision").and_then(Value::as_str),
        Some("block"),
        "hook must block on a red on-stop command; got:\n{}",
        hook.stdout
    );
    assert!(
        hook.stdout.contains("floor.guard"),
        "hook must name the failing command by rule id; got:\n{}",
        hook.stdout
    );

    // The F82 flip: before this pass `check` allowed here.
    let check = check_stop(dir.path())?;
    let (decision, rules) = envelope(&check)?;
    assert_eq!(
        decision, "block",
        "check must reach the same verdict as hook; got:\n{}",
        check.stdout
    );
    assert!(
        rules.iter().any(|rule| rule == "floor.guard"),
        "check must carry the same rule id; got rules {rules:?}"
    );
    assert_eq!(check.code, 2, "check's block exit code is 2 (CI contract)");
    Ok(())
}

#[test]
fn a_live_waiver_reaches_the_stop_floor_on_both_surfaces() -> TestResult {
    let dir = repo(RED_ON_STOP)?;

    // Trigger proof first: without the waiver, BOTH surfaces block. A waived
    // "allow" on a surface that never ran the floor would prove nothing.
    let (before, _) = envelope(&check_stop(dir.path())?)?;
    assert_eq!(before, "block", "un-waived check must block first");
    let hook_before: Value = serde_json::from_str(&hook_stop(dir.path())?.stdout)?;
    assert_eq!(
        hook_before.get("decision").and_then(Value::as_str),
        Some("block")
    );

    waive(dir.path(), "floor.guard")?;

    let (after, rules) = envelope(&check_stop(dir.path())?)?;
    assert_eq!(
        after, "allow",
        "a live waiver must reach check's Stop floor; rules left: {rules:?}"
    );
    let hook_after = hook_stop(dir.path())?;
    assert!(
        hook_after.stdout.trim().is_empty(),
        "hook must honour the same waiver — silence, not a block; got:\n{}",
        hook_after.stdout
    );
    Ok(())
}

#[test]
fn a_green_on_stop_command_runs_on_check_and_allows() -> TestResult {
    let dir = repo(GREEN_ON_STOP_WITH_MARKER)?;
    let check = check_stop(dir.path())?;
    let (decision, rules) = envelope(&check)?;
    assert_eq!(decision, "allow", "got:\n{}", check.stdout);
    assert!(
        rules.is_empty(),
        "a green command adds no violation: {rules:?}"
    );
    // The allow is only parity evidence if the floor ran: the command leaves a
    // marker, and a skipped floor leaves none.
    assert!(
        dir.path().join("ran.marker").exists(),
        "check must RUN the opted-in on-stop command, not skip it"
    );
    Ok(())
}

#[test]
fn check_and_hook_agree_when_the_on_stop_command_is_green() -> TestResult {
    let dir = repo(GREEN_ON_STOP_WITH_MARKER)?;
    let (decision, _) = envelope(&check_stop(dir.path())?)?;
    assert_eq!(decision, "allow");
    let hook = hook_stop(dir.path())?;
    assert_eq!(hook.code, 0);
    assert!(
        hook.stdout.trim().is_empty(),
        "hook allows by silence; got:\n{}",
        hook.stdout
    );
    Ok(())
}

#[test]
fn a_repo_with_no_floor_table_is_unchanged_on_check() -> TestResult {
    // The common case — no [floor] at all — must be byte-for-byte what it was:
    // allow, no violations, exit 0.
    let dir = repo("")?;
    let check = check_stop(dir.path())?;
    let (decision, rules) = envelope(&check)?;
    assert_eq!(decision, "allow", "got:\n{}", check.stdout);
    assert!(rules.is_empty());
    assert_eq!(check.code, 0);
    Ok(())
}