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