pushkin 0.1.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 4 task 7: the latency benchmark (spec §8.1 <100ms warm per-file
//! check; charter §C.3 makes it a CI regression gate from Phase 4).
//! Committed first, read-only hereafter (charter §4.1, N10).
//!
//! `#[ignore]` by default: latency numbers from a debug build or a busy
//! laptop are noise. CI runs it explicitly, release-mode:
//! `cargo test --release -p pushkin-cli --test bench_latency -- --ignored`
//! The measured path is the REAL shim round trip — `pushkin hook claude`
//! process spawn + UDS request + warm verdict — not a function call in a
//! loop, because the budget the spec sets is for what an agent hook
//! actually experiences.

use assert_cmd::Command;
use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};

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

const HANDLER_PATH: &str = "app/api/users/route.ts";
const SAMPLES: usize = 30;
const BUDGET_MS: f64 = 100.0;

fn payload() -> String {
    serde_json::json!({
        "session_id": "bench-session",
        "tool_name": "Write",
        "tool_input": { "file_path": HANDLER_PATH, "content": NONCONFORMING }
    })
    .to_string()
}

/// One timed shim round trip. `Err` (spawn/resolution failure) surfaces
/// in the `#[test]` fn — no unwraps in a free helper (clippy-fatal
/// outside `allow-expect-in-tests`; the Phase-3 §7 precedent).
fn hook_once(dir: &Path) -> Result<Duration, String> {
    let mut command = Command::cargo_bin("pushkin").map_err(|e| format!("binary resolves: {e}"))?;
    let started = Instant::now();
    let output = command
        .current_dir(dir)
        .args(["hook", "claude"])
        .write_stdin(payload())
        .output()
        .map_err(|e| format!("hook run: {e}"))?;
    let elapsed = started.elapsed();
    if output.status.code() != Some(0) {
        return Err(format!("hook exited {:?}", output.status.code()));
    }
    Ok(elapsed)
}

#[test]
#[ignore = "latency gate: run release-mode in CI (--ignored)"]
fn bench_warm_per_file_check_under_100ms() {
    let dir = tempfile::tempdir().unwrap();
    fs::write(dir.path().join("pushkin.toml"), MANIFEST).unwrap();

    let (code, _, stderr) = {
        let mut command = Command::cargo_bin("pushkin").unwrap();
        let output = command
            .current_dir(dir.path())
            .args(["daemon", "start"])
            .output()
            .unwrap();
        (
            output.status.code().unwrap_or(-1),
            String::from_utf8_lossy(&output.stdout).into_owned(),
            String::from_utf8_lossy(&output.stderr).into_owned(),
        )
    };
    assert_eq!(code, 0, "daemon start failed: {stderr}");

    // One untimed warm-up populates the memo; the timed samples then
    // measure the steady state the spec budgets for.
    hook_once(dir.path()).unwrap();

    let mut samples_ms: Vec<f64> = (0..SAMPLES)
        .map(|_| hook_once(dir.path()).unwrap().as_secs_f64() * 1000.0)
        .collect();
    samples_ms.sort_by(|a, b| a.partial_cmp(b).expect("no NaN"));

    let p50 = samples_ms[SAMPLES / 2];
    let p95 = samples_ms[(SAMPLES * 95).div_ceil(100) - 1];
    println!("warm hook round trip over {SAMPLES} samples: p50 {p50:.1}ms, p95 {p95:.1}ms");

    let _ = Command::cargo_bin("pushkin")
        .unwrap()
        .current_dir(dir.path())
        .args(["daemon", "stop"])
        .output();

    assert!(
        p95 < BUDGET_MS,
        "warm per-file check p95 {p95:.1}ms exceeds the {BUDGET_MS}ms budget \
         (spec §8.1; charter §C.3 fails the build on regression)"
    );
}