Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/mimir_lens/risk.rs

//! **Lens C — change-risk scoring** (Tornhill's behavioural code analysis model).
//!
//! `git_heat_facts` × the code room (call sites per item, the test-reachable
//! set) × `coverage_fn`.
//!
//! The model, the "never a composite score alone" discipline and the
//! attribution are dwarves' (`dwarves/crates/dwarves/src/mimir/risk.rs`).
//! `risk = downstream_callers × churn_90d × (1 − coverage)` — a node-level
//! reduce per file, a SORT KEY carried beside its three verbatim inputs, never
//! a lone verdict.

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use super::sources::{Feed, Sources, evidence_note};

/// One unit of code, scored — with every input kept beside the score.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RiskUnit {
    pub repo: String,
    pub file: String,
    /// **Input 1** — call sites landing on the functions defined in this file.
    pub downstream_callers: u32,
    /// **Input 2** — commits touching this file in the last 90 days.
    pub churn_90d: u64,
    /// **Input 3** — fraction of this file's functions a test can reach, `0.0..=1.0`.
    pub coverage_frac: f64,
    /// The composite. A SORT KEY, never a verdict — read the three inputs.
    pub risk: f64,
    pub functions: u32,
    pub breaks_published_api: Option<bool>,
    /// What `coverage_frac` measures: `line` (`coverage_fn`, a test EXECUTED the
    /// function) or `reach` (the room's CALLS walk from a test — more generous).
    pub coverage_basis: String,
}

impl RiskUnit {
    /// The arithmetic, in words — so the number is never unexplained.
    pub fn explain(&self) -> String {
        format!(
            "{} callers × {} commits/90d × (1 − {:.2} covered) = {:.0}",
            self.downstream_callers, self.churn_90d, self.coverage_frac, self.risk
        )
    }
}

/// The whole reading.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RiskReport {
    pub units: Vec<RiskUnit>,
    pub files_scored: usize,
    pub feeds: Vec<Feed>,
    pub coverage_basis: String,
    pub semver_available: bool,
    pub evidence_note: String,
    pub model_attribution: String,
}

/// The attribution string, kept in one place and carried on every report.
pub const ATTRIBUTION: &str = "Behavioural code analysis model: Adam Tornhill, \
    `Your Code as a Crime Scene` (2015) / `Software Design X-Rays` (2018), as \
    implemented by CodeScene. Static substrate: Code Property Graph, Yamaguchi et al., \
    IEEE S&P 2014. Original here: only the cross-repo join over nornir's Mimir.";

impl RiskReport {
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "kind": "risk",
            "files_scored": self.files_scored,
            "units": self.units.len(),
            "coverage_basis": self.coverage_basis,
            "semver_available": self.semver_available,
            "evidence_note": self.evidence_note,
            "model_attribution": self.model_attribution,
            "feeds": self.feeds,
            "worst": self.units.iter().map(|u| serde_json::json!({
                "repo": u.repo, "file": u.file, "risk": u.risk,
                "downstream_callers": u.downstream_callers,
                "churn_90d": u.churn_90d,
                "coverage_frac": u.coverage_frac,
                "coverage_basis": u.coverage_basis,
                "explain": u.explain(),
            })).collect::<Vec<_>>(),
        })
    }
}

/// What the code room knows about one file: its callable items, how many of
/// them a test reaches, and the call sites arriving at them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileFacts {
    pub repo: String,
    pub file: String,
    /// `fn` + `method` items defined in the file (tests excluded).
    pub functions: u32,
    /// Of those, the ones in the test-reachable set.
    pub covered: u32,
    /// CALLS sites arriving at them, from anywhere in the workspace.
    pub callers: u32,
    /// `covered` counts functions a test EXECUTED (`coverage_fn` of the repo's
    /// newest coverage run), not the ones a test can reach.
    pub line_measured: bool,
}

/// A function as the code room places it — what a `coverage_fn` row joins to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpanFn {
    pub ordinal: u64,
    pub repo: String,
    pub file: String,
    pub line: u32,
    pub end_line: u32,
}

