Skip to main content

spec_driven_docs/gates/
suppression_names_its_case.rs

1//! Gate: a cited known-issue case resolves to a record.
2//!
3//! A suppression over a defect this project does not own names the
4//! `KI-<slug>` case that justifies it. The name is worth nothing where it
5//! resolves to no record, so this gate reads the one token this convention
6//! defines and checks it against the records the instance keeps. A commit
7//! that deletes a record while a citation still names it is the failure this
8//! exists to catch.
9//!
10//! The gate judges no suppression syntax. Whether a suppression states a
11//! reason belongs to the linter that honors it, where that linter has a rule
12//! for it: clippy has `clippy::allow_attributes_without_reason` and `ESLint`
13//! has `eslint-comments/require-description`. Where it has none, as Ruff
14//! does not, the reason is a review obligation. A delivered gate parses no
15//! grammar this convention does not define, so no table of comment openers,
16//! filename suffixes, or per-tool reason positions lives here.
17//!
18//! SATISFIES spec-to-code:a-suppression-names-its-case
19//!
20//! What counts as a citation is one token: `KI-` followed by a lowercase
21//! slug, opening and closing on a word boundary. A case id is a lowercase
22//! slug, so a token that runs on past one, such as `KI-vendorXYZ`, names no
23//! case. The gate reports nothing for it and does not resolve it as the
24//! shorter name either. A citation misspelled that way is invisible here,
25//! and the filename gate holds the records themselves to the same shape.
26//!
27//! The scan reads every file the walk yields and needs no knowledge of the
28//! language it is reading. The registry row excludes the documentation root,
29//! because a specification, a chapter, and a record each write the token
30//! while teaching it. A project whose test fixtures write the token reserves
31//! those paths in its own declaration.
32//!
33//! The gate runs always rather than over the staged files. Pre-commit selects
34//! staged files with `--diff-filter=ACMRTUXB`, which omits deletions, so a
35//! filter would hand this check an empty list on exactly the commit it is
36//! for.
37
38use std::collections::BTreeSet;
39
40use crate::domain::finding::Finding;
41use crate::domain::rule_id::RuleId;
42use crate::gates::paths::ki_records;
43use crate::gates::{GateCtx, GateError, GateResult, Violation, walk_files};
44
45/// The rules this gate can cite.
46pub const CITES: &[RuleId] = &[RuleId::SuppressionNamesItsCase];
47
48const CASE: RuleId = RuleId::SuppressionNamesItsCase;
49
50/// Whether a byte run reads as binary, judged by a NUL near its start.
51fn looks_binary(bytes: &[u8]) -> bool {
52    bytes.iter().take(4096).any(|&b| b == 0)
53}
54
55/// Whether a token starting at `index` opens on a word boundary, so a longer
56/// word that ends in the token is not read as the token.
57const fn on_a_boundary(text: &str, index: usize) -> bool {
58    index == 0
59        || !text.as_bytes()[index - 1].is_ascii_alphanumeric()
60            && text.as_bytes()[index - 1] != b'-'
61            && text.as_bytes()[index - 1] != b'_'
62}
63
64/// Every `KI-<slug>` case one line cites.
65fn cited_cases(line: &str) -> impl Iterator<Item = String> + '_ {
66    line.match_indices("KI-")
67        .filter(|(index, _)| on_a_boundary(line, *index))
68        .filter_map(|(index, _)| {
69            let rest = &line[index + 3..];
70            let slug: String = rest
71                .chars()
72                .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
73                .collect();
74            // The slug closes on a boundary too. A case id is a lowercase
75            // slug, so `KI-vendor-quirkXYZ` is not one, and this yields
76            // nothing for it rather than letting it resolve as the shorter
77            // `KI-vendor-quirk`.
78            let closes = rest[slug.len()..]
79                .chars()
80                .next()
81                .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
82            (!slug.is_empty() && closes).then(|| format!("KI-{slug}"))
83        })
84}
85
86/// One citation, and where a reader opens it.
87struct Citation {
88    case: String,
89    file: String,
90    number: usize,
91}
92
93/// Every case cited by the files this gate judges.
94fn citations(ctx: &GateCtx) -> Result<Vec<Citation>, GateError> {
95    let mut found = Vec::new();
96    for file in walk_files(ctx) {
97        let bytes =
98            std::fs::read(ctx.path(&file)).map_err(|source| GateError::io(file.clone(), source))?;
99        if looks_binary(&bytes) {
100            continue;
101        }
102        // Decoded loosely, because the token is ASCII and a single stray
103        // byte elsewhere in the file must not hide it. Rejecting the whole
104        // file there would narrow a scan this rule states unconditionally.
105        let text = String::from_utf8_lossy(&bytes);
106        let name = file.as_str().trim_start_matches("./").to_string();
107        for (index, line) in text.lines().enumerate() {
108            found.extend(cited_cases(line).map(|case| Citation {
109                case,
110                file: name.clone(),
111                number: index + 1,
112            }));
113        }
114    }
115    Ok(found)
116}
117
118/// Judge every cited case against the records the instance keeps.
119///
120/// # Errors
121///
122/// [`GateError::Io`] when a candidate file or a record root cannot be read.
123pub fn run(ctx: &GateCtx, args: &[String]) -> GateResult {
124    let known: BTreeSet<String> = ki_records(ctx, args)?
125        .iter()
126        .filter_map(|record| {
127            record
128                .file_name()
129                .map(|name| name.trim_end_matches(".md").to_string())
130        })
131        .collect();
132    Ok(citations(ctx)?
133        .into_iter()
134        .filter(|citation| !known.contains(&citation.case))
135        .map(|citation| {
136            Violation::Finding(Finding::on_line(
137                CASE,
138                &citation.file,
139                citation.number,
140                format!("{} resolves to no record", citation.case),
141            ))
142        })
143        .collect())
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    use crate::domain::path_filter::{Layer, PathFilter, Pattern};
151
152    /// A context over `dir` that excludes one glob, as a project declaration
153    /// does.
154    fn excluding(dir: &tempfile::TempDir, glob: &str) -> GateCtx {
155        let filter =
156            PathFilter::build(Vec::new(), vec![Pattern::new(glob, Layer::Project)]).unwrap();
157        GateCtx::with_filter(dir.path().to_str().unwrap(), filter)
158    }
159
160    /// A repository holding one file at each named path.
161    fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
162        let dir = tempfile::tempdir().unwrap();
163        for (path, text) in files {
164            let full = dir.path().join(path);
165            std::fs::create_dir_all(full.parent().unwrap()).unwrap();
166            std::fs::write(full, text).unwrap();
167        }
168        dir
169    }
170
171    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
172        run(&GateCtx::new(dir.path().to_str().unwrap()), &[])
173            .unwrap()
174            .iter()
175            .map(ToString::to_string)
176            .collect()
177    }
178
179    /// The record this repository's fixtures resolve against.
180    const RECORD: (&str, &str) = (
181        "_docs/reference/known-issues/KI-vendor-replays.md",
182        "# Vendor replays\n",
183    );
184
185    /// The scan needs no filename suffix table, so it reaches a language no
186    /// table ever named and a file with no suffix at all.
187    #[test]
188    fn an_absent_record_fails_whatever_file_cites_it() {
189        for name in [
190            "src/client.rs",
191            "deploy/pipeline.yaml",
192            "notes.txt",
193            "scripts/publish",
194            "Makefile",
195        ] {
196            let dir = tree(&[(name, "mask for KI-vendor-replays\n")]);
197            let out = run_in(&dir);
198            assert_eq!(out.len(), 1, "{name} reported {out:?}");
199            assert_eq!(
200                out[0],
201                format!(
202                    "FAIL spec-to-code:a-suppression-names-its-case {name}:1: \
203                     KI-vendor-replays resolves to no record"
204                )
205            );
206        }
207    }
208
209    /// An empty zone is the state the check exists to catch, not a reason to
210    /// report nothing.
211    #[test]
212    fn a_repository_keeping_no_records_still_fails_a_citation() {
213        let dir = tree(&[("src/client.rs", "// KI-vendor-replays\n")]);
214        assert!(!dir.path().join("_docs/reference/known-issues").exists());
215        assert_eq!(run_in(&dir).len(), 1);
216    }
217
218    #[test]
219    fn a_citation_whose_record_exists_passes() {
220        let dir = tree(&[("src/client.rs", "// KI-vendor-replays\n"), RECORD]);
221        assert!(run_in(&dir).is_empty());
222    }
223
224    /// The deliberate loss. A suppression carrying neither a case nor a
225    /// reason is another tool's judgment now, and this asserts that it was
226    /// chosen rather than overlooked.
227    #[test]
228    fn a_suppression_with_neither_a_case_nor_a_reason_is_not_this_gates_business() {
229        let dir = tree(&[("src/client.rs", "#[allow(dead_code)]\nfn unused() {}\n")]);
230        assert!(run_in(&dir).is_empty());
231    }
232
233    /// Every site of one fabricated case is reported, because each is a
234    /// separate citation a reader has to repair.
235    #[test]
236    fn each_site_of_one_absent_case_is_reported() {
237        let dir = tree(&[
238            ("src/a.rs", "// KI-gone\n"),
239            ("src/b.rs", "x\n// KI-gone\n"),
240        ]);
241        let out = run_in(&dir);
242        assert_eq!(out.len(), 2, "{out:?}");
243        assert!(out[0].contains("src/a.rs:1"));
244        assert!(out[1].contains("src/b.rs:2"));
245    }
246
247    /// The registry row excludes the documentation root, where a spec, a
248    /// chapter, and a record all write the token while teaching it.
249    #[test]
250    fn an_excluded_path_leaves_the_subject_set() {
251        let dir = tree(&[("_docs/specs/SPEC-spec-to-code.md", "cite KI-vendor-500\n")]);
252        assert_eq!(run_in(&dir).len(), 1, "the unfiltered scan reads it");
253        assert!(run(&excluding(&dir, "_docs/**"), &[]).unwrap().is_empty());
254    }
255
256    /// The records stay readable under a filter, because they are support
257    /// rather than subject. Excluding them would be an off switch.
258    #[test]
259    fn the_records_resolve_a_case_even_where_they_are_excluded() {
260        let dir = tree(&[("src/client.rs", "// KI-vendor-replays\n"), RECORD]);
261        let ctx = excluding(&dir, "_docs/reference/known-issues/**");
262        assert!(run(&ctx, &[]).unwrap().is_empty());
263    }
264
265    #[test]
266    fn a_token_reads_only_on_its_own_word_boundaries() {
267        assert_eq!(
268            cited_cases("names KI-vendor-500 here").collect::<Vec<_>>(),
269            vec!["KI-vendor-500".to_string()]
270        );
271        // A longer word ending in the token is not the token.
272        assert!(cited_cases("WIKI-vendor").next().is_none());
273        // A case id is a lowercase slug, so a token that runs on past one
274        // names no case. It is not reported, and it does not resolve as the
275        // shorter `KI-vendor` either.
276        assert_eq!(
277            cited_cases("KI-vendorXYZ").collect::<Vec<_>>(),
278            Vec::<String>::new()
279        );
280        // A bare prefix names no case.
281        assert!(cited_cases("KI- alone").next().is_none());
282    }
283
284    #[test]
285    fn a_binary_file_is_skipped() {
286        let dir = tempfile::tempdir().unwrap();
287        std::fs::write(dir.path().join("blob.bin"), b"KI-gone\0\0\0").unwrap();
288        assert!(run_in(&dir).is_empty());
289    }
290
291    /// One Latin-1 byte elsewhere in a text file must not hide an ASCII
292    /// citation. The scan is unconditional, and only a binary file is out.
293    #[test]
294    fn a_citation_survives_a_byte_this_process_cannot_decode() {
295        let dir = tempfile::tempdir().unwrap();
296        std::fs::write(dir.path().join("notes.txt"), b"\xe9\n// KI-gone\n").unwrap();
297        let out = run_in(&dir);
298        assert_eq!(out.len(), 1, "{out:?}");
299        assert!(out[0].contains("notes.txt:2"));
300    }
301}