Skip to main content

fallow_cli/report/
sarif.rs

1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::{AnalysisResults, UnusedDependency, UnusedExport, UnusedMember};
7
8use super::grouping::{self, OwnershipResolver};
9use super::{emit_json, relative_uri};
10use crate::explain;
11
12/// Intermediate fields extracted from an issue for SARIF result construction.
13struct SarifFields {
14    rule_id: &'static str,
15    level: &'static str,
16    message: String,
17    uri: String,
18    region: Option<(u32, u32)>,
19    properties: Option<serde_json::Value>,
20}
21
22const fn severity_to_sarif_level(s: Severity) -> &'static str {
23    match s {
24        Severity::Error => "error",
25        Severity::Warn | Severity::Off => "warning",
26    }
27}
28
29/// Build a single SARIF result object.
30///
31/// When `region` is `Some((line, col))`, a `region` block with 1-based
32/// `startLine` and `startColumn` is included in the physical location.
33fn sarif_result(
34    rule_id: &str,
35    level: &str,
36    message: &str,
37    uri: &str,
38    region: Option<(u32, u32)>,
39) -> serde_json::Value {
40    let mut physical_location = serde_json::json!({
41        "artifactLocation": { "uri": uri }
42    });
43    if let Some((line, col)) = region {
44        physical_location["region"] = serde_json::json!({
45            "startLine": line,
46            "startColumn": col
47        });
48    }
49    serde_json::json!({
50        "ruleId": rule_id,
51        "level": level,
52        "message": { "text": message },
53        "locations": [{ "physicalLocation": physical_location }]
54    })
55}
56
57/// Append SARIF results for a slice of items using a closure to extract fields.
58fn push_sarif_results<T>(
59    sarif_results: &mut Vec<serde_json::Value>,
60    items: &[T],
61    extract: impl Fn(&T) -> SarifFields,
62) {
63    for item in items {
64        let fields = extract(item);
65        let mut result = sarif_result(
66            fields.rule_id,
67            fields.level,
68            &fields.message,
69            &fields.uri,
70            fields.region,
71        );
72        if let Some(props) = fields.properties {
73            result["properties"] = props;
74        }
75        sarif_results.push(result);
76    }
77}
78
79/// Build a SARIF rule definition with optional `fullDescription` and `helpUri`
80/// sourced from the centralized explain module.
81fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
82    explain::rule_by_id(id).map_or_else(
83        || {
84            serde_json::json!({
85                "id": id,
86                "shortDescription": { "text": fallback_short },
87                "defaultConfiguration": { "level": level }
88            })
89        },
90        |def| {
91            serde_json::json!({
92                "id": id,
93                "shortDescription": { "text": def.short },
94                "fullDescription": { "text": def.full },
95                "helpUri": explain::rule_docs_url(def),
96                "defaultConfiguration": { "level": level }
97            })
98        },
99    )
100}
101
102/// Extract SARIF fields for an unused export or type export.
103fn sarif_export_fields(
104    export: &UnusedExport,
105    root: &Path,
106    rule_id: &'static str,
107    level: &'static str,
108    kind: &str,
109    re_kind: &str,
110) -> SarifFields {
111    let label = if export.is_re_export { re_kind } else { kind };
112    SarifFields {
113        rule_id,
114        level,
115        message: format!(
116            "{} '{}' is never imported by other modules",
117            label, export.export_name
118        ),
119        uri: relative_uri(&export.path, root),
120        region: Some((export.line, export.col + 1)),
121        properties: if export.is_re_export {
122            Some(serde_json::json!({ "is_re_export": true }))
123        } else {
124            None
125        },
126    }
127}
128
129/// Extract SARIF fields for an unused dependency.
130fn sarif_dep_fields(
131    dep: &UnusedDependency,
132    root: &Path,
133    rule_id: &'static str,
134    level: &'static str,
135    section: &str,
136) -> SarifFields {
137    SarifFields {
138        rule_id,
139        level,
140        message: format!(
141            "Package '{}' is in {} but never imported",
142            dep.package_name, section
143        ),
144        uri: relative_uri(&dep.path, root),
145        region: if dep.line > 0 {
146            Some((dep.line, 1))
147        } else {
148            None
149        },
150        properties: None,
151    }
152}
153
154/// Extract SARIF fields for an unused enum or class member.
155fn sarif_member_fields(
156    member: &UnusedMember,
157    root: &Path,
158    rule_id: &'static str,
159    level: &'static str,
160    kind: &str,
161) -> SarifFields {
162    SarifFields {
163        rule_id,
164        level,
165        message: format!(
166            "{} member '{}.{}' is never referenced",
167            kind, member.parent_name, member.member_name
168        ),
169        uri: relative_uri(&member.path, root),
170        region: Some((member.line, member.col + 1)),
171        properties: None,
172    }
173}
174
175/// Build the SARIF rules list from the current rules configuration.
176fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
177    vec![
178        sarif_rule(
179            "fallow/unused-file",
180            "File is not reachable from any entry point",
181            severity_to_sarif_level(rules.unused_files),
182        ),
183        sarif_rule(
184            "fallow/unused-export",
185            "Export is never imported",
186            severity_to_sarif_level(rules.unused_exports),
187        ),
188        sarif_rule(
189            "fallow/unused-type",
190            "Type export is never imported",
191            severity_to_sarif_level(rules.unused_types),
192        ),
193        sarif_rule(
194            "fallow/unused-dependency",
195            "Dependency listed but never imported",
196            severity_to_sarif_level(rules.unused_dependencies),
197        ),
198        sarif_rule(
199            "fallow/unused-dev-dependency",
200            "Dev dependency listed but never imported",
201            severity_to_sarif_level(rules.unused_dev_dependencies),
202        ),
203        sarif_rule(
204            "fallow/unused-optional-dependency",
205            "Optional dependency listed but never imported",
206            severity_to_sarif_level(rules.unused_optional_dependencies),
207        ),
208        sarif_rule(
209            "fallow/type-only-dependency",
210            "Production dependency only used via type-only imports",
211            severity_to_sarif_level(rules.type_only_dependencies),
212        ),
213        sarif_rule(
214            "fallow/test-only-dependency",
215            "Production dependency only imported by test files",
216            severity_to_sarif_level(rules.test_only_dependencies),
217        ),
218        sarif_rule(
219            "fallow/unused-enum-member",
220            "Enum member is never referenced",
221            severity_to_sarif_level(rules.unused_enum_members),
222        ),
223        sarif_rule(
224            "fallow/unused-class-member",
225            "Class member is never referenced",
226            severity_to_sarif_level(rules.unused_class_members),
227        ),
228        sarif_rule(
229            "fallow/unresolved-import",
230            "Import could not be resolved",
231            severity_to_sarif_level(rules.unresolved_imports),
232        ),
233        sarif_rule(
234            "fallow/unlisted-dependency",
235            "Dependency used but not in package.json",
236            severity_to_sarif_level(rules.unlisted_dependencies),
237        ),
238        sarif_rule(
239            "fallow/duplicate-export",
240            "Export name appears in multiple modules",
241            severity_to_sarif_level(rules.duplicate_exports),
242        ),
243        sarif_rule(
244            "fallow/circular-dependency",
245            "Circular dependency chain detected",
246            severity_to_sarif_level(rules.circular_dependencies),
247        ),
248        sarif_rule(
249            "fallow/boundary-violation",
250            "Import crosses an architecture boundary",
251            severity_to_sarif_level(rules.boundary_violation),
252        ),
253        sarif_rule(
254            "fallow/stale-suppression",
255            "Suppression comment or tag no longer matches any issue",
256            severity_to_sarif_level(rules.stale_suppressions),
257        ),
258    ]
259}
260
261#[must_use]
262#[expect(
263    clippy::too_many_lines,
264    reason = "SARIF builder mapping all issue types to SARIF schema"
265)]
266pub fn build_sarif(
267    results: &AnalysisResults,
268    root: &Path,
269    rules: &RulesConfig,
270) -> serde_json::Value {
271    let mut sarif_results = Vec::new();
272
273    push_sarif_results(&mut sarif_results, &results.unused_files, |file| {
274        SarifFields {
275            rule_id: "fallow/unused-file",
276            level: severity_to_sarif_level(rules.unused_files),
277            message: "File is not reachable from any entry point".to_string(),
278            uri: relative_uri(&file.path, root),
279            region: None,
280            properties: None,
281        }
282    });
283
284    push_sarif_results(&mut sarif_results, &results.unused_exports, |export| {
285        sarif_export_fields(
286            export,
287            root,
288            "fallow/unused-export",
289            severity_to_sarif_level(rules.unused_exports),
290            "Export",
291            "Re-export",
292        )
293    });
294
295    push_sarif_results(&mut sarif_results, &results.unused_types, |export| {
296        sarif_export_fields(
297            export,
298            root,
299            "fallow/unused-type",
300            severity_to_sarif_level(rules.unused_types),
301            "Type export",
302            "Type re-export",
303        )
304    });
305
306    push_sarif_results(&mut sarif_results, &results.unused_dependencies, |dep| {
307        sarif_dep_fields(
308            dep,
309            root,
310            "fallow/unused-dependency",
311            severity_to_sarif_level(rules.unused_dependencies),
312            "dependencies",
313        )
314    });
315
316    push_sarif_results(
317        &mut sarif_results,
318        &results.unused_dev_dependencies,
319        |dep| {
320            sarif_dep_fields(
321                dep,
322                root,
323                "fallow/unused-dev-dependency",
324                severity_to_sarif_level(rules.unused_dev_dependencies),
325                "devDependencies",
326            )
327        },
328    );
329
330    push_sarif_results(
331        &mut sarif_results,
332        &results.unused_optional_dependencies,
333        |dep| {
334            sarif_dep_fields(
335                dep,
336                root,
337                "fallow/unused-optional-dependency",
338                severity_to_sarif_level(rules.unused_optional_dependencies),
339                "optionalDependencies",
340            )
341        },
342    );
343
344    push_sarif_results(&mut sarif_results, &results.type_only_dependencies, |dep| {
345        SarifFields {
346            rule_id: "fallow/type-only-dependency",
347            level: severity_to_sarif_level(rules.type_only_dependencies),
348            message: format!(
349                "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
350                dep.package_name
351            ),
352            uri: relative_uri(&dep.path, root),
353            region: if dep.line > 0 {
354                Some((dep.line, 1))
355            } else {
356                None
357            },
358            properties: None,
359        }
360    });
361
362    push_sarif_results(&mut sarif_results, &results.test_only_dependencies, |dep| {
363        SarifFields {
364            rule_id: "fallow/test-only-dependency",
365            level: severity_to_sarif_level(rules.test_only_dependencies),
366            message: format!(
367                "Package '{}' is only imported by test files (consider moving to devDependencies)",
368                dep.package_name
369            ),
370            uri: relative_uri(&dep.path, root),
371            region: if dep.line > 0 {
372                Some((dep.line, 1))
373            } else {
374                None
375            },
376            properties: None,
377        }
378    });
379
380    push_sarif_results(&mut sarif_results, &results.unused_enum_members, |member| {
381        sarif_member_fields(
382            member,
383            root,
384            "fallow/unused-enum-member",
385            severity_to_sarif_level(rules.unused_enum_members),
386            "Enum",
387        )
388    });
389
390    push_sarif_results(
391        &mut sarif_results,
392        &results.unused_class_members,
393        |member| {
394            sarif_member_fields(
395                member,
396                root,
397                "fallow/unused-class-member",
398                severity_to_sarif_level(rules.unused_class_members),
399                "Class",
400            )
401        },
402    );
403
404    push_sarif_results(&mut sarif_results, &results.unresolved_imports, |import| {
405        SarifFields {
406            rule_id: "fallow/unresolved-import",
407            level: severity_to_sarif_level(rules.unresolved_imports),
408            message: format!("Import '{}' could not be resolved", import.specifier),
409            uri: relative_uri(&import.path, root),
410            region: Some((import.line, import.col + 1)),
411            properties: None,
412        }
413    });
414
415    // Unlisted deps: one result per importing file (SARIF points to the import site)
416    for dep in &results.unlisted_dependencies {
417        for site in &dep.imported_from {
418            sarif_results.push(sarif_result(
419                "fallow/unlisted-dependency",
420                severity_to_sarif_level(rules.unlisted_dependencies),
421                &format!(
422                    "Package '{}' is imported but not listed in package.json",
423                    dep.package_name
424                ),
425                &relative_uri(&site.path, root),
426                Some((site.line, site.col + 1)),
427            ));
428        }
429    }
430
431    // Duplicate exports: one result per location (SARIF 2.1.0 section 3.27.12)
432    for dup in &results.duplicate_exports {
433        for loc in &dup.locations {
434            sarif_results.push(sarif_result(
435                "fallow/duplicate-export",
436                severity_to_sarif_level(rules.duplicate_exports),
437                &format!("Export '{}' appears in multiple modules", dup.export_name),
438                &relative_uri(&loc.path, root),
439                Some((loc.line, loc.col + 1)),
440            ));
441        }
442    }
443
444    push_sarif_results(
445        &mut sarif_results,
446        &results.circular_dependencies,
447        |cycle| {
448            let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
449            let mut display_chain = chain.clone();
450            if let Some(first) = chain.first() {
451                display_chain.push(first.clone());
452            }
453            let first_uri = chain.first().map_or_else(String::new, Clone::clone);
454            SarifFields {
455                rule_id: "fallow/circular-dependency",
456                level: severity_to_sarif_level(rules.circular_dependencies),
457                message: format!(
458                    "Circular dependency{}: {}",
459                    if cycle.is_cross_package {
460                        " (cross-package)"
461                    } else {
462                        ""
463                    },
464                    display_chain.join(" \u{2192} ")
465                ),
466                uri: first_uri,
467                region: if cycle.line > 0 {
468                    Some((cycle.line, cycle.col + 1))
469                } else {
470                    None
471                },
472                properties: None,
473            }
474        },
475    );
476
477    push_sarif_results(
478        &mut sarif_results,
479        &results.boundary_violations,
480        |violation| {
481            let from_uri = relative_uri(&violation.from_path, root);
482            let to_uri = relative_uri(&violation.to_path, root);
483            SarifFields {
484                rule_id: "fallow/boundary-violation",
485                level: severity_to_sarif_level(rules.boundary_violation),
486                message: format!(
487                    "Import from zone '{}' to zone '{}' is not allowed ({})",
488                    violation.from_zone, violation.to_zone, to_uri,
489                ),
490                uri: from_uri,
491                region: if violation.line > 0 {
492                    Some((violation.line, violation.col + 1))
493                } else {
494                    None
495                },
496                properties: None,
497            }
498        },
499    );
500
501    push_sarif_results(
502        &mut sarif_results,
503        &results.stale_suppressions,
504        |suppression| SarifFields {
505            rule_id: "fallow/stale-suppression",
506            level: severity_to_sarif_level(rules.stale_suppressions),
507            message: suppression.description(),
508            uri: relative_uri(&suppression.path, root),
509            region: Some((suppression.line, suppression.col + 1)),
510            properties: None,
511        },
512    );
513
514    serde_json::json!({
515        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
516        "version": "2.1.0",
517        "runs": [{
518            "tool": {
519                "driver": {
520                    "name": "fallow",
521                    "version": env!("CARGO_PKG_VERSION"),
522                    "informationUri": "https://github.com/fallow-rs/fallow",
523                    "rules": build_sarif_rules(rules)
524                }
525            },
526            "results": sarif_results
527        }]
528    })
529}
530
531pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
532    let sarif = build_sarif(results, root, rules);
533    emit_json(&sarif, "SARIF")
534}
535
536/// Print SARIF output with owner properties added to each result.
537///
538/// Calls `build_sarif` to produce the standard SARIF JSON, then post-processes
539/// each result to add `"properties": { "owner": "@team" }` by resolving the
540/// artifact location URI through the `OwnershipResolver`.
541pub(super) fn print_grouped_sarif(
542    results: &AnalysisResults,
543    root: &Path,
544    rules: &RulesConfig,
545    resolver: &OwnershipResolver,
546) -> ExitCode {
547    let mut sarif = build_sarif(results, root, rules);
548
549    // Post-process each result to inject the owner property.
550    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
551        for run in runs {
552            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
553                for result in results {
554                    let uri = result
555                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
556                        .and_then(|v| v.as_str())
557                        .unwrap_or("");
558                    // Decode percent-encoded brackets before ownership lookup
559                    // (SARIF URIs encode `[`/`]` as `%5B`/`%5D`)
560                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
561                    let owner =
562                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
563                    let props = result
564                        .as_object_mut()
565                        .expect("SARIF result should be an object")
566                        .entry("properties")
567                        .or_insert_with(|| serde_json::json!({}));
568                    props
569                        .as_object_mut()
570                        .expect("properties should be an object")
571                        .insert("owner".to_string(), serde_json::Value::String(owner));
572                }
573            }
574        }
575    }
576
577    emit_json(&sarif, "SARIF")
578}
579
580#[expect(
581    clippy::cast_possible_truncation,
582    reason = "line/col numbers are bounded by source size"
583)]
584pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
585    let mut sarif_results = Vec::new();
586
587    for (i, group) in report.clone_groups.iter().enumerate() {
588        for instance in &group.instances {
589            sarif_results.push(sarif_result(
590                "fallow/code-duplication",
591                "warning",
592                &format!(
593                    "Code clone group {} ({} lines, {} instances)",
594                    i + 1,
595                    group.line_count,
596                    group.instances.len()
597                ),
598                &relative_uri(&instance.file, root),
599                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
600            ));
601        }
602    }
603
604    let sarif = serde_json::json!({
605        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
606        "version": "2.1.0",
607        "runs": [{
608            "tool": {
609                "driver": {
610                    "name": "fallow",
611                    "version": env!("CARGO_PKG_VERSION"),
612                    "informationUri": "https://github.com/fallow-rs/fallow",
613                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
614                }
615            },
616            "results": sarif_results
617        }]
618    });
619
620    emit_json(&sarif, "SARIF")
621}
622
623// ── Health SARIF output ────────────────────────────────────────────
624// Note: file_scores are intentionally omitted from SARIF output.
625// SARIF is designed for diagnostic results (issues/findings), not metric tables.
626// File health scores are available in JSON, human, compact, and markdown formats.
627
628#[must_use]
629#[expect(
630    clippy::too_many_lines,
631    reason = "flat rules + results table: adding production-coverage rules pushed past the 150 line threshold but each section is a straightforward sequence of sarif_rule / sarif_result calls"
632)]
633pub fn build_health_sarif(
634    report: &crate::health_types::HealthReport,
635    root: &Path,
636) -> serde_json::Value {
637    use crate::health_types::ExceededThreshold;
638
639    let mut sarif_results = Vec::new();
640
641    for finding in &report.findings {
642        let uri = relative_uri(&finding.path, root);
643        // When CRAP contributes alongside complexity, use the CRAP rule as the
644        // most actionable identifier (CRAP combines complexity and coverage)
645        // and surface all exceeded dimensions in the message.
646        let (rule_id, message) = match finding.exceeded {
647            ExceededThreshold::Cyclomatic => (
648                "fallow/high-cyclomatic-complexity",
649                format!(
650                    "'{}' has cyclomatic complexity {} (threshold: {})",
651                    finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
652                ),
653            ),
654            ExceededThreshold::Cognitive => (
655                "fallow/high-cognitive-complexity",
656                format!(
657                    "'{}' has cognitive complexity {} (threshold: {})",
658                    finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
659                ),
660            ),
661            ExceededThreshold::Both => (
662                "fallow/high-complexity",
663                format!(
664                    "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
665                    finding.name,
666                    finding.cyclomatic,
667                    report.summary.max_cyclomatic_threshold,
668                    finding.cognitive,
669                    report.summary.max_cognitive_threshold,
670                ),
671            ),
672            ExceededThreshold::Crap
673            | ExceededThreshold::CyclomaticCrap
674            | ExceededThreshold::CognitiveCrap
675            | ExceededThreshold::All => {
676                let crap = finding.crap.unwrap_or(0.0);
677                let coverage = finding
678                    .coverage_pct
679                    .map(|pct| format!(", coverage {pct:.0}%"))
680                    .unwrap_or_default();
681                (
682                    "fallow/high-crap-score",
683                    format!(
684                        "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
685                        finding.name,
686                        crap,
687                        report.summary.max_crap_threshold,
688                        finding.cyclomatic,
689                        coverage,
690                    ),
691                )
692            }
693        };
694
695        let level = match finding.severity {
696            crate::health_types::FindingSeverity::Critical => "error",
697            crate::health_types::FindingSeverity::High => "warning",
698            crate::health_types::FindingSeverity::Moderate => "note",
699        };
700        sarif_results.push(sarif_result(
701            rule_id,
702            level,
703            &message,
704            &uri,
705            Some((finding.line, finding.col + 1)),
706        ));
707    }
708
709    if let Some(ref production) = report.production_coverage {
710        append_production_coverage_sarif_results(&mut sarif_results, production, root);
711    }
712
713    // Refactoring targets as SARIF results (warning level — advisory recommendations)
714    for target in &report.targets {
715        let uri = relative_uri(&target.path, root);
716        let message = format!(
717            "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
718            target.category.label(),
719            target.recommendation,
720            target.priority,
721            target.efficiency,
722            target.effort.label(),
723            target.confidence.label(),
724        );
725        sarif_results.push(sarif_result(
726            "fallow/refactoring-target",
727            "warning",
728            &message,
729            &uri,
730            None,
731        ));
732    }
733
734    if let Some(ref gaps) = report.coverage_gaps {
735        for item in &gaps.files {
736            let uri = relative_uri(&item.path, root);
737            let message = format!(
738                "File is runtime-reachable but has no test dependency path ({} value export{})",
739                item.value_export_count,
740                if item.value_export_count == 1 {
741                    ""
742                } else {
743                    "s"
744                },
745            );
746            sarif_results.push(sarif_result(
747                "fallow/untested-file",
748                "warning",
749                &message,
750                &uri,
751                None,
752            ));
753        }
754
755        for item in &gaps.exports {
756            let uri = relative_uri(&item.path, root);
757            let message = format!(
758                "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
759                item.export_name
760            );
761            sarif_results.push(sarif_result(
762                "fallow/untested-export",
763                "warning",
764                &message,
765                &uri,
766                Some((item.line, item.col + 1)),
767            ));
768        }
769    }
770
771    let health_rules = vec![
772        sarif_rule(
773            "fallow/high-cyclomatic-complexity",
774            "Function has high cyclomatic complexity",
775            "note",
776        ),
777        sarif_rule(
778            "fallow/high-cognitive-complexity",
779            "Function has high cognitive complexity",
780            "note",
781        ),
782        sarif_rule(
783            "fallow/high-complexity",
784            "Function exceeds both complexity thresholds",
785            "note",
786        ),
787        sarif_rule(
788            "fallow/high-crap-score",
789            "Function has a high CRAP score (high complexity combined with low coverage)",
790            "warning",
791        ),
792        sarif_rule(
793            "fallow/refactoring-target",
794            "File identified as a high-priority refactoring candidate",
795            "warning",
796        ),
797        sarif_rule(
798            "fallow/untested-file",
799            "Runtime-reachable file has no test dependency path",
800            "warning",
801        ),
802        sarif_rule(
803            "fallow/untested-export",
804            "Runtime-reachable export has no test dependency path",
805            "warning",
806        ),
807        sarif_rule(
808            "fallow/production-safe-to-delete",
809            "Function is statically unused and was never invoked in production",
810            "warning",
811        ),
812        sarif_rule(
813            "fallow/production-review-required",
814            "Function is statically used but was never invoked in production",
815            "warning",
816        ),
817        sarif_rule(
818            "fallow/production-low-traffic",
819            "Function was invoked below the low-traffic threshold relative to total trace count",
820            "note",
821        ),
822        sarif_rule(
823            "fallow/production-coverage-unavailable",
824            "Production coverage could not be resolved for this function",
825            "note",
826        ),
827        sarif_rule(
828            "fallow/production-coverage",
829            "Production coverage finding",
830            "note",
831        ),
832    ];
833
834    serde_json::json!({
835        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
836        "version": "2.1.0",
837        "runs": [{
838            "tool": {
839                "driver": {
840                    "name": "fallow",
841                    "version": env!("CARGO_PKG_VERSION"),
842                    "informationUri": "https://github.com/fallow-rs/fallow",
843                    "rules": health_rules
844                }
845            },
846            "results": sarif_results
847        }]
848    })
849}
850
851fn append_production_coverage_sarif_results(
852    sarif_results: &mut Vec<serde_json::Value>,
853    production: &crate::health_types::ProductionCoverageReport,
854    root: &Path,
855) {
856    for finding in &production.findings {
857        let uri = relative_uri(&finding.path, root);
858        let rule_id = match finding.verdict {
859            crate::health_types::ProductionCoverageVerdict::SafeToDelete => {
860                "fallow/production-safe-to-delete"
861            }
862            crate::health_types::ProductionCoverageVerdict::ReviewRequired => {
863                "fallow/production-review-required"
864            }
865            crate::health_types::ProductionCoverageVerdict::LowTraffic => {
866                "fallow/production-low-traffic"
867            }
868            crate::health_types::ProductionCoverageVerdict::CoverageUnavailable => {
869                "fallow/production-coverage-unavailable"
870            }
871            crate::health_types::ProductionCoverageVerdict::Active
872            | crate::health_types::ProductionCoverageVerdict::Unknown => {
873                "fallow/production-coverage"
874            }
875        };
876        let level = match finding.verdict {
877            crate::health_types::ProductionCoverageVerdict::SafeToDelete
878            | crate::health_types::ProductionCoverageVerdict::ReviewRequired => "warning",
879            _ => "note",
880        };
881        let invocations_hint = finding.invocations.map_or_else(
882            || "untracked".to_owned(),
883            |hits| format!("{hits} invocations"),
884        );
885        let message = format!(
886            "'{}' production coverage verdict: {} ({})",
887            finding.function,
888            finding.verdict.human_label(),
889            invocations_hint,
890        );
891        sarif_results.push(sarif_result(
892            rule_id,
893            level,
894            &message,
895            &uri,
896            Some((finding.line, 1)),
897        ));
898    }
899}
900
901pub(super) fn print_health_sarif(
902    report: &crate::health_types::HealthReport,
903    root: &Path,
904) -> ExitCode {
905    let sarif = build_health_sarif(report, root);
906    emit_json(&sarif, "SARIF")
907}
908
909#[cfg(test)]
910mod tests {
911    use super::*;
912    use crate::report::test_helpers::sample_results;
913    use fallow_core::results::*;
914    use std::path::PathBuf;
915
916    #[test]
917    fn sarif_has_required_top_level_fields() {
918        let root = PathBuf::from("/project");
919        let results = AnalysisResults::default();
920        let sarif = build_sarif(&results, &root, &RulesConfig::default());
921
922        assert_eq!(
923            sarif["$schema"],
924            "https://json.schemastore.org/sarif-2.1.0.json"
925        );
926        assert_eq!(sarif["version"], "2.1.0");
927        assert!(sarif["runs"].is_array());
928    }
929
930    #[test]
931    fn sarif_has_tool_driver_info() {
932        let root = PathBuf::from("/project");
933        let results = AnalysisResults::default();
934        let sarif = build_sarif(&results, &root, &RulesConfig::default());
935
936        let driver = &sarif["runs"][0]["tool"]["driver"];
937        assert_eq!(driver["name"], "fallow");
938        assert!(driver["version"].is_string());
939        assert_eq!(
940            driver["informationUri"],
941            "https://github.com/fallow-rs/fallow"
942        );
943    }
944
945    #[test]
946    fn sarif_declares_all_rules() {
947        let root = PathBuf::from("/project");
948        let results = AnalysisResults::default();
949        let sarif = build_sarif(&results, &root, &RulesConfig::default());
950
951        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
952            .as_array()
953            .expect("rules should be an array");
954        assert_eq!(rules.len(), 16);
955
956        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
957        assert!(rule_ids.contains(&"fallow/unused-file"));
958        assert!(rule_ids.contains(&"fallow/unused-export"));
959        assert!(rule_ids.contains(&"fallow/unused-type"));
960        assert!(rule_ids.contains(&"fallow/unused-dependency"));
961        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
962        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
963        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
964        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
965        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
966        assert!(rule_ids.contains(&"fallow/unused-class-member"));
967        assert!(rule_ids.contains(&"fallow/unresolved-import"));
968        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
969        assert!(rule_ids.contains(&"fallow/duplicate-export"));
970        assert!(rule_ids.contains(&"fallow/circular-dependency"));
971        assert!(rule_ids.contains(&"fallow/boundary-violation"));
972    }
973
974    #[test]
975    fn sarif_empty_results_no_results_entries() {
976        let root = PathBuf::from("/project");
977        let results = AnalysisResults::default();
978        let sarif = build_sarif(&results, &root, &RulesConfig::default());
979
980        let sarif_results = sarif["runs"][0]["results"]
981            .as_array()
982            .expect("results should be an array");
983        assert!(sarif_results.is_empty());
984    }
985
986    #[test]
987    fn sarif_unused_file_result() {
988        let root = PathBuf::from("/project");
989        let mut results = AnalysisResults::default();
990        results.unused_files.push(UnusedFile {
991            path: root.join("src/dead.ts"),
992        });
993
994        let sarif = build_sarif(&results, &root, &RulesConfig::default());
995        let entries = sarif["runs"][0]["results"].as_array().unwrap();
996        assert_eq!(entries.len(), 1);
997
998        let entry = &entries[0];
999        assert_eq!(entry["ruleId"], "fallow/unused-file");
1000        // Default severity is "error" per RulesConfig::default()
1001        assert_eq!(entry["level"], "error");
1002        assert_eq!(
1003            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1004            "src/dead.ts"
1005        );
1006    }
1007
1008    #[test]
1009    fn sarif_unused_export_includes_region() {
1010        let root = PathBuf::from("/project");
1011        let mut results = AnalysisResults::default();
1012        results.unused_exports.push(UnusedExport {
1013            path: root.join("src/utils.ts"),
1014            export_name: "helperFn".to_string(),
1015            is_type_only: false,
1016            line: 10,
1017            col: 4,
1018            span_start: 120,
1019            is_re_export: false,
1020        });
1021
1022        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1023        let entry = &sarif["runs"][0]["results"][0];
1024        assert_eq!(entry["ruleId"], "fallow/unused-export");
1025
1026        let region = &entry["locations"][0]["physicalLocation"]["region"];
1027        assert_eq!(region["startLine"], 10);
1028        // SARIF columns are 1-based, code adds +1 to the 0-based col
1029        assert_eq!(region["startColumn"], 5);
1030    }
1031
1032    #[test]
1033    fn sarif_unresolved_import_is_error_level() {
1034        let root = PathBuf::from("/project");
1035        let mut results = AnalysisResults::default();
1036        results.unresolved_imports.push(UnresolvedImport {
1037            path: root.join("src/app.ts"),
1038            specifier: "./missing".to_string(),
1039            line: 1,
1040            col: 0,
1041            specifier_col: 0,
1042        });
1043
1044        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1045        let entry = &sarif["runs"][0]["results"][0];
1046        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1047        assert_eq!(entry["level"], "error");
1048    }
1049
1050    #[test]
1051    fn sarif_unlisted_dependency_points_to_import_site() {
1052        let root = PathBuf::from("/project");
1053        let mut results = AnalysisResults::default();
1054        results.unlisted_dependencies.push(UnlistedDependency {
1055            package_name: "chalk".to_string(),
1056            imported_from: vec![ImportSite {
1057                path: root.join("src/cli.ts"),
1058                line: 3,
1059                col: 0,
1060            }],
1061        });
1062
1063        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1064        let entry = &sarif["runs"][0]["results"][0];
1065        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1066        assert_eq!(entry["level"], "error");
1067        assert_eq!(
1068            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1069            "src/cli.ts"
1070        );
1071        let region = &entry["locations"][0]["physicalLocation"]["region"];
1072        assert_eq!(region["startLine"], 3);
1073        assert_eq!(region["startColumn"], 1);
1074    }
1075
1076    #[test]
1077    fn sarif_dependency_issues_point_to_package_json() {
1078        let root = PathBuf::from("/project");
1079        let mut results = AnalysisResults::default();
1080        results.unused_dependencies.push(UnusedDependency {
1081            package_name: "lodash".to_string(),
1082            location: DependencyLocation::Dependencies,
1083            path: root.join("package.json"),
1084            line: 5,
1085        });
1086        results.unused_dev_dependencies.push(UnusedDependency {
1087            package_name: "jest".to_string(),
1088            location: DependencyLocation::DevDependencies,
1089            path: root.join("package.json"),
1090            line: 5,
1091        });
1092
1093        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1094        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1095        for entry in entries {
1096            assert_eq!(
1097                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1098                "package.json"
1099            );
1100        }
1101    }
1102
1103    #[test]
1104    fn sarif_duplicate_export_emits_one_result_per_location() {
1105        let root = PathBuf::from("/project");
1106        let mut results = AnalysisResults::default();
1107        results.duplicate_exports.push(DuplicateExport {
1108            export_name: "Config".to_string(),
1109            locations: vec![
1110                DuplicateLocation {
1111                    path: root.join("src/a.ts"),
1112                    line: 15,
1113                    col: 0,
1114                },
1115                DuplicateLocation {
1116                    path: root.join("src/b.ts"),
1117                    line: 30,
1118                    col: 0,
1119                },
1120            ],
1121        });
1122
1123        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1124        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1125        // One SARIF result per location, not one per DuplicateExport
1126        assert_eq!(entries.len(), 2);
1127        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1128        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1129        assert_eq!(
1130            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1131            "src/a.ts"
1132        );
1133        assert_eq!(
1134            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1135            "src/b.ts"
1136        );
1137    }
1138
1139    #[test]
1140    fn sarif_all_issue_types_produce_results() {
1141        let root = PathBuf::from("/project");
1142        let results = sample_results(&root);
1143        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1144
1145        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1146        // All issue types with one entry each; duplicate_exports has 2 locations => one extra SARIF result
1147        assert_eq!(entries.len(), results.total_issues() + 1);
1148
1149        let rule_ids: Vec<&str> = entries
1150            .iter()
1151            .map(|e| e["ruleId"].as_str().unwrap())
1152            .collect();
1153        assert!(rule_ids.contains(&"fallow/unused-file"));
1154        assert!(rule_ids.contains(&"fallow/unused-export"));
1155        assert!(rule_ids.contains(&"fallow/unused-type"));
1156        assert!(rule_ids.contains(&"fallow/unused-dependency"));
1157        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1158        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1159        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1160        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1161        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1162        assert!(rule_ids.contains(&"fallow/unused-class-member"));
1163        assert!(rule_ids.contains(&"fallow/unresolved-import"));
1164        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1165        assert!(rule_ids.contains(&"fallow/duplicate-export"));
1166    }
1167
1168    #[test]
1169    fn sarif_serializes_to_valid_json() {
1170        let root = PathBuf::from("/project");
1171        let results = sample_results(&root);
1172        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1173
1174        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1175        let reparsed: serde_json::Value =
1176            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1177        assert_eq!(reparsed, sarif);
1178    }
1179
1180    #[test]
1181    fn sarif_file_write_produces_valid_sarif() {
1182        let root = PathBuf::from("/project");
1183        let results = sample_results(&root);
1184        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1185        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1186
1187        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1188        let _ = std::fs::create_dir_all(&dir);
1189        let sarif_path = dir.join("results.sarif");
1190        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1191
1192        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1193        let parsed: serde_json::Value =
1194            serde_json::from_str(&contents).expect("file should contain valid JSON");
1195
1196        assert_eq!(parsed["version"], "2.1.0");
1197        assert_eq!(
1198            parsed["$schema"],
1199            "https://json.schemastore.org/sarif-2.1.0.json"
1200        );
1201        let sarif_results = parsed["runs"][0]["results"]
1202            .as_array()
1203            .expect("results should be an array");
1204        assert!(!sarif_results.is_empty());
1205
1206        // Clean up
1207        let _ = std::fs::remove_file(&sarif_path);
1208        let _ = std::fs::remove_dir(&dir);
1209    }
1210
1211    // ── Health SARIF ──
1212
1213    #[test]
1214    fn health_sarif_empty_no_results() {
1215        let root = PathBuf::from("/project");
1216        let report = crate::health_types::HealthReport {
1217            summary: crate::health_types::HealthSummary {
1218                files_analyzed: 10,
1219                functions_analyzed: 50,
1220                ..Default::default()
1221            },
1222            ..Default::default()
1223        };
1224        let sarif = build_health_sarif(&report, &root);
1225        assert_eq!(sarif["version"], "2.1.0");
1226        let results = sarif["runs"][0]["results"].as_array().unwrap();
1227        assert!(results.is_empty());
1228        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1229            .as_array()
1230            .unwrap();
1231        assert_eq!(rules.len(), 12);
1232    }
1233
1234    #[test]
1235    fn health_sarif_cyclomatic_only() {
1236        let root = PathBuf::from("/project");
1237        let report = crate::health_types::HealthReport {
1238            findings: vec![crate::health_types::HealthFinding {
1239                path: root.join("src/utils.ts"),
1240                name: "parseExpression".to_string(),
1241                line: 42,
1242                col: 0,
1243                cyclomatic: 25,
1244                cognitive: 10,
1245                line_count: 80,
1246                param_count: 0,
1247                exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1248                severity: crate::health_types::FindingSeverity::High,
1249                crap: None,
1250                coverage_pct: None,
1251            }],
1252            summary: crate::health_types::HealthSummary {
1253                files_analyzed: 5,
1254                functions_analyzed: 20,
1255                functions_above_threshold: 1,
1256                ..Default::default()
1257            },
1258            ..Default::default()
1259        };
1260        let sarif = build_health_sarif(&report, &root);
1261        let entry = &sarif["runs"][0]["results"][0];
1262        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1263        assert_eq!(entry["level"], "warning");
1264        assert!(
1265            entry["message"]["text"]
1266                .as_str()
1267                .unwrap()
1268                .contains("cyclomatic complexity 25")
1269        );
1270        assert_eq!(
1271            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1272            "src/utils.ts"
1273        );
1274        let region = &entry["locations"][0]["physicalLocation"]["region"];
1275        assert_eq!(region["startLine"], 42);
1276        assert_eq!(region["startColumn"], 1);
1277    }
1278
1279    #[test]
1280    fn health_sarif_cognitive_only() {
1281        let root = PathBuf::from("/project");
1282        let report = crate::health_types::HealthReport {
1283            findings: vec![crate::health_types::HealthFinding {
1284                path: root.join("src/api.ts"),
1285                name: "handleRequest".to_string(),
1286                line: 10,
1287                col: 4,
1288                cyclomatic: 8,
1289                cognitive: 20,
1290                line_count: 40,
1291                param_count: 0,
1292                exceeded: crate::health_types::ExceededThreshold::Cognitive,
1293                severity: crate::health_types::FindingSeverity::High,
1294                crap: None,
1295                coverage_pct: None,
1296            }],
1297            summary: crate::health_types::HealthSummary {
1298                files_analyzed: 3,
1299                functions_analyzed: 10,
1300                functions_above_threshold: 1,
1301                ..Default::default()
1302            },
1303            ..Default::default()
1304        };
1305        let sarif = build_health_sarif(&report, &root);
1306        let entry = &sarif["runs"][0]["results"][0];
1307        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1308        assert!(
1309            entry["message"]["text"]
1310                .as_str()
1311                .unwrap()
1312                .contains("cognitive complexity 20")
1313        );
1314        let region = &entry["locations"][0]["physicalLocation"]["region"];
1315        assert_eq!(region["startColumn"], 5); // col 4 + 1
1316    }
1317
1318    #[test]
1319    fn health_sarif_both_thresholds() {
1320        let root = PathBuf::from("/project");
1321        let report = crate::health_types::HealthReport {
1322            findings: vec![crate::health_types::HealthFinding {
1323                path: root.join("src/complex.ts"),
1324                name: "doEverything".to_string(),
1325                line: 1,
1326                col: 0,
1327                cyclomatic: 30,
1328                cognitive: 45,
1329                line_count: 100,
1330                param_count: 0,
1331                exceeded: crate::health_types::ExceededThreshold::Both,
1332                severity: crate::health_types::FindingSeverity::High,
1333                crap: None,
1334                coverage_pct: None,
1335            }],
1336            summary: crate::health_types::HealthSummary {
1337                files_analyzed: 1,
1338                functions_analyzed: 1,
1339                functions_above_threshold: 1,
1340                ..Default::default()
1341            },
1342            ..Default::default()
1343        };
1344        let sarif = build_health_sarif(&report, &root);
1345        let entry = &sarif["runs"][0]["results"][0];
1346        assert_eq!(entry["ruleId"], "fallow/high-complexity");
1347        let msg = entry["message"]["text"].as_str().unwrap();
1348        assert!(msg.contains("cyclomatic complexity 30"));
1349        assert!(msg.contains("cognitive complexity 45"));
1350    }
1351
1352    #[test]
1353    fn health_sarif_crap_only_emits_crap_rule() {
1354        // CRAP-only: cyclomatic + cognitive below their thresholds, CRAP at or
1355        // above the CRAP threshold. Rule must be `fallow/high-crap-score`.
1356        let root = PathBuf::from("/project");
1357        let report = crate::health_types::HealthReport {
1358            findings: vec![crate::health_types::HealthFinding {
1359                path: root.join("src/untested.ts"),
1360                name: "risky".to_string(),
1361                line: 8,
1362                col: 0,
1363                cyclomatic: 10,
1364                cognitive: 10,
1365                line_count: 20,
1366                param_count: 1,
1367                exceeded: crate::health_types::ExceededThreshold::Crap,
1368                severity: crate::health_types::FindingSeverity::High,
1369                crap: Some(82.2),
1370                coverage_pct: Some(12.0),
1371            }],
1372            summary: crate::health_types::HealthSummary {
1373                files_analyzed: 1,
1374                functions_analyzed: 1,
1375                functions_above_threshold: 1,
1376                ..Default::default()
1377            },
1378            ..Default::default()
1379        };
1380        let sarif = build_health_sarif(&report, &root);
1381        let entry = &sarif["runs"][0]["results"][0];
1382        assert_eq!(entry["ruleId"], "fallow/high-crap-score");
1383        let msg = entry["message"]["text"].as_str().unwrap();
1384        assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
1385        assert!(msg.contains("coverage 12%"), "msg: {msg}");
1386    }
1387
1388    #[test]
1389    fn health_sarif_cyclomatic_crap_uses_crap_rule() {
1390        // Cyclomatic + CRAP both exceeded. The CRAP-centric rule subsumes
1391        // the cyclomatic breach; only one SARIF result is emitted.
1392        let root = PathBuf::from("/project");
1393        let report = crate::health_types::HealthReport {
1394            findings: vec![crate::health_types::HealthFinding {
1395                path: root.join("src/hot.ts"),
1396                name: "branchy".to_string(),
1397                line: 1,
1398                col: 0,
1399                cyclomatic: 67,
1400                cognitive: 12,
1401                line_count: 80,
1402                param_count: 1,
1403                exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
1404                severity: crate::health_types::FindingSeverity::Critical,
1405                crap: Some(182.0),
1406                coverage_pct: None,
1407            }],
1408            summary: crate::health_types::HealthSummary {
1409                files_analyzed: 1,
1410                functions_analyzed: 1,
1411                functions_above_threshold: 1,
1412                ..Default::default()
1413            },
1414            ..Default::default()
1415        };
1416        let sarif = build_health_sarif(&report, &root);
1417        let results = sarif["runs"][0]["results"].as_array().unwrap();
1418        assert_eq!(
1419            results.len(),
1420            1,
1421            "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
1422        );
1423        assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
1424        let msg = results[0]["message"]["text"].as_str().unwrap();
1425        assert!(msg.contains("CRAP score 182"), "msg: {msg}");
1426        // coverage_pct absent => no coverage suffix
1427        assert!(!msg.contains("coverage"), "msg: {msg}");
1428    }
1429
1430    // ── Severity mapping ──
1431
1432    #[test]
1433    fn severity_to_sarif_level_error() {
1434        assert_eq!(severity_to_sarif_level(Severity::Error), "error");
1435    }
1436
1437    #[test]
1438    fn severity_to_sarif_level_warn() {
1439        assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
1440    }
1441
1442    #[test]
1443    fn severity_to_sarif_level_off() {
1444        assert_eq!(severity_to_sarif_level(Severity::Off), "warning");
1445    }
1446
1447    // ── Re-export properties ──
1448
1449    #[test]
1450    fn sarif_re_export_has_properties() {
1451        let root = PathBuf::from("/project");
1452        let mut results = AnalysisResults::default();
1453        results.unused_exports.push(UnusedExport {
1454            path: root.join("src/index.ts"),
1455            export_name: "reExported".to_string(),
1456            is_type_only: false,
1457            line: 1,
1458            col: 0,
1459            span_start: 0,
1460            is_re_export: true,
1461        });
1462
1463        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1464        let entry = &sarif["runs"][0]["results"][0];
1465        assert_eq!(entry["properties"]["is_re_export"], true);
1466        let msg = entry["message"]["text"].as_str().unwrap();
1467        assert!(msg.starts_with("Re-export"));
1468    }
1469
1470    #[test]
1471    fn sarif_non_re_export_has_no_properties() {
1472        let root = PathBuf::from("/project");
1473        let mut results = AnalysisResults::default();
1474        results.unused_exports.push(UnusedExport {
1475            path: root.join("src/utils.ts"),
1476            export_name: "foo".to_string(),
1477            is_type_only: false,
1478            line: 5,
1479            col: 0,
1480            span_start: 0,
1481            is_re_export: false,
1482        });
1483
1484        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1485        let entry = &sarif["runs"][0]["results"][0];
1486        assert!(entry.get("properties").is_none());
1487        let msg = entry["message"]["text"].as_str().unwrap();
1488        assert!(msg.starts_with("Export"));
1489    }
1490
1491    // ── Type re-export ──
1492
1493    #[test]
1494    fn sarif_type_re_export_message() {
1495        let root = PathBuf::from("/project");
1496        let mut results = AnalysisResults::default();
1497        results.unused_types.push(UnusedExport {
1498            path: root.join("src/index.ts"),
1499            export_name: "MyType".to_string(),
1500            is_type_only: true,
1501            line: 1,
1502            col: 0,
1503            span_start: 0,
1504            is_re_export: true,
1505        });
1506
1507        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1508        let entry = &sarif["runs"][0]["results"][0];
1509        assert_eq!(entry["ruleId"], "fallow/unused-type");
1510        let msg = entry["message"]["text"].as_str().unwrap();
1511        assert!(msg.starts_with("Type re-export"));
1512        assert_eq!(entry["properties"]["is_re_export"], true);
1513    }
1514
1515    // ── Dependency line == 0 skips region ──
1516
1517    #[test]
1518    fn sarif_dependency_line_zero_skips_region() {
1519        let root = PathBuf::from("/project");
1520        let mut results = AnalysisResults::default();
1521        results.unused_dependencies.push(UnusedDependency {
1522            package_name: "lodash".to_string(),
1523            location: DependencyLocation::Dependencies,
1524            path: root.join("package.json"),
1525            line: 0,
1526        });
1527
1528        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1529        let entry = &sarif["runs"][0]["results"][0];
1530        let phys = &entry["locations"][0]["physicalLocation"];
1531        assert!(phys.get("region").is_none());
1532    }
1533
1534    #[test]
1535    fn sarif_dependency_line_nonzero_has_region() {
1536        let root = PathBuf::from("/project");
1537        let mut results = AnalysisResults::default();
1538        results.unused_dependencies.push(UnusedDependency {
1539            package_name: "lodash".to_string(),
1540            location: DependencyLocation::Dependencies,
1541            path: root.join("package.json"),
1542            line: 7,
1543        });
1544
1545        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1546        let entry = &sarif["runs"][0]["results"][0];
1547        let region = &entry["locations"][0]["physicalLocation"]["region"];
1548        assert_eq!(region["startLine"], 7);
1549        assert_eq!(region["startColumn"], 1);
1550    }
1551
1552    // ── Type-only dependency line == 0 skips region ──
1553
1554    #[test]
1555    fn sarif_type_only_dep_line_zero_skips_region() {
1556        let root = PathBuf::from("/project");
1557        let mut results = AnalysisResults::default();
1558        results.type_only_dependencies.push(TypeOnlyDependency {
1559            package_name: "zod".to_string(),
1560            path: root.join("package.json"),
1561            line: 0,
1562        });
1563
1564        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1565        let entry = &sarif["runs"][0]["results"][0];
1566        let phys = &entry["locations"][0]["physicalLocation"];
1567        assert!(phys.get("region").is_none());
1568    }
1569
1570    // ── Circular dependency line == 0 skips region ──
1571
1572    #[test]
1573    fn sarif_circular_dep_line_zero_skips_region() {
1574        let root = PathBuf::from("/project");
1575        let mut results = AnalysisResults::default();
1576        results.circular_dependencies.push(CircularDependency {
1577            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1578            length: 2,
1579            line: 0,
1580            col: 0,
1581            is_cross_package: false,
1582        });
1583
1584        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1585        let entry = &sarif["runs"][0]["results"][0];
1586        let phys = &entry["locations"][0]["physicalLocation"];
1587        assert!(phys.get("region").is_none());
1588    }
1589
1590    #[test]
1591    fn sarif_circular_dep_line_nonzero_has_region() {
1592        let root = PathBuf::from("/project");
1593        let mut results = AnalysisResults::default();
1594        results.circular_dependencies.push(CircularDependency {
1595            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1596            length: 2,
1597            line: 5,
1598            col: 2,
1599            is_cross_package: false,
1600        });
1601
1602        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1603        let entry = &sarif["runs"][0]["results"][0];
1604        let region = &entry["locations"][0]["physicalLocation"]["region"];
1605        assert_eq!(region["startLine"], 5);
1606        assert_eq!(region["startColumn"], 3);
1607    }
1608
1609    // ── Unused optional dependency ──
1610
1611    #[test]
1612    fn sarif_unused_optional_dependency_result() {
1613        let root = PathBuf::from("/project");
1614        let mut results = AnalysisResults::default();
1615        results.unused_optional_dependencies.push(UnusedDependency {
1616            package_name: "fsevents".to_string(),
1617            location: DependencyLocation::OptionalDependencies,
1618            path: root.join("package.json"),
1619            line: 12,
1620        });
1621
1622        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1623        let entry = &sarif["runs"][0]["results"][0];
1624        assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
1625        let msg = entry["message"]["text"].as_str().unwrap();
1626        assert!(msg.contains("optionalDependencies"));
1627    }
1628
1629    // ── Enum and class member SARIF messages ──
1630
1631    #[test]
1632    fn sarif_enum_member_message_format() {
1633        let root = PathBuf::from("/project");
1634        let mut results = AnalysisResults::default();
1635        results
1636            .unused_enum_members
1637            .push(fallow_core::results::UnusedMember {
1638                path: root.join("src/enums.ts"),
1639                parent_name: "Color".to_string(),
1640                member_name: "Purple".to_string(),
1641                kind: fallow_core::extract::MemberKind::EnumMember,
1642                line: 5,
1643                col: 2,
1644            });
1645
1646        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1647        let entry = &sarif["runs"][0]["results"][0];
1648        assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
1649        let msg = entry["message"]["text"].as_str().unwrap();
1650        assert!(msg.contains("Enum member 'Color.Purple'"));
1651        let region = &entry["locations"][0]["physicalLocation"]["region"];
1652        assert_eq!(region["startColumn"], 3); // col 2 + 1
1653    }
1654
1655    #[test]
1656    fn sarif_class_member_message_format() {
1657        let root = PathBuf::from("/project");
1658        let mut results = AnalysisResults::default();
1659        results
1660            .unused_class_members
1661            .push(fallow_core::results::UnusedMember {
1662                path: root.join("src/service.ts"),
1663                parent_name: "API".to_string(),
1664                member_name: "fetch".to_string(),
1665                kind: fallow_core::extract::MemberKind::ClassMethod,
1666                line: 10,
1667                col: 4,
1668            });
1669
1670        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1671        let entry = &sarif["runs"][0]["results"][0];
1672        assert_eq!(entry["ruleId"], "fallow/unused-class-member");
1673        let msg = entry["message"]["text"].as_str().unwrap();
1674        assert!(msg.contains("Class member 'API.fetch'"));
1675    }
1676
1677    // ── Duplication SARIF ──
1678
1679    #[test]
1680    #[expect(
1681        clippy::cast_possible_truncation,
1682        reason = "test line/col values are trivially small"
1683    )]
1684    fn duplication_sarif_structure() {
1685        use fallow_core::duplicates::*;
1686
1687        let root = PathBuf::from("/project");
1688        let report = DuplicationReport {
1689            clone_groups: vec![CloneGroup {
1690                instances: vec![
1691                    CloneInstance {
1692                        file: root.join("src/a.ts"),
1693                        start_line: 1,
1694                        end_line: 10,
1695                        start_col: 0,
1696                        end_col: 0,
1697                        fragment: String::new(),
1698                    },
1699                    CloneInstance {
1700                        file: root.join("src/b.ts"),
1701                        start_line: 5,
1702                        end_line: 14,
1703                        start_col: 2,
1704                        end_col: 0,
1705                        fragment: String::new(),
1706                    },
1707                ],
1708                token_count: 50,
1709                line_count: 10,
1710            }],
1711            clone_families: vec![],
1712            mirrored_directories: vec![],
1713            stats: DuplicationStats::default(),
1714        };
1715
1716        let sarif = serde_json::json!({
1717            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1718            "version": "2.1.0",
1719            "runs": [{
1720                "tool": {
1721                    "driver": {
1722                        "name": "fallow",
1723                        "version": env!("CARGO_PKG_VERSION"),
1724                        "informationUri": "https://github.com/fallow-rs/fallow",
1725                        "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1726                    }
1727                },
1728                "results": []
1729            }]
1730        });
1731        // Just verify the function doesn't panic and produces expected structure
1732        let _ = sarif;
1733
1734        // Test the actual build path through print_duplication_sarif internals
1735        let mut sarif_results = Vec::new();
1736        for (i, group) in report.clone_groups.iter().enumerate() {
1737            for instance in &group.instances {
1738                sarif_results.push(sarif_result(
1739                    "fallow/code-duplication",
1740                    "warning",
1741                    &format!(
1742                        "Code clone group {} ({} lines, {} instances)",
1743                        i + 1,
1744                        group.line_count,
1745                        group.instances.len()
1746                    ),
1747                    &super::super::relative_uri(&instance.file, &root),
1748                    Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1749                ));
1750            }
1751        }
1752        assert_eq!(sarif_results.len(), 2);
1753        assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
1754        assert!(
1755            sarif_results[0]["message"]["text"]
1756                .as_str()
1757                .unwrap()
1758                .contains("10 lines")
1759        );
1760        let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
1761        assert_eq!(region0["startLine"], 1);
1762        assert_eq!(region0["startColumn"], 1); // start_col 0 + 1
1763        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
1764        assert_eq!(region1["startLine"], 5);
1765        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
1766    }
1767
1768    // ── sarif_rule fallback (unknown rule ID) ──
1769
1770    #[test]
1771    fn sarif_rule_known_id_has_full_description() {
1772        let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
1773        assert!(rule.get("fullDescription").is_some());
1774        assert!(rule.get("helpUri").is_some());
1775    }
1776
1777    #[test]
1778    fn sarif_rule_unknown_id_uses_fallback() {
1779        let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
1780        assert_eq!(rule["shortDescription"]["text"], "fallback text");
1781        assert!(rule.get("fullDescription").is_none());
1782        assert!(rule.get("helpUri").is_none());
1783        assert_eq!(rule["defaultConfiguration"]["level"], "warning");
1784    }
1785
1786    // ── sarif_result without region ──
1787
1788    #[test]
1789    fn sarif_result_no_region_omits_region_key() {
1790        let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
1791        let phys = &result["locations"][0]["physicalLocation"];
1792        assert!(phys.get("region").is_none());
1793        assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
1794    }
1795
1796    #[test]
1797    fn sarif_result_with_region_includes_region() {
1798        let result = sarif_result(
1799            "rule/test",
1800            "error",
1801            "test msg",
1802            "src/file.ts",
1803            Some((10, 5)),
1804        );
1805        let region = &result["locations"][0]["physicalLocation"]["region"];
1806        assert_eq!(region["startLine"], 10);
1807        assert_eq!(region["startColumn"], 5);
1808    }
1809
1810    // ── Health SARIF refactoring targets ──
1811
1812    #[test]
1813    fn health_sarif_includes_refactoring_targets() {
1814        use crate::health_types::*;
1815
1816        let root = PathBuf::from("/project");
1817        let report = HealthReport {
1818            summary: HealthSummary {
1819                files_analyzed: 10,
1820                functions_analyzed: 50,
1821                ..Default::default()
1822            },
1823            targets: vec![RefactoringTarget {
1824                path: root.join("src/complex.ts"),
1825                priority: 85.0,
1826                efficiency: 42.5,
1827                recommendation: "Split high-impact file".into(),
1828                category: RecommendationCategory::SplitHighImpact,
1829                effort: EffortEstimate::Medium,
1830                confidence: Confidence::High,
1831                factors: vec![],
1832                evidence: None,
1833            }],
1834            ..Default::default()
1835        };
1836
1837        let sarif = build_health_sarif(&report, &root);
1838        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1839        assert_eq!(entries.len(), 1);
1840        assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
1841        assert_eq!(entries[0]["level"], "warning");
1842        let msg = entries[0]["message"]["text"].as_str().unwrap();
1843        assert!(msg.contains("high impact"));
1844        assert!(msg.contains("Split high-impact file"));
1845        assert!(msg.contains("42.5"));
1846    }
1847
1848    #[test]
1849    fn health_sarif_includes_coverage_gaps() {
1850        use crate::health_types::*;
1851
1852        let root = PathBuf::from("/project");
1853        let report = HealthReport {
1854            summary: HealthSummary {
1855                files_analyzed: 10,
1856                functions_analyzed: 50,
1857                ..Default::default()
1858            },
1859            coverage_gaps: Some(CoverageGaps {
1860                summary: CoverageGapSummary {
1861                    runtime_files: 2,
1862                    covered_files: 0,
1863                    file_coverage_pct: 0.0,
1864                    untested_files: 1,
1865                    untested_exports: 1,
1866                },
1867                files: vec![UntestedFile {
1868                    path: root.join("src/app.ts"),
1869                    value_export_count: 2,
1870                }],
1871                exports: vec![UntestedExport {
1872                    path: root.join("src/app.ts"),
1873                    export_name: "loader".into(),
1874                    line: 12,
1875                    col: 4,
1876                }],
1877            }),
1878            ..Default::default()
1879        };
1880
1881        let sarif = build_health_sarif(&report, &root);
1882        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1883        assert_eq!(entries.len(), 2);
1884        assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
1885        assert_eq!(
1886            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1887            "src/app.ts"
1888        );
1889        assert!(
1890            entries[0]["message"]["text"]
1891                .as_str()
1892                .unwrap()
1893                .contains("2 value exports")
1894        );
1895        assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
1896        assert_eq!(
1897            entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
1898            12
1899        );
1900        assert_eq!(
1901            entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
1902            5
1903        );
1904    }
1905
1906    // ── Health SARIF rules include fullDescription from explain module ──
1907
1908    #[test]
1909    fn health_sarif_rules_have_full_descriptions() {
1910        let root = PathBuf::from("/project");
1911        let report = crate::health_types::HealthReport::default();
1912        let sarif = build_health_sarif(&report, &root);
1913        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1914            .as_array()
1915            .unwrap();
1916        for rule in rules {
1917            let id = rule["id"].as_str().unwrap();
1918            assert!(
1919                rule.get("fullDescription").is_some(),
1920                "health rule {id} should have fullDescription"
1921            );
1922            assert!(
1923                rule.get("helpUri").is_some(),
1924                "health rule {id} should have helpUri"
1925            );
1926        }
1927    }
1928
1929    // ── Warn severity propagates correctly ──
1930
1931    #[test]
1932    fn sarif_warn_severity_produces_warning_level() {
1933        let root = PathBuf::from("/project");
1934        let mut results = AnalysisResults::default();
1935        results.unused_files.push(UnusedFile {
1936            path: root.join("src/dead.ts"),
1937        });
1938
1939        let rules = RulesConfig {
1940            unused_files: Severity::Warn,
1941            ..RulesConfig::default()
1942        };
1943
1944        let sarif = build_sarif(&results, &root, &rules);
1945        let entry = &sarif["runs"][0]["results"][0];
1946        assert_eq!(entry["level"], "warning");
1947    }
1948
1949    // ── Unused file has no region ──
1950
1951    #[test]
1952    fn sarif_unused_file_has_no_region() {
1953        let root = PathBuf::from("/project");
1954        let mut results = AnalysisResults::default();
1955        results.unused_files.push(UnusedFile {
1956            path: root.join("src/dead.ts"),
1957        });
1958
1959        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1960        let entry = &sarif["runs"][0]["results"][0];
1961        let phys = &entry["locations"][0]["physicalLocation"];
1962        assert!(phys.get("region").is_none());
1963    }
1964
1965    // ── Multiple unlisted deps with multiple import sites ──
1966
1967    #[test]
1968    fn sarif_unlisted_dep_multiple_import_sites() {
1969        let root = PathBuf::from("/project");
1970        let mut results = AnalysisResults::default();
1971        results.unlisted_dependencies.push(UnlistedDependency {
1972            package_name: "dotenv".to_string(),
1973            imported_from: vec![
1974                ImportSite {
1975                    path: root.join("src/a.ts"),
1976                    line: 1,
1977                    col: 0,
1978                },
1979                ImportSite {
1980                    path: root.join("src/b.ts"),
1981                    line: 5,
1982                    col: 0,
1983                },
1984            ],
1985        });
1986
1987        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1988        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1989        // One SARIF result per import site
1990        assert_eq!(entries.len(), 2);
1991        assert_eq!(
1992            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1993            "src/a.ts"
1994        );
1995        assert_eq!(
1996            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1997            "src/b.ts"
1998        );
1999    }
2000
2001    // ── Empty unlisted dep (no import sites) produces zero results ──
2002
2003    #[test]
2004    fn sarif_unlisted_dep_no_import_sites() {
2005        let root = PathBuf::from("/project");
2006        let mut results = AnalysisResults::default();
2007        results.unlisted_dependencies.push(UnlistedDependency {
2008            package_name: "phantom".to_string(),
2009            imported_from: vec![],
2010        });
2011
2012        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2013        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2014        // No import sites => no SARIF results for this unlisted dep
2015        assert!(entries.is_empty());
2016    }
2017}