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