Skip to main content

dk_runner/steps/semantic/
mod.rs

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
20/// Build the full registry of all 9 semantic checks.
21fn 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
29/// Generate a suggestion for a finding (hardcoded mapping by check name).
30fn 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
79/// Run all (or a filtered subset of) semantic checks against a changeset.
80///
81/// # Arguments
82///
83/// * `engine` — the dk-engine orchestrator
84/// * `repo_id` — repository UUID
85/// * `changeset_files` — relative paths of changed files
86/// * `work_dir` — directory where changeset files are materialized
87/// * `filter` — if non-empty, only run checks whose names appear in this list
88///
89/// # Returns
90///
91/// A tuple of `(StepOutput, Vec<Finding>, Vec<Suggestion>)`.
92pub 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    // Build the check context from graph stores + parsed changeset.
102    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    // Collect checks, optionally filtering.
117    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    // Run each check and aggregate findings.
128    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    // Generate suggestions for each finding.
151    let suggestions: Vec<Suggestion> = all_findings
152        .iter()
153        .enumerate()
154        .filter_map(|(idx, f)| suggest(idx, f))
155        .collect();
156
157    // Determine overall status.
158    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
178/// Backward-compatible entry point for the scheduler (no Engine required).
179///
180/// Validates check names against the registry and reports pass/skip.
181/// This will be replaced once the scheduler is wired with Engine access (Task 9).
182pub 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    // ── Integration tests for individual semantic checks ──────────────
297
298    #[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        // Wrap the deeply nested branching in a function so per-function
389        // complexity tracking detects it.
390        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}