use crate::audit::{AuditReport, Finding, GateResult, Severity};
#[must_use]
pub fn format(report: &AuditReport) -> String {
let runs = serde_json::json!([build_run(report)]);
let doc = serde_json::json!({
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": runs,
});
serde_json::to_string_pretty(&doc)
.unwrap_or_else(|_| String::from("{\"version\":\"2.1.0\",\"runs\":[]}"))
}
fn build_run(report: &AuditReport) -> serde_json::Value {
serde_json::json!({
"tool": {
"driver": {
"name": "ssg",
"informationUri": "https://github.com/sebastienrousseau/static-site-generator",
"semanticVersion": env!("CARGO_PKG_VERSION"),
"rules": rules_for(report),
}
},
"results": results_for(report),
})
}
fn rules_for(report: &AuditReport) -> Vec<serde_json::Value> {
report.gates.iter().map(rule_for_gate).collect()
}
fn rule_for_gate(g: &GateResult) -> serde_json::Value {
serde_json::json!({
"id": g.name,
"name": g.name,
"shortDescription": { "text": format!("ssg audit gate `{}`", g.name) },
"fullDescription": { "text": describe_gate(&g.name) },
"helpUri": format!(
"https://github.com/sebastienrousseau/static-site-generator#audit-gate-{}",
g.name
),
})
}
fn describe_gate(name: &str) -> String {
match name {
"wcag" => "WCAG 2.2 Level AA conformance checks against the built HTML.".to_string(),
"jsonld" => "Schema.org JSON-LD blocks: schema validity, required fields, type coherence.".to_string(),
"hreflang" => "Multilingual hreflang link-rel completeness and reciprocity.".to_string(),
"csp_sri" => "Content Security Policy + Subresource Integrity hashes.".to_string(),
"pqc_tls" => "Post-quantum-aware TLS and HSTS edge headers.".to_string(),
"html5" => "HTML5 structural validity (doctype, charset, landmarks).".to_string(),
"links" => "Internal link resolution; optional external HEAD probing.".to_string(),
"metadata" => "Open Graph + Twitter Card + canonical chain completeness.".to_string(),
"markdownlint" => "Markdown formatting and frontmatter sanity.".to_string(),
"performance" => "Lighthouse-aligned performance budgets (Speed Index, LCP, CLS).".to_string(),
"ai_discovery" => "agents.txt + .well-known/{ai-plugin.json,mcp.json} discovery files.".to_string(),
"feeds" => "RSS / Atom / JSON Feed schema validity.".to_string(),
"images" => "Responsive <picture>, alt text presence, modern format coverage.".to_string(),
"search_index" => "ssg-search artefacts: embeddings.bin, model.bin, tokenizer.bin, manifest.json.".to_string(),
other => format!("ssg audit gate `{other}` (third-party).") ,
}
}
fn results_for(report: &AuditReport) -> Vec<serde_json::Value> {
let mut out = Vec::new();
for gate in &report.gates {
for finding in &gate.findings {
out.push(result_for_finding(finding));
}
}
out
}
fn result_for_finding(f: &Finding) -> serde_json::Value {
let mut obj = serde_json::Map::new();
let rule_id = match &f.code {
Some(code) => format!("{}.{}", f.gate, code),
None => f.gate.clone(),
};
let _ = obj.insert("ruleId".into(), serde_json::Value::String(rule_id));
let level = match f.severity {
Severity::Error => "error",
Severity::Warn => "warning",
Severity::Info => "note",
};
let _ = obj.insert("level".into(), serde_json::Value::String(level.into()));
let _ =
obj.insert("message".into(), serde_json::json!({ "text": f.message }));
let uri = f.path.as_deref().unwrap_or("<site-wide>");
let _ = obj.insert(
"locations".into(),
serde_json::json!([{
"physicalLocation": {
"artifactLocation": {
"uri": uri,
"uriBaseId": "%SRCROOT%",
}
}
}]),
);
let _ = obj.insert(
"properties".into(),
serde_json::json!({
"gate": f.gate,
"severity": severity_str(f.severity),
}),
);
serde_json::Value::Object(obj)
}
const fn severity_str(sev: Severity) -> &'static str {
match sev {
Severity::Error => "error",
Severity::Warn => "warn",
Severity::Info => "info",
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::audit::{Finding, GateResult, SeverityCounts};
fn empty_report() -> AuditReport {
AuditReport { gates: vec![] }
}
fn report_with_two_findings() -> AuditReport {
AuditReport {
gates: vec![
GateResult {
name: "wcag".to_string(),
skipped: false,
skip_reason: None,
severity_counts: SeverityCounts {
info: 0,
warn: 0,
error: 1,
},
findings: vec![Finding::new(
"wcag",
Severity::Error,
"<img> missing alt",
)
.with_code("WCAG-1.1.1")
.with_path("blog/post.html")],
},
GateResult {
name: "hreflang".to_string(),
skipped: false,
skip_reason: None,
severity_counts: SeverityCounts {
info: 1,
warn: 0,
error: 0,
},
findings: vec![Finding::new(
"hreflang",
Severity::Info,
"no-op site-wide finding",
)],
},
],
}
}
#[test]
fn emits_top_level_sarif_shape() {
let s = format(&empty_report());
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["version"], "2.1.0");
assert!(v["$schema"].as_str().unwrap().contains("sarif-spec"));
assert!(v["runs"].is_array());
assert_eq!(v["runs"].as_array().unwrap().len(), 1);
}
#[test]
fn driver_carries_name_and_semantic_version() {
let s = format(&empty_report());
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let driver = &v["runs"][0]["tool"]["driver"];
assert_eq!(driver["name"], "ssg");
assert_eq!(driver["semanticVersion"], env!("CARGO_PKG_VERSION"));
assert!(driver["informationUri"]
.as_str()
.unwrap()
.starts_with("https://"));
}
#[test]
fn one_rule_per_gate() {
let s = format(&report_with_two_findings());
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let rules = v["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
assert_eq!(rules.len(), 2);
let ids: Vec<&str> =
rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
assert!(ids.contains(&"wcag"));
assert!(ids.contains(&"hreflang"));
}
#[test]
fn known_gate_has_description() {
let s = format(&report_with_two_findings());
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let wcag = v["runs"][0]["tool"]["driver"]["rules"][0]
["fullDescription"]["text"]
.as_str()
.unwrap()
.to_string();
assert!(wcag.contains("WCAG"));
}
#[test]
fn unknown_gate_uses_generic_description() {
let report = AuditReport {
gates: vec![GateResult {
name: "thirdparty".to_string(),
skipped: false,
skip_reason: None,
severity_counts: SeverityCounts::default(),
findings: vec![],
}],
};
let s = format(&report);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let txt = v["runs"][0]["tool"]["driver"]["rules"][0]["fullDescription"]
["text"]
.as_str()
.unwrap()
.to_string();
assert!(txt.contains("third-party"));
}
#[test]
fn severity_to_sarif_level_mapping() {
let report = report_with_two_findings();
let s = format(&report);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let results = v["runs"][0]["results"].as_array().unwrap();
assert_eq!(results[0]["level"], "error");
assert_eq!(results[1]["level"], "note");
}
#[test]
fn warn_severity_maps_to_warning_level_and_warn_str() {
let report = AuditReport {
gates: vec![GateResult {
name: "links".to_string(),
skipped: false,
skip_reason: None,
severity_counts: SeverityCounts {
info: 0,
warn: 1,
error: 0,
},
findings: vec![Finding::new(
"links",
Severity::Warn,
"soft warning",
)
.with_path("blog/x.html")],
}],
};
let s = format(&report);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let r0 = &v["runs"][0]["results"][0];
assert_eq!(r0["level"], "warning");
assert_eq!(r0["properties"]["severity"], "warn");
}
#[test]
fn rule_id_includes_code_when_present() {
let s = format(&report_with_two_findings());
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let r0 = &v["runs"][0]["results"][0];
assert_eq!(r0["ruleId"], "wcag.WCAG-1.1.1");
}
#[test]
fn rule_id_omits_code_when_absent() {
let s = format(&report_with_two_findings());
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let r1 = &v["runs"][0]["results"][1];
assert_eq!(r1["ruleId"], "hreflang");
}
#[test]
fn path_emits_physical_location_block() {
let s = format(&report_with_two_findings());
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let r0 = &v["runs"][0]["results"][0];
assert_eq!(
r0["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
"blog/post.html"
);
assert_eq!(
r0["locations"][0]["physicalLocation"]["artifactLocation"]
["uriBaseId"],
"%SRCROOT%"
);
}
#[test]
fn site_wide_finding_emits_synthetic_location() {
let s = format(&report_with_two_findings());
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let r1 = &v["runs"][0]["results"][1];
let loc = &r1["locations"][0]["physicalLocation"]["artifactLocation"];
assert_eq!(loc["uri"], "<site-wide>");
assert_eq!(loc["uriBaseId"], "%SRCROOT%");
}
#[test]
fn properties_carry_gate_and_severity() {
let s = format(&report_with_two_findings());
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let r0 = &v["runs"][0]["results"][0];
assert_eq!(r0["properties"]["gate"], "wcag");
assert_eq!(r0["properties"]["severity"], "error");
}
#[test]
fn message_text_carries_human_readable() {
let s = format(&report_with_two_findings());
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(
v["runs"][0]["results"][0]["message"]["text"],
"<img> missing alt"
);
}
#[test]
fn empty_report_emits_empty_results_array() {
let s = format(&empty_report());
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let results = v["runs"][0]["results"].as_array().unwrap();
assert!(results.is_empty());
}
}