Skip to main content

dk_runner/steps/semantic/
compat.rs

1use std::collections::{HashMap, HashSet};
2
3use dk_core::types::{SymbolKind, Visibility};
4
5use crate::findings::{Finding, Severity};
6
7use super::checks::{CheckContext, SemanticCheck};
8
9// ─── no-public-removal ───────────────────────────────────────────────────
10
11/// Flags public symbols that existed before but are absent after the change
12/// (by `qualified_name`), indicating a breaking API removal.
13pub struct NoPublicRemoval;
14
15impl NoPublicRemoval {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21impl SemanticCheck for NoPublicRemoval {
22    fn name(&self) -> &str {
23        "no-public-removal"
24    }
25
26    fn run(&self, ctx: &CheckContext) -> Vec<Finding> {
27        let mut findings = Vec::new();
28
29        let after_names: HashSet<&str> = ctx
30            .after_symbols
31            .iter()
32            .map(|s| s.qualified_name.as_str())
33            .collect();
34
35        for before_sym in &ctx.before_symbols {
36            if before_sym.visibility != Visibility::Public {
37                continue;
38            }
39            if !after_names.contains(before_sym.qualified_name.as_str()) {
40                findings.push(Finding {
41                    severity: Severity::Error,
42                    check_name: self.name().to_string(),
43                    message: format!(
44                        "public {} '{}' was removed",
45                        before_sym.kind, before_sym.qualified_name
46                    ),
47                    file_path: Some(before_sym.file_path.to_string_lossy().to_string()),
48                    line: None,
49                    symbol: Some(before_sym.qualified_name.clone()),
50                });
51            }
52        }
53
54        findings
55    }
56}
57
58// ─── signature-stable ────────────────────────────────────────────────────
59
60/// Flags public symbols whose signature changed between before and after,
61/// indicating a potential breaking change in the API contract.
62pub struct SignatureStable;
63
64impl SignatureStable {
65    pub fn new() -> Self {
66        Self
67    }
68}
69
70impl SemanticCheck for SignatureStable {
71    fn name(&self) -> &str {
72        "signature-stable"
73    }
74
75    fn run(&self, ctx: &CheckContext) -> Vec<Finding> {
76        let mut findings = Vec::new();
77
78        // Build before map: qualified_name → signature.
79        let before_sigs: HashMap<&str, Option<&str>> = ctx
80            .before_symbols
81            .iter()
82            .filter(|s| s.visibility == Visibility::Public)
83            .map(|s| (s.qualified_name.as_str(), s.signature.as_deref()))
84            .collect();
85
86        for after_sym in &ctx.after_symbols {
87            if after_sym.visibility != Visibility::Public {
88                continue;
89            }
90
91            if let Some(before_sig) = before_sigs.get(after_sym.qualified_name.as_str()) {
92                let after_sig = after_sym.signature.as_deref();
93                if *before_sig != after_sig {
94                    findings.push(Finding {
95                        severity: Severity::Error,
96                        check_name: self.name().to_string(),
97                        message: format!(
98                            "public {} '{}' signature changed: {:?} → {:?}",
99                            after_sym.kind,
100                            after_sym.qualified_name,
101                            before_sig,
102                            after_sig
103                        ),
104                        file_path: Some(after_sym.file_path.to_string_lossy().to_string()),
105                        line: None,
106                        symbol: Some(after_sym.qualified_name.clone()),
107                    });
108                }
109            }
110        }
111
112        findings
113    }
114}
115
116// ─── trait-impl-complete ─────────────────────────────────────────────────
117
118/// Detects impl blocks that lost methods compared to the before state.
119/// Groups symbols by `parent` (SymbolId) and compares method counts.
120pub struct TraitImplComplete;
121
122impl TraitImplComplete {
123    pub fn new() -> Self {
124        Self
125    }
126}
127
128impl SemanticCheck for TraitImplComplete {
129    fn name(&self) -> &str {
130        "trait-impl-complete"
131    }
132
133    fn run(&self, ctx: &CheckContext) -> Vec<Finding> {
134        use dk_core::types::SymbolId;
135
136        let mut findings = Vec::new();
137
138        // Build before: parent_id → set of method qualified_names.
139        let mut before_impl_methods: HashMap<SymbolId, HashSet<&str>> = HashMap::new();
140        for sym in &ctx.before_symbols {
141            if sym.kind == SymbolKind::Function {
142                if let Some(parent_id) = sym.parent {
143                    before_impl_methods
144                        .entry(parent_id)
145                        .or_default()
146                        .insert(sym.qualified_name.as_str());
147                }
148            }
149        }
150
151        // Build after: parent_id → set of method qualified_names.
152        let mut after_impl_methods: HashMap<SymbolId, HashSet<&str>> = HashMap::new();
153        for sym in &ctx.after_symbols {
154            if sym.kind == SymbolKind::Function {
155                if let Some(parent_id) = sym.parent {
156                    after_impl_methods
157                        .entry(parent_id)
158                        .or_default()
159                        .insert(sym.qualified_name.as_str());
160                }
161            }
162        }
163
164        // Find impl blocks that lost methods.
165        for (parent_id, before_methods) in &before_impl_methods {
166            let after_methods = after_impl_methods.get(parent_id);
167            let after_set = after_methods.cloned().unwrap_or_default();
168
169            let lost: Vec<&str> = before_methods
170                .difference(&after_set)
171                .copied()
172                .collect();
173
174            if !lost.is_empty() {
175                // Try to find the parent symbol name for a better message.
176                let parent_name = ctx
177                    .before_symbols
178                    .iter()
179                    .find(|s| s.id == *parent_id)
180                    .map(|s| s.qualified_name.as_str())
181                    .unwrap_or("unknown");
182
183                findings.push(Finding {
184                    severity: Severity::Warning,
185                    check_name: self.name().to_string(),
186                    message: format!(
187                        "impl block '{}' lost {} method(s): {}",
188                        parent_name,
189                        lost.len(),
190                        lost.join(", ")
191                    ),
192                    file_path: None,
193                    line: None,
194                    symbol: Some(parent_name.to_string()),
195                });
196            }
197        }
198
199        findings
200    }
201}
202
203/// Returns all 3 compatibility checks.
204pub fn compat_checks() -> Vec<Box<dyn SemanticCheck>> {
205    vec![
206        Box::new(NoPublicRemoval::new()),
207        Box::new(SignatureStable::new()),
208        Box::new(TraitImplComplete::new()),
209    ]
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use super::super::checks::CheckContext;
216    use dk_core::types::{Span, Symbol, SymbolKind, Visibility};
217    use uuid::Uuid;
218
219    fn empty_context() -> CheckContext {
220        CheckContext {
221            before_symbols: vec![],
222            after_symbols: vec![],
223            before_call_graph: vec![],
224            after_call_graph: vec![],
225            before_deps: vec![],
226            after_deps: vec![],
227            changed_files: vec![],
228        }
229    }
230
231    fn make_sym(name: &str, vis: Visibility, sig: Option<&str>, parent: Option<Uuid>) -> Symbol {
232        Symbol {
233            id: Uuid::new_v4(),
234            name: name.split("::").last().unwrap_or(name).into(),
235            qualified_name: name.into(),
236            kind: SymbolKind::Function,
237            visibility: vis,
238            file_path: "src/lib.rs".into(),
239            span: Span { start_byte: 0, end_byte: 100 },
240            signature: sig.map(String::from),
241            doc_comment: None,
242            parent,
243            last_modified_by: None,
244            last_modified_intent: None,
245        }
246    }
247
248    #[test]
249    fn test_no_public_removal_detects() {
250        let mut ctx = empty_context();
251        ctx.before_symbols.push(make_sym("crate::foo", Visibility::Public, None, None));
252        // after_symbols is empty — foo was removed.
253
254        let check = NoPublicRemoval::new();
255        let findings = check.run(&ctx);
256        assert_eq!(findings.len(), 1);
257        assert_eq!(findings[0].severity, Severity::Error);
258    }
259
260    #[test]
261    fn test_no_public_removal_ignores_private() {
262        let mut ctx = empty_context();
263        ctx.before_symbols.push(make_sym("crate::internal", Visibility::Private, None, None));
264
265        let check = NoPublicRemoval::new();
266        assert!(check.run(&ctx).is_empty());
267    }
268
269    #[test]
270    fn test_signature_stable_detects_change() {
271        let mut ctx = empty_context();
272        ctx.before_symbols.push(make_sym(
273            "crate::process",
274            Visibility::Public,
275            Some("fn process(x: u32) -> u32"),
276            None,
277        ));
278        ctx.after_symbols.push(make_sym(
279            "crate::process",
280            Visibility::Public,
281            Some("fn process(x: u32, y: u32) -> u32"),
282            None,
283        ));
284
285        let check = SignatureStable::new();
286        let findings = check.run(&ctx);
287        assert_eq!(findings.len(), 1);
288        assert_eq!(findings[0].severity, Severity::Error);
289    }
290
291    #[test]
292    fn test_signature_stable_no_change() {
293        let mut ctx = empty_context();
294        let sig = "fn process(x: u32) -> u32";
295        ctx.before_symbols.push(make_sym("crate::process", Visibility::Public, Some(sig), None));
296        ctx.after_symbols.push(make_sym("crate::process", Visibility::Public, Some(sig), None));
297
298        let check = SignatureStable::new();
299        assert!(check.run(&ctx).is_empty());
300    }
301
302    #[test]
303    fn test_trait_impl_complete_detects_lost_method() {
304        let parent_id = Uuid::new_v4();
305        let mut ctx = empty_context();
306
307        // Parent symbol (the impl block).
308        let mut parent_sym = make_sym("crate::MyStruct", Visibility::Public, None, None);
309        parent_sym.id = parent_id;
310        parent_sym.kind = SymbolKind::Impl;
311        ctx.before_symbols.push(parent_sym.clone());
312        ctx.after_symbols.push(parent_sym);
313
314        // Before: two methods.
315        ctx.before_symbols.push(make_sym("crate::MyStruct::method_a", Visibility::Public, None, Some(parent_id)));
316        ctx.before_symbols.push(make_sym("crate::MyStruct::method_b", Visibility::Public, None, Some(parent_id)));
317
318        // After: only one method.
319        ctx.after_symbols.push(make_sym("crate::MyStruct::method_a", Visibility::Public, None, Some(parent_id)));
320
321        let check = TraitImplComplete::new();
322        let findings = check.run(&ctx);
323        assert_eq!(findings.len(), 1);
324        assert_eq!(findings[0].severity, Severity::Warning);
325        assert!(findings[0].message.contains("method_b"));
326    }
327}