Skip to main content

git_slop/report_ops/
sarif.rs

1use 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}