Skip to main content

deprot_core/
signals.rs

1//! Individual health signals.
2//!
3//! Each signal reduces some slice of [`Facts`] to a normalized subscore in `0.0..=1.0` (1.0 =
4//! healthy) plus a human-readable explanation. A signal that cannot be computed from the
5//! available facts returns `None` and is simply left out of the weighted average, so a missing
6//! data source lowers confidence rather than unfairly tanking a grade.
7
8use crate::facts::{Facts, Severity};
9use chrono::{DateTime, Utc};
10
11/// One scored dimension of dependency health.
12#[derive(Debug, Clone, PartialEq)]
13pub struct Signal {
14    /// Stable short name, e.g. `"staleness"`.
15    pub name: &'static str,
16    /// Normalized subscore, `0.0` (worst) ..= `1.0` (best).
17    pub score: f64,
18    /// Relative weight in the aggregate. Only the weights of *present* signals count.
19    pub weight: f64,
20    /// One-line explanation of why this subscore was assigned (powers `--explain`).
21    pub detail: String,
22}
23
24impl Signal {
25    fn new(name: &'static str, score: f64, weight: f64, detail: impl Into<String>) -> Self {
26        Signal {
27            name,
28            score: score.clamp(0.0, 1.0),
29            weight,
30            detail: detail.into(),
31        }
32    }
33}
34
35/// Linear interpolation of `x` from the range `[lo, hi]` onto `[0, 1]`, clamped. When `hi < lo`
36/// the mapping is inverted (larger `x` → smaller output), which is how "more days is worse" style
37/// signals are expressed.
38fn ramp(x: f64, lo: f64, hi: f64) -> f64 {
39    if (hi - lo).abs() < f64::EPSILON {
40        return if x >= lo { 1.0 } else { 0.0 };
41    }
42    ((x - lo) / (hi - lo)).clamp(0.0, 1.0)
43}
44
45/// Staleness: how long since the most recent release. Fresh (<90d) is perfect; it decays to a
46/// floor as the package approaches ~2 years without a release.
47pub fn staleness(facts: &Facts, now: DateTime<Utc>) -> Option<Signal> {
48    let last = facts.latest_published?;
49    let days = (now - last).num_days().max(0) as f64;
50    // 90d -> 1.0, 730d (~2y) -> 0.0
51    let score = 1.0 - ramp(days, 90.0, 730.0);
52    let detail = format!("last release {} days ago", days as i64);
53    Some(Signal::new("staleness", score, 2.0, detail))
54}
55
56/// Release cadence: how many releases shipped in the trailing year. Zero is a strong rot signal;
57/// four or more reads as an actively iterating project.
58pub fn cadence(facts: &Facts) -> Option<Signal> {
59    let n = facts.releases_last_year?;
60    let score = ramp(n as f64, 0.0, 4.0);
61    let detail = format!("{n} release(s) in the last 12 months");
62    Some(Signal::new("cadence", score, 1.0, detail))
63}
64
65/// Deprecation: a registry-level deprecation is a near-fatal health signal.
66pub fn deprecation(facts: &Facts) -> Option<Signal> {
67    if !facts.deprecated {
68        return None;
69    }
70    let detail = facts
71        .deprecated_reason
72        .clone()
73        .filter(|r| !r.is_empty())
74        .map(|r| format!("deprecated: {r}"))
75        .unwrap_or_else(|| "package is deprecated".to_string());
76    Some(Signal::new("deprecation", 0.0, 4.0, detail))
77}
78
79/// Known vulnerabilities: driven by the single worst advisory affecting the analyzed version.
80pub fn vulnerabilities(facts: &Facts) -> Option<Signal> {
81    if facts.vulns.is_empty() {
82        // "No known advisories" is only a *positive* signal about a version we actually resolved
83        // and inspected. When the package could not be resolved at all, an empty vuln list is
84        // absence of data, not a clean bill of health — contribute no signal and let `score` treat
85        // the package as unassessed.
86        if facts.is_unresolved() {
87            return None;
88        }
89        // Absence of *known* vulns is a (mild) positive signal we can assert.
90        return Some(Signal::new(
91            "vulnerabilities",
92            1.0,
93            2.0,
94            "no known advisories".to_string(),
95        ));
96    }
97    let worst = facts
98        .vulns
99        .iter()
100        .max_by(|a, b| a.severity().cmp(&b.severity()))
101        .expect("non-empty checked above");
102    let score = match worst.severity() {
103        Severity::Critical => 0.0,
104        Severity::High => 0.15,
105        Severity::Medium => 0.5,
106        Severity::Low => 0.75,
107    };
108    let detail = format!(
109        "{} known advisory(ies); worst {} ({})",
110        facts.vulns.len(),
111        worst.id,
112        match worst.severity() {
113            Severity::Critical => "critical",
114            Severity::High => "high",
115            Severity::Medium => "medium",
116            Severity::Low => "low",
117        }
118    );
119    Some(Signal::new("vulnerabilities", score, 3.0, detail))
120}
121
122/// License hygiene: a recognized permissive license is best; a copyleft or unusual-but-known
123/// license is fine-with-caveats; a missing or unknown license is a compliance risk.
124pub fn license(facts: &Facts) -> Option<Signal> {
125    if facts.licenses.is_empty() {
126        return Some(Signal::new(
127            "license",
128            0.3,
129            1.0,
130            "no license declared".to_string(),
131        ));
132    }
133    let permissive = [
134        "MIT",
135        "APACHE-2.0",
136        "BSD-2-CLAUSE",
137        "BSD-3-CLAUSE",
138        "ISC",
139        "0BSD",
140        "UNLICENSE",
141    ];
142    let copyleft = ["GPL", "LGPL", "AGPL", "MPL"];
143    let joined = facts.licenses.join(", ");
144    // Break SPDX expressions ("Apache-2.0 OR MIT", "(MIT AND BSD-3-Clause)") into individual
145    // identifier tokens so a compound-but-permissive license is still recognized as permissive.
146    let tokens: Vec<String> = facts
147        .licenses
148        .iter()
149        .flat_map(|l| {
150            l.to_uppercase()
151                .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '.'))
152                .filter(|t| !t.is_empty() && *t != "OR" && *t != "AND" && *t != "WITH")
153                .map(|t| t.to_string())
154                .collect::<Vec<_>>()
155        })
156        .collect();
157    let is_permissive = tokens.iter().any(|t| permissive.contains(&t.as_str()));
158    let is_copyleft = tokens
159        .iter()
160        .any(|t| copyleft.iter().any(|c| t.contains(c)));
161    let (score, note) = if is_permissive {
162        (1.0, "permissive")
163    } else if is_copyleft {
164        (0.6, "copyleft — review obligations")
165    } else {
166        (0.5, "non-standard — review terms")
167    };
168    Some(Signal::new(
169        "license",
170        score,
171        1.0,
172        format!("{joined} ({note})"),
173    ))
174}
175
176/// OpenSSF Scorecard: upstream engineering hygiene (CI, review, signed releases, ...). Prefers
177/// the aggregate score, falling back to the "Maintained" check when only that is present.
178pub fn scorecard(facts: &Facts) -> Option<Signal> {
179    let (val, which) = match (facts.scorecard_overall, facts.scorecard_maintained) {
180        (Some(o), _) => (o, "overall"),
181        (None, Some(m)) => (m, "maintained"),
182        (None, None) => return None,
183    };
184    let score = (val / 10.0).clamp(0.0, 1.0);
185    Some(Signal::new(
186        "scorecard",
187        score,
188        1.5,
189        format!("OpenSSF Scorecard {which} {val:.1}/10"),
190    ))
191}
192
193/// Bus factor / capture risk: how concentrated authorship is. A project where one author owns
194/// almost all recent commits is fragile and a takeover-friendly target.
195pub fn bus_factor(facts: &Facts) -> Option<Signal> {
196    let share = facts.top_contributor_share?;
197    // 50% share -> 1.0 (healthy spread), 95%+ -> ~0.0 (single point of failure)
198    let score = 1.0 - ramp(share, 0.5, 0.95);
199    Some(Signal::new(
200        "bus_factor",
201        score,
202        1.5,
203        format!(
204            "top contributor authored {:.0}% of recent commits",
205            share * 100.0
206        ),
207    ))
208}
209
210/// Archived upstream repository: development has stopped.
211pub fn archived(facts: &Facts) -> Option<Signal> {
212    if !facts.archived {
213        return None;
214    }
215    Some(Signal::new(
216        "archived",
217        0.0,
218        3.0,
219        "source repository is archived".to_string(),
220    ))
221}
222
223/// Compute every applicable signal for a set of facts, in display order.
224pub fn all(facts: &Facts, now: DateTime<Utc>) -> Vec<Signal> {
225    [
226        deprecation(facts),
227        archived(facts),
228        vulnerabilities(facts),
229        staleness(facts, now),
230        cadence(facts),
231        scorecard(facts),
232        bus_factor(facts),
233        license(facts),
234    ]
235    .into_iter()
236    .flatten()
237    .collect()
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn facts_with_licenses(l: &[&str]) -> Facts {
245        Facts {
246            licenses: l.iter().map(|s| s.to_string()).collect(),
247            ..Default::default()
248        }
249    }
250
251    #[test]
252    fn spdx_or_expression_is_permissive() {
253        let sig = license(&facts_with_licenses(&["Apache-2.0 OR MIT"])).unwrap();
254        assert_eq!(sig.score, 1.0, "{}", sig.detail);
255    }
256
257    #[test]
258    fn copyleft_is_penalized() {
259        let sig = license(&facts_with_licenses(&["GPL-3.0-only"])).unwrap();
260        assert!(sig.score < 0.75);
261    }
262
263    #[test]
264    fn missing_license_is_low() {
265        let sig = license(&facts_with_licenses(&[])).unwrap();
266        assert!(sig.score <= 0.3);
267    }
268}