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