pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! R1 facade boundary — no external process is spawned outside a facade.
//!
//! Charter `docs/charters/2026-08-20-r1-facades.md` §4. This is the greppable
//! invariant of the pattern sweep made a committed test so it cannot rot: every
//! `Command::new(` under `pushkin-cli/src/**` must sit in a facade module
//! (`verbs/git.rs` for git, `verbs/external.rs` for other tools) or on a line
//! immediately preceded by a `// FACADE-EXEMPT:` marker naming why.
//!
//! A new legitimate exemption is a visible edit to the allow-set here — that
//! friction is the point (design §3.1, `docs/claude_refactor-patterns-design-2026-08-20.md`).
//!
//! Not a `.github/workflows/**` change (that tree is protected): the boundary is
//! enforced by this source-text scan, run like any other test.

use std::fs;
use std::path::{Path, PathBuf};

/// The `pushkin-cli/src` tree this test guards.
fn src_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("src")
}

/// Facade modules allowed to call `Command::new` directly — relative to `src/`.
const FACADE_FILES: &[&str] = &["verbs/git.rs", "verbs/external.rs"];

/// The exact marker an exempt spawn site must carry on the line above it.
const EXEMPT_MARKER: &str = "// FACADE-EXEMPT:";

/// The spawn constructor the boundary is about.
const SPAWN: &str = "Command::new(";

fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            walk(&path, out);
        } else if path.extension().is_some_and(|ext| ext == "rs") {
            out.push(path);
        }
    }
}

/// The contiguous run of `//` comment lines immediately above `index`
/// (nearest-first), stopping at the first non-comment line. This is where a
/// `FACADE-EXEMPT` marker may live, so a multi-line reason still counts.
fn preceding_comment_block<'a>(lines: &[&'a str], index: usize) -> Vec<&'a str> {
    let mut block = Vec::new();
    let mut cursor = index;
    while cursor > 0 {
        cursor -= 1;
        let line = lines[cursor].trim_start();
        if line.starts_with("//") {
            block.push(lines[cursor]);
        } else {
            break;
        }
    }
    block
}

/// Every `path:line` under `src/**` that calls `Command::new(` outside a facade
/// module and without a `FACADE-EXEMPT` marker in the comment block above it.
fn unrouted_spawn_sites() -> Vec<String> {
    let root = src_root();
    let mut files = Vec::new();
    walk(&root, &mut files);

    let mut offenders = Vec::new();
    for file in files {
        let relative = file
            .strip_prefix(&root)
            .unwrap_or(&file)
            .to_string_lossy()
            .replace('\\', "/");
        if FACADE_FILES.contains(&relative.as_str()) {
            continue;
        }
        let Ok(body) = fs::read_to_string(&file) else {
            continue;
        };
        let lines: Vec<&str> = body.lines().collect();
        for (index, line) in lines.iter().enumerate() {
            if !line.contains(SPAWN) {
                continue;
            }
            // The marker may sit on any line of the contiguous `//` comment
            // block immediately above the spawn — multi-line reasons are normal.
            let exempt = preceding_comment_block(&lines, index)
                .iter()
                .any(|line| line.contains(EXEMPT_MARKER));
            if !exempt {
                offenders.push(format!("{relative}:{}", index + 1));
            }
        }
    }
    offenders.sort();
    offenders
}

#[test]
fn no_command_new_outside_a_facade_or_marked_exemption() {
    let offenders = unrouted_spawn_sites();
    assert!(
        offenders.is_empty(),
        "external processes must be spawned through a facade module \
         (verbs/git.rs, verbs/external.rs) or carry a `{EXEMPT_MARKER}` marker \
         on the line above. Un-routed sites:\n  {}",
        offenders.join("\n  ")
    );
}