hotspots-core 1.36.0

Core library for finding the riskiest code by combining complexity with git change history (Local Risk Score) across TypeScript, JavaScript, Go, Python, Rust, Java, C#, and C
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
//! Cold-start history signals — F63 signal-porting prerequisite.
//!
//! Seven file-level signals (six F62/F63 cold-start signals plus `burst_score`,
//! F93), computed from a single `git log` pass over the whole repo (not one
//! subprocess per file, unlike `populate_authors_90d`/`populate_convention_bug_fix_count`).
//! `burst_score`'s formula lives here too (moved from `snapshot.rs`) so
//! `Snapshot::populate_burst_score` can reuse this module's loader instead of
//! spawning its own redundant full-history `git log` walk — see
//! `hotspots-research/docs/promotion-briefs/burst-score-history-signals-shared-walk.md`.

use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::process::Command;

/// Separator used in git log --format to delimit commits from file lists.
const SEP: &str = "@@HC@@";

/// A single commit: timestamp, author email, and touched files.
pub struct CommitRecord {
    pub ts: i64,
    pub author: String,
    pub files: Vec<String>,
}

/// Per-file cold-start signals (F62/F63 feature set) plus `burst_score` (F93).
pub struct HistorySignals {
    pub commit_count: u32,
    pub author_count: u32,
    pub author_entropy: f64,
    pub isolation_rate: f64,
    pub age_days: f64,
    pub last_touch_days: f64,
    pub burst_score: f64,
    pub newcomer_rate: Option<f64>,
}

/// Load full commit history as `(timestamp, author_email, files)` via a single
/// `git log` subprocess call. Returns an empty vec on any error (caller treats
/// as no-op, matching `coupling::load_commits`'s soft-failure convention).
pub(crate) fn load_commits_with_author(git_dir: &Path) -> Vec<CommitRecord> {
    let format = format!("{SEP}%at {SEP}%ae {SEP}%s");
    let out = Command::new("git")
        .args([
            "--git-dir",
            &git_dir.to_string_lossy(),
            "log",
            "--name-only",
            "--diff-filter=ACDMRT",
            &format!("--format={format}"),
        ])
        .output();

    let stdout = match out {
        Ok(o) if o.status.success() || !o.stdout.is_empty() => o.stdout,
        _ => return vec![],
    };
    let text = String::from_utf8_lossy(&stdout);

    let mut commits: Vec<CommitRecord> = Vec::new();
    let mut cur_ts: i64 = 0;
    let mut cur_author = String::new();
    let mut cur_files: Vec<String> = Vec::new();
    let mut in_commit = false;

    for line in text.lines() {
        if let Some(rest) = line.strip_prefix(SEP) {
            if in_commit && !cur_files.is_empty() {
                commits.push(CommitRecord {
                    ts: cur_ts,
                    author: cur_author.clone(),
                    files: cur_files.clone(),
                });
            }
            let (ts_str, tail) = rest.split_once(' ').unwrap_or((rest, ""));
            cur_ts = ts_str.parse().unwrap_or(0);
            let author = tail.strip_prefix(SEP).unwrap_or(tail);
            let author = author.split(SEP).next().unwrap_or("").trim();
            cur_author = author.to_lowercase();
            cur_files = Vec::new();
            in_commit = true;
        } else if in_commit && !line.trim().is_empty() {
            cur_files.push(line.trim().to_string());
        }
    }
    if in_commit && !cur_files.is_empty() {
        commits.push(CommitRecord {
            ts: cur_ts,
            author: cur_author,
            files: cur_files,
        });
    }

    commits
}

/// Shannon entropy of the commit-author distribution. Mirrors
/// `cheap_signals.py::_author_entropy`.
fn author_entropy(authors: &[&str]) -> f64 {
    let mut counts: HashMap<&str, u32> = HashMap::new();
    for &a in authors {
        *counts.entry(a).or_insert(0) += 1;
    }
    let total = authors.len() as f64;
    if total == 0.0 {
        return 0.0;
    }
    -counts
        .values()
        .map(|&c| {
            let p = c as f64 / total;
            p * p.log2()
        })
        .sum::<f64>()
}

