pushkin 0.2.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 5 task 2 conformance: `pushkin affected` (spec §12, §15 Phase 5).
//! Git diff (staged by default, `--base <ref>` for a range) → touched
//! contracts via the manifest → blast radius (generated bindings + mapped
//! globs) → targeted checks on the touched mapped files only. Committed
//! first, read-only hereafter (charter N10).

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

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

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

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

[gates]
suppression_comments = "deny"
protected_paths = ["pushkin.toml"]
"#;

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

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 git(dir: &Path, args: &[&str]) -> Option<()> {
    let status = std::process::Command::new("git")
        .args(args)
        .current_dir(dir)
        .env("GIT_AUTHOR_NAME", "Affected Tester")
        .env("GIT_AUTHOR_EMAIL", "affected@example.com")
        .env("GIT_COMMITTER_NAME", "Affected Tester")
        .env("GIT_COMMITTER_EMAIL", "affected@example.com")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .ok()?;
    assert!(status.success(), "git {args:?} must succeed");
    Some(())
}

/// Git repo with the manifest committed and one unmapped file, so an empty
/// diff means an empty affected set.
fn repo() -> Option<tempfile::TempDir> {
    let dir = tempfile::tempdir().ok()?;
    git(dir.path(), &["init", "-q"])?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST).ok()?;
    fs::write(dir.path().join("README.md"), "readme\n").ok()?;
    git(dir.path(), &["add", "."])?;
    git(dir.path(), &["commit", "-q", "-m", "seed"])?;
    Some(dir)
}

fn stage(dir: &Path, rel: &str, content: &str) -> Option<()> {
    let path = dir.join(rel);
    fs::create_dir_all(path.parent()?).ok()?;
    fs::write(&path, content).ok()?;
    git(dir, &["add", rel])
}

fn affected_output(dir: &Path) -> Option<(String, i32)> {
    let output = Command::cargo_bin("pushkin")
        .ok()?
        .arg("affected")
        .current_dir(dir)
        .output()
        .ok()?;
    Some((
        String::from_utf8_lossy(&output.stdout).into_owned(),
        output.status.code().unwrap_or(-1),
    ))
}

#[test]
fn staged_diff_maps_to_touched_contracts() {
    let dir = repo().unwrap();
    stage(dir.path(), "app/api/users/route.ts", CONFORMING).unwrap();
    let (stdout, _) = affected_output(dir.path()).unwrap();
    assert!(
        stdout.contains("user"),
        "touched mapped file must name its contract: {stdout}"
    );
    assert!(
        stdout.contains("app/api/users/route.ts"),
        "the touched file itself must be listed: {stdout}"
    );
}

#[test]
fn unmapped_change_yields_empty_affected_set() {
    let dir = repo().unwrap();
    stage(dir.path(), "docs/notes.md", "notes\n").unwrap();
    let (stdout, code) = affected_output(dir.path()).unwrap();
    assert_eq!(code, 0, "unmapped-only diff must exit 0");
    assert!(
        !stdout.contains("contract: user"),
        "no contract may be reported for an unmapped diff: {stdout}"
    );
}

#[test]
fn affected_lists_blast_radius_bindings_and_globs() {
    let dir = repo().unwrap();
    stage(dir.path(), "app/api/users/route.ts", CONFORMING).unwrap();
    let (stdout, _) = affected_output(dir.path()).unwrap();
    for needle in [
        "generated/user.zod.gen.ts",
        "generated/user.gen.sql",
        "app/api/**/*.ts",
    ] {
        assert!(
            stdout.contains(needle),
            "blast radius must list {needle}: {stdout}"
        );
    }
}

#[test]
fn affected_runs_targeted_checks_only_on_touched_files() {
    let dir = repo().unwrap();
    // A PRE-EXISTING violation committed in the seed state must not be
    // re-checked; only the staged (touched) conforming file is.
    fs::create_dir_all(dir.path().join("app/api/legacy")).unwrap();
    fs::write(dir.path().join("app/api/legacy/route.ts"), NONCONFORMING).unwrap();
    git(dir.path(), &["add", "app/api/legacy/route.ts"]).unwrap();
    git(dir.path(), &["commit", "-q", "-m", "legacy violation"]).unwrap();

    stage(dir.path(), "app/api/users/route.ts", CONFORMING).unwrap();
    let (stdout, code) = affected_output(dir.path()).unwrap();
    assert_eq!(
        code, 0,
        "conforming touched file must pass even though an untouched \
         violation exists elsewhere: {stdout}"
    );
    assert!(
        !stdout.contains("legacy/route.ts"),
        "untouched files must not be checked: {stdout}"
    );
}

#[test]
fn touched_violation_blocks_with_exit_2() {
    let dir = repo().unwrap();
    stage(dir.path(), "app/api/users/route.ts", NONCONFORMING).unwrap();
    let (stdout, code) = affected_output(dir.path()).unwrap();
    assert_eq!(
        code, 2,
        "a violating touched file must block (exit 2): {stdout}"
    );
}

#[test]
fn base_ref_range_maps_committed_changes() {
    let dir = repo().unwrap();
    stage(dir.path(), "app/api/users/route.ts", CONFORMING).unwrap();
    git(dir.path(), &["commit", "-q", "-m", "add users route"]).unwrap();
    let output = Command::cargo_bin("pushkin")
        .unwrap()
        .args(["affected", "--base", "HEAD~1"])
        .current_dir(dir.path())
        .output()
        .unwrap();
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("user") && stdout.contains("app/api/users/route.ts"),
        "--base <ref> must map the committed range: {stdout}"
    );
}