1pub mod paths;
11
12pub mod adr_cites_a_live_rule;
13pub mod adr_filename_shape;
14pub mod adr_word_cap;
15pub mod agents_digest_size;
16pub mod chapter_size_cap;
17pub mod comparison_dated_tables;
18pub mod comparison_escaped_pipes;
19pub mod comparison_legend;
20pub mod comparison_one_reference_per_cell;
21pub mod comparison_verdict_word;
22pub mod gate_message_cites_a_rule;
23pub mod instance_manifest;
24pub mod ki_bugzilla_report_width;
25pub mod ki_filename_shape;
26pub mod ki_mechanism_walkthrough;
27pub mod ki_report_body;
28pub mod ki_retire_when;
29pub mod no_personal_path;
30pub mod no_self_narration;
31pub mod prose_stays_unwrapped;
32pub mod spec_requirement_parts;
33pub mod spec_rule_id_unique;
34pub mod spec_size_cap;
35pub mod spec_verify_hooks_exist;
36pub mod suppression_names_its_case;
37
38use std::fmt;
39
40use camino::{Utf8Path, Utf8PathBuf};
41use thiserror::Error;
42
43use crate::domain::finding::Finding;
44use crate::domain::gate_id::GateId;
45use crate::domain::rule_id::RuleId;
46
47#[derive(Debug, Clone)]
49pub struct GateCtx {
50 pub repo_root: Utf8PathBuf,
52}
53
54impl GateCtx {
55 #[must_use]
57 pub fn new(repo_root: impl Into<Utf8PathBuf>) -> Self {
58 Self {
59 repo_root: repo_root.into(),
60 }
61 }
62
63 #[must_use]
65 pub fn path(&self, relative: impl AsRef<Utf8Path>) -> Utf8PathBuf {
66 self.repo_root.join(relative)
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum Violation {
73 Finding(Finding),
75 Layout(String),
78 Note(String),
80}
81
82impl fmt::Display for Violation {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 match self {
85 Self::Finding(finding) => finding.fmt(f),
86 Self::Layout(reason) => write!(f, "FAIL {reason}"),
87 Self::Note(text) => f.write_str(text),
88 }
89 }
90}
91
92#[derive(Debug, Error)]
94pub enum GateError {
95 #[error("{path}: {source}")]
97 Io {
98 path: Utf8PathBuf,
100 source: std::io::Error,
102 },
103}
104
105impl GateError {
106 pub(crate) fn io(path: impl Into<Utf8PathBuf>, source: std::io::Error) -> Self {
107 Self::Io {
108 path: path.into(),
109 source,
110 }
111 }
112}
113
114impl From<GateError> for crate::error::AppError {
115 fn from(error: GateError) -> Self {
116 match error {
117 GateError::Io { path, source } => {
118 let kind = source.kind();
119 Self::Io(std::io::Error::new(kind, format!("{path}: {source}")))
120 }
121 }
122 }
123}
124
125pub type GateResult = Result<Vec<Violation>, GateError>;
127
128pub type GateFn = fn(&GateCtx, &[String]) -> GateResult;
130
131#[derive(Debug)]
133pub struct GateSpec {
134 pub id: GateId,
136 pub name: &'static str,
138 pub files: Option<&'static str>,
140 pub types: Option<&'static str>,
142 pub exclude: Option<&'static str>,
144 pub always_run: bool,
146 pub cites: &'static [RuleId],
148 pub run: GateFn,
150}
151
152#[must_use]
154pub fn spec(id: GateId) -> &'static GateSpec {
155 let index = GateId::ALL.iter().position(|g| *g == id).unwrap_or(0);
156 &GATES[index]
157}
158
159pub static GATES: &[GateSpec] = &[
161 GateSpec {
162 id: GateId::AdrCitesALiveRule,
163 name: "decision record citations resolve",
164 files: None,
165 types: None,
166 exclude: None,
167 always_run: true,
168 cites: adr_cites_a_live_rule::CITES,
169 run: adr_cites_a_live_rule::run,
170 },
171 GateSpec {
172 id: GateId::AdrFilenameShape,
173 name: "decision record filename shape",
174 files: Some(r"^{docs_root}/decisions/.*\.md$"),
175 types: None,
176 exclude: None,
177 always_run: false,
178 cites: adr_filename_shape::CITES,
179 run: adr_filename_shape::run,
180 },
181 GateSpec {
182 id: GateId::AdrWordCap,
183 name: "decision record word cap",
184 files: None,
185 types: None,
186 exclude: None,
187 always_run: true,
188 cites: adr_word_cap::CITES,
189 run: adr_word_cap::run,
190 },
191 GateSpec {
192 id: GateId::AgentsDigestSize,
193 name: "agent digest size",
194 files: None,
195 types: None,
196 exclude: None,
197 always_run: true,
198 cites: agents_digest_size::CITES,
199 run: agents_digest_size::run,
200 },
201 GateSpec {
202 id: GateId::ChapterSizeCap,
203 name: "chapter and catalog size",
204 files: None,
205 types: None,
206 exclude: None,
207 always_run: true,
208 cites: chapter_size_cap::CITES,
209 run: chapter_size_cap::run,
210 },
211 GateSpec {
212 id: GateId::ComparisonDatedTables,
213 name: "comparison tables are dated",
214 files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
215 types: None,
216 exclude: None,
217 always_run: false,
218 cites: comparison_dated_tables::CITES,
219 run: comparison_dated_tables::run,
220 },
221 GateSpec {
222 id: GateId::ComparisonEscapedPipes,
223 name: "comparison table pipes are escaped",
224 files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
225 types: None,
226 exclude: None,
227 always_run: false,
228 cites: comparison_escaped_pipes::CITES,
229 run: comparison_escaped_pipes::run,
230 },
231 GateSpec {
232 id: GateId::ComparisonLegend,
233 name: "comparison legend",
234 files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
235 types: None,
236 exclude: None,
237 always_run: false,
238 cites: comparison_legend::CITES,
239 run: comparison_legend::run,
240 },
241 GateSpec {
242 id: GateId::ComparisonOneReferencePerCell,
243 name: "one reference per comparison cell",
244 files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
245 types: None,
246 exclude: None,
247 always_run: false,
248 cites: comparison_one_reference_per_cell::CITES,
249 run: comparison_one_reference_per_cell::run,
250 },
251 GateSpec {
252 id: GateId::ComparisonVerdictWord,
253 name: "comparison verdict word",
254 files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
255 types: None,
256 exclude: None,
257 always_run: false,
258 cites: comparison_verdict_word::CITES,
259 run: comparison_verdict_word::run,
260 },
261 GateSpec {
262 id: GateId::GateMessageCitesARule,
263 name: "gate messages cite a rule",
264 files: None,
265 types: None,
266 exclude: None,
267 always_run: true,
268 cites: gate_message_cites_a_rule::CITES,
269 run: gate_message_cites_a_rule::run,
270 },
271 GateSpec {
272 id: GateId::InstanceManifest,
273 name: "instance manifest",
274 files: None,
275 types: None,
276 exclude: None,
277 always_run: true,
278 cites: instance_manifest::CITES,
279 run: instance_manifest::run,
280 },
281 GateSpec {
282 id: GateId::KiBugzillaReportWidth,
283 name: "Bugzilla report width",
284 files: None,
285 types: None,
286 exclude: None,
287 always_run: true,
288 cites: ki_bugzilla_report_width::CITES,
289 run: ki_bugzilla_report_width::run,
290 },
291 GateSpec {
292 id: GateId::KiFilenameShape,
293 name: "known issue filename shape",
294 files: Some(r"^{docs_root}/reference/known-issues/.*\.md$"),
295 types: None,
296 exclude: None,
297 always_run: false,
298 cites: ki_filename_shape::CITES,
299 run: ki_filename_shape::run,
300 },
301 GateSpec {
302 id: GateId::KiMechanismWalkthrough,
303 name: "known issue mechanism walkthrough",
304 files: None,
305 types: None,
306 exclude: None,
307 always_run: true,
308 cites: ki_mechanism_walkthrough::CITES,
309 run: ki_mechanism_walkthrough::run,
310 },
311 GateSpec {
312 id: GateId::KiReportBody,
313 name: "known issue report body",
314 files: None,
315 types: None,
316 exclude: None,
317 always_run: true,
318 cites: ki_report_body::CITES,
319 run: ki_report_body::run,
320 },
321 GateSpec {
322 id: GateId::KiRetireWhen,
323 name: "known issue retirement condition",
324 files: None,
325 types: None,
326 exclude: None,
327 always_run: true,
328 cites: ki_retire_when::CITES,
329 run: ki_retire_when::run,
330 },
331 GateSpec {
332 id: GateId::NoPersonalPath,
333 name: "no personal path",
334 files: None,
335 types: Some("text"),
336 exclude: None,
337 always_run: false,
338 cites: no_personal_path::CITES,
339 run: no_personal_path::run,
340 },
341 GateSpec {
342 id: GateId::NoSelfNarration,
343 name: "documents state the present",
344 files: None,
345 types: Some("markdown"),
346 exclude: Some("^{docs_root}/decisions/"),
347 always_run: false,
348 cites: no_self_narration::CITES,
349 run: no_self_narration::run,
350 },
351 GateSpec {
352 id: GateId::ProseStaysUnwrapped,
353 name: "prose lines stay unwrapped",
354 files: None,
355 types: Some("markdown"),
356 exclude: Some(r"(?:^|/)CHANGELOG\.md$"),
357 always_run: false,
358 cites: prose_stays_unwrapped::CITES,
359 run: prose_stays_unwrapped::run,
360 },
361 GateSpec {
362 id: GateId::SpecRequirementParts,
363 name: "spec requirement parts",
364 files: Some(r"^{docs_root}/specs/SPEC-.*\.md$"),
365 types: None,
366 exclude: None,
367 always_run: false,
368 cites: spec_requirement_parts::CITES,
369 run: spec_requirement_parts::run,
370 },
371 GateSpec {
372 id: GateId::SpecRuleIdUnique,
373 name: "spec rule IDs are unique",
374 files: None,
375 types: None,
376 exclude: None,
377 always_run: true,
378 cites: spec_rule_id_unique::CITES,
379 run: spec_rule_id_unique::run,
380 },
381 GateSpec {
382 id: GateId::SpecSizeCap,
383 name: "spec size cap",
384 files: None,
385 types: None,
386 exclude: None,
387 always_run: true,
388 cites: spec_size_cap::CITES,
389 run: spec_size_cap::run,
390 },
391 GateSpec {
392 id: GateId::SpecVerifyHooksExist,
393 name: "spec hook references exist",
394 files: None,
395 types: None,
396 exclude: None,
397 always_run: true,
398 cites: spec_verify_hooks_exist::CITES,
399 run: spec_verify_hooks_exist::run,
400 },
401 GateSpec {
402 id: GateId::SuppressionNamesItsCase,
403 name: "suppressions name a known issue",
404 files: None,
405 types: None,
406 exclude: None,
407 always_run: true,
408 cites: suppression_names_its_case::CITES,
409 run: suppression_names_its_case::run,
410 },
411];
412
413pub const PRUNED_DIRS: &[&str] = &[".git", "node_modules", ".venv", "vendor", "target", "dist"];
416
417#[must_use]
419pub fn line_count(text: &str) -> usize {
420 text.matches('\n').count()
421}
422
423pub fn read_text(ctx: &GateCtx, relative: impl AsRef<Utf8Path>) -> Result<String, GateError> {
429 let relative = relative.as_ref();
430 std::fs::read_to_string(ctx.path(relative)).map_err(|source| GateError::io(relative, source))
431}
432
433#[must_use]
436pub fn walk_files(ctx: &GateCtx) -> Vec<Utf8PathBuf> {
437 let root = ctx.repo_root.as_std_path();
438 let mut files: Vec<Utf8PathBuf> = walkdir::WalkDir::new(root)
439 .into_iter()
440 .filter_entry(|entry| {
441 !(entry.file_type().is_dir()
442 && entry.depth() > 0
443 && entry
444 .file_name()
445 .to_str()
446 .is_some_and(|name| PRUNED_DIRS.contains(&name)))
447 })
448 .filter_map(Result::ok)
449 .filter(|entry| entry.file_type().is_file())
450 .filter_map(|entry| {
451 let relative = entry.path().strip_prefix(root).ok()?.to_str()?;
452 Some(Utf8PathBuf::from(format!("./{relative}")))
453 })
454 .collect();
455 files.sort();
456 files
457}
458
459#[cfg(test)]
460pub(crate) mod tests_support {
461 pub fn ki_fixture(retire_line: &str) -> tempfile::TempDir {
464 ki_record(&format!(
465 "---\nupstream: https://example.invalid/issues\n{retire_line}---\n# Vendor issue\n## How it works\nRun.\n"
466 ))
467 }
468
469 pub fn ki_fixture_body(body: &str) -> tempfile::TempDir {
472 ki_record(&format!(
473 "---\nupstream: https://example.invalid/issues\nretire_when: release >= 2.0\n---\n{body}"
474 ))
475 }
476
477 pub fn ki_fixture_upstream(upstream: &str, body: &str) -> tempfile::TempDir {
480 ki_record(&format!(
481 "---\nupstream: {upstream}\nretire_when: release >= 2.0\n---\n{body}"
482 ))
483 }
484
485 fn ki_record(text: &str) -> tempfile::TempDir {
486 let dir = tempfile::tempdir().unwrap();
487 let records = dir.path().join("_docs/reference/known-issues");
488 std::fs::create_dir_all(&records).unwrap();
489 std::fs::write(records.join("KI-vendor.md"), text).unwrap();
490 dir
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn registry_covers_every_gate_exactly_once_in_order() {
500 assert_eq!(GATES.len(), GateId::ALL.len());
501 for (row, id) in GATES.iter().zip(GateId::ALL) {
502 assert_eq!(row.id, *id);
503 assert_eq!(spec(*id).id, *id);
504 }
505 }
506
507 #[test]
508 fn every_gate_declares_the_rules_it_cites() {
509 for row in GATES {
510 assert!(!row.cites.is_empty(), "{} cites nothing", row.id);
511 }
512 }
513
514 #[test]
515 fn cited_rules_resolve_in_the_embedded_specs() {
516 let defined = crate::embedded::spec_rule_ids();
517 for row in GATES {
518 for rule in row.cites {
519 assert!(
520 defined.contains(rule.as_str()),
521 "{}: {rule} is undefined",
522 row.id
523 );
524 }
525 }
526 }
527}