Skip to main content

ssg/audit/gates/
mod.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Per-gate implementations for [`crate::audit`].
5//!
6//! Each gate is its own module. [`all`] returns the registry order used
7//! by [`crate::audit::AuditRunner`] — the order is stable so callers
8//! can rely on the JSON / `JUnit` output layout being deterministic.
9
10use super::AuditGate;
11
12pub mod util;
13// Re-exported for downstream crates and the audit binary; the library
14// build alone does not reference every name, which is not the same as
15// them being unused.
16#[allow(unused_imports)]
17pub use util::{find_tag_end, hreflang_attr, strip_script_and_style};
18
19pub mod ai_discovery;
20pub mod broken_links;
21pub mod csp_sri;
22pub mod feeds;
23pub mod hreflang;
24pub mod html5;
25pub mod images;
26pub mod jsonld;
27pub mod lang_consistency;
28pub mod markdownlint;
29pub mod metadata;
30pub mod performance;
31pub mod pqc_tls;
32pub mod search_index;
33pub mod wcag;
34
35/// Returns the 15 built-in gates in registration order.
36///
37/// Order is part of the public contract: the JSON output, `JUnit` XML
38/// output, and CI dashboard tooling all rely on it being stable — new
39/// gates append at the end so existing positions never shift.
40///
41/// # Examples
42///
43/// ```
44/// use ssg::audit::gates::all;
45/// let gates = all();
46/// assert_eq!(gates.len(), 15);
47/// assert_eq!(gates[0].name(), "wcag");
48/// ```
49#[must_use]
50pub fn all() -> Vec<Box<dyn AuditGate>> {
51    vec![
52        Box::new(wcag::WcagGate),
53        Box::new(jsonld::JsonLdGate),
54        Box::new(hreflang::HreflangGate),
55        Box::new(csp_sri::CspSriGate),
56        Box::new(pqc_tls::PqcTlsGate),
57        Box::new(html5::Html5Gate),
58        Box::new(broken_links::BrokenLinksGate),
59        Box::new(metadata::MetadataGate),
60        Box::new(markdownlint::MarkdownlintGate),
61        Box::new(performance::PerformanceGate),
62        Box::new(ai_discovery::AiDiscoveryGate),
63        Box::new(feeds::FeedsGate),
64        Box::new(images::ImagesGate),
65        Box::new(search_index::SearchIndexGate),
66        Box::new(lang_consistency::LangConsistencyGate),
67    ]
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn all_returns_fifteen_gates_in_stable_order() {
76        let gates = all();
77        let names: Vec<&str> = gates.iter().map(|g| g.name()).collect();
78        assert_eq!(names.len(), 15);
79        assert_eq!(
80            names,
81            vec![
82                "wcag",
83                "jsonld",
84                "hreflang",
85                "csp_sri",
86                "pqc_tls",
87                "html5",
88                "links",
89                "metadata",
90                "markdownlint",
91                "performance",
92                "ai_discovery",
93                "feeds",
94                "images",
95                "search_index",
96                "lang_consistency",
97            ]
98        );
99    }
100
101    #[test]
102    fn every_gate_has_a_non_empty_explainer() {
103        for g in all() {
104            let name = g.name();
105            let explainer = g.explain();
106            assert!(
107                !explainer.trim().is_empty(),
108                "gate `{name}` has empty explainer"
109            );
110        }
111    }
112
113    #[test]
114    fn gate_names_are_unique() {
115        let gates = all();
116        let mut names: Vec<&str> = gates.iter().map(|g| g.name()).collect();
117        names.sort_unstable();
118        let original_len = names.len();
119        names.dedup();
120        assert_eq!(names.len(), original_len, "duplicate gate name detected");
121    }
122
123    #[test]
124    fn gate_names_are_snake_case_no_whitespace() {
125        for g in all() {
126            let name = g.name();
127            assert!(!name.is_empty(), "empty name");
128            assert!(
129                name.chars().all(|c| c.is_ascii_lowercase()
130                    || c.is_ascii_digit()
131                    || c == '_'),
132                "gate `{name}` is not snake_case"
133            );
134            assert!(!name.contains(' '), "gate `{name}` contains whitespace");
135        }
136    }
137
138    #[test]
139    fn explainers_are_reasonably_long() {
140        for g in all() {
141            let name = g.name();
142            let len = g.explain().len();
143            assert!(
144                len >= 40,
145                "gate `{name}` explainer is too short ({len} chars)"
146            );
147        }
148    }
149
150    #[test]
151    fn first_gate_is_wcag() {
152        let gates = all();
153        assert_eq!(gates.first().map(|g| g.name()), Some("wcag"));
154    }
155
156    #[test]
157    fn last_gate_is_lang_consistency() {
158        // New gates append at the end so existing positions are stable.
159        let gates = all();
160        assert_eq!(gates.last().map(|g| g.name()), Some("lang_consistency"));
161    }
162
163    #[test]
164    fn util_re_exports_compile() {
165        let tag = r#"<a href="x">"#;
166        assert_eq!(hreflang_attr(tag, "href"), Some("x".to_string()));
167        let end = find_tag_end(tag, 0);
168        assert_eq!(end, tag.len());
169    }
170
171    #[test]
172    fn all_returns_fresh_boxes_each_call() {
173        let a = all();
174        let b = all();
175        assert_eq!(a.len(), b.len());
176        for (x, y) in a.iter().zip(b.iter()) {
177            assert_eq!(x.name(), y.name());
178        }
179    }
180}