use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::audit::normalized::{AuditContext, NormalizedReport};
use crate::audit::verdict::{Verdict, VerdictResult};
use crate::audit::{
compute_recurring_rules, AccessibilityScorer, AuditReport, BatchReport, RecurringRule,
SampleMetadata,
};
use crate::error::Result;
use crate::output::builder::{build_batch_presentation_with_normalized, build_view_model};
use crate::output::module::ReportModule as _;
use crate::output::report_model::{ReportConfig, UrlMatrixRow};
const SCHEMA_VERSION: &str = "2.0";
mod detail;
pub(crate) mod helpers;
use detail::{build_batch_detail, build_page, DetailContext};
use helpers::{
aggregate_occurrences, aggregate_severity, avg_module_score, batch_report_timestamp,
build_accessibility_score_breakdown, build_decision_actions, build_en301549_batch_rollup,
build_internal_comparison, build_management_risks, build_wcag_coverage_for_level,
build_wcag_coverage_summary, normalized_module_score, overall_score_weight_basis,
weight_basis_is_uniform,
};
#[cfg(test)]
mod tests;
pub fn format_json_normalized(
ctx: &AuditContext<'_>,
report: &AuditReport,
pretty: bool,
) -> Result<String> {
UnifiedReport::single(ctx, report).to_json(pretty)
}
pub fn format_json_cached(normalized: &NormalizedReport, pretty: bool) -> Result<String> {
UnifiedReport::single_from_normalized(normalized).to_json(pretty)
}
pub fn format_json_batch(batch_report: &BatchReport, pretty: bool) -> Result<String> {
UnifiedReport::batch(batch_report).to_json(pretty)
}
#[derive(Debug, Serialize)]
pub struct UnifiedReport {
pub schema_version: &'static str,
pub report_type: &'static str,
pub tool_version: &'static str,
pub build_id: &'static str,
pub metadata: ReportMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
pub audit_scope: Option<crate::audit::AuditScope>,
#[serde(skip_serializing_if = "Option::is_none")]
pub execution_environment: Option<crate::audit::ExecutionEnvironment>,
pub audit_quality: crate::audit::AuditQuality,
pub metric_context: MetricContext,
pub summary: UnifiedSummary,
#[serde(skip_serializing_if = "Option::is_none")]
pub sample: Option<SampleMetadata>,
pub pages: Vec<PageEntry>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub url_matrix: Vec<UrlMatrixRow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub internal_comparison: Option<InternalComparison>,
#[serde(skip_serializing_if = "Option::is_none")]
pub crawl_diagnostics: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sitemap_diagnostics: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub site_analysis: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub artifacts: Vec<ArtifactDescriptor>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub collection_errors: Vec<ReportError>,
pub verdict: Verdict,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub verdict_reasons: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct MetricContext {
pub score_scale: ScoreScale,
pub score_definitions: Vec<MetricDefinition>,
pub count_definitions: Vec<MetricDefinition>,
}
#[derive(Debug, Serialize)]
pub struct ScoreScale {
pub minimum: u32,
pub maximum: u32,
pub higher_is_better: bool,
}
#[derive(Debug, Serialize)]
pub struct MetricDefinition {
pub field: &'static str,
pub unit: &'static str,
pub meaning: &'static str,
}
fn metric_context(report_type: &'static str) -> MetricContext {
let mut score_definitions = Vec::new();
let mut count_definitions = Vec::new();
for metric in crate::registry::REGISTRY {
let definition = MetricDefinition {
field: metric.json_path,
unit: metric.unit,
meaning: metric.meaning_for(report_type),
};
if metric.kind == crate::registry::MetricKind::Count {
count_definitions.push(definition);
} else {
score_definitions.push(definition);
}
}
MetricContext {
score_scale: ScoreScale {
minimum: 0,
maximum: 100,
higher_is_better: true,
},
score_definitions,
count_definitions,
}
}
#[derive(Debug, Serialize)]
pub struct UnifiedSummary {
pub url_count: usize,
pub accessibility_score: u32,
pub overall_score: u32,
pub score: u32,
pub grade: String,
pub certificate: String,
pub risk_level: crate::audit::normalized::RiskLevel,
pub violation_count: usize,
pub finding_count: usize,
pub finding_occurrence_count: usize,
pub severity_counts: crate::audit::normalized::SeverityCounts,
pub severity_counts_scope: String,
pub occurrence_counts: crate::audit::normalized::SeverityCounts,
pub occurrence_counts_scope: String,
pub passed_url_count: usize,
pub failed_url_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub attempted_url_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exclusions: Option<crate::audit::exclusion::BatchExclusionSummary>,
#[serde(default)]
pub violated_rule_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub top_recurring_rules: Vec<RecurringRule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub template_clusters: Vec<crate::audit::TemplateCluster>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub en301549_rollup: Vec<En301549BatchClauseRollup>,
#[serde(skip_serializing_if = "Option::is_none")]
pub performance_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seo_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub security_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mobile_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ux_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub journey_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub performance_throttled_avg_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lh_mobile_score: Option<u32>,
pub wcag_coverage: WcagCoverageSummary,
pub overall_score_weight_basis: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub overall_score_weight_basis_uniform: Option<bool>,
pub accessibility_score_breakdown: Vec<AccessibilityScoreComponent>,
pub management_risks: Vec<ManagementRisk>,
pub top_actions: Vec<DecisionAction>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub duplicate_content: Vec<crate::output::report_model::DuplicateContentGroup>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub missing_tag_prevalence: Vec<crate::output::report_model::MissingTagPrevalence>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub canonical_issues: Vec<crate::output::report_model::CanonicalIssue>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub hreflang_issues: Vec<crate::output::report_model::HreflangIssue>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sitemap_http_issues: Vec<crate::audit::SitemapHttpIssue>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub orphan_sitemap_urls: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub linked_not_in_sitemap: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub robots_conflicts: Vec<crate::audit::RobotsSitemapConflict>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub minification_inconsistencies: Vec<crate::output::report_model::MinificationInconsistency>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub redirect_chain_issues: Vec<crate::output::report_model::RedirectChainIssue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub crawl_depth_diagnostics: Option<crate::audit::CrawlDepthDiagnostics>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commerce: Option<crate::commerce::CommerceSiteSummary>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ArtifactDescriptor {
pub kind: &'static str,
pub media_type: &'static str,
pub delivery: &'static str,
pub path_template: &'static str,
pub status: crate::audit::ExecutionStatus,
}
fn artifacts_for(normalized: &NormalizedReport) -> Vec<ArtifactDescriptor> {
if normalized.screen_reader.is_some() {
vec![ArtifactDescriptor {
kind: "screen_reader_audit",
media_type: "application/json",
delivery: "sidecar",
path_template: "<primary-output-stem>.screen-reader.json",
status: crate::audit::ExecutionStatus::Completed,
}]
} else {
Vec::new()
}
}
#[derive(Debug, Serialize)]
pub struct WcagCoverageSummary {
pub wcag_version: &'static str,
pub level: String,
pub automated_criteria: usize,
pub manual_review_criteria: usize,
pub total_wcag_aa_criteria: usize,
pub note: String,
}
#[derive(Debug, Serialize)]
pub struct En301549Annex {
pub standard_version: &'static str,
pub mapping_version: u32,
pub scope: &'static str,
pub clauses: Vec<En301549ClauseEntry>,
pub out_of_standard_findings: usize,
pub out_of_scope_chapters: Vec<OutOfScopeChapterEntry>,
pub disclaimer: String,
}
#[derive(Debug, Serialize)]
pub struct En301549ClauseEntry {
pub en_clause: &'static str,
pub wcag: &'static str,
pub level: &'static str,
pub title: &'static str,
pub status: En301549ClauseStatusKind,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub findings: Vec<En301549FindingRef>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum En301549ClauseStatusKind {
ViolationsFound,
NoViolationsAutomated,
ManualReviewRequired,
PartialAutomatedEvaluation,
NotEvaluated,
}
#[derive(Debug, Serialize)]
pub struct En301549FindingRef {
pub rule_id: String,
pub occurrences: usize,
}
#[derive(Debug, Serialize)]
pub struct OutOfScopeChapterEntry {
pub chapter: &'static str,
pub title: &'static str,
}
#[derive(Debug, Serialize)]
pub struct En301549BatchClauseRollup {
pub en_clause: &'static str,
pub wcag: &'static str,
pub level: &'static str,
pub title: &'static str,
pub status: En301549ClauseStatusKind,
pub affected_pages: usize,
}
#[derive(Debug, Serialize)]
pub struct BikGuideAnnex {
pub guide: &'static str,
pub chapters: Vec<BikChapterEntry>,
}
#[derive(Debug, Serialize)]
pub struct BikChapterEntry {
pub id: &'static str,
pub title: &'static str,
pub status: BikChapterStatusKind,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub findings: Vec<BikFindingRefEntry>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BikChapterStatusKind {
FindingsPresent,
NoFindingsDetected,
NotChecked,
}
#[derive(Debug, Serialize)]
pub struct BikFindingRefEntry {
pub label: String,
pub occurrences: usize,
}
#[derive(Debug, Serialize)]
pub struct AccessibilityScoreComponent {
pub area: String,
pub score: u32,
pub weight_pct: u32,
pub estimated_lost_points: u32,
pub main_driver: String,
}
#[derive(Debug, Serialize)]
pub struct ManagementRisk {
pub dimension: String,
pub level: String,
pub rationale: String,
}
#[derive(Debug, Serialize)]
pub struct DecisionAction {
pub rule_id: String,
pub title: String,
pub risk: String,
pub priority: String,
pub complexity: String,
pub occurrence_count: usize,
pub url_count: usize,
pub root_cause: String,
pub expected_impact: String,
}
#[derive(Debug, Serialize)]
pub struct InternalComparison {
pub module_extremes: Vec<ModuleExtreme>,
pub outlier_urls: Vec<UrlOutlier>,
pub root_causes: Vec<RootCauseSummary>,
}
#[derive(Debug, Serialize)]
pub struct ModuleExtreme {
pub module: String,
pub best_url: String,
pub best_score: u32,
pub worst_url: String,
pub worst_score: u32,
}
#[derive(Debug, Serialize)]
pub struct UrlOutlier {
pub url: String,
pub accessibility_score: u32,
pub batch_average: u32,
pub delta_points: i32,
pub reason: String,
}
#[derive(Debug, Serialize)]
pub struct RootCauseSummary {
pub title: String,
pub occurrence_count: usize,
pub affected_urls: usize,
pub classification: String,
}
#[derive(Debug, Serialize)]
pub struct PageEntry {
pub url: String,
pub accessibility_score: u32,
pub overall_score: u32,
pub grade: String,
pub certificate: String,
pub violation_count: usize,
pub finding_count: usize,
pub finding_occurrence_count: usize,
pub violated_rule_count: usize,
pub severity_counts: crate::audit::normalized::SeverityCounts,
pub severity_counts_scope: String,
pub occurrence_counts: crate::audit::normalized::SeverityCounts,
pub occurrence_counts_scope: String,
pub nodes_analyzed: usize,
pub duration_ms: u64,
pub module_scores: Vec<crate::audit::normalized::ModuleScoreEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub performance_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seo_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub security_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mobile_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ux_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub journey_score: Option<u32>,
pub score_calculation_method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub score_breakdown: Option<crate::audit::normalized::ScoreBreakdown>,
pub risk: crate::audit::normalized::RiskAssessment,
pub principle_coverage: crate::audit::PrincipleCoverage,
pub findings: Vec<crate::audit::normalized::NormalizedFinding>,
pub audit_flags: Vec<crate::audit::normalized::AuditFlag>,
pub audit_quality: crate::audit::AuditQuality,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub module_runs: Vec<crate::audit::ModuleRun>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rule_outcomes: Vec<crate::wcag::RuleRun>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub accessibility_assessments: Vec<crate::audit::normalized::AccessibilityAssessment>,
#[serde(skip_serializing_if = "Option::is_none")]
pub navigation: Option<crate::audit::NavigationSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub consent: Option<crate::audit::ConsentAuditState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub consent_privacy: Option<crate::audit::ConsentPrivacySnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclusions: Option<crate::audit::exclusion::ExclusionReport>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub interactive_findings: Vec<crate::audit::normalized::InteractiveFinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accessibility_journey: Option<crate::audit::normalized::AccessibilityJourney>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screen_reader: Option<crate::screen_reader::ScreenReaderSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_profile: Option<BatchContentProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_modes: Option<crate::display::DisplayModesInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frames: Option<crate::audit::frames::FrameCoverage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<PageDetail>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BatchContentProfile {
#[serde(skip_serializing_if = "Option::is_none")]
pub page_type: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub attributes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub semantic_score: Option<u32>,
pub biggest_lever: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub topic_terms: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub top_issues: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct PageDetail {
pub fix_guidance: Vec<FixGuidance>,
pub en301549_annex: En301549Annex,
pub bik_guide: BikGuideAnnex,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub accessibility_subcategory_scores: Vec<crate::audit::normalized::SubcategoryScoreEntry>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub security_category_scores: Vec<crate::audit::normalized::SecurityCategoryScoreEntry>,
pub modules: ModuleBlob,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub confidence_summary: Vec<OutputConfidenceSignal>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<OutputCapabilitySignal>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport_scores: Option<crate::audit::ViewportScores>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport_comparison: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub budget_violations: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub throttled_performance: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screenshot_status: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub collection_errors: Vec<ReportError>,
}
#[derive(Debug, Default, Serialize)]
pub struct ModuleBlob {
#[serde(skip_serializing_if = "Option::is_none")]
pub accessibility: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dual_viewport: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub search_experience: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub performance: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seo: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub security: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub html_conform: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mobile: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ux: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub journey: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dark_mode: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub design_quality: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_transparency: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_dns: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_quality: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_visibility: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_visibility: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_stack: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub patterns: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub best_practices: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commerce: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
pub struct FixGuidance {
pub rule_id: String,
pub title: String,
pub wcag_criterion: String,
pub severity: String,
pub risk: String,
pub remediation_priority: String,
pub complexity: String,
pub complexity_reason: String,
pub confidence: String,
pub false_positive_risk: String,
pub verification: String,
pub expected_impact: String,
pub bfsg_relevance: String,
pub occurrence_count: usize,
pub problem: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_impact: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub typical_cause: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recommendation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub technical_note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_example: Option<CodeExample>,
pub affected_selectors: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct CodeExample {
pub bad: String,
pub good: String,
}
#[derive(Debug, Serialize)]
pub struct OutputConfidenceSignal {
pub signal: String,
pub assessment: String,
}
#[derive(Debug, Serialize)]
pub struct OutputCapabilitySignal {
pub signal: String,
pub source: String,
pub confidence: String,
pub surfaces: Vec<String>,
pub note: String,
}
#[derive(Debug, Serialize)]
pub struct ReportError {
pub module: &'static str,
pub error_type: &'static str,
pub reason: String,
}
#[derive(Debug, Serialize)]
pub struct ReportMetadata {
pub tool: String,
pub timestamp: DateTime<Utc>,
pub wcag_level: String,
pub execution_time_ms: u64,
}
fn aggregate_audit_quality(reports: &[NormalizedReport]) -> crate::audit::AuditQuality {
let failed_rule_checks = reports
.iter()
.map(|report| report.execution.quality.failed_rule_checks)
.sum();
let partial_or_failed_modules = reports
.iter()
.map(|report| report.execution.quality.partial_or_failed_modules)
.sum();
let status = if reports.iter().any(|report| {
report.execution.quality.status == crate::audit::AuditQualityStatus::Insufficient
}) {
crate::audit::AuditQualityStatus::Insufficient
} else if reports
.iter()
.any(|report| report.execution.quality.status == crate::audit::AuditQualityStatus::Partial)
{
crate::audit::AuditQualityStatus::Partial
} else {
crate::audit::AuditQualityStatus::Complete
};
crate::audit::AuditQuality {
status,
qualified_results: status != crate::audit::AuditQualityStatus::Complete,
failed_rule_checks,
partial_or_failed_modules,
reasons: reports
.iter()
.filter(|report| report.execution.quality.qualified_results)
.map(|report| format!("qualified_page:{}", report.url))
.collect(),
}
}
fn build_site_analysis(
batch: &BatchReport,
presentation: &crate::output::report_model::BatchPresentation,
normalized_reports: &[NormalizedReport],
) -> serde_json::Value {
let portfolio = &presentation.portfolio_summary;
let consistency = batch.consistency.as_ref().map(|value| {
serde_json::json!({
"navigation": {
"pages_with_main_navigation": value.navigation.pages_with_main_nav,
"pages_with_skip_link": value.navigation.pages_with_skip_link,
"total_pages": value.navigation.total_pages,
},
"headings": {
"pages_with_single_h1": value.headings.pages_with_single_h1,
"pages_with_no_h1": value.headings.pages_with_no_h1,
"pages_with_multiple_h1": value.headings.pages_with_multiple_h1,
"total_pages": value.headings.total_pages,
},
"canonical": {
"www_count": value.canonical.www_count,
"non_www_count": value.canonical.non_www_count,
"missing_count": value.canonical.missing_count,
"total_pages": value.canonical.total_pages,
},
"orphan_pages": {
"urls": value.orphan_pages.orphan_urls,
"total_pages": value.orphan_pages.total_pages,
"scope": "audited_urls_only",
},
"schema_graph": {
"conflicts": value.schema_graph.conflicts,
},
"help": value.help,
"wcag_cross_page": value.wcag_cross_page,
})
});
let interactive = presentation.interactive_summary.as_ref().map(|value| {
serde_json::json!({
"pages_tested": value.total_pages_tested,
"pages_with_issues": value.pages_with_issues,
"has_critical": value.has_critical,
"categories": value.categories.iter().map(|category| serde_json::json!({
"category": category.category,
"affected_urls": category.affected_urls,
"max_severity": category.max_severity,
})).collect::<Vec<_>>(),
})
});
let mut assessment_groups: std::collections::BTreeMap<
(String, String, String),
(usize, std::collections::BTreeSet<String>),
> = std::collections::BTreeMap::new();
for report in normalized_reports {
for assessment in &report.accessibility_assessments {
let entry = assessment_groups
.entry((
assessment.kind.clone(),
assessment.rule_id.clone(),
assessment.wcag_criterion.clone(),
))
.or_default();
entry.0 += 1;
entry.1.insert(report.url.clone());
}
}
serde_json::json!({
"module_averages": portfolio.module_averages.iter().map(|(module, score)| serde_json::json!({
"module": module,
"score": score,
})).collect::<Vec<_>>(),
"active_modules": portfolio.active_modules,
"consistency": consistency,
"page_types": portfolio.page_type_distribution.iter().map(|(page_type, count, percentage)| serde_json::json!({
"page_type": page_type,
"count": count,
"percentage": percentage,
})).collect::<Vec<_>>(),
"content_quality": {
"strongest_pages": portfolio.strongest_content_pages.iter().map(|(url, page_type, score)| serde_json::json!({
"url": url, "page_type": page_type, "score": score,
})).collect::<Vec<_>>(),
"weakest_pages": portfolio.weakest_content_pages.iter().map(|(url, page_type, score)| serde_json::json!({
"url": url, "page_type": page_type, "score": score,
})).collect::<Vec<_>>(),
},
"content_topics": {
"top_terms": portfolio.top_topics.iter().map(|(term, pages)| serde_json::json!({
"term": term, "page_count": pages,
})).collect::<Vec<_>>(),
"overlap_pairs": portfolio.overlap_pairs.iter().map(|(url_a, url_b, shared_terms)| serde_json::json!({
"url_a": url_a, "url_b": url_b, "shared_terms": shared_terms,
})).collect::<Vec<_>>(),
},
"duplicates": {
"exact": portfolio.duplicate_content,
"near": portfolio.near_duplicates.iter().map(|(url_a, url_b, similarity)| serde_json::json!({
"url_a": url_a, "url_b": url_b, "similarity_percent": similarity,
})).collect::<Vec<_>>(),
},
"performance": {
"budget_summary": portfolio.budget_summary.iter().map(|(metric, budget, affected_urls, severity)| serde_json::json!({
"metric": metric, "budget": budget, "affected_urls": affected_urls, "severity": severity,
})).collect::<Vec<_>>(),
"render_blocking_summary": portfolio.render_blocking_summary.iter().map(|(metric, value)| serde_json::json!({
"metric": metric, "value": value,
})).collect::<Vec<_>>(),
},
"structured_data": {
"type_distribution": portfolio.schema_distribution.iter().map(|(schema_type, pages)| serde_json::json!({
"schema_type": schema_type, "page_count": pages,
})).collect::<Vec<_>>(),
"pages_without_schema": portfolio.pages_without_schema,
"entity_conflicts": batch.consistency.as_ref().map(|c| c.schema_graph.conflicts.clone()).unwrap_or_default(),
},
"interactive_summary": interactive,
"accessibility_assessments": assessment_groups.into_iter().map(|((kind, rule_id, wcag_criterion), (occurrences, urls))| serde_json::json!({
"kind": kind,
"rule_id": rule_id,
"wcag_criterion": wcag_criterion,
"occurrences": occurrences,
"affected_url_count": urls.len(),
"affected_urls": urls,
})).collect::<Vec<_>>(),
})
}
impl UnifiedReport {
pub fn single(ctx: &AuditContext<'_>, raw: &AuditReport) -> Self {
let mut collection_errors: Vec<ReportError> = Vec::new();
let budget_violations = raw
.experience
.budget_violations
.iter()
.filter_map(|v| {
serde_json::to_value(v)
.map_err(|e| {
collection_errors.push(ReportError {
module: "budget_violations",
error_type: "serialization_failed",
reason: e.to_string(),
})
})
.ok()
})
.collect();
let screenshot_status = match &raw.screenshot_status {
crate::audit::ScreenshotStatus::NotRequested => None,
s => serde_json::to_value(s)
.map_err(|e| {
collection_errors.push(ReportError {
module: "screenshot_status",
error_type: "serialization_failed",
reason: e.to_string(),
})
})
.ok(),
};
let detail_ctx = DetailContext {
budget_violations,
screenshot_status,
collection_errors,
};
let page = build_page(&ctx.normalized, Some(ctx), Some(detail_ctx));
Self::wrap_single(ctx, page)
}
pub fn single_from_normalized(normalized: &NormalizedReport) -> Self {
let page = build_page(normalized, None, Some(DetailContext::default()));
Self::wrap_single_from_normalized(normalized, page)
}
pub fn batch(batch_report: &BatchReport) -> Self {
let normalized_reports: Vec<NormalizedReport> = batch_report
.reports
.iter()
.map(|r| crate::audit::normalize(r).normalized)
.collect();
let i18n = crate::i18n::I18n::new("en").expect("canonical JSON locale must load");
let presentation =
build_batch_presentation_with_normalized(batch_report, &i18n, &normalized_reports);
let pages: Vec<PageEntry> = normalized_reports
.iter()
.map(|n| {
let mut page = build_page(n, None, None);
page.detail = Some(build_batch_detail(n));
page.content_profile = presentation
.url_details
.iter()
.find(|detail| detail.url == n.url)
.map(|detail| BatchContentProfile {
page_type: detail.page_type.clone(),
attributes: detail.page_attributes.clone(),
semantic_score: detail.page_semantic_score,
biggest_lever: detail.biggest_lever.clone(),
topic_terms: detail.topic_terms.clone(),
top_issues: detail.top_issues.clone(),
});
page
})
.collect();
let severity_counts = aggregate_severity(&pages);
let occurrence_counts = aggregate_occurrences(&pages);
let accessibility_score = batch_report.summary.average_score.round() as u32;
let overall_score_batch = presentation.portfolio_summary.average_overall_score;
let summary = UnifiedSummary {
url_count: batch_report.summary.total_urls,
accessibility_score,
overall_score: overall_score_batch,
score: overall_score_batch,
grade: AccessibilityScorer::calculate_grade(overall_score_batch as f32).to_string(),
certificate: AccessibilityScorer::calculate_certificate(overall_score_batch as f32)
.to_string(),
risk_level: batch_report.summary.risk,
violation_count: pages.iter().map(|p| p.violation_count).sum(),
finding_count: pages.iter().map(|p| p.finding_count).sum(),
finding_occurrence_count: pages.iter().map(|p| p.finding_occurrence_count).sum(),
severity_counts,
severity_counts_scope: "wcag_only".to_string(),
occurrence_counts,
occurrence_counts_scope: "wcag_only".to_string(),
passed_url_count: batch_report.summary.passed,
failed_url_count: batch_report.summary.failed,
attempted_url_count: Some(batch_report.summary.total_urls + batch_report.errors.len()),
exclusions: crate::audit::exclusion::BatchExclusionSummary::aggregate(
batch_report
.reports
.iter()
.filter_map(|r| r.accessibility.execution.exclusions.as_ref()),
),
violated_rule_count: batch_report.summary.violated_rule_count,
top_recurring_rules: batch_report.summary.top_recurring_rules.clone(),
template_clusters: batch_report.summary.template_clusters.clone(),
en301549_rollup: build_en301549_batch_rollup(&normalized_reports),
performance_score: avg_module_score(&pages, "Performance"),
seo_score: avg_module_score(&pages, "SEO"),
security_score: avg_module_score(&pages, "Security"),
mobile_score: avg_module_score(&pages, "Mobile"),
ux_score: avg_module_score(&pages, "UX"),
journey_score: avg_module_score(&pages, "Journey"),
performance_throttled_avg_score: None,
lh_mobile_score: None,
wcag_coverage: normalized_reports
.first()
.map(build_wcag_coverage_summary)
.unwrap_or_else(|| build_wcag_coverage_for_level("mixed")),
overall_score_weight_basis: overall_score_weight_basis(&normalized_reports),
overall_score_weight_basis_uniform: Some(weight_basis_is_uniform(&normalized_reports)),
accessibility_score_breakdown: build_accessibility_score_breakdown(
&normalized_reports,
accessibility_score,
),
management_risks: build_management_risks(&normalized_reports),
top_actions: build_decision_actions(&normalized_reports),
duplicate_content: presentation.portfolio_summary.duplicate_content.clone(),
missing_tag_prevalence: presentation
.portfolio_summary
.missing_tag_prevalence
.clone(),
canonical_issues: presentation.portfolio_summary.canonical_issues.clone(),
hreflang_issues: presentation.portfolio_summary.hreflang_issues.clone(),
sitemap_http_issues: presentation.portfolio_summary.sitemap_http_issues.clone(),
orphan_sitemap_urls: presentation.portfolio_summary.orphan_sitemap_urls.clone(),
linked_not_in_sitemap: presentation.portfolio_summary.linked_not_in_sitemap.clone(),
robots_conflicts: presentation.portfolio_summary.robots_conflicts.clone(),
minification_inconsistencies: presentation
.portfolio_summary
.minification_inconsistencies
.clone(),
redirect_chain_issues: presentation.portfolio_summary.redirect_chain_issues.clone(),
crawl_depth_diagnostics: presentation
.portfolio_summary
.crawl_depth_diagnostics
.clone(),
commerce: crate::commerce::aggregate_site_commerce(
batch_report
.reports
.iter()
.filter_map(|r| r.commerce.as_ref()),
),
};
let mut collection_errors: Vec<ReportError> = Vec::new();
let crawl_diagnostics = batch_report.crawl_diagnostics.as_ref().and_then(|c| {
serde_json::to_value(c)
.map_err(|e| {
collection_errors.push(ReportError {
module: "crawl_diagnostics",
error_type: "serialization_failed",
reason: e.to_string(),
})
})
.ok()
});
let sitemap_diagnostics = batch_report.sitemap_diagnostics.as_ref().and_then(|s| {
serde_json::to_value(s)
.map_err(|e| {
collection_errors.push(ReportError {
module: "sitemap_diagnostics",
error_type: "serialization_failed",
reason: e.to_string(),
})
})
.ok()
});
let errors: Vec<serde_json::Value> = batch_report
.errors
.iter()
.filter_map(|e| {
serde_json::to_value(e)
.map_err(|err| {
collection_errors.push(ReportError {
module: "errors",
error_type: "serialization_failed",
reason: err.to_string(),
})
})
.ok()
})
.collect();
let site_analysis = build_site_analysis(batch_report, &presentation, &normalized_reports);
UnifiedReport {
schema_version: SCHEMA_VERSION,
report_type: "batch",
tool_version: env!("CARGO_PKG_VERSION"),
build_id: env!("AUDITMYSITE_BUILD_SHA"),
metadata: ReportMetadata {
tool: format!("auditmysite v{}", env!("CARGO_PKG_VERSION")),
timestamp: batch_report_timestamp(batch_report),
wcag_level: normalized_reports
.first()
.map(|r| r.wcag_level.to_string())
.unwrap_or_else(|| "mixed".to_string()),
execution_time_ms: batch_report.total_duration_ms,
},
audit_scope: normalized_reports
.first()
.map(|report| report.execution.scope.clone()),
execution_environment: normalized_reports
.first()
.map(|report| report.execution.environment.clone()),
audit_quality: aggregate_audit_quality(&normalized_reports),
metric_context: metric_context("batch"),
summary,
sample: batch_report.sample.clone(),
pages,
url_matrix: presentation.url_matrix,
internal_comparison: Some(build_internal_comparison(&normalized_reports)),
crawl_diagnostics,
sitemap_diagnostics,
site_analysis: Some(site_analysis),
artifacts: Vec::new(),
errors,
collection_errors,
verdict: Verdict::Pass,
verdict_reasons: Vec::new(),
}
}
pub fn to_json(&self, pretty: bool) -> Result<String> {
let output = if pretty {
serde_json::to_string_pretty(self)
} else {
serde_json::to_string(self)
};
output.map_err(|e| crate::error::AuditError::OutputError {
reason: format!("JSON serialization failed: {}", e),
})
}
pub fn with_verdict(mut self, vr: &VerdictResult) -> Self {
self.verdict = vr.verdict;
self.verdict_reasons = vr.reasons.clone();
self
}
fn wrap_single(ctx: &AuditContext<'_>, page: PageEntry) -> Self {
let no_legal_flags = !page.findings.iter().any(|f| f.is_legal_flag());
let passed = usize::from(
page.accessibility_score >= 80 && page.severity_counts.critical == 0 && no_legal_flags,
);
let violated_rule_count = page.violated_rule_count;
let (top_recurring_rules, _) =
compute_recurring_rules(std::slice::from_ref(&ctx.normalized));
let summary = UnifiedSummary {
url_count: 1,
accessibility_score: page.accessibility_score,
overall_score: page.overall_score,
score: page.overall_score,
grade: page.grade.clone(),
certificate: page.certificate.clone(),
risk_level: page.risk.level,
violation_count: page.violation_count,
finding_count: page.finding_count,
finding_occurrence_count: page.finding_occurrence_count,
severity_counts: page.severity_counts.clone(),
severity_counts_scope: "wcag_only".to_string(),
occurrence_counts: page.occurrence_counts.clone(),
occurrence_counts_scope: "wcag_only".to_string(),
passed_url_count: passed,
failed_url_count: 1 - passed,
attempted_url_count: None,
exclusions: None,
violated_rule_count,
top_recurring_rules,
template_clusters: Vec::new(),
en301549_rollup: Vec::new(),
performance_score: normalized_module_score(&ctx.normalized, "Performance"),
seo_score: normalized_module_score(&ctx.normalized, "SEO"),
security_score: normalized_module_score(&ctx.normalized, "Security"),
mobile_score: normalized_module_score(&ctx.normalized, "Mobile"),
ux_score: normalized_module_score(&ctx.normalized, "UX"),
journey_score: normalized_module_score(&ctx.normalized, "Journey"),
performance_throttled_avg_score: {
let scores: Vec<u32> = ctx
.raw_throttled_performance
.iter()
.map(|t| t.score)
.collect();
if scores.is_empty() {
None
} else {
Some(scores.iter().sum::<u32>() / scores.len() as u32)
}
},
lh_mobile_score: ctx
.raw_throttled_performance
.iter()
.find(|t| t.profile == crate::browser::ThrottleProfile::LhMobile)
.map(|t| t.score),
wcag_coverage: build_wcag_coverage_summary(&ctx.normalized),
overall_score_weight_basis: overall_score_weight_basis(std::slice::from_ref(
&ctx.normalized,
)),
overall_score_weight_basis_uniform: None,
accessibility_score_breakdown: build_accessibility_score_breakdown(
std::slice::from_ref(&ctx.normalized),
page.accessibility_score,
),
management_risks: build_management_risks(std::slice::from_ref(&ctx.normalized)),
top_actions: build_decision_actions(std::slice::from_ref(&ctx.normalized)),
duplicate_content: Vec::new(),
missing_tag_prevalence: Vec::new(),
canonical_issues: Vec::new(),
hreflang_issues: Vec::new(),
sitemap_http_issues: Vec::new(),
orphan_sitemap_urls: Vec::new(),
linked_not_in_sitemap: Vec::new(),
robots_conflicts: Vec::new(),
minification_inconsistencies: Vec::new(),
redirect_chain_issues: Vec::new(),
crawl_depth_diagnostics: None,
commerce: None,
};
UnifiedReport {
schema_version: SCHEMA_VERSION,
report_type: "single",
tool_version: env!("CARGO_PKG_VERSION"),
build_id: env!("AUDITMYSITE_BUILD_SHA"),
metadata: ReportMetadata {
tool: format!("auditmysite v{}", env!("CARGO_PKG_VERSION")),
timestamp: ctx.normalized.timestamp,
wcag_level: ctx.normalized.wcag_level.to_string(),
execution_time_ms: ctx.normalized.duration_ms,
},
audit_scope: Some(ctx.normalized.execution.scope.clone()),
execution_environment: Some(ctx.normalized.execution.environment.clone()),
audit_quality: ctx.normalized.execution.quality.clone(),
metric_context: metric_context("single"),
summary,
sample: None,
pages: vec![page],
url_matrix: Vec::new(),
internal_comparison: None,
crawl_diagnostics: None,
sitemap_diagnostics: None,
site_analysis: None,
artifacts: artifacts_for(&ctx.normalized),
errors: Vec::new(),
collection_errors: Vec::new(),
verdict: Verdict::Pass,
verdict_reasons: Vec::new(),
}
}
fn wrap_single_from_normalized(normalized: &NormalizedReport, page: PageEntry) -> Self {
let no_legal_flags = !page.findings.iter().any(|f| f.is_legal_flag());
let passed = usize::from(
page.accessibility_score >= 80 && page.severity_counts.critical == 0 && no_legal_flags,
);
let violated_rule_count = page.violated_rule_count;
let (top_recurring_rules, _) = compute_recurring_rules(std::slice::from_ref(normalized));
let summary = UnifiedSummary {
url_count: 1,
accessibility_score: page.accessibility_score,
overall_score: page.overall_score,
score: page.overall_score,
grade: page.grade.clone(),
certificate: page.certificate.clone(),
risk_level: page.risk.level,
violation_count: page.violation_count,
finding_count: page.finding_count,
finding_occurrence_count: page.finding_occurrence_count,
severity_counts: page.severity_counts.clone(),
severity_counts_scope: "wcag_only".to_string(),
occurrence_counts: page.occurrence_counts.clone(),
occurrence_counts_scope: "wcag_only".to_string(),
passed_url_count: passed,
failed_url_count: 1 - passed,
attempted_url_count: None,
exclusions: None,
violated_rule_count,
top_recurring_rules,
template_clusters: Vec::new(),
en301549_rollup: Vec::new(),
performance_score: normalized_module_score(normalized, "Performance"),
seo_score: normalized_module_score(normalized, "SEO"),
security_score: normalized_module_score(normalized, "Security"),
mobile_score: normalized_module_score(normalized, "Mobile"),
ux_score: normalized_module_score(normalized, "UX"),
journey_score: normalized_module_score(normalized, "Journey"),
performance_throttled_avg_score: None,
lh_mobile_score: None,
wcag_coverage: build_wcag_coverage_summary(normalized),
overall_score_weight_basis: overall_score_weight_basis(std::slice::from_ref(
normalized,
)),
overall_score_weight_basis_uniform: None,
accessibility_score_breakdown: build_accessibility_score_breakdown(
std::slice::from_ref(normalized),
page.accessibility_score,
),
management_risks: build_management_risks(std::slice::from_ref(normalized)),
top_actions: build_decision_actions(std::slice::from_ref(normalized)),
duplicate_content: Vec::new(),
missing_tag_prevalence: Vec::new(),
canonical_issues: Vec::new(),
hreflang_issues: Vec::new(),
sitemap_http_issues: Vec::new(),
orphan_sitemap_urls: Vec::new(),
linked_not_in_sitemap: Vec::new(),
robots_conflicts: Vec::new(),
minification_inconsistencies: Vec::new(),
redirect_chain_issues: Vec::new(),
crawl_depth_diagnostics: None,
commerce: None,
};
UnifiedReport {
schema_version: SCHEMA_VERSION,
report_type: "single",
tool_version: env!("CARGO_PKG_VERSION"),
build_id: env!("AUDITMYSITE_BUILD_SHA"),
metadata: ReportMetadata {
tool: format!("auditmysite v{}", env!("CARGO_PKG_VERSION")),
timestamp: normalized.timestamp,
wcag_level: normalized.wcag_level.to_string(),
execution_time_ms: normalized.duration_ms,
},
audit_scope: Some(normalized.execution.scope.clone()),
execution_environment: Some(normalized.execution.environment.clone()),
audit_quality: normalized.execution.quality.clone(),
metric_context: metric_context("single"),
summary,
sample: None,
pages: vec![page],
url_matrix: Vec::new(),
internal_comparison: None,
crawl_diagnostics: None,
sitemap_diagnostics: None,
site_analysis: None,
artifacts: artifacts_for(normalized),
errors: Vec::new(),
collection_errors: Vec::new(),
verdict: Verdict::Pass,
verdict_reasons: Vec::new(),
}
}
}