1pub mod checks;
2pub mod context;
3pub mod safety;
4pub mod compat;
5pub mod quality;
6
7use std::path::Path;
8use std::sync::Arc;
9use std::time::Instant;
10
11use uuid::Uuid;
12
13use dk_engine::repo::Engine;
14
15use crate::executor::{StepOutput, StepStatus};
16use crate::findings::{Finding, Severity, Suggestion};
17
18use checks::SemanticCheck;
19
20fn all_checks() -> Vec<Box<dyn SemanticCheck>> {
22 let mut checks: Vec<Box<dyn SemanticCheck>> = Vec::new();
23 checks.extend(safety::safety_checks());
24 checks.extend(compat::compat_checks());
25 checks.extend(quality::quality_checks());
26 checks
27}
28
29fn suggest(finding_index: usize, finding: &Finding) -> Option<Suggestion> {
31 let (description, replacement) = match finding.check_name.as_str() {
32 "no-unsafe-added" => (
33 "Wrap unsafe code in a safe abstraction or add a safety comment".to_string(),
34 Some("// SAFETY: <explain why this is safe>\nunsafe { ... }".to_string()),
35 ),
36 "no-unwrap-added" => (
37 "Replace .unwrap() with ? operator or .expect(\"reason\")".to_string(),
38 Some(".expect(\"TODO: add error context\")".to_string()),
39 ),
40 "error-handling-preserved" => (
41 "Restore the Result return type to maintain error handling".to_string(),
42 None,
43 ),
44 "no-public-removal" => (
45 "Restore the public symbol or deprecate it first with #[deprecated]".to_string(),
46 None,
47 ),
48 "signature-stable" => (
49 "Keep the original signature and add a new function with the updated signature".to_string(),
50 None,
51 ),
52 "trait-impl-complete" => (
53 "Restore the missing method(s) in the impl block".to_string(),
54 None,
55 ),
56 "complexity-limit" => (
57 "Refactor into smaller functions to reduce branching complexity".to_string(),
58 None,
59 ),
60 "no-dependency-cycles" => (
61 "Break the cycle by extracting shared logic into a separate module".to_string(),
62 None,
63 ),
64 "dead-code-detection" => (
65 "Remove the unused function or add a caller".to_string(),
66 None,
67 ),
68 _ => return None,
69 };
70
71 Some(Suggestion {
72 finding_index,
73 description,
74 file_path: finding.file_path.clone().unwrap_or_default(),
75 replacement,
76 })
77}
78
79pub async fn run_semantic_step(
93 engine: &Arc<Engine>,
94 repo_id: Uuid,
95 changeset_files: &[String],
96 work_dir: &Path,
97 filter: &[String],
98) -> (StepOutput, Vec<Finding>, Vec<Suggestion>) {
99 let start = Instant::now();
100
101 let ctx = match context::build_check_context(engine, repo_id, changeset_files, work_dir).await
103 {
104 Ok(ctx) => ctx,
105 Err(e) => {
106 let output = StepOutput {
107 status: StepStatus::Fail,
108 stdout: String::new(),
109 stderr: format!("Failed to build check context: {e}"),
110 duration: start.elapsed(),
111 };
112 return (output, vec![], vec![]);
113 }
114 };
115
116 let checks = all_checks();
118 let active_checks: Vec<&Box<dyn SemanticCheck>> = if filter.is_empty() {
119 checks.iter().collect()
120 } else {
121 checks
122 .iter()
123 .filter(|c| filter.iter().any(|f| f == c.name()))
124 .collect()
125 };
126
127 let mut all_findings: Vec<Finding> = Vec::new();
129 let mut results: Vec<String> = Vec::new();
130
131 for check in &active_checks {
132 let findings = check.run(&ctx);
133 if findings.is_empty() {
134 results.push(format!("[PASS] {}", check.name()));
135 } else {
136 let errors = findings.iter().filter(|f| f.severity == Severity::Error).count();
137 let warnings = findings.iter().filter(|f| f.severity == Severity::Warning).count();
138 let infos = findings.iter().filter(|f| f.severity == Severity::Info).count();
139 results.push(format!(
140 "[FIND] {} — {} error(s), {} warning(s), {} info(s)",
141 check.name(),
142 errors,
143 warnings,
144 infos
145 ));
146 all_findings.extend(findings);
147 }
148 }
149
150 let suggestions: Vec<Suggestion> = all_findings
152 .iter()
153 .enumerate()
154 .filter_map(|(idx, f)| suggest(idx, f))
155 .collect();
156
157 let has_errors = all_findings
159 .iter()
160 .any(|f| f.severity == Severity::Error);
161
162 let status = if has_errors {
163 StepStatus::Fail
164 } else {
165 StepStatus::Pass
166 };
167
168 let output = StepOutput {
169 status,
170 stdout: results.join("\n"),
171 stderr: String::new(),
172 duration: start.elapsed(),
173 };
174
175 (output, all_findings, suggestions)
176}
177
178pub async fn run_semantic_step_simple(checks: &[String]) -> StepOutput {
183 let start = Instant::now();
184 let registry = all_checks();
185 let known_names: Vec<&str> = registry.iter().map(|c| c.name()).collect();
186
187 let mut results = Vec::new();
188 let mut all_pass = true;
189
190 for check in checks {
191 if known_names.contains(&check.as_str()) {
192 results.push(format!(
193 "[PASS] {}: auto-approved (engine not wired yet)",
194 check
195 ));
196 } else {
197 results.push(format!("[SKIP] {}: unknown check", check));
198 all_pass = false;
199 }
200 }
201
202 let status = if all_pass {
203 StepStatus::Pass
204 } else {
205 StepStatus::Skip
206 };
207
208 StepOutput {
209 status,
210 stdout: results.join("\n"),
211 stderr: String::new(),
212 duration: start.elapsed(),
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn test_all_checks_registered() {
222 let checks = all_checks();
223 assert_eq!(checks.len(), 9, "Expected 9 semantic checks, got {}", checks.len());
224 }
225
226 #[test]
227 fn test_check_names_unique() {
228 let checks = all_checks();
229 let mut names: Vec<&str> = checks.iter().map(|c| c.name()).collect();
230 let total = names.len();
231 names.sort();
232 names.dedup();
233 assert_eq!(
234 names.len(),
235 total,
236 "Duplicate check names found"
237 );
238 }
239
240 #[test]
241 fn test_check_names_are_expected() {
242 let checks = all_checks();
243 let names: Vec<&str> = checks.iter().map(|c| c.name()).collect();
244
245 let expected = [
246 "no-unsafe-added",
247 "no-unwrap-added",
248 "error-handling-preserved",
249 "no-public-removal",
250 "signature-stable",
251 "trait-impl-complete",
252 "complexity-limit",
253 "no-dependency-cycles",
254 "dead-code-detection",
255 ];
256
257 for name in &expected {
258 assert!(
259 names.contains(name),
260 "Missing expected check: {}",
261 name
262 );
263 }
264 }
265
266 #[test]
267 fn test_suggest_returns_suggestion_for_known_checks() {
268 let finding = Finding {
269 severity: Severity::Error,
270 check_name: "no-unsafe-added".into(),
271 message: "test".into(),
272 file_path: Some("src/lib.rs".into()),
273 line: Some(1),
274 symbol: None,
275 };
276
277 let suggestion = suggest(0, &finding);
278 assert!(suggestion.is_some());
279 assert_eq!(suggestion.unwrap().finding_index, 0);
280 }
281
282 #[test]
283 fn test_suggest_returns_none_for_unknown_check() {
284 let finding = Finding {
285 severity: Severity::Info,
286 check_name: "unknown-check-xyz".into(),
287 message: "test".into(),
288 file_path: None,
289 line: None,
290 symbol: None,
291 };
292
293 assert!(suggest(0, &finding).is_none());
294 }
295
296 #[test]
299 fn test_safety_no_unsafe_detects_unsafe_block() {
300 use checks::{CheckContext, ChangedFile, SemanticCheck};
301 use safety::NoUnsafeAdded;
302
303 let ctx = CheckContext {
304 before_symbols: Vec::new(),
305 after_symbols: Vec::new(),
306 before_call_graph: Vec::new(),
307 after_call_graph: Vec::new(),
308 before_deps: Vec::new(),
309 after_deps: Vec::new(),
310 changed_files: vec![ChangedFile {
311 path: "src/lib.rs".to_string(),
312 content: Some("fn foo() {\n unsafe {\n ptr::read(p)\n }\n}".to_string()),
313 }],
314 };
315
316 let check = NoUnsafeAdded::new();
317 let findings = check.run(&ctx);
318 assert_eq!(findings.len(), 1);
319 assert_eq!(findings[0].severity, Severity::Error);
320 }
321
322 #[test]
323 fn test_compat_no_public_removal() {
324 use checks::{CheckContext, SemanticCheck};
325 use compat::NoPublicRemoval;
326 use dk_core::types::*;
327
328 let sym = Symbol {
329 id: uuid::Uuid::new_v4(),
330 name: "foo".to_string(),
331 qualified_name: "crate::foo".to_string(),
332 kind: SymbolKind::Function,
333 visibility: Visibility::Public,
334 file_path: "src/lib.rs".into(),
335 span: Span { start_byte: 0, end_byte: 100 },
336 signature: Some("fn foo()".to_string()),
337 doc_comment: None,
338 parent: None,
339 last_modified_by: None,
340 last_modified_intent: None,
341 };
342
343 let ctx = CheckContext {
344 before_symbols: vec![sym],
345 after_symbols: Vec::new(),
346 before_call_graph: Vec::new(),
347 after_call_graph: Vec::new(),
348 before_deps: Vec::new(),
349 after_deps: Vec::new(),
350 changed_files: Vec::new(),
351 };
352
353 let check = NoPublicRemoval::new();
354 let findings = check.run(&ctx);
355 assert_eq!(findings.len(), 1);
356 assert_eq!(findings[0].check_name, "no-public-removal");
357 }
358
359 #[test]
360 fn test_safety_no_unwrap_detects_unwrap() {
361 use checks::{CheckContext, ChangedFile, SemanticCheck};
362 use safety::NoUnwrapAdded;
363
364 let ctx = CheckContext {
365 before_symbols: Vec::new(),
366 after_symbols: Vec::new(),
367 before_call_graph: Vec::new(),
368 after_call_graph: Vec::new(),
369 before_deps: Vec::new(),
370 after_deps: Vec::new(),
371 changed_files: vec![ChangedFile {
372 path: "src/lib.rs".to_string(),
373 content: Some("let x = foo.unwrap();".to_string()),
374 }],
375 };
376
377 let check = NoUnwrapAdded::new();
378 let findings = check.run(&ctx);
379 assert_eq!(findings.len(), 1);
380 assert_eq!(findings[0].severity, Severity::Warning);
381 }
382
383 #[test]
384 fn test_quality_complexity_limit() {
385 use checks::{CheckContext, ChangedFile, SemanticCheck};
386 use quality::ComplexityLimit;
387
388 let inner = (0..15).map(|i| format!("if x > {} {{", i)).collect::<Vec<_>>().join("\n")
391 + &"\n}".repeat(15);
392 let deeply_nested = format!("fn deep() {{\n{}\n}}", inner);
393
394 let ctx = CheckContext {
395 before_symbols: Vec::new(),
396 after_symbols: Vec::new(),
397 before_call_graph: Vec::new(),
398 after_call_graph: Vec::new(),
399 before_deps: Vec::new(),
400 after_deps: Vec::new(),
401 changed_files: vec![ChangedFile {
402 path: "src/lib.rs".to_string(),
403 content: Some(deeply_nested),
404 }],
405 };
406
407 let check = ComplexityLimit::with_threshold(10);
408 let findings = check.run(&ctx);
409 assert!(!findings.is_empty(), "should detect high complexity");
410 }
411}