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