pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! F48 Phase B — the opencode arm. The gate judges the file an `edit` call WILL
//! produce, instead of refusing because the payload carried no content.
//!
//! COMMITTED RED. A NEW file per N10.
//!
//! **This arm is a reuse, and that is the finding.** opencode's `edit` takes
//! `filePath` / `oldString` / `newString` / optional `replaceAll` (default
//! false); a target that is not found errors, and a target found more than once
//! errors unless `replaceAll` is set. That is Claude's `Edit`, field for field
//! and rule for rule — established from opencode's own tool schema and prompt
//! text, not inferred from a capture. So the semantics need no new decisions,
//! and the arm reduces to mapping three field names onto the existing
//! `Replacement`.
//!
//! **Why it still gets its own suite.** "Identical to Claude's" is a claim about
//! another family's behavior, and claims like that are exactly what this project
//! has been wrong about before (F51 judged a fragment as a file; F52 judged a
//! hunk as one). The equivalence is therefore asserted here rather than assumed,
//! including the ambiguity rule — which is the one place a careless mapping
//! would quietly differ, by defaulting `replaceAll` to true and replacing every
//! occurrence where opencode itself would have refused.

use assert_cmd::Command;
use std::fs;
use std::path::Path;
use std::process::Command as StdCommand;

type TestResult = Result<(), Box<dyn std::error::Error>>;

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"]
read_only_paths = ["crates/**/tests/**"]
"#;

const MAPPED: &str = "app/api/users/route.ts";
const COMMITTED_TEST: &str = "crates/pushkin-cli/tests/committed_suite.rs";

const RULE_CONTENT_UNAVAILABLE: &str = "pushkin.content_unavailable";
const RULE_UNVALIDATED: &str = "contract.boundary.unvalidated_input";
const RULE_READ_ONLY: &str = "pushkin.read_only_path";

const CONFORMING: &str = "import { UserCreateSchema } from \"contracts/user.zod\";\n\
                          export async function POST(req: Request) {\n  \
                          const body = UserCreateSchema.parse(await req.json());\n  \
                          return Response.json(body);\n}\n";

const BREAK_OLD: &str = "const body = UserCreateSchema.parse(await req.json());";
const BREAK_NEW: &str = "const body = await req.json();";

fn git(dir: &Path, args: &[&str]) -> TestResult {
    let status = StdCommand::new("git")
        .current_dir(dir)
        .args(args)
        .status()?;
    assert!(status.success(), "git {args:?} failed");
    Ok(())
}

fn repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST)?;
    fs::create_dir_all(dir.path().join("contracts"))?;
    fs::write(
        dir.path().join("contracts/user.zod.ts"),
        "export const user = 1;\n",
    )?;
    fs::create_dir_all(dir.path().join("app/api/users"))?;
    fs::write(dir.path().join(MAPPED), CONFORMING)?;
    fs::create_dir_all(dir.path().join("crates/pushkin-cli/tests"))?;
    fs::write(dir.path().join(COMMITTED_TEST), "// committed suite\n")?;
    git(dir.path(), &["init", "-q", "."])?;
    git(dir.path(), &["add", "-A"])?;
    git(
        dir.path(),
        &[
            "-c",
            "user.name=PhaseB Opencode",
            "-c",
            "user.email=pb@test",
            "commit",
            "-qm",
            "fixture",
        ],
    )?;
    Ok(dir)
}

/// opencode hands its tools an ABSOLUTE `filePath`. On macOS a temp dir is
/// reached through a `/private` symlink, so an un-canonicalized spelling never
/// relativizes and the manifest globs miss — which would let a gated-path
/// assertion "pass" against a gate that had allowed.
fn absolute(dir: &Path, relative: &str) -> String {
    dir.canonicalize()
        .unwrap_or_else(|_| dir.to_path_buf())
        .join(relative)
        .to_string_lossy()
        .into_owned()
}

fn edit_payload(dir: &Path, path: &str, old: &str, new: &str) -> String {
    serde_json::json!({
        "sessionID": "phase-b-opencode",
        "tool": "edit",
        "args": { "filePath": absolute(dir, path), "oldString": old, "newString": new },
    })
    .to_string()
}

fn edit_all_payload(dir: &Path, path: &str, old: &str, new: &str) -> String {
    serde_json::json!({
        "sessionID": "phase-b-opencode",
        "tool": "edit",
        "args": {
            "filePath": absolute(dir, path),
            "oldString": old,
            "newString": new,
            "replaceAll": true,
        },
    })
    .to_string()
}

fn write_payload(dir: &Path, path: &str, content: &str) -> String {
    serde_json::json!({
        "sessionID": "phase-b-opencode",
        "tool": "write",
        "args": { "filePath": absolute(dir, path), "content": content },
    })
    .to_string()
}

fn hook(
    dir: &Path,
    payload: &str,
    daemon: Option<&str>,
) -> Result<String, Box<dyn std::error::Error>> {
    let mut cmd = Command::cargo_bin("pushkin")?;
    cmd.current_dir(dir).write_stdin(payload.to_owned());
    if let Some(mode) = daemon {
        cmd.env("PUSHKIN_DAEMON", mode);
    }
    let output = cmd.args(["hook", "opencode"]).output()?;
    Ok(format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    ))
}

// ---------------------------------------------------------------------------
// The point of the arm
// ---------------------------------------------------------------------------

#[test]
fn an_edit_that_breaks_the_contract_is_denied_under_the_real_rule() -> TestResult {
    let dir = repo()?;

    let output = hook(
        dir.path(),
        &edit_payload(dir.path(), MAPPED, BREAK_OLD, BREAK_NEW),
        Some("off"),
    )?;

    assert!(
        output.contains(RULE_UNVALIDATED),
        "the synthesized file drops the parse and must be judged on it: {output}"
    );
    assert!(
        !output.contains(RULE_CONTENT_UNAVAILABLE),
        "content IS available now — it was synthesized: {output}"
    );
    Ok(())
}

