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