Skip to main content

fallow_cli/report/
sarif.rs

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