Skip to main content

fallow_api/
sarif_output.rs

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