#[test]
fn an_edit_that_keeps_the_contract_is_allowed() -> TestResult {
    let dir = repo()?;

    let output = hook(
        dir.path(),
        &edit_payload(
            dir.path(),
            MAPPED,
            "return Response.json(body);",
            "return Response.json({ ...body });",
        ),
        Some("off"),
    )?;

    assert!(
        !output.contains(RULE_UNVALIDATED) && !output.contains(RULE_CONTENT_UNAVAILABLE),
        "the synthesized file still parses through the contract: {output}"
    );
    Ok(())
}

/// The equivalence that defines the program: an edit producing content X reaches
/// the same verdict as a write of X.
#[test]
fn an_edit_and_the_equivalent_write_reach_the_same_verdict() -> TestResult {
    let dir = repo()?;
    let synthesized = CONFORMING.replace(BREAK_OLD, BREAK_NEW);

    let via_edit = hook(
        dir.path(),
        &edit_payload(dir.path(), MAPPED, BREAK_OLD, BREAK_NEW),
        Some("off"),
    )?;
    let via_write = hook(
        dir.path(),
        &write_payload(dir.path(), MAPPED, &synthesized),
        Some("off"),
    )?;

    for output in [&via_edit, &via_write] {
        assert!(
            output.contains(RULE_UNVALIDATED),
            "both routes judge the same bytes: {output}"
        );
        // The Phase A refusal NAMES the rule it could not evaluate, so the
        // assertion above matches its prose too. Without this the test passes
        // against a gate that refused both — which is not equivalence, it is
        // two refusals.
        assert!(
            !output.contains(RULE_CONTENT_UNAVAILABLE),
            "and judge them, rather than both refusing: {output}"
        );
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// The occurrence rules — where a careless mapping would quietly differ
// ---------------------------------------------------------------------------

#[test]
fn an_edit_whose_target_is_missing_falls_back_to_the_interim_refusal() -> TestResult {
    let dir = repo()?;

    let output = hook(
        dir.path(),
        &edit_payload(dir.path(), MAPPED, "nowhere in the file", "x"),
        Some("off"),
    )?;

    assert!(
        output.contains(RULE_CONTENT_UNAVAILABLE),
        "no faithful reconstruction is possible, so refuse: {output}"
    );
    Ok(())
}

/// THE assertion that stops the mapping from drifting. `replaceAll` defaults to
/// FALSE, and opencode itself errors on a target it finds more than once. A
/// mapping that defaulted it to true would replace every occurrence, synthesize
/// a file opencode would never have produced, and then judge it confidently.
#[test]
fn an_ambiguous_target_falls_back_to_the_interim_refusal() -> TestResult {
    let dir = repo()?;

    // `body` occurs on more than one line of the fixture.
    let output = hook(
        dir.path(),
        &edit_payload(dir.path(), MAPPED, "body", "payload"),
        Some("off"),
    )?;

    assert!(
        output.contains(RULE_CONTENT_UNAVAILABLE),
        "an ambiguous target must refuse, never pick the first: {output}"
    );
    Ok(())
}

/// And the other side of the same rule: with `replaceAll` the edit is no longer
/// ambiguous, so it must reconstruct rather than refuse.
#[test]
fn replace_all_resolves_an_otherwise_ambiguous_target() -> TestResult {
    let dir = repo()?;

    let output = hook(
        dir.path(),
        &edit_all_payload(dir.path(), MAPPED, "body", "payload"),
        Some("off"),
    )?;

    assert!(
        !output.contains(RULE_CONTENT_UNAVAILABLE),
        "replaceAll says every occurrence was meant, so there is nothing \
         ambiguous left to refuse: {output}"
    );
    Ok(())
}

// ---------------------------------------------------------------------------
// Ordering, warm/cold, and the neighbouring arm
// ---------------------------------------------------------------------------

#[test]
fn a_read_only_path_denies_before_synthesis_is_attempted() -> TestResult {
    let dir = repo()?;

    let output = hook(
        dir.path(),
        &edit_payload(dir.path(), COMMITTED_TEST, "committed", "tampered"),
        Some("off"),
    )?;

    assert!(output.contains(RULE_READ_ONLY), "path rule first: {output}");
    Ok(())
}

// F76 ruling 1C: the "synthesis verdict is identical warm and cold" test that
// lived here was deleted — an opencode edit normalizes to `MutateNoContent`,
// and `gate_mutation` judges the synthesized content COLD; the daemon
// (`check_or_cold`, Write-only) is never reached, so the assertion compared
// two runs of the same cold code. The verdict it re-asserted is pinned
// discriminately by
// `an_edit_that_breaks_the_contract_is_denied_under_the_real_rule` above.

/// Regression against F59, which landed alongside this: `apply_patch` is
/// opencode's other mutation tool and must keep its own verdict. The two arms
/// key on the tool name, so a mistake in either reclassifies the other.
#[test]
fn the_patch_tool_still_reaches_the_read_only_rule() -> TestResult {
    let dir = repo()?;
    let payload = serde_json::json!({
        "sessionID": "phase-b-opencode",
        "tool": "apply_patch",
        "args": {
            "patchText": format!(
                "*** Begin Patch\n*** Update File: {COMMITTED_TEST}\n@@\n-a\n+b\n*** End Patch\n"
            ),
        },
    })
    .to_string();

    let output = hook(dir.path(), &payload, Some("off"))?;

    assert!(output.contains(RULE_READ_ONLY), "{output}");
    Ok(())
}