amont_runtime/content.rs
1//! The content checks, asked about a FILE rather than about the index.
2//!
3//! Every check reachable from here is pure: give it a path and some bytes and
4//! it answers, with no git, no index, no stash, and no opinion about what is
5//! staged. That is the whole difference from [`crate::dispatch`], which
6//! rehearses a commit and is entitled to all of those things.
7//!
8//! The separation is deliberate and load-bearing. `amont run` answers "is this
9//! commit ready", which is a question about the index; an editor asks "what is
10//! wrong with this buffer", which is a question about content that may not be
11//! staged and may never have been saved. Conflating them would drag index
12//! fidelity — the stash, the restore path, the staged-only hold — into what
13//! ought to be a read-only lookup.
14//!
15//! Not every check can come here, and that is fine. `branch-pattern`,
16//! `pull-rebase` and `package-lock` are not about the contents of a file at
17//! all, and `clippy`/`ruff`/`eslint` already have editor integrations of their
18//! own. What is left is the set amont uniquely owns — which is also the set
19//! `docs/ci.md` says CI deliberately does not reproduce.
20
21use crate::finding::Finding;
22use crate::hooks::{ban_terms, large_files, merge_conflict, secrets};
23
24/// Every finding for one file, ordered for reading.
25///
26/// `bytes` rather than `&str` because two of the four decisions are made on
27/// bytes: a file's SIZE is a byte count, and whether a file is text at all is
28/// git's NUL heuristic. Converting first would answer both questions wrongly
29/// for exactly the files where they matter.
30pub fn scan(file: &str, bytes: &[u8]) -> Vec<Finding> {
31 let mut out = Vec::new();
32
33 // Size first, and unconditionally: it is the one thing still worth saying
34 // about a file too big or too binary for everything below.
35 if let Some(f) = large_files::scan(file, bytes.len() as u64) {
36 out.push(f);
37 }
38 if !secrets::is_scannable(bytes) {
39 return sorted(out);
40 }
41
42 let text = String::from_utf8_lossy(bytes);
43 // Each check's own short name is passed as the self-exclusion key. In a
44 // hook that key is the hook's name; here there is no stage, so the check
45 // names itself. The only visible difference is in amont's own repository,
46 // where a fixture named after a hook rather than after a check is no
47 // longer skipped — which is the correct answer for a verb that was asked
48 // about that file by name.
49 out.extend(ban_terms::scan(file, &text, ban_terms::NAME));
50 out.extend(merge_conflict::scan(file, &text, merge_conflict::NAME));
51 out.extend(secrets::findings(file, &text));
52 sorted(out)
53}
54
55/// By position, then by check, so output is stable across runs.
56///
57/// Stability is not cosmetic here: a diagnostic list that reorders between
58/// invocations makes an editor's gutter flicker and makes a diff of two runs
59/// unreadable. Findings without a line sort first — they are about the whole
60/// file, so they belong above anything inside it.
61fn sorted(mut findings: Vec<Finding>) -> Vec<Finding> {
62 findings.sort_by(|a, b| {
63 a.file
64 .cmp(&b.file)
65 .then(a.line.cmp(&b.line))
66 .then(a.column.cmp(&b.column))
67 .then(a.check.cmp(b.check))
68 });
69 findings
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use crate::check::Severity;
76
77 #[test]
78 fn a_clean_file_has_nothing_to_say() {
79 assert!(scan("app.js", b"const x = 1;\n").is_empty());
80 }
81
82 #[test]
83 fn a_banned_term_is_placed() {
84 let f = scan("app.js", b"const x = 1;\n\n debugger;\n");
85 assert_eq!(f.len(), 1);
86 assert_eq!(f[0].check, "ban-terms");
87 assert_eq!(f[0].line, Some(3));
88 assert_eq!(f[0].column, Some(3));
89 }
90
91 /// The extension gate still applies: `debugger;` in a `.txt` is prose.
92 #[test]
93 fn scope_is_respected_outside_the_index_too() {
94 assert!(scan("notes.txt", b"debugger;\n").is_empty());
95 }
96
97 /// A binary blob gets a size verdict and no text scanning — reading it as
98 /// text would produce nonsense positions in nonexistent lines.
99 #[test]
100 fn a_binary_file_is_sized_but_not_read() {
101 let mut bytes = vec![0u8; 200];
102 bytes.extend_from_slice(b"debugger;");
103 let f = scan("blob.js", &bytes);
104 assert!(f.iter().all(|f| f.check != "ban-terms"), "{f:?}");
105 }
106
107 /// Whole-file findings sort above positioned ones.
108 #[test]
109 fn findings_are_ordered_for_reading() {
110 let whole = Finding::new("large-files", "a.js", Severity::Warn, "x");
111 let late = Finding::new("ban-terms", "a.js", Severity::Block, "y").at_line(9);
112 let early = Finding::new("secrets", "a.js", Severity::Block, "z").at_line(2);
113 let out = sorted(vec![late, whole, early]);
114 assert_eq!(
115 out.iter().map(|f| f.line).collect::<Vec<_>>(),
116 vec![None, Some(2), Some(9)]
117 );
118 }
119}