git_slop/report_ops/
sarif.rs1use super::*;
2
3fn sarif_level(record: &Value) -> &'static str {
4 let slop_band = string(record.get("slop_band"));
5 let context_band = string(record.get("context_band"));
6 if slop_band == "critical" || context_band == "critical" || context_band == "refactor_required"
7 {
8 "error"
9 } else if matches!(slop_band.as_str(), "high" | "moderate") || context_band == "warning" {
10 "warning"
11 } else {
12 "note"
13 }
14}
15
16fn sarif_record(report: &Value, queue_item: &Value) -> Value {
17 let path = string(queue_item.get("path"));
18 let mut record = find_record(report, &path)
19 .map(|(record, _)| record)
20 .unwrap_or_else(|| queue_item.clone());
21 let record_object = record.as_object_mut().expect("record object");
22 for key in ["slop_score", "slop_band", "context_band", "reason_codes"] {
23 if !record_object.contains_key(key) {
24 record_object.insert(
25 key.to_string(),
26 queue_item.get(key).cloned().unwrap_or_else(|| {
27 if key == "reason_codes" {
28 json!([])
29 } else {
30 Value::Null
31 }
32 }),
33 );
34 }
35 }
36 record
37}
38
39fn sarif_result(record: &Value, rank: usize) -> Value {
40 let path = string(record.get("path"));
41 let reasons = string_array(record.get("reason_codes"));
42 let reasons_text = if reasons.is_empty() {
43 "no reason codes".to_string()
44 } else {
45 reasons.join(", ")
46 };
47 let overlays: Map<String, Value> = strongest_pressures(record.get("overlays"), 8)
48 .into_iter()
49 .map(|(label, value)| (label, json!(round6(value))))
50 .collect();
51 json!({
52 "ruleId": "git-slop.hotspot",
53 "ruleIndex": 0,
54 "level": sarif_level(record),
55 "message": {
56 "text": format!(
57 "{path} is ranked {} with slop_score {} and context {} ({reasons_text}).",
58 json_scalar_text(record.get("slop_band")),
59 json_scalar_text(record.get("slop_score")),
60 json_scalar_text(record.get("context_band")),
61 ),
62 },
63 "locations": [{
64 "physicalLocation": {
65 "artifactLocation": {"uri": path},
66 },
67 }],
68 "properties": {
69 "git_slop": {
70 "rank": rank,
71 "slop_score": record.get("slop_score").cloned().unwrap_or(Value::Null),
72 "slop_band": record.get("slop_band").cloned().unwrap_or(Value::Null),
73 "context_band": record.get("context_band").cloned().unwrap_or(Value::Null),
74 "reason_codes": record.get("reason_codes").cloned().unwrap_or_else(|| json!([])),
75 "costs": record.get("costs").cloned().unwrap_or_else(|| json!({})),
76 "strongest_overlays": Value::Object(overlays),
77 "evidence_boundary": "Hotspot cost and overlay evidence are preserved as separate properties; SARIF export does not rescore the finding.",
78 },
79 },
80 })
81}
82
83pub fn sarif_payload(
84 report: &Value,
85 report_path: Option<&str>,
86 top: Option<usize>,
87) -> Result<Value> {
88 require_report_schema(report, "sarif")?;
89 if top == Some(0) {
90 bail!("--top must be greater than zero.");
91 }
92 let queue = array_at(report, &["action_queue"]);
93 let take = top.unwrap_or(queue.len());
94 let results: Vec<Value> = queue
95 .iter()
96 .take(take)
97 .enumerate()
98 .filter_map(|(index, item)| {
99 item.get("path")
100 .and_then(Value::as_str)
101 .map(|_| sarif_result(&sarif_record(report, item), index + 1))
102 })
103 .collect();
104 let repo = report.get("repo").unwrap_or(&Value::Null);
105 let repository_uri = repo
106 .get("remote_url")
107 .or_else(|| repo.get("git_remote_url"))
108 .or_else(|| repo.get("repo_name"))
109 .cloned()
110 .unwrap_or(Value::Null);
111 let revision_id = repo
112 .get("head_sha")
113 .or_else(|| repo.get("head_commit"))
114 .cloned()
115 .unwrap_or(Value::Null);
116 Ok(json!({
117 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
118 "version": "2.1.0",
119 "runs": [{
120 "tool": {
121 "driver": {
122 "name": "git-slop",
123 "informationUri": "https://github.com/coreycoto/git-slop",
124 "rules": [{
125 "id": "git-slop.hotspot",
126 "name": "Git Slop hotspot",
127 "shortDescription": {"text": "File ranked in the git-slop action queue."},
128 "fullDescription": {"text": "A deterministic git-slop hotspot based on context cost. Overlay evidence is exported separately in result properties and does not change detector scoring."},
129 "help": {"text": "Review the git-slop report, explain output, or plan output for supporting evidence before deciding whether maintenance work is appropriate."},
130 "properties": {
131 "precision": "medium",
132 "tags": ["maintainability", "context-cost", "git-slop"],
133 },
134 }],
135 },
136 },
137 "automationDetails": {"id": "git-slop/sarif"},
138 "versionControlProvenance": [{
139 "repositoryUri": repository_uri,
140 "revisionId": revision_id,
141 }],
142 "invocations": [{
143 "executionSuccessful": true,
144 "properties": {
145 "git_slop": {
146 "schema_version": SARIF_SCHEMA_VERSION,
147 "report_schema_version": report.get("schema_version").cloned().unwrap_or(Value::Null),
148 "report_path": report_path,
149 "boundary_note": SARIF_BOUNDARY_NOTE,
150 },
151 },
152 }],
153 "results": results,
154 "properties": {
155 "git_slop": {
156 "summary": report.get("summary").cloned().unwrap_or_else(|| json!({})),
157 "stats": report.get("stats").cloned().unwrap_or_else(|| json!({})),
158 "boundary_note": SARIF_BOUNDARY_NOTE,
159 },
160 },
161 }],
162 }))
163}
164
165pub fn render_json(payload: &Value) -> Result<String> {
166 Ok(format!("{}\n", serde_json::to_string_pretty(payload)?))
167}