Skip to main content

dk_runner/steps/semantic/
safety.rs

1use regex::Regex;
2
3use crate::findings::{Finding, Severity};
4
5use super::checks::{CheckContext, SemanticCheck};
6
7// ─── no-unsafe-added ─────────────────────────────────────────────────────
8
9/// Flags any `unsafe {` blocks found in changed files.
10pub 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
57// ─── no-unwrap-added ─────────────────────────────────────────────────────
58
59/// Flags `.unwrap()` calls in changed files, skipping test files.
60pub 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            // Skip test files.
82            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
120// ─── error-handling-preserved ────────────────────────────────────────────
121
122/// Detects functions whose signature previously returned `Result` but no
123/// longer does after the changeset.
124pub 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        // Build a map of before functions by qualified_name that return Result.
144        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        // Check the after symbols.
163        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
194/// Returns all 3 safety checks.
195pub 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}