software_engineering/static_analysis_metrics.rs
1//! # Static Analysis and Code Smell Metrics
2//!
3//! [Static analysis](https://en.wikipedia.org/wiki/Static_program_analysis)
4//! tools scan source code without executing it, flagging patterns known
5//! to correlate with defects, security vulnerabilities, or
6//! maintainability problems, plus the broader category of code smells —
7//! structural patterns that are not necessarily bugs but tend to make
8//! code harder to understand, test, or safely change. The gap this
9//! module addresses is between what a tool reports and what actually
10//! matters: a raw finding count conflates trivial style preferences with
11//! genuine, severe risk, and it can be driven down through suppression
12//! as easily as through real fixes.
13//!
14//! ## Formula
15//!
16//! ```text
17//! Findings per KLOC = findings / (lines_of_code / 1000)
18//!
19//! Severity-weighted score = Σ (count × severity_weight)
20//! Critical = 5, Major = 3, Minor = 1
21//! ```
22//!
23//! ## Why it matters
24//!
25//! The value of static analysis comes not from the raw finding count but
26//! from how well an organization triages severity: a small number of
27//! critical findings deserves more attention than a large number of
28//! trivial ones. Reporting only a total count invites exactly the wrong
29//! incentive — suppressing findings (real or not) to make the number
30//! smaller — while a severity-weighted score keeps a spike in trivial
31//! findings from visually swamping a smaller but far more consequential
32//! rise in critical ones.
33//!
34//! ## Example
35//!
36//! ```rust
37//! use software_engineering::static_analysis_metrics::{
38//! FindingSeverity, findings_per_kloc, severity_weighted_finding_score,
39//! };
40//!
41//! // 45 findings across 15,000 lines of code: 3 findings per KLOC.
42//! let density = findings_per_kloc(45.0, 15_000.0).unwrap();
43//! assert!((density - 3.0).abs() < 1e-9);
44//!
45//! // A single critical finding outweighs four minor ones — the
46//! // severity-weighted score is what keeps that visible.
47//! let critical = severity_weighted_finding_score(&[(FindingSeverity::Critical, 1.0)]);
48//! let many_minor = severity_weighted_finding_score(&[(FindingSeverity::Minor, 4.0)]);
49//! assert!(critical > many_minor);
50//! ```
51//!
52//! ## Pitfalls
53//!
54//! - **Treating raw finding count as the metric** — conflates trivial
55//! and severe issues and is easily gamed through suppression.
56//! - **Requiring the entire historical backlog resolved before any new
57//! work proceeds** — usually impractical and drives suppression rather
58//! than genuine fixes.
59//! - **Ignoring false-positive rate** — an unmanaged noise level leads
60//! teams to tune out the tool's output entirely, including real
61//! findings.
62//! - **Silent, undocumented suppression of legitimate findings** —
63//! erodes the tool's signal and leaves no audit trail.
64//! - **Treating a finding as an automatic verdict with no human review**
65//! — misses context a tool cannot see.
66//!
67//! ## Sources
68//!
69//! - Chapter 4.4, Static analysis and code smell metrics.
70//!
71//! Topic doc: software-engineering-metrics/locales/en-001/chapters/04-04-static-analysis-and-code-smell-metrics.md
72
73/// Severity classification for a static-analysis finding.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum FindingSeverity {
76 /// A finding with severe, direct risk (e.g. a likely defect or
77 /// security exposure).
78 Critical,
79 /// A finding with meaningful but non-severe risk.
80 Major,
81 /// A trivial, mostly stylistic finding.
82 Minor,
83}
84
85/// The fixed weight applied to a finding of a given severity in
86/// [`severity_weighted_finding_score`]: Critical = 5.0, Major = 3.0,
87/// Minor = 1.0 — the same weighting convention this crate uses for
88/// escaped defects, so a spike in trivial findings can't visually swamp
89/// a smaller but more consequential rise in critical ones.
90///
91/// # Arguments
92///
93/// * `severity` — the finding severity to weight.
94///
95/// # Returns
96///
97/// The fixed weight for that severity.
98///
99/// # Examples
100///
101/// ```rust
102/// use software_engineering::static_analysis_metrics::{FindingSeverity, severity_weight};
103///
104/// assert!((severity_weight(FindingSeverity::Critical) - 5.0).abs() < 1e-9);
105/// assert!((severity_weight(FindingSeverity::Major) - 3.0).abs() < 1e-9);
106/// assert!((severity_weight(FindingSeverity::Minor) - 1.0).abs() < 1e-9);
107/// ```
108#[must_use]
109pub fn severity_weight(severity: FindingSeverity) -> f64 {
110 match severity {
111 FindingSeverity::Critical => 5.0,
112 FindingSeverity::Major => 3.0,
113 FindingSeverity::Minor => 1.0,
114 }
115}
116
117/// Findings per thousand lines of code (KLOC) — a normalized density
118/// that resists the "the codebase just got bigger" confound a raw
119/// finding count has.
120///
121/// `findings / (lines_of_code / 1000)`.
122///
123/// # Arguments
124///
125/// * `findings` — total finding count.
126/// * `lines_of_code` — size of the scanned codebase, in lines.
127///
128/// # Returns
129///
130/// The finding density per KLOC, or `None` if `lines_of_code` is zero.
131///
132/// # Examples
133///
134/// ```rust
135/// use software_engineering::static_analysis_metrics::findings_per_kloc;
136///
137/// assert_eq!(findings_per_kloc(45.0, 15_000.0), Some(3.0));
138/// assert_eq!(findings_per_kloc(45.0, 0.0), None);
139/// ```
140#[must_use]
141pub fn findings_per_kloc(findings: f64, lines_of_code: f64) -> Option<f64> {
142 if lines_of_code == 0.0 {
143 return None;
144 }
145 Some(findings / (lines_of_code / 1000.0))
146}
147
148/// A severity-weighted static-analysis finding score, so a spike in
149/// trivial findings cannot visually swamp a smaller rise in critical
150/// ones.
151///
152/// Sum over `counts` of `count × severity_weight(severity)`.
153///
154/// # Arguments
155///
156/// * `counts` — pairs of (severity, count) for the findings being
157/// scored.
158///
159/// # Returns
160///
161/// The severity-weighted score. An empty slice sums to `0.0`.
162///
163/// # Examples
164///
165/// ```rust
166/// use software_engineering::static_analysis_metrics::{FindingSeverity, severity_weighted_finding_score};
167///
168/// // One critical finding (weight 5) outweighs four minor ones (weight 1 each = 4).
169/// let critical = severity_weighted_finding_score(&[(FindingSeverity::Critical, 1.0)]);
170/// let many_minor = severity_weighted_finding_score(&[(FindingSeverity::Minor, 4.0)]);
171/// assert!(critical > many_minor);
172/// assert_eq!(severity_weighted_finding_score(&[]), 0.0);
173/// ```
174#[must_use]
175pub fn severity_weighted_finding_score(counts: &[(FindingSeverity, f64)]) -> f64 {
176 counts
177 .iter()
178 .map(|&(severity, count)| count * severity_weight(severity))
179 .sum()
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn severity_weight_matches_documented_values_for_all_severities() {
188 assert!((severity_weight(FindingSeverity::Critical) - 5.0).abs() < 1e-9);
189 assert!((severity_weight(FindingSeverity::Major) - 3.0).abs() < 1e-9);
190 assert!((severity_weight(FindingSeverity::Minor) - 1.0).abs() < 1e-9);
191 }
192
193 // "Findings per KLOC" as a normalized density that resists a
194 // growing-codebase confound.
195 #[test]
196 fn findings_per_kloc_divides_by_thousand_lines() {
197 let density = findings_per_kloc(45.0, 15_000.0).unwrap();
198 assert!((density - 3.0).abs() < 1e-9);
199 }
200
201 #[test]
202 fn findings_per_kloc_is_none_for_zero_lines_of_code() {
203 assert_eq!(findings_per_kloc(45.0, 0.0), None);
204 }
205
206 // "A small number of critical findings deserves more attention than
207 // a large number of trivial ones."
208 #[test]
209 fn one_critical_finding_outweighs_several_minor_findings() {
210 let critical = severity_weighted_finding_score(&[(FindingSeverity::Critical, 1.0)]);
211 let many_minor = severity_weighted_finding_score(&[(FindingSeverity::Minor, 4.0)]);
212 assert!(critical > many_minor);
213 }
214
215 #[test]
216 fn empty_counts_sum_to_zero() {
217 assert!((severity_weighted_finding_score(&[]) - 0.0).abs() < 1e-9);
218 }
219}