pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Shape-2 equivalence: both gate surfaces route through the shared
//! `verbs::decide` path.
//!
//! Authorized by `docs/charters/2026-08-18-gate-dispatch-conformance.md`
//! Addendum 1 (Option A / A-strict, supervising ruling 2026-08-20). A NEW file
//! (N10 / V2): no production file and no committed suite is touched.
//!
//! **What this pins that `gate_dispatch_conformance` does not.** The conformance
//! suite proves the two surfaces AGREE; it would stay green even if `decide`
//! were dead code and both surfaces still ran their old inline dispatch. This
//! file proves the extraction is LIVE — that `decide` is genuinely on both call
//! paths — by driving a payload whose verdict only the shared path produces, on
//! each surface, and asserting the real blocking verdict appears (F76: a test
//! must prove its trigger fired, not merely that nothing changed).
//!
//! The trigger fires by construction: each case asserts a POSITIVE block with a
//! specific rule id. A regression that severed either surface from `decide`
//! (e.g. a surface returning empty/allow, or panicking) turns these red — a
//! vacuous pass is not reachable.

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>>;

/// Arms its own gates — the repo `pushkin.toml` has `[gates]` paused, so a
/// suite leaning on it would assert nothing. Mirrors the conformance fixture.
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"]
read_only_paths = ["crates/**/tests/**"]
retrieval_paths = ["crates/**/*.rs"]
retrieval_tool = "mcp__codebase-retrieval__codebase-retrieval"
"#;

const PROTECTED: &str = "pushkin.toml";
const COMMITTED_TEST: &str = "crates/pushkin-cli/tests/committed_suite.rs";
const MAPPED: &str = "app/api/users/route.ts";

const RULE_PROTECTED: &str = "pushkin.protected_path";
const RULE_READ_ONLY: &str = "pushkin.read_only_path";
const RULE_UNVALIDATED: &str = "contract.boundary.unvalidated_input";

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

fn repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    let p = dir.path();
    fs::write(p.join(PROTECTED), MANIFEST)?;
    fs::create_dir_all(p.join("contracts"))?;
    fs::write(p.join("contracts/user.zod.ts"), "export const user = 1;\n")?;
    fs::create_dir_all(p.join("app/api/users"))?;
    fs::write(p.join(MAPPED), "export const handler = 1;\n")?;
    fs::create_dir_all(p.join("crates/pushkin-cli/tests"))?;
    fs::write(p.join(COMMITTED_TEST), "// committed suite\n")?;
    git(p, &["init", "-q", "."])?;
    git(p, &["add", "-A"])?;
    git(
        p,
        &[
            "-c",
            "user.name=Shape2 Equivalence",
            "-c",
            "user.email=shape2@test",
            "commit",
            "-qm",
            "fixture",
        ],
    )?;
    Ok(dir)
}

/// `pushkin check --json <payload>` — the Floor surface. Cold path pinned so the
/// verdict never depends on a stray daemon on the test host.
fn check_json(dir: &Path, payload: &str) -> Result<String, Box<dyn std::error::Error>> {
    let out = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .env("PUSHKIN_DAEMON", "off")
        .args(["check", "--json"])
        .write_stdin(payload.to_owned())
        .output()?;
    Ok(String::from_utf8(out.stdout)?)
}

/// `pushkin hook claude <payload>` — the `AgentWriteTime` surface. A deny is
/// encoded on stdout; an allow prints nothing.
fn hook_claude(dir: &Path, payload: &str) -> Result<String, Box<dyn std::error::Error>> {
    let out = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .env("PUSHKIN_DAEMON", "off")
        .args(["hook", "claude"])
        .write_stdin(payload.to_owned())
        .output()?;
    Ok(String::from_utf8(out.stdout)?)
}

/// The Write leaf — the shared `decide_write`. A write to a protected path is a
/// block only the evaluation path produces; asserting the rule id on BOTH
/// surfaces proves each reached `decide`.
#[test]
fn write_leaf_blocks_through_decide_on_both_surfaces() -> TestResult {
    let dir = repo()?;
    let payload = r#"{"session_id":"s2-write","tool_name":"Write","tool_input":{"file_path":"pushkin.toml","content":"version = 1"}}"#;

    let check = check_json(dir.path(), payload)?;
    assert!(
        check.contains(RULE_PROTECTED) && check.contains("\"block\""),
        "Floor Write leaf must reach decide and block: {check}"
    );

    let hook = hook_claude(dir.path(), payload)?;
    assert!(
        hook.contains(RULE_PROTECTED) && hook.contains("deny"),
        "AgentWriteTime Write leaf must reach decide and deny: {hook}"
    );
    Ok(())
}

/// The `MutateNoContent` leaf — F48's exact drift class (the identical
/// `Edit`/`MultiEdit` fail-open that had to be fixed twice). An `Edit` of a
/// committed read-only test blocks through the shared `decide_mutate` on both.
#[test]
fn mutate_no_content_leaf_blocks_through_decide_on_both_surfaces() -> TestResult {
    let dir = repo()?;
    let payload = r#"{"session_id":"s2-edit","tool_name":"Edit","tool_input":{"file_path":"crates/pushkin-cli/tests/committed_suite.rs","old_string":"// committed suite","new_string":"// tampered"}}"#;

    let check = check_json(dir.path(), payload)?;
    assert!(
        check.contains(RULE_READ_ONLY) && check.contains("\"block\""),
        "Floor MutateNoContent leaf must reach decide and block: {check}"
    );

    let hook = hook_claude(dir.path(), payload)?;
    assert!(
        hook.contains(RULE_READ_ONLY) && hook.contains("deny"),
        "AgentWriteTime MutateNoContent leaf must reach decide and deny: {hook}"
    );
    Ok(())
}

/// A conforming write is allowed through `decide` on both surfaces — the
/// non-block side of the leaf, so a rule that blocked unconditionally could not
/// masquerade as correct.
#[test]
fn conforming_write_allows_through_decide_on_both_surfaces() -> TestResult {
    let dir = repo()?;
    let payload = r#"{"session_id":"s2-ok","tool_name":"Write","tool_input":{"file_path":"app/api/users/route.ts","content":"import { UserCreateSchema } from \"contracts/user.zod\";\nexport async function POST(req){ const b = UserCreateSchema.parse(await req.json()); return Response.json(b); }"}}"#;

    let check = check_json(dir.path(), payload)?;
    assert!(
        check.contains("\"allow\"") && !check.contains(RULE_UNVALIDATED),
        "Floor conforming write must allow through decide: {check}"
    );

    // The Claude allow envelope carries no deny and no violation rule id.
    let hook = hook_claude(dir.path(), payload)?;
    assert!(
        !hook.contains("deny") && !hook.contains(RULE_UNVALIDATED),
        "AgentWriteTime conforming write must allow through decide: {hook}"
    );
    Ok(())
}