Skip to main content

fallow_cli/report/
sarif.rs

1use std::path::{Path, PathBuf};
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::{
7    AnalysisResults, BoundaryViolation, CircularDependency, DuplicateExportFinding,
8    EmptyCatalogGroupFinding, MisconfiguredDependencyOverrideFinding, PrivateTypeLeak,
9    StaleSuppression, TestOnlyDependency, TypeOnlyDependency, UnlistedDependencyFinding,
10    UnresolvedCatalogReferenceFinding, UnresolvedImport, UnusedCatalogEntryFinding,
11    UnusedDependency, UnusedDependencyOverrideFinding, UnusedExport, UnusedFile, UnusedMember,
12};
13use rustc_hash::FxHashMap;
14
15use super::ci::{fingerprint, severity};
16use super::grouping::{self, OwnershipResolver};
17use super::{emit_json, relative_uri};
18use crate::explain;
19
20/// Intermediate fields extracted from an issue for SARIF result construction.
21struct SarifFields {
22    rule_id: &'static str,
23    level: &'static str,
24    message: String,
25    uri: String,
26    region: Option<(u32, u32)>,
27    source_path: Option<PathBuf>,
28    properties: Option<serde_json::Value>,
29}
30
31#[derive(Default)]
32struct SourceSnippetCache {
33    files: FxHashMap<PathBuf, Vec<String>>,
34}
35
36impl SourceSnippetCache {
37    fn line(&mut self, path: &Path, line: u32) -> Option<String> {
38        if line == 0 {
39            return None;
40        }
41        if !self.files.contains_key(path) {
42            let lines = std::fs::read_to_string(path)
43                .ok()
44                .map(|source| source.lines().map(str::to_owned).collect())
45                .unwrap_or_default();
46            self.files.insert(path.to_path_buf(), lines);
47        }
48        self.files
49            .get(path)
50            .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
51            .cloned()
52    }
53}
54
55fn severity_to_sarif_level(s: Severity) -> &'static str {
56    severity::sarif_level(s)
57}
58
59fn configured_sarif_level(s: Severity) -> &'static str {
60    match s {
61        Severity::Error | Severity::Warn => severity_to_sarif_level(s),
62        Severity::Off => "none",
63    }
64}
65
66/// Build a single SARIF result object.
67///
68/// When `region` is `Some((line, col))`, a `region` block with 1-based
69/// `startLine` and `startColumn` is included in the physical location.
70fn sarif_result(
71    rule_id: &str,
72    level: &str,
73    message: &str,
74    uri: &str,
75    region: Option<(u32, u32)>,
76) -> serde_json::Value {
77    sarif_result_with_snippet(rule_id, level, message, uri, region, None)
78}
79
80fn sarif_result_with_snippet(
81    rule_id: &str,
82    level: &str,
83    message: &str,
84    uri: &str,
85    region: Option<(u32, u32)>,
86    snippet: Option<&str>,
87) -> serde_json::Value {
88    let mut physical_location = serde_json::json!({
89        "artifactLocation": { "uri": uri }
90    });
91    if let Some((line, col)) = region {
92        physical_location["region"] = serde_json::json!({
93            "startLine": line,
94            "startColumn": col
95        });
96    }
97    let line = region.map_or_else(String::new, |(line, _)| line.to_string());
98    let col = region.map_or_else(String::new, |(_, col)| col.to_string());
99    let normalized_snippet = snippet
100        .map(fingerprint::normalize_snippet)
101        .filter(|snippet| !snippet.is_empty());
102    let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
103        || fingerprint::fingerprint_hash(&[rule_id, uri, &line, &col]),
104        |snippet| fingerprint::finding_fingerprint(rule_id, uri, snippet),
105    );
106    let partial_fingerprint_ghas = partial_fingerprint.clone();
107    serde_json::json!({
108        "ruleId": rule_id,
109        "level": level,
110        "message": { "text": message },
111        "locations": [{ "physicalLocation": physical_location }],
112        "partialFingerprints": {
113            fingerprint::FINGERPRINT_KEY: partial_fingerprint,
114            fingerprint::GHAS_FINGERPRINT_KEY: partial_fingerprint_ghas
115        }
116    })
117}
118
119/// Append SARIF results for a slice of items using a closure to extract fields.
120fn push_sarif_results<T>(
121    sarif_results: &mut Vec<serde_json::Value>,
122    items: &[T],
123    snippets: &mut SourceSnippetCache,
124    mut extract: impl FnMut(&T) -> SarifFields,
125) {
126    for item in items {
127        let fields = extract(item);
128        let source_snippet = fields
129            .source_path
130            .as_deref()
131            .zip(fields.region)
132            .and_then(|(path, (line, _))| snippets.line(path, line));
133        let mut result = sarif_result_with_snippet(
134            fields.rule_id,
135            fields.level,
136            &fields.message,
137            &fields.uri,
138            fields.region,
139            source_snippet.as_deref(),
140        );
141        if let Some(props) = fields.properties {
142            result["properties"] = props;
143        }
144        sarif_results.push(result);
145    }
146}
147
148/// Build a SARIF rule definition with optional `fullDescription` and `helpUri`
149/// sourced from the centralized explain module.
150fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
151    explain::rule_by_id(id).map_or_else(
152        || {
153            serde_json::json!({
154                "id": id,
155                "shortDescription": { "text": fallback_short },
156                "defaultConfiguration": { "level": level }
157            })
158        },
159        |def| {
160            serde_json::json!({
161                "id": id,
162                "shortDescription": { "text": def.short },
163                "fullDescription": { "text": def.full },
164                "helpUri": explain::rule_docs_url(def),
165                "defaultConfiguration": { "level": level }
166            })
167        },
168    )
169}
170
171/// Extract SARIF fields for an unused export or type export.
172fn sarif_export_fields(
173    export: &UnusedExport,
174    root: &Path,
175    rule_id: &'static str,
176    level: &'static str,
177    kind: &str,
178    re_kind: &str,
179) -> SarifFields {
180    let label = if export.is_re_export { re_kind } else { kind };
181    SarifFields {
182        rule_id,
183        level,
184        message: format!(
185            "{} '{}' is never imported by other modules",
186            label, export.export_name
187        ),
188        uri: relative_uri(&export.path, root),
189        region: Some((export.line, export.col + 1)),
190        source_path: Some(export.path.clone()),
191        properties: if export.is_re_export {
192            Some(serde_json::json!({ "is_re_export": true }))
193        } else {
194            None
195        },
196    }
197}
198
199fn sarif_private_type_leak_fields(
200    leak: &PrivateTypeLeak,
201    root: &Path,
202    level: &'static str,
203) -> SarifFields {
204    SarifFields {
205        rule_id: "fallow/private-type-leak",
206        level,
207        message: format!(
208            "Export '{}' references private type '{}'",
209            leak.export_name, leak.type_name
210        ),
211        uri: relative_uri(&leak.path, root),
212        region: Some((leak.line, leak.col + 1)),
213        source_path: Some(leak.path.clone()),
214        properties: None,
215    }
216}
217
218/// Extract SARIF fields for an unused dependency.
219fn sarif_dep_fields(
220    dep: &UnusedDependency,
221    root: &Path,
222    rule_id: &'static str,
223    level: &'static str,
224    section: &str,
225) -> SarifFields {
226    let workspace_context = if dep.used_in_workspaces.is_empty() {
227        String::new()
228    } else {
229        let workspaces = dep
230            .used_in_workspaces
231            .iter()
232            .map(|path| relative_uri(path, root))
233            .collect::<Vec<_>>()
234            .join(", ");
235        format!("; imported in other workspaces: {workspaces}")
236    };
237    SarifFields {
238        rule_id,
239        level,
240        message: format!(
241            "Package '{}' is in {} but never imported{}",
242            dep.package_name, section, workspace_context
243        ),
244        uri: relative_uri(&dep.path, root),
245        region: if dep.line > 0 {
246            Some((dep.line, 1))
247        } else {
248            None
249        },
250        source_path: (dep.line > 0).then(|| dep.path.clone()),
251        properties: None,
252    }
253}
254
255/// Extract SARIF fields for an unused enum or class member.
256fn sarif_member_fields(
257    member: &UnusedMember,
258    root: &Path,
259    rule_id: &'static str,
260    level: &'static str,
261    kind: &str,
262) -> SarifFields {
263    SarifFields {
264        rule_id,
265        level,
266        message: format!(
267            "{} member '{}.{}' is never referenced",
268            kind, member.parent_name, member.member_name
269        ),
270        uri: relative_uri(&member.path, root),
271        region: Some((member.line, member.col + 1)),
272        source_path: Some(member.path.clone()),
273        properties: None,
274    }
275}
276
277fn sarif_unused_file_fields(file: &UnusedFile, root: &Path, level: &'static str) -> SarifFields {
278    SarifFields {
279        rule_id: "fallow/unused-file",
280        level,
281        message: "File is not reachable from any entry point".to_string(),
282        uri: relative_uri(&file.path, root),
283        region: None,
284        source_path: None,
285        properties: None,
286    }
287}
288
289fn sarif_type_only_dep_fields(
290    dep: &TypeOnlyDependency,
291    root: &Path,
292    level: &'static str,
293) -> SarifFields {
294    SarifFields {
295        rule_id: "fallow/type-only-dependency",
296        level,
297        message: format!(
298            "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
299            dep.package_name
300        ),
301        uri: relative_uri(&dep.path, root),
302        region: if dep.line > 0 {
303            Some((dep.line, 1))
304        } else {
305            None
306        },
307        source_path: (dep.line > 0).then(|| dep.path.clone()),
308        properties: None,
309    }
310}
311
312fn sarif_test_only_dep_fields(
313    dep: &TestOnlyDependency,
314    root: &Path,
315    level: &'static str,
316) -> SarifFields {
317    SarifFields {
318        rule_id: "fallow/test-only-dependency",
319        level,
320        message: format!(
321            "Package '{}' is only imported by test files (consider moving to devDependencies)",
322            dep.package_name
323        ),
324        uri: relative_uri(&dep.path, root),
325        region: if dep.line > 0 {
326            Some((dep.line, 1))
327        } else {
328            None
329        },
330        source_path: (dep.line > 0).then(|| dep.path.clone()),
331        properties: None,
332    }
333}
334
335fn sarif_unresolved_import_fields(
336    import: &UnresolvedImport,
337    root: &Path,
338    level: &'static str,
339) -> SarifFields {
340    SarifFields {
341        rule_id: "fallow/unresolved-import",
342        level,
343        message: format!("Import '{}' could not be resolved", import.specifier),
344        uri: relative_uri(&import.path, root),
345        region: Some((import.line, import.col + 1)),
346        source_path: Some(import.path.clone()),
347        properties: None,
348    }
349}
350
351fn sarif_circular_dep_fields(
352    cycle: &CircularDependency,
353    root: &Path,
354    level: &'static str,
355) -> SarifFields {
356    let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
357    let mut display_chain = chain.clone();
358    if let Some(first) = chain.first() {
359        display_chain.push(first.clone());
360    }
361    let first_uri = chain.first().map_or_else(String::new, Clone::clone);
362    let first_path = cycle.files.first().cloned();
363    SarifFields {
364        rule_id: "fallow/circular-dependency",
365        level,
366        message: format!(
367            "Circular dependency{}: {}",
368            if cycle.is_cross_package {
369                " (cross-package)"
370            } else {
371                ""
372            },
373            display_chain.join(" \u{2192} ")
374        ),
375        uri: first_uri,
376        region: if cycle.line > 0 {
377            Some((cycle.line, cycle.col + 1))
378        } else {
379            None
380        },
381        source_path: (cycle.line > 0).then_some(first_path).flatten(),
382        properties: None,
383    }
384}
385
386fn sarif_re_export_cycle_fields(
387    cycle: &fallow_core::results::ReExportCycle,
388    root: &Path,
389    level: &'static str,
390) -> SarifFields {
391    let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
392    let first_uri = chain.first().map_or_else(String::new, Clone::clone);
393    let first_path = cycle.files.first().cloned();
394    let kind_tag = match cycle.kind {
395        fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
396        fallow_core::results::ReExportCycleKind::MultiNode => "",
397    };
398    SarifFields {
399        rule_id: "fallow/re-export-cycle",
400        level,
401        message: format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
402        uri: first_uri,
403        region: None,
404        source_path: first_path,
405        properties: None,
406    }
407}
408
409fn sarif_boundary_violation_fields(
410    violation: &BoundaryViolation,
411    root: &Path,
412    level: &'static str,
413) -> SarifFields {
414    let from_uri = relative_uri(&violation.from_path, root);
415    let to_uri = relative_uri(&violation.to_path, root);
416    SarifFields {
417        rule_id: "fallow/boundary-violation",
418        level,
419        message: format!(
420            "Import from zone '{}' to zone '{}' is not allowed ({})",
421            violation.from_zone, violation.to_zone, to_uri,
422        ),
423        uri: from_uri,
424        region: if violation.line > 0 {
425            Some((violation.line, violation.col + 1))
426        } else {
427            None
428        },
429        source_path: (violation.line > 0).then(|| violation.from_path.clone()),
430        properties: None,
431    }
432}
433
434fn sarif_stale_suppression_fields(
435    suppression: &StaleSuppression,
436    root: &Path,
437    level: &'static str,
438) -> SarifFields {
439    SarifFields {
440        rule_id: "fallow/stale-suppression",
441        level,
442        message: suppression.display_message(),
443        uri: relative_uri(&suppression.path, root),
444        region: Some((suppression.line, suppression.col + 1)),
445        source_path: Some(suppression.path.clone()),
446        properties: None,
447    }
448}
449
450fn sarif_unused_catalog_entry_fields(
451    entry: &UnusedCatalogEntryFinding,
452    root: &Path,
453    level: &'static str,
454) -> SarifFields {
455    let entry = &entry.entry;
456    let message = if entry.catalog_name == "default" {
457        format!(
458            "Catalog entry '{}' is not referenced by any workspace package",
459            entry.entry_name
460        )
461    } else {
462        format!(
463            "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
464            entry.entry_name, entry.catalog_name
465        )
466    };
467    SarifFields {
468        rule_id: "fallow/unused-catalog-entry",
469        level,
470        message,
471        uri: relative_uri(&entry.path, root),
472        region: Some((entry.line, 1)),
473        source_path: Some(entry.path.clone()),
474        properties: None,
475    }
476}
477
478fn sarif_unused_dependency_override_fields(
479    finding: &UnusedDependencyOverrideFinding,
480    root: &Path,
481    level: &'static str,
482) -> SarifFields {
483    let finding = &finding.entry;
484    let mut message = format!(
485        "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
486        finding.raw_key, finding.version_range, finding.target_package,
487    );
488    if let Some(hint) = &finding.hint {
489        use std::fmt::Write as _;
490        let _ = write!(message, " ({hint})");
491    }
492    SarifFields {
493        rule_id: "fallow/unused-dependency-override",
494        level,
495        message,
496        uri: relative_uri(&finding.path, root),
497        region: Some((finding.line, 1)),
498        source_path: Some(finding.path.clone()),
499        properties: None,
500    }
501}
502
503fn sarif_misconfigured_dependency_override_fields(
504    finding: &MisconfiguredDependencyOverrideFinding,
505    root: &Path,
506    level: &'static str,
507) -> SarifFields {
508    let finding = &finding.entry;
509    let message = format!(
510        "Override `{}` -> `{}` is malformed: {}",
511        finding.raw_key,
512        finding.raw_value,
513        finding.reason.describe(),
514    );
515    SarifFields {
516        rule_id: "fallow/misconfigured-dependency-override",
517        level,
518        message,
519        uri: relative_uri(&finding.path, root),
520        region: Some((finding.line, 1)),
521        source_path: Some(finding.path.clone()),
522        properties: None,
523    }
524}
525
526fn sarif_unresolved_catalog_reference_fields(
527    finding: &UnresolvedCatalogReferenceFinding,
528    root: &Path,
529    level: &'static str,
530) -> SarifFields {
531    let finding = &finding.reference;
532    let catalog_phrase = if finding.catalog_name == "default" {
533        "the default catalog".to_string()
534    } else {
535        format!("catalog '{}'", finding.catalog_name)
536    };
537    let mut message = format!(
538        "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
539        finding.entry_name,
540        if finding.catalog_name == "default" {
541            ""
542        } else {
543            finding.catalog_name.as_str()
544        },
545        catalog_phrase,
546    );
547    if !finding.available_in_catalogs.is_empty() {
548        use std::fmt::Write as _;
549        let _ = write!(
550            message,
551            " (available in: {})",
552            finding.available_in_catalogs.join(", ")
553        );
554    }
555    SarifFields {
556        rule_id: "fallow/unresolved-catalog-reference",
557        level,
558        message,
559        uri: relative_uri(&finding.path, root),
560        region: Some((finding.line, 1)),
561        source_path: Some(finding.path.clone()),
562        properties: None,
563    }
564}
565
566fn sarif_empty_catalog_group_fields(
567    group: &EmptyCatalogGroupFinding,
568    root: &Path,
569    level: &'static str,
570) -> SarifFields {
571    let group = &group.group;
572    SarifFields {
573        rule_id: "fallow/empty-catalog-group",
574        level,
575        message: format!("Catalog group '{}' has no entries", group.catalog_name),
576        uri: relative_uri(&group.path, root),
577        region: Some((group.line, 1)),
578        source_path: Some(group.path.clone()),
579        properties: None,
580    }
581}
582
583/// Unlisted deps fan out to one SARIF result per import site, so they do not
584/// fit `push_sarif_results`. Keep the nested-loop shape in its own helper.
585fn push_sarif_unlisted_deps(
586    sarif_results: &mut Vec<serde_json::Value>,
587    deps: &[UnlistedDependencyFinding],
588    root: &Path,
589    level: &'static str,
590    snippets: &mut SourceSnippetCache,
591) {
592    for entry in deps {
593        let dep = &entry.dep;
594        for site in &dep.imported_from {
595            let uri = relative_uri(&site.path, root);
596            let source_snippet = snippets.line(&site.path, site.line);
597            sarif_results.push(sarif_result_with_snippet(
598                "fallow/unlisted-dependency",
599                level,
600                &format!(
601                    "Package '{}' is imported but not listed in package.json",
602                    dep.package_name
603                ),
604                &uri,
605                Some((site.line, site.col + 1)),
606                source_snippet.as_deref(),
607            ));
608        }
609    }
610}
611
612/// Duplicate exports fan out to one SARIF result per location
613/// (SARIF 2.1.0 section 3.27.12), so they do not fit `push_sarif_results`.
614fn push_sarif_duplicate_exports(
615    sarif_results: &mut Vec<serde_json::Value>,
616    dups: &[DuplicateExportFinding],
617    root: &Path,
618    level: &'static str,
619    snippets: &mut SourceSnippetCache,
620) {
621    for dup in dups {
622        let dup = &dup.export;
623        for loc in &dup.locations {
624            let uri = relative_uri(&loc.path, root);
625            let source_snippet = snippets.line(&loc.path, loc.line);
626            sarif_results.push(sarif_result_with_snippet(
627                "fallow/duplicate-export",
628                level,
629                &format!("Export '{}' appears in multiple modules", dup.export_name),
630                &uri,
631                Some((loc.line, loc.col + 1)),
632                source_snippet.as_deref(),
633            ));
634        }
635    }
636}
637
638/// Build the SARIF rules list from the current rules configuration.
639fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
640    [
641        (
642            "fallow/unused-file",
643            "File is not reachable from any entry point",
644            rules.unused_files,
645        ),
646        (
647            "fallow/unused-export",
648            "Export is never imported",
649            rules.unused_exports,
650        ),
651        (
652            "fallow/unused-type",
653            "Type export is never imported",
654            rules.unused_types,
655        ),
656        (
657            "fallow/private-type-leak",
658            "Exported signature references a same-file private type",
659            rules.private_type_leaks,
660        ),
661        (
662            "fallow/unused-dependency",
663            "Dependency listed but never imported",
664            rules.unused_dependencies,
665        ),
666        (
667            "fallow/unused-dev-dependency",
668            "Dev dependency listed but never imported",
669            rules.unused_dev_dependencies,
670        ),
671        (
672            "fallow/unused-optional-dependency",
673            "Optional dependency listed but never imported",
674            rules.unused_optional_dependencies,
675        ),
676        (
677            "fallow/type-only-dependency",
678            "Production dependency only used via type-only imports",
679            rules.type_only_dependencies,
680        ),
681        (
682            "fallow/test-only-dependency",
683            "Production dependency only imported by test files",
684            rules.test_only_dependencies,
685        ),
686        (
687            "fallow/unused-enum-member",
688            "Enum member is never referenced",
689            rules.unused_enum_members,
690        ),
691        (
692            "fallow/unused-class-member",
693            "Class member is never referenced",
694            rules.unused_class_members,
695        ),
696        (
697            "fallow/unresolved-import",
698            "Import could not be resolved",
699            rules.unresolved_imports,
700        ),
701        (
702            "fallow/unlisted-dependency",
703            "Dependency used but not in package.json",
704            rules.unlisted_dependencies,
705        ),
706        (
707            "fallow/duplicate-export",
708            "Export name appears in multiple modules",
709            rules.duplicate_exports,
710        ),
711        (
712            "fallow/circular-dependency",
713            "Circular dependency chain detected",
714            rules.circular_dependencies,
715        ),
716        (
717            "fallow/re-export-cycle",
718            "Two or more barrel files re-export from each other in a loop",
719            rules.re_export_cycle,
720        ),
721        (
722            "fallow/boundary-violation",
723            "Import crosses an architecture boundary",
724            rules.boundary_violation,
725        ),
726        (
727            "fallow/stale-suppression",
728            "Suppression comment or tag no longer matches any issue",
729            rules.stale_suppressions,
730        ),
731        (
732            "fallow/unused-catalog-entry",
733            "pnpm catalog entry not referenced by any workspace package",
734            rules.unused_catalog_entries,
735        ),
736        (
737            "fallow/empty-catalog-group",
738            "pnpm named catalog group has no entries",
739            rules.empty_catalog_groups,
740        ),
741        (
742            "fallow/unresolved-catalog-reference",
743            "package.json catalog reference points at a catalog that does not declare the package",
744            rules.unresolved_catalog_references,
745        ),
746        (
747            "fallow/unused-dependency-override",
748            "pnpm dependency override target is not declared or lockfile-resolved",
749            rules.unused_dependency_overrides,
750        ),
751        (
752            "fallow/misconfigured-dependency-override",
753            "pnpm dependency override key or value is malformed",
754            rules.misconfigured_dependency_overrides,
755        ),
756    ]
757    .into_iter()
758    .map(|(id, description, rule_severity)| {
759        sarif_rule(id, description, configured_sarif_level(rule_severity))
760    })
761    .collect()
762}
763
764#[must_use]
765pub fn build_sarif(
766    results: &AnalysisResults,
767    root: &Path,
768    rules: &RulesConfig,
769) -> serde_json::Value {
770    let mut sarif_results = Vec::new();
771    let mut snippets = SourceSnippetCache::default();
772
773    push_sarif_results(
774        &mut sarif_results,
775        &results.unused_files,
776        &mut snippets,
777        |f| sarif_unused_file_fields(&f.file, root, severity_to_sarif_level(rules.unused_files)),
778    );
779    push_sarif_results(
780        &mut sarif_results,
781        &results.unused_exports,
782        &mut snippets,
783        |e| {
784            sarif_export_fields(
785                &e.export,
786                root,
787                "fallow/unused-export",
788                severity_to_sarif_level(rules.unused_exports),
789                "Export",
790                "Re-export",
791            )
792        },
793    );
794    push_sarif_results(
795        &mut sarif_results,
796        &results.unused_types,
797        &mut snippets,
798        |e| {
799            sarif_export_fields(
800                &e.export,
801                root,
802                "fallow/unused-type",
803                severity_to_sarif_level(rules.unused_types),
804                "Type export",
805                "Type re-export",
806            )
807        },
808    );
809    push_sarif_results(
810        &mut sarif_results,
811        &results.private_type_leaks,
812        &mut snippets,
813        |e| {
814            sarif_private_type_leak_fields(
815                &e.leak,
816                root,
817                severity_to_sarif_level(rules.private_type_leaks),
818            )
819        },
820    );
821    push_dependency_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
822    push_member_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
823    push_sarif_results(
824        &mut sarif_results,
825        &results.unresolved_imports,
826        &mut snippets,
827        |i| {
828            sarif_unresolved_import_fields(
829                &i.import,
830                root,
831                severity_to_sarif_level(rules.unresolved_imports),
832            )
833        },
834    );
835    push_misc_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
836    push_graph_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
837    push_catalog_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
838
839    serde_json::json!({
840        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
841        "version": "2.1.0",
842        "runs": [{
843            "tool": {
844                "driver": {
845                    "name": "fallow",
846                    "version": env!("CARGO_PKG_VERSION"),
847                    "informationUri": "https://github.com/fallow-rs/fallow",
848                    "rules": build_sarif_rules(rules)
849                }
850            },
851            "results": sarif_results
852        }]
853    })
854}
855
856fn push_dependency_sarif_results(
857    sarif_results: &mut Vec<serde_json::Value>,
858    results: &AnalysisResults,
859    root: &Path,
860    rules: &RulesConfig,
861    snippets: &mut SourceSnippetCache,
862) {
863    push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
864        sarif_dep_fields(
865            &d.dep,
866            root,
867            "fallow/unused-dependency",
868            severity_to_sarif_level(rules.unused_dependencies),
869            "dependencies",
870        )
871    });
872    push_sarif_results(
873        sarif_results,
874        &results.unused_dev_dependencies,
875        snippets,
876        |d| {
877            sarif_dep_fields(
878                &d.dep,
879                root,
880                "fallow/unused-dev-dependency",
881                severity_to_sarif_level(rules.unused_dev_dependencies),
882                "devDependencies",
883            )
884        },
885    );
886    push_sarif_results(
887        sarif_results,
888        &results.unused_optional_dependencies,
889        snippets,
890        |d| {
891            sarif_dep_fields(
892                &d.dep,
893                root,
894                "fallow/unused-optional-dependency",
895                severity_to_sarif_level(rules.unused_optional_dependencies),
896                "optionalDependencies",
897            )
898        },
899    );
900    push_sarif_results(
901        sarif_results,
902        &results.type_only_dependencies,
903        snippets,
904        |d| {
905            sarif_type_only_dep_fields(
906                &d.dep,
907                root,
908                severity_to_sarif_level(rules.type_only_dependencies),
909            )
910        },
911    );
912    push_sarif_results(
913        sarif_results,
914        &results.test_only_dependencies,
915        snippets,
916        |d| {
917            sarif_test_only_dep_fields(
918                &d.dep,
919                root,
920                severity_to_sarif_level(rules.test_only_dependencies),
921            )
922        },
923    );
924}
925
926fn push_member_sarif_results(
927    sarif_results: &mut Vec<serde_json::Value>,
928    results: &AnalysisResults,
929    root: &Path,
930    rules: &RulesConfig,
931    snippets: &mut SourceSnippetCache,
932) {
933    push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
934        sarif_member_fields(
935            &m.member,
936            root,
937            "fallow/unused-enum-member",
938            severity_to_sarif_level(rules.unused_enum_members),
939            "Enum",
940        )
941    });
942    push_sarif_results(
943        sarif_results,
944        &results.unused_class_members,
945        snippets,
946        |m| {
947            sarif_member_fields(
948                &m.member,
949                root,
950                "fallow/unused-class-member",
951                severity_to_sarif_level(rules.unused_class_members),
952                "Class",
953            )
954        },
955    );
956}
957
958fn push_misc_sarif_results(
959    sarif_results: &mut Vec<serde_json::Value>,
960    results: &AnalysisResults,
961    root: &Path,
962    rules: &RulesConfig,
963    snippets: &mut SourceSnippetCache,
964) {
965    if !results.unlisted_dependencies.is_empty() {
966        push_sarif_unlisted_deps(
967            sarif_results,
968            &results.unlisted_dependencies,
969            root,
970            severity_to_sarif_level(rules.unlisted_dependencies),
971            snippets,
972        );
973    }
974    if !results.duplicate_exports.is_empty() {
975        push_sarif_duplicate_exports(
976            sarif_results,
977            &results.duplicate_exports,
978            root,
979            severity_to_sarif_level(rules.duplicate_exports),
980            snippets,
981        );
982    }
983}
984
985fn push_graph_sarif_results(
986    sarif_results: &mut Vec<serde_json::Value>,
987    results: &AnalysisResults,
988    root: &Path,
989    rules: &RulesConfig,
990    snippets: &mut SourceSnippetCache,
991) {
992    push_sarif_results(
993        sarif_results,
994        &results.circular_dependencies,
995        snippets,
996        |c| {
997            sarif_circular_dep_fields(
998                &c.cycle,
999                root,
1000                severity_to_sarif_level(rules.circular_dependencies),
1001            )
1002        },
1003    );
1004    push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1005        sarif_re_export_cycle_fields(
1006            &c.cycle,
1007            root,
1008            severity_to_sarif_level(rules.re_export_cycle),
1009        )
1010    });
1011    push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1012        sarif_boundary_violation_fields(
1013            &v.violation,
1014            root,
1015            severity_to_sarif_level(rules.boundary_violation),
1016        )
1017    });
1018    push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1019        sarif_stale_suppression_fields(s, root, severity_to_sarif_level(rules.stale_suppressions))
1020    });
1021}
1022
1023fn push_catalog_sarif_results(
1024    sarif_results: &mut Vec<serde_json::Value>,
1025    results: &AnalysisResults,
1026    root: &Path,
1027    rules: &RulesConfig,
1028    snippets: &mut SourceSnippetCache,
1029) {
1030    push_sarif_results(
1031        sarif_results,
1032        &results.unused_catalog_entries,
1033        snippets,
1034        |e| {
1035            sarif_unused_catalog_entry_fields(
1036                e,
1037                root,
1038                severity_to_sarif_level(rules.unused_catalog_entries),
1039            )
1040        },
1041    );
1042    push_sarif_results(
1043        sarif_results,
1044        &results.empty_catalog_groups,
1045        snippets,
1046        |g| {
1047            sarif_empty_catalog_group_fields(
1048                g,
1049                root,
1050                severity_to_sarif_level(rules.empty_catalog_groups),
1051            )
1052        },
1053    );
1054    push_sarif_results(
1055        sarif_results,
1056        &results.unresolved_catalog_references,
1057        snippets,
1058        |f| {
1059            sarif_unresolved_catalog_reference_fields(
1060                f,
1061                root,
1062                severity_to_sarif_level(rules.unresolved_catalog_references),
1063            )
1064        },
1065    );
1066    push_sarif_results(
1067        sarif_results,
1068        &results.unused_dependency_overrides,
1069        snippets,
1070        |f| {
1071            sarif_unused_dependency_override_fields(
1072                f,
1073                root,
1074                severity_to_sarif_level(rules.unused_dependency_overrides),
1075            )
1076        },
1077    );
1078    push_sarif_results(
1079        sarif_results,
1080        &results.misconfigured_dependency_overrides,
1081        snippets,
1082        |f| {
1083            sarif_misconfigured_dependency_override_fields(
1084                f,
1085                root,
1086                severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1087            )
1088        },
1089    );
1090}
1091
1092pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
1093    let sarif = build_sarif(results, root, rules);
1094    emit_json(&sarif, "SARIF")
1095}
1096
1097/// Print SARIF output with owner properties added to each result.
1098///
1099/// Calls `build_sarif` to produce the standard SARIF JSON, then post-processes
1100/// each result to add `"properties": { "owner": "@team" }` by resolving the
1101/// artifact location URI through the `OwnershipResolver`.
1102#[expect(
1103    clippy::expect_used,
1104    reason = "grouped SARIF entries are JSON objects created by build_sarif"
1105)]
1106pub(super) fn print_grouped_sarif(
1107    results: &AnalysisResults,
1108    root: &Path,
1109    rules: &RulesConfig,
1110    resolver: &OwnershipResolver,
1111) -> ExitCode {
1112    let mut sarif = build_sarif(results, root, rules);
1113
1114    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1115        for run in runs {
1116            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1117                for result in results {
1118                    let uri = result
1119                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1120                        .and_then(|v| v.as_str())
1121                        .unwrap_or("");
1122                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1123                    let owner =
1124                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1125                    let props = result
1126                        .as_object_mut()
1127                        .expect("SARIF result should be an object")
1128                        .entry("properties")
1129                        .or_insert_with(|| serde_json::json!({}));
1130                    props
1131                        .as_object_mut()
1132                        .expect("properties should be an object")
1133                        .insert("owner".to_string(), serde_json::Value::String(owner));
1134                }
1135            }
1136        }
1137    }
1138
1139    emit_json(&sarif, "SARIF")
1140}
1141
1142#[expect(
1143    clippy::cast_possible_truncation,
1144    reason = "line/col numbers are bounded by source size"
1145)]
1146pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
1147    let mut sarif_results = Vec::new();
1148    let mut snippets = SourceSnippetCache::default();
1149
1150    for (i, group) in report.clone_groups.iter().enumerate() {
1151        for instance in &group.instances {
1152            let uri = relative_uri(&instance.file, root);
1153            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1154            sarif_results.push(sarif_result_with_snippet(
1155                "fallow/code-duplication",
1156                "warning",
1157                &format!(
1158                    "Code clone group {} ({} lines, {} instances)",
1159                    i + 1,
1160                    group.line_count,
1161                    group.instances.len()
1162                ),
1163                &uri,
1164                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1165                source_snippet.as_deref(),
1166            ));
1167        }
1168    }
1169
1170    let sarif = serde_json::json!({
1171        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1172        "version": "2.1.0",
1173        "runs": [{
1174            "tool": {
1175                "driver": {
1176                    "name": "fallow",
1177                    "version": env!("CARGO_PKG_VERSION"),
1178                    "informationUri": "https://github.com/fallow-rs/fallow",
1179                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1180                }
1181            },
1182            "results": sarif_results
1183        }]
1184    });
1185
1186    emit_json(&sarif, "SARIF")
1187}
1188
1189/// Print SARIF duplication output with a `properties.group` tag on every
1190/// result.
1191///
1192/// Each clone group is attributed to its largest owner (most instances; ties
1193/// broken alphabetically) via [`super::dupes_grouping::largest_owner`], and
1194/// every result emitted for that group's instances carries the same
1195/// `properties.group` value. This mirrors the health SARIF convention
1196/// (`print_grouped_health_sarif`) so consumers (GitHub Code Scanning, GitLab
1197/// Code Quality) can partition findings per team / package / directory
1198/// without re-resolving ownership.
1199#[expect(
1200    clippy::cast_possible_truncation,
1201    reason = "line/col numbers are bounded by source size"
1202)]
1203#[expect(
1204    clippy::expect_used,
1205    reason = "duplication SARIF entries are JSON objects created by sarif_result_with_snippet"
1206)]
1207pub(super) fn print_grouped_duplication_sarif(
1208    report: &DuplicationReport,
1209    root: &Path,
1210    resolver: &OwnershipResolver,
1211) -> ExitCode {
1212    let mut sarif_results = Vec::new();
1213    let mut snippets = SourceSnippetCache::default();
1214
1215    for (i, group) in report.clone_groups.iter().enumerate() {
1216        let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
1217        for instance in &group.instances {
1218            let uri = relative_uri(&instance.file, root);
1219            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1220            let mut result = sarif_result_with_snippet(
1221                "fallow/code-duplication",
1222                "warning",
1223                &format!(
1224                    "Code clone group {} ({} lines, {} instances)",
1225                    i + 1,
1226                    group.line_count,
1227                    group.instances.len()
1228                ),
1229                &uri,
1230                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1231                source_snippet.as_deref(),
1232            );
1233            let props = result
1234                .as_object_mut()
1235                .expect("SARIF result should be an object")
1236                .entry("properties")
1237                .or_insert_with(|| serde_json::json!({}));
1238            props
1239                .as_object_mut()
1240                .expect("properties should be an object")
1241                .insert(
1242                    "group".to_string(),
1243                    serde_json::Value::String(primary_owner.clone()),
1244                );
1245            sarif_results.push(result);
1246        }
1247    }
1248
1249    let sarif = serde_json::json!({
1250        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1251        "version": "2.1.0",
1252        "runs": [{
1253            "tool": {
1254                "driver": {
1255                    "name": "fallow",
1256                    "version": env!("CARGO_PKG_VERSION"),
1257                    "informationUri": "https://github.com/fallow-rs/fallow",
1258                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1259                }
1260            },
1261            "results": sarif_results
1262        }]
1263    });
1264
1265    emit_json(&sarif, "SARIF")
1266}
1267
1268#[must_use]
1269pub fn build_health_sarif(
1270    report: &crate::health_types::HealthReport,
1271    root: &Path,
1272) -> serde_json::Value {
1273    let mut sarif_results = Vec::new();
1274    let mut snippets = SourceSnippetCache::default();
1275
1276    append_complexity_sarif_results(&mut sarif_results, report, root, &mut snippets);
1277
1278    if let Some(ref production) = report.runtime_coverage {
1279        append_runtime_coverage_sarif_results(&mut sarif_results, production, root, &mut snippets);
1280    }
1281    if let Some(ref intelligence) = report.coverage_intelligence {
1282        append_coverage_intelligence_sarif_results(
1283            &mut sarif_results,
1284            intelligence,
1285            root,
1286            &mut snippets,
1287        );
1288    }
1289
1290    append_refactoring_target_sarif_results(&mut sarif_results, report, root);
1291    append_coverage_gap_sarif_results(&mut sarif_results, report, root, &mut snippets);
1292
1293    let health_rules = vec![
1294        sarif_rule(
1295            "fallow/high-cyclomatic-complexity",
1296            "Function has high cyclomatic complexity",
1297            "note",
1298        ),
1299        sarif_rule(
1300            "fallow/high-cognitive-complexity",
1301            "Function has high cognitive complexity",
1302            "note",
1303        ),
1304        sarif_rule(
1305            "fallow/high-complexity",
1306            "Function exceeds both complexity thresholds",
1307            "note",
1308        ),
1309        sarif_rule(
1310            "fallow/high-crap-score",
1311            "Function has a high CRAP score (high complexity combined with low coverage)",
1312            "warning",
1313        ),
1314        sarif_rule(
1315            "fallow/refactoring-target",
1316            "File identified as a high-priority refactoring candidate",
1317            "warning",
1318        ),
1319        sarif_rule(
1320            "fallow/untested-file",
1321            "Runtime-reachable file has no test dependency path",
1322            "warning",
1323        ),
1324        sarif_rule(
1325            "fallow/untested-export",
1326            "Runtime-reachable export has no test dependency path",
1327            "warning",
1328        ),
1329        sarif_rule(
1330            "fallow/runtime-safe-to-delete",
1331            "Function is statically unused and was never invoked in production",
1332            "warning",
1333        ),
1334        sarif_rule(
1335            "fallow/runtime-review-required",
1336            "Function is statically used but was never invoked in production",
1337            "warning",
1338        ),
1339        sarif_rule(
1340            "fallow/runtime-low-traffic",
1341            "Function was invoked below the low-traffic threshold relative to total trace count",
1342            "note",
1343        ),
1344        sarif_rule(
1345            "fallow/runtime-coverage-unavailable",
1346            "Runtime coverage could not be resolved for this function",
1347            "note",
1348        ),
1349        sarif_rule(
1350            "fallow/runtime-coverage",
1351            "Runtime coverage finding",
1352            "note",
1353        ),
1354        sarif_rule(
1355            "fallow/coverage-intelligence-risky-change",
1356            "Changed hot path combines high CRAP and low test coverage",
1357            "warning",
1358        ),
1359        sarif_rule(
1360            "fallow/coverage-intelligence-delete",
1361            "Static and runtime evidence indicate code can be deleted",
1362            "warning",
1363        ),
1364        sarif_rule(
1365            "fallow/coverage-intelligence-review",
1366            "Cold reachable uncovered code needs owner review",
1367            "warning",
1368        ),
1369        sarif_rule(
1370            "fallow/coverage-intelligence-refactor",
1371            "Hot covered code has high CRAP and should be refactored carefully",
1372            "warning",
1373        ),
1374    ];
1375
1376    serde_json::json!({
1377        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1378        "version": "2.1.0",
1379        "runs": [{
1380            "tool": {
1381                "driver": {
1382                    "name": "fallow",
1383                    "version": env!("CARGO_PKG_VERSION"),
1384                    "informationUri": "https://github.com/fallow-rs/fallow",
1385                    "rules": health_rules
1386                }
1387            },
1388            "results": sarif_results
1389        }]
1390    })
1391}
1392
1393fn append_complexity_sarif_results(
1394    sarif_results: &mut Vec<serde_json::Value>,
1395    report: &crate::health_types::HealthReport,
1396    root: &Path,
1397    snippets: &mut SourceSnippetCache,
1398) {
1399    for finding in &report.findings {
1400        let uri = relative_uri(&finding.path, root);
1401        let (rule_id, message) = health_complexity_sarif_message(finding, report);
1402        let level = match finding.severity {
1403            crate::health_types::FindingSeverity::Critical => "error",
1404            crate::health_types::FindingSeverity::High => "warning",
1405            crate::health_types::FindingSeverity::Moderate => "note",
1406        };
1407        let source_snippet = snippets.line(&finding.path, finding.line);
1408        sarif_results.push(sarif_result_with_snippet(
1409            rule_id,
1410            level,
1411            &message,
1412            &uri,
1413            Some((finding.line, finding.col + 1)),
1414            source_snippet.as_deref(),
1415        ));
1416    }
1417}
1418
1419fn health_complexity_sarif_message(
1420    finding: &crate::health_types::ComplexityViolation,
1421    report: &crate::health_types::HealthReport,
1422) -> (&'static str, String) {
1423    match finding.exceeded {
1424        crate::health_types::ExceededThreshold::Cyclomatic => (
1425            "fallow/high-cyclomatic-complexity",
1426            format!(
1427                "'{}' has cyclomatic complexity {} (threshold: {})",
1428                finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
1429            ),
1430        ),
1431        crate::health_types::ExceededThreshold::Cognitive => (
1432            "fallow/high-cognitive-complexity",
1433            format!(
1434                "'{}' has cognitive complexity {} (threshold: {})",
1435                finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
1436            ),
1437        ),
1438        crate::health_types::ExceededThreshold::Both => (
1439            "fallow/high-complexity",
1440            format!(
1441                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
1442                finding.name,
1443                finding.cyclomatic,
1444                report.summary.max_cyclomatic_threshold,
1445                finding.cognitive,
1446                report.summary.max_cognitive_threshold,
1447            ),
1448        ),
1449        crate::health_types::ExceededThreshold::Crap
1450        | crate::health_types::ExceededThreshold::CyclomaticCrap
1451        | crate::health_types::ExceededThreshold::CognitiveCrap
1452        | crate::health_types::ExceededThreshold::All => {
1453            let crap = finding.crap.unwrap_or(0.0);
1454            let coverage = finding
1455                .coverage_pct
1456                .map(|pct| format!(", coverage {pct:.0}%"))
1457                .unwrap_or_default();
1458            (
1459                "fallow/high-crap-score",
1460                format!(
1461                    "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
1462                    finding.name,
1463                    crap,
1464                    report.summary.max_crap_threshold,
1465                    finding.cyclomatic,
1466                    coverage,
1467                ),
1468            )
1469        }
1470    }
1471}
1472
1473fn append_refactoring_target_sarif_results(
1474    sarif_results: &mut Vec<serde_json::Value>,
1475    report: &crate::health_types::HealthReport,
1476    root: &Path,
1477) {
1478    for target in &report.targets {
1479        let uri = relative_uri(&target.path, root);
1480        let message = format!(
1481            "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
1482            target.category.label(),
1483            target.recommendation,
1484            target.priority,
1485            target.efficiency,
1486            target.effort.label(),
1487            target.confidence.label(),
1488        );
1489        sarif_results.push(sarif_result(
1490            "fallow/refactoring-target",
1491            "warning",
1492            &message,
1493            &uri,
1494            None,
1495        ));
1496    }
1497}
1498
1499fn append_coverage_gap_sarif_results(
1500    sarif_results: &mut Vec<serde_json::Value>,
1501    report: &crate::health_types::HealthReport,
1502    root: &Path,
1503    snippets: &mut SourceSnippetCache,
1504) {
1505    let Some(ref gaps) = report.coverage_gaps else {
1506        return;
1507    };
1508    for item in &gaps.files {
1509        let uri = relative_uri(&item.file.path, root);
1510        let message = format!(
1511            "File is runtime-reachable but has no test dependency path ({} value export{})",
1512            item.file.value_export_count,
1513            if item.file.value_export_count == 1 {
1514                ""
1515            } else {
1516                "s"
1517            },
1518        );
1519        sarif_results.push(sarif_result(
1520            "fallow/untested-file",
1521            "warning",
1522            &message,
1523            &uri,
1524            None,
1525        ));
1526    }
1527
1528    for item in &gaps.exports {
1529        let uri = relative_uri(&item.export.path, root);
1530        let message = format!(
1531            "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1532            item.export.export_name
1533        );
1534        let source_snippet = snippets.line(&item.export.path, item.export.line);
1535        sarif_results.push(sarif_result_with_snippet(
1536            "fallow/untested-export",
1537            "warning",
1538            &message,
1539            &uri,
1540            Some((item.export.line, item.export.col + 1)),
1541            source_snippet.as_deref(),
1542        ));
1543    }
1544}
1545
1546fn append_runtime_coverage_sarif_results(
1547    sarif_results: &mut Vec<serde_json::Value>,
1548    production: &crate::health_types::RuntimeCoverageReport,
1549    root: &Path,
1550    snippets: &mut SourceSnippetCache,
1551) {
1552    for finding in &production.findings {
1553        let uri = relative_uri(&finding.path, root);
1554        let rule_id = match finding.verdict {
1555            crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1556                "fallow/runtime-safe-to-delete"
1557            }
1558            crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1559                "fallow/runtime-review-required"
1560            }
1561            crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
1562            crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1563                "fallow/runtime-coverage-unavailable"
1564            }
1565            crate::health_types::RuntimeCoverageVerdict::Active
1566            | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1567        };
1568        let level = match finding.verdict {
1569            crate::health_types::RuntimeCoverageVerdict::SafeToDelete
1570            | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
1571            _ => "note",
1572        };
1573        let invocations_hint = finding.invocations.map_or_else(
1574            || "untracked".to_owned(),
1575            |hits| format!("{hits} invocations"),
1576        );
1577        let message = format!(
1578            "'{}' runtime coverage verdict: {} ({})",
1579            finding.function,
1580            finding.verdict.human_label(),
1581            invocations_hint,
1582        );
1583        let source_snippet = snippets.line(&finding.path, finding.line);
1584        sarif_results.push(sarif_result_with_snippet(
1585            rule_id,
1586            level,
1587            &message,
1588            &uri,
1589            Some((finding.line, 1)),
1590            source_snippet.as_deref(),
1591        ));
1592    }
1593}
1594
1595fn append_coverage_intelligence_sarif_results(
1596    sarif_results: &mut Vec<serde_json::Value>,
1597    intelligence: &crate::health_types::CoverageIntelligenceReport,
1598    root: &Path,
1599    snippets: &mut SourceSnippetCache,
1600) {
1601    for finding in &intelligence.findings {
1602        let rule_id = coverage_intelligence_rule_id(finding.recommendation);
1603        let level = match finding.verdict {
1604            crate::health_types::CoverageIntelligenceVerdict::Clean
1605            | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
1606            _ => "warning",
1607        };
1608        let uri = relative_uri(&finding.path, root);
1609        let identity = finding.identity.as_deref().unwrap_or("code");
1610        let signals = finding
1611            .signals
1612            .iter()
1613            .map(ToString::to_string)
1614            .collect::<Vec<_>>()
1615            .join(", ");
1616        let message = format!(
1617            "'{}' coverage intelligence verdict: {} ({}, signals: {})",
1618            identity, finding.verdict, finding.recommendation, signals,
1619        );
1620        let source_snippet = snippets.line(&finding.path, finding.line);
1621        let mut result = sarif_result_with_snippet(
1622            rule_id,
1623            level,
1624            &message,
1625            &uri,
1626            Some((finding.line, 1)),
1627            source_snippet.as_deref(),
1628        );
1629        result["properties"] = serde_json::json!({
1630            "coverage_intelligence_id": &finding.id,
1631            "verdict": finding.verdict,
1632            "recommendation": finding.recommendation,
1633            "confidence": finding.confidence,
1634            "signals": &finding.signals,
1635            "related_ids": &finding.related_ids,
1636        });
1637        sarif_results.push(result);
1638    }
1639}
1640
1641fn coverage_intelligence_rule_id(
1642    recommendation: crate::health_types::CoverageIntelligenceRecommendation,
1643) -> &'static str {
1644    match recommendation {
1645        crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
1646            "fallow/coverage-intelligence-risky-change"
1647        }
1648        crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
1649            "fallow/coverage-intelligence-delete"
1650        }
1651        crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
1652            "fallow/coverage-intelligence-review"
1653        }
1654        crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
1655            "fallow/coverage-intelligence-refactor"
1656        }
1657    }
1658}
1659
1660pub(super) fn print_health_sarif(
1661    report: &crate::health_types::HealthReport,
1662    root: &Path,
1663) -> ExitCode {
1664    let sarif = build_health_sarif(report, root);
1665    emit_json(&sarif, "SARIF")
1666}
1667
1668/// Print health SARIF with a per-result `properties.group` tag.
1669///
1670/// Mirrors the dead-code grouped SARIF pattern (`print_grouped_sarif`):
1671/// build the standard SARIF first, then post-process each result to inject
1672/// the resolver-derived group key on `properties.group`. Consumers that read
1673/// SARIF (GitHub Code Scanning, GitLab Code Quality) can then partition
1674/// findings per team / package / directory without dropping out of the
1675/// SARIF pipeline. Each finding's URI is decoded (`%5B` -> `[`, `%5D` -> `]`)
1676/// before resolution, matching the dead-code behaviour for paths containing
1677/// brackets like Next.js dynamic routes.
1678#[expect(
1679    clippy::expect_used,
1680    reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
1681)]
1682pub(super) fn print_grouped_health_sarif(
1683    report: &crate::health_types::HealthReport,
1684    root: &Path,
1685    resolver: &OwnershipResolver,
1686) -> ExitCode {
1687    let mut sarif = build_health_sarif(report, root);
1688
1689    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1690        for run in runs {
1691            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1692                for result in results {
1693                    let uri = result
1694                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1695                        .and_then(|v| v.as_str())
1696                        .unwrap_or("");
1697                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1698                    let group =
1699                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1700                    let props = result
1701                        .as_object_mut()
1702                        .expect("SARIF result should be an object")
1703                        .entry("properties")
1704                        .or_insert_with(|| serde_json::json!({}));
1705                    props
1706                        .as_object_mut()
1707                        .expect("properties should be an object")
1708                        .insert("group".to_string(), serde_json::Value::String(group));
1709                }
1710            }
1711        }
1712    }
1713
1714    emit_json(&sarif, "SARIF")
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719    use super::*;
1720    use crate::report::test_helpers::sample_results;
1721    use fallow_core::results::*;
1722    use std::path::PathBuf;
1723
1724    #[test]
1725    fn sarif_has_required_top_level_fields() {
1726        let root = PathBuf::from("/project");
1727        let results = AnalysisResults::default();
1728        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1729
1730        assert_eq!(
1731            sarif["$schema"],
1732            "https://json.schemastore.org/sarif-2.1.0.json"
1733        );
1734        assert_eq!(sarif["version"], "2.1.0");
1735        assert!(sarif["runs"].is_array());
1736    }
1737
1738    #[test]
1739    fn sarif_has_tool_driver_info() {
1740        let root = PathBuf::from("/project");
1741        let results = AnalysisResults::default();
1742        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1743
1744        let driver = &sarif["runs"][0]["tool"]["driver"];
1745        assert_eq!(driver["name"], "fallow");
1746        assert!(driver["version"].is_string());
1747        assert_eq!(
1748            driver["informationUri"],
1749            "https://github.com/fallow-rs/fallow"
1750        );
1751    }
1752
1753    #[test]
1754    fn sarif_declares_all_rules() {
1755        let root = PathBuf::from("/project");
1756        let results = AnalysisResults::default();
1757        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1758
1759        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1760            .as_array()
1761            .expect("rules should be an array");
1762        assert_eq!(rules.len(), 23);
1763
1764        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1765        assert!(rule_ids.contains(&"fallow/unused-file"));
1766        assert!(rule_ids.contains(&"fallow/unused-export"));
1767        assert!(rule_ids.contains(&"fallow/unused-type"));
1768        assert!(rule_ids.contains(&"fallow/private-type-leak"));
1769        assert!(rule_ids.contains(&"fallow/unused-dependency"));
1770        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1771        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1772        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1773        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1774        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1775        assert!(rule_ids.contains(&"fallow/unused-class-member"));
1776        assert!(rule_ids.contains(&"fallow/unresolved-import"));
1777        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1778        assert!(rule_ids.contains(&"fallow/duplicate-export"));
1779        assert!(rule_ids.contains(&"fallow/circular-dependency"));
1780        assert!(rule_ids.contains(&"fallow/re-export-cycle"));
1781        assert!(rule_ids.contains(&"fallow/boundary-violation"));
1782        assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
1783        assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
1784        assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
1785        assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
1786        assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
1787    }
1788
1789    #[test]
1790    fn sarif_empty_results_no_results_entries() {
1791        let root = PathBuf::from("/project");
1792        let results = AnalysisResults::default();
1793        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1794
1795        let sarif_results = sarif["runs"][0]["results"]
1796            .as_array()
1797            .expect("results should be an array");
1798        assert!(sarif_results.is_empty());
1799    }
1800
1801    #[test]
1802    fn sarif_unused_file_result() {
1803        let root = PathBuf::from("/project");
1804        let mut results = AnalysisResults::default();
1805        results
1806            .unused_files
1807            .push(UnusedFileFinding::with_actions(UnusedFile {
1808                path: root.join("src/dead.ts"),
1809            }));
1810
1811        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1812        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1813        assert_eq!(entries.len(), 1);
1814
1815        let entry = &entries[0];
1816        assert_eq!(entry["ruleId"], "fallow/unused-file");
1817        assert_eq!(entry["level"], "error");
1818        assert_eq!(
1819            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1820            "src/dead.ts"
1821        );
1822    }
1823
1824    #[test]
1825    fn sarif_unused_export_includes_region() {
1826        let root = PathBuf::from("/project");
1827        let mut results = AnalysisResults::default();
1828        results
1829            .unused_exports
1830            .push(UnusedExportFinding::with_actions(UnusedExport {
1831                path: root.join("src/utils.ts"),
1832                export_name: "helperFn".to_string(),
1833                is_type_only: false,
1834                line: 10,
1835                col: 4,
1836                span_start: 120,
1837                is_re_export: false,
1838            }));
1839
1840        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1841        let entry = &sarif["runs"][0]["results"][0];
1842        assert_eq!(entry["ruleId"], "fallow/unused-export");
1843
1844        let region = &entry["locations"][0]["physicalLocation"]["region"];
1845        assert_eq!(region["startLine"], 10);
1846        assert_eq!(region["startColumn"], 5);
1847    }
1848
1849    #[test]
1850    fn sarif_unresolved_import_is_error_level() {
1851        let root = PathBuf::from("/project");
1852        let mut results = AnalysisResults::default();
1853        results
1854            .unresolved_imports
1855            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1856                path: root.join("src/app.ts"),
1857                specifier: "./missing".to_string(),
1858                line: 1,
1859                col: 0,
1860                specifier_col: 0,
1861            }));
1862
1863        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1864        let entry = &sarif["runs"][0]["results"][0];
1865        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1866        assert_eq!(entry["level"], "error");
1867    }
1868
1869    #[test]
1870    fn sarif_unlisted_dependency_points_to_import_site() {
1871        let root = PathBuf::from("/project");
1872        let mut results = AnalysisResults::default();
1873        results
1874            .unlisted_dependencies
1875            .push(UnlistedDependencyFinding::with_actions(
1876                UnlistedDependency {
1877                    package_name: "chalk".to_string(),
1878                    imported_from: vec![ImportSite {
1879                        path: root.join("src/cli.ts"),
1880                        line: 3,
1881                        col: 0,
1882                    }],
1883                },
1884            ));
1885
1886        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1887        let entry = &sarif["runs"][0]["results"][0];
1888        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1889        assert_eq!(entry["level"], "error");
1890        assert_eq!(
1891            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1892            "src/cli.ts"
1893        );
1894        let region = &entry["locations"][0]["physicalLocation"]["region"];
1895        assert_eq!(region["startLine"], 3);
1896        assert_eq!(region["startColumn"], 1);
1897    }
1898
1899    #[test]
1900    fn sarif_dependency_issues_point_to_package_json() {
1901        let root = PathBuf::from("/project");
1902        let mut results = AnalysisResults::default();
1903        results
1904            .unused_dependencies
1905            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1906                package_name: "lodash".to_string(),
1907                location: DependencyLocation::Dependencies,
1908                path: root.join("package.json"),
1909                line: 5,
1910                used_in_workspaces: Vec::new(),
1911            }));
1912        results
1913            .unused_dev_dependencies
1914            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1915                package_name: "jest".to_string(),
1916                location: DependencyLocation::DevDependencies,
1917                path: root.join("package.json"),
1918                line: 5,
1919                used_in_workspaces: Vec::new(),
1920            }));
1921
1922        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1923        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1924        for entry in entries {
1925            assert_eq!(
1926                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1927                "package.json"
1928            );
1929        }
1930    }
1931
1932    #[test]
1933    fn sarif_duplicate_export_emits_one_result_per_location() {
1934        let root = PathBuf::from("/project");
1935        let mut results = AnalysisResults::default();
1936        results
1937            .duplicate_exports
1938            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1939                export_name: "Config".to_string(),
1940                locations: vec![
1941                    DuplicateLocation {
1942                        path: root.join("src/a.ts"),
1943                        line: 15,
1944                        col: 0,
1945                    },
1946                    DuplicateLocation {
1947                        path: root.join("src/b.ts"),
1948                        line: 30,
1949                        col: 0,
1950                    },
1951                ],
1952            }));
1953
1954        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1955        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1956        assert_eq!(entries.len(), 2);
1957        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1958        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1959        assert_eq!(
1960            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1961            "src/a.ts"
1962        );
1963        assert_eq!(
1964            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1965            "src/b.ts"
1966        );
1967    }
1968
1969    #[test]
1970    fn sarif_all_issue_types_produce_results() {
1971        let root = PathBuf::from("/project");
1972        let results = sample_results(&root);
1973        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1974
1975        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1976        assert_eq!(entries.len(), results.total_issues() + 1);
1977
1978        let rule_ids: Vec<&str> = entries
1979            .iter()
1980            .map(|e| e["ruleId"].as_str().unwrap())
1981            .collect();
1982        assert!(rule_ids.contains(&"fallow/unused-file"));
1983        assert!(rule_ids.contains(&"fallow/unused-export"));
1984        assert!(rule_ids.contains(&"fallow/unused-type"));
1985        assert!(rule_ids.contains(&"fallow/unused-dependency"));
1986        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1987        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1988        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1989        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1990        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1991        assert!(rule_ids.contains(&"fallow/unused-class-member"));
1992        assert!(rule_ids.contains(&"fallow/unresolved-import"));
1993        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1994        assert!(rule_ids.contains(&"fallow/duplicate-export"));
1995    }
1996
1997    #[test]
1998    fn sarif_serializes_to_valid_json() {
1999        let root = PathBuf::from("/project");
2000        let results = sample_results(&root);
2001        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2002
2003        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2004        let reparsed: serde_json::Value =
2005            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
2006        assert_eq!(reparsed, sarif);
2007    }
2008
2009    #[test]
2010    fn sarif_file_write_produces_valid_sarif() {
2011        let root = PathBuf::from("/project");
2012        let results = sample_results(&root);
2013        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2014        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2015
2016        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
2017        let _ = std::fs::create_dir_all(&dir);
2018        let sarif_path = dir.join("results.sarif");
2019        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
2020
2021        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
2022        let parsed: serde_json::Value =
2023            serde_json::from_str(&contents).expect("file should contain valid JSON");
2024
2025        assert_eq!(parsed["version"], "2.1.0");
2026        assert_eq!(
2027            parsed["$schema"],
2028            "https://json.schemastore.org/sarif-2.1.0.json"
2029        );
2030        let sarif_results = parsed["runs"][0]["results"]
2031            .as_array()
2032            .expect("results should be an array");
2033        assert!(!sarif_results.is_empty());
2034
2035        let _ = std::fs::remove_file(&sarif_path);
2036        let _ = std::fs::remove_dir(&dir);
2037    }
2038
2039    #[test]
2040    fn health_sarif_empty_no_results() {
2041        let root = PathBuf::from("/project");
2042        let report = crate::health_types::HealthReport {
2043            summary: crate::health_types::HealthSummary {
2044                files_analyzed: 10,
2045                functions_analyzed: 50,
2046                ..Default::default()
2047            },
2048            ..Default::default()
2049        };
2050        let sarif = build_health_sarif(&report, &root);
2051        assert_eq!(sarif["version"], "2.1.0");
2052        let results = sarif["runs"][0]["results"].as_array().unwrap();
2053        assert!(results.is_empty());
2054        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2055            .as_array()
2056            .unwrap();
2057        assert_eq!(rules.len(), 16);
2058    }
2059
2060    #[test]
2061    fn health_sarif_coverage_intelligence_preserves_structured_properties() {
2062        use crate::health_types::{
2063            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2064            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2065            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2066            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2067            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2068            HealthReport, HealthSummary,
2069        };
2070
2071        let root = PathBuf::from("/project");
2072        let report = HealthReport {
2073            summary: HealthSummary {
2074                files_analyzed: 10,
2075                functions_analyzed: 50,
2076                ..Default::default()
2077            },
2078            coverage_intelligence: Some(CoverageIntelligenceReport {
2079                schema_version: CoverageIntelligenceSchemaVersion::V1,
2080                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2081                summary: CoverageIntelligenceSummary {
2082                    findings: 1,
2083                    high_confidence_deletes: 1,
2084                    ..Default::default()
2085                },
2086                findings: vec![CoverageIntelligenceFinding {
2087                    id: "fallow:coverage-intel:abc123".to_owned(),
2088                    path: root.join("src/dead.ts"),
2089                    identity: Some("deadPath".to_owned()),
2090                    line: 9,
2091                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2092                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2093                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2094                    confidence: CoverageIntelligenceConfidence::High,
2095                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2096                    evidence: CoverageIntelligenceEvidence {
2097                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2098                        ..Default::default()
2099                    },
2100                    actions: vec![CoverageIntelligenceAction {
2101                        kind: "delete-after-confirming-owner".to_owned(),
2102                        description: "Confirm ownership".to_owned(),
2103                        auto_fixable: false,
2104                    }],
2105                }],
2106            }),
2107            ..Default::default()
2108        };
2109
2110        let sarif = build_health_sarif(&report, &root);
2111        let result = &sarif["runs"][0]["results"][0];
2112        assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
2113        assert_eq!(
2114            result["properties"]["coverage_intelligence_id"],
2115            "fallow:coverage-intel:abc123"
2116        );
2117        assert_eq!(
2118            result["properties"]["recommendation"],
2119            "delete-after-confirming-owner"
2120        );
2121        assert_eq!(result["properties"]["confidence"], "high");
2122        assert_eq!(result["properties"]["signals"][0], "runtime_cold");
2123        assert_eq!(
2124            result["properties"]["related_ids"][0],
2125            "fallow:prod:deadbeef"
2126        );
2127    }
2128
2129    #[test]
2130    fn health_sarif_cyclomatic_only() {
2131        let root = PathBuf::from("/project");
2132        let report = crate::health_types::HealthReport {
2133            findings: vec![
2134                crate::health_types::ComplexityViolation {
2135                    path: root.join("src/utils.ts"),
2136                    name: "parseExpression".to_string(),
2137                    line: 42,
2138                    col: 0,
2139                    cyclomatic: 25,
2140                    cognitive: 10,
2141                    line_count: 80,
2142                    param_count: 0,
2143                    exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
2144                    severity: crate::health_types::FindingSeverity::High,
2145                    crap: None,
2146                    coverage_pct: None,
2147                    coverage_tier: None,
2148                    coverage_source: None,
2149                    inherited_from: None,
2150                    component_rollup: None,
2151                    contributions: Vec::new(),
2152                }
2153                .into(),
2154            ],
2155            summary: crate::health_types::HealthSummary {
2156                files_analyzed: 5,
2157                functions_analyzed: 20,
2158                functions_above_threshold: 1,
2159                ..Default::default()
2160            },
2161            ..Default::default()
2162        };
2163        let sarif = build_health_sarif(&report, &root);
2164        let entry = &sarif["runs"][0]["results"][0];
2165        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
2166        assert_eq!(entry["level"], "warning");
2167        assert!(
2168            entry["message"]["text"]
2169                .as_str()
2170                .unwrap()
2171                .contains("cyclomatic complexity 25")
2172        );
2173        assert_eq!(
2174            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2175            "src/utils.ts"
2176        );
2177        let region = &entry["locations"][0]["physicalLocation"]["region"];
2178        assert_eq!(region["startLine"], 42);
2179        assert_eq!(region["startColumn"], 1);
2180    }
2181
2182    #[test]
2183    fn health_sarif_cognitive_only() {
2184        let root = PathBuf::from("/project");
2185        let report = crate::health_types::HealthReport {
2186            findings: vec![
2187                crate::health_types::ComplexityViolation {
2188                    path: root.join("src/api.ts"),
2189                    name: "handleRequest".to_string(),
2190                    line: 10,
2191                    col: 4,
2192                    cyclomatic: 8,
2193                    cognitive: 20,
2194                    line_count: 40,
2195                    param_count: 0,
2196                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
2197                    severity: crate::health_types::FindingSeverity::High,
2198                    crap: None,
2199                    coverage_pct: None,
2200                    coverage_tier: None,
2201                    coverage_source: None,
2202                    inherited_from: None,
2203                    component_rollup: None,
2204                    contributions: Vec::new(),
2205                }
2206                .into(),
2207            ],
2208            summary: crate::health_types::HealthSummary {
2209                files_analyzed: 3,
2210                functions_analyzed: 10,
2211                functions_above_threshold: 1,
2212                ..Default::default()
2213            },
2214            ..Default::default()
2215        };
2216        let sarif = build_health_sarif(&report, &root);
2217        let entry = &sarif["runs"][0]["results"][0];
2218        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
2219        assert!(
2220            entry["message"]["text"]
2221                .as_str()
2222                .unwrap()
2223                .contains("cognitive complexity 20")
2224        );
2225        let region = &entry["locations"][0]["physicalLocation"]["region"];
2226        assert_eq!(region["startColumn"], 5); // col 4 + 1
2227    }
2228
2229    #[test]
2230    fn health_sarif_both_thresholds() {
2231        let root = PathBuf::from("/project");
2232        let report = crate::health_types::HealthReport {
2233            findings: vec![
2234                crate::health_types::ComplexityViolation {
2235                    path: root.join("src/complex.ts"),
2236                    name: "doEverything".to_string(),
2237                    line: 1,
2238                    col: 0,
2239                    cyclomatic: 30,
2240                    cognitive: 45,
2241                    line_count: 100,
2242                    param_count: 0,
2243                    exceeded: crate::health_types::ExceededThreshold::Both,
2244                    severity: crate::health_types::FindingSeverity::High,
2245                    crap: None,
2246                    coverage_pct: None,
2247                    coverage_tier: None,
2248                    coverage_source: None,
2249                    inherited_from: None,
2250                    component_rollup: None,
2251                    contributions: Vec::new(),
2252                }
2253                .into(),
2254            ],
2255            summary: crate::health_types::HealthSummary {
2256                files_analyzed: 1,
2257                functions_analyzed: 1,
2258                functions_above_threshold: 1,
2259                ..Default::default()
2260            },
2261            ..Default::default()
2262        };
2263        let sarif = build_health_sarif(&report, &root);
2264        let entry = &sarif["runs"][0]["results"][0];
2265        assert_eq!(entry["ruleId"], "fallow/high-complexity");
2266        let msg = entry["message"]["text"].as_str().unwrap();
2267        assert!(msg.contains("cyclomatic complexity 30"));
2268        assert!(msg.contains("cognitive complexity 45"));
2269    }
2270
2271    #[test]
2272    fn health_sarif_crap_only_emits_crap_rule() {
2273        let root = PathBuf::from("/project");
2274        let report = crate::health_types::HealthReport {
2275            findings: vec![
2276                crate::health_types::ComplexityViolation {
2277                    path: root.join("src/untested.ts"),
2278                    name: "risky".to_string(),
2279                    line: 8,
2280                    col: 0,
2281                    cyclomatic: 10,
2282                    cognitive: 10,
2283                    line_count: 20,
2284                    param_count: 1,
2285                    exceeded: crate::health_types::ExceededThreshold::Crap,
2286                    severity: crate::health_types::FindingSeverity::High,
2287                    crap: Some(82.2),
2288                    coverage_pct: Some(12.0),
2289                    coverage_tier: None,
2290                    coverage_source: None,
2291                    inherited_from: None,
2292                    component_rollup: None,
2293                    contributions: Vec::new(),
2294                }
2295                .into(),
2296            ],
2297            summary: crate::health_types::HealthSummary {
2298                files_analyzed: 1,
2299                functions_analyzed: 1,
2300                functions_above_threshold: 1,
2301                ..Default::default()
2302            },
2303            ..Default::default()
2304        };
2305        let sarif = build_health_sarif(&report, &root);
2306        let entry = &sarif["runs"][0]["results"][0];
2307        assert_eq!(entry["ruleId"], "fallow/high-crap-score");
2308        let msg = entry["message"]["text"].as_str().unwrap();
2309        assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
2310        assert!(msg.contains("coverage 12%"), "msg: {msg}");
2311    }
2312
2313    #[test]
2314    fn health_sarif_cyclomatic_crap_uses_crap_rule() {
2315        let root = PathBuf::from("/project");
2316        let report = crate::health_types::HealthReport {
2317            findings: vec![
2318                crate::health_types::ComplexityViolation {
2319                    path: root.join("src/hot.ts"),
2320                    name: "branchy".to_string(),
2321                    line: 1,
2322                    col: 0,
2323                    cyclomatic: 67,
2324                    cognitive: 12,
2325                    line_count: 80,
2326                    param_count: 1,
2327                    exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2328                    severity: crate::health_types::FindingSeverity::Critical,
2329                    crap: Some(182.0),
2330                    coverage_pct: None,
2331                    coverage_tier: None,
2332                    coverage_source: None,
2333                    inherited_from: None,
2334                    component_rollup: None,
2335                    contributions: Vec::new(),
2336                }
2337                .into(),
2338            ],
2339            summary: crate::health_types::HealthSummary {
2340                files_analyzed: 1,
2341                functions_analyzed: 1,
2342                functions_above_threshold: 1,
2343                ..Default::default()
2344            },
2345            ..Default::default()
2346        };
2347        let sarif = build_health_sarif(&report, &root);
2348        let results = sarif["runs"][0]["results"].as_array().unwrap();
2349        assert_eq!(
2350            results.len(),
2351            1,
2352            "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
2353        );
2354        assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
2355        let msg = results[0]["message"]["text"].as_str().unwrap();
2356        assert!(msg.contains("CRAP score 182"), "msg: {msg}");
2357        assert!(!msg.contains("coverage"), "msg: {msg}");
2358    }
2359
2360    #[test]
2361    fn severity_to_sarif_level_error() {
2362        assert_eq!(severity_to_sarif_level(Severity::Error), "error");
2363    }
2364
2365    #[test]
2366    fn severity_to_sarif_level_warn() {
2367        assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
2368    }
2369
2370    #[test]
2371    #[should_panic(expected = "internal error: entered unreachable code")]
2372    fn severity_to_sarif_level_off() {
2373        let _ = severity_to_sarif_level(Severity::Off);
2374    }
2375
2376    #[test]
2377    fn sarif_re_export_has_properties() {
2378        let root = PathBuf::from("/project");
2379        let mut results = AnalysisResults::default();
2380        results
2381            .unused_exports
2382            .push(UnusedExportFinding::with_actions(UnusedExport {
2383                path: root.join("src/index.ts"),
2384                export_name: "reExported".to_string(),
2385                is_type_only: false,
2386                line: 1,
2387                col: 0,
2388                span_start: 0,
2389                is_re_export: true,
2390            }));
2391
2392        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2393        let entry = &sarif["runs"][0]["results"][0];
2394        assert_eq!(entry["properties"]["is_re_export"], true);
2395        let msg = entry["message"]["text"].as_str().unwrap();
2396        assert!(msg.starts_with("Re-export"));
2397    }
2398
2399    #[test]
2400    fn sarif_non_re_export_has_no_properties() {
2401        let root = PathBuf::from("/project");
2402        let mut results = AnalysisResults::default();
2403        results
2404            .unused_exports
2405            .push(UnusedExportFinding::with_actions(UnusedExport {
2406                path: root.join("src/utils.ts"),
2407                export_name: "foo".to_string(),
2408                is_type_only: false,
2409                line: 5,
2410                col: 0,
2411                span_start: 0,
2412                is_re_export: false,
2413            }));
2414
2415        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2416        let entry = &sarif["runs"][0]["results"][0];
2417        assert!(entry.get("properties").is_none());
2418        let msg = entry["message"]["text"].as_str().unwrap();
2419        assert!(msg.starts_with("Export"));
2420    }
2421
2422    #[test]
2423    fn sarif_type_re_export_message() {
2424        let root = PathBuf::from("/project");
2425        let mut results = AnalysisResults::default();
2426        results
2427            .unused_types
2428            .push(UnusedTypeFinding::with_actions(UnusedExport {
2429                path: root.join("src/index.ts"),
2430                export_name: "MyType".to_string(),
2431                is_type_only: true,
2432                line: 1,
2433                col: 0,
2434                span_start: 0,
2435                is_re_export: true,
2436            }));
2437
2438        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2439        let entry = &sarif["runs"][0]["results"][0];
2440        assert_eq!(entry["ruleId"], "fallow/unused-type");
2441        let msg = entry["message"]["text"].as_str().unwrap();
2442        assert!(msg.starts_with("Type re-export"));
2443        assert_eq!(entry["properties"]["is_re_export"], true);
2444    }
2445
2446    #[test]
2447    fn sarif_dependency_line_zero_skips_region() {
2448        let root = PathBuf::from("/project");
2449        let mut results = AnalysisResults::default();
2450        results
2451            .unused_dependencies
2452            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2453                package_name: "lodash".to_string(),
2454                location: DependencyLocation::Dependencies,
2455                path: root.join("package.json"),
2456                line: 0,
2457                used_in_workspaces: Vec::new(),
2458            }));
2459
2460        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2461        let entry = &sarif["runs"][0]["results"][0];
2462        let phys = &entry["locations"][0]["physicalLocation"];
2463        assert!(phys.get("region").is_none());
2464    }
2465
2466    #[test]
2467    fn sarif_dependency_line_nonzero_has_region() {
2468        let root = PathBuf::from("/project");
2469        let mut results = AnalysisResults::default();
2470        results
2471            .unused_dependencies
2472            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2473                package_name: "lodash".to_string(),
2474                location: DependencyLocation::Dependencies,
2475                path: root.join("package.json"),
2476                line: 7,
2477                used_in_workspaces: Vec::new(),
2478            }));
2479
2480        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2481        let entry = &sarif["runs"][0]["results"][0];
2482        let region = &entry["locations"][0]["physicalLocation"]["region"];
2483        assert_eq!(region["startLine"], 7);
2484        assert_eq!(region["startColumn"], 1);
2485    }
2486
2487    #[test]
2488    fn sarif_type_only_dep_line_zero_skips_region() {
2489        let root = PathBuf::from("/project");
2490        let mut results = AnalysisResults::default();
2491        results
2492            .type_only_dependencies
2493            .push(TypeOnlyDependencyFinding::with_actions(
2494                TypeOnlyDependency {
2495                    package_name: "zod".to_string(),
2496                    path: root.join("package.json"),
2497                    line: 0,
2498                },
2499            ));
2500
2501        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2502        let entry = &sarif["runs"][0]["results"][0];
2503        let phys = &entry["locations"][0]["physicalLocation"];
2504        assert!(phys.get("region").is_none());
2505    }
2506
2507    #[test]
2508    fn sarif_circular_dep_line_zero_skips_region() {
2509        let root = PathBuf::from("/project");
2510        let mut results = AnalysisResults::default();
2511        results
2512            .circular_dependencies
2513            .push(CircularDependencyFinding::with_actions(
2514                CircularDependency {
2515                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2516                    length: 2,
2517                    line: 0,
2518                    col: 0,
2519                    edges: Vec::new(),
2520                    is_cross_package: false,
2521                },
2522            ));
2523
2524        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2525        let entry = &sarif["runs"][0]["results"][0];
2526        let phys = &entry["locations"][0]["physicalLocation"];
2527        assert!(phys.get("region").is_none());
2528    }
2529
2530    #[test]
2531    fn sarif_circular_dep_line_nonzero_has_region() {
2532        let root = PathBuf::from("/project");
2533        let mut results = AnalysisResults::default();
2534        results
2535            .circular_dependencies
2536            .push(CircularDependencyFinding::with_actions(
2537                CircularDependency {
2538                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2539                    length: 2,
2540                    line: 5,
2541                    col: 2,
2542                    edges: Vec::new(),
2543                    is_cross_package: false,
2544                },
2545            ));
2546
2547        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2548        let entry = &sarif["runs"][0]["results"][0];
2549        let region = &entry["locations"][0]["physicalLocation"]["region"];
2550        assert_eq!(region["startLine"], 5);
2551        assert_eq!(region["startColumn"], 3);
2552    }
2553
2554    #[test]
2555    fn sarif_unused_optional_dependency_result() {
2556        let root = PathBuf::from("/project");
2557        let mut results = AnalysisResults::default();
2558        results
2559            .unused_optional_dependencies
2560            .push(UnusedOptionalDependencyFinding::with_actions(
2561                UnusedDependency {
2562                    package_name: "fsevents".to_string(),
2563                    location: DependencyLocation::OptionalDependencies,
2564                    path: root.join("package.json"),
2565                    line: 12,
2566                    used_in_workspaces: Vec::new(),
2567                },
2568            ));
2569
2570        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2571        let entry = &sarif["runs"][0]["results"][0];
2572        assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
2573        let msg = entry["message"]["text"].as_str().unwrap();
2574        assert!(msg.contains("optionalDependencies"));
2575    }
2576
2577    #[test]
2578    fn sarif_enum_member_message_format() {
2579        let root = PathBuf::from("/project");
2580        let mut results = AnalysisResults::default();
2581        results.unused_enum_members.push(
2582            fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
2583                path: root.join("src/enums.ts"),
2584                parent_name: "Color".to_string(),
2585                member_name: "Purple".to_string(),
2586                kind: fallow_core::extract::MemberKind::EnumMember,
2587                line: 5,
2588                col: 2,
2589            }),
2590        );
2591
2592        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2593        let entry = &sarif["runs"][0]["results"][0];
2594        assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
2595        let msg = entry["message"]["text"].as_str().unwrap();
2596        assert!(msg.contains("Enum member 'Color.Purple'"));
2597        let region = &entry["locations"][0]["physicalLocation"]["region"];
2598        assert_eq!(region["startColumn"], 3); // col 2 + 1
2599    }
2600
2601    #[test]
2602    fn sarif_class_member_message_format() {
2603        let root = PathBuf::from("/project");
2604        let mut results = AnalysisResults::default();
2605        results.unused_class_members.push(
2606            fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
2607                path: root.join("src/service.ts"),
2608                parent_name: "API".to_string(),
2609                member_name: "fetch".to_string(),
2610                kind: fallow_core::extract::MemberKind::ClassMethod,
2611                line: 10,
2612                col: 4,
2613            }),
2614        );
2615
2616        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2617        let entry = &sarif["runs"][0]["results"][0];
2618        assert_eq!(entry["ruleId"], "fallow/unused-class-member");
2619        let msg = entry["message"]["text"].as_str().unwrap();
2620        assert!(msg.contains("Class member 'API.fetch'"));
2621    }
2622
2623    #[test]
2624    #[expect(
2625        clippy::cast_possible_truncation,
2626        reason = "test line/col values are trivially small"
2627    )]
2628    fn duplication_sarif_structure() {
2629        use fallow_core::duplicates::*;
2630
2631        let root = PathBuf::from("/project");
2632        let report = DuplicationReport {
2633            clone_groups: vec![CloneGroup {
2634                instances: vec![
2635                    CloneInstance {
2636                        file: root.join("src/a.ts"),
2637                        start_line: 1,
2638                        end_line: 10,
2639                        start_col: 0,
2640                        end_col: 0,
2641                        fragment: String::new(),
2642                    },
2643                    CloneInstance {
2644                        file: root.join("src/b.ts"),
2645                        start_line: 5,
2646                        end_line: 14,
2647                        start_col: 2,
2648                        end_col: 0,
2649                        fragment: String::new(),
2650                    },
2651                ],
2652                token_count: 50,
2653                line_count: 10,
2654            }],
2655            clone_families: vec![],
2656            mirrored_directories: vec![],
2657            stats: DuplicationStats::default(),
2658        };
2659
2660        let sarif = serde_json::json!({
2661            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2662            "version": "2.1.0",
2663            "runs": [{
2664                "tool": {
2665                    "driver": {
2666                        "name": "fallow",
2667                        "version": env!("CARGO_PKG_VERSION"),
2668                        "informationUri": "https://github.com/fallow-rs/fallow",
2669                        "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2670                    }
2671                },
2672                "results": []
2673            }]
2674        });
2675        let _ = sarif;
2676
2677        let mut sarif_results = Vec::new();
2678        for (i, group) in report.clone_groups.iter().enumerate() {
2679            for instance in &group.instances {
2680                sarif_results.push(sarif_result(
2681                    "fallow/code-duplication",
2682                    "warning",
2683                    &format!(
2684                        "Code clone group {} ({} lines, {} instances)",
2685                        i + 1,
2686                        group.line_count,
2687                        group.instances.len()
2688                    ),
2689                    &super::super::relative_uri(&instance.file, &root),
2690                    Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2691                ));
2692            }
2693        }
2694        assert_eq!(sarif_results.len(), 2);
2695        assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
2696        assert!(
2697            sarif_results[0]["message"]["text"]
2698                .as_str()
2699                .unwrap()
2700                .contains("10 lines")
2701        );
2702        let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
2703        assert_eq!(region0["startLine"], 1);
2704        assert_eq!(region0["startColumn"], 1); // start_col 0 + 1
2705        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
2706        assert_eq!(region1["startLine"], 5);
2707        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
2708    }
2709
2710    #[test]
2711    fn sarif_rule_known_id_has_full_description() {
2712        let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
2713        assert!(rule.get("fullDescription").is_some());
2714        assert!(rule.get("helpUri").is_some());
2715    }
2716
2717    #[test]
2718    fn sarif_rule_unknown_id_uses_fallback() {
2719        let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
2720        assert_eq!(rule["shortDescription"]["text"], "fallback text");
2721        assert!(rule.get("fullDescription").is_none());
2722        assert!(rule.get("helpUri").is_none());
2723        assert_eq!(rule["defaultConfiguration"]["level"], "warning");
2724    }
2725
2726    #[test]
2727    fn sarif_result_no_region_omits_region_key() {
2728        let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
2729        let phys = &result["locations"][0]["physicalLocation"];
2730        assert!(phys.get("region").is_none());
2731        assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
2732    }
2733
2734    #[test]
2735    fn sarif_result_with_region_includes_region() {
2736        let result = sarif_result(
2737            "rule/test",
2738            "error",
2739            "test msg",
2740            "src/file.ts",
2741            Some((10, 5)),
2742        );
2743        let region = &result["locations"][0]["physicalLocation"]["region"];
2744        assert_eq!(region["startLine"], 10);
2745        assert_eq!(region["startColumn"], 5);
2746    }
2747
2748    #[test]
2749    fn sarif_partial_fingerprint_ignores_rendered_message() {
2750        let a = sarif_result(
2751            "rule/test",
2752            "error",
2753            "first message",
2754            "src/file.ts",
2755            Some((10, 5)),
2756        );
2757        let b = sarif_result(
2758            "rule/test",
2759            "error",
2760            "rewritten message",
2761            "src/file.ts",
2762            Some((10, 5)),
2763        );
2764        assert_eq!(
2765            a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2766            b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2767        );
2768    }
2769
2770    #[test]
2771    fn health_sarif_includes_refactoring_targets() {
2772        use crate::health_types::*;
2773
2774        let root = PathBuf::from("/project");
2775        let report = HealthReport {
2776            summary: HealthSummary {
2777                files_analyzed: 10,
2778                functions_analyzed: 50,
2779                ..Default::default()
2780            },
2781            targets: vec![
2782                RefactoringTarget {
2783                    path: root.join("src/complex.ts"),
2784                    priority: 85.0,
2785                    efficiency: 42.5,
2786                    recommendation: "Split high-impact file".into(),
2787                    category: RecommendationCategory::SplitHighImpact,
2788                    effort: EffortEstimate::Medium,
2789                    confidence: Confidence::High,
2790                    factors: vec![],
2791                    evidence: None,
2792                }
2793                .into(),
2794            ],
2795            ..Default::default()
2796        };
2797
2798        let sarif = build_health_sarif(&report, &root);
2799        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2800        assert_eq!(entries.len(), 1);
2801        assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
2802        assert_eq!(entries[0]["level"], "warning");
2803        let msg = entries[0]["message"]["text"].as_str().unwrap();
2804        assert!(msg.contains("high impact"));
2805        assert!(msg.contains("Split high-impact file"));
2806        assert!(msg.contains("42.5"));
2807    }
2808
2809    #[test]
2810    fn health_sarif_includes_coverage_gaps() {
2811        use crate::health_types::*;
2812
2813        let root = PathBuf::from("/project");
2814        let report = HealthReport {
2815            summary: HealthSummary {
2816                files_analyzed: 10,
2817                functions_analyzed: 50,
2818                ..Default::default()
2819            },
2820            coverage_gaps: Some(CoverageGaps {
2821                summary: CoverageGapSummary {
2822                    runtime_files: 2,
2823                    covered_files: 0,
2824                    file_coverage_pct: 0.0,
2825                    untested_files: 1,
2826                    untested_exports: 1,
2827                },
2828                files: vec![UntestedFileFinding::with_actions(
2829                    UntestedFile {
2830                        path: root.join("src/app.ts"),
2831                        value_export_count: 2,
2832                    },
2833                    &root,
2834                )],
2835                exports: vec![UntestedExportFinding::with_actions(
2836                    UntestedExport {
2837                        path: root.join("src/app.ts"),
2838                        export_name: "loader".into(),
2839                        line: 12,
2840                        col: 4,
2841                    },
2842                    &root,
2843                )],
2844            }),
2845            ..Default::default()
2846        };
2847
2848        let sarif = build_health_sarif(&report, &root);
2849        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2850        assert_eq!(entries.len(), 2);
2851        assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
2852        assert_eq!(
2853            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2854            "src/app.ts"
2855        );
2856        assert!(
2857            entries[0]["message"]["text"]
2858                .as_str()
2859                .unwrap()
2860                .contains("2 value exports")
2861        );
2862        assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
2863        assert_eq!(
2864            entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
2865            12
2866        );
2867        assert_eq!(
2868            entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
2869            5
2870        );
2871    }
2872
2873    #[test]
2874    fn health_sarif_rules_have_full_descriptions() {
2875        let root = PathBuf::from("/project");
2876        let report = crate::health_types::HealthReport::default();
2877        let sarif = build_health_sarif(&report, &root);
2878        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2879            .as_array()
2880            .unwrap();
2881        for rule in rules {
2882            let id = rule["id"].as_str().unwrap();
2883            assert!(
2884                rule.get("fullDescription").is_some(),
2885                "health rule {id} should have fullDescription"
2886            );
2887            assert!(
2888                rule.get("helpUri").is_some(),
2889                "health rule {id} should have helpUri"
2890            );
2891        }
2892    }
2893
2894    #[test]
2895    fn sarif_warn_severity_produces_warning_level() {
2896        let root = PathBuf::from("/project");
2897        let mut results = AnalysisResults::default();
2898        results
2899            .unused_files
2900            .push(UnusedFileFinding::with_actions(UnusedFile {
2901                path: root.join("src/dead.ts"),
2902            }));
2903
2904        let rules = RulesConfig {
2905            unused_files: Severity::Warn,
2906            ..RulesConfig::default()
2907        };
2908
2909        let sarif = build_sarif(&results, &root, &rules);
2910        let entry = &sarif["runs"][0]["results"][0];
2911        assert_eq!(entry["level"], "warning");
2912    }
2913
2914    #[test]
2915    fn sarif_unused_file_has_no_region() {
2916        let root = PathBuf::from("/project");
2917        let mut results = AnalysisResults::default();
2918        results
2919            .unused_files
2920            .push(UnusedFileFinding::with_actions(UnusedFile {
2921                path: root.join("src/dead.ts"),
2922            }));
2923
2924        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2925        let entry = &sarif["runs"][0]["results"][0];
2926        let phys = &entry["locations"][0]["physicalLocation"];
2927        assert!(phys.get("region").is_none());
2928    }
2929
2930    #[test]
2931    fn sarif_unlisted_dep_multiple_import_sites() {
2932        let root = PathBuf::from("/project");
2933        let mut results = AnalysisResults::default();
2934        results
2935            .unlisted_dependencies
2936            .push(UnlistedDependencyFinding::with_actions(
2937                UnlistedDependency {
2938                    package_name: "dotenv".to_string(),
2939                    imported_from: vec![
2940                        ImportSite {
2941                            path: root.join("src/a.ts"),
2942                            line: 1,
2943                            col: 0,
2944                        },
2945                        ImportSite {
2946                            path: root.join("src/b.ts"),
2947                            line: 5,
2948                            col: 0,
2949                        },
2950                    ],
2951                },
2952            ));
2953
2954        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2955        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2956        assert_eq!(entries.len(), 2);
2957        assert_eq!(
2958            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2959            "src/a.ts"
2960        );
2961        assert_eq!(
2962            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2963            "src/b.ts"
2964        );
2965    }
2966
2967    #[test]
2968    fn sarif_unlisted_dep_no_import_sites() {
2969        let root = PathBuf::from("/project");
2970        let mut results = AnalysisResults::default();
2971        results
2972            .unlisted_dependencies
2973            .push(UnlistedDependencyFinding::with_actions(
2974                UnlistedDependency {
2975                    package_name: "phantom".to_string(),
2976                    imported_from: vec![],
2977                },
2978            ));
2979
2980        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2981        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2982        assert!(entries.is_empty());
2983    }
2984}