Skip to main content

spec_driven_docs/gates/
suppression_names_its_case.rs

1//! Gate: every markdown suppression names the case that justifies it, and
2//! that case resolves to a record.
3//!
4//! A suppression with no case behind it becomes permanent by default: the
5//! next reader takes it for a design choice and nothing says what would
6//! retire it. Scoped to the HTML-comment form, which is the suppression a
7//! document can carry; prose about a suppression is content. Binary files
8//! and vendored trees are skipped, and the known-issues directory is exempt
9//! — a record may legitimately discuss suppressions.
10
11use std::collections::BTreeSet;
12
13use crate::domain::finding::Finding;
14use crate::domain::rule_id::RuleId;
15use crate::gates::paths::ki_records;
16use crate::gates::{GateCtx, GateError, GateResult, Violation, walk_files};
17
18/// The rules this gate can cite.
19pub const CITES: &[RuleId] = &[RuleId::SuppressionNamesItsCase];
20
21const RULE: RuleId = RuleId::SuppressionNamesItsCase;
22
23fn is_suppression(line: &str) -> bool {
24    let mut rest = line;
25    while let Some(index) = rest.find("<!--") {
26        let after = rest[index + 4..].trim_start_matches(' ');
27        if after.starts_with("dprint-ignore") || after.starts_with("markdownlint-disable") {
28            return true;
29        }
30        rest = &rest[index + 4..];
31    }
32    false
33}
34
35fn is_closing(line: &str) -> bool {
36    line.contains("dprint-ignore-end") || line.contains("markdownlint-enable")
37}
38
39fn cited_cases(line: &str) -> impl Iterator<Item = String> + '_ {
40    line.match_indices("KI-").filter_map(|(index, _)| {
41        let slug: String = line[index + 3..]
42            .chars()
43            .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
44            .collect();
45        (!slug.is_empty()).then(|| format!("KI-{slug}"))
46    })
47}
48
49fn looks_binary(bytes: &[u8]) -> bool {
50    bytes.iter().take(4096).any(|&b| b == 0)
51}
52
53/// Judge every suppression in the repository.
54///
55/// # Errors
56///
57/// [`GateError::Io`] when a candidate file cannot be read.
58pub fn run(ctx: &GateCtx, args: &[String]) -> GateResult {
59    let mut suppressions: Vec<(String, usize, String)> = Vec::new();
60    for file in walk_files(ctx) {
61        if file
62            .components()
63            .any(|part| part.as_str() == "known-issues")
64        {
65            continue;
66        }
67        let bytes =
68            std::fs::read(ctx.path(&file)).map_err(|source| GateError::io(file.clone(), source))?;
69        if looks_binary(&bytes) {
70            continue;
71        }
72        let Ok(text) = String::from_utf8(bytes) else {
73            continue;
74        };
75        for (number, line) in text.lines().enumerate() {
76            if is_suppression(line) && !is_closing(line) {
77                suppressions.push((
78                    file.as_str().trim_start_matches("./").to_string(),
79                    number + 1,
80                    line.to_string(),
81                ));
82            }
83        }
84    }
85
86    let mut violations = Vec::new();
87    let caseless: Vec<&(String, usize, String)> = suppressions
88        .iter()
89        .filter(|(_, _, line)| cited_cases(line).next().is_none())
90        .collect();
91    if !caseless.is_empty() {
92        violations.push(Violation::Finding(Finding::global(RULE, "")));
93        for (file, number, line) in caseless {
94            violations.push(Violation::Note(format!("./{file}:{number}:{line}")));
95        }
96    }
97
98    let known: BTreeSet<String> = ki_records(ctx, args)?
99        .iter()
100        .filter_map(|record| {
101            record
102                .file_name()
103                .map(|name| name.trim_end_matches(".md").to_string())
104        })
105        .collect();
106    let cited: BTreeSet<String> = suppressions
107        .iter()
108        .flat_map(|(_, _, line)| cited_cases(line))
109        .collect();
110    for case in cited {
111        if !known.contains(&case) {
112            violations.push(Violation::Finding(Finding::global(
113                RULE,
114                format!("{case} resolves to no record"),
115            )));
116        }
117    }
118    Ok(violations)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn fixture() -> tempfile::TempDir {
126        let dir = tempfile::tempdir().unwrap();
127        let records = dir.path().join("_docs/reference/known-issues");
128        std::fs::create_dir_all(&records).unwrap();
129        std::fs::write(records.join("KI-vendor-quirk.md"), "# Quirk\n").unwrap();
130        dir
131    }
132
133    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
134        let ctx = GateCtx::new(dir.path().to_str().unwrap());
135        run(&ctx, &[])
136            .unwrap()
137            .iter()
138            .map(ToString::to_string)
139            .collect()
140    }
141
142    #[test]
143    fn a_repository_without_suppressions_passes() {
144        assert!(run_in(&fixture()).is_empty());
145    }
146
147    #[test]
148    fn a_suppression_naming_a_record_passes() {
149        let dir = fixture();
150        std::fs::write(
151            dir.path().join("local.md"),
152            format!(
153                "<!-- markdownlint-{} MD013 KI-vendor-quirk -->\n",
154                "disable"
155            ),
156        )
157        .unwrap();
158        assert!(run_in(&dir).is_empty());
159    }
160
161    #[test]
162    fn a_caseless_suppression_is_rejected_with_its_line() {
163        let dir = fixture();
164        std::fs::write(
165            dir.path().join("local.md"),
166            format!("<!-- markdownlint-{} -->\n", "disable"),
167        )
168        .unwrap();
169        let out = run_in(&dir);
170        assert_eq!(out.len(), 2);
171        assert_eq!(out[0], "FAIL spec-to-code:a-suppression-names-its-case");
172        assert!(out[1].contains("local.md:1"));
173    }
174
175    #[test]
176    fn a_case_resolving_to_no_record_is_rejected() {
177        let dir = fixture();
178        std::fs::write(
179            dir.path().join("local.md"),
180            format!("<!-- markdownlint-{} KI-absent-record -->\n", "disable"),
181        )
182        .unwrap();
183        let out = run_in(&dir);
184        assert_eq!(
185            out,
186            vec![
187                "FAIL spec-to-code:a-suppression-names-its-case: KI-absent-record resolves to no record"
188                    .to_string()
189            ]
190        );
191    }
192
193    #[test]
194    fn closing_markers_are_not_suppressions() {
195        let dir = fixture();
196        std::fs::write(
197            dir.path().join("local.md"),
198            "<!-- dprint-ignore-end -->\n<!-- markdownlint-enable -->\n",
199        )
200        .unwrap();
201        assert!(run_in(&dir).is_empty());
202    }
203}