pushkin-daemon 0.2.0

Warm-path daemon for the pushkin write-gate
Documentation
//! Phase 4 task 5: watcher + warm per-file state (spec §8.1). Committed
//! first, read-only hereafter (charter §4.1, N10). Pins: warm results are
//! IDENTICAL to cold ones (the daemon memoizes, it never re-decides — the
//! exit metric's conformance clause); a repeat check of unchanged content
//! is served from memory (measurably faster than the first); a manifest
//! change invalidates EVERYTHING (mappings shape every verdict); a
//! watched-file change invalidates that file's entries; invalidation is
//! keyed so untouched files stay warm.

use pushkin_core::envelope::Decision;
use pushkin_core::manifest::Manifest;
use pushkin_core::pipeline::{check_write, WriteRequest};
use pushkin_daemon::warm::WarmState;

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 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 HANDLER_PATH: &str = "app/api/users/route.ts";

fn request(content: &str) -> WriteRequest {
    WriteRequest {
        file_path: HANDLER_PATH.to_owned(),
        content: content.to_owned(),
    }
}

#[test]
fn warm_check_result_matches_cold_check_result() {
    let manifest = Manifest::parse(MANIFEST).unwrap();
    let warm = WarmState::new(Manifest::parse(MANIFEST).unwrap());

    for content in [NONCONFORMING, CONFORMING] {
        let cold = check_write(&manifest, &request(content));
        // Twice: the miss path and the hit path must BOTH match cold.
        for pass in ["miss", "hit"] {
            let warm_result = warm.check(&request(content));
            assert_eq!(
                warm_result.decision, cold.decision,
                "{pass}: warm and cold must agree on decision"
            );
            assert_eq!(
                warm_result.violations.len(),
                cold.violations.len(),
                "{pass}: same violation count"
            );
            for (w, c) in warm_result.violations.iter().zip(cold.violations.iter()) {
                assert_eq!(w.rule, c.rule, "{pass}: same rule");
                assert_eq!(w.line, c.line, "{pass}: same line");
                assert_eq!(w.fix_hint, c.fix_hint, "{pass}: same fix hint");
            }
        }
    }
}

#[test]
fn warm_repeat_check_is_served_from_memory() {
    let warm = WarmState::new(Manifest::parse(MANIFEST).unwrap());

    let first = warm.check(&request(NONCONFORMING));
    assert_eq!(first.decision, Decision::Block);
    assert_eq!(warm.hits(), 0, "first check is a miss");

    let second = warm.check(&request(NONCONFORMING));
    assert_eq!(second.decision, Decision::Block);
    assert_eq!(warm.hits(), 1, "identical repeat must hit the memo");

    // Different content for the same path is a different verdict — never
    // served from the stale entry.
    let third = warm.check(&request(CONFORMING));
    assert_eq!(third.decision, Decision::Allow);
    assert_eq!(warm.hits(), 1, "changed content must miss");
}

#[test]
fn manifest_swap_invalidates_every_entry() {
    let warm = WarmState::new(Manifest::parse(MANIFEST).unwrap());
    let _ = warm.check(&request(NONCONFORMING));
    let _ = warm.check(&request(NONCONFORMING));
    assert_eq!(warm.hits(), 1);

    // An unmapped manifest: the same write is now clean — a stale memo
    // would keep blocking it.
    let unmapped = MANIFEST.replace("app/api/**/*.ts", "elsewhere/**/*.ts");
    warm.swap_manifest(Manifest::parse(&unmapped).unwrap());

    let result = warm.check(&request(NONCONFORMING));
    assert_eq!(
        result.decision,
        Decision::Allow,
        "post-swap verdicts come from the new manifest, not the memo"
    );
    assert_eq!(warm.hits(), 1, "swap must clear the memo (this was a miss)");
}

#[test]
fn path_invalidation_is_keyed_not_global() {
    let warm = WarmState::new(Manifest::parse(MANIFEST).unwrap());
    let other = WriteRequest {
        file_path: "app/api/orders/route.ts".to_owned(),
        content: NONCONFORMING.to_owned(),
    };
    let _ = warm.check(&request(NONCONFORMING));
    let _ = warm.check(&other);

    warm.invalidate_path(HANDLER_PATH);

    let _ = warm.check(&other);
    assert_eq!(warm.hits(), 1, "untouched path stays warm");
    let _ = warm.check(&request(NONCONFORMING));
    assert_eq!(warm.hits(), 1, "invalidated path re-computes");
}

#[test]
fn watcher_invalidates_changed_file_state() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("pushkin.toml"), MANIFEST).unwrap();

    let warm = WarmState::new(Manifest::parse(MANIFEST).unwrap());
    let _watcher = warm.watch(dir.path()).unwrap();

    let _ = warm.check(&request(NONCONFORMING));
    let _ = warm.check(&request(NONCONFORMING));
    assert_eq!(warm.hits(), 1, "memo warm before the disk change");

    // A manifest edit on disk must flow through the watcher and clear
    // the memo (mappings shape every verdict).
    let unmapped = MANIFEST.replace("app/api/**/*.ts", "elsewhere/**/*.ts");
    std::fs::write(dir.path().join("pushkin.toml"), &unmapped).unwrap();

    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
    let mut invalidated = false;
    while std::time::Instant::now() < deadline {
        let result = warm.check(&request(NONCONFORMING));
        if result.decision == Decision::Allow {
            invalidated = true;
            break;
        }
        std::thread::sleep(std::time::Duration::from_millis(50));
    }
    assert!(
        invalidated,
        "the watcher must reload the manifest and drop stale verdicts"
    );
}