pushkin-core 0.1.0

Core envelope, manifest, pipeline, and waiver types for the pushkin write-gate
Documentation
//! The deterministic per-write validation pipeline (spec §8.2, Phase 1
//! subset: contract conformance heuristic, suppression detection, protected
//! paths), driven by the manifest instead of Phase 0's hardcoded settings.
//! Rule IDs and decisions are conformance-locked to the Phase 0 Bun spike.

use crate::envelope::{CheckResult, Decision, Severity, Violation};
use crate::manifest::Manifest;

pub const RULE_UNVALIDATED_INPUT: &str = "contract.boundary.unvalidated_input";
pub const RULE_NEW_SUPPRESSION: &str = "pushkin.suppression.new";
pub const RULE_PROTECTED_PATH: &str = "pushkin.protected_path";
pub const RULE_READ_ONLY_PATH: &str = "pushkin.read_only_path";

const SUPPRESSION_MARKERS: &[&str] = &[
    "@ts-ignore",
    "@ts-expect-error",
    "@ts-nocheck",
    "eslint-disable",
    "noqa",
    "type: ignore",
];

const HANDLER_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE"];

pub struct WriteRequest {
    pub file_path: String,
    pub content: String,
}

#[must_use]
pub fn check_write(manifest: &Manifest, request: &WriteRequest) -> CheckResult {
    let started = std::time::Instant::now();
    let mut violations = Vec::new();

    violations.extend(check_protected_path(manifest, request));
    violations.extend(check_suppressions(manifest, request));
    violations.extend(check_boundary_validation(manifest, request));

    CheckResult {
        decision: if violations.is_empty() {
            Decision::Allow
        } else {
            Decision::Block
        },
        violations,
        duration_ms: started.elapsed().as_secs_f64() * 1000.0,
    }
}

/// `pushkin/` (waivers, adapter configs, bindings) is gate surface by
/// construction (integration doc §7: "agents cannot waive"), independent of
/// what the manifest lists.
const BUILTIN_PROTECTED_PREFIX: &str = "pushkin/";

fn check_protected_path(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
    let builtin = request.file_path.starts_with(BUILTIN_PROTECTED_PREFIX);
    if !builtin && !manifest.is_protected(&request.file_path) {
        return Vec::new();
    }
    vec![Violation {
        file: request.file_path.clone(),
        line: 1,
        rule: RULE_PROTECTED_PATH.to_owned(),
        contract: None,
        fix_hint: "This path is part of Pushkin's own gate surface and may not be edited \
                   by agents. If the change is genuinely required, a human must make it."
            .to_owned(),
        suggestions: vec!["Ask the human operator to apply this change manually.".to_owned()],
        severity: Severity::Error,
    }]
}

/// The violation for an agent write to a COMMITTED file under a
/// `read_only_paths` glob. Built here so the rule id and wording live
/// beside their siblings, but raised by the CLI layer, which owns the
/// committed-in-HEAD predicate — core stays free of git and the
/// filesystem.
#[must_use]
pub fn read_only_violation(path: &str) -> Violation {
    Violation {
        file: path.to_owned(),
        line: 1,
        rule: RULE_READ_ONLY_PATH.to_owned(),
        contract: None,
        fix_hint: "This file is committed under a read-only path (N10: committed suites \
                   are read-only). Agents may add NEW files here; a committed file only \
                   a human may change."
            .to_owned(),
        suggestions: vec![
            "Author a new file for new coverage, or ask the human operator to apply \
             this edit manually."
                .to_owned(),
        ],
        severity: Severity::Error,
    }
}

fn check_suppressions(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
    if manifest.mapping_for(&request.file_path).is_none() {
        return Vec::new();
    }
    let mut violations = Vec::new();
    for (index, line_text) in request.content.lines().enumerate() {
        let Some(marker) = SUPPRESSION_MARKERS.iter().find(|m| line_text.contains(**m)) else {
            continue;
        };
        violations.push(Violation {
            file: request.file_path.clone(),
            line: to_line_number(index),
            rule: RULE_NEW_SUPPRESSION.to_owned(),
            contract: None,
            fix_hint: format!(
                "Remove the suppression comment ('{marker}') and fix the underlying issue instead."
            ),
            suggestions: vec![
                "Fix the reported type/lint error rather than silencing it.".to_owned()
            ],
            severity: Severity::Error,
        });
    }
    violations
}

fn check_boundary_validation(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
    let Some(mapping) = manifest.mapping_for(&request.file_path) else {
        return Vec::new();
    };
    if mapping.require.as_deref() != Some("boundary-validation") {
        return Vec::new();
    }
    let Some(handler_line) = find_handler_line(&request.content) else {
        return Vec::new();
    };
    if parses_with_contract(&request.content) {
        return Vec::new();
    }
    let contract = mapping.contracts.first().map(|c| c.as_str().to_owned());
    let schema_name = schema_symbol(contract.as_deref());
    vec![Violation {
        file: request.file_path.clone(),
        line: handler_line,
        rule: RULE_UNVALIDATED_INPUT.to_owned(),
        contract: contract.clone(),
        fix_hint: format!(
            "Parse the request body with {schema_name} (contract '{}') before use — e.g. \
             const body = {schema_name}.parse(await req.json()); — fix and retry the write.",
            contract.as_deref().unwrap_or("unknown")
        ),
        suggestions: vec![
            format!("import {{ {schema_name} }} from \"contracts/user.zod\""),
            format!("contract_show {}", contract.as_deref().unwrap_or("unknown")),
        ],
        severity: Severity::Error,
    }]
}

/// Phase 0 parity: `UserCreateSchema` for contract `user`. Phase 3's
/// symbol-level mapper generalizes this; until then the convention is
/// `<PascalCase contract>CreateSchema`.
fn schema_symbol(contract: Option<&str>) -> String {
    let name = contract.unwrap_or("unknown");
    let mut pascal = String::new();
    for part in name.split(['-', '_']) {
        let mut chars = part.chars();
        if let Some(first) = chars.next() {
            pascal.extend(first.to_uppercase());
            pascal.push_str(chars.as_str());
        }
    }
    format!("{pascal}CreateSchema")
}

fn find_handler_line(content: &str) -> Option<u32> {
    for (index, line_text) in content.lines().enumerate() {
        let is_export = line_text.contains("export");
        let is_function = line_text.contains("function") || line_text.contains("async function");
        if is_export
            && is_function
            && HANDLER_METHODS
                .iter()
                .any(|method| line_text.contains(method))
        {
            return Some(to_line_number(index));
        }
    }
    None
}

fn parses_with_contract(content: &str) -> bool {
    content.lines().any(|line_text| {
        let Some(schema_pos) = line_text.find("Schema") else {
            return false;
        };
        let rest = &line_text[schema_pos..];
        rest.contains(".parse") || rest.contains(".safeParse")
    })
}

fn to_line_number(index: usize) -> u32 {
    u32::try_from(index)
        .unwrap_or(u32::MAX - 1)
        .saturating_add(1)
}