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,
}
}
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,
}]
}
#[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,
}]
}
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)
}