1use 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
50const 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#[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
157fn 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}