1use crate::audit::{AuditReport, Finding, GateResult, Severity};
37
38#[must_use]
52pub fn format(report: &AuditReport) -> String {
53 let runs = serde_json::json!([build_run(report)]);
54 let doc = serde_json::json!({
55 "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
56 "version": "2.1.0",
57 "runs": runs,
58 });
59 stringify(serde_json::to_string_pretty(&doc))
64}
65
66fn stringify(res: Result<String, serde_json::Error>) -> String {
70 res.unwrap_or_else(|_| String::from("{\"version\":\"2.1.0\",\"runs\":[]}"))
71}
72
73fn build_run(report: &AuditReport) -> serde_json::Value {
75 serde_json::json!({
76 "tool": {
77 "driver": {
78 "name": "ssg",
79 "informationUri": "https://github.com/sebastienrousseau/static-site-generator",
80 "semanticVersion": env!("CARGO_PKG_VERSION"),
81 "rules": rules_for(report),
82 }
83 },
84 "results": results_for(report),
85 })
86}
87
88fn rules_for(report: &AuditReport) -> Vec<serde_json::Value> {
90 report.gates.iter().map(rule_for_gate).collect()
91}
92
93fn rule_for_gate(g: &GateResult) -> serde_json::Value {
95 serde_json::json!({
96 "id": g.name,
97 "name": g.name,
98 "shortDescription": { "text": format!("ssg audit gate `{}`", g.name) },
99 "fullDescription": { "text": describe_gate(&g.name) },
100 "helpUri": format!(
101 "https://github.com/sebastienrousseau/static-site-generator#audit-gate-{}",
102 g.name
103 ),
104 })
105}
106
107fn describe_gate(name: &str) -> String {
111 match name {
112 "wcag" => "WCAG 2.2 Level AA conformance checks against the built HTML.".to_string(),
113 "jsonld" => "Schema.org JSON-LD blocks: schema validity, required fields, type coherence.".to_string(),
114 "hreflang" => "Multilingual hreflang link-rel completeness and reciprocity.".to_string(),
115 "csp_sri" => "Content Security Policy + Subresource Integrity hashes.".to_string(),
116 "pqc_tls" => "Post-quantum-aware TLS and HSTS edge headers.".to_string(),
117 "html5" => "HTML5 structural validity (doctype, charset, landmarks).".to_string(),
118 "links" => "Internal link resolution; optional external HEAD probing.".to_string(),
119 "metadata" => "Open Graph + Twitter Card + canonical chain completeness.".to_string(),
120 "markdownlint" => "Markdown formatting and frontmatter sanity.".to_string(),
121 "performance" => "Lighthouse-aligned performance budgets (Speed Index, LCP, CLS).".to_string(),
122 "ai_discovery" => "agents.txt + .well-known/{ai-plugin.json,mcp.json} discovery files.".to_string(),
123 "feeds" => "RSS / Atom / JSON Feed schema validity.".to_string(),
124 "images" => "Responsive <picture>, alt text presence, modern format coverage.".to_string(),
125 "search_index" => "ssg-search artefacts: embeddings.bin, model.bin, tokenizer.bin, manifest.json.".to_string(),
126 "lang_consistency" => "JSON-LD inLanguage vs <html lang> base-language consistency.".to_string(),
127 other => format!("ssg audit gate `{other}` (third-party).") ,
128 }
129}
130
131fn results_for(report: &AuditReport) -> Vec<serde_json::Value> {
133 let mut out = Vec::new();
134 for gate in &report.gates {
135 for finding in &gate.findings {
136 out.push(result_for_finding(finding));
137 }
138 }
139 out
140}
141
142fn result_for_finding(f: &Finding) -> serde_json::Value {
144 let mut obj = serde_json::Map::new();
145
146 let rule_id = match &f.code {
150 Some(code) => format!("{}.{}", f.gate, code),
151 None => f.gate.clone(),
152 };
153 let _ = obj.insert("ruleId".into(), serde_json::Value::String(rule_id));
154
155 let level = match f.severity {
157 Severity::Error => "error",
158 Severity::Warn => "warning",
159 Severity::Info => "note",
160 };
161 let _ = obj.insert("level".into(), serde_json::Value::String(level.into()));
162
163 let _ =
164 obj.insert("message".into(), serde_json::json!({ "text": f.message }));
165
166 let uri = f.path.as_deref().unwrap_or("<site-wide>");
173 let _ = obj.insert(
174 "locations".into(),
175 serde_json::json!([{
176 "physicalLocation": {
177 "artifactLocation": {
178 "uri": uri,
179 "uriBaseId": "%SRCROOT%",
180 }
181 }
182 }]),
183 );
184
185 let _ = obj.insert(
187 "properties".into(),
188 serde_json::json!({
189 "gate": f.gate,
190 "severity": severity_str(f.severity),
191 }),
192 );
193
194 serde_json::Value::Object(obj)
195}
196
197const fn severity_str(sev: Severity) -> &'static str {
199 match sev {
200 Severity::Error => "error",
201 Severity::Warn => "warn",
202 Severity::Info => "info",
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::audit::{Finding, GateResult, SeverityCounts};
210
211 fn empty_report() -> AuditReport {
212 AuditReport { gates: vec![] }
213 }
214
215 fn report_with_two_findings() -> AuditReport {
216 AuditReport {
217 gates: vec![
218 GateResult {
219 name: "wcag".to_string(),
220 skipped: false,
221 skip_reason: None,
222 severity_counts: SeverityCounts {
223 info: 0,
224 warn: 0,
225 error: 1,
226 },
227 findings: vec![Finding::new(
228 "wcag",
229 Severity::Error,
230 "<img> missing alt",
231 )
232 .with_code("WCAG-1.1.1")
233 .with_path("blog/post.html")],
234 },
235 GateResult {
236 name: "hreflang".to_string(),
237 skipped: false,
238 skip_reason: None,
239 severity_counts: SeverityCounts {
240 info: 1,
241 warn: 0,
242 error: 0,
243 },
244 findings: vec![Finding::new(
245 "hreflang",
246 Severity::Info,
247 "no-op site-wide finding",
248 )],
249 },
250 ],
251 }
252 }
253
254 #[test]
255 fn emits_top_level_sarif_shape() {
256 let s = format(&empty_report());
257 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
258 assert_eq!(v["version"], "2.1.0");
259 assert!(v["$schema"].as_str().unwrap().contains("sarif-spec"));
260 assert!(v["runs"].is_array());
261 assert_eq!(v["runs"].as_array().unwrap().len(), 1);
262 }
263
264 #[test]
265 fn driver_carries_name_and_semantic_version() {
266 let s = format(&empty_report());
267 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
268 let driver = &v["runs"][0]["tool"]["driver"];
269 assert_eq!(driver["name"], "ssg");
270 assert_eq!(driver["semanticVersion"], env!("CARGO_PKG_VERSION"));
271 assert!(driver["informationUri"]
272 .as_str()
273 .unwrap()
274 .starts_with("https://"));
275 }
276
277 #[test]
278 fn one_rule_per_gate() {
279 let s = format(&report_with_two_findings());
280 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
281 let rules = v["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
282 assert_eq!(rules.len(), 2);
283 let ids: Vec<&str> =
284 rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
285 assert!(ids.contains(&"wcag"));
286 assert!(ids.contains(&"hreflang"));
287 }
288
289 #[test]
290 fn known_gate_has_description() {
291 let s = format(&report_with_two_findings());
293 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
294 let wcag = v["runs"][0]["tool"]["driver"]["rules"][0]
295 ["fullDescription"]["text"]
296 .as_str()
297 .unwrap()
298 .to_string();
299 assert!(wcag.contains("WCAG"));
301 }
302
303 #[test]
304 fn unknown_gate_uses_generic_description() {
305 let report = AuditReport {
306 gates: vec![GateResult {
307 name: "thirdparty".to_string(),
308 skipped: false,
309 skip_reason: None,
310 severity_counts: SeverityCounts::default(),
311 findings: vec![],
312 }],
313 };
314 let s = format(&report);
315 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
316 let txt = v["runs"][0]["tool"]["driver"]["rules"][0]["fullDescription"]
317 ["text"]
318 .as_str()
319 .unwrap()
320 .to_string();
321 assert!(txt.contains("third-party"));
322 }
323
324 #[test]
325 fn severity_to_sarif_level_mapping() {
326 let report = report_with_two_findings();
327 let s = format(&report);
328 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
329 let results = v["runs"][0]["results"].as_array().unwrap();
330 assert_eq!(results[0]["level"], "error");
331 assert_eq!(results[1]["level"], "note");
332 }
333
334 #[test]
335 fn warn_severity_maps_to_warning_level_and_warn_str() {
336 let report = AuditReport {
340 gates: vec![GateResult {
341 name: "links".to_string(),
342 skipped: false,
343 skip_reason: None,
344 severity_counts: SeverityCounts {
345 info: 0,
346 warn: 1,
347 error: 0,
348 },
349 findings: vec![Finding::new(
350 "links",
351 Severity::Warn,
352 "soft warning",
353 )
354 .with_path("blog/x.html")],
355 }],
356 };
357 let s = format(&report);
358 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
359 let r0 = &v["runs"][0]["results"][0];
360 assert_eq!(r0["level"], "warning");
361 assert_eq!(r0["properties"]["severity"], "warn");
362 }
363
364 #[test]
365 fn rule_id_includes_code_when_present() {
366 let s = format(&report_with_two_findings());
367 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
368 let r0 = &v["runs"][0]["results"][0];
369 assert_eq!(r0["ruleId"], "wcag.WCAG-1.1.1");
370 }
371
372 #[test]
373 fn rule_id_omits_code_when_absent() {
374 let s = format(&report_with_two_findings());
375 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
376 let r1 = &v["runs"][0]["results"][1];
377 assert_eq!(r1["ruleId"], "hreflang");
378 }
379
380 #[test]
381 fn path_emits_physical_location_block() {
382 let s = format(&report_with_two_findings());
383 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
384 let r0 = &v["runs"][0]["results"][0];
385 assert_eq!(
386 r0["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
387 "blog/post.html"
388 );
389 assert_eq!(
390 r0["locations"][0]["physicalLocation"]["artifactLocation"]
391 ["uriBaseId"],
392 "%SRCROOT%"
393 );
394 }
395
396 #[test]
397 fn site_wide_finding_emits_synthetic_location() {
398 let s = format(&report_with_two_findings());
402 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
403 let r1 = &v["runs"][0]["results"][1];
404 let loc = &r1["locations"][0]["physicalLocation"]["artifactLocation"];
405 assert_eq!(loc["uri"], "<site-wide>");
406 assert_eq!(loc["uriBaseId"], "%SRCROOT%");
407 }
408
409 #[test]
410 fn properties_carry_gate_and_severity() {
411 let s = format(&report_with_two_findings());
412 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
413 let r0 = &v["runs"][0]["results"][0];
414 assert_eq!(r0["properties"]["gate"], "wcag");
415 assert_eq!(r0["properties"]["severity"], "error");
416 }
417
418 #[test]
419 fn message_text_carries_human_readable() {
420 let s = format(&report_with_two_findings());
421 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
422 assert_eq!(
423 v["runs"][0]["results"][0]["message"]["text"],
424 "<img> missing alt"
425 );
426 }
427
428 #[test]
429 fn stringify_falls_back_to_minimal_document_on_error() {
430 let err = serde_json::from_str::<serde_json::Value>("{")
431 .expect_err("truncated JSON must fail");
432 let s = stringify(Err(err));
433 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
434 assert_eq!(v["version"], "2.1.0");
435 assert!(v["runs"].as_array().unwrap().is_empty());
436 }
437
438 #[test]
439 fn empty_report_emits_empty_results_array() {
440 let s = format(&empty_report());
441 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
442 let results = v["runs"][0]["results"].as_array().unwrap();
443 assert!(results.is_empty());
444 }
445}