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