Skip to main content

fallow_cli/report/
sarif.rs

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