Skip to main content

amont_runtime/
downgrade.rs

1//! The ledger of problems that did NOT block — the shadow-mode signal, kept
2//! instead of discarded.
3//!
4//! [`crate::dispatch`] already detects the interesting event. `Report.downgraded`
5//! is every check that FAILED while the severity that applies said `warn`, and
6//! until this module existed the detection was followed by one line of output
7//! and nothing else:
8//!
9//! ```text
10//! ! 1 check(s) reported a problem but are set to warn: pre-commit-ban-terms
11//! ```
12//!
13//! That line is the whole evidence base for the question a team lead actually
14//! asks before adopting anything that can block a commit — *will this annoy my
15//! team into switching it off?* Turning every blocking check down to `warn` for
16//! a fortnight answers it exactly, and answered it into a scrollback buffer
17//! that nobody kept.
18//!
19//! This module only counts. Nothing here participates in any verdict, and
20//! nothing ever may — the same rule [`crate::bypass`] states about itself, for
21//! the same reason: a wrong read in a gate weakens a gate, and a wrong read
22//! here miscounts a report.
23//!
24//! The ledger is a local file, never a ref, never pushed, never sent anywhere —
25//! the project's no-telemetry promise applies in full. It lives in the COMMON
26//! git dir because "what has this repository been warning about" is a question
27//! about the repository, not about one worktree. `amont uninstall` deletes it;
28//! so does `amont.recordDowngrades false`, prospectively.
29//!
30//! A consequence worth stating rather than leaving to be discovered: this
31//! **cannot aggregate across a team**. Every developer's ledger is their own
32//! machine's. A lead runs the trial on their own checkout, or asks people to
33//! paste. That is a deliberate limit of the no-telemetry promise, not a gap.
34//!
35//! Format, versioned like its siblings (`amont-bypass-v1`, `amont-gate-v1`):
36//!
37//! ```text
38//! amont-downgrade-v1
39//! <unix-epoch> <head-oid> <check-id> <origin>
40//! ```
41//!
42//! No paths ever appear in the file, which is why space delimiting is safe here
43//! where `staged_only` needed NUL.
44
45use std::io::Write;
46use std::path::{Path, PathBuf};
47
48/// First line of the ledger. A future amont that changes the shape bumps this,
49/// and an old ledger reads as empty rather than being misread.
50pub const FORMAT: &str = "amont-downgrade-v1";
51
52/// The ledger's filename inside the common git dir.
53const LEDGER: &str = "amont-downgrades";
54
55/// Compact past this — roughly 5,500 events.
56///
57/// Four times [`crate::bypass`]'s ceiling, deliberately. A bypass is a rare
58/// act; a downgrade fires on every failing check of every commit, and the
59/// whole point of the file is to survive a fortnight of exactly that. Five
60/// failing checks at twenty commits a day is ~1,400 events in two weeks, and
61/// compacting mid-trial would turn the total the lead is reading into a silent
62/// floor at the moment they most need it to be a count.
63const MAX_BYTES: u64 = 256 * 1024;
64
65/// Events kept by a compaction, newest first. After a compaction the total is
66/// a FLOOR, not an exact count — the recent shape survives, which is the part
67/// that means anything.
68const KEEP: usize = 2_000;
69
70/// Why the check did not block.
71///
72/// This is the field that separates *"it would have blocked if you had not
73/// turned it down"* — the trial signal — from *"this check is advisory by
74/// design and is firing a lot"*, which is worth seeing but is not evidence
75/// about a rollout.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum Origin {
78    /// `git config amont.severity.<check> warn` — the trial switch.
79    Config,
80    /// The repository's committed policy.
81    Policy,
82    /// The check declares `warn` itself. Nothing was overridden.
83    Declared,
84}
85
86impl Origin {
87    pub fn as_str(self) -> &'static str {
88        match self {
89            Origin::Config => "config",
90            Origin::Policy => "policy",
91            Origin::Declared => "declared",
92        }
93    }
94
95    fn parse(s: &str) -> Option<Origin> {
96        match s {
97            "config" => Some(Origin::Config),
98            "policy" => Some(Origin::Policy),
99            "declared" => Some(Origin::Declared),
100            _ => None,
101        }
102    }
103
104    /// Would this check have blocked, had nothing been turned down?
105    pub fn would_block(self) -> bool {
106        matches!(self, Origin::Config | Origin::Policy)
107    }
108}
109
110/// What the ledger says, aggregated. Everything a reader displays comes through
111/// here; nobody re-parses the file.
112#[derive(Debug, Default, Clone, PartialEq, Eq)]
113pub struct Ledger {
114    /// Events on record (a floor after compaction).
115    pub total: usize,
116    /// Of those, the ones that would have blocked but for an override.
117    pub would_block: usize,
118    /// DISTINCT commits those events happened against.
119    ///
120    /// Not the same fact as `total`, and the difference is the one a reader
121    /// most needs: forty commits each tripping a check once is a check the
122    /// team disagrees with, while one commit tripping it forty times is one
123    /// person losing an afternoon to it. Counting only events conflates them.
124    pub commits: usize,
125    /// The oldest event's epoch — "since when" for the report.
126    pub first: Option<u64>,
127    /// The newest event's epoch, if any.
128    pub last: Option<u64>,
129    /// Count descending, then check ascending — a stable order a golden render
130    /// can pin.
131    pub by_check: Vec<CheckCount>,
132}
133
134/// One check's slice of the ledger.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct CheckCount {
137    pub check: String,
138    pub count: usize,
139    /// Of `count`, the events that would have blocked but for an override.
140    pub would_block: usize,
141    /// The newest event for THIS check.
142    pub last: u64,
143}
144
145/// One valid event line, or nothing. The shared trust boundary: `parse` and
146/// compaction both refuse a line through this, so a hand-edited ledger can
147/// neither skew the counts with garbage nor smuggle a control byte into a
148/// terminal — check names must be short printable ASCII, oids hex.
149fn event(line: &str) -> Option<(u64, &str, &str, Origin)> {
150    let mut fields = line.split_whitespace();
151    let (Some(epoch), Some(oid), Some(check), Some(origin), None) = (
152        fields.next(),
153        fields.next(),
154        fields.next(),
155        fields.next(),
156        fields.next(),
157    ) else {
158        return None;
159    };
160    let epoch = epoch.parse::<u64>().ok()?;
161    if !(7..=64).contains(&oid.len()) || !oid.bytes().all(|b| b.is_ascii_hexdigit()) {
162        return None;
163    }
164    // Longer than `bypass`'s 32: these are full check IDs, and
165    // `pre-commit-lint-json-yaml` is already 25 characters.
166    if !(1..=64).contains(&check.len()) || !check.bytes().all(|b| b.is_ascii_graphic()) {
167        return None;
168    }
169    Some((epoch, oid, check, Origin::parse(origin)?))
170}
171
172/// Aggregate a ledger's text. Pure; a malformed line is skipped, never guessed
173/// at, and a missing or foreign header reads as an empty ledger.
174pub fn parse(text: &str) -> Ledger {
175    let mut lines = text.lines().filter(|l| !l.trim().is_empty());
176    if lines.next() != Some(FORMAT) {
177        return Ledger::default();
178    }
179    let mut out = Ledger::default();
180    let mut oids: Vec<&str> = Vec::new();
181    for line in lines {
182        let Some((epoch, oid, check, origin)) = event(line) else {
183            continue;
184        };
185        out.total += 1;
186        if origin.would_block() {
187            out.would_block += 1;
188        }
189        out.first = Some(out.first.map_or(epoch, |f| f.min(epoch)));
190        out.last = Some(out.last.map_or(epoch, |l| l.max(epoch)));
191        if !oids.contains(&oid) {
192            oids.push(oid);
193        }
194        match out.by_check.iter_mut().find(|c| c.check == check) {
195            Some(c) => {
196                c.count += 1;
197                c.would_block += usize::from(origin.would_block());
198                c.last = c.last.max(epoch);
199            }
200            None => out.by_check.push(CheckCount {
201                check: check.to_string(),
202                count: 1,
203                would_block: usize::from(origin.would_block()),
204                last: epoch,
205            }),
206        }
207    }
208    out.commits = oids.len();
209    out.by_check
210        .sort_by(|a, b| b.count.cmp(&a.count).then(a.check.cmp(&b.check)));
211    out
212}
213
214/// The fleet's door: read the ledger under an already-resolved common dir.
215/// Absent file, unreadable file, foreign format — all read as empty.
216pub fn read_at(common_dir: &Path) -> Ledger {
217    read_file(&common_dir.join(LEDGER))
218}
219
220/// The in-repo door: resolves the common dir itself (process cwd). Empty on any
221/// failure — `amont list` in a broken repo still prints.
222pub fn read() -> Ledger {
223    ledger_path().map(|p| read_file(&p)).unwrap_or_default()
224}
225
226fn read_file(path: &Path) -> Ledger {
227    std::fs::read_to_string(path)
228        .map(|t| parse(&t))
229        .unwrap_or_default()
230}
231
232/// Record every check that failed without blocking.
233///
234/// Called from the HOOK entry points only — never from `amont run`. See
235/// `dispatch::pre_commit` for why that distinction is the whole integrity of
236/// these numbers.
237///
238/// Best-effort and silent, like the hook it runs in: the number's whole value
239/// is that it is collected without a lecture, and a bookkeeping failure must
240/// never disturb a commit.
241pub(crate) fn note(settings: &crate::config::Settings, events: &[(String, Origin)]) {
242    if events.is_empty() {
243        return;
244    }
245    if !crate::config::boolean_or(settings, "amont.recordDowngrades", true) {
246        return;
247    }
248    // The parent commit, which groups an afternoon's repeated attempts at one
249    // commit together. Before the first commit there is no HEAD, and that is
250    // exactly when somebody is setting a repository up — so record it against
251    // a zero oid rather than dropping the event.
252    let oid = crate::git::stdout(&["rev-parse", "HEAD"]).unwrap_or_else(|| "0000000".into());
253    let Some(path) = ledger_path() else { return };
254    append(&path, &oid, events);
255}
256
257/// `<common-dir>/amont-downgrades` — shared by every worktree of the repo.
258fn ledger_path() -> Option<PathBuf> {
259    let dir = crate::git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
260    Some(Path::new(&dir).join(LEDGER))
261}
262
263/// Append one event line per check, creating the header first if the file is
264/// new. Best-effort throughout.
265fn append(path: &Path, commit: &str, events: &[(String, Origin)]) {
266    // `create_new` means exactly one writer ever wins the header, whatever the
267    // worktree count.
268    let _ = std::fs::OpenOptions::new()
269        .write(true)
270        .create_new(true)
271        .open(path)
272        .and_then(|mut f| f.write_all(format!("{FORMAT}\n").as_bytes()));
273    compact_if_large(path);
274    let now = now_epoch();
275    let mut body = String::new();
276    for (check, origin) in events {
277        body.push_str(&format!("{now} {commit} {check} {}\n", origin.as_str()));
278    }
279    // One O_APPEND write of a few short lines: atomic enough in practice, and a
280    // torn line is dropped by `parse` rather than misread.
281    let _ = std::fs::OpenOptions::new()
282        .create(true)
283        .append(true)
284        .open(path)
285        .and_then(|mut f| f.write_all(body.as_bytes()));
286}
287
288/// Keep the file bounded: past [`MAX_BYTES`], rewrite it as the header plus the
289/// newest [`KEEP`] valid events. A concurrent appender can lose a few events to
290/// the rename — acceptable for a counter, unlike for a gate.
291fn compact_if_large(path: &Path) {
292    let Ok(meta) = std::fs::metadata(path) else {
293        return;
294    };
295    if meta.len() <= MAX_BYTES {
296        return;
297    }
298    let Ok(text) = std::fs::read_to_string(path) else {
299        return;
300    };
301    let events: Vec<&str> = text.lines().filter(|l| event(l).is_some()).collect();
302    let keep = &events[events.len().saturating_sub(KEEP)..];
303    let mut body = String::with_capacity(keep.len() * 64 + FORMAT.len() + 1);
304    body.push_str(FORMAT);
305    body.push('\n');
306    for line in keep {
307        body.push_str(line);
308        body.push('\n');
309    }
310    let tmp = path.with_file_name(format!("{LEDGER}.tmp-{}", std::process::id()));
311    if std::fs::write(&tmp, body).is_ok() {
312        let _ = std::fs::rename(&tmp, path);
313    }
314}
315
316fn now_epoch() -> u64 {
317    std::time::SystemTime::now()
318        .duration_since(std::time::UNIX_EPOCH)
319        .map(|d| d.as_secs())
320        .unwrap_or_default()
321}
322
323/// uninstall: the ledger is OUR bookkeeping, gone with the hooks.
324pub fn forget() -> bool {
325    ledger_path().is_some_and(|path| std::fs::remove_file(&path).is_ok())
326}
327
328/// The same, for a repository this process is not standing in — what the fleet
329/// needs, and it must resolve the path the way git would THERE.
330pub fn forget_in(repo: &Path) -> bool {
331    let Some(dir) = crate::git::stdout_in(
332        repo,
333        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
334    ) else {
335        return false;
336    };
337    std::fs::remove_file(Path::new(&dir).join(LEDGER)).is_ok()
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    fn ledger(events: &[&str]) -> String {
345        let mut s = format!("{FORMAT}\n");
346        for e in events {
347            s.push_str(e);
348            s.push('\n');
349        }
350        s
351    }
352
353    /// No header, no ledger — a truncated or foreign file reads as empty.
354    #[test]
355    fn a_ledger_without_the_header_is_ignored() {
356        assert_eq!(
357            parse("100 abcdef0 pre-commit-x config\n"),
358            Ledger::default()
359        );
360        assert_eq!(parse(""), Ledger::default());
361    }
362
363    /// A future format version reads as empty rather than being misread.
364    #[test]
365    fn a_ledger_in_an_unknown_format_version_reads_as_empty() {
366        assert_eq!(
367            parse("amont-downgrade-v2\n100 abcdef0 pre-commit-x config\n"),
368            Ledger::default()
369        );
370    }
371
372    /// A torn or hand-mangled line is skipped; its neighbours still count.
373    #[test]
374    fn malformed_lines_are_skipped_and_the_rest_still_counted() {
375        let text = ledger(&[
376            "100 abcdef0 pre-commit-x config",
377            "not an event line",
378            "101 abcdef0 pre-commit-x",             // three fields
379            "102 abcdef0 pre-commit-x config more", // five fields
380            "103 nothexg pre-commit-x config",      // oid not hex
381            "104 abc pre-commit-x config",          // oid too short
382            "105 abcdef0 pre-commit-x sideways",    // unknown origin
383            "106 abcdef0 pre-commit-x policy",
384        ]);
385        let l = parse(&text);
386        assert_eq!(l.total, 2);
387        assert_eq!(l.last, Some(106));
388    }
389
390    /// The trust boundary: a check name carrying a control byte never reaches
391    /// a display — the line is refused wholesale.
392    #[test]
393    fn a_check_name_with_a_control_byte_is_rejected() {
394        assert_eq!(
395            parse(&ledger(&["100 abcdef0 pre\u{1b}commit config"])).total,
396            0
397        );
398    }
399
400    /// Counts group by check; each group keeps its own newest timestamp, and
401    /// the order is count desc then name asc — stable for a render.
402    #[test]
403    fn counts_group_by_check_and_keep_the_latest_timestamp() {
404        let text = ledger(&[
405            "100 aaaaaaa pre-commit-usual-name config",
406            "200 bbbbbbb pre-commit-ban-terms config",
407            "300 ccccccc pre-commit-usual-name config",
408        ]);
409        let l = parse(&text);
410        assert_eq!(l.total, 3);
411        assert_eq!(l.first, Some(100));
412        assert_eq!(l.last, Some(300));
413        assert_eq!(l.by_check.len(), 2);
414        assert_eq!(l.by_check[0].check, "pre-commit-usual-name");
415        assert_eq!(l.by_check[0].count, 2);
416        assert_eq!(l.by_check[0].last, 300);
417        assert_eq!(l.by_check[1].check, "pre-commit-ban-terms");
418    }
419
420    /// THE distinction the report exists to draw. Three events against one
421    /// commit is one person fighting one commit; the same three spread over
422    /// three commits is a check the team disagrees with. `total` cannot tell
423    /// them apart and `commits` can.
424    #[test]
425    fn distinct_commits_are_counted_separately_from_events() {
426        let one = ledger(&[
427            "100 aaaaaaa pre-commit-usual-name config",
428            "101 aaaaaaa pre-commit-usual-name config",
429            "102 aaaaaaa pre-commit-usual-name config",
430        ]);
431        let l = parse(&one);
432        assert_eq!((l.total, l.commits), (3, 1));
433
434        let many = ledger(&[
435            "100 aaaaaaa pre-commit-usual-name config",
436            "101 bbbbbbb pre-commit-usual-name config",
437            "102 ccccccc pre-commit-usual-name config",
438        ]);
439        let l = parse(&many);
440        assert_eq!((l.total, l.commits), (3, 3));
441    }
442
443    /// A check that declares `warn` itself was never going to block, so it is
444    /// counted but is NOT evidence about a rollout. Conflating the two would
445    /// inflate the one number a lead reads.
446    #[test]
447    fn only_overridden_checks_count_as_would_have_blocked() {
448        let text = ledger(&[
449            "100 aaaaaaa pre-commit-ban-terms config",
450            "101 aaaaaaa pre-commit-secrets policy",
451            "102 aaaaaaa pre-commit-agents-md declared",
452        ]);
453        let l = parse(&text);
454        assert_eq!(l.total, 3);
455        assert_eq!(l.would_block, 2, "the declared-warn check is not evidence");
456        let declared = l
457            .by_check
458            .iter()
459            .find(|c| c.check == "pre-commit-agents-md")
460            .unwrap();
461        assert_eq!((declared.count, declared.would_block), (1, 0));
462    }
463
464    /// A repo that never warned about anything has no file, and that reads as
465    /// zero — not as an error.
466    #[test]
467    fn an_absent_ledger_reads_as_empty() {
468        let dir = std::env::temp_dir().join(format!("amont-downgrade-none-{}", std::process::id()));
469        let _ = std::fs::create_dir_all(&dir);
470        assert_eq!(read_at(&dir), Ledger::default());
471        let _ = std::fs::remove_dir_all(&dir);
472    }
473
474    /// Compaction keeps the header and the NEWEST events; the file shrinks and
475    /// still parses.
476    #[test]
477    fn compaction_keeps_the_header_and_the_newest_events() {
478        let dir =
479            std::env::temp_dir().join(format!("amont-downgrade-compact-{}", std::process::id()));
480        let _ = std::fs::create_dir_all(&dir);
481        let path = dir.join(LEDGER);
482        let mut body = format!("{FORMAT}\n");
483        // Well past MAX_BYTES: ~6,000 events of ~45 bytes.
484        for i in 0..6_000u64 {
485            body.push_str(&format!(
486                "{i} abcdef0123456789 pre-commit-ban-terms config\n"
487            ));
488        }
489        std::fs::write(&path, body).unwrap();
490        compact_if_large(&path);
491        let text = std::fs::read_to_string(&path).unwrap();
492        assert!(text.starts_with(FORMAT));
493        let l = parse(&text);
494        assert_eq!(l.total, KEEP);
495        assert_eq!(l.last, Some(5_999), "the newest events survive");
496        let _ = std::fs::remove_dir_all(&dir);
497    }
498
499    /// The ceiling is deliberately above a fortnight of ordinary trial volume;
500    /// compacting mid-trial would turn the total into a silent floor exactly
501    /// when a lead is reading it as a count.
502    #[test]
503    fn the_ceiling_survives_a_fortnight_of_trial_volume() {
504        let per_event = "1756300000 abcdef0123456789 pre-commit-usual-name config\n".len() as u64;
505        let fortnight = 5 * 20 * 14; // five checks, twenty commits a day
506        assert!(
507            per_event * fortnight < MAX_BYTES,
508            "{fortnight} events of {per_event} bytes must fit under {MAX_BYTES}"
509        );
510    }
511
512    /// Appending to a fresh path writes the header once; appending again does
513    /// not duplicate it.
514    #[test]
515    fn appending_twice_writes_exactly_one_header() {
516        let dir =
517            std::env::temp_dir().join(format!("amont-downgrade-append-{}", std::process::id()));
518        let _ = std::fs::create_dir_all(&dir);
519        let path = dir.join(LEDGER);
520        append(
521            &path,
522            "abcdef0123456789",
523            &[("pre-commit-ban-terms".into(), Origin::Config)],
524        );
525        append(
526            &path,
527            "abcdef0123456789",
528            &[("pre-commit-secrets".into(), Origin::Declared)],
529        );
530        let text = std::fs::read_to_string(&path).unwrap();
531        assert_eq!(text.matches(FORMAT).count(), 1, "{text:?}");
532        let l = parse(&text);
533        assert_eq!((l.total, l.would_block, l.commits), (2, 1, 1));
534        let _ = std::fs::remove_dir_all(&dir);
535    }
536
537    /// Every origin survives a write and read back as itself.
538    #[test]
539    fn origins_round_trip() {
540        for o in [Origin::Config, Origin::Policy, Origin::Declared] {
541            assert_eq!(Origin::parse(o.as_str()), Some(o));
542        }
543        assert_eq!(Origin::parse("warn"), None);
544    }
545}