pushkin 0.2.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Legacy (sentinel-era) footprint migration (remediation pass 3, PART B2).
//! `pushkin init` and `pushkin doctor --repair` recognize entries carrying
//! the OLD marker (`_sentinel` / sentinel-v1) as OURS and replace them with
//! pushkin-v1 entries; `--remove-agent` strips BOTH old and new artifacts;
//! the first run with old state dirs and no new ones performs a one-time
//! rename (`.sentinel/` → `.pushkin/`, `sentinel/` → `pushkin/`) with
//! waiver records preserved, and refuses loudly when old and new both
//! exist. Committed with its implementation per the pass-1
//! red-locally/commit-green protocol; read-only hereafter (charter N10).

use assert_cmd::Command;
use serde_json::{json, Value};
use std::fs;

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"]
"#;

/// Conforming handler except for the suppression comment — the only
/// violation is `pushkin.suppression.new`, the rule the legacy waiver
/// below covers under its old spelling.
const SUPPRESSED_WRITE: &str = "export async function POST(req: Request) {\n  // @ts-ignore\n  const body = UserCreateSchema.parse(await req.json());\n  return Response.json(body);\n}\n";

/// A waiver record as a PRE-RENAME binary wrote it: legacy rule id.
const LEGACY_WAIVERS: &str = "[[waivers]]\nid = \"legacy-w1\"\nrule = \"sentinel.suppression.new\"\npath = \"app/api/**\"\nreason = \"granted pre-rename\"\nauthor = \"human\"\ngranted_at = \"2026-08-01T00:00:00Z\"\nexpires_at = \"2099-01-01T00:00:00Z\"\n";

fn repo() -> std::io::Result<tempfile::TempDir> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST)?;
    Ok(dir)
}

fn pushkin(dir: &tempfile::TempDir) -> Option<Command> {
    let mut cmd = Command::cargo_bin("pushkin").ok()?;
    cmd.current_dir(dir.path());
    Some(cmd)
}

fn run(dir: &tempfile::TempDir, args: &[&str]) -> Option<(Option<i32>, String, String)> {
    let output = pushkin(dir)?.args(args).output().ok()?;
    Some((
        output.status.code(),
        String::from_utf8_lossy(&output.stdout).into_owned(),
        String::from_utf8_lossy(&output.stderr).into_owned(),
    ))
}

/// A `.claude/settings.json` as a sentinel-era install wrote it, plus one
/// user-owned hook that must survive every migration path.
fn write_legacy_claude_settings(dir: &tempfile::TempDir) -> std::io::Result<()> {
    let settings = json!({
        "hooks": {
            "PreToolUse": [
                {
                    "_sentinel": "sentinel-v1",
                    "matcher": "Write|Edit|MultiEdit",
                    "hooks": [{ "type": "command", "command": "/old/path/sentinel hook claude" }]
                },
                {
                    "matcher": "Bash",
                    "hooks": [{ "type": "command", "command": "my-own-hook" }]
                }
            ],
            "Stop": [
                {
                    "_sentinel": "sentinel-v1",
                    "hooks": [{ "type": "command", "command": "/old/path/sentinel hook claude" }]
                }
            ]
        }
    });
    fs::create_dir_all(dir.path().join(".claude"))?;
    fs::write(
        dir.path().join(".claude/settings.json"),
        settings.to_string(),
    )
}

fn claude_settings(dir: &tempfile::TempDir) -> Option<Value> {
    let text = fs::read_to_string(dir.path().join(".claude/settings.json")).ok()?;
    serde_json::from_str(&text).ok()
}

