Skip to main content

amont_runtime/
bypass.rs

1//! The ledger of unverified commits — the bypass signal, kept instead of
2//! discarded.
3//!
4//! [`crate::gate_stamp`] already detects the interesting event: a commit
5//! that a commit-time gate declaration covered, created without that gate
6//! having run — `--no-verify` is the commonest cause, a blocked attempt
7//! retried with it the second, a gate whose tool was missing the third.
8//! Until this module existed the detection was followed by a bare `return`:
9//! the first symptom of a slow or flaky check (people routing around it) was
10//! thrown away at the exact moment it was in hand.
11//!
12//! This module only counts. The stamp gates a check (a wrong read there
13//! weakens the push gate); the ledger informs a dashboard (a wrong read here
14//! miscounts). That difference in stakes is why this is not part of
15//! `gate_stamp` — nothing in this file participates in any suppression
16//! decision, and nothing ever may.
17//!
18//! The ledger is a local file, never a ref, never pushed, never sent
19//! anywhere — the project's no-telemetry promise applies in full. It lives
20//! in the COMMON git dir (unlike the deliberately worktree-private marker)
21//! because "how often does this repository dodge its gate" is a question
22//! about the repository, not about one worktree. `amont uninstall` deletes
23//! it; so does `amont.recordBypasses false`, prospectively.
24//!
25//! Format, versioned like its siblings (`amont-gate-v1`, `amont-held-v1`):
26//!
27//! ```text
28//! amont-bypass-v1
29//! <unix-epoch> <commit-oid> <script>
30//! ```
31//!
32//! One line per uncovered script. No paths ever appear in the file, which is
33//! why newline/space delimiting is safe here where `staged_only` needed NUL.
34
35use std::io::Write;
36use std::path::{Path, PathBuf};
37
38/// First line of the ledger. A future amont that changes the shape bumps
39/// this, and an old ledger reads as empty rather than being misread.
40pub const FORMAT: &str = "amont-bypass-v1";
41
42/// The ledger's filename inside the common git dir.
43const LEDGER: &str = "amont-bypasses";
44
45/// Compact past this — roughly 1,100 events. A ledger that long has long
46/// since saturated the signal it exists to carry.
47const MAX_BYTES: u64 = 64 * 1024;
48
49/// Events kept by a compaction, newest first. After a compaction the total
50/// is a FLOOR, not an exact count — the recent shape survives, which is the
51/// part that means anything.
52const KEEP: usize = 500;
53
54/// What the ledger says, aggregated. Everything a reader displays comes
55/// through here; nobody re-parses the file.
56#[derive(Debug, Default, Clone, PartialEq, Eq)]
57pub struct Ledger {
58    /// Events on record (a floor after compaction).
59    pub total: usize,
60    /// The newest event's epoch, if any.
61    pub last: Option<u64>,
62    /// Count descending, then script ascending — a stable order a golden
63    /// render can pin.
64    pub by_script: Vec<ScriptCount>,
65}
66
67/// One script's slice of the ledger.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ScriptCount {
70    pub script: String,
71    pub count: usize,
72    /// The newest event for THIS script.
73    pub last: u64,
74}
75
76/// One valid event line, or nothing. The shared trust boundary: `parse` and
77/// compaction both refuse a line through this, so a hand-edited ledger can
78/// neither skew the counts with garbage nor smuggle a control byte into a
79/// terminal — script names must be short printable ASCII, oids hex.
80fn event(line: &str) -> Option<(u64, &str, &str)> {
81    let mut fields = line.split_whitespace();
82    let (Some(epoch), Some(oid), Some(script), None) =
83        (fields.next(), fields.next(), fields.next(), fields.next())
84    else {
85        return None;
86    };
87    let epoch = epoch.parse::<u64>().ok()?;
88    if !(7..=64).contains(&oid.len()) || !oid.bytes().all(|b| b.is_ascii_hexdigit()) {
89        return None;
90    }
91    if !(1..=32).contains(&script.len()) || !script.bytes().all(|b| b.is_ascii_graphic()) {
92        return None;
93    }
94    Some((epoch, oid, script))
95}
96
97/// Aggregate a ledger's text. Pure; a malformed line is skipped, never
98/// guessed at, and a missing or foreign header reads as an empty ledger.
99pub fn parse(text: &str) -> Ledger {
100    let mut lines = text.lines().filter(|l| !l.trim().is_empty());
101    if lines.next() != Some(FORMAT) {
102        return Ledger::default();
103    }
104    let mut out = Ledger::default();
105    for line in lines {
106        let Some((epoch, _oid, script)) = event(line) else {
107            continue;
108        };
109        out.total += 1;
110        out.last = Some(out.last.map_or(epoch, |l| l.max(epoch)));
111        match out.by_script.iter_mut().find(|s| s.script == script) {
112            Some(s) => {
113                s.count += 1;
114                s.last = s.last.max(epoch);
115            }
116            None => out.by_script.push(ScriptCount {
117                script: script.to_string(),
118                count: 1,
119                last: epoch,
120            }),
121        }
122    }
123    out.by_script
124        .sort_by(|a, b| b.count.cmp(&a.count).then(a.script.cmp(&b.script)));
125    out
126}
127
128/// The fleet's door: read the ledger under an already-resolved common dir.
129/// Absent file, unreadable file, foreign format — all read as empty.
130pub fn read_at(common_dir: &Path) -> Ledger {
131    read_file(&common_dir.join(LEDGER))
132}
133
134/// The in-repo door: resolves the common dir itself (process cwd). Empty on
135/// any failure — `amont list` in a broken repo still prints.
136pub fn read() -> Ledger {
137    ledger_path().map(|p| read_file(&p)).unwrap_or_default()
138}
139
140fn read_file(path: &Path) -> Ledger {
141    std::fs::read_to_string(path)
142        .map(|t| parse(&t))
143        .unwrap_or_default()
144}
145
146/// A relative age in the largest unit that fits — integer arithmetic only,
147/// no calendar. A timestamp from the future (clock skew between worktree
148/// hosts) clamps to "just now" rather than underflowing.
149pub fn age(now: u64, then: u64) -> String {
150    let d = now.saturating_sub(then);
151    if d < 60 {
152        "just now".to_string()
153    } else if d < 3600 {
154        format!("{}m ago", d / 60)
155    } else if d < 86_400 {
156        format!("{}h ago", d / 3600)
157    } else if d < 7 * 86_400 {
158        format!("{}d ago", d / 86_400)
159    } else if d < 365 * 86_400 {
160        format!("{}w ago", d / (7 * 86_400))
161    } else {
162        format!("{}y ago", d / (365 * 86_400))
163    }
164}
165
166/// post-commit: record every gate-declared script this commit's files were
167/// covered by that is NOT in `stamped` (what [`crate::gate_stamp`] just
168/// wrote a note for). Completely silent, like the hook it runs in — the
169/// number's whole value is that it is collected without a lecture.
170///
171/// The ordering below is the design: a repository that declares no gate
172/// pays ZERO extra git spawns, and a gated repository whose commit was
173/// properly stamped pays zero too. Only a commit already known to be
174/// unverified spends processes.
175pub(crate) fn note_unverified(
176    settings: &crate::config::Settings,
177    manifest: &crate::manifest::Manifest,
178    stamped: &[String],
179) {
180    let names = crate::hooks::run_tests::gate_names_declared(&manifest.externals);
181    if names.is_empty() {
182        return;
183    }
184    if names.iter().all(|n| stamped.iter().any(|s| s == n)) {
185        return;
186    }
187    // Skips and severity overrides can retire a declaration from the gate;
188    // an entry the push gate would not trust cannot be "bypassed". EVERY
189    // blocking declaration counts, whatever its name — the ledger is about
190    // dodged checks, not about npm's vocabulary.
191    let declared = crate::hooks::run_tests::blocking_commit_decls(settings, &manifest.externals);
192    let missing: Vec<_> = declared
193        .iter()
194        .filter(|d| !stamped.contains(&d.script))
195        .collect();
196    if missing.is_empty() {
197        return;
198    }
199    if !crate::config::boolean_or(settings, "amont.recordBypasses", true) {
200        return;
201    }
202    let files = head_files();
203    if files.is_empty() {
204        return; // git could not tell → do not guess
205    }
206    let scripts: Vec<&str> = missing
207        .iter()
208        .filter(|d| d.scope.matches(&files))
209        .map(|d| d.script.as_str())
210        .collect();
211    if scripts.is_empty() {
212        return;
213    }
214    let Some(oid) = crate::git::stdout(&["rev-parse", "HEAD"]) else {
215        return;
216    };
217    let Some(path) = ledger_path() else { return };
218    append(&path, &oid, &scripts);
219}
220
221/// HEAD's own files. `--root` because a parentless commit prints NOTHING
222/// without it, and the initial commit is exactly the one somebody makes with
223/// `--no-verify`. `-m` because a conflict-resolution commit DOES run
224/// post-commit and shows nothing without it. `stdout_paths` inserts `-z`.
225fn head_files() -> Vec<String> {
226    crate::git::stdout_paths(&[
227        "diff-tree",
228        "--no-commit-id",
229        "--name-only",
230        "-r",
231        "-m",
232        "--root",
233        "HEAD",
234    ])
235    .unwrap_or_default()
236}
237
238/// `<common-dir>/amont-bypasses` — shared by every worktree of the repo.
239fn ledger_path() -> Option<PathBuf> {
240    let dir = crate::git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
241    Some(Path::new(&dir).join(LEDGER))
242}
243
244/// Append one event line per script, creating the header first if the file
245/// is new. Best-effort throughout: a bookkeeping failure must never disturb
246/// a commit that already exists.
247fn append(path: &Path, commit: &str, scripts: &[&str]) {
248    // `create_new` means exactly one writer ever wins the header, whatever
249    // the worktree count.
250    let _ = std::fs::OpenOptions::new()
251        .write(true)
252        .create_new(true)
253        .open(path)
254        .and_then(|mut f| f.write_all(format!("{FORMAT}\n").as_bytes()));
255    compact_if_large(path);
256    let now = now_epoch();
257    let mut body = String::new();
258    for script in scripts {
259        body.push_str(&format!("{now} {commit} {script}\n"));
260    }
261    // One O_APPEND write of a few short lines: atomic enough in practice,
262    // and a torn line is dropped by `parse` rather than misread.
263    let _ = std::fs::OpenOptions::new()
264        .create(true)
265        .append(true)
266        .open(path)
267        .and_then(|mut f| f.write_all(body.as_bytes()));
268}
269
270/// Keep the file bounded: past [`MAX_BYTES`], rewrite it as the header plus
271/// the newest [`KEEP`] valid events. A concurrent appender can lose a few
272/// events to the rename — acceptable for a counter, unlike for a gate.
273fn compact_if_large(path: &Path) {
274    let Ok(meta) = std::fs::metadata(path) else {
275        return;
276    };
277    if meta.len() <= MAX_BYTES {
278        return;
279    }
280    let Ok(text) = std::fs::read_to_string(path) else {
281        return;
282    };
283    let events: Vec<&str> = text.lines().filter(|l| event(l).is_some()).collect();
284    let keep = &events[events.len().saturating_sub(KEEP)..];
285    let mut body = String::with_capacity(keep.len() * 64 + FORMAT.len() + 1);
286    body.push_str(FORMAT);
287    body.push('\n');
288    for line in keep {
289        body.push_str(line);
290        body.push('\n');
291    }
292    let tmp = path.with_file_name(format!("{LEDGER}.tmp-{}", std::process::id()));
293    if std::fs::write(&tmp, body).is_ok() {
294        let _ = std::fs::rename(&tmp, path);
295    }
296}
297
298fn now_epoch() -> u64 {
299    std::time::SystemTime::now()
300        .duration_since(std::time::UNIX_EPOCH)
301        .map(|d| d.as_secs())
302        .unwrap_or_default()
303}
304
305/// uninstall: the ledger is OUR bookkeeping, gone with the hooks.
306pub fn forget() -> bool {
307    ledger_path().is_some_and(|path| std::fs::remove_file(&path).is_ok())
308}
309
310/// The same, for a repository this process is not standing in — what the
311/// fleet needs, and it must resolve the path the way git would THERE:
312/// `--git-common-dir` differs per repository, and a linked worktree's
313/// ledger lives with its main checkout.
314pub fn forget_in(repo: &Path) -> bool {
315    let Some(dir) = crate::git::stdout_in(
316        repo,
317        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
318    ) else {
319        return false;
320    };
321    std::fs::remove_file(Path::new(&dir).join(LEDGER)).is_ok()
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn ledger(events: &[&str]) -> String {
329        let mut s = format!("{FORMAT}\n");
330        for e in events {
331            s.push_str(e);
332            s.push('\n');
333        }
334        s
335    }
336
337    /// No header, no ledger — a truncated or foreign file reads as empty.
338    #[test]
339    fn a_ledger_without_the_header_is_ignored() {
340        assert_eq!(parse("100 abcdef0 typecheck\n"), Ledger::default());
341        assert_eq!(parse(""), Ledger::default());
342    }
343
344    /// A future format version reads as empty rather than being misread.
345    #[test]
346    fn a_ledger_in_an_unknown_format_version_reads_as_empty() {
347        assert_eq!(
348            parse("amont-bypass-v2\n100 abcdef0 typecheck\n"),
349            Ledger::default()
350        );
351    }
352
353    /// A torn or hand-mangled line is skipped; its neighbours still count.
354    #[test]
355    fn malformed_lines_are_skipped_and_the_rest_still_counted() {
356        let text = ledger(&[
357            "100 abcdef0 typecheck",
358            "not an event line",
359            "101 abcdef0",            // two fields
360            "102 abcdef0 test extra", // four fields
361            "103 nothexg typecheck",  // oid not hex
362            "104 abc typecheck",      // oid too short
363            "105 abcdef0 test",
364        ]);
365        let l = parse(&text);
366        assert_eq!(l.total, 2);
367        assert_eq!(l.last, Some(105));
368    }
369
370    /// The trust boundary: a script name carrying a control byte never
371    /// reaches a display — the line is refused wholesale.
372    #[test]
373    fn a_script_name_with_a_control_byte_is_rejected() {
374        let text = ledger(&["100 abcdef0 type\u{1b}check"]);
375        assert_eq!(parse(&text).total, 0);
376    }
377
378    /// Counts group by script; each group keeps its own newest timestamp,
379    /// and the order is count desc then name asc — stable for a render.
380    #[test]
381    fn counts_group_by_script_and_keep_the_latest_timestamp() {
382        let text = ledger(&[
383            "100 aaaaaaa typecheck",
384            "200 bbbbbbb test",
385            "300 ccccccc typecheck",
386        ]);
387        let l = parse(&text);
388        assert_eq!(l.total, 3);
389        assert_eq!(l.last, Some(300));
390        assert_eq!(l.by_script.len(), 2);
391        assert_eq!(l.by_script[0].script, "typecheck");
392        assert_eq!(l.by_script[0].count, 2);
393        assert_eq!(l.by_script[0].last, 300);
394        assert_eq!(l.by_script[1].script, "test");
395        assert_eq!(l.by_script[1].last, 200);
396    }
397
398    /// A repo that never bypassed anything has no file, and that reads as
399    /// zero — not as an error.
400    #[test]
401    fn an_absent_ledger_reads_as_empty() {
402        let dir = std::env::temp_dir().join(format!("amont-bypass-none-{}", std::process::id()));
403        let _ = std::fs::create_dir_all(&dir);
404        assert_eq!(read_at(&dir), Ledger::default());
405        let _ = std::fs::remove_dir_all(&dir);
406    }
407
408    /// Compaction keeps the header and the NEWEST events; the file shrinks
409    /// and still parses.
410    #[test]
411    fn compaction_keeps_the_header_and_the_newest_events() {
412        let dir = std::env::temp_dir().join(format!("amont-bypass-compact-{}", std::process::id()));
413        let _ = std::fs::create_dir_all(&dir);
414        let path = dir.join(LEDGER);
415        let mut body = format!("{FORMAT}\n");
416        // Well past MAX_BYTES: ~2,600 events of ~40 bytes.
417        for i in 0..2_600u64 {
418            body.push_str(&format!("{i} abcdef0123456789 typecheck\n"));
419        }
420        std::fs::write(&path, body).unwrap();
421        compact_if_large(&path);
422        let text = std::fs::read_to_string(&path).unwrap();
423        assert!(text.starts_with(FORMAT));
424        let l = parse(&text);
425        assert_eq!(l.total, KEEP);
426        assert_eq!(l.last, Some(2_599), "the newest events survive");
427        let _ = std::fs::remove_dir_all(&dir);
428    }
429
430    /// Appending to a fresh path writes the header once; appending again
431    /// does not duplicate it.
432    #[test]
433    fn appending_twice_writes_exactly_one_header() {
434        let dir = std::env::temp_dir().join(format!("amont-bypass-append-{}", std::process::id()));
435        let _ = std::fs::create_dir_all(&dir);
436        let path = dir.join(LEDGER);
437        append(&path, "abcdef0123456789", &["typecheck"]);
438        append(&path, "abcdef0123456789", &["test"]);
439        let text = std::fs::read_to_string(&path).unwrap();
440        assert_eq!(text.matches(FORMAT).count(), 1, "{text:?}");
441        assert_eq!(parse(&text).total, 2);
442        let _ = std::fs::remove_dir_all(&dir);
443    }
444
445    /// The largest unit that fits, and clean boundaries.
446    #[test]
447    fn age_reads_in_the_largest_unit_that_fits() {
448        assert_eq!(age(1000, 990), "just now");
449        assert_eq!(age(1000 + 120, 1000), "2m ago");
450        assert_eq!(age(1000 + 2 * 3600, 1000), "2h ago");
451        assert_eq!(age(1000 + 3 * 86_400, 1000), "3d ago");
452        assert_eq!(age(1000 + 20 * 86_400, 1000), "2w ago");
453        assert_eq!(age(1000 + 800 * 86_400, 1000), "2y ago");
454    }
455
456    /// Clock skew across machines can put an event in the future; that
457    /// clamps to "just now" instead of underflowing to eternity.
458    #[test]
459    fn a_timestamp_from_the_future_does_not_underflow() {
460        assert_eq!(age(100, 200), "just now");
461    }
462}