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