spec_driven_docs/gates/
suppression_names_its_case.rs1use 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
45pub const CITES: &[RuleId] = &[RuleId::SuppressionNamesItsCase];
47
48const CASE: RuleId = RuleId::SuppressionNamesItsCase;
49
50fn looks_binary(bytes: &[u8]) -> bool {
52 bytes.iter().take(4096).any(|&b| b == 0)
53}
54
55const 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
64fn 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 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
86struct Citation {
88 case: String,
89 file: String,
90 number: usize,
91}
92
93fn 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 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
118pub 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 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 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 const RECORD: (&str, &str) = (
181 "_docs/reference/known-issues/KI-vendor-replays.md",
182 "# Vendor replays\n",
183 );
184
185 #[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 #[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 #[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 #[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 #[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 #[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 assert!(cited_cases("WIKI-vendor").next().is_none());
273 assert_eq!(
277 cited_cases("KI-vendorXYZ").collect::<Vec<_>>(),
278 Vec::<String>::new()
279 );
280 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 #[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}