1use crate::fingerprint::fingerprint;
8use camino::Utf8Path;
9use mollify_graph::ModuleGraph;
10use mollify_types::{Action, Category, Confidence, Finding, Location, Severity};
11use rustc_hash::{FxHashMap, FxHashSet};
12
13pub fn analyze(root: &Utf8Path, graph: &ModuleGraph, coverage_path: &Utf8Path) -> Vec<Finding> {
15 let Ok(text) = std::fs::read_to_string(coverage_path) else {
16 return Vec::new();
17 };
18 let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
19 return Vec::new();
20 };
21 let Some(files) = json.get("files").and_then(|f| f.as_object()) else {
22 return Vec::new();
23 };
24
25 let mut by_key: FxHashMap<String, FxHashSet<u32>> = FxHashMap::default();
27 for (key, val) in files {
28 let mut set = FxHashSet::default();
29 if let Some(lines) = val.get("executed_lines").and_then(|l| l.as_array()) {
30 for l in lines {
31 if let Some(n) = l.as_u64() {
32 set.insert(n as u32);
33 }
34 }
35 }
36 by_key.insert(key.clone(), set);
37 }
38
39 let mut findings = Vec::new();
40 for m in &graph.modules {
41 let executed = match_coverage(root, &m.path, &by_key);
42 let Some(executed) = executed else {
43 continue; };
45 for f in &m.parsed.functions {
46 let ran = (f.line..=f.end_line).any(|ln| executed.contains(&ln));
47 if ran {
48 continue;
49 }
50 let rule = "cold-code";
51 findings.push(Finding {
52 fingerprint: fingerprint(rule, &[m.path.as_str(), &f.name, &f.line.to_string()]),
53 rule: rule.into(),
54 category: Category::DeadCode,
55 severity: Severity::Warn,
56 confidence: Confidence::Likely,
57 attribution: None,
58 reason: format!(
59 "function `{}` is reachable but never executed in the provided coverage (cold path)",
60 f.name
61 ),
62 location: Location {
63 path: m.path.clone(),
64 line: f.line,
65 column: 0,
66 end_line: Some(f.end_line),
67 },
68 actions: vec![Action {
69 kind: "review-cold-code".into(),
70 description: format!(
71 "`{}` ran zero times in this coverage — verify it's dead before removing",
72 f.name
73 ),
74 auto_fixable: false,
75 suppression_comment: Some("# mollify: ignore[cold-code]".into()),
76 }],
77 });
78 }
79 }
80 findings
81}
82
83fn match_coverage<'a>(
86 root: &Utf8Path,
87 path: &Utf8Path,
88 by_key: &'a FxHashMap<String, FxHashSet<u32>>,
89) -> Option<&'a FxHashSet<u32>> {
90 if let Some(s) = by_key.get(path.as_str()) {
91 return Some(s);
92 }
93 let rel = path
94 .strip_prefix(root)
95 .unwrap_or(path)
96 .as_str()
97 .trim_start_matches("./");
98 if let Some(s) = by_key.get(rel) {
99 return Some(s);
100 }
101 let name = path.file_name()?;
102 by_key
103 .iter()
104 .find(|(k, _)| k.ends_with(name))
105 .map(|(_, v)| v)
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use camino::Utf8PathBuf;
112 use mollify_graph::discover_python_files;
113
114 fn temp(tag: &str) -> Utf8PathBuf {
115 let base =
116 std::env::temp_dir().join(format!("mollify-core-cov-{}-{tag}", std::process::id()));
117 let _ = std::fs::remove_dir_all(&base);
118 std::fs::create_dir_all(&base).unwrap();
119 Utf8PathBuf::from_path_buf(base).unwrap()
120 }
121
122 #[test]
123 fn flags_cold_function() {
124 let d = temp("cov");
125 std::fs::write(
127 d.join("app.py"),
128 "def hot():\n return 1\n\ndef cold():\n return 2\n",
129 )
130 .unwrap();
131 let cov = d.join("coverage.json");
133 std::fs::write(&cov, r#"{"files":{"app.py":{"executed_lines":[1,2]}}}"#).unwrap();
134 let files = discover_python_files(&d);
135 let g = ModuleGraph::build(&d, &files);
136 let f = analyze(&d, &g, &cov);
137 assert!(
138 f.iter()
139 .any(|x| x.rule == "cold-code" && x.reason.contains("cold")),
140 "got {f:?}"
141 );
142 assert!(!f.iter().any(|x| x.reason.contains("`hot`")));
143 std::fs::remove_dir_all(&d).ok();
144 }
145}