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/undoc_api.rs

//! **Lens — `Mimir.UndocApi`: undocumented public API in churny files.**
//!
//! Idea #16 (`mimir-ideas-2026-07-26.md`, EASY). A public function with **no doc
//! comment** that lives in a file **many commits and many authors** keep changing
//! is an unstable, undocumented public contract — the callers depend on behaviour
//! nobody wrote down and everybody edits. Neither a linter (no churn/author axis)
//! nor a churn tool (no visibility/doc axis) can rank this; it needs the code room
//! (rustc's effective visibility + `has_doc` per item) × `git_heat_facts` (per-file
//! `commits_90d` / `authors_total`) joined on `(repo, file)`.
//!
//! "Public" is what another crate can NAME: effective visibility `direct` or
//! `reexported`. A `pub fn` in a private module nobody re-exports is not API.
//!
//! Churn is a FILE property (`git_heat_facts` is keyed `(repo, file)`), so every
//! undocumented public fn in a hot file inherits that file's heat — a proxy, stated
//! as such. Ranked by recent churn, then author breadth (a many-author hot file is
//! a coordination hazard), then total churn.
//!
//! Pure fold. Anti-silent-empty: an unfed `git_heat_facts` reports a MISSING FEED
//! via [`evidence_note`] — an empty result never reads as "all documented".

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

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

/// One undocumented public function in a churny file.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UndocFn {
    pub repo: String,
    /// The public, doc-less function name.
    pub func: String,
    pub file: String,
    pub line: u32,
    /// Commits touching this file in the last 90 days (recent churn).
    pub commits_90d: i64,
    /// Commits touching this file over all history.
    pub commits_total: i64,
    /// Distinct authors who have touched this file (coordination breadth).
    pub authors_total: i64,
}

impl UndocFn {
    pub fn explain(&self) -> String {
        format!(
            "pub `{}` ({}:{}) has NO doc comment yet its file saw {} commit(s)/90d from {} \
             author(s) — an undocumented, actively-churned public contract",
            self.func, self.file, self.line, self.commits_90d, self.authors_total,
        )
    }
}

/// The whole reading.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UndocApiReport {
    /// Public functions seen (the denominator).
    pub pub_fns: usize,
    /// Public functions with no doc comment.
    pub undocumented_pub_fns: usize,
    /// Undocumented public fns whose file cleared the churn floor (the findings).
    pub churny_undocumented: usize,
    /// The churn floor applied (`commits_90d >= min_commits_90d`).
    pub min_commits_90d: i64,
    pub worst: Vec<UndocFn>,
    pub feeds: Vec<Feed>,
    pub evidence_note: String,
    pub model_attribution: String,
}

pub const ATTRIBUTION: &str = "Composition of nornir primitives: the code room's \
    callable items (effective visibility direct | reexported AND no doc comment) × \
    git_heat_facts (per-file commits_90d / authors_total) joined on (repo, file). Ranks the undocumented public surface by \
    how actively its file churns and how many authors touch it — the undocumented-\
    contract-under-active-change question no linter or churn tool answers alone.";

impl UndocApiReport {
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "kind": "undoc_api",
            "pub_fns": self.pub_fns,
            "undocumented_pub_fns": self.undocumented_pub_fns,
            "churny_undocumented": self.churny_undocumented,
            "min_commits_90d": self.min_commits_90d,
            "evidence_note": self.evidence_note,
            "model_attribution": self.model_attribution,
            "feeds": self.feeds,
            "worst": self.worst.iter().map(|u| serde_json::json!({
                "repo": u.repo,
                "func": u.func,
                "file": u.file,
                "line": u.line,
                "commits_90d": u.commits_90d,
                "commits_total": u.commits_total,
                "authors_total": u.authors_total,
                "explain": u.explain(),
            })).collect::<Vec<_>>(),
        })
    }
}

/// Parse an integer cell (blank/torn ⇒ 0).
fn int_of(s: &str) -> i64 {
    s.trim().parse::<i64>().unwrap_or(0)
}

/// One callable item of the public API, as the code room holds it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiFn {
    pub repo: String,
    /// The item's path (`shop::cart::Cart::total`).
    pub func: String,
    pub file: String,
    pub line: u32,
    /// It carries a doc comment.
    pub documented: bool,
}