#[test]
fn init_renames_legacy_state_and_waiver_dirs_once() {
    let dir = repo().unwrap();
    fs::create_dir_all(dir.path().join(".sentinel")).unwrap();
    fs::write(dir.path().join(".sentinel/instructions.md"), "override").unwrap();
    fs::create_dir_all(dir.path().join("sentinel")).unwrap();
    fs::write(dir.path().join("sentinel/waivers.toml"), LEGACY_WAIVERS).unwrap();

    let (code, stdout, stderr) = run(&dir, &["init"]).unwrap();
    assert_eq!(code, Some(0), "init must succeed: {stdout}{stderr}");
    assert!(
        dir.path().join(".pushkin/instructions.md").exists(),
        "state dir contents must survive the rename"
    );
    assert!(
        !dir.path().join(".sentinel").exists(),
        "old state dir must be gone after the one-time rename"
    );
    let waivers = fs::read_to_string(dir.path().join("pushkin/waivers.toml"))
        .expect("waiver dir renamed with records preserved");
    assert!(
        waivers.contains("legacy-w1"),
        "signed waiver history must survive the move: {waivers}"
    );
    assert!(
        !dir.path().join("sentinel").exists(),
        "old waiver dir must be gone after the one-time rename"
    );
}

#[test]
fn init_refuses_loudly_when_both_state_dirs_exist() {
    let dir = repo().unwrap();
    fs::create_dir_all(dir.path().join(".sentinel")).unwrap();
    fs::create_dir_all(dir.path().join(".pushkin")).unwrap();

    let (code, stdout, stderr) = run(&dir, &["init"]).unwrap();
    assert_ne!(code, Some(0), "init must refuse: {stdout}{stderr}");
    let all = format!("{stdout}{stderr}");
    assert!(
        all.contains(".sentinel") && all.contains(".pushkin"),
        "the refusal must name both directories: {all}"
    );
}

#[test]
fn init_refuses_loudly_when_both_waiver_dirs_exist() {
    let dir = repo().unwrap();
    fs::create_dir_all(dir.path().join("sentinel")).unwrap();
    fs::write(dir.path().join("sentinel/waivers.toml"), LEGACY_WAIVERS).unwrap();
    fs::create_dir_all(dir.path().join("pushkin")).unwrap();
    fs::write(dir.path().join("pushkin/waivers.toml"), "waivers = []\n").unwrap();

    let (code, stdout, stderr) = run(&dir, &["init"]).unwrap();
    assert_ne!(code, Some(0), "init must refuse: {stdout}{stderr}");
    let all = format!("{stdout}{stderr}");
    assert!(
        all.contains("sentinel/") && all.contains("pushkin/"),
        "the refusal must name both directories: {all}"
    );
}

#[test]
fn doctor_repair_performs_the_one_time_rename() {
    let dir = repo().unwrap();
    fs::create_dir_all(dir.path().join(".sentinel")).unwrap();
    fs::write(dir.path().join(".sentinel/instructions.md"), "override").unwrap();

    let (_, stdout, stderr) = run(&dir, &["doctor", "--repair"]).unwrap();
    assert!(
        dir.path().join(".pushkin/instructions.md").exists(),
        "doctor --repair must migrate legacy state dirs: {stdout}{stderr}"
    );
    assert!(!dir.path().join(".sentinel").exists());
}

#[test]
fn init_replaces_legacy_marker_entries_in_place() {
    let dir = repo().unwrap();
    write_legacy_claude_settings(&dir).unwrap();

    let (code, stdout, stderr) = run(&dir, &["init"]).unwrap();
    assert_eq!(code, Some(0), "init must succeed: {stdout}{stderr}");
    let settings = claude_settings(&dir).unwrap();
    let pre = settings["hooks"]["PreToolUse"]
        .as_array()
        .expect("PreToolUse entries");
    let ours: Vec<&Value> = pre
        .iter()
        .filter(|entry| entry.get("_pushkin").is_some())
        .collect();
    assert_eq!(ours.len(), 1, "one pushkin entry, not a stack: {settings}");
    assert!(
        pre.iter().all(|entry| entry.get("_sentinel").is_none()),
        "legacy-marker entries are OURS — replaced, never duplicated: {settings}"
    );
    assert!(
        pre.iter()
            .any(|entry| entry["hooks"][0]["command"] == "my-own-hook"),
        "user-owned hooks must be preserved: {settings}"
    );
}

