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_filing;
27pub mod ki_mechanism_walkthrough;
28pub mod ki_record;
29pub mod ki_report_body;
30pub mod ki_retire_when;
31pub mod ki_state;
32pub mod markdown_prose;
33pub mod no_personal_path;
34pub mod no_self_narration;
35pub mod prose_stays_unwrapped;
36pub mod simple_english;
37pub mod spec_requirement_parts;
38pub mod spec_rule_id_unique;
39pub mod spec_size_cap;
40pub mod spec_verify_hooks_exist;
41pub mod suppression_names_its_case;
42pub mod tracking_registry;
43
44use std::fmt;
45
46use camino::{Utf8Path, Utf8PathBuf};
47use thiserror::Error;
48
49use crate::domain::finding::Finding;
50use crate::domain::gate_id::GateId;
51use crate::domain::rule_id::RuleId;
52
53#[derive(Debug, Clone)]
55pub struct GateCtx {
56 pub repo_root: Utf8PathBuf,
58}
59
60impl GateCtx {
61 #[must_use]
63 pub fn new(repo_root: impl Into<Utf8PathBuf>) -> Self {
64 Self {
65 repo_root: repo_root.into(),
66 }
67 }
68
69 #[must_use]
71 pub fn path(&self, relative: impl AsRef<Utf8Path>) -> Utf8PathBuf {
72 self.repo_root.join(relative)
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum Violation {
79 Finding(Finding),
81 Layout(String),
84 Note(String),
86}
87
88impl fmt::Display for Violation {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 match self {
91 Self::Finding(finding) => finding.fmt(f),
92 Self::Layout(reason) => write!(f, "FAIL {reason}"),
93 Self::Note(text) => f.write_str(text),
94 }
95 }
96}
97
98#[derive(Debug, Error)]
100pub enum GateError {
101 #[error("{path}: {source}")]
103 Io {
104 path: Utf8PathBuf,
106 source: std::io::Error,
108 },
109}
110
111impl GateError {
112 pub(crate) fn io(path: impl Into<Utf8PathBuf>, source: std::io::Error) -> Self {
113 Self::Io {
114 path: path.into(),
115 source,
116 }
117 }
118}
119
120impl From<GateError> for crate::error::AppError {
121 fn from(error: GateError) -> Self {
122 match error {
123 GateError::Io { path, source } => {
124 let kind = source.kind();
125 Self::Io(std::io::Error::new(kind, format!("{path}: {source}")))
126 }
127 }
128 }
129}
130
131pub type GateResult = Result<Vec<Violation>, GateError>;
133
134pub type GateFn = fn(&GateCtx, &[String]) -> GateResult;
136
137#[derive(Debug)]
139pub struct GateSpec {
140 pub id: GateId,
142 pub name: &'static str,
144 pub files: Option<&'static str>,
146 pub types: Option<&'static str>,
148 pub exclude: Option<&'static str>,
150 pub always_run: bool,
152 pub cites: &'static [RuleId],
154 pub run: GateFn,
156}
157
158#[must_use]
160pub fn spec(id: GateId) -> &'static GateSpec {
161 let index = GateId::ALL.iter().position(|g| *g == id).unwrap_or(0);
162 &GATES[index]
163}
164
165pub static GATES: &[GateSpec] = &[
167 GateSpec {
168 id: GateId::AdrCitesALiveRule,
169 name: "decision record citations resolve",
170 files: None,
171 types: None,
172 exclude: None,
173 always_run: true,
174 cites: adr_cites_a_live_rule::CITES,
175 run: adr_cites_a_live_rule::run,
176 },
177 GateSpec {
178 id: GateId::AdrFilenameShape,
179 name: "decision record filename shape",
180 files: Some(r"^{docs_root}/decisions/.*\.md$"),
181 types: None,
182 exclude: None,
183 always_run: false,
184 cites: adr_filename_shape::CITES,
185 run: adr_filename_shape::run,
186 },
187 GateSpec {
188 id: GateId::AdrWordCap,
189 name: "decision record word cap",
190 files: None,
191 types: None,
192 exclude: None,
193 always_run: true,
194 cites: adr_word_cap::CITES,
195 run: adr_word_cap::run,
196 },
197 GateSpec {
198 id: GateId::AgentsDigestSize,
199 name: "agent digest size",
200 files: None,
201 types: None,
202 exclude: None,
203 always_run: true,
204 cites: agents_digest_size::CITES,
205 run: agents_digest_size::run,
206 },
207 GateSpec {
208 id: GateId::ChapterSizeCap,
209 name: "chapter and catalog size",
210 files: None,
211 types: None,
212 exclude: None,
213 always_run: true,
214 cites: chapter_size_cap::CITES,
215 run: chapter_size_cap::run,
216 },
217 GateSpec {
218 id: GateId::ComparisonDatedTables,
219 name: "comparison tables are dated",
220 files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
221 types: None,
222 exclude: None,
223 always_run: false,
224 cites: comparison_dated_tables::CITES,
225 run: comparison_dated_tables::run,
226 },
227 GateSpec {
228 id: GateId::ComparisonEscapedPipes,
229 name: "comparison table pipes are escaped",
230 files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
231 types: None,
232 exclude: None,
233 always_run: false,
234 cites: comparison_escaped_pipes::CITES,
235 run: comparison_escaped_pipes::run,
236 },
237 GateSpec {
238 id: GateId::ComparisonLegend,
239 name: "comparison legend",
240 files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
241 types: None,
242 exclude: None,
243 always_run: false,
244 cites: comparison_legend::CITES,
245 run: comparison_legend::run,
246 },
247 GateSpec {
248 id: GateId::ComparisonOneReferencePerCell,
249 name: "one reference per comparison cell",
250 files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
251 types: None,
252 exclude: None,
253 always_run: false,
254 cites: comparison_one_reference_per_cell::CITES,
255 run: comparison_one_reference_per_cell::run,
256 },
257 GateSpec {
258 id: GateId::ComparisonVerdictWord,
259 name: "comparison verdict word",
260 files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
261 types: None,
262 exclude: None,
263 always_run: false,
264 cites: comparison_verdict_word::CITES,
265 run: comparison_verdict_word::run,
266 },
267 GateSpec {
268 id: GateId::GateMessageCitesARule,
269 name: "gate messages cite a rule",
270 files: None,
271 types: None,
272 exclude: None,
273 always_run: true,
274 cites: gate_message_cites_a_rule::CITES,
275 run: gate_message_cites_a_rule::run,
276 },
277 GateSpec {
278 id: GateId::InstanceManifest,
279 name: "instance manifest",
280 files: None,
281 types: None,
282 exclude: None,
283 always_run: true,
284 cites: instance_manifest::CITES,
285 run: instance_manifest::run,
286 },
287 GateSpec {
288 id: GateId::KiBugzillaReportWidth,
289 name: "Bugzilla report width",
290 files: None,
291 types: None,
292 exclude: None,
293 always_run: true,
294 cites: ki_bugzilla_report_width::CITES,
295 run: ki_bugzilla_report_width::run,
296 },
297 GateSpec {
298 id: GateId::KiFilenameShape,
299 name: "known issue filename shape",
300 files: Some(r"^{docs_root}/reference/known-issues/.*\.md$"),
301 types: None,
302 exclude: None,
303 always_run: false,
304 cites: ki_filename_shape::CITES,
305 run: ki_filename_shape::run,
306 },
307 GateSpec {
308 id: GateId::KiFiling,
309 name: "known issue filing state",
310 files: None,
311 types: None,
312 exclude: None,
313 always_run: true,
314 cites: ki_filing::CITES,
315 run: ki_filing::run,
316 },
317 GateSpec {
318 id: GateId::KiMechanismWalkthrough,
319 name: "known issue mechanism walkthrough",
320 files: None,
321 types: None,
322 exclude: None,
323 always_run: true,
324 cites: ki_mechanism_walkthrough::CITES,
325 run: ki_mechanism_walkthrough::run,
326 },
327 GateSpec {
328 id: GateId::KiReportBody,
329 name: "known issue report body",
330 files: None,
331 types: None,
332 exclude: None,
333 always_run: true,
334 cites: ki_report_body::CITES,
335 run: ki_report_body::run,
336 },
337 GateSpec {
338 id: GateId::KiRetireWhen,
339 name: "known issue retirement condition",
340 files: None,
341 types: None,
342 exclude: None,
343 always_run: true,
344 cites: ki_retire_when::CITES,
345 run: ki_retire_when::run,
346 },
347 GateSpec {
348 id: GateId::KiState,
349 name: "known issue state",
350 files: None,
351 types: None,
352 exclude: None,
353 always_run: true,
354 cites: ki_state::CITES,
355 run: ki_state::run,
356 },
357 GateSpec {
358 id: GateId::NoPersonalPath,
359 name: "no personal path",
360 files: None,
361 types: Some("text"),
362 exclude: None,
363 always_run: false,
364 cites: no_personal_path::CITES,
365 run: no_personal_path::run,
366 },
367 GateSpec {
368 id: GateId::NoSelfNarration,
369 name: "documents state the present",
370 files: None,
371 types: Some("markdown"),
372 exclude: Some("^{docs_root}/decisions/"),
373 always_run: false,
374 cites: no_self_narration::CITES,
375 run: no_self_narration::run,
376 },
377 GateSpec {
378 id: GateId::ProseStaysUnwrapped,
379 name: "prose lines stay unwrapped",
380 files: None,
381 types: Some("markdown"),
382 exclude: Some(r"(?:^|/)CHANGELOG\.md$"),
383 always_run: false,
384 cites: prose_stays_unwrapped::CITES,
385 run: prose_stays_unwrapped::run,
386 },
387 GateSpec {
388 id: GateId::SpecRequirementParts,
389 name: "spec requirement parts",
390 files: Some(r"^{docs_root}/specs/SPEC-.*\.md$"),
391 types: None,
392 exclude: None,
393 always_run: false,
394 cites: spec_requirement_parts::CITES,
395 run: spec_requirement_parts::run,
396 },
397 GateSpec {
398 id: GateId::SpecRuleIdUnique,
399 name: "spec rule IDs are unique",
400 files: None,
401 types: None,
402 exclude: None,
403 always_run: true,
404 cites: spec_rule_id_unique::CITES,
405 run: spec_rule_id_unique::run,
406 },
407 GateSpec {
408 id: GateId::SpecSizeCap,
409 name: "spec size cap",
410 files: None,
411 types: None,
412 exclude: None,
413 always_run: true,
414 cites: spec_size_cap::CITES,
415 run: spec_size_cap::run,
416 },
417 GateSpec {
418 id: GateId::SpecVerifyHooksExist,
419 name: "spec hook references exist",
420 files: None,
421 types: None,
422 exclude: None,
423 always_run: true,
424 cites: spec_verify_hooks_exist::CITES,
425 run: spec_verify_hooks_exist::run,
426 },
427 GateSpec {
428 id: GateId::SuppressionNamesItsCase,
429 name: "suppressions name a known issue",
430 files: None,
431 types: None,
432 exclude: None,
433 always_run: true,
434 cites: suppression_names_its_case::CITES,
435 run: suppression_names_its_case::run,
436 },
437 GateSpec {
438 id: GateId::SimpleEnglish,
439 name: "prose follows SimpleEnglish",
440 files: None,
441 types: Some("markdown"),
442 exclude: Some(
443 r"^_docs/decisions/|^docs/decisions/|(?:^|/)CHANGELOG\.md$|^third-party/|^\.spec-driven-docs/|(?:^|/)tests/fixtures/",
444 ),
445 always_run: false,
446 cites: simple_english::CITES,
447 run: simple_english::run,
448 },
449 GateSpec {
450 id: GateId::TrackingRegistry,
451 name: "tracking registry is valid and current",
452 files: None,
453 types: None,
454 exclude: None,
455 always_run: true,
456 cites: tracking_registry::CITES,
457 run: tracking_registry::run,
458 },
459];
460
461pub const PRUNED_DIRS: &[&str] = &[".git", "node_modules", ".venv", "vendor", "target", "dist"];
464
465#[must_use]
467pub fn line_count(text: &str) -> usize {
468 text.matches('\n').count()
469}
470
471pub fn read_text(ctx: &GateCtx, relative: impl AsRef<Utf8Path>) -> Result<String, GateError> {
477 let relative = relative.as_ref();
478 std::fs::read_to_string(ctx.path(relative)).map_err(|source| GateError::io(relative, source))
479}
480
481#[must_use]
488pub fn front_matter_values(text: &str, key: &str) -> Vec<String> {
489 let mut lines = text.lines();
490 if lines.next() != Some("---") {
491 return Vec::new();
492 }
493 lines
494 .take_while(|line| *line != "---")
495 .filter_map(|line| {
496 line.strip_prefix(key)
497 .and_then(|rest| rest.strip_prefix(':'))
498 })
499 .map(|value| value.trim().to_string())
500 .collect()
501}
502
503#[must_use]
506pub fn walk_files(ctx: &GateCtx) -> Vec<Utf8PathBuf> {
507 let root = ctx.repo_root.as_std_path();
508 let mut files: Vec<Utf8PathBuf> = walkdir::WalkDir::new(root)
509 .into_iter()
510 .filter_entry(|entry| {
511 !(entry.file_type().is_dir()
512 && entry.depth() > 0
513 && entry
514 .file_name()
515 .to_str()
516 .is_some_and(|name| PRUNED_DIRS.contains(&name)))
517 })
518 .filter_map(Result::ok)
519 .filter(|entry| entry.file_type().is_file())
520 .filter_map(|entry| {
521 let relative = entry.path().strip_prefix(root).ok()?.to_str()?;
522 Some(Utf8PathBuf::from(format!("./{relative}")))
523 })
524 .collect();
525 files.sort();
526 files
527}
528
529#[cfg(test)]
530pub(crate) mod tests_support {
531 pub fn ki_fixture_state(state: &str, retire_line: &str) -> tempfile::TempDir {
534 ki_record(&format!(
535 "---\nupstream: https://example.invalid/issues\nstate: {state}\nfiling: gathering\n{retire_line}---\n# Vendor issue\n## How it works\nRun.\n"
536 ))
537 }
538
539 pub fn ki_fixture_body(body: &str) -> tempfile::TempDir {
542 ki_record(&format!(
543 "---\nupstream: https://example.invalid/issues\nstate: masked\nfiling: gathering\nretire_when: release >= 2.0\n---\n{body}"
544 ))
545 }
546
547 pub fn ki_fixture_upstream(upstream: &str, body: &str) -> tempfile::TempDir {
550 ki_fixture_filing("filed", upstream, body)
551 }
552
553 pub fn ki_fixture_filing(filing: &str, upstream: &str, body: &str) -> tempfile::TempDir {
556 ki_record(&format!(
557 "---\nupstream: {upstream}\nstate: masked\nfiling: {filing}\nretire_when: release >= 2.0\n---\n{body}"
558 ))
559 }
560
561 fn ki_record(text: &str) -> tempfile::TempDir {
562 let dir = tempfile::tempdir().unwrap();
563 let records = dir.path().join("_docs/reference/known-issues");
564 std::fs::create_dir_all(&records).unwrap();
565 std::fs::write(records.join("KI-vendor.md"), text).unwrap();
566 dir
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 #[test]
575 fn registry_covers_every_gate_exactly_once_in_order() {
576 assert_eq!(GATES.len(), GateId::ALL.len());
577 for (row, id) in GATES.iter().zip(GateId::ALL) {
578 assert_eq!(row.id, *id);
579 assert_eq!(spec(*id).id, *id);
580 }
581 }
582
583 #[test]
584 fn every_gate_declares_the_rules_it_cites() {
585 for row in GATES {
586 assert!(!row.cites.is_empty(), "{} cites nothing", row.id);
587 }
588 }
589
590 #[test]
591 fn cited_rules_resolve_in_the_embedded_specs() {
592 let defined = crate::embedded::spec_rule_ids();
593 for row in GATES {
594 for rule in row.cites {
595 assert!(
596 defined.contains(rule.as_str()),
597 "{}: {rule} is undefined",
598 row.id
599 );
600 }
601 }
602 }
603}