Skip to main content

spec_drift/analyzers/
coverage.rs

1use super::DriftAnalyzer;
2use crate::context::ProjectContext;
3use crate::domain::{Divergence, FactKind, Location, RuleId, Severity};
4use crate::parsers::MarkdownParser;
5use regex::Regex;
6use std::collections::HashSet;
7use std::path::Path;
8use std::sync::OnceLock;
9
10/// MissingCoverageAnalyzer — enforces `missing_coverage` (heuristic, Notice).
11///
12/// Strategy: for every function-shaped inline code span in Markdown (`fn_name()`
13/// or `Type::method()`) where the target function *exists* in the AST, look for
14/// at least one occurrence of the name in a test-scope file. Test-scope is any
15/// file under `tests/` or any `.rs` file containing a `#[test]` attribute.
16///
17/// The rule intentionally only fires for symbols that *do* exist — otherwise
18/// `symbol_absence` would already have flagged them.
19pub struct MissingCoverageAnalyzer;
20
21impl Default for MissingCoverageAnalyzer {
22    fn default() -> Self {
23        Self
24    }
25}
26
27impl DriftAnalyzer for MissingCoverageAnalyzer {
28    fn id(&self) -> &'static str {
29        "missing_coverage"
30    }
31
32    fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
33        let test_sources = load_test_sources(&ctx.rust_files);
34        if test_sources.is_empty() {
35            // No tests at all — `missing_coverage` would fire on every claim.
36            // That's noise; prefer silence until the user has at least some tests.
37            return Vec::new();
38        }
39
40        let mut out = Vec::new();
41        let mut already_flagged: HashSet<(std::path::PathBuf, u32, String)> = HashSet::new();
42
43        for md in &ctx.markdown_files {
44            let Ok(claims) = MarkdownParser::parse(md) else {
45                continue;
46            };
47            for claim in claims {
48                let Some(leaf) = extract_callable_leaf(&claim.text) else {
49                    continue;
50                };
51
52                // Only flag if the symbol actually exists in the codebase as a
53                // function. Otherwise symbol_absence handles it.
54                let exists_as_fn = ctx
55                    .facts_named(&leaf)
56                    .any(|f| matches!(f.kind, FactKind::Function));
57                if !exists_as_fn {
58                    continue;
59                }
60
61                if tests_reference(&test_sources, &leaf) {
62                    continue;
63                }
64
65                let key = (claim.location.file.clone(), claim.location.line, leaf.clone());
66                if !already_flagged.insert(key) {
67                    continue;
68                }
69
70                out.push(Divergence {
71                    rule: RuleId::MissingCoverage,
72                    severity: Severity::Notice,
73                    location: Location::new(
74                        claim.location.file.clone(),
75                        claim.location.line,
76                    ),
77                    stated: format!("`{leaf}` is a capability the project exposes"),
78                    reality: format!("no test references `{leaf}` by name"),
79                    risk: "Capability claimed in the docs has no guard-rail in tests.".to_string(),
80                    attribution: None,
81
82                });
83            }
84        }
85
86        out
87    }
88}
89
90fn callable_re() -> &'static Regex {
91    static RE: OnceLock<Regex> = OnceLock::new();
92    RE.get_or_init(|| {
93        // Matches `name()` or `Type::method()` (argument list optional), capturing
94        // the leaf identifier.
95        Regex::new(
96            r"^(?:[A-Za-z_][A-Za-z0-9_]*::)*([A-Za-z_][A-Za-z0-9_]*)\([^)]*\)$",
97        )
98        .unwrap()
99    })
100}
101
102fn extract_callable_leaf(span: &str) -> Option<String> {
103    let caps = callable_re().captures(span.trim())?;
104    Some(caps.get(1)?.as_str().to_string())
105}
106
107fn load_test_sources(rust_files: &[std::path::PathBuf]) -> Vec<String> {
108    rust_files
109        .iter()
110        .filter(|p| is_test_file(p))
111        .filter_map(|p| std::fs::read_to_string(p).ok())
112        .collect()
113}
114
115fn is_test_file(path: &Path) -> bool {
116    // `tests/` integration tests.
117    if path.components().any(|c| c.as_os_str() == "tests") {
118        return true;
119    }
120    // Inline `#[cfg(test)]` / `#[test]` in any file.
121    match std::fs::read_to_string(path) {
122        Ok(src) => src.contains("#[test]") || src.contains("#[cfg(test)]"),
123        Err(_) => false,
124    }
125}
126
127fn tests_reference(sources: &[String], symbol: &str) -> bool {
128    // Word-boundary match so `new` doesn't accidentally match `renew`.
129    let pattern = format!(r"\b{}\b", regex::escape(symbol));
130    let Ok(re) = Regex::new(&pattern) else {
131        return false;
132    };
133    sources.iter().any(|s| re.is_match(s))
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::domain::CodeFact;
140
141    fn fact_fn(name: &str) -> CodeFact {
142        CodeFact {
143            location: Location::new("src/lib.rs", 1),
144            kind: FactKind::Function,
145            name: name.to_string(),
146        }
147    }
148
149    #[test]
150    fn flags_documented_fn_absent_from_tests() {
151        let tmp = tempfile::tempdir().unwrap();
152        let md = tmp.path().join("README.md");
153        std::fs::write(&md, "Call `place_order()` to submit.\n").unwrap();
154
155        let test_file = tmp.path().join("tests").join("api.rs");
156        std::fs::create_dir_all(test_file.parent().unwrap()).unwrap();
157        std::fs::write(&test_file, "#[test] fn t() { other_fn(); }\n").unwrap();
158
159        let mut ctx = ProjectContext::new(tmp.path());
160        ctx.markdown_files.push(md);
161        ctx.rust_files.push(test_file);
162        ctx.code_facts.push(fact_fn("place_order"));
163
164        let divs = MissingCoverageAnalyzer.analyze(&ctx);
165        assert_eq!(divs.len(), 1);
166        assert_eq!(divs[0].rule, RuleId::MissingCoverage);
167        assert_eq!(divs[0].severity, Severity::Notice);
168    }
169
170    #[test]
171    fn passes_when_test_mentions_symbol() {
172        let tmp = tempfile::tempdir().unwrap();
173        let md = tmp.path().join("README.md");
174        std::fs::write(&md, "Call `place_order()` to submit.\n").unwrap();
175
176        let test_file = tmp.path().join("tests").join("api.rs");
177        std::fs::create_dir_all(test_file.parent().unwrap()).unwrap();
178        std::fs::write(
179            &test_file,
180            "#[test] fn t() { let _ = place_order(); }\n",
181        )
182        .unwrap();
183
184        let mut ctx = ProjectContext::new(tmp.path());
185        ctx.markdown_files.push(md);
186        ctx.rust_files.push(test_file);
187        ctx.code_facts.push(fact_fn("place_order"));
188
189        assert!(MissingCoverageAnalyzer.analyze(&ctx).is_empty());
190    }
191
192    #[test]
193    fn does_not_flag_missing_symbols() {
194        // If the symbol doesn't exist at all, that's symbol_absence's job.
195        let tmp = tempfile::tempdir().unwrap();
196        let md = tmp.path().join("README.md");
197        std::fs::write(&md, "Call `ghost_fn()` to vanish.\n").unwrap();
198
199        let test_file = tmp.path().join("tests").join("api.rs");
200        std::fs::create_dir_all(test_file.parent().unwrap()).unwrap();
201        std::fs::write(&test_file, "#[test] fn t() {}\n").unwrap();
202
203        let mut ctx = ProjectContext::new(tmp.path());
204        ctx.markdown_files.push(md);
205        ctx.rust_files.push(test_file);
206        // No code fact — ghost_fn doesn't exist.
207
208        assert!(MissingCoverageAnalyzer.analyze(&ctx).is_empty());
209    }
210
211    #[test]
212    fn word_boundary_prevents_substring_false_positive() {
213        // `new` must not match `renew`.
214        let tmp = tempfile::tempdir().unwrap();
215        let md = tmp.path().join("README.md");
216        std::fs::write(&md, "Call `new()`.\n").unwrap();
217
218        let test_file = tmp.path().join("tests").join("api.rs");
219        std::fs::create_dir_all(test_file.parent().unwrap()).unwrap();
220        std::fs::write(&test_file, "#[test] fn t() { renew(); }\n").unwrap();
221
222        let mut ctx = ProjectContext::new(tmp.path());
223        ctx.markdown_files.push(md);
224        ctx.rust_files.push(test_file);
225        ctx.code_facts.push(fact_fn("new"));
226
227        let divs = MissingCoverageAnalyzer.analyze(&ctx);
228        assert_eq!(divs.len(), 1);
229    }
230
231    #[test]
232    fn silent_when_no_tests_exist() {
233        let tmp = tempfile::tempdir().unwrap();
234        let md = tmp.path().join("README.md");
235        std::fs::write(&md, "Call `f()`.\n").unwrap();
236
237        let mut ctx = ProjectContext::new(tmp.path());
238        ctx.markdown_files.push(md);
239        ctx.code_facts.push(fact_fn("f"));
240        // No rust_files — project has no tests yet.
241
242        assert!(MissingCoverageAnalyzer.analyze(&ctx).is_empty());
243    }
244}