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