pushkin 0.2.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! `pushkin check`: stdin hook payload → gate decision. Exit 0 allow,
//! exit 2 block (+ retry prompt on stderr). A Stop payload (no `tool_input`)
//! sweeps every mapped file in the repo (spec §8.1: cheap per-edit,
//! expensive on Stop). Fail-open on malformed input is LOGGED as an event;
//! a partial parse that reveals a protected-path target fails CLOSED
//! (review directives, Phase 1 plan).
//!
//! `--staged` (spec §12) gates the git INDEX instead of reading stdin —
//! the pre-commit floor's scope: only files this commit actually ships.
//! `--json` swaps the prose retry prompt for the §8.3 envelope on stdout.

use anyhow::Result;
use pushkin_core::envelope::{CheckResult, Decision, Violation};
use pushkin_core::events::EventLog;
use pushkin_core::manifest::Manifest;
use pushkin_core::pipeline::{check_write, WriteRequest};
use std::io::Read;

use super::{apply_waivers, events_db_path, load_manifest};

/// How the verdict is rendered: prose retry prompt (the agent-facing
/// default) or the uniform §8.3 envelope as JSON (spec §17's floor).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Output {
    Prose,
    Json,
}

impl Output {
    fn from_flag(json: bool) -> Self {
        if json {
            Output::Json
        } else {
            Output::Prose
        }
    }
}

pub fn run(staged: bool, json: bool) -> Result<i32> {
    let output = Output::from_flag(json);
    // F71 Phase B, Addendum 4 (2026-08-19): the ratified N13 rider holds here
    // — "no error class of a check that ran fails open". An unloadable
    // manifest errs loudly below and blocks the commit with its own error
    // text; `git commit --no-verify` is the documented escape. Addendum 1's
    // warn-and-pass reading was corrected — it had not priced the rider's
    // error clause or `lefthook_fail_open.rs`, which pins this exact case.
    let manifest = load_manifest()?;

    // The [features] git-plane switch covers the staged check itself, so
    // a floor still installed somewhere (a teammate's clone, CI) obeys
    // the committed manifest rather than its own install state. Positive
    // probe only: a broken manifest already erred loudly above, exactly
    // per the ratified N13 exit contract. A notice, not silence — a
    // skipped gate the human turned off is still worth one stderr line.
    // Agent write-time gating (the stdin path below) is deliberately
    // untouched.
    if staged && !manifest.git_hooks_enabled() {
        eprintln!(
            "pushkin: git hooks disabled ([features] git_hooks = false in \
             pushkin.toml); staged check skipped"
        );
        return Ok(0);
    }

    let log = EventLog::open(events_db_path()?)?;
    let session = log.begin_session()?;

    // --staged never touches stdin: git is the input (spec §12).
    if staged {
        let result = apply_waivers(check_staged(&manifest)?);
        log.append(&session, &result)?;
        return Ok(emit(&result, output));
    }

    let mut raw = String::new();
    std::io::stdin().read_to_string(&mut raw)?;

    let request = match parse_payload(&raw) {
        Ok(Payload::Write(request)) => request,
        Ok(Payload::Stop) => {
            let result = sweep_repo(&manifest)?;
            log.append(&session, &result)?;
            return Ok(emit(&result, output));
        }
        // F48 Phase A — same contract as the hook verb's `evaluate`: path
        // rules decide from the payload, content rules refuse rather than
        // being skipped. The two surfaces are parallel implementations of one
        // contract, so a change to either is a change to both.
        Ok(Payload::MutateNoContent { file, tool }) => {
            let result = apply_waivers(super::gate_mutation(&manifest, &file, tool));
            log.append(&session, &result)?;
            return Ok(emit(&result, output));
        }
        Err(partial) => {
            // Partial parse revealing a protected-path target → fail CLOSED.
            if let Some(file_path) = partial {
                if manifest.is_protected(&file_path) {
                    let result = check_write(
                        &manifest,
                        &WriteRequest {
                            file_path,
                            content: String::new(),
                        },
                    );
                    log.append(&session, &result)?;
                    return Ok(emit(&result, output));
                }
            }
            // F60 — same contract as the hook verb: a payload we cannot read
            // still fails closed when it NAMES a path under an unwaivable rule.
            // The two surfaces are parallel implementations of one rule, so a
            // change to either is a change to both.
            let scanned = apply_waivers(super::gate_unreadable_payload(&manifest, &raw));
            if !scanned.violations.is_empty() {
                log.append(&session, &scanned)?;
                return Ok(emit(&scanned, output));
            }
            // Otherwise fail open — loudly, and on the record.
            log.append_failopen(&session, "malformed hook payload")?;
            eprintln!(
                "pushkin: unrecognized hook payload, failing open (event logged; \
                 run `pushkin doctor` if this repeats)"
            );
            return Ok(0);
        }
    };

    let result = check_write(&manifest, &request);
    let result = apply_waivers(super::gate_read_only(&manifest, result, &request.file_path));
    log.append(&session, &result)?;
    Ok(emit(&result, output))
}

