Skip to main content

spec_drift/analyzers/
tests.rs

1use super::DriftAnalyzer;
2use crate::context::ProjectContext;
3use crate::domain::{Divergence, Location, RuleId, Severity};
4use crate::error::SpecDriftError;
5use std::path::Path;
6
7/// TestsAnalyzer — enforces the `lying_test` rule (heuristic).
8///
9/// A test that *names* a negative assertion (contains "cannot", "rejects",
10/// "forbidden", "returns_error", "fails", ...) but has a body that only makes
11/// positive assertions (plain `assert!(x)` on an `is_ok()`-ish expression, or
12/// no assertions at all) is almost certainly lying. The heuristic is
13/// intentionally conservative and only fires when intent and assertion
14/// plainly disagree.
15#[derive(Default)]
16pub struct TestsAnalyzer;
17
18impl DriftAnalyzer for TestsAnalyzer {
19    fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
20        let mut out = Vec::new();
21        for rs in &ctx.rust_files {
22            match scan_file(rs) {
23                Ok(mut divs) => out.append(&mut divs),
24                Err(e) => eprintln!("spec-drift: skipping {} in tests pillar: {e}", rs.display()),
25            }
26        }
27        out
28    }
29}
30
31fn scan_file(path: &Path) -> Result<Vec<Divergence>, SpecDriftError> {
32    let source = std::fs::read_to_string(path).map_err(|e| SpecDriftError::Io {
33        path: path.to_path_buf(),
34        source: e,
35    })?;
36    let file = syn::parse_file(&source).map_err(|e| SpecDriftError::RustParse {
37        path: path.to_path_buf(),
38        source: e,
39    })?;
40
41    let mut out = Vec::new();
42    visit_items(&file.items, path, &mut out);
43    Ok(out)
44}
45
46fn visit_items(items: &[syn::Item], path: &Path, out: &mut Vec<Divergence>) {
47    for item in items {
48        match item {
49            syn::Item::Fn(f) if is_test_fn(&f.attrs) => inspect_test(f, path, out),
50            syn::Item::Mod(m) => {
51                if let Some((_, inner)) = &m.content {
52                    visit_items(inner, path, out);
53                }
54            }
55            _ => {}
56        }
57    }
58}
59
60fn is_test_fn(attrs: &[syn::Attribute]) -> bool {
61    attrs.iter().any(|a| {
62        let p = a.path();
63        p.is_ident("test") || p.segments.last().is_some_and(|s| s.ident == "test")
64    })
65}
66
67fn inspect_test(f: &syn::ItemFn, path: &Path, out: &mut Vec<Divergence>) {
68    let name = f.sig.ident.to_string();
69    if !intent_is_negative(&name) {
70        return;
71    }
72
73    let (count, has_negative) = scan_assertions(&f.block.stmts);
74
75    // A negatively-named test must either assert nothing (empty test) or
76    // contain at least one negative assertion. If it has only positive
77    // assertions, the name and the check disagree → drift.
78    if count == 0 || !has_negative {
79        let line = f.sig.ident.span().start().line as u32;
80        let reason = if count == 0 {
81            "has no assertions"
82        } else {
83            "only contains positive assertions"
84        };
85        out.push(Divergence {
86            rule: RuleId::LyingTest,
87            severity: Severity::Critical,
88            location: Location::new(path, line),
89            stated: format!("`{name}` verifies a negative / failure path"),
90            reality: format!("`{name}` {reason}"),
91            risk: "The test is green but proves nothing about the stated contract.".to_string(),
92            attribution: None,
93        });
94    }
95}
96
97fn intent_is_negative(name: &str) -> bool {
98    let n = name.to_ascii_lowercase();
99
100    // Tests that describe *detecting* a drift / violation / error are not
101    // negative-path tests of the subject — they are positive tests of the
102    // detector. `flags_missing_symbol` means "the analyzer flags a missing
103    // symbol", not "the subject is missing its symbol". Skip these.
104    const DETECTION_PREFIXES: &[&str] = &[
105        "flags_",
106        "detects_",
107        "finds_",
108        "reports_",
109        "surfaces_",
110        "identifies_",
111        "catches_",
112        "warns_",
113        "does_not_",
114        "doesnt_",
115    ];
116    if DETECTION_PREFIXES.iter().any(|p| n.starts_with(p)) {
117        return false;
118    }
119
120    // Note: "missing" is intentionally omitted — too ambiguous in names like
121    // `missing_file_is_not_an_error` where it describes the input, not the
122    // expected outcome. `rejects_missing_field` is still caught via "rejects".
123    const NEGATIVE_HINTS: &[&str] = &[
124        "cannot",
125        "rejects",
126        "forbidden",
127        "unauthorized",
128        "denied",
129        "returns_error",
130        "returns_err",
131        "fails",
132        "invalid",
133        "not_allowed",
134        "panics",
135    ];
136    NEGATIVE_HINTS.iter().any(|h| n.contains(h))
137}
138
139fn scan_assertions(stmts: &[syn::Stmt]) -> (usize, bool) {
140    let mut count = 0usize;
141    let mut has_neg = false;
142    for stmt in stmts {
143        walk_stmt(stmt, &mut count, &mut has_neg);
144    }
145    (count, has_neg)
146}
147
148fn walk_stmt(stmt: &syn::Stmt, count: &mut usize, has_neg: &mut bool) {
149    match stmt {
150        syn::Stmt::Expr(e, _) => walk_expr(e, count, has_neg),
151        syn::Stmt::Local(local) => {
152            if let Some(init) = &local.init {
153                walk_expr(&init.expr, count, has_neg);
154            }
155        }
156        syn::Stmt::Macro(m) => {
157            handle_macro(&m.mac, count, has_neg);
158        }
159        _ => {}
160    }
161}
162
163fn walk_expr(expr: &syn::Expr, count: &mut usize, has_neg: &mut bool) {
164    match expr {
165        syn::Expr::Macro(m) => handle_macro(&m.mac, count, has_neg),
166        syn::Expr::Block(b) => {
167            for s in &b.block.stmts {
168                walk_stmt(s, count, has_neg);
169            }
170        }
171        syn::Expr::If(i) => {
172            for s in &i.then_branch.stmts {
173                walk_stmt(s, count, has_neg);
174            }
175            if let Some((_, else_expr)) = &i.else_branch {
176                walk_expr(else_expr, count, has_neg);
177            }
178        }
179        syn::Expr::Match(m) => {
180            for arm in &m.arms {
181                walk_expr(&arm.body, count, has_neg);
182            }
183        }
184        _ => {}
185    }
186}
187
188fn handle_macro(mac: &syn::Macro, count: &mut usize, has_neg: &mut bool) {
189    let name = mac.path.segments.last().map(|s| s.ident.to_string());
190    let Some(name) = name else { return };
191
192    let is_assertion = matches!(
193        name.as_str(),
194        "assert"
195            | "assert_eq"
196            | "assert_ne"
197            | "debug_assert"
198            | "debug_assert_eq"
199            | "debug_assert_ne"
200            | "panic"
201    );
202    if !is_assertion {
203        return;
204    }
205
206    *count += 1;
207    let tokens = mac.tokens.to_string();
208    if assertion_looks_negative(&name, &tokens) {
209        *has_neg = true;
210    }
211}
212
213fn assertion_looks_negative(name: &str, tokens: &str) -> bool {
214    if name == "assert_ne" {
215        return true;
216    }
217    if name == "panic" {
218        return true;
219    }
220    // `syn` emits `TokenStream::to_string()` with spaces around punctuation
221    // ("res . is_none ()"), so naive substring checks miss real negative
222    // assertions. Normalize by stripping whitespace and lowercasing before
223    // matching.
224    let t: String = tokens
225        .chars()
226        .filter(|c| !c.is_whitespace())
227        .collect::<String>()
228        .to_ascii_lowercase();
229    t.starts_with('!')
230        || t.contains(".is_err")
231        || t.contains(".is_none")
232        || t.contains("matches!(")
233        || t.contains("err(")
234        || t.contains(".is_not_")
235        || t.contains("403")
236        || t.contains("401")
237        || t.contains("404")
238}
239
240#[cfg(test)]
241mod tests_inner {
242    use super::*;
243
244    fn run_on_source(src: &str) -> Vec<Divergence> {
245        let tmp = tempfile::tempdir().unwrap();
246        let path = tmp.path().join("lib.rs");
247        std::fs::write(&path, src).unwrap();
248
249        let mut ctx = ProjectContext::new(tmp.path());
250        ctx.rust_files.push(path);
251        TestsAnalyzer.analyze(&ctx)
252    }
253
254    #[test]
255    fn flags_negative_name_with_only_positive_assertion() {
256        let src = r#"
257            #[test]
258            fn user_cannot_access_admin_panel() {
259                assert!(true);
260            }
261        "#;
262        let divs = run_on_source(src);
263        assert_eq!(divs.len(), 1);
264        assert_eq!(divs[0].rule, RuleId::LyingTest);
265    }
266
267    #[test]
268    fn flags_negative_name_with_no_assertions() {
269        let src = r#"
270            #[test]
271            fn rejects_invalid_payload() {
272                let _ = 1;
273            }
274        "#;
275        let divs = run_on_source(src);
276        assert_eq!(divs.len(), 1);
277    }
278
279    #[test]
280    fn passes_negative_name_with_is_err_assertion() {
281        let src = r#"
282            #[test]
283            fn rejects_invalid_payload() {
284                let res: Result<(), ()> = Err(());
285                assert!(res.is_err());
286            }
287        "#;
288        let divs = run_on_source(src);
289        assert!(divs.is_empty());
290    }
291
292    #[test]
293    fn ignores_positive_tests() {
294        let src = r#"
295            #[test]
296            fn builds_a_widget() {
297                assert!(true);
298            }
299        "#;
300        let divs = run_on_source(src);
301        assert!(divs.is_empty());
302    }
303
304    #[test]
305    fn exempts_detection_prefixed_test_names() {
306        // `flags_`, `detects_`, etc. describe the analyzer's job, not the
307        // subject's failure. They must not be treated as negative-path tests.
308        let src = r#"
309            #[test]
310            fn flags_missing_symbol_in_markdown() {
311                let found = 1;
312                assert_eq!(found, 1);
313            }
314        "#;
315        let divs = run_on_source(src);
316        assert!(divs.is_empty());
317    }
318
319    #[test]
320    fn passes_negative_name_with_status_code_check() {
321        let src = r#"
322            #[test]
323            fn user_cannot_access_admin_panel() {
324                assert_eq!(status, 403);
325            }
326        "#;
327        let divs = run_on_source(src);
328        assert!(divs.is_empty());
329    }
330}