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