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 budget;
17pub mod chapter_size_cap;
18pub mod comparison_dated_tables;
19pub mod comparison_escaped_pipes;
20pub mod comparison_legend;
21pub mod comparison_one_reference_per_cell;
22pub mod comparison_verdict_word;
23pub mod gate_message_cites_a_rule;
24pub mod instance_manifest;
25pub mod ki_bugzilla_report_width;
26pub mod ki_checked_date;
27pub mod ki_filename_shape;
28pub mod ki_filing;
29pub mod ki_mechanism_walkthrough;
30pub mod ki_record;
31pub mod ki_report_body;
32pub mod ki_retire_when;
33pub mod ki_state;
34pub mod markdown_prose;
35pub mod no_personal_path;
36pub mod no_self_narration;
37pub mod prose_stays_unwrapped;
38pub mod spec_requirement_parts;
39pub mod spec_rule_id_unique;
40pub mod spec_size_cap;
41pub mod spec_verify_hooks_exist;
42pub mod suppression_names_its_case;
43pub mod tracking_registry;
44
45use std::fmt;
46
47use camino::{Utf8Path, Utf8PathBuf};
48use thiserror::Error;
49
50use crate::domain::finding::Finding;
51use crate::domain::gate_id::GateId;
52use crate::domain::path_filter::PathFilter;
53use crate::domain::rule_id::RuleId;
54
55#[derive(Debug)]
78pub struct GateCtx {
79 pub repo_root: Utf8PathBuf,
81 filter: PathFilter,
84}
85
86impl GateCtx {
87 #[must_use]
92 pub fn new(repo_root: impl Into<Utf8PathBuf>) -> Self {
93 Self {
94 repo_root: repo_root.into(),
95 filter: PathFilter::permissive(),
96 }
97 }
98
99 #[must_use]
101 pub fn with_filter(repo_root: impl Into<Utf8PathBuf>, filter: PathFilter) -> Self {
102 Self {
103 repo_root: repo_root.into(),
104 filter,
105 }
106 }
107
108 #[must_use]
112 pub fn path(&self, relative: impl AsRef<Utf8Path>) -> Utf8PathBuf {
113 self.repo_root.join(relative)
114 }
115
116 fn relative(&self, path: &Utf8Path) -> Utf8PathBuf {
122 crate::domain::path_filter::project(path, &self.repo_root)
123 }
124
125 #[must_use]
130 pub fn subjects<P: AsRef<Utf8Path>>(&self, candidates: impl IntoIterator<Item = P>) -> Vec<P> {
131 candidates
132 .into_iter()
133 .filter(|path| self.filter.judges(&self.relative(path.as_ref())))
134 .collect()
135 }
136
137 #[must_use]
142 pub fn retained<P: AsRef<Utf8Path>>(&self, candidates: impl IntoIterator<Item = P>) -> Vec<P> {
143 candidates
144 .into_iter()
145 .filter(|path| self.filter.retains(&self.relative(path.as_ref())))
146 .collect()
147 }
148
149 #[must_use]
151 pub const fn filter(&self) -> &PathFilter {
152 &self.filter
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum Violation {
159 Finding(Finding),
161 Layout(String),
164 Note(String),
166}
167
168impl fmt::Display for Violation {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 match self {
171 Self::Finding(finding) => finding.fmt(f),
172 Self::Layout(reason) => write!(f, "FAIL {reason}"),
173 Self::Note(text) => f.write_str(text),
174 }
175 }
176}
177
178#[derive(Debug, Error)]
180pub enum GateError {
181 #[error("{path}: {source}")]
183 Io {
184 path: Utf8PathBuf,
186 source: std::io::Error,
188 },
189 #[error("{0}")]
192 Debt(crate::domain::debt::DebtError),
193}
194
195impl GateError {
196 pub(crate) fn io(path: impl Into<Utf8PathBuf>, source: std::io::Error) -> Self {
197 Self::Io {
198 path: path.into(),
199 source,
200 }
201 }
202}
203
204impl From<GateError> for crate::error::AppError {
205 fn from(error: GateError) -> Self {
206 match error {
207 GateError::Io { path, source } => {
208 let kind = source.kind();
209 Self::Io(std::io::Error::new(kind, format!("{path}: {source}")))
210 }
211 GateError::Debt(error) => Self::Debt(error),
212 }
213 }
214}
215
216pub type GateResult = Result<Vec<Violation>, GateError>;
218
219pub type GateFn = fn(&GateCtx, &[String]) -> GateResult;
221
222#[derive(Debug)]
224pub struct GateSpec {
225 pub id: GateId,
227 pub name: &'static str,
229 pub include: &'static [&'static str],
237 pub types: Option<&'static str>,
243 pub exclude: &'static [&'static str],
246 pub always_run: bool,
248 pub discovers: bool,
258 pub cites: &'static [RuleId],
260 pub run: GateFn,
262}
263
264#[must_use]
266pub fn spec(id: GateId) -> &'static GateSpec {
267 let index = GateId::ALL.iter().position(|g| *g == id).unwrap_or(0);
268 &GATES[index]
269}
270
271pub static GATES: &[GateSpec] = &[
273 GateSpec {
274 id: GateId::AdrCitesALiveRule,
275 name: "decision record citations resolve",
276 include: &[r"{docs_root}/decisions/*.md"],
277 types: None,
278 exclude: &[],
279 always_run: true,
280 discovers: true,
281 cites: adr_cites_a_live_rule::CITES,
282 run: adr_cites_a_live_rule::run,
283 },
284 GateSpec {
285 id: GateId::AdrFilenameShape,
286 name: "decision record filename shape",
287 include: &[r"{docs_root}/decisions/*.md"],
288 types: None,
289 exclude: &[],
290 always_run: false,
291 discovers: false,
292 cites: adr_filename_shape::CITES,
293 run: adr_filename_shape::run,
294 },
295 GateSpec {
296 id: GateId::AdrWordCap,
297 name: "decision record word cap",
298 include: &[r"{docs_root}/decisions/*.md"],
299 types: None,
300 exclude: &[],
301 always_run: true,
302 discovers: true,
303 cites: adr_word_cap::CITES,
304 run: adr_word_cap::run,
305 },
306 GateSpec {
307 id: GateId::AgentsDigestSize,
308 name: "agent digest size",
309 include: &[r"**/AGENTS.md"],
310 types: None,
311 exclude: &[],
312 always_run: true,
313 discovers: false,
314 cites: agents_digest_size::CITES,
315 run: agents_digest_size::run,
316 },
317 GateSpec {
318 id: GateId::ChapterSizeCap,
319 name: "chapter and catalog size",
320 include: &[r"**/*.md"],
321 types: None,
322 exclude: &[],
323 always_run: true,
324 discovers: false,
325 cites: chapter_size_cap::CITES,
326 run: chapter_size_cap::run,
327 },
328 GateSpec {
329 id: GateId::ComparisonDatedTables,
330 name: "comparison tables are dated",
331 include: &[r"**/COMPARISON-*.md"],
332 types: None,
333 exclude: &[],
334 always_run: false,
335 discovers: false,
336 cites: comparison_dated_tables::CITES,
337 run: comparison_dated_tables::run,
338 },
339 GateSpec {
340 id: GateId::ComparisonEscapedPipes,
341 name: "comparison table pipes are escaped",
342 include: &[r"**/COMPARISON-*.md"],
343 types: None,
344 exclude: &[],
345 always_run: false,
346 discovers: false,
347 cites: comparison_escaped_pipes::CITES,
348 run: comparison_escaped_pipes::run,
349 },
350 GateSpec {
351 id: GateId::ComparisonLegend,
352 name: "comparison legend",
353 include: &[r"**/COMPARISON-*.md"],
354 types: None,
355 exclude: &[],
356 always_run: false,
357 discovers: false,
358 cites: comparison_legend::CITES,
359 run: comparison_legend::run,
360 },
361 GateSpec {
362 id: GateId::ComparisonOneReferencePerCell,
363 name: "one reference per comparison cell",
364 include: &[r"**/COMPARISON-*.md"],
365 types: None,
366 exclude: &[],
367 always_run: false,
368 discovers: false,
369 cites: comparison_one_reference_per_cell::CITES,
370 run: comparison_one_reference_per_cell::run,
371 },
372 GateSpec {
373 id: GateId::ComparisonVerdictWord,
374 name: "comparison verdict word",
375 include: &[r"**/COMPARISON-*.md"],
376 types: None,
377 exclude: &[],
378 always_run: false,
379 discovers: false,
380 cites: comparison_verdict_word::CITES,
381 run: comparison_verdict_word::run,
382 },
383 GateSpec {
384 id: GateId::GateMessageCitesARule,
385 name: "gate messages cite a rule",
386 include: &[],
390 types: None,
391 exclude: &[],
392 always_run: true,
393 discovers: false,
394 cites: gate_message_cites_a_rule::CITES,
395 run: gate_message_cites_a_rule::run,
396 },
397 GateSpec {
398 id: GateId::InstanceManifest,
399 name: "instance manifest",
400 include: &[crate::domain::paths::MANIFEST_PATH],
401 types: None,
402 exclude: &[],
403 always_run: true,
404 discovers: true,
405 cites: instance_manifest::CITES,
406 run: instance_manifest::run,
407 },
408 GateSpec {
409 id: GateId::KiBugzillaReportWidth,
410 name: "Bugzilla report width",
411 include: &[r"{docs_root}/reference/known-issues/*.md"],
412 types: None,
413 exclude: &[],
414 always_run: true,
415 discovers: true,
416 cites: ki_bugzilla_report_width::CITES,
417 run: ki_bugzilla_report_width::run,
418 },
419 GateSpec {
420 id: GateId::KiCheckedDate,
421 name: "known issue last-check date",
422 include: &[r"{docs_root}/reference/known-issues/*.md"],
423 types: None,
424 exclude: &[],
425 always_run: true,
426 discovers: true,
427 cites: ki_checked_date::CITES,
428 run: ki_checked_date::run,
429 },
430 GateSpec {
431 id: GateId::KiFilenameShape,
432 name: "known issue filename shape",
433 include: &[r"{docs_root}/reference/known-issues/*.md"],
434 types: None,
435 exclude: &[],
436 always_run: false,
437 discovers: false,
438 cites: ki_filename_shape::CITES,
439 run: ki_filename_shape::run,
440 },
441 GateSpec {
442 id: GateId::KiFiling,
443 name: "known issue filing state",
444 include: &[r"{docs_root}/reference/known-issues/*.md"],
445 types: None,
446 exclude: &[],
447 always_run: true,
448 discovers: true,
449 cites: ki_filing::CITES,
450 run: ki_filing::run,
451 },
452 GateSpec {
453 id: GateId::KiMechanismWalkthrough,
454 name: "known issue mechanism walkthrough",
455 include: &[r"{docs_root}/reference/known-issues/*.md"],
456 types: None,
457 exclude: &[],
458 always_run: true,
459 discovers: true,
460 cites: ki_mechanism_walkthrough::CITES,
461 run: ki_mechanism_walkthrough::run,
462 },
463 GateSpec {
464 id: GateId::KiReportBody,
465 name: "known issue report body",
466 include: &[r"{docs_root}/reference/known-issues/*.md"],
467 types: None,
468 exclude: &[],
469 always_run: true,
470 discovers: true,
471 cites: ki_report_body::CITES,
472 run: ki_report_body::run,
473 },
474 GateSpec {
475 id: GateId::KiRetireWhen,
476 name: "known issue retirement condition",
477 include: &[r"{docs_root}/reference/known-issues/*.md"],
478 types: None,
479 exclude: &[],
480 always_run: true,
481 discovers: true,
482 cites: ki_retire_when::CITES,
483 run: ki_retire_when::run,
484 },
485 GateSpec {
486 id: GateId::KiState,
487 name: "known issue state",
488 include: &[r"{docs_root}/reference/known-issues/*.md"],
489 types: None,
490 exclude: &[],
491 always_run: true,
492 discovers: true,
493 cites: ki_state::CITES,
494 run: ki_state::run,
495 },
496 GateSpec {
497 id: GateId::NoPersonalPath,
498 name: "no personal path",
499 include: &[],
508 types: Some("text"),
509 exclude: &[],
510 always_run: false,
511 discovers: false,
512 cites: no_personal_path::CITES,
513 run: no_personal_path::run,
514 },
515 GateSpec {
516 id: GateId::NoSelfNarration,
517 name: "documents state the present",
518 include: &[r"{docs_root}/{specs,decisions,guides,reference,explanation}/**/*.md"],
519 types: Some("markdown"),
520 exclude: &[r"{docs_root}/decisions/**"],
521 always_run: false,
522 discovers: false,
523 cites: no_self_narration::CITES,
524 run: no_self_narration::run,
525 },
526 GateSpec {
527 id: GateId::ProseStaysUnwrapped,
528 name: "prose lines stay unwrapped",
529 include: &[r"{docs_root}/{specs,decisions,guides,reference,explanation}/**/*.md"],
530 types: Some("markdown"),
531 exclude: &[r"**/CHANGELOG.md"],
532 always_run: false,
533 discovers: false,
534 cites: prose_stays_unwrapped::CITES,
535 run: prose_stays_unwrapped::run,
536 },
537 GateSpec {
538 id: GateId::SpecRequirementParts,
539 name: "spec requirement parts",
540 include: &[r"{docs_root}/specs/SPEC-*.md"],
541 types: None,
542 exclude: &[],
543 always_run: false,
544 discovers: false,
545 cites: spec_requirement_parts::CITES,
546 run: spec_requirement_parts::run,
547 },
548 GateSpec {
549 id: GateId::SpecRuleIdUnique,
550 name: "spec rule IDs are unique",
551 include: &[r"{docs_root}/specs/SPEC-*.md"],
552 types: None,
553 exclude: &[],
554 always_run: true,
555 discovers: true,
556 cites: spec_rule_id_unique::CITES,
557 run: spec_rule_id_unique::run,
558 },
559 GateSpec {
560 id: GateId::SpecSizeCap,
561 name: "spec size cap",
562 include: &[r"{docs_root}/specs/SPEC-*.md"],
563 types: None,
564 exclude: &[],
565 always_run: true,
566 discovers: true,
567 cites: spec_size_cap::CITES,
568 run: spec_size_cap::run,
569 },
570 GateSpec {
571 id: GateId::SpecVerifyHooksExist,
572 name: "spec hook references exist",
573 include: &[r"{docs_root}/specs/SPEC-*.md"],
574 types: None,
575 exclude: &[],
576 always_run: true,
577 discovers: true,
578 cites: spec_verify_hooks_exist::CITES,
579 run: spec_verify_hooks_exist::run,
580 },
581 GateSpec {
582 id: GateId::SuppressionNamesItsCase,
583 name: "suppressions name a known issue",
584 include: &[],
590 types: None,
591 exclude: &[r"{docs_root}/**"],
592 always_run: true,
593 discovers: false,
594 cites: suppression_names_its_case::CITES,
595 run: suppression_names_its_case::run,
596 },
597 GateSpec {
598 id: GateId::TrackingRegistry,
599 name: "tracking registry is valid and current",
600 include: &[r"{docs_root}/reference/tracking.yaml"],
601 types: None,
602 exclude: &[],
603 always_run: true,
604 discovers: true,
605 cites: tracking_registry::CITES,
606 run: tracking_registry::run,
607 },
608];
609
610pub const PRUNED_DIRS: &[&str] = &[
613 ".git",
614 "node_modules",
615 ".venv",
616 "vendor",
617 "third-party",
618 "target",
619 "dist",
620];
621
622#[must_use]
624pub fn line_count(text: &str) -> usize {
625 text.matches('\n').count()
626}
627
628pub fn read_text(ctx: &GateCtx, relative: impl AsRef<Utf8Path>) -> Result<String, GateError> {
634 let relative = relative.as_ref();
635 std::fs::read_to_string(ctx.path(relative)).map_err(|source| GateError::io(relative, source))
636}
637
638#[must_use]
645pub fn front_matter_values(text: &str, key: &str) -> Vec<String> {
646 let mut lines = text.lines();
647 if lines.next() != Some("---") {
648 return Vec::new();
649 }
650 lines
651 .take_while(|line| *line != "---")
652 .filter_map(|line| {
653 line.strip_prefix(key)
654 .and_then(|rest| rest.strip_prefix(':'))
655 })
656 .map(|value| value.trim().to_string())
657 .collect()
658}
659
660fn pruner(root: &Utf8Path) -> ignore::overrides::Override {
666 let mut builder = ignore::overrides::OverrideBuilder::new(root.as_std_path());
667 for dir in PRUNED_DIRS {
668 let _ = builder.add(&format!("!{dir}/**"));
671 let _ = builder.add(&format!("!{dir}"));
672 }
673 builder
674 .build()
675 .unwrap_or_else(|_| ignore::overrides::Override::empty())
676}
677
678#[must_use]
687pub fn walk_files(ctx: &GateCtx) -> Vec<Utf8PathBuf> {
688 let root = ctx.repo_root.as_std_path();
689 let mut files: Vec<Utf8PathBuf> = ignore::WalkBuilder::new(root)
690 .standard_filters(false)
691 .git_ignore(true)
692 .git_exclude(false)
693 .git_global(false)
694 .ignore(false)
695 .parents(false)
696 .require_git(false)
697 .hidden(false)
698 .overrides(pruner(&ctx.repo_root))
699 .build()
700 .filter_map(Result::ok)
701 .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
702 .filter_map(|entry| {
703 let relative = entry.path().strip_prefix(root).ok()?.to_str()?;
704 Some(Utf8PathBuf::from(format!("./{relative}")))
705 })
706 .collect();
707 files.sort();
708 ctx.subjects(files)
709}
710
711#[cfg(test)]
712pub(crate) mod tests_support {
713 pub fn ki_fixture_state(state: &str, retire_line: &str) -> tempfile::TempDir {
716 ki_record(&format!(
717 "---\nupstream: https://example.invalid/issues\nstate: {state}\nfiling: gathering\n{retire_line}---\n# Vendor issue\n## How it works\nRun.\n"
718 ))
719 }
720
721 pub fn ki_fixture_checked(state: &str, checked_line: &str) -> tempfile::TempDir {
724 ki_record(&format!(
725 "---\nupstream: https://example.invalid/issues\nstate: {state}\nfiling: gathering\nretire_when: release >= 2.0\n{checked_line}---\n# Vendor issue\n## How it works\nRun.\n"
726 ))
727 }
728
729 pub fn ki_fixture_body(body: &str) -> tempfile::TempDir {
732 ki_record(&format!(
733 "---\nupstream: https://example.invalid/issues\nstate: masked\nfiling: gathering\nretire_when: release >= 2.0\n---\n{body}"
734 ))
735 }
736
737 pub fn ki_fixture_upstream(upstream: &str, body: &str) -> tempfile::TempDir {
740 ki_fixture_filing("filed", upstream, body)
741 }
742
743 pub fn ki_fixture_filing(filing: &str, upstream: &str, body: &str) -> tempfile::TempDir {
746 ki_record(&format!(
747 "---\nupstream: {upstream}\nstate: masked\nfiling: {filing}\nretire_when: release >= 2.0\n---\n{body}"
748 ))
749 }
750
751 fn ki_record(text: &str) -> tempfile::TempDir {
752 let dir = tempfile::tempdir().unwrap();
753 let records = dir.path().join("_docs/reference/known-issues");
754 std::fs::create_dir_all(&records).unwrap();
755 std::fs::write(records.join("KI-vendor.md"), text).unwrap();
756 dir
757 }
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763
764 fn tree(paths: &[(&str, &str)]) -> tempfile::TempDir {
766 let dir = tempfile::tempdir().expect("a scratch directory");
767 for (path, body) in paths {
768 let full = dir.path().join(path);
769 if let Some(parent) = full.parent() {
770 std::fs::create_dir_all(parent).expect("the parent exists");
771 }
772 std::fs::write(&full, body).expect("the file is written");
773 }
774 dir
775 }
776
777 fn walked(dir: &tempfile::TempDir) -> Vec<String> {
778 let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
779 .expect("the scratch path is UTF-8");
780 walk_files(&GateCtx::new(root))
781 .into_iter()
782 .map(|p| p.to_string())
783 .collect()
784 }
785
786 #[test]
787 fn walk_files_skips_a_gitignored_file() {
788 let dir = tree(&[
789 (".gitignore", "generated.md\n"),
790 ("generated.md", "x\n"),
791 ("kept.md", "x\n"),
792 ]);
793 let files = walked(&dir);
794 assert!(files.contains(&"./kept.md".to_string()));
795 assert!(
796 !files.contains(&"./generated.md".to_string()),
797 "a git-ignored file still reached a walking gate: {files:?}"
798 );
799 }
800
801 #[test]
802 fn walk_files_ignores_a_machine_local_exclude_file() {
803 let dir = tree(&[
807 (".git/info/exclude", "governed.md\n"),
808 ("governed.md", "x\n"),
809 ]);
810 assert!(
811 walked(&dir).contains(&"./governed.md".to_string()),
812 "a machine-local exclude hid a governed file"
813 );
814 }
815
816 #[test]
817 fn walk_files_still_prunes_the_pruned_dirs() {
818 let dir = tree(&[
819 ("target/debug/artifact", "x\n"),
820 ("node_modules/pkg/index.js", "x\n"),
821 ("src/main.rs", "x\n"),
822 ]);
823 let files = walked(&dir);
824 assert_eq!(files, vec!["./src/main.rs".to_string()]);
825 }
826
827 #[test]
828 fn walk_files_yields_dotted_paths() {
829 let dir = tree(&[(".markdownlint/base.yaml", "x\n")]);
830 assert!(walked(&dir).contains(&"./.markdownlint/base.yaml".to_string()));
831 }
832
833 #[test]
834 fn registry_covers_every_gate_exactly_once_in_order() {
835 assert_eq!(GATES.len(), GateId::ALL.len());
836 for (row, id) in GATES.iter().zip(GateId::ALL) {
837 assert_eq!(row.id, *id);
838 assert_eq!(spec(*id).id, *id);
839 }
840 }
841
842 #[test]
843 fn every_gate_declares_the_rules_it_cites() {
844 for row in GATES {
845 assert!(!row.cites.is_empty(), "{} cites nothing", row.id);
846 }
847 }
848
849 #[test]
850 fn cited_rules_resolve_in_the_embedded_specs() {
851 let defined = crate::embedded::spec_rule_ids();
852 for row in GATES {
853 for rule in row.cites {
854 assert!(
855 defined.contains(rule.as_str()),
856 "{}: {rule} is undefined",
857 row.id
858 );
859 }
860 }
861 }
862}