1use crate::error::SsgError;
23use crate::plugin::{Plugin, PluginContext};
24use base64::Engine;
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha384};
27use std::collections::{BTreeMap, HashMap};
28use std::fs;
29use std::path::{Path, PathBuf};
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct PillarResult {
34 pub pass: bool,
36 pub issues: Vec<String>,
38}
39
40impl PillarResult {
41 #[must_use]
43 pub const fn new_pass() -> Self {
44 Self {
45 pass: true,
46 issues: Vec::new(),
47 }
48 }
49
50 pub fn add_issue(&mut self, issue: impl Into<String>) {
52 self.pass = false;
53 self.issues.push(issue.into());
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59pub struct QualityGateReport {
60 pub pages_scanned: usize,
62 pub passed_pillars: usize,
64 pub total_pillars: usize,
66 pub pass_rate: f64,
68 pub total_issues: usize,
70 pub pillars: BTreeMap<String, PillarResult>,
72}
73
74#[derive(Debug, Clone, Copy, Default)]
76pub struct AuditPlugin;
77
78impl AuditPlugin {
79 #[must_use]
81 pub fn compute_sri(bytes: &[u8]) -> String {
82 let mut hasher = Sha384::new();
83 hasher.update(bytes);
84 let digest = hasher.finalize();
85 format!(
86 "sha384-{}",
87 base64::engine::general_purpose::STANDARD.encode(digest)
88 )
89 }
90
91 fn opening_tags(html: &str) -> Vec<&str> {
99 let bytes = html.as_bytes();
100 let mut tags = Vec::new();
101 let mut i = 0usize;
102 while i < bytes.len() {
103 if bytes[i] != b'<' {
104 i += 1;
105 continue;
106 }
107 let start = i;
108 i += 1;
109 let mut quote: Option<u8> = None;
110 while i < bytes.len() {
111 let c = bytes[i];
112 match quote {
113 Some(q) if c == q => quote = None,
114 Some(_) => {}
115 None if c == b'"' || c == b'\'' => quote = Some(c),
116 None if c == b'>' => break,
117 None => {}
118 }
119 i += 1;
120 }
121 if i < bytes.len() {
122 if let Some(tag) = html.get(start..=i) {
125 tags.push(tag);
126 }
127 i += 1;
128 }
129 }
130 tags
131 }
132
133 fn tag_attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
139 let mut from = 0usize;
140 while let Some(pos) = tag[from..].find(name) {
141 let at = from + pos;
142 let after = at + name.len();
143 let preceded_by_space = tag[..at]
144 .chars()
145 .next_back()
146 .is_some_and(char::is_whitespace);
147 let rest = tag.get(after..).unwrap_or("");
148 if preceded_by_space && rest.starts_with('=') {
149 let value = rest.get(1..).unwrap_or("");
150 let q = value.as_bytes().first().copied();
151 if q == Some(b'"') || q == Some(b'\'') {
152 let q = q.unwrap_or(b'"') as char;
153 let body = value.get(1..).unwrap_or("");
154 if let Some(end) = body.find(q) {
155 return body.get(..end);
156 }
157 }
158 }
159 from = after.max(at + 1);
160 }
161 None
162 }
163
164 #[must_use]
166 pub fn audit_directory(site_dir: &Path) -> QualityGateReport {
167 let mut pillars = BTreeMap::new();
168 let pillar_names = [
169 "1. Output & Essential Files",
170 "2. Meta Leaks & Content Hygiene",
171 "3. CSP & Security Integrity",
172 "4. SRI Hashes Sync",
173 "5. Hero Banner Subpage Isolation",
174 "6. Apple HIG Navbar & Footer Hygiene",
175 "7. Theme, Search & Lightbox Engines",
176 "8. Forms & Link Integrity",
177 "9. CloudCDN Asset Resolution",
178 "10. Accessibility & Semantic Hierarchy",
179 ];
180
181 for name in pillar_names {
182 let _ = pillars.insert(name.to_string(), PillarResult::new_pass());
183 }
184
185 if !site_dir.exists() {
186 for p in pillars.values_mut() {
187 p.add_issue(format!(
188 "Site directory not found: {}",
189 site_dir.display()
190 ));
191 }
192 return QualityGateReport {
193 pages_scanned: 0,
194 passed_pillars: 0,
195 total_pillars: 10,
196 pass_rate: 0.0,
197 total_issues: 10,
198 pillars,
199 };
200 }
201
202 let req_files = [
204 "robots.txt",
205 "sitemap.xml",
206 "manifest.json",
207 "rss.xml",
208 "search-index.json",
209 ];
210 for rf in req_files {
211 if !site_dir.join(rf).is_file() {
212 if let Some(p) = pillars.get_mut("1. Output & Essential Files")
213 {
214 p.add_issue(format!("Missing essential file: {rf}"));
215 }
216 }
217 }
218
219 let sindex_path = site_dir.join("search-index.json");
221 if sindex_path.is_file() {
222 if let Ok(content) = fs::read_to_string(&sindex_path) {
223 if let Ok(val) =
224 serde_json::from_str::<serde_json::Value>(&content)
225 {
226 let entries = if let Some(arr) = val.as_array() {
227 Some(arr)
228 } else {
229 val.get("entries").and_then(serde_json::Value::as_array)
230 };
231
232 if let Some(entries) = entries {
233 for entry in entries {
234 if let Some(url) = entry
235 .get("url")
236 .and_then(serde_json::Value::as_str)
237 {
238 let u_lower = url.to_lowercase();
239 if u_lower.contains("/404")
240 || u_lower.contains("/offline")
241 || u_lower.contains("/thanks")
242 || u_lower.contains("404.html")
243 || u_lower.contains("offline.html")
244 || u_lower.contains("thanks.html")
245 {
246 if let Some(p) = pillars.get_mut(
247 "7. Theme, Search & Lightbox Engines",
248 ) {
249 p.add_issue(format!(
250 "search-index.json contains utility page: {url}"
251 ));
252 }
253 }
254 }
255 }
256 }
257 }
258 }
259 }
260
261 let mut asset_hashes: HashMap<String, String> = HashMap::new();
263 let mut html_files: Vec<PathBuf> = Vec::new();
264
265 let mut stack = vec![site_dir.to_path_buf()];
266 while let Some(dir) = stack.pop() {
267 if let Ok(entries) = fs::read_dir(&dir) {
268 for entry in entries.flatten() {
269 let path = entry.path();
270 if path.is_dir() {
271 let name = path
272 .file_name()
273 .unwrap_or_default()
274 .to_string_lossy();
275 if !name.starts_with('.')
276 && name != "_layouts"
277 && name != "templates"
278 && name != "node_modules"
279 {
280 stack.push(path);
281 }
282 } else if path.is_file() {
283 let ext = path
284 .extension()
285 .unwrap_or_default()
286 .to_string_lossy();
287 if ext == "html" {
288 html_files.push(path);
289 } else if ext == "js" || ext == "css" {
290 if let Ok(bytes) = fs::read(&path) {
291 let hash = Self::compute_sri(&bytes);
292 let fname = path
293 .file_name()
294 .unwrap_or_default()
295 .to_string_lossy()
296 .to_string();
297 let rel = path
298 .strip_prefix(site_dir)
299 .unwrap_or(&path)
300 .to_string_lossy()
301 .replace('\\', "/");
302 let _ = asset_hashes
303 .insert(format!("/{rel}"), hash.clone());
304 let _ = asset_hashes.insert(fname, hash);
305 }
306 }
307 }
308 }
309 }
310 }
311
312 html_files.sort();
320
321 if html_files.is_empty() {
322 if let Some(p) = pillars.get_mut("1. Output & Essential Files") {
323 p.add_issue("No compiled HTML files found in output directory");
324 }
325 }
326
327 for path in &html_files {
329 let rel = path
330 .strip_prefix(site_dir)
331 .unwrap_or(path)
332 .to_string_lossy()
333 .replace('\\', "/");
334
335 let Ok(html) = fs::read_to_string(path) else {
336 continue;
337 };
338
339 if let Some(start) = html.find("<head") {
341 if let Some(end) = html[start..].find("</head>") {
342 let head_txt = &html[start..start + end];
343 if head_txt.contains("<div")
344 || head_txt.contains("<p")
345 || head_txt.contains("<span")
346 {
347 if let Some(p) =
348 pillars.get_mut("2. Meta Leaks & Content Hygiene")
349 {
350 p.add_issue(format!(
351 "{rel}: Unescaped HTML container inside <head>"
352 ));
353 }
354 }
355 if head_txt.contains("<div")
356 || head_txt.contains("<h")
357 {
358 if let Some(p) =
359 pillars.get_mut("2. Meta Leaks & Content Hygiene")
360 {
361 p.add_issue(format!(
362 "{rel}: Leaked escaped entity in <head>"
363 ));
364 }
365 }
366 }
367 }
368
369 if html.contains(".class=\"") || html.contains(".class=\\\"") {
371 if let Some(p) =
372 pillars.get_mut("2. Meta Leaks & Content Hygiene")
373 {
374 p.add_issue(format!(
375 "{rel}: Leaked .class= template artifact"
376 ));
377 }
378 }
379 if html.contains("<div")
380 || html.contains("<h2")
381 || html.contains("<p>")
382 || html.contains("<img")
383 {
384 if let Some(p) =
385 pillars.get_mut("2. Meta Leaks & Content Hygiene")
386 {
387 p.add_issue(format!(
388 "{rel}: Escaped HTML entities leaked in body content"
389 ));
390 }
391 }
392
393 if !html.to_lowercase().contains("content-security-policy") {
395 if let Some(p) = pillars.get_mut("3. CSP & Security Integrity")
396 {
397 p.add_issue(format!(
398 "{rel}: Missing Content-Security-Policy meta tag"
399 ));
400 }
401 } else if !html.contains("script-src")
402 && !html.contains("default-src")
403 {
404 if let Some(p) = pillars.get_mut("3. CSP & Security Integrity")
405 {
406 p.add_issue(format!(
407 "{rel}: CSP missing essential directives"
408 ));
409 }
410 }
411
412 for tag in Self::opening_tags(&html) {
429 let Some(int_val) = Self::tag_attr(tag, "integrity") else {
430 continue;
431 };
432 let Some(url) = Self::tag_attr(tag, "src")
434 .or_else(|| Self::tag_attr(tag, "href"))
435 else {
436 continue;
437 };
438 if url.starts_with("http://") || url.starts_with("https://") {
439 continue;
440 }
441 let expected = asset_hashes
442 .get(url)
443 .or_else(|| asset_hashes.get(url.trim_start_matches('/')));
444 if let Some(exp) = expected {
445 if exp != int_val {
446 if let Some(p) = pillars.get_mut("4. SRI Hashes Sync") {
447 p.add_issue(format!(
448 "{rel}: SRI mismatch for {url}"
449 ));
450 }
451 }
452 }
453 }
454
455 if rel != "index.html"
457 && html.contains("class=\"hero-banner-container\"")
458 {
459 if let Some(p) =
460 pillars.get_mut("5. Hero Banner Subpage Isolation")
461 {
462 p.add_issue(format!(
463 "{rel}: Subpage has full-screen hero banner"
464 ));
465 }
466 }
467
468 if !is_taxonomy_page(&rel) {
470 if !has_responsive_navbar(&html) {
471 if let Some(p) =
472 pillars.get_mut("6. Apple HIG Navbar & Footer Hygiene")
473 {
474 p.add_issue(format!(
475 "{rel}: Missing responsive navbar"
476 ));
477 }
478 }
479
480 if html.contains("<footer") && !html.contains("made-with-ssg") {
482 if let Some(p) =
483 pillars.get_mut("6. Apple HIG Navbar & Footer Hygiene")
484 {
485 p.add_issue(format!(
486 "{rel}: Footer missing 'Made with SSG' link"
487 ));
488 }
489 }
490 }
491
492 if rel.to_lowercase().contains("contact")
499 && !is_taxonomy_page(&rel)
500 && !html.contains("http-equiv=\"refresh\"")
501 && (!html.contains("<form") || !html.contains("action="))
502 {
503 {
504 if let Some(p) =
505 pillars.get_mut("8. Forms & Link Integrity")
506 {
507 p.add_issue(format!("{rel}: Contact page missing functional form action"));
508 }
509 }
510 }
511
512 if !html.contains("lang=") {
514 if let Some(p) =
515 pillars.get_mut("10. Accessibility & Semantic Hierarchy")
516 {
517 p.add_issue(format!("{rel}: Missing html lang attribute"));
518 }
519 }
520 if !html.contains("<h1") {
521 if let Some(p) =
522 pillars.get_mut("10. Accessibility & Semantic Hierarchy")
523 {
524 p.add_issue(format!(
525 "{rel}: Missing first-level <h1> heading"
526 ));
527 }
528 }
529 }
530
531 let total_issues: usize =
532 pillars.values().map(|p| p.issues.len()).sum();
533 let passed_pillars: usize = pillars.values().filter(|p| p.pass).count();
534 let pass_rate = if pillars.is_empty() {
535 0.0
536 } else {
537 (passed_pillars as f64 / pillars.len() as f64) * 100.0
538 };
539
540 QualityGateReport {
541 pages_scanned: html_files.len(),
542 passed_pillars,
543 total_pillars: 10,
544 pass_rate,
545 total_issues,
546 pillars,
547 }
548 }
549}
550
551fn is_taxonomy_page(rel: &str) -> bool {
560 if rel.starts_with("tags/") {
561 return true;
562 }
563 match rel.split_once('/') {
567 Some((first, rest))
569 if (2..=5).contains(&first.len())
570 && first
571 .chars()
572 .all(|c| c.is_ascii_alphabetic() || c == '-') =>
573 {
574 rest.starts_with("tags/")
575 }
576 _ => false,
577 }
578}
579
580fn has_responsive_navbar(html: &str) -> bool {
590 let bootstrap = html.contains("navbar") && html.contains("navbar-brand");
591
592 let has_nav_landmark = html.contains("<nav")
593 || html.contains("role=\"navigation\"")
594 || html.contains("role='navigation'");
595
596 let has_brand = html.contains("class=\"brand\"")
599 || html.contains("class='brand'")
600 || html.contains("navbar-brand")
601 || html.contains("class=\"site-title\"")
602 || html.contains("rel=\"home\"")
603 || html.contains("rel='home'");
604
605 bootstrap || (has_nav_landmark && has_brand)
606}
607
608impl Plugin for AuditPlugin {
609 fn name(&self) -> &'static str {
610 "audit"
611 }
612
613 fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
614 Ok(())
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use tempfile::TempDir;
622
623 #[test]
624 fn test_audit_plugin_name() {
625 let plugin = AuditPlugin;
626 assert_eq!(plugin.name(), "audit");
627 }
628
629 fn minified_site(script_integrity: &str) -> TempDir {
634 let dir = TempDir::new().expect("tempdir");
635 let root = dir.path();
636 let css = b"body{margin:0}";
637 let js = b"document.documentElement.classList.remove('no-js');";
638 fs::write(root.join("style.css"), css).expect("css");
639 fs::write(root.join("theme-init.js"), js).expect("js");
640 let css_sri = AuditPlugin::compute_sri(css);
641 let html = format!(
644 "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
645 <title>t</title><meta http-equiv=\"Content-Security-Policy\" \
646 content=\"default-src 'self'; script-src 'self'\">\
647 <link rel=\"stylesheet\" href=\"/style.css\" \
648 integrity=\"{css_sri}\" crossorigin=\"anonymous\">\
649 <script src=\"/theme-init.js\" integrity=\"{script_integrity}\" \
650 crossorigin=\"anonymous\"></script></head><body><h1>t</h1>\
651 </body></html>"
652 );
653 fs::write(root.join("index.html"), html).expect("html");
654 dir
655 }
656
657 fn sri_issues(report: &QualityGateReport) -> Vec<String> {
658 report
659 .pillars
660 .get("4. SRI Hashes Sync")
661 .map(|p| p.issues.clone())
662 .unwrap_or_default()
663 }
664
665 #[test]
671 fn correct_hashes_on_one_minified_line_raise_no_sri_issue() {
672 let js = b"document.documentElement.classList.remove('no-js');";
673 let dir = minified_site(&AuditPlugin::compute_sri(js));
674 let report = AuditPlugin::audit_directory(dir.path());
675 assert!(
676 sri_issues(&report).is_empty(),
677 "correct hashes must not be reported: {:?}",
678 sri_issues(&report)
679 );
680 }
681
682 #[test]
692 fn a_wrong_hash_after_a_correct_one_on_the_same_line_is_caught() {
693 let dir = TempDir::new().expect("tempdir");
694 let root = dir.path();
695 let first = b"console.log('first');";
696 let second = b"console.log('second');";
697 fs::write(root.join("first.js"), first).expect("first");
698 fs::write(root.join("second.js"), second).expect("second");
699 let good = AuditPlugin::compute_sri(first);
700 let html = format!(
701 "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
702 <title>t</title><meta http-equiv=\"Content-Security-Policy\" \
703 content=\"default-src 'self'; script-src 'self'\">\
704 <script src=\"/first.js\" integrity=\"{good}\" \
705 crossorigin=\"anonymous\"></script>\
706 <script src=\"/second.js\" \
707 integrity=\"sha384-notTheHashOfSecondJsAtAllNotEvenClose\" \
708 crossorigin=\"anonymous\"></script></head><body><h1>t</h1>\
709 </body></html>"
710 );
711 fs::write(root.join("index.html"), html).expect("html");
712
713 let report = AuditPlugin::audit_directory(root);
714 let issues = sri_issues(&report);
715 assert!(
716 issues.iter().any(|i| i.contains("/second.js")),
717 "the wrong hash on the second script must be reported, \
718 got: {issues:?}"
719 );
720 assert!(
721 !issues.iter().any(|i| i.contains("/first.js")),
722 "the correct first script must not be reported: {issues:?}"
723 );
724 }
725
726 #[test]
729 fn tag_attr_does_not_match_a_prefixed_lookalike() {
730 let tag = "<script data-src=\"/decoy.js\" src=\"/real.js\">";
731 assert_eq!(AuditPlugin::tag_attr(tag, "src"), Some("/real.js"));
732 let only_decoy = "<script data-src=\"/decoy.js\">";
733 assert_eq!(AuditPlugin::tag_attr(only_decoy, "src"), None);
734 }
735
736 #[test]
738 fn opening_tags_are_bounded_quote_aware() {
739 let html = "<a title=\"a > b\" href=\"/x\">text</a>";
740 let tags = AuditPlugin::opening_tags(html);
741 assert_eq!(AuditPlugin::tag_attr(tags[0], "href"), Some("/x"));
742 }
743
744 #[test]
745 fn test_compute_sri() {
746 let data = b"console.log('hello world');";
747 let sri = AuditPlugin::compute_sri(data);
748 assert!(sri.starts_with("sha384-"));
749 }
750
751 #[test]
752 fn test_taxonomy_page_matches_plain_tags_root() {
753 assert!(is_taxonomy_page("tags/index.html"));
754 assert!(is_taxonomy_page("tags/method/index.html"));
755 }
756
757 #[test]
758 fn test_taxonomy_page_matches_locale_prefixed_tags() {
759 assert!(is_taxonomy_page("fr/tags/index.html"));
762 assert!(is_taxonomy_page("fr/tags/editorial/index.html"));
763 assert!(is_taxonomy_page("pt-br/tags/index.html"));
764 }
765
766 #[test]
767 fn test_taxonomy_page_rejects_authored_pages() {
768 assert!(!is_taxonomy_page("index.html"));
769 assert!(!is_taxonomy_page("about/index.html"));
770 assert!(!is_taxonomy_page("fr/a-propos/index.html"));
771 assert!(!is_taxonomy_page("tagging-guide/index.html"));
773 }
774
775 #[test]
787 fn issues_are_recorded_in_lexical_path_order() {
788 let temp = TempDir::new().unwrap();
789 let sdir = temp.path();
790
791 fs::write(sdir.join("robots.txt"), "User-agent: *").unwrap();
792 fs::write(sdir.join("sitemap.xml"), "<urlset></urlset>").unwrap();
793 fs::write(sdir.join("manifest.json"), "{}").unwrap();
794 fs::write(sdir.join("rss.xml"), "<rss></rss>").unwrap();
795 fs::write(sdir.join("search-index.json"), "[]").unwrap();
796
797 for name in ["zulu", "alpha", "mike", "bravo"] {
799 let sub = sdir.join(name);
800 fs::create_dir_all(&sub).unwrap();
801 fs::write(
802 sub.join("index.html"),
803 "<html><body><p>no lang, no h1</p></body></html>",
804 )
805 .unwrap();
806 }
807
808 let report = AuditPlugin::audit_directory(sdir);
809 let pillar = report
810 .pillars
811 .get("10. Accessibility & Semantic Hierarchy")
812 .expect("accessibility pillar is always present");
813 assert!(
814 pillar.issues.len() >= 4,
815 "expected an issue per page, got {:?}",
816 pillar.issues
817 );
818
819 let paths: Vec<&str> = pillar
820 .issues
821 .iter()
822 .filter_map(|i| i.split(':').next())
823 .collect();
824 let mut sorted = paths.clone();
825 sorted.sort_unstable();
826 assert_eq!(
827 paths, sorted,
828 "issues are not in lexical path order; the file walk is unsorted"
829 );
830 }
831
832 #[test]
833 fn test_navbar_accepts_bootstrap_class_pair() {
834 let html = r#"<nav class="navbar"><a class="navbar-brand" href="/">Home</a></nav>"#;
835 assert!(has_responsive_navbar(html));
836 }
837
838 #[test]
839 fn test_navbar_accepts_semantic_nav_with_brand() {
840 let html = r#"<header><a class="brand" href="/">Lucid</a>
843 <nav aria-label="Main"><ul><li><a href="/install/">Install</a></li></ul></nav>
844</header>"#;
845 assert!(has_responsive_navbar(html));
846 }
847
848 #[test]
849 fn test_navbar_accepts_navigation_role_with_home_rel() {
850 let html =
851 r#"<div role="navigation"><a rel="home" href="/">Site</a></div>"#;
852 assert!(has_responsive_navbar(html));
853 }
854
855 #[test]
856 fn test_navbar_rejects_page_without_navigation() {
857 let html =
858 r#"<header><h1>Just a title</h1></header><main><p>Body</p></main>"#;
859 assert!(!has_responsive_navbar(html));
860 }
861
862 #[test]
863 fn test_navbar_rejects_nav_landmark_without_brand() {
864 let html = r#"<nav aria-label="Main"><ul><li><a href="/a/">A</a></li></ul></nav>"#;
867 assert!(!has_responsive_navbar(html));
868 }
869
870 #[test]
871 fn test_audit_directory_non_existent() {
872 let p = Path::new("/non/existent/path/here");
873 let report = AuditPlugin::audit_directory(p);
874 assert_eq!(report.passed_pillars, 0);
875 assert_eq!(report.total_issues, 10);
876 }
877
878 #[test]
879 fn test_audit_directory_clean_site() {
880 let temp = TempDir::new().unwrap();
881 let sdir = temp.path();
882
883 fs::write(sdir.join("robots.txt"), "User-agent: *\nDisallow:").unwrap();
885 fs::write(sdir.join("sitemap.xml"), "<urlset></urlset>").unwrap();
886 fs::write(sdir.join("manifest.json"), "{}").unwrap();
887 fs::write(sdir.join("rss.xml"), "<rss></rss>").unwrap();
888 fs::write(sdir.join("search-index.json"), "[]").unwrap();
889
890 let html = r#"<!DOCTYPE html>
892<html lang="en-GB">
893<head>
894 <meta charset="utf-8">
895 <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline';">
896 <title>Clean Test Site</title>
897</head>
898<body>
899 <nav class="navbar"><a class="navbar-brand" href="/">Home</a></nav>
900 <main id="main">
901 <h1>Clean Test Site</h1>
902 <p>Welcome to the clean site.</p>
903 </main>
904 <footer>
905 <a href="/made-with-ssg/index.html">Made with SSG</a>
906 </footer>
907</body>
908</html>"#;
909 fs::write(sdir.join("index.html"), html).unwrap();
910
911 let report = AuditPlugin::audit_directory(sdir);
912 assert_eq!(report.passed_pillars, 10);
913 assert_eq!(report.total_issues, 0);
914 assert!((report.pass_rate - 100.0).abs() < f64::EPSILON);
917 }
918}