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);
}
}