spec_driven_docs/gates/
suppression_names_its_case.rs1use 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
18pub 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
53pub 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}