#[test]
fn doctor_flags_legacy_marker_and_repair_migrates_it() {
    let dir = repo().unwrap();
    write_legacy_claude_settings(&dir).unwrap();

    let (code, stdout, _) = run(&dir, &["doctor"]).unwrap();
    assert_eq!(code, Some(1), "legacy marker is a finding: {stdout}");
    assert!(
        stdout.contains("legacy"),
        "doctor must name the legacy marker: {stdout}"
    );

    let (code, stdout, stderr) = run(&dir, &["doctor", "--repair"]).unwrap();
    assert_eq!(code, Some(0), "repair must succeed: {stdout}{stderr}");
    let settings = claude_settings(&dir).unwrap();
    let rendered = settings.to_string();
    assert!(
        !rendered.contains("_sentinel"),
        "repair must replace the legacy entries: {rendered}"
    );
    assert!(
        rendered.contains("_pushkin"),
        "repair must install current-marker entries: {rendered}"
    );
}

#[test]
fn remove_agent_strips_both_old_and_new_artifacts() {
    let dir = repo().unwrap();
    let hooks = json!({
        "hooks": {
            "PreToolUse": [
                { "_sentinel": "sentinel-v1",
                  "hooks": [{ "type": "command", "command": "/old/path/sentinel hook codex" }] },
                { "_pushkin": "pushkin-v1",
                  "hooks": [{ "type": "command", "command": "/new/path/pushkin hook codex" }] },
                { "matcher": "Bash",
                  "hooks": [{ "type": "command", "command": "my-own-hook" }] }
            ]
        }
    });
    fs::create_dir_all(dir.path().join(".codex/rules")).unwrap();
    fs::write(
        dir.path().join(".codex/hooks.json"),
        serde_json::to_string_pretty(&hooks).unwrap(),
    )
    .unwrap();
    fs::write(dir.path().join(".codex/rules/sentinel.rules"), "old\n").unwrap();
    fs::write(dir.path().join(".codex/rules/pushkin.rules"), "new\n").unwrap();

    let (code, stdout, stderr) = run(&dir, &["init", "--remove-agent", "codex"]).unwrap();
    assert_eq!(code, Some(0), "remove must succeed: {stdout}{stderr}");
    assert!(
        !dir.path().join(".codex/rules/sentinel.rules").exists(),
        "legacy execpolicy floor must be stripped"
    );
    assert!(
        !dir.path().join(".codex/rules/pushkin.rules").exists(),
        "current execpolicy floor must be stripped"
    );
    let text = fs::read_to_string(dir.path().join(".codex/hooks.json")).unwrap();
    assert!(
        !text.contains("_sentinel") && !text.contains("_pushkin"),
        "both marker generations must be stripped: {text}"
    );
    assert!(
        text.contains("my-own-hook"),
        "user-owned entries must be preserved: {text}"
    );
}

#[test]
fn migrated_waiver_still_suppresses_its_matching_deny() {
    let dir = repo().unwrap();
    fs::create_dir_all(dir.path().join("sentinel")).unwrap();
    fs::write(dir.path().join("sentinel/waivers.toml"), LEGACY_WAIVERS).unwrap();

    let (code, stdout, stderr) = run(&dir, &["init"]).unwrap();
    assert_eq!(code, Some(0), "init must succeed: {stdout}{stderr}");

    let payload = json!({
        "session_id": "legacy-migration-suite",
        "tool_name": "Write",
        "tool_input": { "file_path": "app/api/users/route.ts", "content": SUPPRESSED_WRITE }
    })
    .to_string();
    let output = pushkin(&dir)
        .expect("binary resolves")
        .args(["hook", "claude"])
        .write_stdin(payload)
        .output()
        .expect("hook runs");
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.trim().is_empty(),
        "the pre-rename waiver must suppress the renamed rule's deny \
         (Claude-family allow = silence): {stdout}"
    );
}