/// Sliding 30-day-window max/mean commit ratio (F93). Moved from `snapshot.rs`
/// so it shares this module's single-pass commit load with the other six
/// cold-start signals instead of triggering its own `git log` walk.
///
/// For each commit, counts how many commits (including itself) fall within the
/// following 30-day window, then divides the maximum such count by the mean.
/// Mirrors `cheap_signals.py::_burst_score` in `hotspots-research`. Returns
/// `1.0` (no burst signal) when fewer than 2 timestamps are given.
fn burst_score(timestamps: &[i64]) -> f64 {
    const BURST_WINDOW_DAYS: i64 = 30;
    const BURST_WINDOW_SECS: i64 = BURST_WINDOW_DAYS * 86400;

    if timestamps.len() < 2 {
        return 1.0;
    }

    let mut sorted = timestamps.to_vec();
    sorted.sort_unstable();

    let mut counts = Vec::with_capacity(sorted.len());
    let mut j = 0usize;
    for (i, &t) in sorted.iter().enumerate() {
        while j < sorted.len() && sorted[j] < t + BURST_WINDOW_SECS {
            j += 1;
        }
        counts.push((j - i) as f64);
    }

    let mean_c = counts.iter().sum::<f64>() / counts.len() as f64;
    if mean_c == 0.0 {
        return 1.0;
    }

    counts.iter().cloned().fold(f64::MIN, f64::max) / mean_c
}

/// Newcomer commit ratio (F102/F05): fraction of a file's commits in the
/// 90-day window before `cutoff` that were authored by someone whose
/// first-ever commit to the repo also falls in that same window.
///
/// Ports `scripts/eval/eval_ownership_axis_independence.py`'s formula exactly:
/// files with no commits in the window return `None` (excluded), not `0.0` —
/// a file with real window commits and zero newcomers is a valid `Some(0.0)`.
const NEWCOMER_WINDOW_DAYS: i64 = 90;
const NEWCOMER_WINDOW_SECS: i64 = NEWCOMER_WINDOW_DAYS * 86400;

fn newcomer_rate(
    window_authors: &[&str],
    first_seen: &HashMap<&str, i64>,
    window_start: i64,
) -> Option<f64> {
    if window_authors.is_empty() {
        return None;
    }
    let newcomer_count = window_authors
        .iter()
        .filter(|&&a| first_seen.get(a).is_some_and(|&ts| ts >= window_start))
        .count();
    Some(newcomer_count as f64 / window_authors.len() as f64)
}

