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