Skip to main content

pushkin_core/
pipeline.rs

1//! The deterministic per-write validation pipeline (spec §8.2, Phase 1
2//! subset: contract conformance heuristic, suppression detection, protected
3//! paths), driven by the manifest instead of Phase 0's hardcoded settings.
4//! Rule IDs and decisions are conformance-locked to the Phase 0 Bun spike.
5
6use crate::envelope::{CheckResult, Decision, Severity, Violation};
7use crate::manifest::Manifest;
8
9pub const RULE_UNVALIDATED_INPUT: &str = "contract.boundary.unvalidated_input";
10pub const RULE_NEW_SUPPRESSION: &str = "pushkin.suppression.new";
11pub const RULE_PROTECTED_PATH: &str = "pushkin.protected_path";
12pub const RULE_READ_ONLY_PATH: &str = "pushkin.read_only_path";
13
14const SUPPRESSION_MARKERS: &[&str] = &[
15    "@ts-ignore",
16    "@ts-expect-error",
17    "@ts-nocheck",
18    "eslint-disable",
19    "noqa",
20    "type: ignore",
21];
22
23const HANDLER_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE"];
24
25pub struct WriteRequest {
26    pub file_path: String,
27    pub content: String,
28}
29
30#[must_use]
31pub fn check_write(manifest: &Manifest, request: &WriteRequest) -> CheckResult {
32    let started = std::time::Instant::now();
33    let mut violations = Vec::new();
34
35    violations.extend(check_protected_path(manifest, request));
36    violations.extend(check_suppressions(manifest, request));
37    violations.extend(check_boundary_validation(manifest, request));
38
39    CheckResult {
40        decision: if violations.is_empty() {
41            Decision::Allow
42        } else {
43            Decision::Block
44        },
45        violations,
46        duration_ms: started.elapsed().as_secs_f64() * 1000.0,
47    }
48}
49
50/// `pushkin/` (waivers, adapter configs, bindings) is gate surface by
51/// construction (integration doc §7: "agents cannot waive"), independent of
52/// what the manifest lists.
53const BUILTIN_PROTECTED_PREFIX: &str = "pushkin/";
54
55fn check_protected_path(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
56    let builtin = request.file_path.starts_with(BUILTIN_PROTECTED_PREFIX);
57    if !builtin && !manifest.is_protected(&request.file_path) {
58        return Vec::new();
59    }
60    vec![Violation {
61        file: request.file_path.clone(),
62        line: 1,
63        rule: RULE_PROTECTED_PATH.to_owned(),
64        contract: None,
65        fix_hint: "This path is part of Pushkin's own gate surface and may not be edited \
66                   by agents. If the change is genuinely required, a human must make it."
67            .to_owned(),
68        suggestions: vec!["Ask the human operator to apply this change manually.".to_owned()],
69        severity: Severity::Error,
70    }]
71}
72
73/// The violation for an agent write to a COMMITTED file under a
74/// `read_only_paths` glob. Built here so the rule id and wording live
75/// beside their siblings, but raised by the CLI layer, which owns the
76/// committed-in-HEAD predicate — core stays free of git and the
77/// filesystem.
78#[must_use]
79pub fn read_only_violation(path: &str) -> Violation {
80    Violation {
81        file: path.to_owned(),
82        line: 1,
83        rule: RULE_READ_ONLY_PATH.to_owned(),
84        contract: None,
85        fix_hint: "This file is committed under a read-only path (N10: committed suites \
86                   are read-only). Agents may add NEW files here; a committed file only \
87                   a human may change."
88            .to_owned(),
89        suggestions: vec![
90            "Author a new file for new coverage, or ask the human operator to apply \
91             this edit manually."
92                .to_owned(),
93        ],
94        severity: Severity::Error,
95    }
96}
97
98fn check_suppressions(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
99    if manifest.mapping_for(&request.file_path).is_none() {
100        return Vec::new();
101    }
102    let mut violations = Vec::new();
103    for (index, line_text) in request.content.lines().enumerate() {
104        let Some(marker) = SUPPRESSION_MARKERS.iter().find(|m| line_text.contains(**m)) else {
105            continue;
106        };
107        violations.push(Violation {
108            file: request.file_path.clone(),
109            line: to_line_number(index),
110            rule: RULE_NEW_SUPPRESSION.to_owned(),
111            contract: None,
112            fix_hint: format!(
113                "Remove the suppression comment ('{marker}') and fix the underlying issue instead."
114            ),
115            suggestions: vec![
116                "Fix the reported type/lint error rather than silencing it.".to_owned()
117            ],
118            severity: Severity::Error,
119        });
120    }
121    violations
122}
123
124fn check_boundary_validation(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
125    let Some(mapping) = manifest.mapping_for(&request.file_path) else {
126        return Vec::new();
127    };
128    if mapping.require.as_deref() != Some("boundary-validation") {
129        return Vec::new();
130    }
131    let Some(handler_line) = find_handler_line(&request.content) else {
132        return Vec::new();
133    };
134    if parses_with_contract(&request.content) {
135        return Vec::new();
136    }
137    let contract = mapping.contracts.first().map(|c| c.as_str().to_owned());
138    let schema_name = schema_symbol(contract.as_deref());
139    vec![Violation {
140        file: request.file_path.clone(),
141        line: handler_line,
142        rule: RULE_UNVALIDATED_INPUT.to_owned(),
143        contract: contract.clone(),
144        fix_hint: format!(
145            "Parse the request body with {schema_name} (contract '{}') before use — e.g. \
146             const body = {schema_name}.parse(await req.json()); — fix and retry the write.",
147            contract.as_deref().unwrap_or("unknown")
148        ),
149        suggestions: vec![
150            format!("import {{ {schema_name} }} from \"contracts/user.zod\""),
151            format!("contract_show {}", contract.as_deref().unwrap_or("unknown")),
152        ],
153        severity: Severity::Error,
154    }]
155}
156
157/// Phase 0 parity: `UserCreateSchema` for contract `user`. Phase 3's
158/// symbol-level mapper generalizes this; until then the convention is
159/// `<PascalCase contract>CreateSchema`.
160fn schema_symbol(contract: Option<&str>) -> String {
161    let name = contract.unwrap_or("unknown");
162    let mut pascal = String::new();
163    for part in name.split(['-', '_']) {
164        let mut chars = part.chars();
165        if let Some(first) = chars.next() {
166            pascal.extend(first.to_uppercase());
167            pascal.push_str(chars.as_str());
168        }
169    }
170    format!("{pascal}CreateSchema")
171}
172
173fn find_handler_line(content: &str) -> Option<u32> {
174    for (index, line_text) in content.lines().enumerate() {
175        let is_export = line_text.contains("export");
176        let is_function = line_text.contains("function") || line_text.contains("async function");
177        if is_export
178            && is_function
179            && HANDLER_METHODS
180                .iter()
181                .any(|method| line_text.contains(method))
182        {
183            return Some(to_line_number(index));
184        }
185    }
186    None
187}
188
189fn parses_with_contract(content: &str) -> bool {
190    content.lines().any(|line_text| {
191        let Some(schema_pos) = line_text.find("Schema") else {
192            return false;
193        };
194        let rest = &line_text[schema_pos..];
195        rest.contains(".parse") || rest.contains(".safeParse")
196    })
197}
198
199fn to_line_number(index: usize) -> u32 {
200    u32::try_from(index)
201        .unwrap_or(u32::MAX - 1)
202        .saturating_add(1)
203}