dk_runner/steps/semantic/
compat.rs1use std::collections::{HashMap, HashSet};
2
3use dk_core::types::{SymbolKind, Visibility};
4
5use crate::findings::{Finding, Severity};
6
7use super::checks::{CheckContext, SemanticCheck};
8
9pub 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
58pub 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 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
116pub 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 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 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 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 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
203pub 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 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 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 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 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}