pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 3 task 6: just-in-time nudge engine (spec §7.4). Committed
//! first, read-only hereafter (charter §4.1). Pins: mode gate
//! `off|ab|on` via `PUSHKIN_NUDGE` (default `ab`); `off` emits nothing
//! and records nothing; A/B arm assignment is stable per scope (FNV-1a
//! — std hashers are not contractually stable across toolchains); every
//! nudge decision is telemetry-tagged with its arm; a grep-family tool
//! call after a block (visible through the opencode blanket hook) makes
//! the next denial carry a pointer to the right tool instead.

use assert_cmd::Command;
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]
protected_paths = ["pushkin.toml"]
"#;

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

fn write_payload(session: &str) -> String {
    serde_json::json!({
        "sessionID": session,
        "tool": "write",
        "args": { "filePath": "app/api/users/route.ts", "content": NONCONFORMING }
    })
    .to_string()
}

fn grep_payload(session: &str) -> String {
    serde_json::json!({
        "sessionID": session,
        "tool": "grep",
        "args": { "pattern": "UserCreateSchema" }
    })
    .to_string()
}

fn run_hook(dir: &tempfile::TempDir, payload: &str, mode: &str) -> (i32, String) {
    let Ok(mut command) = Command::cargo_bin("pushkin") else {
        return (-1, "cargo_bin resolution failed".to_owned());
    };
    let Ok(output) = command
        .current_dir(dir.path())
        .env("PUSHKIN_NUDGE", mode)
        .args(["hook", "opencode"])
        .write_stdin(payload.to_owned())
        .output()
    else {
        return (-1, "spawn failed".to_owned());
    };
    (
        output.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&output.stdout).into_owned(),
    )
}

fn nudge_event_arms(dir: &tempfile::TempDir) -> Vec<String> {
    let Ok(conn) = rusqlite::Connection::open(dir.path().join(".pushkin/events.db")) else {
        return Vec::new();
    };
    let Ok(mut statement) =
        conn.prepare("SELECT payload FROM events WHERE rule = 'pushkin.nudge' ORDER BY id")
    else {
        return Vec::new();
    };
    let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(0)) else {
        return Vec::new();
    };
    rows.filter_map(Result::ok)
        .filter_map(|payload| {
            serde_json::from_str::<serde_json::Value>(&payload)
                .ok()?
                .get("arm")?
                .as_str()
                .map(str::to_owned)
        })
        .collect()
}

#[test]
fn mode_off_emits_nothing() {
    let dir = repo().unwrap();
    run_hook(&dir, &write_payload("n1"), "off");
    run_hook(&dir, &grep_payload("n1"), "off");
    let (_, second_deny) = run_hook(&dir, &write_payload("n1"), "off");
    assert!(
        !second_deny.contains("nudge:"),
        "off means no nudge line: {second_deny}"
    );
    assert!(
        nudge_event_arms(&dir).is_empty(),
        "off means no nudge telemetry either"
    );
}

#[test]
fn ab_arm_assignment_stable_via_fnv1a() {
    let dir = repo().unwrap();
    run_hook(&dir, &write_payload("n-stable"), "ab");
    run_hook(&dir, &write_payload("n-stable"), "ab");
    run_hook(&dir, &write_payload("n-stable"), "ab");
    let arms = nudge_event_arms(&dir);
    assert!(
        !arms.is_empty(),
        "repeat blocks in ab mode record arm-tagged telemetry"
    );
    assert!(
        arms.windows(2).all(|pair| pair[0] == pair[1]),
        "the same scope maps to the same arm across separate process runs: {arms:?}"
    );
}

#[test]
fn nudge_events_tagged_by_arm() {
    let dir = repo().unwrap();
    run_hook(&dir, &write_payload("n2"), "ab");
    run_hook(&dir, &write_payload("n2"), "ab");
    let arms = nudge_event_arms(&dir);
    assert!(
        arms.iter()
            .all(|arm| arm == "control" || arm == "treatment"),
        "every nudge decision is telemetry-tagged with its A/B arm: {arms:?}"
    );
    assert!(
        !arms.is_empty(),
        "the repeat block recorded a nudge decision"
    );
}

#[test]
fn grep_after_block_gets_tool_pointer() {
    let dir = repo().unwrap();
    run_hook(&dir, &write_payload("n3"), "on");
    let (grep_code, grep_stdout) = run_hook(&dir, &grep_payload("n3"), "on");
    assert_eq!(grep_code, 0);
    assert!(
        grep_stdout.contains("\"allow\""),
        "investigation traffic is allowed, never blocked: {grep_stdout}"
    );
    let (_, deny) = run_hook(&dir, &write_payload("n3"), "on");
    assert!(
        deny.contains("nudge:"),
        "the denial after a grep-for-the-schema carries the nudge: {deny}"
    );
    assert!(
        deny.contains("pushkin instructions"),
        "the nudge points at the right tool: {deny}"
    );
}