/// What [`join_line_coverage`] made of a coverage run.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LineJoin {
    /// Room functions a test executed.
    pub covered: BTreeSet<u64>,
    /// Room functions the coverage run measured at all (executed or not).
    pub measured: BTreeSet<u64>,
    /// The repos a coverage run covers.
    pub repos: BTreeSet<String>,
    /// Rows no room function's span holds (another build of the file, a
    /// macro-only function, a row without a line).
    pub unjoined: usize,
}

/// **Join `coverage_fn` rows to the room's functions** by `(repo, file, line)`:
/// a measured function belongs to the INNERMOST room function whose span holds
/// its first line (a closure's record lands on the closure when the room has it,
/// else on the function around it). A room function is covered when any record
/// that lands on it was executed — generic instances are one record each.
pub fn join_line_coverage(fns: &[SpanFn], rows: &[super::rows::CoverageFnFact]) -> LineJoin {
    let mut by_file: BTreeMap<(&str, &str), Vec<&SpanFn>> = BTreeMap::new();
    for f in fns {
        by_file.entry((f.repo.as_str(), f.file.as_str())).or_default().push(f);
    }
    let mut out = LineJoin::default();
    for r in rows {
        out.repos.insert(r.repo.clone());
        let hit = by_file.get(&(r.repo.as_str(), r.file.as_str())).and_then(|cands| {
            cands
                .iter()
                .filter(|f| r.line > 0 && f.line <= r.line && r.line <= f.end_line.max(f.line))
                .max_by(|a, b| a.line.cmp(&b.line).then(b.end_line.cmp(&a.end_line)))
        });
        match hit {
            Some(f) => {
                out.measured.insert(f.ordinal);
                if r.exercised() {
                    out.covered.insert(f.ordinal);
                }
            }
            None => out.unjoined += 1,
        }
    }
    out
}