/// Renders the verdict in the requested shape and returns the exit code:
/// 0 allow, 2 block. JSON goes to stdout (machine-readable, spec §17);
/// prose stays on stderr, where the agent-facing retry prompt has always
/// been.
/// The §8.3 envelope plus the resolved manifest path (F73, ruling §2.2).
///
/// The field is added HERE rather than to `CheckResult` on purpose. `CheckResult`
/// crosses the daemon's IPC wire under `deny_unknown_fields` and is constructed at
/// 26 sites, and `pushkin-core` is deliberately free of filesystem knowledge — it
/// cannot know which manifest the CLI resolved. So the type and the wire are
/// untouched and only the rendered envelope grows a key. Additive: `decision`,
/// `violations` and `durationMs` are unchanged, which is what
/// `gate_dispatch_conformance` reads.
fn envelope_json(result: &CheckResult) -> Option<String> {
    let mut value = serde_json::to_value(result).ok()?;
    value
        .as_object_mut()?
        .insert("manifest".to_owned(), super::manifest_display().into());
    serde_json::to_string_pretty(&value).ok()
}

fn emit(result: &CheckResult, output: Output) -> i32 {
    match output {
        Output::Json => println!(
            "{}",
            envelope_json(result).unwrap_or_else(|| {
                // Unreachable — `CheckResult` is plain derived data that
                // `serde_json` cannot fail to serialize — but a fallback
                // that ever fired must fail loud: deny-shaped by
                // construction, so a serialization bug can never launder a
                // blocking verdict into an allow-shaped envelope (M3).
                r#"{"decision":"block","violations":[],"durationMs":0.0}"#.to_owned()
            })
        ),
        Output::Prose => {
            if !result.violations.is_empty() {
                eprintln!("{}", render(result));
            }
        }
    }
    if result.violations.is_empty() {
        0
    } else {
        2
    }
}

/// Evidence half of the amended charter option B: was an agent denied a
/// protected write to `file` that is still uncommitted?
///
/// The window opens at the last commit TOUCHING this path, so committing the
/// file is what resolves the evidence — which is precisely what "a human
/// should own this change" means in practice. `RULE_PROTECTED_PATH` is
/// deliberately never waivable (`waivers.rs:179-183`, "the harness cannot be
/// negotiated with"), so a waiver is not available as an exit and must not
/// be the design's assumed one.
///
/// Fails OPEN on every error — a missing, locked, or unreadable event log
/// yields `false`, so the floor degrades to the S2d advisory rather than
/// inventing a block from an absent record. The write-time gate is the one
/// that matters; this is a second line, and a second line that guesses is
/// worse than one that abstains.
fn agent_was_denied(file: &str) -> bool {
    let Ok(log) = pushkin_core::events::EventLog::open(std::path::Path::new(super::EVENTS_DB))
    else {
        return false;
    };
    let since = super::last_commit_touching(file).unwrap_or_default();
    log.denied_since(pushkin_core::pipeline::RULE_PROTECTED_PATH, file, &since)
        .unwrap_or(false)
}

/// The pre-commit floor's pass (spec §12, §17): every staged path with a
/// manifest mapping, gated on its INDEX content — `git show :<path>`, not
/// the working tree, because the commit ships the index. Files the author
/// never touched are out of scope by construction.
fn check_staged(manifest: &Manifest) -> Result<CheckResult> {
    let started = std::time::Instant::now();
    let mut violations: Vec<Violation> = Vec::new();
    for file_path in super::git::staged_files()? {
        // Protected surface staged: an ADVISORY, never a deny (S2d). The
        // floor runs for humans too, and a human commit of the manifest
        // is legitimate — the write path already denies agents at edit
        // time (and cannot see Bash-side writes; that boundary is the
        // advisory's reason to be loud). The notice stays on stderr so a
        // --json envelope on stdout is unchanged.
        if manifest.is_protected(&file_path) || file_path.starts_with("pushkin/") {
            // ...UNLESS an agent was denied this exact path in the current
            // commit cycle and it is staged anyway. Then the write reached
            // the tree through a surface PreToolUse never saw (`ed`,
            // `git apply`, a shell redirect), and the advisory's own reason
            // to be loud has become evidence: a recorded deny plus a changed
            // tree. That is not a guess about authorship, so it can block.
            // One event, one message — the notice stands down when it does.
            if agent_was_denied(&file_path) {
                violations.push(pushkin_core::pipeline::protected_path_bypass_violation(
                    &file_path,
                ));
                continue;
            }
            eprintln!(
                "pushkin: note — protected surface staged ({file_path}); the write \
                 gate denies agent edits here and this floor does not block commits. \
                 A human should own this change."
            );
        }
        // Read-only paths are checked BEFORE the mapping skip: committed
        // test suites have no contract mapping, and the pre-commit floor
        // is exactly where an agent edit to one must be caught. A staged
        // NEW file is not in HEAD and passes.
        if manifest.is_read_only(&file_path) && super::committed_in_head(&file_path) {
            violations.push(pushkin_core::pipeline::read_only_violation(&file_path));
            continue;
        }
        if manifest.mapping_for(&file_path).is_none() {
            continue;
        }
        let Some(content) = super::git::staged_content(&file_path)? else {
            continue;
        };
        violations.extend(check_write(manifest, &WriteRequest { file_path, content }).violations);
    }
    Ok(CheckResult {
        decision: if violations.is_empty() {
            Decision::Allow
        } else {
            Decision::Block
        },
        violations,
        duration_ms: started.elapsed().as_secs_f64() * 1000.0,
    })
}

