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_boundary_violation_fields(
387    violation: &BoundaryViolation,
388    root: &Path,
389    level: &'static str,
390) -> SarifFields {
391    let from_uri = relative_uri(&violation.from_path, root);
392    let to_uri = relative_uri(&violation.to_path, root);
393    SarifFields {
394        rule_id: "fallow/boundary-violation",
395        level,
396        message: format!(
397            "Import from zone '{}' to zone '{}' is not allowed ({})",
398            violation.from_zone, violation.to_zone, to_uri,
399        ),
400        uri: from_uri,
401        region: if violation.line > 0 {
402            Some((violation.line, violation.col + 1))
403        } else {
404            None
405        },
406        source_path: (violation.line > 0).then(|| violation.from_path.clone()),
407        properties: None,
408    }
409}
410
411fn sarif_stale_suppression_fields(
412    suppression: &StaleSuppression,
413    root: &Path,
414    level: &'static str,
415) -> SarifFields {
416    SarifFields {
417        rule_id: "fallow/stale-suppression",
418        level,
419        message: suppression.description(),
420        uri: relative_uri(&suppression.path, root),
421        region: Some((suppression.line, suppression.col + 1)),
422        source_path: Some(suppression.path.clone()),
423        properties: None,
424    }
425}
426
427fn sarif_unused_catalog_entry_fields(
428    entry: &UnusedCatalogEntryFinding,
429    root: &Path,
430    level: &'static str,
431) -> SarifFields {
432    let entry = &entry.entry;
433    let message = if entry.catalog_name == "default" {
434        format!(
435            "Catalog entry '{}' is not referenced by any workspace package",
436            entry.entry_name
437        )
438    } else {
439        format!(
440            "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
441            entry.entry_name, entry.catalog_name
442        )
443    };
444    SarifFields {
445        rule_id: "fallow/unused-catalog-entry",
446        level,
447        message,
448        uri: relative_uri(&entry.path, root),
449        region: Some((entry.line, 1)),
450        source_path: Some(entry.path.clone()),
451        properties: None,
452    }
453}
454
455fn sarif_unused_dependency_override_fields(
456    finding: &UnusedDependencyOverrideFinding,
457    root: &Path,
458    level: &'static str,
459) -> SarifFields {
460    let finding = &finding.entry;
461    let mut message = format!(
462        "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
463        finding.raw_key, finding.version_range, finding.target_package,
464    );
465    if let Some(hint) = &finding.hint {
466        use std::fmt::Write as _;
467        let _ = write!(message, " ({hint})");
468    }
469    SarifFields {
470        rule_id: "fallow/unused-dependency-override",
471        level,
472        message,
473        uri: relative_uri(&finding.path, root),
474        region: Some((finding.line, 1)),
475        source_path: Some(finding.path.clone()),
476        properties: None,
477    }
478}
479
480fn sarif_misconfigured_dependency_override_fields(
481    finding: &MisconfiguredDependencyOverrideFinding,
482    root: &Path,
483    level: &'static str,
484) -> SarifFields {
485    let finding = &finding.entry;
486    let message = format!(
487        "Override `{}` -> `{}` is malformed: {}",
488        finding.raw_key,
489        finding.raw_value,
490        finding.reason.describe(),
491    );
492    SarifFields {
493        rule_id: "fallow/misconfigured-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_unresolved_catalog_reference_fields(
504    finding: &UnresolvedCatalogReferenceFinding,
505    root: &Path,
506    level: &'static str,
507) -> SarifFields {
508    let finding = &finding.reference;
509    let catalog_phrase = if finding.catalog_name == "default" {
510        "the default catalog".to_string()
511    } else {
512        format!("catalog '{}'", finding.catalog_name)
513    };
514    let mut message = format!(
515        "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
516        finding.entry_name,
517        if finding.catalog_name == "default" {
518            ""
519        } else {
520            finding.catalog_name.as_str()
521        },
522        catalog_phrase,
523    );
524    if !finding.available_in_catalogs.is_empty() {
525        use std::fmt::Write as _;
526        let _ = write!(
527            message,
528            " (available in: {})",
529            finding.available_in_catalogs.join(", ")
530        );
531    }
532    SarifFields {
533        rule_id: "fallow/unresolved-catalog-reference",
534        level,
535        message,
536        uri: relative_uri(&finding.path, root),
537        region: Some((finding.line, 1)),
538        source_path: Some(finding.path.clone()),
539        properties: None,
540    }
541}
542
543fn sarif_empty_catalog_group_fields(
544    group: &EmptyCatalogGroupFinding,
545    root: &Path,
546    level: &'static str,
547) -> SarifFields {
548    let group = &group.group;
549    SarifFields {
550        rule_id: "fallow/empty-catalog-group",
551        level,
552        message: format!("Catalog group '{}' has no entries", group.catalog_name),
553        uri: relative_uri(&group.path, root),
554        region: Some((group.line, 1)),
555        source_path: Some(group.path.clone()),
556        properties: None,
557    }
558}
559
560/// Unlisted deps fan out to one SARIF result per import site, so they do not
561/// fit `push_sarif_results`. Keep the nested-loop shape in its own helper.
562fn push_sarif_unlisted_deps(
563    sarif_results: &mut Vec<serde_json::Value>,
564    deps: &[UnlistedDependencyFinding],
565    root: &Path,
566    level: &'static str,
567    snippets: &mut SourceSnippetCache,
568) {
569    for entry in deps {
570        let dep = &entry.dep;
571        for site in &dep.imported_from {
572            let uri = relative_uri(&site.path, root);
573            let source_snippet = snippets.line(&site.path, site.line);
574            sarif_results.push(sarif_result_with_snippet(
575                "fallow/unlisted-dependency",
576                level,
577                &format!(
578                    "Package '{}' is imported but not listed in package.json",
579                    dep.package_name
580                ),
581                &uri,
582                Some((site.line, site.col + 1)),
583                source_snippet.as_deref(),
584            ));
585        }
586    }
587}
588
589/// Duplicate exports fan out to one SARIF result per location
590/// (SARIF 2.1.0 section 3.27.12), so they do not fit `push_sarif_results`.
591fn push_sarif_duplicate_exports(
592    sarif_results: &mut Vec<serde_json::Value>,
593    dups: &[DuplicateExportFinding],
594    root: &Path,
595    level: &'static str,
596    snippets: &mut SourceSnippetCache,
597) {
598    for dup in dups {
599        let dup = &dup.export;
600        for loc in &dup.locations {
601            let uri = relative_uri(&loc.path, root);
602            let source_snippet = snippets.line(&loc.path, loc.line);
603            sarif_results.push(sarif_result_with_snippet(
604                "fallow/duplicate-export",
605                level,
606                &format!("Export '{}' appears in multiple modules", dup.export_name),
607                &uri,
608                Some((loc.line, loc.col + 1)),
609                source_snippet.as_deref(),
610            ));
611        }
612    }
613}
614
615/// Build the SARIF rules list from the current rules configuration.
616fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
617    [
618        (
619            "fallow/unused-file",
620            "File is not reachable from any entry point",
621            rules.unused_files,
622        ),
623        (
624            "fallow/unused-export",
625            "Export is never imported",
626            rules.unused_exports,
627        ),
628        (
629            "fallow/unused-type",
630            "Type export is never imported",
631            rules.unused_types,
632        ),
633        (
634            "fallow/private-type-leak",
635            "Exported signature references a same-file private type",
636            rules.private_type_leaks,
637        ),
638        (
639            "fallow/unused-dependency",
640            "Dependency listed but never imported",
641            rules.unused_dependencies,
642        ),
643        (
644            "fallow/unused-dev-dependency",
645            "Dev dependency listed but never imported",
646            rules.unused_dev_dependencies,
647        ),
648        (
649            "fallow/unused-optional-dependency",
650            "Optional dependency listed but never imported",
651            rules.unused_optional_dependencies,
652        ),
653        (
654            "fallow/type-only-dependency",
655            "Production dependency only used via type-only imports",
656            rules.type_only_dependencies,
657        ),
658        (
659            "fallow/test-only-dependency",
660            "Production dependency only imported by test files",
661            rules.test_only_dependencies,
662        ),
663        (
664            "fallow/unused-enum-member",
665            "Enum member is never referenced",
666            rules.unused_enum_members,
667        ),
668        (
669            "fallow/unused-class-member",
670            "Class member is never referenced",
671            rules.unused_class_members,
672        ),
673        (
674            "fallow/unresolved-import",
675            "Import could not be resolved",
676            rules.unresolved_imports,
677        ),
678        (
679            "fallow/unlisted-dependency",
680            "Dependency used but not in package.json",
681            rules.unlisted_dependencies,
682        ),
683        (
684            "fallow/duplicate-export",
685            "Export name appears in multiple modules",
686            rules.duplicate_exports,
687        ),
688        (
689            "fallow/circular-dependency",
690            "Circular dependency chain detected",
691            rules.circular_dependencies,
692        ),
693        (
694            "fallow/boundary-violation",
695            "Import crosses an architecture boundary",
696            rules.boundary_violation,
697        ),
698        (
699            "fallow/stale-suppression",
700            "Suppression comment or tag no longer matches any issue",
701            rules.stale_suppressions,
702        ),
703        (
704            "fallow/unused-catalog-entry",
705            "pnpm catalog entry not referenced by any workspace package",
706            rules.unused_catalog_entries,
707        ),
708        (
709            "fallow/empty-catalog-group",
710            "pnpm named catalog group has no entries",
711            rules.empty_catalog_groups,
712        ),
713        (
714            "fallow/unresolved-catalog-reference",
715            "package.json catalog reference points at a catalog that does not declare the package",
716            rules.unresolved_catalog_references,
717        ),
718        (
719            "fallow/unused-dependency-override",
720            "pnpm dependency override target is not declared or lockfile-resolved",
721            rules.unused_dependency_overrides,
722        ),
723        (
724            "fallow/misconfigured-dependency-override",
725            "pnpm dependency override key or value is malformed",
726            rules.misconfigured_dependency_overrides,
727        ),
728    ]
729    .into_iter()
730    .map(|(id, description, rule_severity)| {
731        sarif_rule(id, description, configured_sarif_level(rule_severity))
732    })
733    .collect()
734}
735
736#[must_use]
737#[expect(
738    clippy::too_many_lines,
739    reason = "SARIF builds one flat result list across every analysis family"
740)]
741pub fn build_sarif(
742    results: &AnalysisResults,
743    root: &Path,
744    rules: &RulesConfig,
745) -> serde_json::Value {
746    let mut sarif_results = Vec::new();
747    let mut snippets = SourceSnippetCache::default();
748
749    push_sarif_results(
750        &mut sarif_results,
751        &results.unused_files,
752        &mut snippets,
753        |f| sarif_unused_file_fields(&f.file, root, severity_to_sarif_level(rules.unused_files)),
754    );
755    push_sarif_results(
756        &mut sarif_results,
757        &results.unused_exports,
758        &mut snippets,
759        |e| {
760            sarif_export_fields(
761                &e.export,
762                root,
763                "fallow/unused-export",
764                severity_to_sarif_level(rules.unused_exports),
765                "Export",
766                "Re-export",
767            )
768        },
769    );
770    push_sarif_results(
771        &mut sarif_results,
772        &results.unused_types,
773        &mut snippets,
774        |e| {
775            sarif_export_fields(
776                &e.export,
777                root,
778                "fallow/unused-type",
779                severity_to_sarif_level(rules.unused_types),
780                "Type export",
781                "Type re-export",
782            )
783        },
784    );
785    push_sarif_results(
786        &mut sarif_results,
787        &results.private_type_leaks,
788        &mut snippets,
789        |e| {
790            sarif_private_type_leak_fields(
791                &e.leak,
792                root,
793                severity_to_sarif_level(rules.private_type_leaks),
794            )
795        },
796    );
797    push_sarif_results(
798        &mut sarif_results,
799        &results.unused_dependencies,
800        &mut snippets,
801        |d| {
802            sarif_dep_fields(
803                &d.dep,
804                root,
805                "fallow/unused-dependency",
806                severity_to_sarif_level(rules.unused_dependencies),
807                "dependencies",
808            )
809        },
810    );
811    push_sarif_results(
812        &mut sarif_results,
813        &results.unused_dev_dependencies,
814        &mut snippets,
815        |d| {
816            sarif_dep_fields(
817                &d.dep,
818                root,
819                "fallow/unused-dev-dependency",
820                severity_to_sarif_level(rules.unused_dev_dependencies),
821                "devDependencies",
822            )
823        },
824    );
825    push_sarif_results(
826        &mut sarif_results,
827        &results.unused_optional_dependencies,
828        &mut snippets,
829        |d| {
830            sarif_dep_fields(
831                &d.dep,
832                root,
833                "fallow/unused-optional-dependency",
834                severity_to_sarif_level(rules.unused_optional_dependencies),
835                "optionalDependencies",
836            )
837        },
838    );
839    push_sarif_results(
840        &mut sarif_results,
841        &results.type_only_dependencies,
842        &mut snippets,
843        |d| {
844            sarif_type_only_dep_fields(
845                &d.dep,
846                root,
847                severity_to_sarif_level(rules.type_only_dependencies),
848            )
849        },
850    );
851    push_sarif_results(
852        &mut sarif_results,
853        &results.test_only_dependencies,
854        &mut snippets,
855        |d| {
856            sarif_test_only_dep_fields(
857                &d.dep,
858                root,
859                severity_to_sarif_level(rules.test_only_dependencies),
860            )
861        },
862    );
863    push_sarif_results(
864        &mut sarif_results,
865        &results.unused_enum_members,
866        &mut snippets,
867        |m| {
868            sarif_member_fields(
869                &m.member,
870                root,
871                "fallow/unused-enum-member",
872                severity_to_sarif_level(rules.unused_enum_members),
873                "Enum",
874            )
875        },
876    );
877    push_sarif_results(
878        &mut sarif_results,
879        &results.unused_class_members,
880        &mut snippets,
881        |m| {
882            sarif_member_fields(
883                &m.member,
884                root,
885                "fallow/unused-class-member",
886                severity_to_sarif_level(rules.unused_class_members),
887                "Class",
888            )
889        },
890    );
891    push_sarif_results(
892        &mut sarif_results,
893        &results.unresolved_imports,
894        &mut snippets,
895        |i| {
896            sarif_unresolved_import_fields(
897                &i.import,
898                root,
899                severity_to_sarif_level(rules.unresolved_imports),
900            )
901        },
902    );
903    if !results.unlisted_dependencies.is_empty() {
904        push_sarif_unlisted_deps(
905            &mut sarif_results,
906            &results.unlisted_dependencies,
907            root,
908            severity_to_sarif_level(rules.unlisted_dependencies),
909            &mut snippets,
910        );
911    }
912    if !results.duplicate_exports.is_empty() {
913        push_sarif_duplicate_exports(
914            &mut sarif_results,
915            &results.duplicate_exports,
916            root,
917            severity_to_sarif_level(rules.duplicate_exports),
918            &mut snippets,
919        );
920    }
921    push_sarif_results(
922        &mut sarif_results,
923        &results.circular_dependencies,
924        &mut snippets,
925        |c| {
926            sarif_circular_dep_fields(
927                &c.cycle,
928                root,
929                severity_to_sarif_level(rules.circular_dependencies),
930            )
931        },
932    );
933    push_sarif_results(
934        &mut sarif_results,
935        &results.boundary_violations,
936        &mut snippets,
937        |v| {
938            sarif_boundary_violation_fields(
939                &v.violation,
940                root,
941                severity_to_sarif_level(rules.boundary_violation),
942            )
943        },
944    );
945    push_sarif_results(
946        &mut sarif_results,
947        &results.stale_suppressions,
948        &mut snippets,
949        |s| {
950            sarif_stale_suppression_fields(
951                s,
952                root,
953                severity_to_sarif_level(rules.stale_suppressions),
954            )
955        },
956    );
957    push_sarif_results(
958        &mut sarif_results,
959        &results.unused_catalog_entries,
960        &mut snippets,
961        |e| {
962            sarif_unused_catalog_entry_fields(
963                e,
964                root,
965                severity_to_sarif_level(rules.unused_catalog_entries),
966            )
967        },
968    );
969    push_sarif_results(
970        &mut sarif_results,
971        &results.empty_catalog_groups,
972        &mut snippets,
973        |g| {
974            sarif_empty_catalog_group_fields(
975                g,
976                root,
977                severity_to_sarif_level(rules.empty_catalog_groups),
978            )
979        },
980    );
981    push_sarif_results(
982        &mut sarif_results,
983        &results.unresolved_catalog_references,
984        &mut snippets,
985        |f| {
986            sarif_unresolved_catalog_reference_fields(
987                f,
988                root,
989                severity_to_sarif_level(rules.unresolved_catalog_references),
990            )
991        },
992    );
993    push_sarif_results(
994        &mut sarif_results,
995        &results.unused_dependency_overrides,
996        &mut snippets,
997        |f| {
998            sarif_unused_dependency_override_fields(
999                f,
1000                root,
1001                severity_to_sarif_level(rules.unused_dependency_overrides),
1002            )
1003        },
1004    );
1005    push_sarif_results(
1006        &mut sarif_results,
1007        &results.misconfigured_dependency_overrides,
1008        &mut snippets,
1009        |f| {
1010            sarif_misconfigured_dependency_override_fields(
1011                f,
1012                root,
1013                severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1014            )
1015        },
1016    );
1017
1018    serde_json::json!({
1019        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1020        "version": "2.1.0",
1021        "runs": [{
1022            "tool": {
1023                "driver": {
1024                    "name": "fallow",
1025                    "version": env!("CARGO_PKG_VERSION"),
1026                    "informationUri": "https://github.com/fallow-rs/fallow",
1027                    "rules": build_sarif_rules(rules)
1028                }
1029            },
1030            "results": sarif_results
1031        }]
1032    })
1033}
1034
1035pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
1036    let sarif = build_sarif(results, root, rules);
1037    emit_json(&sarif, "SARIF")
1038}
1039
1040/// Print SARIF output with owner properties added to each result.
1041///
1042/// Calls `build_sarif` to produce the standard SARIF JSON, then post-processes
1043/// each result to add `"properties": { "owner": "@team" }` by resolving the
1044/// artifact location URI through the `OwnershipResolver`.
1045pub(super) fn print_grouped_sarif(
1046    results: &AnalysisResults,
1047    root: &Path,
1048    rules: &RulesConfig,
1049    resolver: &OwnershipResolver,
1050) -> ExitCode {
1051    let mut sarif = build_sarif(results, root, rules);
1052
1053    // Post-process each result to inject the owner property.
1054    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1055        for run in runs {
1056            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1057                for result in results {
1058                    let uri = result
1059                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1060                        .and_then(|v| v.as_str())
1061                        .unwrap_or("");
1062                    // Decode percent-encoded brackets before ownership lookup
1063                    // (SARIF URIs encode `[`/`]` as `%5B`/`%5D`)
1064                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1065                    let owner =
1066                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1067                    let props = result
1068                        .as_object_mut()
1069                        .expect("SARIF result should be an object")
1070                        .entry("properties")
1071                        .or_insert_with(|| serde_json::json!({}));
1072                    props
1073                        .as_object_mut()
1074                        .expect("properties should be an object")
1075                        .insert("owner".to_string(), serde_json::Value::String(owner));
1076                }
1077            }
1078        }
1079    }
1080
1081    emit_json(&sarif, "SARIF")
1082}
1083
1084#[expect(
1085    clippy::cast_possible_truncation,
1086    reason = "line/col numbers are bounded by source size"
1087)]
1088pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
1089    let mut sarif_results = Vec::new();
1090    let mut snippets = SourceSnippetCache::default();
1091
1092    for (i, group) in report.clone_groups.iter().enumerate() {
1093        for instance in &group.instances {
1094            let uri = relative_uri(&instance.file, root);
1095            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1096            sarif_results.push(sarif_result_with_snippet(
1097                "fallow/code-duplication",
1098                "warning",
1099                &format!(
1100                    "Code clone group {} ({} lines, {} instances)",
1101                    i + 1,
1102                    group.line_count,
1103                    group.instances.len()
1104                ),
1105                &uri,
1106                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1107                source_snippet.as_deref(),
1108            ));
1109        }
1110    }
1111
1112    let sarif = serde_json::json!({
1113        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1114        "version": "2.1.0",
1115        "runs": [{
1116            "tool": {
1117                "driver": {
1118                    "name": "fallow",
1119                    "version": env!("CARGO_PKG_VERSION"),
1120                    "informationUri": "https://github.com/fallow-rs/fallow",
1121                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1122                }
1123            },
1124            "results": sarif_results
1125        }]
1126    });
1127
1128    emit_json(&sarif, "SARIF")
1129}
1130
1131/// Print SARIF duplication output with a `properties.group` tag on every
1132/// result.
1133///
1134/// Each clone group is attributed to its largest owner (most instances; ties
1135/// broken alphabetically) via [`super::dupes_grouping::largest_owner`], and
1136/// every result emitted for that group's instances carries the same
1137/// `properties.group` value. This mirrors the health SARIF convention
1138/// (`print_grouped_health_sarif`) so consumers (GitHub Code Scanning, GitLab
1139/// Code Quality) can partition findings per team / package / directory
1140/// without re-resolving ownership.
1141#[expect(
1142    clippy::cast_possible_truncation,
1143    reason = "line/col numbers are bounded by source size"
1144)]
1145pub(super) fn print_grouped_duplication_sarif(
1146    report: &DuplicationReport,
1147    root: &Path,
1148    resolver: &OwnershipResolver,
1149) -> ExitCode {
1150    let mut sarif_results = Vec::new();
1151    let mut snippets = SourceSnippetCache::default();
1152
1153    for (i, group) in report.clone_groups.iter().enumerate() {
1154        // Compute the group's primary owner once. Every result emitted for
1155        // this group carries the same `properties.group` value (the GROUP'S
1156        // owner, not the per-instance owner).
1157        let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
1158        for instance in &group.instances {
1159            let uri = relative_uri(&instance.file, root);
1160            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1161            let mut result = sarif_result_with_snippet(
1162                "fallow/code-duplication",
1163                "warning",
1164                &format!(
1165                    "Code clone group {} ({} lines, {} instances)",
1166                    i + 1,
1167                    group.line_count,
1168                    group.instances.len()
1169                ),
1170                &uri,
1171                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1172                source_snippet.as_deref(),
1173            );
1174            let props = result
1175                .as_object_mut()
1176                .expect("SARIF result should be an object")
1177                .entry("properties")
1178                .or_insert_with(|| serde_json::json!({}));
1179            props
1180                .as_object_mut()
1181                .expect("properties should be an object")
1182                .insert(
1183                    "group".to_string(),
1184                    serde_json::Value::String(primary_owner.clone()),
1185                );
1186            sarif_results.push(result);
1187        }
1188    }
1189
1190    let sarif = serde_json::json!({
1191        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1192        "version": "2.1.0",
1193        "runs": [{
1194            "tool": {
1195                "driver": {
1196                    "name": "fallow",
1197                    "version": env!("CARGO_PKG_VERSION"),
1198                    "informationUri": "https://github.com/fallow-rs/fallow",
1199                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1200                }
1201            },
1202            "results": sarif_results
1203        }]
1204    });
1205
1206    emit_json(&sarif, "SARIF")
1207}
1208
1209// ── Health SARIF output ────────────────────────────────────────────
1210// Note: file_scores are intentionally omitted from SARIF output.
1211// SARIF is designed for diagnostic results (issues/findings), not metric tables.
1212// File health scores are available in JSON, human, compact, and markdown formats.
1213
1214#[must_use]
1215#[expect(
1216    clippy::too_many_lines,
1217    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"
1218)]
1219pub fn build_health_sarif(
1220    report: &crate::health_types::HealthReport,
1221    root: &Path,
1222) -> serde_json::Value {
1223    use crate::health_types::ExceededThreshold;
1224
1225    let mut sarif_results = Vec::new();
1226    let mut snippets = SourceSnippetCache::default();
1227
1228    for finding in &report.findings {
1229        let uri = relative_uri(&finding.path, root);
1230        // When CRAP contributes alongside complexity, use the CRAP rule as the
1231        // most actionable identifier (CRAP combines complexity and coverage)
1232        // and surface all exceeded dimensions in the message.
1233        let (rule_id, message) = match finding.exceeded {
1234            ExceededThreshold::Cyclomatic => (
1235                "fallow/high-cyclomatic-complexity",
1236                format!(
1237                    "'{}' has cyclomatic complexity {} (threshold: {})",
1238                    finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
1239                ),
1240            ),
1241            ExceededThreshold::Cognitive => (
1242                "fallow/high-cognitive-complexity",
1243                format!(
1244                    "'{}' has cognitive complexity {} (threshold: {})",
1245                    finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
1246                ),
1247            ),
1248            ExceededThreshold::Both => (
1249                "fallow/high-complexity",
1250                format!(
1251                    "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
1252                    finding.name,
1253                    finding.cyclomatic,
1254                    report.summary.max_cyclomatic_threshold,
1255                    finding.cognitive,
1256                    report.summary.max_cognitive_threshold,
1257                ),
1258            ),
1259            ExceededThreshold::Crap
1260            | ExceededThreshold::CyclomaticCrap
1261            | ExceededThreshold::CognitiveCrap
1262            | ExceededThreshold::All => {
1263                let crap = finding.crap.unwrap_or(0.0);
1264                let coverage = finding
1265                    .coverage_pct
1266                    .map(|pct| format!(", coverage {pct:.0}%"))
1267                    .unwrap_or_default();
1268                (
1269                    "fallow/high-crap-score",
1270                    format!(
1271                        "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
1272                        finding.name,
1273                        crap,
1274                        report.summary.max_crap_threshold,
1275                        finding.cyclomatic,
1276                        coverage,
1277                    ),
1278                )
1279            }
1280        };
1281
1282        let level = match finding.severity {
1283            crate::health_types::FindingSeverity::Critical => "error",
1284            crate::health_types::FindingSeverity::High => "warning",
1285            crate::health_types::FindingSeverity::Moderate => "note",
1286        };
1287        let source_snippet = snippets.line(&finding.path, finding.line);
1288        sarif_results.push(sarif_result_with_snippet(
1289            rule_id,
1290            level,
1291            &message,
1292            &uri,
1293            Some((finding.line, finding.col + 1)),
1294            source_snippet.as_deref(),
1295        ));
1296    }
1297
1298    if let Some(ref production) = report.runtime_coverage {
1299        append_runtime_coverage_sarif_results(&mut sarif_results, production, root, &mut snippets);
1300    }
1301
1302    // Refactoring targets as SARIF results (warning level — advisory recommendations)
1303    for target in &report.targets {
1304        let uri = relative_uri(&target.path, root);
1305        let message = format!(
1306            "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
1307            target.category.label(),
1308            target.recommendation,
1309            target.priority,
1310            target.efficiency,
1311            target.effort.label(),
1312            target.confidence.label(),
1313        );
1314        sarif_results.push(sarif_result(
1315            "fallow/refactoring-target",
1316            "warning",
1317            &message,
1318            &uri,
1319            None,
1320        ));
1321    }
1322
1323    if let Some(ref gaps) = report.coverage_gaps {
1324        for item in &gaps.files {
1325            let uri = relative_uri(&item.file.path, root);
1326            let message = format!(
1327                "File is runtime-reachable but has no test dependency path ({} value export{})",
1328                item.file.value_export_count,
1329                if item.file.value_export_count == 1 {
1330                    ""
1331                } else {
1332                    "s"
1333                },
1334            );
1335            sarif_results.push(sarif_result(
1336                "fallow/untested-file",
1337                "warning",
1338                &message,
1339                &uri,
1340                None,
1341            ));
1342        }
1343
1344        for item in &gaps.exports {
1345            let uri = relative_uri(&item.export.path, root);
1346            let message = format!(
1347                "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1348                item.export.export_name
1349            );
1350            let source_snippet = snippets.line(&item.export.path, item.export.line);
1351            sarif_results.push(sarif_result_with_snippet(
1352                "fallow/untested-export",
1353                "warning",
1354                &message,
1355                &uri,
1356                Some((item.export.line, item.export.col + 1)),
1357                source_snippet.as_deref(),
1358            ));
1359        }
1360    }
1361
1362    let health_rules = vec![
1363        sarif_rule(
1364            "fallow/high-cyclomatic-complexity",
1365            "Function has high cyclomatic complexity",
1366            "note",
1367        ),
1368        sarif_rule(
1369            "fallow/high-cognitive-complexity",
1370            "Function has high cognitive complexity",
1371            "note",
1372        ),
1373        sarif_rule(
1374            "fallow/high-complexity",
1375            "Function exceeds both complexity thresholds",
1376            "note",
1377        ),
1378        sarif_rule(
1379            "fallow/high-crap-score",
1380            "Function has a high CRAP score (high complexity combined with low coverage)",
1381            "warning",
1382        ),
1383        sarif_rule(
1384            "fallow/refactoring-target",
1385            "File identified as a high-priority refactoring candidate",
1386            "warning",
1387        ),
1388        sarif_rule(
1389            "fallow/untested-file",
1390            "Runtime-reachable file has no test dependency path",
1391            "warning",
1392        ),
1393        sarif_rule(
1394            "fallow/untested-export",
1395            "Runtime-reachable export has no test dependency path",
1396            "warning",
1397        ),
1398        sarif_rule(
1399            "fallow/runtime-safe-to-delete",
1400            "Function is statically unused and was never invoked in production",
1401            "warning",
1402        ),
1403        sarif_rule(
1404            "fallow/runtime-review-required",
1405            "Function is statically used but was never invoked in production",
1406            "warning",
1407        ),
1408        sarif_rule(
1409            "fallow/runtime-low-traffic",
1410            "Function was invoked below the low-traffic threshold relative to total trace count",
1411            "note",
1412        ),
1413        sarif_rule(
1414            "fallow/runtime-coverage-unavailable",
1415            "Runtime coverage could not be resolved for this function",
1416            "note",
1417        ),
1418        sarif_rule(
1419            "fallow/runtime-coverage",
1420            "Runtime coverage finding",
1421            "note",
1422        ),
1423    ];
1424
1425    serde_json::json!({
1426        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1427        "version": "2.1.0",
1428        "runs": [{
1429            "tool": {
1430                "driver": {
1431                    "name": "fallow",
1432                    "version": env!("CARGO_PKG_VERSION"),
1433                    "informationUri": "https://github.com/fallow-rs/fallow",
1434                    "rules": health_rules
1435                }
1436            },
1437            "results": sarif_results
1438        }]
1439    })
1440}
1441
1442// Note: `production.hot_paths`, `production.signals`, and per-hot-path
1443// `end_line` are intentionally omitted from SARIF output. SARIF is
1444// designed for diagnostic results (issues a reviewer should act on),
1445// not for state observations. `hot-path-touched` is informational
1446// (PR-context heads-up that a touched function is on the hot path),
1447// not a finding to fix; surfacing it as a SARIF result would clutter
1448// Code Scanning's UI with non-actionable entries. JSON consumers that
1449// want the full picture read `runtime_coverage.signals[]` and
1450// `runtime_coverage.hot_paths[]` directly.
1451fn append_runtime_coverage_sarif_results(
1452    sarif_results: &mut Vec<serde_json::Value>,
1453    production: &crate::health_types::RuntimeCoverageReport,
1454    root: &Path,
1455    snippets: &mut SourceSnippetCache,
1456) {
1457    for finding in &production.findings {
1458        let uri = relative_uri(&finding.path, root);
1459        let rule_id = match finding.verdict {
1460            crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1461                "fallow/runtime-safe-to-delete"
1462            }
1463            crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1464                "fallow/runtime-review-required"
1465            }
1466            crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
1467            crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1468                "fallow/runtime-coverage-unavailable"
1469            }
1470            crate::health_types::RuntimeCoverageVerdict::Active
1471            | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1472        };
1473        let level = match finding.verdict {
1474            crate::health_types::RuntimeCoverageVerdict::SafeToDelete
1475            | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
1476            _ => "note",
1477        };
1478        let invocations_hint = finding.invocations.map_or_else(
1479            || "untracked".to_owned(),
1480            |hits| format!("{hits} invocations"),
1481        );
1482        let message = format!(
1483            "'{}' runtime coverage verdict: {} ({})",
1484            finding.function,
1485            finding.verdict.human_label(),
1486            invocations_hint,
1487        );
1488        let source_snippet = snippets.line(&finding.path, finding.line);
1489        sarif_results.push(sarif_result_with_snippet(
1490            rule_id,
1491            level,
1492            &message,
1493            &uri,
1494            Some((finding.line, 1)),
1495            source_snippet.as_deref(),
1496        ));
1497    }
1498}
1499
1500pub(super) fn print_health_sarif(
1501    report: &crate::health_types::HealthReport,
1502    root: &Path,
1503) -> ExitCode {
1504    let sarif = build_health_sarif(report, root);
1505    emit_json(&sarif, "SARIF")
1506}
1507
1508/// Print health SARIF with a per-result `properties.group` tag.
1509///
1510/// Mirrors the dead-code grouped SARIF pattern (`print_grouped_sarif`):
1511/// build the standard SARIF first, then post-process each result to inject
1512/// the resolver-derived group key on `properties.group`. Consumers that read
1513/// SARIF (GitHub Code Scanning, GitLab Code Quality) can then partition
1514/// findings per team / package / directory without dropping out of the
1515/// SARIF pipeline. Each finding's URI is decoded (`%5B` -> `[`, `%5D` -> `]`)
1516/// before resolution, matching the dead-code behaviour for paths containing
1517/// brackets like Next.js dynamic routes.
1518pub(super) fn print_grouped_health_sarif(
1519    report: &crate::health_types::HealthReport,
1520    root: &Path,
1521    resolver: &OwnershipResolver,
1522) -> ExitCode {
1523    let mut sarif = build_health_sarif(report, root);
1524
1525    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1526        for run in runs {
1527            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1528                for result in results {
1529                    let uri = result
1530                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1531                        .and_then(|v| v.as_str())
1532                        .unwrap_or("");
1533                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1534                    let group =
1535                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1536                    let props = result
1537                        .as_object_mut()
1538                        .expect("SARIF result should be an object")
1539                        .entry("properties")
1540                        .or_insert_with(|| serde_json::json!({}));
1541                    props
1542                        .as_object_mut()
1543                        .expect("properties should be an object")
1544                        .insert("group".to_string(), serde_json::Value::String(group));
1545                }
1546            }
1547        }
1548    }
1549
1550    emit_json(&sarif, "SARIF")
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555    use super::*;
1556    use crate::report::test_helpers::sample_results;
1557    use fallow_core::results::*;
1558    use std::path::PathBuf;
1559
1560    #[test]
1561    fn sarif_has_required_top_level_fields() {
1562        let root = PathBuf::from("/project");
1563        let results = AnalysisResults::default();
1564        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1565
1566        assert_eq!(
1567            sarif["$schema"],
1568            "https://json.schemastore.org/sarif-2.1.0.json"
1569        );
1570        assert_eq!(sarif["version"], "2.1.0");
1571        assert!(sarif["runs"].is_array());
1572    }
1573
1574    #[test]
1575    fn sarif_has_tool_driver_info() {
1576        let root = PathBuf::from("/project");
1577        let results = AnalysisResults::default();
1578        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1579
1580        let driver = &sarif["runs"][0]["tool"]["driver"];
1581        assert_eq!(driver["name"], "fallow");
1582        assert!(driver["version"].is_string());
1583        assert_eq!(
1584            driver["informationUri"],
1585            "https://github.com/fallow-rs/fallow"
1586        );
1587    }
1588
1589    #[test]
1590    fn sarif_declares_all_rules() {
1591        let root = PathBuf::from("/project");
1592        let results = AnalysisResults::default();
1593        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1594
1595        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1596            .as_array()
1597            .expect("rules should be an array");
1598        assert_eq!(rules.len(), 22);
1599
1600        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1601        assert!(rule_ids.contains(&"fallow/unused-file"));
1602        assert!(rule_ids.contains(&"fallow/unused-export"));
1603        assert!(rule_ids.contains(&"fallow/unused-type"));
1604        assert!(rule_ids.contains(&"fallow/private-type-leak"));
1605        assert!(rule_ids.contains(&"fallow/unused-dependency"));
1606        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1607        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1608        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1609        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1610        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1611        assert!(rule_ids.contains(&"fallow/unused-class-member"));
1612        assert!(rule_ids.contains(&"fallow/unresolved-import"));
1613        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1614        assert!(rule_ids.contains(&"fallow/duplicate-export"));
1615        assert!(rule_ids.contains(&"fallow/circular-dependency"));
1616        assert!(rule_ids.contains(&"fallow/boundary-violation"));
1617        assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
1618        assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
1619        assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
1620        assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
1621        assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
1622    }
1623
1624    #[test]
1625    fn sarif_empty_results_no_results_entries() {
1626        let root = PathBuf::from("/project");
1627        let results = AnalysisResults::default();
1628        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1629
1630        let sarif_results = sarif["runs"][0]["results"]
1631            .as_array()
1632            .expect("results should be an array");
1633        assert!(sarif_results.is_empty());
1634    }
1635
1636    #[test]
1637    fn sarif_unused_file_result() {
1638        let root = PathBuf::from("/project");
1639        let mut results = AnalysisResults::default();
1640        results
1641            .unused_files
1642            .push(UnusedFileFinding::with_actions(UnusedFile {
1643                path: root.join("src/dead.ts"),
1644            }));
1645
1646        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1647        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1648        assert_eq!(entries.len(), 1);
1649
1650        let entry = &entries[0];
1651        assert_eq!(entry["ruleId"], "fallow/unused-file");
1652        // Default severity is "error" per RulesConfig::default()
1653        assert_eq!(entry["level"], "error");
1654        assert_eq!(
1655            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1656            "src/dead.ts"
1657        );
1658    }
1659
1660    #[test]
1661    fn sarif_unused_export_includes_region() {
1662        let root = PathBuf::from("/project");
1663        let mut results = AnalysisResults::default();
1664        results
1665            .unused_exports
1666            .push(UnusedExportFinding::with_actions(UnusedExport {
1667                path: root.join("src/utils.ts"),
1668                export_name: "helperFn".to_string(),
1669                is_type_only: false,
1670                line: 10,
1671                col: 4,
1672                span_start: 120,
1673                is_re_export: false,
1674            }));
1675
1676        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1677        let entry = &sarif["runs"][0]["results"][0];
1678        assert_eq!(entry["ruleId"], "fallow/unused-export");
1679
1680        let region = &entry["locations"][0]["physicalLocation"]["region"];
1681        assert_eq!(region["startLine"], 10);
1682        // SARIF columns are 1-based, code adds +1 to the 0-based col
1683        assert_eq!(region["startColumn"], 5);
1684    }
1685
1686    #[test]
1687    fn sarif_unresolved_import_is_error_level() {
1688        let root = PathBuf::from("/project");
1689        let mut results = AnalysisResults::default();
1690        results
1691            .unresolved_imports
1692            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1693                path: root.join("src/app.ts"),
1694                specifier: "./missing".to_string(),
1695                line: 1,
1696                col: 0,
1697                specifier_col: 0,
1698            }));
1699
1700        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1701        let entry = &sarif["runs"][0]["results"][0];
1702        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1703        assert_eq!(entry["level"], "error");
1704    }
1705
1706    #[test]
1707    fn sarif_unlisted_dependency_points_to_import_site() {
1708        let root = PathBuf::from("/project");
1709        let mut results = AnalysisResults::default();
1710        results
1711            .unlisted_dependencies
1712            .push(UnlistedDependencyFinding::with_actions(
1713                UnlistedDependency {
1714                    package_name: "chalk".to_string(),
1715                    imported_from: vec![ImportSite {
1716                        path: root.join("src/cli.ts"),
1717                        line: 3,
1718                        col: 0,
1719                    }],
1720                },
1721            ));
1722
1723        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1724        let entry = &sarif["runs"][0]["results"][0];
1725        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1726        assert_eq!(entry["level"], "error");
1727        assert_eq!(
1728            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1729            "src/cli.ts"
1730        );
1731        let region = &entry["locations"][0]["physicalLocation"]["region"];
1732        assert_eq!(region["startLine"], 3);
1733        assert_eq!(region["startColumn"], 1);
1734    }
1735
1736    #[test]
1737    fn sarif_dependency_issues_point_to_package_json() {
1738        let root = PathBuf::from("/project");
1739        let mut results = AnalysisResults::default();
1740        results
1741            .unused_dependencies
1742            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1743                package_name: "lodash".to_string(),
1744                location: DependencyLocation::Dependencies,
1745                path: root.join("package.json"),
1746                line: 5,
1747                used_in_workspaces: Vec::new(),
1748            }));
1749        results
1750            .unused_dev_dependencies
1751            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1752                package_name: "jest".to_string(),
1753                location: DependencyLocation::DevDependencies,
1754                path: root.join("package.json"),
1755                line: 5,
1756                used_in_workspaces: Vec::new(),
1757            }));
1758
1759        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1760        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1761        for entry in entries {
1762            assert_eq!(
1763                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1764                "package.json"
1765            );
1766        }
1767    }
1768
1769    #[test]
1770    fn sarif_duplicate_export_emits_one_result_per_location() {
1771        let root = PathBuf::from("/project");
1772        let mut results = AnalysisResults::default();
1773        results
1774            .duplicate_exports
1775            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1776                export_name: "Config".to_string(),
1777                locations: vec![
1778                    DuplicateLocation {
1779                        path: root.join("src/a.ts"),
1780                        line: 15,
1781                        col: 0,
1782                    },
1783                    DuplicateLocation {
1784                        path: root.join("src/b.ts"),
1785                        line: 30,
1786                        col: 0,
1787                    },
1788                ],
1789            }));
1790
1791        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1792        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1793        // One SARIF result per location, not one per DuplicateExport
1794        assert_eq!(entries.len(), 2);
1795        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1796        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1797        assert_eq!(
1798            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1799            "src/a.ts"
1800        );
1801        assert_eq!(
1802            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1803            "src/b.ts"
1804        );
1805    }
1806
1807    #[test]
1808    fn sarif_all_issue_types_produce_results() {
1809        let root = PathBuf::from("/project");
1810        let results = sample_results(&root);
1811        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1812
1813        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1814        // All issue types with one entry each; duplicate_exports has 2 locations => one extra SARIF result
1815        assert_eq!(entries.len(), results.total_issues() + 1);
1816
1817        let rule_ids: Vec<&str> = entries
1818            .iter()
1819            .map(|e| e["ruleId"].as_str().unwrap())
1820            .collect();
1821        assert!(rule_ids.contains(&"fallow/unused-file"));
1822        assert!(rule_ids.contains(&"fallow/unused-export"));
1823        assert!(rule_ids.contains(&"fallow/unused-type"));
1824        assert!(rule_ids.contains(&"fallow/unused-dependency"));
1825        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1826        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1827        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1828        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1829        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1830        assert!(rule_ids.contains(&"fallow/unused-class-member"));
1831        assert!(rule_ids.contains(&"fallow/unresolved-import"));
1832        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1833        assert!(rule_ids.contains(&"fallow/duplicate-export"));
1834    }
1835
1836    #[test]
1837    fn sarif_serializes_to_valid_json() {
1838        let root = PathBuf::from("/project");
1839        let results = sample_results(&root);
1840        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1841
1842        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1843        let reparsed: serde_json::Value =
1844            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1845        assert_eq!(reparsed, sarif);
1846    }
1847
1848    #[test]
1849    fn sarif_file_write_produces_valid_sarif() {
1850        let root = PathBuf::from("/project");
1851        let results = sample_results(&root);
1852        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1853        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1854
1855        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1856        let _ = std::fs::create_dir_all(&dir);
1857        let sarif_path = dir.join("results.sarif");
1858        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1859
1860        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1861        let parsed: serde_json::Value =
1862            serde_json::from_str(&contents).expect("file should contain valid JSON");
1863
1864        assert_eq!(parsed["version"], "2.1.0");
1865        assert_eq!(
1866            parsed["$schema"],
1867            "https://json.schemastore.org/sarif-2.1.0.json"
1868        );
1869        let sarif_results = parsed["runs"][0]["results"]
1870            .as_array()
1871            .expect("results should be an array");
1872        assert!(!sarif_results.is_empty());
1873
1874        // Clean up
1875        let _ = std::fs::remove_file(&sarif_path);
1876        let _ = std::fs::remove_dir(&dir);
1877    }
1878
1879    // ── Health SARIF ──
1880
1881    #[test]
1882    fn health_sarif_empty_no_results() {
1883        let root = PathBuf::from("/project");
1884        let report = crate::health_types::HealthReport {
1885            summary: crate::health_types::HealthSummary {
1886                files_analyzed: 10,
1887                functions_analyzed: 50,
1888                ..Default::default()
1889            },
1890            ..Default::default()
1891        };
1892        let sarif = build_health_sarif(&report, &root);
1893        assert_eq!(sarif["version"], "2.1.0");
1894        let results = sarif["runs"][0]["results"].as_array().unwrap();
1895        assert!(results.is_empty());
1896        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1897            .as_array()
1898            .unwrap();
1899        assert_eq!(rules.len(), 12);
1900    }
1901
1902    #[test]
1903    fn health_sarif_cyclomatic_only() {
1904        let root = PathBuf::from("/project");
1905        let report = crate::health_types::HealthReport {
1906            findings: vec![
1907                crate::health_types::ComplexityViolation {
1908                    path: root.join("src/utils.ts"),
1909                    name: "parseExpression".to_string(),
1910                    line: 42,
1911                    col: 0,
1912                    cyclomatic: 25,
1913                    cognitive: 10,
1914                    line_count: 80,
1915                    param_count: 0,
1916                    exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1917                    severity: crate::health_types::FindingSeverity::High,
1918                    crap: None,
1919                    coverage_pct: None,
1920                    coverage_tier: None,
1921                    coverage_source: None,
1922                    inherited_from: None,
1923                    component_rollup: None,
1924                }
1925                .into(),
1926            ],
1927            summary: crate::health_types::HealthSummary {
1928                files_analyzed: 5,
1929                functions_analyzed: 20,
1930                functions_above_threshold: 1,
1931                ..Default::default()
1932            },
1933            ..Default::default()
1934        };
1935        let sarif = build_health_sarif(&report, &root);
1936        let entry = &sarif["runs"][0]["results"][0];
1937        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1938        assert_eq!(entry["level"], "warning");
1939        assert!(
1940            entry["message"]["text"]
1941                .as_str()
1942                .unwrap()
1943                .contains("cyclomatic complexity 25")
1944        );
1945        assert_eq!(
1946            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1947            "src/utils.ts"
1948        );
1949        let region = &entry["locations"][0]["physicalLocation"]["region"];
1950        assert_eq!(region["startLine"], 42);
1951        assert_eq!(region["startColumn"], 1);
1952    }
1953
1954    #[test]
1955    fn health_sarif_cognitive_only() {
1956        let root = PathBuf::from("/project");
1957        let report = crate::health_types::HealthReport {
1958            findings: vec![
1959                crate::health_types::ComplexityViolation {
1960                    path: root.join("src/api.ts"),
1961                    name: "handleRequest".to_string(),
1962                    line: 10,
1963                    col: 4,
1964                    cyclomatic: 8,
1965                    cognitive: 20,
1966                    line_count: 40,
1967                    param_count: 0,
1968                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
1969                    severity: crate::health_types::FindingSeverity::High,
1970                    crap: None,
1971                    coverage_pct: None,
1972                    coverage_tier: None,
1973                    coverage_source: None,
1974                    inherited_from: None,
1975                    component_rollup: None,
1976                }
1977                .into(),
1978            ],
1979            summary: crate::health_types::HealthSummary {
1980                files_analyzed: 3,
1981                functions_analyzed: 10,
1982                functions_above_threshold: 1,
1983                ..Default::default()
1984            },
1985            ..Default::default()
1986        };
1987        let sarif = build_health_sarif(&report, &root);
1988        let entry = &sarif["runs"][0]["results"][0];
1989        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1990        assert!(
1991            entry["message"]["text"]
1992                .as_str()
1993                .unwrap()
1994                .contains("cognitive complexity 20")
1995        );
1996        let region = &entry["locations"][0]["physicalLocation"]["region"];
1997        assert_eq!(region["startColumn"], 5); // col 4 + 1
1998    }
1999
2000    #[test]
2001    fn health_sarif_both_thresholds() {
2002        let root = PathBuf::from("/project");
2003        let report = crate::health_types::HealthReport {
2004            findings: vec![
2005                crate::health_types::ComplexityViolation {
2006                    path: root.join("src/complex.ts"),
2007                    name: "doEverything".to_string(),
2008                    line: 1,
2009                    col: 0,
2010                    cyclomatic: 30,
2011                    cognitive: 45,
2012                    line_count: 100,
2013                    param_count: 0,
2014                    exceeded: crate::health_types::ExceededThreshold::Both,
2015                    severity: crate::health_types::FindingSeverity::High,
2016                    crap: None,
2017                    coverage_pct: None,
2018                    coverage_tier: None,
2019                    coverage_source: None,
2020                    inherited_from: None,
2021                    component_rollup: None,
2022                }
2023                .into(),
2024            ],
2025            summary: crate::health_types::HealthSummary {
2026                files_analyzed: 1,
2027                functions_analyzed: 1,
2028                functions_above_threshold: 1,
2029                ..Default::default()
2030            },
2031            ..Default::default()
2032        };
2033        let sarif = build_health_sarif(&report, &root);
2034        let entry = &sarif["runs"][0]["results"][0];
2035        assert_eq!(entry["ruleId"], "fallow/high-complexity");
2036        let msg = entry["message"]["text"].as_str().unwrap();
2037        assert!(msg.contains("cyclomatic complexity 30"));
2038        assert!(msg.contains("cognitive complexity 45"));
2039    }
2040
2041    #[test]
2042    fn health_sarif_crap_only_emits_crap_rule() {
2043        // CRAP-only: cyclomatic + cognitive below their thresholds, CRAP at or
2044        // above the CRAP threshold. Rule must be `fallow/high-crap-score`.
2045        let root = PathBuf::from("/project");
2046        let report = crate::health_types::HealthReport {
2047            findings: vec![
2048                crate::health_types::ComplexityViolation {
2049                    path: root.join("src/untested.ts"),
2050                    name: "risky".to_string(),
2051                    line: 8,
2052                    col: 0,
2053                    cyclomatic: 10,
2054                    cognitive: 10,
2055                    line_count: 20,
2056                    param_count: 1,
2057                    exceeded: crate::health_types::ExceededThreshold::Crap,
2058                    severity: crate::health_types::FindingSeverity::High,
2059                    crap: Some(82.2),
2060                    coverage_pct: Some(12.0),
2061                    coverage_tier: None,
2062                    coverage_source: None,
2063                    inherited_from: None,
2064                    component_rollup: None,
2065                }
2066                .into(),
2067            ],
2068            summary: crate::health_types::HealthSummary {
2069                files_analyzed: 1,
2070                functions_analyzed: 1,
2071                functions_above_threshold: 1,
2072                ..Default::default()
2073            },
2074            ..Default::default()
2075        };
2076        let sarif = build_health_sarif(&report, &root);
2077        let entry = &sarif["runs"][0]["results"][0];
2078        assert_eq!(entry["ruleId"], "fallow/high-crap-score");
2079        let msg = entry["message"]["text"].as_str().unwrap();
2080        assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
2081        assert!(msg.contains("coverage 12%"), "msg: {msg}");
2082    }
2083
2084    #[test]
2085    fn health_sarif_cyclomatic_crap_uses_crap_rule() {
2086        // Cyclomatic + CRAP both exceeded. The CRAP-centric rule subsumes
2087        // the cyclomatic breach; only one SARIF result is emitted.
2088        let root = PathBuf::from("/project");
2089        let report = crate::health_types::HealthReport {
2090            findings: vec![
2091                crate::health_types::ComplexityViolation {
2092                    path: root.join("src/hot.ts"),
2093                    name: "branchy".to_string(),
2094                    line: 1,
2095                    col: 0,
2096                    cyclomatic: 67,
2097                    cognitive: 12,
2098                    line_count: 80,
2099                    param_count: 1,
2100                    exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2101                    severity: crate::health_types::FindingSeverity::Critical,
2102                    crap: Some(182.0),
2103                    coverage_pct: None,
2104                    coverage_tier: None,
2105                    coverage_source: None,
2106                    inherited_from: None,
2107                    component_rollup: None,
2108                }
2109                .into(),
2110            ],
2111            summary: crate::health_types::HealthSummary {
2112                files_analyzed: 1,
2113                functions_analyzed: 1,
2114                functions_above_threshold: 1,
2115                ..Default::default()
2116            },
2117            ..Default::default()
2118        };
2119        let sarif = build_health_sarif(&report, &root);
2120        let results = sarif["runs"][0]["results"].as_array().unwrap();
2121        assert_eq!(
2122            results.len(),
2123            1,
2124            "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
2125        );
2126        assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
2127        let msg = results[0]["message"]["text"].as_str().unwrap();
2128        assert!(msg.contains("CRAP score 182"), "msg: {msg}");
2129        // coverage_pct absent => no coverage suffix
2130        assert!(!msg.contains("coverage"), "msg: {msg}");
2131    }
2132
2133    // ── Severity mapping ──
2134
2135    #[test]
2136    fn severity_to_sarif_level_error() {
2137        assert_eq!(severity_to_sarif_level(Severity::Error), "error");
2138    }
2139
2140    #[test]
2141    fn severity_to_sarif_level_warn() {
2142        assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
2143    }
2144
2145    #[test]
2146    #[should_panic(expected = "internal error: entered unreachable code")]
2147    fn severity_to_sarif_level_off() {
2148        let _ = severity_to_sarif_level(Severity::Off);
2149    }
2150
2151    // ── Re-export properties ──
2152
2153    #[test]
2154    fn sarif_re_export_has_properties() {
2155        let root = PathBuf::from("/project");
2156        let mut results = AnalysisResults::default();
2157        results
2158            .unused_exports
2159            .push(UnusedExportFinding::with_actions(UnusedExport {
2160                path: root.join("src/index.ts"),
2161                export_name: "reExported".to_string(),
2162                is_type_only: false,
2163                line: 1,
2164                col: 0,
2165                span_start: 0,
2166                is_re_export: true,
2167            }));
2168
2169        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2170        let entry = &sarif["runs"][0]["results"][0];
2171        assert_eq!(entry["properties"]["is_re_export"], true);
2172        let msg = entry["message"]["text"].as_str().unwrap();
2173        assert!(msg.starts_with("Re-export"));
2174    }
2175
2176    #[test]
2177    fn sarif_non_re_export_has_no_properties() {
2178        let root = PathBuf::from("/project");
2179        let mut results = AnalysisResults::default();
2180        results
2181            .unused_exports
2182            .push(UnusedExportFinding::with_actions(UnusedExport {
2183                path: root.join("src/utils.ts"),
2184                export_name: "foo".to_string(),
2185                is_type_only: false,
2186                line: 5,
2187                col: 0,
2188                span_start: 0,
2189                is_re_export: false,
2190            }));
2191
2192        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2193        let entry = &sarif["runs"][0]["results"][0];
2194        assert!(entry.get("properties").is_none());
2195        let msg = entry["message"]["text"].as_str().unwrap();
2196        assert!(msg.starts_with("Export"));
2197    }
2198
2199    // ── Type re-export ──
2200
2201    #[test]
2202    fn sarif_type_re_export_message() {
2203        let root = PathBuf::from("/project");
2204        let mut results = AnalysisResults::default();
2205        results
2206            .unused_types
2207            .push(UnusedTypeFinding::with_actions(UnusedExport {
2208                path: root.join("src/index.ts"),
2209                export_name: "MyType".to_string(),
2210                is_type_only: true,
2211                line: 1,
2212                col: 0,
2213                span_start: 0,
2214                is_re_export: true,
2215            }));
2216
2217        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2218        let entry = &sarif["runs"][0]["results"][0];
2219        assert_eq!(entry["ruleId"], "fallow/unused-type");
2220        let msg = entry["message"]["text"].as_str().unwrap();
2221        assert!(msg.starts_with("Type re-export"));
2222        assert_eq!(entry["properties"]["is_re_export"], true);
2223    }
2224
2225    // ── Dependency line == 0 skips region ──
2226
2227    #[test]
2228    fn sarif_dependency_line_zero_skips_region() {
2229        let root = PathBuf::from("/project");
2230        let mut results = AnalysisResults::default();
2231        results
2232            .unused_dependencies
2233            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2234                package_name: "lodash".to_string(),
2235                location: DependencyLocation::Dependencies,
2236                path: root.join("package.json"),
2237                line: 0,
2238                used_in_workspaces: Vec::new(),
2239            }));
2240
2241        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2242        let entry = &sarif["runs"][0]["results"][0];
2243        let phys = &entry["locations"][0]["physicalLocation"];
2244        assert!(phys.get("region").is_none());
2245    }
2246
2247    #[test]
2248    fn sarif_dependency_line_nonzero_has_region() {
2249        let root = PathBuf::from("/project");
2250        let mut results = AnalysisResults::default();
2251        results
2252            .unused_dependencies
2253            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2254                package_name: "lodash".to_string(),
2255                location: DependencyLocation::Dependencies,
2256                path: root.join("package.json"),
2257                line: 7,
2258                used_in_workspaces: Vec::new(),
2259            }));
2260
2261        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2262        let entry = &sarif["runs"][0]["results"][0];
2263        let region = &entry["locations"][0]["physicalLocation"]["region"];
2264        assert_eq!(region["startLine"], 7);
2265        assert_eq!(region["startColumn"], 1);
2266    }
2267
2268    // ── Type-only dependency line == 0 skips region ──
2269
2270    #[test]
2271    fn sarif_type_only_dep_line_zero_skips_region() {
2272        let root = PathBuf::from("/project");
2273        let mut results = AnalysisResults::default();
2274        results
2275            .type_only_dependencies
2276            .push(TypeOnlyDependencyFinding::with_actions(
2277                TypeOnlyDependency {
2278                    package_name: "zod".to_string(),
2279                    path: root.join("package.json"),
2280                    line: 0,
2281                },
2282            ));
2283
2284        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2285        let entry = &sarif["runs"][0]["results"][0];
2286        let phys = &entry["locations"][0]["physicalLocation"];
2287        assert!(phys.get("region").is_none());
2288    }
2289
2290    // ── Circular dependency line == 0 skips region ──
2291
2292    #[test]
2293    fn sarif_circular_dep_line_zero_skips_region() {
2294        let root = PathBuf::from("/project");
2295        let mut results = AnalysisResults::default();
2296        results
2297            .circular_dependencies
2298            .push(CircularDependencyFinding::with_actions(
2299                CircularDependency {
2300                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2301                    length: 2,
2302                    line: 0,
2303                    col: 0,
2304                    is_cross_package: false,
2305                },
2306            ));
2307
2308        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2309        let entry = &sarif["runs"][0]["results"][0];
2310        let phys = &entry["locations"][0]["physicalLocation"];
2311        assert!(phys.get("region").is_none());
2312    }
2313
2314    #[test]
2315    fn sarif_circular_dep_line_nonzero_has_region() {
2316        let root = PathBuf::from("/project");
2317        let mut results = AnalysisResults::default();
2318        results
2319            .circular_dependencies
2320            .push(CircularDependencyFinding::with_actions(
2321                CircularDependency {
2322                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2323                    length: 2,
2324                    line: 5,
2325                    col: 2,
2326                    is_cross_package: false,
2327                },
2328            ));
2329
2330        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2331        let entry = &sarif["runs"][0]["results"][0];
2332        let region = &entry["locations"][0]["physicalLocation"]["region"];
2333        assert_eq!(region["startLine"], 5);
2334        assert_eq!(region["startColumn"], 3);
2335    }
2336
2337    // ── Unused optional dependency ──
2338
2339    #[test]
2340    fn sarif_unused_optional_dependency_result() {
2341        let root = PathBuf::from("/project");
2342        let mut results = AnalysisResults::default();
2343        results
2344            .unused_optional_dependencies
2345            .push(UnusedOptionalDependencyFinding::with_actions(
2346                UnusedDependency {
2347                    package_name: "fsevents".to_string(),
2348                    location: DependencyLocation::OptionalDependencies,
2349                    path: root.join("package.json"),
2350                    line: 12,
2351                    used_in_workspaces: Vec::new(),
2352                },
2353            ));
2354
2355        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2356        let entry = &sarif["runs"][0]["results"][0];
2357        assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
2358        let msg = entry["message"]["text"].as_str().unwrap();
2359        assert!(msg.contains("optionalDependencies"));
2360    }
2361
2362    // ── Enum and class member SARIF messages ──
2363
2364    #[test]
2365    fn sarif_enum_member_message_format() {
2366        let root = PathBuf::from("/project");
2367        let mut results = AnalysisResults::default();
2368        results.unused_enum_members.push(
2369            fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
2370                path: root.join("src/enums.ts"),
2371                parent_name: "Color".to_string(),
2372                member_name: "Purple".to_string(),
2373                kind: fallow_core::extract::MemberKind::EnumMember,
2374                line: 5,
2375                col: 2,
2376            }),
2377        );
2378
2379        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2380        let entry = &sarif["runs"][0]["results"][0];
2381        assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
2382        let msg = entry["message"]["text"].as_str().unwrap();
2383        assert!(msg.contains("Enum member 'Color.Purple'"));
2384        let region = &entry["locations"][0]["physicalLocation"]["region"];
2385        assert_eq!(region["startColumn"], 3); // col 2 + 1
2386    }
2387
2388    #[test]
2389    fn sarif_class_member_message_format() {
2390        let root = PathBuf::from("/project");
2391        let mut results = AnalysisResults::default();
2392        results.unused_class_members.push(
2393            fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
2394                path: root.join("src/service.ts"),
2395                parent_name: "API".to_string(),
2396                member_name: "fetch".to_string(),
2397                kind: fallow_core::extract::MemberKind::ClassMethod,
2398                line: 10,
2399                col: 4,
2400            }),
2401        );
2402
2403        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2404        let entry = &sarif["runs"][0]["results"][0];
2405        assert_eq!(entry["ruleId"], "fallow/unused-class-member");
2406        let msg = entry["message"]["text"].as_str().unwrap();
2407        assert!(msg.contains("Class member 'API.fetch'"));
2408    }
2409
2410    // ── Duplication SARIF ──
2411
2412    #[test]
2413    #[expect(
2414        clippy::cast_possible_truncation,
2415        reason = "test line/col values are trivially small"
2416    )]
2417    fn duplication_sarif_structure() {
2418        use fallow_core::duplicates::*;
2419
2420        let root = PathBuf::from("/project");
2421        let report = DuplicationReport {
2422            clone_groups: vec![CloneGroup {
2423                instances: vec![
2424                    CloneInstance {
2425                        file: root.join("src/a.ts"),
2426                        start_line: 1,
2427                        end_line: 10,
2428                        start_col: 0,
2429                        end_col: 0,
2430                        fragment: String::new(),
2431                    },
2432                    CloneInstance {
2433                        file: root.join("src/b.ts"),
2434                        start_line: 5,
2435                        end_line: 14,
2436                        start_col: 2,
2437                        end_col: 0,
2438                        fragment: String::new(),
2439                    },
2440                ],
2441                token_count: 50,
2442                line_count: 10,
2443            }],
2444            clone_families: vec![],
2445            mirrored_directories: vec![],
2446            stats: DuplicationStats::default(),
2447        };
2448
2449        let sarif = serde_json::json!({
2450            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2451            "version": "2.1.0",
2452            "runs": [{
2453                "tool": {
2454                    "driver": {
2455                        "name": "fallow",
2456                        "version": env!("CARGO_PKG_VERSION"),
2457                        "informationUri": "https://github.com/fallow-rs/fallow",
2458                        "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2459                    }
2460                },
2461                "results": []
2462            }]
2463        });
2464        // Just verify the function doesn't panic and produces expected structure
2465        let _ = sarif;
2466
2467        // Test the actual build path through print_duplication_sarif internals
2468        let mut sarif_results = Vec::new();
2469        for (i, group) in report.clone_groups.iter().enumerate() {
2470            for instance in &group.instances {
2471                sarif_results.push(sarif_result(
2472                    "fallow/code-duplication",
2473                    "warning",
2474                    &format!(
2475                        "Code clone group {} ({} lines, {} instances)",
2476                        i + 1,
2477                        group.line_count,
2478                        group.instances.len()
2479                    ),
2480                    &super::super::relative_uri(&instance.file, &root),
2481                    Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2482                ));
2483            }
2484        }
2485        assert_eq!(sarif_results.len(), 2);
2486        assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
2487        assert!(
2488            sarif_results[0]["message"]["text"]
2489                .as_str()
2490                .unwrap()
2491                .contains("10 lines")
2492        );
2493        let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
2494        assert_eq!(region0["startLine"], 1);
2495        assert_eq!(region0["startColumn"], 1); // start_col 0 + 1
2496        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
2497        assert_eq!(region1["startLine"], 5);
2498        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
2499    }
2500
2501    // ── sarif_rule fallback (unknown rule ID) ──
2502
2503    #[test]
2504    fn sarif_rule_known_id_has_full_description() {
2505        let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
2506        assert!(rule.get("fullDescription").is_some());
2507        assert!(rule.get("helpUri").is_some());
2508    }
2509
2510    #[test]
2511    fn sarif_rule_unknown_id_uses_fallback() {
2512        let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
2513        assert_eq!(rule["shortDescription"]["text"], "fallback text");
2514        assert!(rule.get("fullDescription").is_none());
2515        assert!(rule.get("helpUri").is_none());
2516        assert_eq!(rule["defaultConfiguration"]["level"], "warning");
2517    }
2518
2519    // ── sarif_result without region ──
2520
2521    #[test]
2522    fn sarif_result_no_region_omits_region_key() {
2523        let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
2524        let phys = &result["locations"][0]["physicalLocation"];
2525        assert!(phys.get("region").is_none());
2526        assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
2527    }
2528
2529    #[test]
2530    fn sarif_result_with_region_includes_region() {
2531        let result = sarif_result(
2532            "rule/test",
2533            "error",
2534            "test msg",
2535            "src/file.ts",
2536            Some((10, 5)),
2537        );
2538        let region = &result["locations"][0]["physicalLocation"]["region"];
2539        assert_eq!(region["startLine"], 10);
2540        assert_eq!(region["startColumn"], 5);
2541    }
2542
2543    #[test]
2544    fn sarif_partial_fingerprint_ignores_rendered_message() {
2545        let a = sarif_result(
2546            "rule/test",
2547            "error",
2548            "first message",
2549            "src/file.ts",
2550            Some((10, 5)),
2551        );
2552        let b = sarif_result(
2553            "rule/test",
2554            "error",
2555            "rewritten message",
2556            "src/file.ts",
2557            Some((10, 5)),
2558        );
2559        assert_eq!(
2560            a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2561            b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2562        );
2563    }
2564
2565    // ── Health SARIF refactoring targets ──
2566
2567    #[test]
2568    fn health_sarif_includes_refactoring_targets() {
2569        use crate::health_types::*;
2570
2571        let root = PathBuf::from("/project");
2572        let report = HealthReport {
2573            summary: HealthSummary {
2574                files_analyzed: 10,
2575                functions_analyzed: 50,
2576                ..Default::default()
2577            },
2578            targets: vec![
2579                RefactoringTarget {
2580                    path: root.join("src/complex.ts"),
2581                    priority: 85.0,
2582                    efficiency: 42.5,
2583                    recommendation: "Split high-impact file".into(),
2584                    category: RecommendationCategory::SplitHighImpact,
2585                    effort: EffortEstimate::Medium,
2586                    confidence: Confidence::High,
2587                    factors: vec![],
2588                    evidence: None,
2589                }
2590                .into(),
2591            ],
2592            ..Default::default()
2593        };
2594
2595        let sarif = build_health_sarif(&report, &root);
2596        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2597        assert_eq!(entries.len(), 1);
2598        assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
2599        assert_eq!(entries[0]["level"], "warning");
2600        let msg = entries[0]["message"]["text"].as_str().unwrap();
2601        assert!(msg.contains("high impact"));
2602        assert!(msg.contains("Split high-impact file"));
2603        assert!(msg.contains("42.5"));
2604    }
2605
2606    #[test]
2607    fn health_sarif_includes_coverage_gaps() {
2608        use crate::health_types::*;
2609
2610        let root = PathBuf::from("/project");
2611        let report = HealthReport {
2612            summary: HealthSummary {
2613                files_analyzed: 10,
2614                functions_analyzed: 50,
2615                ..Default::default()
2616            },
2617            coverage_gaps: Some(CoverageGaps {
2618                summary: CoverageGapSummary {
2619                    runtime_files: 2,
2620                    covered_files: 0,
2621                    file_coverage_pct: 0.0,
2622                    untested_files: 1,
2623                    untested_exports: 1,
2624                },
2625                files: vec![UntestedFileFinding::with_actions(
2626                    UntestedFile {
2627                        path: root.join("src/app.ts"),
2628                        value_export_count: 2,
2629                    },
2630                    &root,
2631                )],
2632                exports: vec![UntestedExportFinding::with_actions(
2633                    UntestedExport {
2634                        path: root.join("src/app.ts"),
2635                        export_name: "loader".into(),
2636                        line: 12,
2637                        col: 4,
2638                    },
2639                    &root,
2640                )],
2641            }),
2642            ..Default::default()
2643        };
2644
2645        let sarif = build_health_sarif(&report, &root);
2646        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2647        assert_eq!(entries.len(), 2);
2648        assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
2649        assert_eq!(
2650            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2651            "src/app.ts"
2652        );
2653        assert!(
2654            entries[0]["message"]["text"]
2655                .as_str()
2656                .unwrap()
2657                .contains("2 value exports")
2658        );
2659        assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
2660        assert_eq!(
2661            entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
2662            12
2663        );
2664        assert_eq!(
2665            entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
2666            5
2667        );
2668    }
2669
2670    // ── Health SARIF rules include fullDescription from explain module ──
2671
2672    #[test]
2673    fn health_sarif_rules_have_full_descriptions() {
2674        let root = PathBuf::from("/project");
2675        let report = crate::health_types::HealthReport::default();
2676        let sarif = build_health_sarif(&report, &root);
2677        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2678            .as_array()
2679            .unwrap();
2680        for rule in rules {
2681            let id = rule["id"].as_str().unwrap();
2682            assert!(
2683                rule.get("fullDescription").is_some(),
2684                "health rule {id} should have fullDescription"
2685            );
2686            assert!(
2687                rule.get("helpUri").is_some(),
2688                "health rule {id} should have helpUri"
2689            );
2690        }
2691    }
2692
2693    // ── Warn severity propagates correctly ──
2694
2695    #[test]
2696    fn sarif_warn_severity_produces_warning_level() {
2697        let root = PathBuf::from("/project");
2698        let mut results = AnalysisResults::default();
2699        results
2700            .unused_files
2701            .push(UnusedFileFinding::with_actions(UnusedFile {
2702                path: root.join("src/dead.ts"),
2703            }));
2704
2705        let rules = RulesConfig {
2706            unused_files: Severity::Warn,
2707            ..RulesConfig::default()
2708        };
2709
2710        let sarif = build_sarif(&results, &root, &rules);
2711        let entry = &sarif["runs"][0]["results"][0];
2712        assert_eq!(entry["level"], "warning");
2713    }
2714
2715    // ── Unused file has no region ──
2716
2717    #[test]
2718    fn sarif_unused_file_has_no_region() {
2719        let root = PathBuf::from("/project");
2720        let mut results = AnalysisResults::default();
2721        results
2722            .unused_files
2723            .push(UnusedFileFinding::with_actions(UnusedFile {
2724                path: root.join("src/dead.ts"),
2725            }));
2726
2727        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2728        let entry = &sarif["runs"][0]["results"][0];
2729        let phys = &entry["locations"][0]["physicalLocation"];
2730        assert!(phys.get("region").is_none());
2731    }
2732
2733    // ── Multiple unlisted deps with multiple import sites ──
2734
2735    #[test]
2736    fn sarif_unlisted_dep_multiple_import_sites() {
2737        let root = PathBuf::from("/project");
2738        let mut results = AnalysisResults::default();
2739        results
2740            .unlisted_dependencies
2741            .push(UnlistedDependencyFinding::with_actions(
2742                UnlistedDependency {
2743                    package_name: "dotenv".to_string(),
2744                    imported_from: vec![
2745                        ImportSite {
2746                            path: root.join("src/a.ts"),
2747                            line: 1,
2748                            col: 0,
2749                        },
2750                        ImportSite {
2751                            path: root.join("src/b.ts"),
2752                            line: 5,
2753                            col: 0,
2754                        },
2755                    ],
2756                },
2757            ));
2758
2759        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2760        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2761        // One SARIF result per import site
2762        assert_eq!(entries.len(), 2);
2763        assert_eq!(
2764            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2765            "src/a.ts"
2766        );
2767        assert_eq!(
2768            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2769            "src/b.ts"
2770        );
2771    }
2772
2773    // ── Empty unlisted dep (no import sites) produces zero results ──
2774
2775    #[test]
2776    fn sarif_unlisted_dep_no_import_sites() {
2777        let root = PathBuf::from("/project");
2778        let mut results = AnalysisResults::default();
2779        results
2780            .unlisted_dependencies
2781            .push(UnlistedDependencyFinding::with_actions(
2782                UnlistedDependency {
2783                    package_name: "phantom".to_string(),
2784                    imported_from: vec![],
2785                },
2786            ));
2787
2788        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2789        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2790        // No import sites => no SARIF results for this unlisted dep
2791        assert!(entries.is_empty());
2792    }
2793}