dk_runner/steps/semantic/
safety.rs1use regex::Regex;
2
3use crate::findings::{Finding, Severity};
4
5use super::checks::{CheckContext, SemanticCheck};
6
7pub struct NoUnsafeAdded {
11 re: Regex,
12}
13
14impl NoUnsafeAdded {
15 pub fn new() -> Self {
16 Self {
17 re: Regex::new(r"unsafe\s*\{").expect("invalid regex"),
18 }
19 }
20}
21
22impl SemanticCheck for NoUnsafeAdded {
23 fn name(&self) -> &str {
24 "no-unsafe-added"
25 }
26
27 fn run(&self, ctx: &CheckContext) -> Vec<Finding> {
28 let mut findings = Vec::new();
29
30 for file in &ctx.changed_files {
31 let content = match &file.content {
32 Some(c) => c,
33 None => continue,
34 };
35
36 for (line_idx, line) in content.lines().enumerate() {
37 if self.re.is_match(line) {
38 findings.push(Finding {
39 severity: Severity::Error,
40 check_name: self.name().to_string(),
41 message: format!(
42 "unsafe block found at line {}",
43 line_idx + 1
44 ),
45 file_path: Some(file.path.clone()),
46 line: Some((line_idx + 1) as u32),
47 symbol: None,
48 });
49 }
50 }
51 }
52
53 findings
54 }
55}
56
57pub struct NoUnwrapAdded {
61 re: Regex,
62}
63
64impl NoUnwrapAdded {
65 pub fn new() -> Self {
66 Self {
67 re: Regex::new(r"\.unwrap\(\)").expect("invalid regex"),
68 }
69 }
70}
71
72impl SemanticCheck for NoUnwrapAdded {
73 fn name(&self) -> &str {
74 "no-unwrap-added"
75 }
76
77 fn run(&self, ctx: &CheckContext) -> Vec<Finding> {
78 let mut findings = Vec::new();
79
80 for file in &ctx.changed_files {
81 if file.path.contains("test")
83 || file.path.contains("tests/")
84 || file.path.ends_with("_test.rs")
85 || file.path.ends_with("_test.py")
86 || file.path.ends_with(".test.ts")
87 || file.path.ends_with(".test.tsx")
88 || file.path.ends_with(".spec.ts")
89 || file.path.ends_with(".spec.tsx")
90 {
91 continue;
92 }
93
94 let content = match &file.content {
95 Some(c) => c,
96 None => continue,
97 };
98
99 for (line_idx, line) in content.lines().enumerate() {
100 if self.re.is_match(line) {
101 findings.push(Finding {
102 severity: Severity::Warning,
103 check_name: self.name().to_string(),
104 message: format!(
105 ".unwrap() call at line {} — consider using ? or .expect()",
106 line_idx + 1
107 ),
108 file_path: Some(file.path.clone()),
109 line: Some((line_idx + 1) as u32),
110 symbol: None,
111 });
112 }
113 }
114 }
115
116 findings
117 }
118}
119
120pub struct ErrorHandlingPreserved;
125
126impl ErrorHandlingPreserved {
127 pub fn new() -> Self {
128 Self
129 }
130}
131
132impl SemanticCheck for ErrorHandlingPreserved {
133 fn name(&self) -> &str {
134 "error-handling-preserved"
135 }
136
137 fn run(&self, ctx: &CheckContext) -> Vec<Finding> {
138 use dk_core::types::SymbolKind;
139 use std::collections::HashMap;
140
141 let mut findings = Vec::new();
142
143 let before_result_fns: HashMap<&str, &str> = ctx
145 .before_symbols
146 .iter()
147 .filter(|s| s.kind == SymbolKind::Function)
148 .filter(|s| {
149 s.signature
150 .as_deref()
151 .map(|sig| sig.contains("Result"))
152 .unwrap_or(false)
153 })
154 .map(|s| {
155 (
156 s.qualified_name.as_str(),
157 s.signature.as_deref().unwrap_or(""),
158 )
159 })
160 .collect();
161
162 for after_sym in &ctx.after_symbols {
164 if after_sym.kind != SymbolKind::Function {
165 continue;
166 }
167 if let Some(_before_sig) = before_result_fns.get(after_sym.qualified_name.as_str()) {
168 let after_has_result = after_sym
169 .signature
170 .as_deref()
171 .map(|sig| sig.contains("Result"))
172 .unwrap_or(false);
173
174 if !after_has_result {
175 findings.push(Finding {
176 severity: Severity::Error,
177 check_name: self.name().to_string(),
178 message: format!(
179 "function '{}' previously returned Result but no longer does",
180 after_sym.qualified_name
181 ),
182 file_path: Some(after_sym.file_path.to_string_lossy().to_string()),
183 line: None,
184 symbol: Some(after_sym.qualified_name.clone()),
185 });
186 }
187 }
188 }
189
190 findings
191 }
192}
193
194pub fn safety_checks() -> Vec<Box<dyn SemanticCheck>> {
196 vec![
197 Box::new(NoUnsafeAdded::new()),
198 Box::new(NoUnwrapAdded::new()),
199 Box::new(ErrorHandlingPreserved::new()),
200 ]
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::findings::Severity;
207 use super::super::checks::{ChangedFile, CheckContext};
208
209 fn empty_context() -> CheckContext {
210 CheckContext {
211 before_symbols: vec![],
212 after_symbols: vec![],
213 before_call_graph: vec![],
214 after_call_graph: vec![],
215 before_deps: vec![],
216 after_deps: vec![],
217 changed_files: vec![],
218 }
219 }
220
221 #[test]
222 fn test_no_unsafe_detects_block() {
223 let mut ctx = empty_context();
224 ctx.changed_files.push(ChangedFile {
225 path: "src/lib.rs".into(),
226 content: Some("fn foo() {\n unsafe {\n ptr::read(p)\n }\n}".into()),
227 });
228
229 let check = NoUnsafeAdded::new();
230 let findings = check.run(&ctx);
231 assert_eq!(findings.len(), 1);
232 assert_eq!(findings[0].severity, Severity::Error);
233 assert_eq!(findings[0].line, Some(2));
234 }
235
236 #[test]
237 fn test_no_unsafe_clean_file() {
238 let mut ctx = empty_context();
239 ctx.changed_files.push(ChangedFile {
240 path: "src/lib.rs".into(),
241 content: Some("fn safe_fn() { let x = 1; }".into()),
242 });
243
244 let check = NoUnsafeAdded::new();
245 assert!(check.run(&ctx).is_empty());
246 }
247
248 #[test]
249 fn test_no_unwrap_detects_call() {
250 let mut ctx = empty_context();
251 ctx.changed_files.push(ChangedFile {
252 path: "src/main.rs".into(),
253 content: Some("let val = opt.unwrap();".into()),
254 });
255
256 let check = NoUnwrapAdded::new();
257 let findings = check.run(&ctx);
258 assert_eq!(findings.len(), 1);
259 assert_eq!(findings[0].severity, Severity::Warning);
260 }
261
262 #[test]
263 fn test_no_unwrap_skips_test_files() {
264 let mut ctx = empty_context();
265 ctx.changed_files.push(ChangedFile {
266 path: "tests/integration.rs".into(),
267 content: Some("let val = opt.unwrap();".into()),
268 });
269
270 let check = NoUnwrapAdded::new();
271 assert!(check.run(&ctx).is_empty());
272 }
273
274 #[test]
275 fn test_error_handling_preserved_detects_removal() {
276 use dk_core::types::{Span, Symbol, SymbolKind, Visibility};
277 use uuid::Uuid;
278
279 let sym_id = Uuid::new_v4();
280 let mut ctx = empty_context();
281
282 ctx.before_symbols.push(Symbol {
283 id: sym_id,
284 name: "process".into(),
285 qualified_name: "crate::process".into(),
286 kind: SymbolKind::Function,
287 visibility: Visibility::Public,
288 file_path: "src/lib.rs".into(),
289 span: Span { start_byte: 0, end_byte: 100 },
290 signature: Some("fn process() -> Result<(), Error>".into()),
291 doc_comment: None,
292 parent: None,
293 last_modified_by: None,
294 last_modified_intent: None,
295 });
296
297 ctx.after_symbols.push(Symbol {
298 id: sym_id,
299 name: "process".into(),
300 qualified_name: "crate::process".into(),
301 kind: SymbolKind::Function,
302 visibility: Visibility::Public,
303 file_path: "src/lib.rs".into(),
304 span: Span { start_byte: 0, end_byte: 80 },
305 signature: Some("fn process() -> ()".into()),
306 doc_comment: None,
307 parent: None,
308 last_modified_by: None,
309 last_modified_intent: None,
310 });
311
312 let check = ErrorHandlingPreserved::new();
313 let findings = check.run(&ctx);
314 assert_eq!(findings.len(), 1);
315 assert_eq!(findings[0].severity, Severity::Error);
316 assert!(findings[0].message.contains("Result"));
317 }
318}