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