enum Payload {
    Write(WriteRequest),
    Stop,
    /// F48 Phase A — `Edit`/`MultiEdit`: a target named, content absent.
    /// Phase B carries the edit operations too, so the check verb can
    /// reconstruct exactly as the hook verb does.
    MutateNoContent {
        file: crate::agents::FileWrite,
        tool: &'static str,
    },
}

/// `Ok(Write)` on a well-formed `PreToolUse` payload; `Ok(Stop)` on a
/// Stop-event payload (no `tool_input`); `Ok(MutateNoContent)` on an
/// `Edit`/`MultiEdit` payload, which names a target and carries no content;
/// Err(Some(path)) when malformed but a file-path
/// target was recoverable; Err(None) when nothing was.
fn parse_payload(raw: &str) -> Result<Payload, Option<String>> {
    let value: serde_json::Value = serde_json::from_str(raw).map_err(|_| None)?;
    let Some(tool_input) = value.get("tool_input") else {
        // Claude's Stop hook payload carries `stop_hook_active`, never `tool_input`.
        if value.get("stop_hook_active").is_some() {
            return Ok(Payload::Stop);
        }
        return Err(None);
    };
    let file_path = tool_input
        .get("file_path")
        .and_then(serde_json::Value::as_str)
        .ok_or(None)?
        .to_owned();

    let tool_name = value.get("tool_name").and_then(serde_json::Value::as_str);

    // F48 Phase A — recognized BEFORE the write parse below, which treats
    // absent content as malformed. Mirrors `normalize_claude_family`; the two
    // parsers are parallel and must stay in step.
    let mutation_tool = match tool_name {
        Some("Edit") => Some("Edit"),
        Some("MultiEdit") => Some("MultiEdit"),
        _ => None,
    };
    if let Some(tool) = mutation_tool {
        return Ok(Payload::MutateNoContent {
            file: crate::agents::FileWrite {
                path: file_path,
                content: String::new(),
                edits: crate::agents::claude_replacements(tool_input),
            },
            tool,
        });
    }

    let well_formed = tool_name.is_some();
    let content = tool_input
        .get("content")
        .and_then(serde_json::Value::as_str);
    match (well_formed, content) {
        (true, Some(content)) => Ok(Payload::Write(WriteRequest {
            file_path,
            content: content.to_owned(),
        })),
        _ => Err(Some(file_path)),
    }
}

/// The expensive whole-repo pass: every mapped file re-checked with the same
/// core the per-write gate uses. Shared with the hook verb's Stop handling.
pub fn sweep_repo(manifest: &Manifest) -> Result<CheckResult> {
    let started = std::time::Instant::now();
    let mut violations: Vec<Violation> = Vec::new();
    for file_path in walk_repo(std::path::Path::new("."))? {
        if manifest.mapping_for(&file_path).is_none() {
            continue;
        }
        let content = std::fs::read_to_string(&file_path)?;
        violations.extend(check_write(manifest, &WriteRequest { file_path, content }).violations);
    }
    Ok(CheckResult {
        decision: if violations.is_empty() {
            Decision::Allow
        } else {
            Decision::Block
        },
        violations,
        duration_ms: started.elapsed().as_secs_f64() * 1000.0,
    })
}

const IGNORED_DIRS: &[&str] = &["node_modules", ".git", ".pushkin", ".scout", "target"];

fn walk_repo(root: &std::path::Path) -> Result<Vec<String>> {
    let mut files = Vec::new();
    let mut pending = vec![root.to_path_buf()];
    while let Some(dir) = pending.pop() {
        for entry in std::fs::read_dir(&dir)? {
            let entry = entry?;
            let path = entry.path();
            let name = entry.file_name().to_string_lossy().into_owned();
            if path.is_dir() {
                if !IGNORED_DIRS.contains(&name.as_str()) {
                    pending.push(path);
                }
            } else if let Ok(relative) = path.strip_prefix(root) {
                files.push(relative.to_string_lossy().into_owned());
            }
        }
    }
    files.sort();
    Ok(files)
}

fn render(result: &CheckResult) -> String {
    let mut lines = vec!["pushkin: write blocked. Fix the violations below and retry.".to_owned()];
    for violation in &result.violations {
        let contract = violation
            .contract
            .as_deref()
            .map(|name| format!(" (contract: {name})"))
            .unwrap_or_default();
        lines.push(format!(
            "  {}:{} [{}]{contract}",
            violation.file, violation.line, violation.rule
        ));
        lines.push(format!("    fix: {}", violation.fix_hint));
        for suggestion in &violation.suggestions {
            lines.push(format!("    try: {suggestion}"));
        }
    }
    lines.join("\n")
}