/// Single pass over `commits`, aggregating per-file cold-start signals plus
/// `burst_score`. Mirrors `cheap_signals.py::compute_file_signals` (minus
/// `co_change_partners`, `mean_co_change_size` — out of scope, see brief).
pub fn compute_history_signals(commits: &[CommitRecord]) -> HashMap<String, HistorySignals> {
    let now_ts = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    let newcomer_window_start = now_ts - NEWCOMER_WINDOW_SECS;

    // Repo-wide first-ever-commit timestamp per author (by email), for newcomer_rate.
    let mut first_seen: HashMap<&str, i64> = HashMap::new();
    for commit in commits {
        first_seen
            .entry(commit.author.as_str())
            .and_modify(|ts| *ts = (*ts).min(commit.ts))
            .or_insert(commit.ts);
    }

    // commit index -> number of files touched (for isolation_rate)
    let commit_file_count: Vec<usize> = commits.iter().map(|c| c.files.len()).collect();

    let mut file_commit_idxs: HashMap<&str, Vec<usize>> = HashMap::new();
    for (idx, commit) in commits.iter().enumerate() {
        for f in &commit.files {
            file_commit_idxs.entry(f.as_str()).or_default().push(idx);
        }
    }

    let mut signals = HashMap::with_capacity(file_commit_idxs.len());
    for (file, idxs) in file_commit_idxs {
        let timestamps: Vec<i64> = idxs.iter().map(|&i| commits[i].ts).collect();
        let authors: Vec<&str> = idxs.iter().map(|&i| commits[i].author.as_str()).collect();

        let n = idxs.len();
        let isolated = idxs.iter().filter(|&&i| commit_file_count[i] == 1).count();
        let isolation_rate = isolated as f64 / n as f64;

        let unique_authors: HashSet<&str> = authors.iter().copied().collect();

        let first_ts = *timestamps.iter().min().unwrap_or(&0);
        let last_ts = *timestamps.iter().max().unwrap_or(&0);

        let window_authors: Vec<&str> = idxs
            .iter()
            .filter(|&&i| commits[i].ts >= newcomer_window_start)
            .map(|&i| commits[i].author.as_str())
            .collect();

        signals.insert(
            file.to_string(),
            HistorySignals {
                commit_count: n as u32,
                author_count: unique_authors.len() as u32,
                author_entropy: author_entropy(&authors),
                isolation_rate,
                age_days: (last_ts - first_ts) as f64 / 86400.0,
                last_touch_days: (now_ts - last_ts) as f64 / 86400.0,
                burst_score: burst_score(&timestamps),
                newcomer_rate: newcomer_rate(&window_authors, &first_seen, newcomer_window_start),
            },
        );
    }

    signals
}

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

    fn commit(ts: i64, author: &str, files: &[&str]) -> CommitRecord {
        CommitRecord {
            ts,
            author: author.to_string(),
            files: files.iter().map(|s| s.to_string()).collect(),
        }
    }

    /// Hand-computed fixture: 3 files, ≤10 commits, matching the acceptance
    /// criteria in the F63 signal-porting-prerequisite brief.
    ///
    /// Commits (ts in days since epoch, converted to seconds):
    ///   d0: alice   -> a.rs, b.rs   (co-touch, both non-isolated)
    ///   d1: alice   -> a.rs         (isolated)
    ///   d5: bob     -> a.rs         (isolated)
    ///   d5: bob     -> c.rs         (isolated)
    ///   d10: alice  -> b.rs         (isolated)
    fn fixture_commits() -> Vec<CommitRecord> {
        const DAY: i64 = 86400;
        vec![
            commit(0, "alice@example.com", &["a.rs", "b.rs"]),
            commit(DAY, "alice@example.com", &["a.rs"]),
            commit(5 * DAY, "bob@example.com", &["a.rs"]),
            commit(5 * DAY, "bob@example.com", &["c.rs"]),
            commit(10 * DAY, "alice@example.com", &["b.rs"]),
        ]
    }

    #[test]
    fn author_entropy_matches_hand_computation() {
        // a.rs touched by alice, alice, bob -> counts {alice:2, bob:1}, total=3
        // H = -[ (2/3)log2(2/3) + (1/3)log2(1/3) ] ≈ 0.9183
        let authors = vec!["alice", "alice", "bob"];
        let h = author_entropy(&authors);
        assert!((h - 0.9182958).abs() < 1e-6, "got {h}");
    }

    #[test]
    fn author_entropy_single_author_is_zero() {
        let authors = vec!["alice", "alice", "alice"];
        assert_eq!(author_entropy(&authors), 0.0);
    }

    #[test]
    fn compute_history_signals_matches_fixture() {
        const DAY: i64 = 86400;
        let commits = fixture_commits();
        let signals = compute_history_signals(&commits);

        // a.rs: commits at d0 (2-file, not isolated), d1 (isolated), d5 (isolated)
        let a = signals.get("a.rs").expect("a.rs present");
        assert_eq!(a.commit_count, 3);
        assert_eq!(a.author_count, 2); // alice, bob
        assert!((a.isolation_rate - (2.0 / 3.0)).abs() < 1e-9);
        assert!((a.age_days - 5.0).abs() < 1e-9); // (5*DAY - 0) / DAY
        let expected_last_touch = (std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs() as i64
            - 5 * DAY) as f64
            / 86400.0;
        assert!((a.last_touch_days - expected_last_touch).abs() < 1.0);

        // b.rs: commits at d0 (2-file, not isolated), d10 (isolated)
        let b = signals.get("b.rs").expect("b.rs present");
        assert_eq!(b.commit_count, 2);
        assert_eq!(b.author_count, 1); // alice only (case-folded)
        assert!((b.isolation_rate - 0.5).abs() < 1e-9);
        assert!((b.age_days - 10.0).abs() < 1e-9);

        // c.rs: single commit at d5, isolated
        let c = signals.get("c.rs").expect("c.rs present");
        assert_eq!(c.commit_count, 1);
        assert_eq!(c.author_count, 1);
        assert_eq!(c.isolation_rate, 1.0);
        assert_eq!(c.age_days, 0.0); // only one timestamp -> first == last
    }

    #[test]
    fn compute_history_signals_no_entry_for_untouched_file() {
        let commits = fixture_commits();
        let signals = compute_history_signals(&commits);
        assert!(!signals.contains_key("d.rs"));
    }

    #[test]
    fn load_commits_with_author_returns_empty_on_bad_path() {
        let bad = Path::new("/nonexistent/path/that/does/not/exist/.git");
        let commits = load_commits_with_author(bad);
        assert!(commits.is_empty());
    }

    #[test]
    fn burst_score_single_commit_is_baseline() {
        assert_eq!(burst_score(&[0]), 1.0);
    }

    #[test]
    fn burst_score_evenly_spaced_commits_is_baseline() {
        // Commits 60 days apart never share a 30-day window with another commit,
        // so max == mean == 1.0 for every commit.
        const DAY: i64 = 86400;
        let timestamps = vec![0, 60 * DAY, 120 * DAY, 180 * DAY];
        assert_eq!(burst_score(&timestamps), 1.0);
    }

    #[test]
    fn burst_score_detects_a_burst() {
        // 5 commits clustered on day 0, then 1 commit alone on day 60: the
        // clustered commits' windows each see all 5, giving max=5, mean=(5*5+1)/6.
        const DAY: i64 = 86400;
        let timestamps = vec![0, 0, 0, 0, 0, 60 * DAY];
        let score = burst_score(&timestamps);
        assert!(score > 1.0, "expected a burst signal, got {score}");
    }

    #[test]
    fn compute_history_signals_matches_burst_score_fixture() {
        let commits = fixture_commits();
        let signals = compute_history_signals(&commits);
        // a.rs: commits at d0, d1, d5 — all within 30 days of each other, so
        // the forward-window counts are [3, 2, 1] (max=3, mean=2, ratio=1.5).
        let a = signals.get("a.rs").expect("a.rs present");
        assert!((a.burst_score - 1.5).abs() < 1e-9, "got {}", a.burst_score);
        // c.rs: single commit -> baseline 1.0.
        let c = signals.get("c.rs").expect("c.rs present");
        assert_eq!(c.burst_score, 1.0);
    }

    #[test]
    fn newcomer_rate_matches_reference() {
        // Hand-computed case ported from F102's eval_ownership_axis_independence.py
        // definition: window = [window_start, now]. alice's first-ever commit is
        // before the window (not a newcomer); bob's first-ever commit is inside
        // the window (a newcomer). All commits below fall inside a 90-day window
        // ending "now", so window_authors == authors for both files.
        let window_start = 0i64;
        let first_seen: HashMap<&str, i64> =
            HashMap::from([("alice", -1000), ("bob", window_start + 10)]);

        // a.rs: alice, alice, bob -> 1 newcomer (bob) out of 3 = 1/3.
        let window_authors = vec!["alice", "alice", "bob"];
        let rate = newcomer_rate(&window_authors, &first_seen, window_start);
        assert!((rate.unwrap() - (1.0 / 3.0)).abs() < 1e-9, "got {:?}", rate);

        // b.rs: alice only -> 0 newcomers out of 1 = 0.0 (valid Some, not None).
        let window_authors = vec!["alice"];
        let rate = newcomer_rate(&window_authors, &first_seen, window_start);
        assert_eq!(rate, Some(0.0));

        // No commits in the window -> None (excluded), not 0.0.
        let window_authors: Vec<&str> = vec![];
        let rate = newcomer_rate(&window_authors, &first_seen, window_start);
        assert_eq!(rate, None);
    }

    #[test]
    fn compute_history_signals_newcomer_rate_excludes_no_window_data() {
        // fixture_commits() timestamps are seconds-since-epoch day 0-10 (1970),
        // far outside any 90-day window ending "now" (2026) — so every file's
        // newcomer_rate is None (excluded), matching the "no commits in the
        // window" case, not a `0.0` value.
        let commits = fixture_commits();
        let signals = compute_history_signals(&commits);
        for file in ["a.rs", "b.rs", "c.rs"] {
            let s = signals.get(file).expect("file present");
            assert_eq!(
                s.newcomer_rate, None,
                "{file} has no commits in the 90-day window ending now"
            );
        }
    }

    #[test]
    fn compute_history_signals_newcomer_rate_included_when_recent() {
        // Same shape as fixture_commits() but with timestamps anchored to "now"
        // so the commits fall inside the 90-day window and newcomer_rate is
        // defined (Some), exercising the real end-to-end wiring through
        // compute_history_signals (not just the newcomer_rate() unit above).
        const DAY: i64 = 86400;
        let now_ts = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs() as i64;
        let commits = vec![
            commit(now_ts - 10 * DAY, "alice@example.com", &["a.rs"]),
            commit(now_ts - 5 * DAY, "bob@example.com", &["a.rs"]),
        ];
        let signals = compute_history_signals(&commits);
        let a = signals.get("a.rs").expect("a.rs present");
        // alice's only commit is inside the window -> not a newcomer by this
        // definition's "first commit inside window" rule only if her
        // first-ever commit is also inside the window, which it is here
        // (her only commit). bob likewise. Both are newcomers: rate = 1.0.
        assert_eq!(a.newcomer_rate, Some(1.0));
    }
}