Skip to main content

fallow_output/
analysis_sarif.rs

1//! Shared SARIF output assembly for health and duplication reports.
2
3use std::path::Path;
4
5use crate::{
6    CoverageIntelligenceRecommendation, CoverageIntelligenceReport, CoverageIntelligenceVerdict,
7    ExceededThreshold, FindingSeverity, HealthReport, RuntimeCoverageReport,
8    RuntimeCoverageVerdict, SarifDocumentInput, SarifSourceSnippetCache as SourceSnippetCache,
9    StylingFindingSeverity, build_sarif_document,
10    build_sarif_result_with_snippet as sarif_result_with_snippet, normalize_uri,
11};
12use fallow_types::duplicates::{CloneGroup, DuplicationReport};
13use fallow_types::output_dead_code::EffectiveSeverity;
14
15type SarifRuleBuilder<'a> = dyn Fn(&str, &str, &str) -> serde_json::Value + 'a;
16
17/// Build SARIF output from duplication analysis results.
18#[must_use]
19pub fn build_duplication_sarif(
20    report: &DuplicationReport,
21    root: &Path,
22    rule_builder: &SarifRuleBuilder<'_>,
23) -> serde_json::Value {
24    build_duplication_sarif_with_group(report, root, rule_builder, |_| None)
25}
26
27/// Build grouped SARIF output from duplication analysis results.
28#[must_use]
29pub fn build_grouped_duplication_sarif(
30    report: &DuplicationReport,
31    root: &Path,
32    rule_builder: &SarifRuleBuilder<'_>,
33    group_for_clone: impl Fn(&CloneGroup) -> String,
34) -> serde_json::Value {
35    build_duplication_sarif_with_group(report, root, rule_builder, |group| {
36        Some(group_for_clone(group))
37    })
38}
39
40#[expect(
41    clippy::cast_possible_truncation,
42    reason = "line and column values are bounded by source size"
43)]
44fn build_duplication_sarif_with_group(
45    report: &DuplicationReport,
46    root: &Path,
47    rule_builder: &SarifRuleBuilder<'_>,
48    group_for_clone: impl Fn(&CloneGroup) -> Option<String>,
49) -> serde_json::Value {
50    let mut sarif_results = Vec::new();
51    let mut snippets = SourceSnippetCache::with_root(root);
52
53    for (i, group) in report.clone_groups.iter().enumerate() {
54        let group_value = group_for_clone(group);
55        for instance in &group.instances {
56            let uri = relative_uri(&instance.file, root);
57            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
58            let mut result = sarif_result_with_snippet(
59                "fallow/code-duplication",
60                "warning",
61                &format!(
62                    "Code clone group {} ({} lines, {} instances)",
63                    i + 1,
64                    group.line_count,
65                    group.instances.len()
66                ),
67                &uri,
68                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
69                source_snippet.as_deref(),
70            );
71            if let Some(group) = &group_value {
72                set_sarif_result_property(&mut result, "group", group.clone());
73            }
74            sarif_results.push(result);
75        }
76    }
77
78    let rules = vec![rule_builder(
79        "fallow/code-duplication",
80        "Duplicated code block",
81        "warning",
82    )];
83    sarif_document(&sarif_results, &rules)
84}
85
86/// Build SARIF output from a health report.
87#[must_use]
88pub fn build_health_sarif(
89    report: &HealthReport,
90    root: &Path,
91    rule_builder: &SarifRuleBuilder<'_>,
92) -> serde_json::Value {
93    let mut sarif_results = Vec::new();
94    let mut snippets = SourceSnippetCache::with_root(root);
95
96    append_health_sarif_results(report, root, &mut sarif_results, &mut snippets);
97    let health_rules = health_sarif_rules(rule_builder, report);
98    sarif_document(&sarif_results, &health_rules)
99}
100
101/// Add a SARIF result property by resolving each result URI through a caller.
102pub fn annotate_sarif_results(
103    sarif: &mut serde_json::Value,
104    property: &str,
105    mut value_for_uri: impl FnMut(&str) -> String,
106) {
107    if let Some(runs) = sarif
108        .get_mut("runs")
109        .and_then(serde_json::Value::as_array_mut)
110    {
111        for run in runs {
112            if let Some(results) = run
113                .get_mut("results")
114                .and_then(serde_json::Value::as_array_mut)
115            {
116                for result in results {
117                    let uri = result
118                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
119                        .and_then(serde_json::Value::as_str)
120                        .unwrap_or("");
121                    let value = value_for_uri(uri);
122                    set_sarif_result_property(result, property, value);
123                }
124            }
125        }
126    }
127}
128
129fn set_sarif_result_property(result: &mut serde_json::Value, key: &str, value: String) {
130    let Some(result) = result.as_object_mut() else {
131        return;
132    };
133    let props = result
134        .entry("properties")
135        .or_insert_with(|| serde_json::json!({}));
136    let Some(props) = props.as_object_mut() else {
137        return;
138    };
139    props.insert(key.to_string(), serde_json::Value::String(value));
140}
141
142fn append_health_sarif_results(
143    report: &HealthReport,
144    root: &Path,
145    sarif_results: &mut Vec<serde_json::Value>,
146    snippets: &mut SourceSnippetCache,
147) {
148    append_complexity_sarif_results(sarif_results, report, root, snippets);
149
150    if let Some(ref production) = report.runtime_coverage {
151        append_runtime_coverage_sarif_results(sarif_results, production, root, snippets);
152    }
153    if let Some(ref intelligence) = report.coverage_intelligence {
154        append_coverage_intelligence_sarif_results(sarif_results, intelligence, root, snippets);
155    }
156
157    append_refactoring_target_sarif_results(sarif_results, report, root);
158    append_coverage_gap_sarif_results(sarif_results, report, root, snippets);
159    append_styling_sarif_results(sarif_results, report, root);
160}
161
162/// SARIF results for the styling-domain findings (css-token-drift, ...). Uses the
163/// finding's kebab `code` as the SARIF rule id via the shared issue_meta contract,
164/// so every styling family surfaces uniformly. Advisory (`warning` level).
165fn append_styling_sarif_results(
166    sarif_results: &mut Vec<serde_json::Value>,
167    report: &HealthReport,
168    root: &Path,
169) {
170    for finding in &report.styling_findings {
171        let uri = relative_uri(std::path::Path::new(&finding.path), root);
172        let message = format!(
173            "[{}] {}: `{}`",
174            finding.code, finding.sub_kind, finding.value
175        );
176        sarif_results.push(sarif_result(
177            &format!("fallow/{}", finding.code),
178            styling_sarif_level(finding.effective_severity),
179            &message,
180            &uri,
181            Some((finding.line, 1)),
182        ));
183    }
184}
185
186fn health_styling_sarif_rules(
187    rule_builder: &SarifRuleBuilder<'_>,
188    report: &HealthReport,
189) -> Vec<serde_json::Value> {
190    vec![
191        rule_builder(
192            "fallow/css-token-drift",
193            "CSS / CSS-in-JS design-token drift (a hardcoded value where a token exists)",
194            styling_rule_default_level(report, "css-token-drift"),
195        ),
196        rule_builder(
197            "fallow/css-duplicate-block",
198            "CSS / CSS-in-JS duplicate declaration block",
199            styling_rule_default_level(report, "css-duplicate-block"),
200        ),
201        rule_builder(
202            "fallow/css-selector-complexity",
203            "CSS selector complexity, deep nesting, or important density",
204            styling_rule_default_level(report, "css-selector-complexity"),
205        ),
206        rule_builder(
207            "fallow/css-dead-surface",
208            "CSS / CSS-in-JS dead styling surface",
209            styling_rule_default_level(report, "css-dead-surface"),
210        ),
211        rule_builder(
212            "fallow/css-broken-reference",
213            "CSS / CSS-in-JS reference resolves to no known styling definition",
214            styling_rule_default_level(report, "css-broken-reference"),
215        ),
216    ]
217}
218
219fn health_sarif_rules(
220    rule_builder: &SarifRuleBuilder<'_>,
221    report: &HealthReport,
222) -> Vec<serde_json::Value> {
223    let mut rules = health_complexity_sarif_rules(rule_builder);
224    rules.extend(health_runtime_sarif_rules(rule_builder));
225    rules.extend(health_coverage_intelligence_sarif_rules(rule_builder));
226    rules.extend(health_styling_sarif_rules(rule_builder, report));
227    rules
228}
229
230fn styling_rule_default_level(report: &HealthReport, code: &str) -> &'static str {
231    if report.styling_findings.iter().any(|finding| {
232        finding.code == code && finding.effective_severity == StylingFindingSeverity::Error
233    }) {
234        "error"
235    } else {
236        "warning"
237    }
238}
239
240const fn styling_sarif_level(severity: StylingFindingSeverity) -> &'static str {
241    match severity {
242        StylingFindingSeverity::Error => "error",
243        StylingFindingSeverity::Warn => "warning",
244    }
245}
246
247fn health_complexity_sarif_rules(rule_builder: &SarifRuleBuilder<'_>) -> Vec<serde_json::Value> {
248    vec![
249        rule_builder(
250            "fallow/high-cyclomatic-complexity",
251            "Function has high cyclomatic complexity",
252            "note",
253        ),
254        rule_builder(
255            "fallow/high-cognitive-complexity",
256            "Function has high cognitive complexity",
257            "note",
258        ),
259        rule_builder(
260            "fallow/high-complexity",
261            "Function exceeds both complexity thresholds",
262            "note",
263        ),
264        rule_builder(
265            "fallow/high-crap-score",
266            "Function has a high CRAP score (high complexity combined with low coverage)",
267            "warning",
268        ),
269        rule_builder(
270            "fallow/refactoring-target",
271            "File identified as a high-priority refactoring candidate",
272            "warning",
273        ),
274    ]
275}
276
277fn health_runtime_sarif_rules(rule_builder: &SarifRuleBuilder<'_>) -> Vec<serde_json::Value> {
278    vec![
279        rule_builder(
280            "fallow/untested-file",
281            "Runtime-reachable file has no test dependency path",
282            "warning",
283        ),
284        rule_builder(
285            "fallow/untested-export",
286            "Runtime-reachable export has no test dependency path",
287            "warning",
288        ),
289        rule_builder(
290            "fallow/runtime-safe-to-delete",
291            "Function is statically unused and was never invoked in production",
292            "warning",
293        ),
294        rule_builder(
295            "fallow/runtime-review-required",
296            "Function is statically used but was never invoked in production",
297            "warning",
298        ),
299        rule_builder(
300            "fallow/runtime-low-traffic",
301            "Function was invoked below the low-traffic threshold relative to total trace count",
302            "note",
303        ),
304        rule_builder(
305            "fallow/runtime-coverage-unavailable",
306            "Runtime coverage could not be resolved for this function",
307            "note",
308        ),
309        rule_builder(
310            "fallow/runtime-coverage",
311            "Runtime coverage finding",
312            "note",
313        ),
314    ]
315}
316
317fn health_coverage_intelligence_sarif_rules(
318    rule_builder: &SarifRuleBuilder<'_>,
319) -> Vec<serde_json::Value> {
320    vec![
321        rule_builder(
322            "fallow/coverage-intelligence-risky-change",
323            "Changed hot path combines high CRAP and low test coverage",
324            "warning",
325        ),
326        rule_builder(
327            "fallow/coverage-intelligence-delete",
328            "Static and runtime evidence indicate code can be deleted",
329            "warning",
330        ),
331        rule_builder(
332            "fallow/coverage-intelligence-review",
333            "Cold reachable uncovered code needs owner review",
334            "warning",
335        ),
336        rule_builder(
337            "fallow/coverage-intelligence-refactor",
338            "Hot covered code has high CRAP and should be refactored carefully",
339            "warning",
340        ),
341    ]
342}
343
344fn append_complexity_sarif_results(
345    sarif_results: &mut Vec<serde_json::Value>,
346    report: &HealthReport,
347    root: &Path,
348    snippets: &mut SourceSnippetCache,
349) {
350    for finding in &report.findings {
351        let uri = relative_uri(&finding.path, root);
352        let (rule_id, message) = health_complexity_sarif_message(finding, report);
353        let level = complexity_sarif_level(finding);
354        let source_snippet = snippets.line(&finding.path, finding.line);
355        let mut result = sarif_result_with_snippet(
356            rule_id,
357            level,
358            &message,
359            &uri,
360            Some((finding.line, finding.col + 1)),
361            source_snippet.as_deref(),
362        );
363        set_audit_attribution_properties(&mut result, finding);
364        sarif_results.push(result);
365    }
366}
367
368/// Audit only: put the attribution class, the grown metrics and the gate
369/// exemption of a complexity finding in the result property bag. The message
370/// and the fingerprints do not change.
371fn set_audit_attribution_properties(
372    result: &mut serde_json::Value,
373    finding: &crate::HealthFinding,
374) {
375    if finding.attribution.is_none() && finding.gate_exemption.is_none() {
376        return;
377    }
378    let Some(object) = result.as_object_mut() else {
379        return;
380    };
381    let props = object
382        .entry("properties")
383        .or_insert_with(|| serde_json::json!({}));
384    let Some(props) = props.as_object_mut() else {
385        return;
386    };
387    if let Some(attribution) = finding.attribution {
388        props.insert(
389            "attribution".to_string(),
390            serde_json::to_value(attribution).unwrap_or(serde_json::Value::Null),
391        );
392    }
393    if let Some(exemption) = finding.gate_exemption {
394        props.insert(
395            "gate_exemption".to_string(),
396            serde_json::Value::String(exemption.token().to_string()),
397        );
398    }
399    if !finding.worsened_metrics.is_empty() {
400        props.insert(
401            "worsened_metrics".to_string(),
402            serde_json::to_value(&finding.worsened_metrics).unwrap_or(serde_json::Value::Null),
403        );
404    }
405}
406
407/// The SARIF level of a complexity finding.
408///
409/// The gate severity from the `complexity-*` rules sets the level. The band
410/// stays in the message. A finding without a gate severity (a saved report
411/// from an older version) keeps the band mapping.
412const fn complexity_sarif_level(finding: &crate::ComplexityViolation) -> &'static str {
413    match finding.effective_severity {
414        Some(EffectiveSeverity::Error) => "error",
415        Some(EffectiveSeverity::Warn) => "warning",
416        None => match finding.severity {
417            FindingSeverity::Critical => "error",
418            FindingSeverity::High => "warning",
419            FindingSeverity::Moderate => "note",
420        },
421    }
422}
423
424/// Build the SARIF rule id and message for a complexity finding.
425///
426/// Thresholds come from the finding's resolved ceilings, so an override-affected
427/// finding is never described against a ceiling it was not evaluated with
428/// (issue #2163).
429fn health_complexity_sarif_message(
430    finding: &crate::ComplexityViolation,
431    report: &HealthReport,
432) -> (&'static str, String) {
433    let thresholds = finding.resolved_thresholds(&report.summary);
434    match finding.exceeded {
435        ExceededThreshold::Cyclomatic => (
436            "fallow/high-cyclomatic-complexity",
437            format!(
438                "'{}' has cyclomatic complexity {} (threshold: {})",
439                finding.name, finding.cyclomatic, thresholds.max_cyclomatic,
440            ),
441        ),
442        ExceededThreshold::Cognitive => (
443            "fallow/high-cognitive-complexity",
444            format!(
445                "'{}' has cognitive complexity {} (threshold: {})",
446                finding.name, finding.cognitive, thresholds.max_cognitive,
447            ),
448        ),
449        ExceededThreshold::Both => (
450            "fallow/high-complexity",
451            format!(
452                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
453                finding.name,
454                finding.cyclomatic,
455                thresholds.max_cyclomatic,
456                finding.cognitive,
457                thresholds.max_cognitive,
458            ),
459        ),
460        ExceededThreshold::Crap
461        | ExceededThreshold::CyclomaticCrap
462        | ExceededThreshold::CognitiveCrap
463        | ExceededThreshold::All => {
464            let crap = finding.crap.unwrap_or(0.0);
465            let coverage = finding
466                .coverage_pct
467                .map(|pct| format!(", coverage {pct:.0}%"))
468                .unwrap_or_default();
469            (
470                "fallow/high-crap-score",
471                format!(
472                    "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
473                    finding.name, crap, thresholds.max_crap, finding.cyclomatic, coverage,
474                ),
475            )
476        }
477    }
478}
479
480fn append_refactoring_target_sarif_results(
481    sarif_results: &mut Vec<serde_json::Value>,
482    report: &HealthReport,
483    root: &Path,
484) {
485    for target in &report.targets {
486        let uri = relative_uri(&target.path, root);
487        let message = format!(
488            "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
489            target.category.label(),
490            target.recommendation,
491            target.priority,
492            target.efficiency,
493            target.effort.label(),
494            target.confidence.label(),
495        );
496        sarif_results.push(sarif_result(
497            "fallow/refactoring-target",
498            "warning",
499            &message,
500            &uri,
501            None,
502        ));
503    }
504}
505
506fn append_coverage_gap_sarif_results(
507    sarif_results: &mut Vec<serde_json::Value>,
508    report: &HealthReport,
509    root: &Path,
510    snippets: &mut SourceSnippetCache,
511) {
512    let Some(ref gaps) = report.coverage_gaps else {
513        return;
514    };
515    for item in &gaps.files {
516        let uri = relative_uri(&item.file.path, root);
517        let message = format!(
518            "File is runtime-reachable but has no test dependency path ({} value export{})",
519            item.file.value_export_count,
520            if item.file.value_export_count == 1 {
521                ""
522            } else {
523                "s"
524            },
525        );
526        sarif_results.push(sarif_result(
527            "fallow/untested-file",
528            "warning",
529            &message,
530            &uri,
531            None,
532        ));
533    }
534
535    for item in &gaps.exports {
536        let uri = relative_uri(&item.export.path, root);
537        let message = format!(
538            "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
539            item.export.export_name
540        );
541        let source_snippet = snippets.line(&item.export.path, item.export.line);
542        sarif_results.push(sarif_result_with_snippet(
543            "fallow/untested-export",
544            "warning",
545            &message,
546            &uri,
547            Some((item.export.line, item.export.col + 1)),
548            source_snippet.as_deref(),
549        ));
550    }
551}
552
553fn append_runtime_coverage_sarif_results(
554    sarif_results: &mut Vec<serde_json::Value>,
555    production: &RuntimeCoverageReport,
556    root: &Path,
557    snippets: &mut SourceSnippetCache,
558) {
559    for finding in &production.findings {
560        let uri = relative_uri(&finding.path, root);
561        let rule_id = match finding.verdict {
562            RuntimeCoverageVerdict::SafeToDelete => "fallow/runtime-safe-to-delete",
563            RuntimeCoverageVerdict::ReviewRequired => "fallow/runtime-review-required",
564            RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
565            RuntimeCoverageVerdict::CoverageUnavailable => "fallow/runtime-coverage-unavailable",
566            RuntimeCoverageVerdict::Active | RuntimeCoverageVerdict::Unknown => {
567                "fallow/runtime-coverage"
568            }
569        };
570        let level = match finding.verdict {
571            RuntimeCoverageVerdict::SafeToDelete | RuntimeCoverageVerdict::ReviewRequired => {
572                "warning"
573            }
574            _ => "note",
575        };
576        let invocations_hint = finding.invocations.map_or_else(
577            || "untracked".to_owned(),
578            |hits| format!("{hits} invocations"),
579        );
580        let message = format!(
581            "'{}' runtime coverage verdict: {} ({})",
582            finding.function,
583            finding.verdict.human_label(),
584            invocations_hint,
585        );
586        let source_snippet = snippets.line(&finding.path, finding.line);
587        sarif_results.push(sarif_result_with_snippet(
588            rule_id,
589            level,
590            &message,
591            &uri,
592            Some((finding.line, 1)),
593            source_snippet.as_deref(),
594        ));
595    }
596}
597
598fn append_coverage_intelligence_sarif_results(
599    sarif_results: &mut Vec<serde_json::Value>,
600    intelligence: &CoverageIntelligenceReport,
601    root: &Path,
602    snippets: &mut SourceSnippetCache,
603) {
604    for finding in &intelligence.findings {
605        let rule_id = coverage_intelligence_rule_id(finding.recommendation);
606        let level = match finding.verdict {
607            CoverageIntelligenceVerdict::Clean | CoverageIntelligenceVerdict::Unknown => continue,
608            _ => "warning",
609        };
610        let uri = relative_uri(&finding.path, root);
611        let identity = finding.identity.as_deref().unwrap_or("code");
612        let signals = finding
613            .signals
614            .iter()
615            .map(ToString::to_string)
616            .collect::<Vec<_>>()
617            .join(", ");
618        let message = format!(
619            "'{}' coverage intelligence verdict: {} ({}, signals: {})",
620            identity, finding.verdict, finding.recommendation, signals,
621        );
622        let source_snippet = snippets.line(&finding.path, finding.line);
623        let mut result = sarif_result_with_snippet(
624            rule_id,
625            level,
626            &message,
627            &uri,
628            Some((finding.line, 1)),
629            source_snippet.as_deref(),
630        );
631        result["properties"] = serde_json::json!({
632            "coverage_intelligence_id": &finding.id,
633            "verdict": finding.verdict,
634            "recommendation": finding.recommendation,
635            "confidence": finding.confidence,
636            "signals": &finding.signals,
637            "related_ids": &finding.related_ids,
638        });
639        sarif_results.push(result);
640    }
641}
642
643fn coverage_intelligence_rule_id(
644    recommendation: CoverageIntelligenceRecommendation,
645) -> &'static str {
646    match recommendation {
647        CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
648            "fallow/coverage-intelligence-risky-change"
649        }
650        CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
651            "fallow/coverage-intelligence-delete"
652        }
653        CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
654            "fallow/coverage-intelligence-review"
655        }
656        CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
657            "fallow/coverage-intelligence-refactor"
658        }
659    }
660}
661
662fn sarif_document(
663    sarif_results: &[serde_json::Value],
664    sarif_rules: &[serde_json::Value],
665) -> serde_json::Value {
666    build_sarif_document(SarifDocumentInput {
667        results: sarif_results,
668        rules: sarif_rules,
669        tool_version: env!("CARGO_PKG_VERSION"),
670    })
671}
672
673fn sarif_result(
674    rule_id: &str,
675    level: &str,
676    message: &str,
677    uri: &str,
678    region: Option<(u32, u32)>,
679) -> serde_json::Value {
680    sarif_result_with_snippet(rule_id, level, message, uri, region, None)
681}
682
683fn relative_uri(path: &Path, root: &Path) -> String {
684    normalize_uri(
685        &path
686            .strip_prefix(root)
687            .unwrap_or(path)
688            .display()
689            .to_string(),
690    )
691}
692
693#[cfg(test)]
694mod tests {
695    use std::path::PathBuf;
696
697    use crate::{SarifRuleInput, build_sarif_rule};
698    use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
699
700    use super::*;
701
702    fn rule(id: &str, short_description: &str, level: &str) -> serde_json::Value {
703        build_sarif_rule(SarifRuleInput {
704            id,
705            short_description,
706            level,
707            full_description: None,
708            help_uri: None,
709        })
710    }
711
712    #[test]
713    fn grouped_duplication_sarif_attaches_group_property() {
714        let root = PathBuf::from("/repo");
715        let report = DuplicationReport {
716            clone_groups: vec![CloneGroup {
717                instances: vec![CloneInstance {
718                    is_symlink: false,
719                    file: root.join("src/a.ts"),
720                    start_line: 2,
721                    end_line: 5,
722                    start_col: 0,
723                    end_col: 1,
724                    fragment: "copy();".to_string(),
725                }],
726                token_count: 10,
727                line_count: 4,
728                similarity: None,
729            }],
730            clone_families: Vec::new(),
731            mirrored_directories: Vec::new(),
732            stats: DuplicationStats::default(),
733        };
734
735        let sarif = build_grouped_duplication_sarif(&report, &root, &rule, |_| "src".to_string());
736
737        assert_eq!(sarif["runs"][0]["results"][0]["properties"]["group"], "src");
738        assert_eq!(
739            sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]
740                ["uri"],
741            "src/a.ts"
742        );
743    }
744
745    fn sarif_message_violation(
746        exceeded: ExceededThreshold,
747        effective: Option<crate::HealthEffectiveThresholds>,
748    ) -> crate::ComplexityViolation {
749        crate::ComplexityViolation {
750            path: PathBuf::from("src/Widget.svelte"),
751            name: "<template>".to_string(),
752            line: 5,
753            col: 0,
754            cyclomatic: 25,
755            standard_cyclomatic: None,
756            cognitive: 21,
757            line_count: 13,
758            param_count: 0,
759            react_hook_count: 0,
760            react_jsx_max_depth: 0,
761            react_prop_count: 0,
762            react_hook_profile: None,
763            exceeded,
764            severity: crate::FindingSeverity::High,
765            effective_severity: None,
766            crap: Some(90.0),
767            mutation: None,
768            coverage_pct: None,
769            coverage_tier: None,
770            coverage_source: None,
771            inherited_from: None,
772            component_rollup: None,
773            contributions: Vec::new(),
774            effective_thresholds: effective,
775            threshold_source: effective.map(|_| crate::ThresholdSource::Override),
776        }
777    }
778
779    #[test]
780    fn sarif_message_uses_the_override_ceiling_not_the_global_one() {
781        let report = HealthReport::default();
782        let overridden = crate::HealthEffectiveThresholds {
783            max_cyclomatic: 500,
784            max_cognitive: 500,
785            max_crap: 500.0,
786            max_unit_size: 60,
787        };
788
789        let (global_rule, global_message) = health_complexity_sarif_message(
790            &sarif_message_violation(ExceededThreshold::Cyclomatic, None),
791            &report,
792        );
793        assert_eq!(global_rule, "fallow/high-cyclomatic-complexity");
794        assert!(
795            global_message.contains(&format!(
796                "threshold: {}",
797                report.summary.max_cyclomatic_threshold
798            )),
799            "{global_message}"
800        );
801
802        let (_, override_message) = health_complexity_sarif_message(
803            &sarif_message_violation(ExceededThreshold::Cyclomatic, Some(overridden)),
804            &report,
805        );
806        assert!(
807            override_message.contains("threshold: 500"),
808            "an override-affected finding must not be described against the global ceiling: {override_message}"
809        );
810
811        let (crap_rule, crap_message) = health_complexity_sarif_message(
812            &sarif_message_violation(ExceededThreshold::Crap, Some(overridden)),
813            &report,
814        );
815        assert_eq!(crap_rule, "fallow/high-crap-score");
816        assert!(crap_message.contains("threshold: 500.0"), "{crap_message}");
817    }
818}