pushkin 0.2.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Distribution stream S1(c) — the read-only-paths gate (D1, re-ruled
//! 2026-08-15 after the waiver flow was execution-proven unable to clear
//! `pushkin.protected_path`: "Read-only paths: deny edits, allow new
//! files").
//!
//! `[gates] read_only_paths` globs protect COMMITTED files — the predicate
//! is presence in git HEAD, which is N10's own wording ("committed first,
//! read-only hereafter"): an agent write to a committed file under the
//! globs is denied (`pushkin.read_only_path`, unwaivable like protected
//! paths); creating a new file, and editing it for as long as it stays
//! uncommitted, is the ordinary RED-suite authoring window and needs no
//! ceremony.
//!
//! Committed first (RED — at the baseline the manifest itself rejects the
//! unknown `read_only_paths` key, so every test here is red), read-only
//! hereafter (N10).

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

/// A manifest whose gates mark `crates/**/tests/**` read-only — the exact
/// shape this repo's own manifest adopts at the end of this pass.
const MANIFEST_WITH_READ_ONLY: &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"]
read_only_paths = ["crates/**/tests/**"]
"#;

const COMMITTED_TEST: &str = "crates/pushkin-cli/tests/committed_suite.rs";
const NEW_TEST: &str = "crates/pushkin-cli/tests/brand_new_red_suite.rs";

fn git(dir: &Path, args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
    let status = StdCommand::new("git")
        .current_dir(dir)
        .args(args)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()?;
    if !status.success() {
        return Err(format!("git {args:?} failed in fixture").into());
    }
    Ok(())
}

/// A git-backed fixture: the manifest and one suite file are COMMITTED —
/// in HEAD — so the read-only predicate has something real to key on.
fn repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST_WITH_READ_ONLY)?;
    fs::create_dir_all(dir.path().join("crates/pushkin-cli/tests"))?;
    fs::write(
        dir.path().join(COMMITTED_TEST),
        "// a committed, read-only suite\n",
    )?;
    git(dir.path(), &["init", "-q", "."])?;
    git(dir.path(), &["add", "-A"])?;
    git(
        dir.path(),
        &[
            "-c",
            "user.name=Gate Suite",
            "-c",
            "user.email=gate@test",
            "commit",
            "-qm",
            "fixture",
        ],
    )?;
    Ok(dir)
}

/// A gated write attempt via the stdin payload path (the cli.rs precedent):
/// exit 2 carries a deny, exit 0 an allow; stderr carries the rule id.
fn check_write(
    dir: &Path,
    file_path: &str,
) -> Result<(Option<i32>, String), Box<dyn std::error::Error>> {
    let payload = format!(
        r#"{{"tool_name":"Write","tool_input":{{"file_path":"{file_path}","content":"// agent write"}}}}"#
    );
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .write_stdin(payload)
        .arg("check")
        .output()?;
    Ok((
        output.status.code(),
        format!(
            "{}{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        ),
    ))
}

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

    let (code, output) = check_write(dir.path(), COMMITTED_TEST)?;
    assert_eq!(
        code,
        Some(2),
        "an agent edit to a committed test must be denied: {output}"
    );
    assert!(
        output.contains("pushkin.read_only_path"),
        "the deny names its own rule: {output}"
    );
    Ok(())
}

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

    let (code, output) = check_write(dir.path(), NEW_TEST)?;
    assert_eq!(
        code,
        Some(0),
        "a NEW file under the glob is a phase-authored RED suite — allowed \
         with no ceremony (the N10 mirror): {output}"
    );
    Ok(())
}

#[test]
fn an_uncommitted_file_stays_editable_until_committed() -> TestResult {
    let dir = repo()?;
    fs::write(dir.path().join(NEW_TEST), "// draft red suite\n")?;

    let (code, output) = check_write(dir.path(), NEW_TEST)?;
    assert_eq!(
        code,
        Some(0),
        "the authoring window: a file on disk but not in HEAD is still the \
         author's to iterate on: {output}"
    );
    Ok(())
}

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

    let waive = Command::cargo_bin("pushkin")?
        .current_dir(dir.path())
        .env("GIT_AUTHOR_NAME", "Read Only Suite")
        .args([
            "waive",
            "pushkin.read_only_path",
            "--path",
            "crates/**/tests/**",
            "--ttl",
            "2h",
            "--reason",
            "read-only-paths gate suite",
        ])
        .output()?;
    assert!(
        waive.status.success(),
        "the grant itself records; suppression is what must refuse: {}",
        String::from_utf8_lossy(&waive.stderr)
    );

    let (code, output) = check_write(dir.path(), COMMITTED_TEST)?;
    assert_eq!(
        code,
        Some(2),
        "read-only paths are unwaivable by construction, like protected \
         paths: {output}"
    );
    assert!(
        output.contains("pushkin.read_only_path"),
        "the deny still names its rule: {output}"
    );
    Ok(())
}

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

    let (code, output) = check_write(dir.path(), "pushkin.toml")?;
    assert_eq!(
        code,
        Some(2),
        "the protected-path deny is unchanged beside read_only_paths: {output}"
    );
    assert!(
        output.contains("pushkin.protected_path"),
        "protected keeps its own rule id: {output}"
    );
    Ok(())
}