/// **Rank the undocumented public surface by file churn.** Pure fold — no IO.
/// `api` is every public callable; `sources` carries `git_heat_facts` (latest
/// snapshot per repo); `min_commits_90d` is the churn floor (0 ⇒ default of 2).
pub fn analyze(api: &[ApiFn], sources: &Sources, feeds: Vec<Feed>, repo_filter: &str, min_commits_90d: i64, top_n: usize) -> UndocApiReport {
    let floor = if min_commits_90d <= 0 {
        2
    } else {
        min_commits_90d
    };

    // ── per-file churn: (repo, file) → (commits_90d, commits_total, authors_total) ──
    let heat = sources.get("git_heat_facts");
    let hc = heat.cursor(&[
        "repo",
        "file",
        "commits_90d",
        "commits_total",
        "authors_total",
    ]);
    let mut churn: BTreeMap<(String, String), (i64, i64, i64)> = BTreeMap::new();
    for r in hc.rows() {
        let key = (hc.get(r, 0).to_string(), hc.get(r, 1).to_string());
        // Keep the max per (repo,file) — snapshot dedup already applied upstream, but
        // a torn duplicate must never LOWER the churn.
        let e = churn.entry(key).or_insert((0, 0, 0));
        e.0 = e.0.max(int_of(hc.get(r, 2)));
        e.1 = e.1.max(int_of(hc.get(r, 3)));
        e.2 = e.2.max(int_of(hc.get(r, 4)));
    }

    // ── undocumented public fns joined to their file's churn ─────────────────────
    let mut pub_fns = 0usize;
    let mut undocumented_pub_fns = 0usize;
    let mut findings: Vec<UndocFn> = Vec::new();
    for f in api {
        if !repo_filter.is_empty() && f.repo != repo_filter {
            continue;
        }
        pub_fns += 1;
        if f.documented {
            continue;
        }
        undocumented_pub_fns += 1;
        let Some(&(c90, ctot, authors)) = churn.get(&(f.repo.clone(), f.file.clone())) else {
            continue; // no churn record for this file (never committed / torn feed)
        };
        if c90 < floor {
            continue;
        }
        findings.push(UndocFn { repo: f.repo.clone(), func: f.func.clone(), file: f.file.clone(), line: f.line, commits_90d: c90, commits_total: ctot, authors_total: authors });
    }

    // Rank: recent churn, then author breadth, then total churn, then name (stable).
    findings.sort_by(|a, b| {
        b.commits_90d
            .cmp(&a.commits_90d)
            .then_with(|| b.authors_total.cmp(&a.authors_total))
            .then_with(|| b.commits_total.cmp(&a.commits_total))
            .then_with(|| a.repo.cmp(&b.repo))
            .then_with(|| a.func.cmp(&b.func))
    });
    let churny_undocumented = findings.len();
    findings.truncate(top_n);

    UndocApiReport {
        pub_fns,
        undocumented_pub_fns,
        churny_undocumented,
        min_commits_90d: floor,
        worst: findings,
        evidence_note: evidence_note(&feeds, "UndocApi (undocumented churny public API)"),
        model_attribution: ATTRIBUTION.to_string(),
        feeds,
    }
}

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

    fn heat(rows: &[&[&str]]) -> Sources {
        let t = Table::new(
            ["repo", "file", "commits_90d", "commits_total", "authors_total"].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 api(file: &str, func: &str, documented: bool, line: u32) -> ApiFn {
        ApiFn { repo: "a".into(), func: func.into(), file: file.into(), line, documented }
    }

    /// **Red-when-reverted core.** Only a public fn with no doc comment in a file
    /// clearing the churn floor is a finding — a DOCUMENTED public fn and a
    /// low-churn file are excluded — and findings rank by recent churn. Drop the
    /// `documented` test and the documented fn wrongly appears; reverse the sort
    /// and the ranking flips. (What is public is the feed's question: the room's
    /// effective visibility.)
    #[test]
    fn only_undocumented_pub_fns_in_churny_files_rank_by_churn() {
        let fns = [
            // hot, undocumented → the worst finding.
            api("hot.rs", "hot_api", false, 10),
            // warm, undocumented → a finding, ranked below hot_api.
            api("warm.rs", "warm_api", false, 20),
            // DOCUMENTED → excluded.
            api("hot.rs", "documented_api", true, 30),
            // undocumented, but its file is COLD (below floor) → excluded.
            api("cold.rs", "cold_api", false, 50),
        ];
        let s = heat(&[&["a", "hot.rs", "40", "120", "5"], &["a", "warm.rs", "6", "20", "2"], &["a", "cold.rs", "1", "3", "1"]]);
        let r = analyze(&fns, &s, s.feeds(&["git_heat_facts"]), "", 2, 10);

        assert_eq!(r.pub_fns, 4);
        assert_eq!(r.undocumented_pub_fns, 3, "hot_api, warm_api, cold_api are doc-less");
        assert_eq!(r.churny_undocumented, 2, "only hot_api + warm_api clear the churn floor of 2");
        assert_eq!(r.worst[0].func, "hot_api", "highest recent churn ranks first");
        assert_eq!(r.worst[0].commits_90d, 40);
        assert_eq!(r.worst[0].authors_total, 5);
        assert_eq!(r.worst[1].func, "warm_api");
        assert!(!r.worst.iter().any(|u| u.func == "documented_api"), "a documented fn is not a finding");
        assert!(!r.worst.iter().any(|u| u.func == "cold_api"), "a cold file is below the churn floor");
        // another repo's filter sees none of them
        assert_eq!(analyze(&fns, &s, s.feeds(&["git_heat_facts"]), "b", 2, 10).pub_fns, 0);
    }

    /// Anti-silent-empty: an unfed `git_heat_facts` reads as a MISSING FEED, not "all documented".
    #[test]
    fn an_empty_heat_feed_reads_as_missing_not_all_documented() {
        let s = heat(&[]);
        let r = analyze(&[api("f.rs", "g", false, 1)], &s, s.feeds(&["git_heat_facts"]), "", 0, 10);
        assert_eq!(r.churny_undocumented, 0);
        assert!(r.evidence_note.contains("MISSING FEEDS"), "{}", r.evidence_note);
        assert!(r.evidence_note.contains("git_heat_facts"), "names the producer: {}", r.evidence_note);
    }
}