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]
765#[expect(
766    clippy::too_many_lines,
767    reason = "SARIF builds one flat result list across every analysis family"
768)]
769pub fn build_sarif(
770    results: &AnalysisResults,
771    root: &Path,
772    rules: &RulesConfig,
773) -> serde_json::Value {
774    let mut sarif_results = Vec::new();
775    let mut snippets = SourceSnippetCache::default();
776
777    push_sarif_results(
778        &mut sarif_results,
779        &results.unused_files,
780        &mut snippets,
781        |f| sarif_unused_file_fields(&f.file, root, severity_to_sarif_level(rules.unused_files)),
782    );
783    push_sarif_results(
784        &mut sarif_results,
785        &results.unused_exports,
786        &mut snippets,
787        |e| {
788            sarif_export_fields(
789                &e.export,
790                root,
791                "fallow/unused-export",
792                severity_to_sarif_level(rules.unused_exports),
793                "Export",
794                "Re-export",
795            )
796        },
797    );
798    push_sarif_results(
799        &mut sarif_results,
800        &results.unused_types,
801        &mut snippets,
802        |e| {
803            sarif_export_fields(
804                &e.export,
805                root,
806                "fallow/unused-type",
807                severity_to_sarif_level(rules.unused_types),
808                "Type export",
809                "Type re-export",
810            )
811        },
812    );
813    push_sarif_results(
814        &mut sarif_results,
815        &results.private_type_leaks,
816        &mut snippets,
817        |e| {
818            sarif_private_type_leak_fields(
819                &e.leak,
820                root,
821                severity_to_sarif_level(rules.private_type_leaks),
822            )
823        },
824    );
825    push_sarif_results(
826        &mut sarif_results,
827        &results.unused_dependencies,
828        &mut snippets,
829        |d| {
830            sarif_dep_fields(
831                &d.dep,
832                root,
833                "fallow/unused-dependency",
834                severity_to_sarif_level(rules.unused_dependencies),
835                "dependencies",
836            )
837        },
838    );
839    push_sarif_results(
840        &mut sarif_results,
841        &results.unused_dev_dependencies,
842        &mut snippets,
843        |d| {
844            sarif_dep_fields(
845                &d.dep,
846                root,
847                "fallow/unused-dev-dependency",
848                severity_to_sarif_level(rules.unused_dev_dependencies),
849                "devDependencies",
850            )
851        },
852    );
853    push_sarif_results(
854        &mut sarif_results,
855        &results.unused_optional_dependencies,
856        &mut snippets,
857        |d| {
858            sarif_dep_fields(
859                &d.dep,
860                root,
861                "fallow/unused-optional-dependency",
862                severity_to_sarif_level(rules.unused_optional_dependencies),
863                "optionalDependencies",
864            )
865        },
866    );
867    push_sarif_results(
868        &mut sarif_results,
869        &results.type_only_dependencies,
870        &mut snippets,
871        |d| {
872            sarif_type_only_dep_fields(
873                &d.dep,
874                root,
875                severity_to_sarif_level(rules.type_only_dependencies),
876            )
877        },
878    );
879    push_sarif_results(
880        &mut sarif_results,
881        &results.test_only_dependencies,
882        &mut snippets,
883        |d| {
884            sarif_test_only_dep_fields(
885                &d.dep,
886                root,
887                severity_to_sarif_level(rules.test_only_dependencies),
888            )
889        },
890    );
891    push_sarif_results(
892        &mut sarif_results,
893        &results.unused_enum_members,
894        &mut snippets,
895        |m| {
896            sarif_member_fields(
897                &m.member,
898                root,
899                "fallow/unused-enum-member",
900                severity_to_sarif_level(rules.unused_enum_members),
901                "Enum",
902            )
903        },
904    );
905    push_sarif_results(
906        &mut sarif_results,
907        &results.unused_class_members,
908        &mut snippets,
909        |m| {
910            sarif_member_fields(
911                &m.member,
912                root,
913                "fallow/unused-class-member",
914                severity_to_sarif_level(rules.unused_class_members),
915                "Class",
916            )
917        },
918    );
919    push_sarif_results(
920        &mut sarif_results,
921        &results.unresolved_imports,
922        &mut snippets,
923        |i| {
924            sarif_unresolved_import_fields(
925                &i.import,
926                root,
927                severity_to_sarif_level(rules.unresolved_imports),
928            )
929        },
930    );
931    if !results.unlisted_dependencies.is_empty() {
932        push_sarif_unlisted_deps(
933            &mut sarif_results,
934            &results.unlisted_dependencies,
935            root,
936            severity_to_sarif_level(rules.unlisted_dependencies),
937            &mut snippets,
938        );
939    }
940    if !results.duplicate_exports.is_empty() {
941        push_sarif_duplicate_exports(
942            &mut sarif_results,
943            &results.duplicate_exports,
944            root,
945            severity_to_sarif_level(rules.duplicate_exports),
946            &mut snippets,
947        );
948    }
949    push_sarif_results(
950        &mut sarif_results,
951        &results.circular_dependencies,
952        &mut snippets,
953        |c| {
954            sarif_circular_dep_fields(
955                &c.cycle,
956                root,
957                severity_to_sarif_level(rules.circular_dependencies),
958            )
959        },
960    );
961    push_sarif_results(
962        &mut sarif_results,
963        &results.re_export_cycles,
964        &mut snippets,
965        |c| {
966            sarif_re_export_cycle_fields(
967                &c.cycle,
968                root,
969                severity_to_sarif_level(rules.re_export_cycle),
970            )
971        },
972    );
973    push_sarif_results(
974        &mut sarif_results,
975        &results.boundary_violations,
976        &mut snippets,
977        |v| {
978            sarif_boundary_violation_fields(
979                &v.violation,
980                root,
981                severity_to_sarif_level(rules.boundary_violation),
982            )
983        },
984    );
985    push_sarif_results(
986        &mut sarif_results,
987        &results.stale_suppressions,
988        &mut snippets,
989        |s| {
990            sarif_stale_suppression_fields(
991                s,
992                root,
993                severity_to_sarif_level(rules.stale_suppressions),
994            )
995        },
996    );
997    push_sarif_results(
998        &mut sarif_results,
999        &results.unused_catalog_entries,
1000        &mut snippets,
1001        |e| {
1002            sarif_unused_catalog_entry_fields(
1003                e,
1004                root,
1005                severity_to_sarif_level(rules.unused_catalog_entries),
1006            )
1007        },
1008    );
1009    push_sarif_results(
1010        &mut sarif_results,
1011        &results.empty_catalog_groups,
1012        &mut snippets,
1013        |g| {
1014            sarif_empty_catalog_group_fields(
1015                g,
1016                root,
1017                severity_to_sarif_level(rules.empty_catalog_groups),
1018            )
1019        },
1020    );
1021    push_sarif_results(
1022        &mut sarif_results,
1023        &results.unresolved_catalog_references,
1024        &mut snippets,
1025        |f| {
1026            sarif_unresolved_catalog_reference_fields(
1027                f,
1028                root,
1029                severity_to_sarif_level(rules.unresolved_catalog_references),
1030            )
1031        },
1032    );
1033    push_sarif_results(
1034        &mut sarif_results,
1035        &results.unused_dependency_overrides,
1036        &mut snippets,
1037        |f| {
1038            sarif_unused_dependency_override_fields(
1039                f,
1040                root,
1041                severity_to_sarif_level(rules.unused_dependency_overrides),
1042            )
1043        },
1044    );
1045    push_sarif_results(
1046        &mut sarif_results,
1047        &results.misconfigured_dependency_overrides,
1048        &mut snippets,
1049        |f| {
1050            sarif_misconfigured_dependency_override_fields(
1051                f,
1052                root,
1053                severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1054            )
1055        },
1056    );
1057
1058    serde_json::json!({
1059        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1060        "version": "2.1.0",
1061        "runs": [{
1062            "tool": {
1063                "driver": {
1064                    "name": "fallow",
1065                    "version": env!("CARGO_PKG_VERSION"),
1066                    "informationUri": "https://github.com/fallow-rs/fallow",
1067                    "rules": build_sarif_rules(rules)
1068                }
1069            },
1070            "results": sarif_results
1071        }]
1072    })
1073}
1074
1075pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
1076    let sarif = build_sarif(results, root, rules);
1077    emit_json(&sarif, "SARIF")
1078}
1079
1080/// Print SARIF output with owner properties added to each result.
1081///
1082/// Calls `build_sarif` to produce the standard SARIF JSON, then post-processes
1083/// each result to add `"properties": { "owner": "@team" }` by resolving the
1084/// artifact location URI through the `OwnershipResolver`.
1085pub(super) fn print_grouped_sarif(
1086    results: &AnalysisResults,
1087    root: &Path,
1088    rules: &RulesConfig,
1089    resolver: &OwnershipResolver,
1090) -> ExitCode {
1091    let mut sarif = build_sarif(results, root, rules);
1092
1093    // Post-process each result to inject the owner property.
1094    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1095        for run in runs {
1096            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1097                for result in results {
1098                    let uri = result
1099                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1100                        .and_then(|v| v.as_str())
1101                        .unwrap_or("");
1102                    // Decode percent-encoded brackets before ownership lookup
1103                    // (SARIF URIs encode `[`/`]` as `%5B`/`%5D`)
1104                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1105                    let owner =
1106                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1107                    let props = result
1108                        .as_object_mut()
1109                        .expect("SARIF result should be an object")
1110                        .entry("properties")
1111                        .or_insert_with(|| serde_json::json!({}));
1112                    props
1113                        .as_object_mut()
1114                        .expect("properties should be an object")
1115                        .insert("owner".to_string(), serde_json::Value::String(owner));
1116                }
1117            }
1118        }
1119    }
1120
1121    emit_json(&sarif, "SARIF")
1122}
1123
1124#[expect(
1125    clippy::cast_possible_truncation,
1126    reason = "line/col numbers are bounded by source size"
1127)]
1128pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
1129    let mut sarif_results = Vec::new();
1130    let mut snippets = SourceSnippetCache::default();
1131
1132    for (i, group) in report.clone_groups.iter().enumerate() {
1133        for instance in &group.instances {
1134            let uri = relative_uri(&instance.file, root);
1135            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1136            sarif_results.push(sarif_result_with_snippet(
1137                "fallow/code-duplication",
1138                "warning",
1139                &format!(
1140                    "Code clone group {} ({} lines, {} instances)",
1141                    i + 1,
1142                    group.line_count,
1143                    group.instances.len()
1144                ),
1145                &uri,
1146                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1147                source_snippet.as_deref(),
1148            ));
1149        }
1150    }
1151
1152    let sarif = serde_json::json!({
1153        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1154        "version": "2.1.0",
1155        "runs": [{
1156            "tool": {
1157                "driver": {
1158                    "name": "fallow",
1159                    "version": env!("CARGO_PKG_VERSION"),
1160                    "informationUri": "https://github.com/fallow-rs/fallow",
1161                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1162                }
1163            },
1164            "results": sarif_results
1165        }]
1166    });
1167
1168    emit_json(&sarif, "SARIF")
1169}
1170
1171/// Print SARIF duplication output with a `properties.group` tag on every
1172/// result.
1173///
1174/// Each clone group is attributed to its largest owner (most instances; ties
1175/// broken alphabetically) via [`super::dupes_grouping::largest_owner`], and
1176/// every result emitted for that group's instances carries the same
1177/// `properties.group` value. This mirrors the health SARIF convention
1178/// (`print_grouped_health_sarif`) so consumers (GitHub Code Scanning, GitLab
1179/// Code Quality) can partition findings per team / package / directory
1180/// without re-resolving ownership.
1181#[expect(
1182    clippy::cast_possible_truncation,
1183    reason = "line/col numbers are bounded by source size"
1184)]
1185pub(super) fn print_grouped_duplication_sarif(
1186    report: &DuplicationReport,
1187    root: &Path,
1188    resolver: &OwnershipResolver,
1189) -> ExitCode {
1190    let mut sarif_results = Vec::new();
1191    let mut snippets = SourceSnippetCache::default();
1192
1193    for (i, group) in report.clone_groups.iter().enumerate() {
1194        // Compute the group's primary owner once. Every result emitted for
1195        // this group carries the same `properties.group` value (the GROUP'S
1196        // owner, not the per-instance owner).
1197        let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
1198        for instance in &group.instances {
1199            let uri = relative_uri(&instance.file, root);
1200            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1201            let mut result = sarif_result_with_snippet(
1202                "fallow/code-duplication",
1203                "warning",
1204                &format!(
1205                    "Code clone group {} ({} lines, {} instances)",
1206                    i + 1,
1207                    group.line_count,
1208                    group.instances.len()
1209                ),
1210                &uri,
1211                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1212                source_snippet.as_deref(),
1213            );
1214            let props = result
1215                .as_object_mut()
1216                .expect("SARIF result should be an object")
1217                .entry("properties")
1218                .or_insert_with(|| serde_json::json!({}));
1219            props
1220                .as_object_mut()
1221                .expect("properties should be an object")
1222                .insert(
1223                    "group".to_string(),
1224                    serde_json::Value::String(primary_owner.clone()),
1225                );
1226            sarif_results.push(result);
1227        }
1228    }
1229
1230    let sarif = serde_json::json!({
1231        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1232        "version": "2.1.0",
1233        "runs": [{
1234            "tool": {
1235                "driver": {
1236                    "name": "fallow",
1237                    "version": env!("CARGO_PKG_VERSION"),
1238                    "informationUri": "https://github.com/fallow-rs/fallow",
1239                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1240                }
1241            },
1242            "results": sarif_results
1243        }]
1244    });
1245
1246    emit_json(&sarif, "SARIF")
1247}
1248
1249// ── Health SARIF output ────────────────────────────────────────────
1250// Note: file_scores are intentionally omitted from SARIF output.
1251// SARIF is designed for diagnostic results (issues/findings), not metric tables.
1252// File health scores are available in JSON, human, compact, and markdown formats.
1253
1254#[must_use]
1255#[expect(
1256    clippy::too_many_lines,
1257    reason = "flat rules + results table: adding runtime-coverage rules pushed past the 150 line threshold but each section is a straightforward sequence of sarif_rule / sarif_result calls"
1258)]
1259pub fn build_health_sarif(
1260    report: &crate::health_types::HealthReport,
1261    root: &Path,
1262) -> serde_json::Value {
1263    use crate::health_types::ExceededThreshold;
1264
1265    let mut sarif_results = Vec::new();
1266    let mut snippets = SourceSnippetCache::default();
1267
1268    for finding in &report.findings {
1269        let uri = relative_uri(&finding.path, root);
1270        // When CRAP contributes alongside complexity, use the CRAP rule as the
1271        // most actionable identifier (CRAP combines complexity and coverage)
1272        // and surface all exceeded dimensions in the message.
1273        let (rule_id, message) = match finding.exceeded {
1274            ExceededThreshold::Cyclomatic => (
1275                "fallow/high-cyclomatic-complexity",
1276                format!(
1277                    "'{}' has cyclomatic complexity {} (threshold: {})",
1278                    finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
1279                ),
1280            ),
1281            ExceededThreshold::Cognitive => (
1282                "fallow/high-cognitive-complexity",
1283                format!(
1284                    "'{}' has cognitive complexity {} (threshold: {})",
1285                    finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
1286                ),
1287            ),
1288            ExceededThreshold::Both => (
1289                "fallow/high-complexity",
1290                format!(
1291                    "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
1292                    finding.name,
1293                    finding.cyclomatic,
1294                    report.summary.max_cyclomatic_threshold,
1295                    finding.cognitive,
1296                    report.summary.max_cognitive_threshold,
1297                ),
1298            ),
1299            ExceededThreshold::Crap
1300            | ExceededThreshold::CyclomaticCrap
1301            | ExceededThreshold::CognitiveCrap
1302            | ExceededThreshold::All => {
1303                let crap = finding.crap.unwrap_or(0.0);
1304                let coverage = finding
1305                    .coverage_pct
1306                    .map(|pct| format!(", coverage {pct:.0}%"))
1307                    .unwrap_or_default();
1308                (
1309                    "fallow/high-crap-score",
1310                    format!(
1311                        "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
1312                        finding.name,
1313                        crap,
1314                        report.summary.max_crap_threshold,
1315                        finding.cyclomatic,
1316                        coverage,
1317                    ),
1318                )
1319            }
1320        };
1321
1322        let level = match finding.severity {
1323            crate::health_types::FindingSeverity::Critical => "error",
1324            crate::health_types::FindingSeverity::High => "warning",
1325            crate::health_types::FindingSeverity::Moderate => "note",
1326        };
1327        let source_snippet = snippets.line(&finding.path, finding.line);
1328        sarif_results.push(sarif_result_with_snippet(
1329            rule_id,
1330            level,
1331            &message,
1332            &uri,
1333            Some((finding.line, finding.col + 1)),
1334            source_snippet.as_deref(),
1335        ));
1336    }
1337
1338    if let Some(ref production) = report.runtime_coverage {
1339        append_runtime_coverage_sarif_results(&mut sarif_results, production, root, &mut snippets);
1340    }
1341    if let Some(ref intelligence) = report.coverage_intelligence {
1342        append_coverage_intelligence_sarif_results(
1343            &mut sarif_results,
1344            intelligence,
1345            root,
1346            &mut snippets,
1347        );
1348    }
1349
1350    // Refactoring targets as SARIF results (warning level — advisory recommendations)
1351    for target in &report.targets {
1352        let uri = relative_uri(&target.path, root);
1353        let message = format!(
1354            "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
1355            target.category.label(),
1356            target.recommendation,
1357            target.priority,
1358            target.efficiency,
1359            target.effort.label(),
1360            target.confidence.label(),
1361        );
1362        sarif_results.push(sarif_result(
1363            "fallow/refactoring-target",
1364            "warning",
1365            &message,
1366            &uri,
1367            None,
1368        ));
1369    }
1370
1371    if let Some(ref gaps) = report.coverage_gaps {
1372        for item in &gaps.files {
1373            let uri = relative_uri(&item.file.path, root);
1374            let message = format!(
1375                "File is runtime-reachable but has no test dependency path ({} value export{})",
1376                item.file.value_export_count,
1377                if item.file.value_export_count == 1 {
1378                    ""
1379                } else {
1380                    "s"
1381                },
1382            );
1383            sarif_results.push(sarif_result(
1384                "fallow/untested-file",
1385                "warning",
1386                &message,
1387                &uri,
1388                None,
1389            ));
1390        }
1391
1392        for item in &gaps.exports {
1393            let uri = relative_uri(&item.export.path, root);
1394            let message = format!(
1395                "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1396                item.export.export_name
1397            );
1398            let source_snippet = snippets.line(&item.export.path, item.export.line);
1399            sarif_results.push(sarif_result_with_snippet(
1400                "fallow/untested-export",
1401                "warning",
1402                &message,
1403                &uri,
1404                Some((item.export.line, item.export.col + 1)),
1405                source_snippet.as_deref(),
1406            ));
1407        }
1408    }
1409
1410    let health_rules = vec![
1411        sarif_rule(
1412            "fallow/high-cyclomatic-complexity",
1413            "Function has high cyclomatic complexity",
1414            "note",
1415        ),
1416        sarif_rule(
1417            "fallow/high-cognitive-complexity",
1418            "Function has high cognitive complexity",
1419            "note",
1420        ),
1421        sarif_rule(
1422            "fallow/high-complexity",
1423            "Function exceeds both complexity thresholds",
1424            "note",
1425        ),
1426        sarif_rule(
1427            "fallow/high-crap-score",
1428            "Function has a high CRAP score (high complexity combined with low coverage)",
1429            "warning",
1430        ),
1431        sarif_rule(
1432            "fallow/refactoring-target",
1433            "File identified as a high-priority refactoring candidate",
1434            "warning",
1435        ),
1436        sarif_rule(
1437            "fallow/untested-file",
1438            "Runtime-reachable file has no test dependency path",
1439            "warning",
1440        ),
1441        sarif_rule(
1442            "fallow/untested-export",
1443            "Runtime-reachable export has no test dependency path",
1444            "warning",
1445        ),
1446        sarif_rule(
1447            "fallow/runtime-safe-to-delete",
1448            "Function is statically unused and was never invoked in production",
1449            "warning",
1450        ),
1451        sarif_rule(
1452            "fallow/runtime-review-required",
1453            "Function is statically used but was never invoked in production",
1454            "warning",
1455        ),
1456        sarif_rule(
1457            "fallow/runtime-low-traffic",
1458            "Function was invoked below the low-traffic threshold relative to total trace count",
1459            "note",
1460        ),
1461        sarif_rule(
1462            "fallow/runtime-coverage-unavailable",
1463            "Runtime coverage could not be resolved for this function",
1464            "note",
1465        ),
1466        sarif_rule(
1467            "fallow/runtime-coverage",
1468            "Runtime coverage finding",
1469            "note",
1470        ),
1471        sarif_rule(
1472            "fallow/coverage-intelligence-risky-change",
1473            "Changed hot path combines high CRAP and low test coverage",
1474            "warning",
1475        ),
1476        sarif_rule(
1477            "fallow/coverage-intelligence-delete",
1478            "Static and runtime evidence indicate code can be deleted",
1479            "warning",
1480        ),
1481        sarif_rule(
1482            "fallow/coverage-intelligence-review",
1483            "Cold reachable uncovered code needs owner review",
1484            "warning",
1485        ),
1486        sarif_rule(
1487            "fallow/coverage-intelligence-refactor",
1488            "Hot covered code has high CRAP and should be refactored carefully",
1489            "warning",
1490        ),
1491    ];
1492
1493    serde_json::json!({
1494        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1495        "version": "2.1.0",
1496        "runs": [{
1497            "tool": {
1498                "driver": {
1499                    "name": "fallow",
1500                    "version": env!("CARGO_PKG_VERSION"),
1501                    "informationUri": "https://github.com/fallow-rs/fallow",
1502                    "rules": health_rules
1503                }
1504            },
1505            "results": sarif_results
1506        }]
1507    })
1508}
1509
1510// Note: `production.hot_paths`, `production.signals`, and per-hot-path
1511// `end_line` are intentionally omitted from SARIF output. SARIF is
1512// designed for diagnostic results (issues a reviewer should act on),
1513// not for state observations. `hot-path-touched` is informational
1514// (PR-context heads-up that a touched function is on the hot path),
1515// not a finding to fix; surfacing it as a SARIF result would clutter
1516// Code Scanning's UI with non-actionable entries. JSON consumers that
1517// want the full picture read `runtime_coverage.signals[]` and
1518// `runtime_coverage.hot_paths[]` directly.
1519fn append_runtime_coverage_sarif_results(
1520    sarif_results: &mut Vec<serde_json::Value>,
1521    production: &crate::health_types::RuntimeCoverageReport,
1522    root: &Path,
1523    snippets: &mut SourceSnippetCache,
1524) {
1525    for finding in &production.findings {
1526        let uri = relative_uri(&finding.path, root);
1527        let rule_id = match finding.verdict {
1528            crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1529                "fallow/runtime-safe-to-delete"
1530            }
1531            crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1532                "fallow/runtime-review-required"
1533            }
1534            crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
1535            crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1536                "fallow/runtime-coverage-unavailable"
1537            }
1538            crate::health_types::RuntimeCoverageVerdict::Active
1539            | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1540        };
1541        let level = match finding.verdict {
1542            crate::health_types::RuntimeCoverageVerdict::SafeToDelete
1543            | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
1544            _ => "note",
1545        };
1546        let invocations_hint = finding.invocations.map_or_else(
1547            || "untracked".to_owned(),
1548            |hits| format!("{hits} invocations"),
1549        );
1550        let message = format!(
1551            "'{}' runtime coverage verdict: {} ({})",
1552            finding.function,
1553            finding.verdict.human_label(),
1554            invocations_hint,
1555        );
1556        let source_snippet = snippets.line(&finding.path, finding.line);
1557        sarif_results.push(sarif_result_with_snippet(
1558            rule_id,
1559            level,
1560            &message,
1561            &uri,
1562            Some((finding.line, 1)),
1563            source_snippet.as_deref(),
1564        ));
1565    }
1566}
1567
1568// Summary-only coverage-intelligence state is intentionally omitted from SARIF.
1569// SARIF should contain actionable diagnostic results, while JSON carries the
1570// full verdict, skipped-match counts, and evidence matrix for agents.
1571fn append_coverage_intelligence_sarif_results(
1572    sarif_results: &mut Vec<serde_json::Value>,
1573    intelligence: &crate::health_types::CoverageIntelligenceReport,
1574    root: &Path,
1575    snippets: &mut SourceSnippetCache,
1576) {
1577    for finding in &intelligence.findings {
1578        let rule_id = coverage_intelligence_rule_id(finding.recommendation);
1579        let level = match finding.verdict {
1580            crate::health_types::CoverageIntelligenceVerdict::Clean
1581            | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
1582            _ => "warning",
1583        };
1584        let uri = relative_uri(&finding.path, root);
1585        let identity = finding.identity.as_deref().unwrap_or("code");
1586        let signals = finding
1587            .signals
1588            .iter()
1589            .map(ToString::to_string)
1590            .collect::<Vec<_>>()
1591            .join(", ");
1592        let message = format!(
1593            "'{}' coverage intelligence verdict: {} ({}, signals: {})",
1594            identity, finding.verdict, finding.recommendation, signals,
1595        );
1596        let source_snippet = snippets.line(&finding.path, finding.line);
1597        let mut result = sarif_result_with_snippet(
1598            rule_id,
1599            level,
1600            &message,
1601            &uri,
1602            Some((finding.line, 1)),
1603            source_snippet.as_deref(),
1604        );
1605        result["properties"] = serde_json::json!({
1606            "coverage_intelligence_id": &finding.id,
1607            "verdict": finding.verdict,
1608            "recommendation": finding.recommendation,
1609            "confidence": finding.confidence,
1610            "signals": &finding.signals,
1611            "related_ids": &finding.related_ids,
1612        });
1613        sarif_results.push(result);
1614    }
1615}
1616
1617fn coverage_intelligence_rule_id(
1618    recommendation: crate::health_types::CoverageIntelligenceRecommendation,
1619) -> &'static str {
1620    match recommendation {
1621        crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
1622            "fallow/coverage-intelligence-risky-change"
1623        }
1624        crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
1625            "fallow/coverage-intelligence-delete"
1626        }
1627        crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
1628            "fallow/coverage-intelligence-review"
1629        }
1630        crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
1631            "fallow/coverage-intelligence-refactor"
1632        }
1633    }
1634}
1635
1636pub(super) fn print_health_sarif(
1637    report: &crate::health_types::HealthReport,
1638    root: &Path,
1639) -> ExitCode {
1640    let sarif = build_health_sarif(report, root);
1641    emit_json(&sarif, "SARIF")
1642}
1643
1644/// Print health SARIF with a per-result `properties.group` tag.
1645///
1646/// Mirrors the dead-code grouped SARIF pattern (`print_grouped_sarif`):
1647/// build the standard SARIF first, then post-process each result to inject
1648/// the resolver-derived group key on `properties.group`. Consumers that read
1649/// SARIF (GitHub Code Scanning, GitLab Code Quality) can then partition
1650/// findings per team / package / directory without dropping out of the
1651/// SARIF pipeline. Each finding's URI is decoded (`%5B` -> `[`, `%5D` -> `]`)
1652/// before resolution, matching the dead-code behaviour for paths containing
1653/// brackets like Next.js dynamic routes.
1654pub(super) fn print_grouped_health_sarif(
1655    report: &crate::health_types::HealthReport,
1656    root: &Path,
1657    resolver: &OwnershipResolver,
1658) -> ExitCode {
1659    let mut sarif = build_health_sarif(report, root);
1660
1661    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1662        for run in runs {
1663            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1664                for result in results {
1665                    let uri = result
1666                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1667                        .and_then(|v| v.as_str())
1668                        .unwrap_or("");
1669                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1670                    let group =
1671                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1672                    let props = result
1673                        .as_object_mut()
1674                        .expect("SARIF result should be an object")
1675                        .entry("properties")
1676                        .or_insert_with(|| serde_json::json!({}));
1677                    props
1678                        .as_object_mut()
1679                        .expect("properties should be an object")
1680                        .insert("group".to_string(), serde_json::Value::String(group));
1681                }
1682            }
1683        }
1684    }
1685
1686    emit_json(&sarif, "SARIF")
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691    use super::*;
1692    use crate::report::test_helpers::sample_results;
1693    use fallow_core::results::*;
1694    use std::path::PathBuf;
1695
1696    #[test]
1697    fn sarif_has_required_top_level_fields() {
1698        let root = PathBuf::from("/project");
1699        let results = AnalysisResults::default();
1700        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1701
1702        assert_eq!(
1703            sarif["$schema"],
1704            "https://json.schemastore.org/sarif-2.1.0.json"
1705        );
1706        assert_eq!(sarif["version"], "2.1.0");
1707        assert!(sarif["runs"].is_array());
1708    }
1709
1710    #[test]
1711    fn sarif_has_tool_driver_info() {
1712        let root = PathBuf::from("/project");
1713        let results = AnalysisResults::default();
1714        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1715
1716        let driver = &sarif["runs"][0]["tool"]["driver"];
1717        assert_eq!(driver["name"], "fallow");
1718        assert!(driver["version"].is_string());
1719        assert_eq!(
1720            driver["informationUri"],
1721            "https://github.com/fallow-rs/fallow"
1722        );
1723    }
1724
1725    #[test]
1726    fn sarif_declares_all_rules() {
1727        let root = PathBuf::from("/project");
1728        let results = AnalysisResults::default();
1729        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1730
1731        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1732            .as_array()
1733            .expect("rules should be an array");
1734        assert_eq!(rules.len(), 23);
1735
1736        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1737        assert!(rule_ids.contains(&"fallow/unused-file"));
1738        assert!(rule_ids.contains(&"fallow/unused-export"));
1739        assert!(rule_ids.contains(&"fallow/unused-type"));
1740        assert!(rule_ids.contains(&"fallow/private-type-leak"));
1741        assert!(rule_ids.contains(&"fallow/unused-dependency"));
1742        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1743        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1744        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1745        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1746        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1747        assert!(rule_ids.contains(&"fallow/unused-class-member"));
1748        assert!(rule_ids.contains(&"fallow/unresolved-import"));
1749        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1750        assert!(rule_ids.contains(&"fallow/duplicate-export"));
1751        assert!(rule_ids.contains(&"fallow/circular-dependency"));
1752        assert!(rule_ids.contains(&"fallow/re-export-cycle"));
1753        assert!(rule_ids.contains(&"fallow/boundary-violation"));
1754        assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
1755        assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
1756        assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
1757        assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
1758        assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
1759    }
1760
1761    #[test]
1762    fn sarif_empty_results_no_results_entries() {
1763        let root = PathBuf::from("/project");
1764        let results = AnalysisResults::default();
1765        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1766
1767        let sarif_results = sarif["runs"][0]["results"]
1768            .as_array()
1769            .expect("results should be an array");
1770        assert!(sarif_results.is_empty());
1771    }
1772
1773    #[test]
1774    fn sarif_unused_file_result() {
1775        let root = PathBuf::from("/project");
1776        let mut results = AnalysisResults::default();
1777        results
1778            .unused_files
1779            .push(UnusedFileFinding::with_actions(UnusedFile {
1780                path: root.join("src/dead.ts"),
1781            }));
1782
1783        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1784        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1785        assert_eq!(entries.len(), 1);
1786
1787        let entry = &entries[0];
1788        assert_eq!(entry["ruleId"], "fallow/unused-file");
1789        // Default severity is "error" per RulesConfig::default()
1790        assert_eq!(entry["level"], "error");
1791        assert_eq!(
1792            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1793            "src/dead.ts"
1794        );
1795    }
1796
1797    #[test]
1798    fn sarif_unused_export_includes_region() {
1799        let root = PathBuf::from("/project");
1800        let mut results = AnalysisResults::default();
1801        results
1802            .unused_exports
1803            .push(UnusedExportFinding::with_actions(UnusedExport {
1804                path: root.join("src/utils.ts"),
1805                export_name: "helperFn".to_string(),
1806                is_type_only: false,
1807                line: 10,
1808                col: 4,
1809                span_start: 120,
1810                is_re_export: false,
1811            }));
1812
1813        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1814        let entry = &sarif["runs"][0]["results"][0];
1815        assert_eq!(entry["ruleId"], "fallow/unused-export");
1816
1817        let region = &entry["locations"][0]["physicalLocation"]["region"];
1818        assert_eq!(region["startLine"], 10);
1819        // SARIF columns are 1-based, code adds +1 to the 0-based col
1820        assert_eq!(region["startColumn"], 5);
1821    }
1822
1823    #[test]
1824    fn sarif_unresolved_import_is_error_level() {
1825        let root = PathBuf::from("/project");
1826        let mut results = AnalysisResults::default();
1827        results
1828            .unresolved_imports
1829            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1830                path: root.join("src/app.ts"),
1831                specifier: "./missing".to_string(),
1832                line: 1,
1833                col: 0,
1834                specifier_col: 0,
1835            }));
1836
1837        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1838        let entry = &sarif["runs"][0]["results"][0];
1839        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1840        assert_eq!(entry["level"], "error");
1841    }
1842
1843    #[test]
1844    fn sarif_unlisted_dependency_points_to_import_site() {
1845        let root = PathBuf::from("/project");
1846        let mut results = AnalysisResults::default();
1847        results
1848            .unlisted_dependencies
1849            .push(UnlistedDependencyFinding::with_actions(
1850                UnlistedDependency {
1851                    package_name: "chalk".to_string(),
1852                    imported_from: vec![ImportSite {
1853                        path: root.join("src/cli.ts"),
1854                        line: 3,
1855                        col: 0,
1856                    }],
1857                },
1858            ));
1859
1860        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1861        let entry = &sarif["runs"][0]["results"][0];
1862        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1863        assert_eq!(entry["level"], "error");
1864        assert_eq!(
1865            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1866            "src/cli.ts"
1867        );
1868        let region = &entry["locations"][0]["physicalLocation"]["region"];
1869        assert_eq!(region["startLine"], 3);
1870        assert_eq!(region["startColumn"], 1);
1871    }
1872
1873    #[test]
1874    fn sarif_dependency_issues_point_to_package_json() {
1875        let root = PathBuf::from("/project");
1876        let mut results = AnalysisResults::default();
1877        results
1878            .unused_dependencies
1879            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1880                package_name: "lodash".to_string(),
1881                location: DependencyLocation::Dependencies,
1882                path: root.join("package.json"),
1883                line: 5,
1884                used_in_workspaces: Vec::new(),
1885            }));
1886        results
1887            .unused_dev_dependencies
1888            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1889                package_name: "jest".to_string(),
1890                location: DependencyLocation::DevDependencies,
1891                path: root.join("package.json"),
1892                line: 5,
1893                used_in_workspaces: Vec::new(),
1894            }));
1895
1896        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1897        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1898        for entry in entries {
1899            assert_eq!(
1900                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1901                "package.json"
1902            );
1903        }
1904    }
1905
1906    #[test]
1907    fn sarif_duplicate_export_emits_one_result_per_location() {
1908        let root = PathBuf::from("/project");
1909        let mut results = AnalysisResults::default();
1910        results
1911            .duplicate_exports
1912            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1913                export_name: "Config".to_string(),
1914                locations: vec![
1915                    DuplicateLocation {
1916                        path: root.join("src/a.ts"),
1917                        line: 15,
1918                        col: 0,
1919                    },
1920                    DuplicateLocation {
1921                        path: root.join("src/b.ts"),
1922                        line: 30,
1923                        col: 0,
1924                    },
1925                ],
1926            }));
1927
1928        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1929        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1930        // One SARIF result per location, not one per DuplicateExport
1931        assert_eq!(entries.len(), 2);
1932        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1933        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1934        assert_eq!(
1935            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1936            "src/a.ts"
1937        );
1938        assert_eq!(
1939            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1940            "src/b.ts"
1941        );
1942    }
1943
1944    #[test]
1945    fn sarif_all_issue_types_produce_results() {
1946        let root = PathBuf::from("/project");
1947        let results = sample_results(&root);
1948        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1949
1950        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1951        // All issue types with one entry each; duplicate_exports has 2 locations => one extra SARIF result
1952        assert_eq!(entries.len(), results.total_issues() + 1);
1953
1954        let rule_ids: Vec<&str> = entries
1955            .iter()
1956            .map(|e| e["ruleId"].as_str().unwrap())
1957            .collect();
1958        assert!(rule_ids.contains(&"fallow/unused-file"));
1959        assert!(rule_ids.contains(&"fallow/unused-export"));
1960        assert!(rule_ids.contains(&"fallow/unused-type"));
1961        assert!(rule_ids.contains(&"fallow/unused-dependency"));
1962        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1963        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1964        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1965        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1966        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1967        assert!(rule_ids.contains(&"fallow/unused-class-member"));
1968        assert!(rule_ids.contains(&"fallow/unresolved-import"));
1969        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1970        assert!(rule_ids.contains(&"fallow/duplicate-export"));
1971    }
1972
1973    #[test]
1974    fn sarif_serializes_to_valid_json() {
1975        let root = PathBuf::from("/project");
1976        let results = sample_results(&root);
1977        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1978
1979        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1980        let reparsed: serde_json::Value =
1981            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1982        assert_eq!(reparsed, sarif);
1983    }
1984
1985    #[test]
1986    fn sarif_file_write_produces_valid_sarif() {
1987        let root = PathBuf::from("/project");
1988        let results = sample_results(&root);
1989        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1990        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1991
1992        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1993        let _ = std::fs::create_dir_all(&dir);
1994        let sarif_path = dir.join("results.sarif");
1995        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1996
1997        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1998        let parsed: serde_json::Value =
1999            serde_json::from_str(&contents).expect("file should contain valid JSON");
2000
2001        assert_eq!(parsed["version"], "2.1.0");
2002        assert_eq!(
2003            parsed["$schema"],
2004            "https://json.schemastore.org/sarif-2.1.0.json"
2005        );
2006        let sarif_results = parsed["runs"][0]["results"]
2007            .as_array()
2008            .expect("results should be an array");
2009        assert!(!sarif_results.is_empty());
2010
2011        // Clean up
2012        let _ = std::fs::remove_file(&sarif_path);
2013        let _ = std::fs::remove_dir(&dir);
2014    }
2015
2016    // ── Health SARIF ──
2017
2018    #[test]
2019    fn health_sarif_empty_no_results() {
2020        let root = PathBuf::from("/project");
2021        let report = crate::health_types::HealthReport {
2022            summary: crate::health_types::HealthSummary {
2023                files_analyzed: 10,
2024                functions_analyzed: 50,
2025                ..Default::default()
2026            },
2027            ..Default::default()
2028        };
2029        let sarif = build_health_sarif(&report, &root);
2030        assert_eq!(sarif["version"], "2.1.0");
2031        let results = sarif["runs"][0]["results"].as_array().unwrap();
2032        assert!(results.is_empty());
2033        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2034            .as_array()
2035            .unwrap();
2036        assert_eq!(rules.len(), 16);
2037    }
2038
2039    #[test]
2040    fn health_sarif_coverage_intelligence_preserves_structured_properties() {
2041        use crate::health_types::{
2042            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2043            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2044            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2045            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2046            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2047            HealthReport, HealthSummary,
2048        };
2049
2050        let root = PathBuf::from("/project");
2051        let report = HealthReport {
2052            summary: HealthSummary {
2053                files_analyzed: 10,
2054                functions_analyzed: 50,
2055                ..Default::default()
2056            },
2057            coverage_intelligence: Some(CoverageIntelligenceReport {
2058                schema_version: CoverageIntelligenceSchemaVersion::V1,
2059                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2060                summary: CoverageIntelligenceSummary {
2061                    findings: 1,
2062                    high_confidence_deletes: 1,
2063                    ..Default::default()
2064                },
2065                findings: vec![CoverageIntelligenceFinding {
2066                    id: "fallow:coverage-intel:abc123".to_owned(),
2067                    path: root.join("src/dead.ts"),
2068                    identity: Some("deadPath".to_owned()),
2069                    line: 9,
2070                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2071                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2072                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2073                    confidence: CoverageIntelligenceConfidence::High,
2074                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2075                    evidence: CoverageIntelligenceEvidence {
2076                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2077                        ..Default::default()
2078                    },
2079                    actions: vec![CoverageIntelligenceAction {
2080                        kind: "delete-after-confirming-owner".to_owned(),
2081                        description: "Confirm ownership".to_owned(),
2082                        auto_fixable: false,
2083                    }],
2084                }],
2085            }),
2086            ..Default::default()
2087        };
2088
2089        let sarif = build_health_sarif(&report, &root);
2090        let result = &sarif["runs"][0]["results"][0];
2091        assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
2092        assert_eq!(
2093            result["properties"]["coverage_intelligence_id"],
2094            "fallow:coverage-intel:abc123"
2095        );
2096        assert_eq!(
2097            result["properties"]["recommendation"],
2098            "delete-after-confirming-owner"
2099        );
2100        assert_eq!(result["properties"]["confidence"], "high");
2101        assert_eq!(result["properties"]["signals"][0], "runtime_cold");
2102        assert_eq!(
2103            result["properties"]["related_ids"][0],
2104            "fallow:prod:deadbeef"
2105        );
2106    }
2107
2108    #[test]
2109    fn health_sarif_cyclomatic_only() {
2110        let root = PathBuf::from("/project");
2111        let report = crate::health_types::HealthReport {
2112            findings: vec![
2113                crate::health_types::ComplexityViolation {
2114                    path: root.join("src/utils.ts"),
2115                    name: "parseExpression".to_string(),
2116                    line: 42,
2117                    col: 0,
2118                    cyclomatic: 25,
2119                    cognitive: 10,
2120                    line_count: 80,
2121                    param_count: 0,
2122                    exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
2123                    severity: crate::health_types::FindingSeverity::High,
2124                    crap: None,
2125                    coverage_pct: None,
2126                    coverage_tier: None,
2127                    coverage_source: None,
2128                    inherited_from: None,
2129                    component_rollup: None,
2130                }
2131                .into(),
2132            ],
2133            summary: crate::health_types::HealthSummary {
2134                files_analyzed: 5,
2135                functions_analyzed: 20,
2136                functions_above_threshold: 1,
2137                ..Default::default()
2138            },
2139            ..Default::default()
2140        };
2141        let sarif = build_health_sarif(&report, &root);
2142        let entry = &sarif["runs"][0]["results"][0];
2143        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
2144        assert_eq!(entry["level"], "warning");
2145        assert!(
2146            entry["message"]["text"]
2147                .as_str()
2148                .unwrap()
2149                .contains("cyclomatic complexity 25")
2150        );
2151        assert_eq!(
2152            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2153            "src/utils.ts"
2154        );
2155        let region = &entry["locations"][0]["physicalLocation"]["region"];
2156        assert_eq!(region["startLine"], 42);
2157        assert_eq!(region["startColumn"], 1);
2158    }
2159
2160    #[test]
2161    fn health_sarif_cognitive_only() {
2162        let root = PathBuf::from("/project");
2163        let report = crate::health_types::HealthReport {
2164            findings: vec![
2165                crate::health_types::ComplexityViolation {
2166                    path: root.join("src/api.ts"),
2167                    name: "handleRequest".to_string(),
2168                    line: 10,
2169                    col: 4,
2170                    cyclomatic: 8,
2171                    cognitive: 20,
2172                    line_count: 40,
2173                    param_count: 0,
2174                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
2175                    severity: crate::health_types::FindingSeverity::High,
2176                    crap: None,
2177                    coverage_pct: None,
2178                    coverage_tier: None,
2179                    coverage_source: None,
2180                    inherited_from: None,
2181                    component_rollup: None,
2182                }
2183                .into(),
2184            ],
2185            summary: crate::health_types::HealthSummary {
2186                files_analyzed: 3,
2187                functions_analyzed: 10,
2188                functions_above_threshold: 1,
2189                ..Default::default()
2190            },
2191            ..Default::default()
2192        };
2193        let sarif = build_health_sarif(&report, &root);
2194        let entry = &sarif["runs"][0]["results"][0];
2195        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
2196        assert!(
2197            entry["message"]["text"]
2198                .as_str()
2199                .unwrap()
2200                .contains("cognitive complexity 20")
2201        );
2202        let region = &entry["locations"][0]["physicalLocation"]["region"];
2203        assert_eq!(region["startColumn"], 5); // col 4 + 1
2204    }
2205
2206    #[test]
2207    fn health_sarif_both_thresholds() {
2208        let root = PathBuf::from("/project");
2209        let report = crate::health_types::HealthReport {
2210            findings: vec![
2211                crate::health_types::ComplexityViolation {
2212                    path: root.join("src/complex.ts"),
2213                    name: "doEverything".to_string(),
2214                    line: 1,
2215                    col: 0,
2216                    cyclomatic: 30,
2217                    cognitive: 45,
2218                    line_count: 100,
2219                    param_count: 0,
2220                    exceeded: crate::health_types::ExceededThreshold::Both,
2221                    severity: crate::health_types::FindingSeverity::High,
2222                    crap: None,
2223                    coverage_pct: None,
2224                    coverage_tier: None,
2225                    coverage_source: None,
2226                    inherited_from: None,
2227                    component_rollup: None,
2228                }
2229                .into(),
2230            ],
2231            summary: crate::health_types::HealthSummary {
2232                files_analyzed: 1,
2233                functions_analyzed: 1,
2234                functions_above_threshold: 1,
2235                ..Default::default()
2236            },
2237            ..Default::default()
2238        };
2239        let sarif = build_health_sarif(&report, &root);
2240        let entry = &sarif["runs"][0]["results"][0];
2241        assert_eq!(entry["ruleId"], "fallow/high-complexity");
2242        let msg = entry["message"]["text"].as_str().unwrap();
2243        assert!(msg.contains("cyclomatic complexity 30"));
2244        assert!(msg.contains("cognitive complexity 45"));
2245    }
2246
2247    #[test]
2248    fn health_sarif_crap_only_emits_crap_rule() {
2249        // CRAP-only: cyclomatic + cognitive below their thresholds, CRAP at or
2250        // above the CRAP threshold. Rule must be `fallow/high-crap-score`.
2251        let root = PathBuf::from("/project");
2252        let report = crate::health_types::HealthReport {
2253            findings: vec![
2254                crate::health_types::ComplexityViolation {
2255                    path: root.join("src/untested.ts"),
2256                    name: "risky".to_string(),
2257                    line: 8,
2258                    col: 0,
2259                    cyclomatic: 10,
2260                    cognitive: 10,
2261                    line_count: 20,
2262                    param_count: 1,
2263                    exceeded: crate::health_types::ExceededThreshold::Crap,
2264                    severity: crate::health_types::FindingSeverity::High,
2265                    crap: Some(82.2),
2266                    coverage_pct: Some(12.0),
2267                    coverage_tier: None,
2268                    coverage_source: None,
2269                    inherited_from: None,
2270                    component_rollup: None,
2271                }
2272                .into(),
2273            ],
2274            summary: crate::health_types::HealthSummary {
2275                files_analyzed: 1,
2276                functions_analyzed: 1,
2277                functions_above_threshold: 1,
2278                ..Default::default()
2279            },
2280            ..Default::default()
2281        };
2282        let sarif = build_health_sarif(&report, &root);
2283        let entry = &sarif["runs"][0]["results"][0];
2284        assert_eq!(entry["ruleId"], "fallow/high-crap-score");
2285        let msg = entry["message"]["text"].as_str().unwrap();
2286        assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
2287        assert!(msg.contains("coverage 12%"), "msg: {msg}");
2288    }
2289
2290    #[test]
2291    fn health_sarif_cyclomatic_crap_uses_crap_rule() {
2292        // Cyclomatic + CRAP both exceeded. The CRAP-centric rule subsumes
2293        // the cyclomatic breach; only one SARIF result is emitted.
2294        let root = PathBuf::from("/project");
2295        let report = crate::health_types::HealthReport {
2296            findings: vec![
2297                crate::health_types::ComplexityViolation {
2298                    path: root.join("src/hot.ts"),
2299                    name: "branchy".to_string(),
2300                    line: 1,
2301                    col: 0,
2302                    cyclomatic: 67,
2303                    cognitive: 12,
2304                    line_count: 80,
2305                    param_count: 1,
2306                    exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2307                    severity: crate::health_types::FindingSeverity::Critical,
2308                    crap: Some(182.0),
2309                    coverage_pct: None,
2310                    coverage_tier: None,
2311                    coverage_source: None,
2312                    inherited_from: None,
2313                    component_rollup: None,
2314                }
2315                .into(),
2316            ],
2317            summary: crate::health_types::HealthSummary {
2318                files_analyzed: 1,
2319                functions_analyzed: 1,
2320                functions_above_threshold: 1,
2321                ..Default::default()
2322            },
2323            ..Default::default()
2324        };
2325        let sarif = build_health_sarif(&report, &root);
2326        let results = sarif["runs"][0]["results"].as_array().unwrap();
2327        assert_eq!(
2328            results.len(),
2329            1,
2330            "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
2331        );
2332        assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
2333        let msg = results[0]["message"]["text"].as_str().unwrap();
2334        assert!(msg.contains("CRAP score 182"), "msg: {msg}");
2335        // coverage_pct absent => no coverage suffix
2336        assert!(!msg.contains("coverage"), "msg: {msg}");
2337    }
2338
2339    // ── Severity mapping ──
2340
2341    #[test]
2342    fn severity_to_sarif_level_error() {
2343        assert_eq!(severity_to_sarif_level(Severity::Error), "error");
2344    }
2345
2346    #[test]
2347    fn severity_to_sarif_level_warn() {
2348        assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
2349    }
2350
2351    #[test]
2352    #[should_panic(expected = "internal error: entered unreachable code")]
2353    fn severity_to_sarif_level_off() {
2354        let _ = severity_to_sarif_level(Severity::Off);
2355    }
2356
2357    // ── Re-export properties ──
2358
2359    #[test]
2360    fn sarif_re_export_has_properties() {
2361        let root = PathBuf::from("/project");
2362        let mut results = AnalysisResults::default();
2363        results
2364            .unused_exports
2365            .push(UnusedExportFinding::with_actions(UnusedExport {
2366                path: root.join("src/index.ts"),
2367                export_name: "reExported".to_string(),
2368                is_type_only: false,
2369                line: 1,
2370                col: 0,
2371                span_start: 0,
2372                is_re_export: true,
2373            }));
2374
2375        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2376        let entry = &sarif["runs"][0]["results"][0];
2377        assert_eq!(entry["properties"]["is_re_export"], true);
2378        let msg = entry["message"]["text"].as_str().unwrap();
2379        assert!(msg.starts_with("Re-export"));
2380    }
2381
2382    #[test]
2383    fn sarif_non_re_export_has_no_properties() {
2384        let root = PathBuf::from("/project");
2385        let mut results = AnalysisResults::default();
2386        results
2387            .unused_exports
2388            .push(UnusedExportFinding::with_actions(UnusedExport {
2389                path: root.join("src/utils.ts"),
2390                export_name: "foo".to_string(),
2391                is_type_only: false,
2392                line: 5,
2393                col: 0,
2394                span_start: 0,
2395                is_re_export: false,
2396            }));
2397
2398        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2399        let entry = &sarif["runs"][0]["results"][0];
2400        assert!(entry.get("properties").is_none());
2401        let msg = entry["message"]["text"].as_str().unwrap();
2402        assert!(msg.starts_with("Export"));
2403    }
2404
2405    // ── Type re-export ──
2406
2407    #[test]
2408    fn sarif_type_re_export_message() {
2409        let root = PathBuf::from("/project");
2410        let mut results = AnalysisResults::default();
2411        results
2412            .unused_types
2413            .push(UnusedTypeFinding::with_actions(UnusedExport {
2414                path: root.join("src/index.ts"),
2415                export_name: "MyType".to_string(),
2416                is_type_only: true,
2417                line: 1,
2418                col: 0,
2419                span_start: 0,
2420                is_re_export: true,
2421            }));
2422
2423        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2424        let entry = &sarif["runs"][0]["results"][0];
2425        assert_eq!(entry["ruleId"], "fallow/unused-type");
2426        let msg = entry["message"]["text"].as_str().unwrap();
2427        assert!(msg.starts_with("Type re-export"));
2428        assert_eq!(entry["properties"]["is_re_export"], true);
2429    }
2430
2431    // ── Dependency line == 0 skips region ──
2432
2433    #[test]
2434    fn sarif_dependency_line_zero_skips_region() {
2435        let root = PathBuf::from("/project");
2436        let mut results = AnalysisResults::default();
2437        results
2438            .unused_dependencies
2439            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2440                package_name: "lodash".to_string(),
2441                location: DependencyLocation::Dependencies,
2442                path: root.join("package.json"),
2443                line: 0,
2444                used_in_workspaces: Vec::new(),
2445            }));
2446
2447        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2448        let entry = &sarif["runs"][0]["results"][0];
2449        let phys = &entry["locations"][0]["physicalLocation"];
2450        assert!(phys.get("region").is_none());
2451    }
2452
2453    #[test]
2454    fn sarif_dependency_line_nonzero_has_region() {
2455        let root = PathBuf::from("/project");
2456        let mut results = AnalysisResults::default();
2457        results
2458            .unused_dependencies
2459            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2460                package_name: "lodash".to_string(),
2461                location: DependencyLocation::Dependencies,
2462                path: root.join("package.json"),
2463                line: 7,
2464                used_in_workspaces: Vec::new(),
2465            }));
2466
2467        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2468        let entry = &sarif["runs"][0]["results"][0];
2469        let region = &entry["locations"][0]["physicalLocation"]["region"];
2470        assert_eq!(region["startLine"], 7);
2471        assert_eq!(region["startColumn"], 1);
2472    }
2473
2474    // ── Type-only dependency line == 0 skips region ──
2475
2476    #[test]
2477    fn sarif_type_only_dep_line_zero_skips_region() {
2478        let root = PathBuf::from("/project");
2479        let mut results = AnalysisResults::default();
2480        results
2481            .type_only_dependencies
2482            .push(TypeOnlyDependencyFinding::with_actions(
2483                TypeOnlyDependency {
2484                    package_name: "zod".to_string(),
2485                    path: root.join("package.json"),
2486                    line: 0,
2487                },
2488            ));
2489
2490        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2491        let entry = &sarif["runs"][0]["results"][0];
2492        let phys = &entry["locations"][0]["physicalLocation"];
2493        assert!(phys.get("region").is_none());
2494    }
2495
2496    // ── Circular dependency line == 0 skips region ──
2497
2498    #[test]
2499    fn sarif_circular_dep_line_zero_skips_region() {
2500        let root = PathBuf::from("/project");
2501        let mut results = AnalysisResults::default();
2502        results
2503            .circular_dependencies
2504            .push(CircularDependencyFinding::with_actions(
2505                CircularDependency {
2506                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2507                    length: 2,
2508                    line: 0,
2509                    col: 0,
2510                    is_cross_package: false,
2511                },
2512            ));
2513
2514        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2515        let entry = &sarif["runs"][0]["results"][0];
2516        let phys = &entry["locations"][0]["physicalLocation"];
2517        assert!(phys.get("region").is_none());
2518    }
2519
2520    #[test]
2521    fn sarif_circular_dep_line_nonzero_has_region() {
2522        let root = PathBuf::from("/project");
2523        let mut results = AnalysisResults::default();
2524        results
2525            .circular_dependencies
2526            .push(CircularDependencyFinding::with_actions(
2527                CircularDependency {
2528                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2529                    length: 2,
2530                    line: 5,
2531                    col: 2,
2532                    is_cross_package: false,
2533                },
2534            ));
2535
2536        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2537        let entry = &sarif["runs"][0]["results"][0];
2538        let region = &entry["locations"][0]["physicalLocation"]["region"];
2539        assert_eq!(region["startLine"], 5);
2540        assert_eq!(region["startColumn"], 3);
2541    }
2542
2543    // ── Unused optional dependency ──
2544
2545    #[test]
2546    fn sarif_unused_optional_dependency_result() {
2547        let root = PathBuf::from("/project");
2548        let mut results = AnalysisResults::default();
2549        results
2550            .unused_optional_dependencies
2551            .push(UnusedOptionalDependencyFinding::with_actions(
2552                UnusedDependency {
2553                    package_name: "fsevents".to_string(),
2554                    location: DependencyLocation::OptionalDependencies,
2555                    path: root.join("package.json"),
2556                    line: 12,
2557                    used_in_workspaces: Vec::new(),
2558                },
2559            ));
2560
2561        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2562        let entry = &sarif["runs"][0]["results"][0];
2563        assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
2564        let msg = entry["message"]["text"].as_str().unwrap();
2565        assert!(msg.contains("optionalDependencies"));
2566    }
2567
2568    // ── Enum and class member SARIF messages ──
2569
2570    #[test]
2571    fn sarif_enum_member_message_format() {
2572        let root = PathBuf::from("/project");
2573        let mut results = AnalysisResults::default();
2574        results.unused_enum_members.push(
2575            fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
2576                path: root.join("src/enums.ts"),
2577                parent_name: "Color".to_string(),
2578                member_name: "Purple".to_string(),
2579                kind: fallow_core::extract::MemberKind::EnumMember,
2580                line: 5,
2581                col: 2,
2582            }),
2583        );
2584
2585        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2586        let entry = &sarif["runs"][0]["results"][0];
2587        assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
2588        let msg = entry["message"]["text"].as_str().unwrap();
2589        assert!(msg.contains("Enum member 'Color.Purple'"));
2590        let region = &entry["locations"][0]["physicalLocation"]["region"];
2591        assert_eq!(region["startColumn"], 3); // col 2 + 1
2592    }
2593
2594    #[test]
2595    fn sarif_class_member_message_format() {
2596        let root = PathBuf::from("/project");
2597        let mut results = AnalysisResults::default();
2598        results.unused_class_members.push(
2599            fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
2600                path: root.join("src/service.ts"),
2601                parent_name: "API".to_string(),
2602                member_name: "fetch".to_string(),
2603                kind: fallow_core::extract::MemberKind::ClassMethod,
2604                line: 10,
2605                col: 4,
2606            }),
2607        );
2608
2609        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2610        let entry = &sarif["runs"][0]["results"][0];
2611        assert_eq!(entry["ruleId"], "fallow/unused-class-member");
2612        let msg = entry["message"]["text"].as_str().unwrap();
2613        assert!(msg.contains("Class member 'API.fetch'"));
2614    }
2615
2616    // ── Duplication SARIF ──
2617
2618    #[test]
2619    #[expect(
2620        clippy::cast_possible_truncation,
2621        reason = "test line/col values are trivially small"
2622    )]
2623    fn duplication_sarif_structure() {
2624        use fallow_core::duplicates::*;
2625
2626        let root = PathBuf::from("/project");
2627        let report = DuplicationReport {
2628            clone_groups: vec![CloneGroup {
2629                instances: vec![
2630                    CloneInstance {
2631                        file: root.join("src/a.ts"),
2632                        start_line: 1,
2633                        end_line: 10,
2634                        start_col: 0,
2635                        end_col: 0,
2636                        fragment: String::new(),
2637                    },
2638                    CloneInstance {
2639                        file: root.join("src/b.ts"),
2640                        start_line: 5,
2641                        end_line: 14,
2642                        start_col: 2,
2643                        end_col: 0,
2644                        fragment: String::new(),
2645                    },
2646                ],
2647                token_count: 50,
2648                line_count: 10,
2649            }],
2650            clone_families: vec![],
2651            mirrored_directories: vec![],
2652            stats: DuplicationStats::default(),
2653        };
2654
2655        let sarif = serde_json::json!({
2656            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2657            "version": "2.1.0",
2658            "runs": [{
2659                "tool": {
2660                    "driver": {
2661                        "name": "fallow",
2662                        "version": env!("CARGO_PKG_VERSION"),
2663                        "informationUri": "https://github.com/fallow-rs/fallow",
2664                        "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2665                    }
2666                },
2667                "results": []
2668            }]
2669        });
2670        // Just verify the function doesn't panic and produces expected structure
2671        let _ = sarif;
2672
2673        // Test the actual build path through print_duplication_sarif internals
2674        let mut sarif_results = Vec::new();
2675        for (i, group) in report.clone_groups.iter().enumerate() {
2676            for instance in &group.instances {
2677                sarif_results.push(sarif_result(
2678                    "fallow/code-duplication",
2679                    "warning",
2680                    &format!(
2681                        "Code clone group {} ({} lines, {} instances)",
2682                        i + 1,
2683                        group.line_count,
2684                        group.instances.len()
2685                    ),
2686                    &super::super::relative_uri(&instance.file, &root),
2687                    Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2688                ));
2689            }
2690        }
2691        assert_eq!(sarif_results.len(), 2);
2692        assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
2693        assert!(
2694            sarif_results[0]["message"]["text"]
2695                .as_str()
2696                .unwrap()
2697                .contains("10 lines")
2698        );
2699        let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
2700        assert_eq!(region0["startLine"], 1);
2701        assert_eq!(region0["startColumn"], 1); // start_col 0 + 1
2702        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
2703        assert_eq!(region1["startLine"], 5);
2704        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
2705    }
2706
2707    // ── sarif_rule fallback (unknown rule ID) ──
2708
2709    #[test]
2710    fn sarif_rule_known_id_has_full_description() {
2711        let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
2712        assert!(rule.get("fullDescription").is_some());
2713        assert!(rule.get("helpUri").is_some());
2714    }
2715
2716    #[test]
2717    fn sarif_rule_unknown_id_uses_fallback() {
2718        let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
2719        assert_eq!(rule["shortDescription"]["text"], "fallback text");
2720        assert!(rule.get("fullDescription").is_none());
2721        assert!(rule.get("helpUri").is_none());
2722        assert_eq!(rule["defaultConfiguration"]["level"], "warning");
2723    }
2724
2725    // ── sarif_result without region ──
2726
2727    #[test]
2728    fn sarif_result_no_region_omits_region_key() {
2729        let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
2730        let phys = &result["locations"][0]["physicalLocation"];
2731        assert!(phys.get("region").is_none());
2732        assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
2733    }
2734
2735    #[test]
2736    fn sarif_result_with_region_includes_region() {
2737        let result = sarif_result(
2738            "rule/test",
2739            "error",
2740            "test msg",
2741            "src/file.ts",
2742            Some((10, 5)),
2743        );
2744        let region = &result["locations"][0]["physicalLocation"]["region"];
2745        assert_eq!(region["startLine"], 10);
2746        assert_eq!(region["startColumn"], 5);
2747    }
2748
2749    #[test]
2750    fn sarif_partial_fingerprint_ignores_rendered_message() {
2751        let a = sarif_result(
2752            "rule/test",
2753            "error",
2754            "first message",
2755            "src/file.ts",
2756            Some((10, 5)),
2757        );
2758        let b = sarif_result(
2759            "rule/test",
2760            "error",
2761            "rewritten message",
2762            "src/file.ts",
2763            Some((10, 5)),
2764        );
2765        assert_eq!(
2766            a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2767            b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2768        );
2769    }
2770
2771    // ── Health SARIF refactoring targets ──
2772
2773    #[test]
2774    fn health_sarif_includes_refactoring_targets() {
2775        use crate::health_types::*;
2776
2777        let root = PathBuf::from("/project");
2778        let report = HealthReport {
2779            summary: HealthSummary {
2780                files_analyzed: 10,
2781                functions_analyzed: 50,
2782                ..Default::default()
2783            },
2784            targets: vec![
2785                RefactoringTarget {
2786                    path: root.join("src/complex.ts"),
2787                    priority: 85.0,
2788                    efficiency: 42.5,
2789                    recommendation: "Split high-impact file".into(),
2790                    category: RecommendationCategory::SplitHighImpact,
2791                    effort: EffortEstimate::Medium,
2792                    confidence: Confidence::High,
2793                    factors: vec![],
2794                    evidence: None,
2795                }
2796                .into(),
2797            ],
2798            ..Default::default()
2799        };
2800
2801        let sarif = build_health_sarif(&report, &root);
2802        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2803        assert_eq!(entries.len(), 1);
2804        assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
2805        assert_eq!(entries[0]["level"], "warning");
2806        let msg = entries[0]["message"]["text"].as_str().unwrap();
2807        assert!(msg.contains("high impact"));
2808        assert!(msg.contains("Split high-impact file"));
2809        assert!(msg.contains("42.5"));
2810    }
2811
2812    #[test]
2813    fn health_sarif_includes_coverage_gaps() {
2814        use crate::health_types::*;
2815
2816        let root = PathBuf::from("/project");
2817        let report = HealthReport {
2818            summary: HealthSummary {
2819                files_analyzed: 10,
2820                functions_analyzed: 50,
2821                ..Default::default()
2822            },
2823            coverage_gaps: Some(CoverageGaps {
2824                summary: CoverageGapSummary {
2825                    runtime_files: 2,
2826                    covered_files: 0,
2827                    file_coverage_pct: 0.0,
2828                    untested_files: 1,
2829                    untested_exports: 1,
2830                },
2831                files: vec![UntestedFileFinding::with_actions(
2832                    UntestedFile {
2833                        path: root.join("src/app.ts"),
2834                        value_export_count: 2,
2835                    },
2836                    &root,
2837                )],
2838                exports: vec![UntestedExportFinding::with_actions(
2839                    UntestedExport {
2840                        path: root.join("src/app.ts"),
2841                        export_name: "loader".into(),
2842                        line: 12,
2843                        col: 4,
2844                    },
2845                    &root,
2846                )],
2847            }),
2848            ..Default::default()
2849        };
2850
2851        let sarif = build_health_sarif(&report, &root);
2852        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2853        assert_eq!(entries.len(), 2);
2854        assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
2855        assert_eq!(
2856            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2857            "src/app.ts"
2858        );
2859        assert!(
2860            entries[0]["message"]["text"]
2861                .as_str()
2862                .unwrap()
2863                .contains("2 value exports")
2864        );
2865        assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
2866        assert_eq!(
2867            entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
2868            12
2869        );
2870        assert_eq!(
2871            entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
2872            5
2873        );
2874    }
2875
2876    // ── Health SARIF rules include fullDescription from explain module ──
2877
2878    #[test]
2879    fn health_sarif_rules_have_full_descriptions() {
2880        let root = PathBuf::from("/project");
2881        let report = crate::health_types::HealthReport::default();
2882        let sarif = build_health_sarif(&report, &root);
2883        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2884            .as_array()
2885            .unwrap();
2886        for rule in rules {
2887            let id = rule["id"].as_str().unwrap();
2888            assert!(
2889                rule.get("fullDescription").is_some(),
2890                "health rule {id} should have fullDescription"
2891            );
2892            assert!(
2893                rule.get("helpUri").is_some(),
2894                "health rule {id} should have helpUri"
2895            );
2896        }
2897    }
2898
2899    // ── Warn severity propagates correctly ──
2900
2901    #[test]
2902    fn sarif_warn_severity_produces_warning_level() {
2903        let root = PathBuf::from("/project");
2904        let mut results = AnalysisResults::default();
2905        results
2906            .unused_files
2907            .push(UnusedFileFinding::with_actions(UnusedFile {
2908                path: root.join("src/dead.ts"),
2909            }));
2910
2911        let rules = RulesConfig {
2912            unused_files: Severity::Warn,
2913            ..RulesConfig::default()
2914        };
2915
2916        let sarif = build_sarif(&results, &root, &rules);
2917        let entry = &sarif["runs"][0]["results"][0];
2918        assert_eq!(entry["level"], "warning");
2919    }
2920
2921    // ── Unused file has no region ──
2922
2923    #[test]
2924    fn sarif_unused_file_has_no_region() {
2925        let root = PathBuf::from("/project");
2926        let mut results = AnalysisResults::default();
2927        results
2928            .unused_files
2929            .push(UnusedFileFinding::with_actions(UnusedFile {
2930                path: root.join("src/dead.ts"),
2931            }));
2932
2933        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2934        let entry = &sarif["runs"][0]["results"][0];
2935        let phys = &entry["locations"][0]["physicalLocation"];
2936        assert!(phys.get("region").is_none());
2937    }
2938
2939    // ── Multiple unlisted deps with multiple import sites ──
2940
2941    #[test]
2942    fn sarif_unlisted_dep_multiple_import_sites() {
2943        let root = PathBuf::from("/project");
2944        let mut results = AnalysisResults::default();
2945        results
2946            .unlisted_dependencies
2947            .push(UnlistedDependencyFinding::with_actions(
2948                UnlistedDependency {
2949                    package_name: "dotenv".to_string(),
2950                    imported_from: vec![
2951                        ImportSite {
2952                            path: root.join("src/a.ts"),
2953                            line: 1,
2954                            col: 0,
2955                        },
2956                        ImportSite {
2957                            path: root.join("src/b.ts"),
2958                            line: 5,
2959                            col: 0,
2960                        },
2961                    ],
2962                },
2963            ));
2964
2965        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2966        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2967        // One SARIF result per import site
2968        assert_eq!(entries.len(), 2);
2969        assert_eq!(
2970            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2971            "src/a.ts"
2972        );
2973        assert_eq!(
2974            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2975            "src/b.ts"
2976        );
2977    }
2978
2979    // ── Empty unlisted dep (no import sites) produces zero results ──
2980
2981    #[test]
2982    fn sarif_unlisted_dep_no_import_sites() {
2983        let root = PathBuf::from("/project");
2984        let mut results = AnalysisResults::default();
2985        results
2986            .unlisted_dependencies
2987            .push(UnlistedDependencyFinding::with_actions(
2988                UnlistedDependency {
2989                    package_name: "phantom".to_string(),
2990                    imported_from: vec![],
2991                },
2992            ));
2993
2994        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2995        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2996        // No import sites => no SARIF results for this unlisted dep
2997        assert!(entries.is_empty());
2998    }
2999}