/// **Score change risk per file.** `sources` carries `git_heat_facts` (latest
/// snapshot per repo) and `coverage_fn`; `feeds` is the record of every input.
pub fn analyze(files: &[FileFacts], sources: &Sources, feeds: Vec<Feed>, top_n: usize) -> RiskReport {
    let line_cov = !sources.get("coverage_fn").is_empty();
    // `semver_diffs` has no producer (the coderoom ingest is gone): the
    // published-API column is not evaluated.
    let semver_available = false;
    let coverage_basis = if line_cov {
        "coverage_fn (source-line, sense C)".to_string()
    } else {
        "test-reachability (sense B, from the code room's CALLS / TESTS edges) — `coverage_fn` is EMPTY. \
         Reachability is MORE generous than line coverage, so risk is UNDER-reported."
            .to_string()
    };

    let heat = sources.get("git_heat_facts");
    let hc = heat.cursor(&["repo", "file", "commits_90d"]);
    let mut churn: BTreeMap<(String, String), u64> = BTreeMap::new();
    for r in hc.rows() {
        churn.insert(
            (hc.get(r, 0).to_string(), hc.get(r, 1).to_string()),
            hc.get(r, 2).trim().parse().unwrap_or(0),
        );
    }

    let mut units: Vec<RiskUnit> = files
        .iter()
        .map(|f| {
            let churn_90d = churn.get(&(f.repo.clone(), f.file.clone())).copied().unwrap_or(0);
            let coverage_frac = if f.functions == 0 { 0.0 } else { f.covered.min(f.functions) as f64 / f.functions as f64 };
            RiskUnit {
                repo: f.repo.clone(),
                file: f.file.clone(),
                downstream_callers: f.callers,
                churn_90d,
                coverage_frac,
                risk: f.callers as f64 * churn_90d as f64 * (1.0 - coverage_frac),
                functions: f.functions,
                breaks_published_api: semver_available.then_some(false),
                coverage_basis: if f.line_measured { "line" } else { "reach" }.to_string(),
            }
        })
        .collect();

    let files_scored = units.len();
    units.retain(|u| u.risk > 0.0);
    units.sort_by(|a, b| {
        b.risk
            .partial_cmp(&a.risk)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then_with(|| a.file.cmp(&b.file))
    });
    units.truncate(top_n);

    let evidence_note = evidence_note(&feeds, "change risk");
    RiskReport {
        units,
        files_scored,
        feeds,
        coverage_basis,
        semver_available,
        evidence_note,
        model_attribution: ATTRIBUTION.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::super::rows::Table;
    use super::*;

    fn heat(rows: &[&[&str]]) -> Sources {
        let t = Table::new(
            ["repo", "file", "commits_90d"].iter().map(|s| s.to_string()).collect(),
            rows.iter().map(|r| r.iter().map(|s| s.to_string()).collect()).collect(),
        );
        let mut s = Sources::default();
        s.add_root("/t", [("git_heat_facts".to_string(), t)].into());
        s
    }

    fn file(functions: u32, covered: u32, callers: u32) -> FileFacts {
        FileFacts { repo: "korp".into(), file: "src/hot.rs".into(), functions, covered, callers, line_measured: false }
    }

    fn span(o: u64, line: u32, end_line: u32) -> SpanFn {
        SpanFn { ordinal: o, repo: "shop".into(), file: "src/lib.rs".into(), line, end_line }
    }
    fn cov(line: u32, covered: i64) -> super::super::rows::CoverageFnFact {
        super::super::rows::CoverageFnFact { repo: "shop".into(), file: "src/lib.rs".into(), line, end_line: line + 1, regions: 2, regions_covered: covered, ..Default::default() }
    }

    #[test]
    fn a_coverage_row_lands_on_the_innermost_room_function_its_line_lies_in() {
        // f 10–30 holds g 12–20; h 40–44 is never executed; one row lies nowhere
        let fns = [span(1, 10, 30), span(2, 12, 20), span(3, 40, 44)];
        let j = join_line_coverage(&fns, &[cov(12, 1), cov(10, 0), cov(40, 0), cov(99, 1), cov(0, 1)]);
        assert_eq!(j.covered, [2].into(), "g ran; f did not: {j:?}");
        assert_eq!(j.measured, [1, 2, 3].into());
        assert_eq!(j.unjoined, 2, "line 99 lies in no function, a row with no line joins nothing");
        assert_eq!(j.repos, ["shop".to_string()].into());
    }

    #[test]
    fn risk_is_callers_times_churn_times_uncovered_and_every_input_moves_it() {
        let s = heat(&[&["korp", "src/hot.rs", "20"]]);
        let feeds = s.feeds(&["git_heat_facts"]);

        let uncovered = analyze(&[file(1, 0, 10)], &s, feeds.clone(), 10);
        let u = &uncovered.units[0];
        assert_eq!(u.downstream_callers, 10);
        assert_eq!(u.churn_90d, 20);
        assert_eq!(u.risk, 200.0);

        // half covered halves it
        assert_eq!(analyze(&[file(2, 1, 10)], &s, feeds.clone(), 10).units[0].risk, 100.0);
        // fully covered: scored, not a finding
        let now = analyze(&[file(1, 1, 10)], &s, feeds.clone(), 10);
        assert!(now.units.is_empty());
        assert_eq!(now.files_scored, 1);
        // no caller, or no churn: no risk
        assert!(analyze(&[file(1, 0, 0)], &s, feeds.clone(), 10).units.is_empty());
        assert!(analyze(&[file(1, 0, 10)], &heat(&[]), feeds, 10).units.is_empty());
    }

    #[test]
    fn the_missing_line_coverage_table_is_declared_with_its_bias() {
        let s = heat(&[]);
        let r = analyze(&[], &s, s.feeds(&["git_heat_facts", "coverage_fn"]), 5);
        assert!(r.coverage_basis.contains("EMPTY"), "{}", r.coverage_basis);
        assert!(r.coverage_basis.contains("UNDER-reported"), "{}", r.coverage_basis);
        assert!(r.evidence_note.contains("MISSING FEEDS"), "{}", r.evidence_note);
    }
}