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, build_sarif_document,
9    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);
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}
183
184fn health_sarif_rules(rule_builder: &SarifRuleBuilder<'_>) -> Vec<serde_json::Value> {
185    let mut rules = health_complexity_sarif_rules(rule_builder);
186    rules.extend(health_runtime_sarif_rules(rule_builder));
187    rules.extend(health_coverage_intelligence_sarif_rules(rule_builder));
188    rules
189}
190
191fn health_complexity_sarif_rules(rule_builder: &SarifRuleBuilder<'_>) -> Vec<serde_json::Value> {
192    vec![
193        rule_builder(
194            "fallow/high-cyclomatic-complexity",
195            "Function has high cyclomatic complexity",
196            "note",
197        ),
198        rule_builder(
199            "fallow/high-cognitive-complexity",
200            "Function has high cognitive complexity",
201            "note",
202        ),
203        rule_builder(
204            "fallow/high-complexity",
205            "Function exceeds both complexity thresholds",
206            "note",
207        ),
208        rule_builder(
209            "fallow/high-crap-score",
210            "Function has a high CRAP score (high complexity combined with low coverage)",
211            "warning",
212        ),
213        rule_builder(
214            "fallow/refactoring-target",
215            "File identified as a high-priority refactoring candidate",
216            "warning",
217        ),
218    ]
219}
220
221fn health_runtime_sarif_rules(rule_builder: &SarifRuleBuilder<'_>) -> Vec<serde_json::Value> {
222    vec![
223        rule_builder(
224            "fallow/untested-file",
225            "Runtime-reachable file has no test dependency path",
226            "warning",
227        ),
228        rule_builder(
229            "fallow/untested-export",
230            "Runtime-reachable export has no test dependency path",
231            "warning",
232        ),
233        rule_builder(
234            "fallow/runtime-safe-to-delete",
235            "Function is statically unused and was never invoked in production",
236            "warning",
237        ),
238        rule_builder(
239            "fallow/runtime-review-required",
240            "Function is statically used but was never invoked in production",
241            "warning",
242        ),
243        rule_builder(
244            "fallow/runtime-low-traffic",
245            "Function was invoked below the low-traffic threshold relative to total trace count",
246            "note",
247        ),
248        rule_builder(
249            "fallow/runtime-coverage-unavailable",
250            "Runtime coverage could not be resolved for this function",
251            "note",
252        ),
253        rule_builder(
254            "fallow/runtime-coverage",
255            "Runtime coverage finding",
256            "note",
257        ),
258    ]
259}
260
261fn health_coverage_intelligence_sarif_rules(
262    rule_builder: &SarifRuleBuilder<'_>,
263) -> Vec<serde_json::Value> {
264    vec![
265        rule_builder(
266            "fallow/coverage-intelligence-risky-change",
267            "Changed hot path combines high CRAP and low test coverage",
268            "warning",
269        ),
270        rule_builder(
271            "fallow/coverage-intelligence-delete",
272            "Static and runtime evidence indicate code can be deleted",
273            "warning",
274        ),
275        rule_builder(
276            "fallow/coverage-intelligence-review",
277            "Cold reachable uncovered code needs owner review",
278            "warning",
279        ),
280        rule_builder(
281            "fallow/coverage-intelligence-refactor",
282            "Hot covered code has high CRAP and should be refactored carefully",
283            "warning",
284        ),
285    ]
286}
287
288fn append_complexity_sarif_results(
289    sarif_results: &mut Vec<serde_json::Value>,
290    report: &HealthReport,
291    root: &Path,
292    snippets: &mut SourceSnippetCache,
293) {
294    for finding in &report.findings {
295        let uri = relative_uri(&finding.path, root);
296        let (rule_id, message) = health_complexity_sarif_message(finding, report);
297        let level = match finding.severity {
298            FindingSeverity::Critical => "error",
299            FindingSeverity::High => "warning",
300            FindingSeverity::Moderate => "note",
301        };
302        let source_snippet = snippets.line(&finding.path, finding.line);
303        sarif_results.push(sarif_result_with_snippet(
304            rule_id,
305            level,
306            &message,
307            &uri,
308            Some((finding.line, finding.col + 1)),
309            source_snippet.as_deref(),
310        ));
311    }
312}
313
314fn health_complexity_sarif_message(
315    finding: &fallow_output::ComplexityViolation,
316    report: &HealthReport,
317) -> (&'static str, String) {
318    match finding.exceeded {
319        ExceededThreshold::Cyclomatic => (
320            "fallow/high-cyclomatic-complexity",
321            format!(
322                "'{}' has cyclomatic complexity {} (threshold: {})",
323                finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
324            ),
325        ),
326        ExceededThreshold::Cognitive => (
327            "fallow/high-cognitive-complexity",
328            format!(
329                "'{}' has cognitive complexity {} (threshold: {})",
330                finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
331            ),
332        ),
333        ExceededThreshold::Both => (
334            "fallow/high-complexity",
335            format!(
336                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
337                finding.name,
338                finding.cyclomatic,
339                report.summary.max_cyclomatic_threshold,
340                finding.cognitive,
341                report.summary.max_cognitive_threshold,
342            ),
343        ),
344        ExceededThreshold::Crap
345        | ExceededThreshold::CyclomaticCrap
346        | ExceededThreshold::CognitiveCrap
347        | ExceededThreshold::All => {
348            let crap = finding.crap.unwrap_or(0.0);
349            let coverage = finding
350                .coverage_pct
351                .map(|pct| format!(", coverage {pct:.0}%"))
352                .unwrap_or_default();
353            (
354                "fallow/high-crap-score",
355                format!(
356                    "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
357                    finding.name,
358                    crap,
359                    report.summary.max_crap_threshold,
360                    finding.cyclomatic,
361                    coverage,
362                ),
363            )
364        }
365    }
366}
367
368fn append_refactoring_target_sarif_results(
369    sarif_results: &mut Vec<serde_json::Value>,
370    report: &HealthReport,
371    root: &Path,
372) {
373    for target in &report.targets {
374        let uri = relative_uri(&target.path, root);
375        let message = format!(
376            "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
377            target.category.label(),
378            target.recommendation,
379            target.priority,
380            target.efficiency,
381            target.effort.label(),
382            target.confidence.label(),
383        );
384        sarif_results.push(sarif_result(
385            "fallow/refactoring-target",
386            "warning",
387            &message,
388            &uri,
389            None,
390        ));
391    }
392}
393
394fn append_coverage_gap_sarif_results(
395    sarif_results: &mut Vec<serde_json::Value>,
396    report: &HealthReport,
397    root: &Path,
398    snippets: &mut SourceSnippetCache,
399) {
400    let Some(ref gaps) = report.coverage_gaps else {
401        return;
402    };
403    for item in &gaps.files {
404        let uri = relative_uri(&item.file.path, root);
405        let message = format!(
406            "File is runtime-reachable but has no test dependency path ({} value export{})",
407            item.file.value_export_count,
408            if item.file.value_export_count == 1 {
409                ""
410            } else {
411                "s"
412            },
413        );
414        sarif_results.push(sarif_result(
415            "fallow/untested-file",
416            "warning",
417            &message,
418            &uri,
419            None,
420        ));
421    }
422
423    for item in &gaps.exports {
424        let uri = relative_uri(&item.export.path, root);
425        let message = format!(
426            "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
427            item.export.export_name
428        );
429        let source_snippet = snippets.line(&item.export.path, item.export.line);
430        sarif_results.push(sarif_result_with_snippet(
431            "fallow/untested-export",
432            "warning",
433            &message,
434            &uri,
435            Some((item.export.line, item.export.col + 1)),
436            source_snippet.as_deref(),
437        ));
438    }
439}
440
441fn append_runtime_coverage_sarif_results(
442    sarif_results: &mut Vec<serde_json::Value>,
443    production: &RuntimeCoverageReport,
444    root: &Path,
445    snippets: &mut SourceSnippetCache,
446) {
447    for finding in &production.findings {
448        let uri = relative_uri(&finding.path, root);
449        let rule_id = match finding.verdict {
450            RuntimeCoverageVerdict::SafeToDelete => "fallow/runtime-safe-to-delete",
451            RuntimeCoverageVerdict::ReviewRequired => "fallow/runtime-review-required",
452            RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
453            RuntimeCoverageVerdict::CoverageUnavailable => "fallow/runtime-coverage-unavailable",
454            RuntimeCoverageVerdict::Active | RuntimeCoverageVerdict::Unknown => {
455                "fallow/runtime-coverage"
456            }
457        };
458        let level = match finding.verdict {
459            RuntimeCoverageVerdict::SafeToDelete | RuntimeCoverageVerdict::ReviewRequired => {
460                "warning"
461            }
462            _ => "note",
463        };
464        let invocations_hint = finding.invocations.map_or_else(
465            || "untracked".to_owned(),
466            |hits| format!("{hits} invocations"),
467        );
468        let message = format!(
469            "'{}' runtime coverage verdict: {} ({})",
470            finding.function,
471            finding.verdict.human_label(),
472            invocations_hint,
473        );
474        let source_snippet = snippets.line(&finding.path, finding.line);
475        sarif_results.push(sarif_result_with_snippet(
476            rule_id,
477            level,
478            &message,
479            &uri,
480            Some((finding.line, 1)),
481            source_snippet.as_deref(),
482        ));
483    }
484}
485
486fn append_coverage_intelligence_sarif_results(
487    sarif_results: &mut Vec<serde_json::Value>,
488    intelligence: &CoverageIntelligenceReport,
489    root: &Path,
490    snippets: &mut SourceSnippetCache,
491) {
492    for finding in &intelligence.findings {
493        let rule_id = coverage_intelligence_rule_id(finding.recommendation);
494        let level = match finding.verdict {
495            CoverageIntelligenceVerdict::Clean | CoverageIntelligenceVerdict::Unknown => continue,
496            _ => "warning",
497        };
498        let uri = relative_uri(&finding.path, root);
499        let identity = finding.identity.as_deref().unwrap_or("code");
500        let signals = finding
501            .signals
502            .iter()
503            .map(ToString::to_string)
504            .collect::<Vec<_>>()
505            .join(", ");
506        let message = format!(
507            "'{}' coverage intelligence verdict: {} ({}, signals: {})",
508            identity, finding.verdict, finding.recommendation, signals,
509        );
510        let source_snippet = snippets.line(&finding.path, finding.line);
511        let mut result = sarif_result_with_snippet(
512            rule_id,
513            level,
514            &message,
515            &uri,
516            Some((finding.line, 1)),
517            source_snippet.as_deref(),
518        );
519        result["properties"] = serde_json::json!({
520            "coverage_intelligence_id": &finding.id,
521            "verdict": finding.verdict,
522            "recommendation": finding.recommendation,
523            "confidence": finding.confidence,
524            "signals": &finding.signals,
525            "related_ids": &finding.related_ids,
526        });
527        sarif_results.push(result);
528    }
529}
530
531fn coverage_intelligence_rule_id(
532    recommendation: CoverageIntelligenceRecommendation,
533) -> &'static str {
534    match recommendation {
535        CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
536            "fallow/coverage-intelligence-risky-change"
537        }
538        CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
539            "fallow/coverage-intelligence-delete"
540        }
541        CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
542            "fallow/coverage-intelligence-review"
543        }
544        CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
545            "fallow/coverage-intelligence-refactor"
546        }
547    }
548}
549
550fn sarif_document(
551    sarif_results: &[serde_json::Value],
552    sarif_rules: &[serde_json::Value],
553) -> serde_json::Value {
554    build_sarif_document(SarifDocumentInput {
555        results: sarif_results,
556        rules: sarif_rules,
557        tool_version: env!("CARGO_PKG_VERSION"),
558    })
559}
560
561fn sarif_result(
562    rule_id: &str,
563    level: &str,
564    message: &str,
565    uri: &str,
566    region: Option<(u32, u32)>,
567) -> serde_json::Value {
568    sarif_result_with_snippet(rule_id, level, message, uri, region, None)
569}
570
571fn sarif_result_with_snippet(
572    rule_id: &str,
573    level: &str,
574    message: &str,
575    uri: &str,
576    region: Option<(u32, u32)>,
577    snippet: Option<&str>,
578) -> serde_json::Value {
579    build_sarif_result(SarifResultInput {
580        rule_id,
581        level,
582        message,
583        uri,
584        region,
585        snippet,
586    })
587}
588
589fn relative_uri(path: &Path, root: &Path) -> String {
590    normalize_uri(
591        &path
592            .strip_prefix(root)
593            .unwrap_or(path)
594            .display()
595            .to_string(),
596    )
597}
598
599#[cfg(test)]
600mod tests {
601    use std::path::PathBuf;
602
603    use fallow_output::{SarifRuleInput, build_sarif_rule};
604    use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
605
606    use super::*;
607
608    fn rule(id: &str, short_description: &str, level: &str) -> serde_json::Value {
609        build_sarif_rule(SarifRuleInput {
610            id,
611            short_description,
612            level,
613            full_description: None,
614            help_uri: None,
615        })
616    }
617
618    #[test]
619    fn grouped_duplication_sarif_attaches_group_property() {
620        let root = PathBuf::from("/repo");
621        let report = DuplicationReport {
622            clone_groups: vec![CloneGroup {
623                instances: vec![CloneInstance {
624                    file: root.join("src/a.ts"),
625                    start_line: 2,
626                    end_line: 5,
627                    start_col: 0,
628                    end_col: 1,
629                    fragment: "copy();".to_string(),
630                }],
631                token_count: 10,
632                line_count: 4,
633            }],
634            clone_families: Vec::new(),
635            mirrored_directories: Vec::new(),
636            stats: DuplicationStats::default(),
637        };
638
639        let sarif = build_grouped_duplication_sarif(&report, &root, &rule, |_| "src".to_string());
640
641        assert_eq!(sarif["runs"][0]["results"][0]["properties"]["group"], "src");
642        assert_eq!(
643            sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]
644                ["uri"],
645            "src/a.ts"
646        );
647    }
648}