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_health_sarif_results(report, root, &mut sarif_results, &mut snippets);
1426    let health_rules = health_sarif_rules();
1427    health_sarif_document(&sarif_results, &health_rules)
1428}
1429
1430fn append_health_sarif_results(
1431    report: &crate::health_types::HealthReport,
1432    root: &Path,
1433    sarif_results: &mut Vec<serde_json::Value>,
1434    snippets: &mut SourceSnippetCache,
1435) {
1436    append_complexity_sarif_results(sarif_results, report, root, snippets);
1437
1438    if let Some(ref production) = report.runtime_coverage {
1439        append_runtime_coverage_sarif_results(sarif_results, production, root, snippets);
1440    }
1441    if let Some(ref intelligence) = report.coverage_intelligence {
1442        append_coverage_intelligence_sarif_results(sarif_results, intelligence, root, snippets);
1443    }
1444
1445    append_refactoring_target_sarif_results(sarif_results, report, root);
1446    append_coverage_gap_sarif_results(sarif_results, report, root, snippets);
1447}
1448
1449fn health_sarif_rules() -> Vec<serde_json::Value> {
1450    vec![
1451        sarif_rule(
1452            "fallow/high-cyclomatic-complexity",
1453            "Function has high cyclomatic complexity",
1454            "note",
1455        ),
1456        sarif_rule(
1457            "fallow/high-cognitive-complexity",
1458            "Function has high cognitive complexity",
1459            "note",
1460        ),
1461        sarif_rule(
1462            "fallow/high-complexity",
1463            "Function exceeds both complexity thresholds",
1464            "note",
1465        ),
1466        sarif_rule(
1467            "fallow/high-crap-score",
1468            "Function has a high CRAP score (high complexity combined with low coverage)",
1469            "warning",
1470        ),
1471        sarif_rule(
1472            "fallow/refactoring-target",
1473            "File identified as a high-priority refactoring candidate",
1474            "warning",
1475        ),
1476        sarif_rule(
1477            "fallow/untested-file",
1478            "Runtime-reachable file has no test dependency path",
1479            "warning",
1480        ),
1481        sarif_rule(
1482            "fallow/untested-export",
1483            "Runtime-reachable export has no test dependency path",
1484            "warning",
1485        ),
1486        sarif_rule(
1487            "fallow/runtime-safe-to-delete",
1488            "Function is statically unused and was never invoked in production",
1489            "warning",
1490        ),
1491        sarif_rule(
1492            "fallow/runtime-review-required",
1493            "Function is statically used but was never invoked in production",
1494            "warning",
1495        ),
1496        sarif_rule(
1497            "fallow/runtime-low-traffic",
1498            "Function was invoked below the low-traffic threshold relative to total trace count",
1499            "note",
1500        ),
1501        sarif_rule(
1502            "fallow/runtime-coverage-unavailable",
1503            "Runtime coverage could not be resolved for this function",
1504            "note",
1505        ),
1506        sarif_rule(
1507            "fallow/runtime-coverage",
1508            "Runtime coverage finding",
1509            "note",
1510        ),
1511        sarif_rule(
1512            "fallow/coverage-intelligence-risky-change",
1513            "Changed hot path combines high CRAP and low test coverage",
1514            "warning",
1515        ),
1516        sarif_rule(
1517            "fallow/coverage-intelligence-delete",
1518            "Static and runtime evidence indicate code can be deleted",
1519            "warning",
1520        ),
1521        sarif_rule(
1522            "fallow/coverage-intelligence-review",
1523            "Cold reachable uncovered code needs owner review",
1524            "warning",
1525        ),
1526        sarif_rule(
1527            "fallow/coverage-intelligence-refactor",
1528            "Hot covered code has high CRAP and should be refactored carefully",
1529            "warning",
1530        ),
1531    ]
1532}
1533
1534fn health_sarif_document(
1535    sarif_results: &[serde_json::Value],
1536    health_rules: &[serde_json::Value],
1537) -> serde_json::Value {
1538    serde_json::json!({
1539        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1540        "version": "2.1.0",
1541        "runs": [{
1542            "tool": {
1543                "driver": {
1544                    "name": "fallow",
1545                    "version": env!("CARGO_PKG_VERSION"),
1546                    "informationUri": "https://github.com/fallow-rs/fallow",
1547                    "rules": health_rules
1548                }
1549            },
1550            "results": sarif_results
1551        }]
1552    })
1553}
1554
1555fn append_complexity_sarif_results(
1556    sarif_results: &mut Vec<serde_json::Value>,
1557    report: &crate::health_types::HealthReport,
1558    root: &Path,
1559    snippets: &mut SourceSnippetCache,
1560) {
1561    for finding in &report.findings {
1562        let uri = relative_uri(&finding.path, root);
1563        let (rule_id, message) = health_complexity_sarif_message(finding, report);
1564        let level = match finding.severity {
1565            crate::health_types::FindingSeverity::Critical => "error",
1566            crate::health_types::FindingSeverity::High => "warning",
1567            crate::health_types::FindingSeverity::Moderate => "note",
1568        };
1569        let source_snippet = snippets.line(&finding.path, finding.line);
1570        sarif_results.push(sarif_result_with_snippet(
1571            rule_id,
1572            level,
1573            &message,
1574            &uri,
1575            Some((finding.line, finding.col + 1)),
1576            source_snippet.as_deref(),
1577        ));
1578    }
1579}
1580
1581fn health_complexity_sarif_message(
1582    finding: &crate::health_types::ComplexityViolation,
1583    report: &crate::health_types::HealthReport,
1584) -> (&'static str, String) {
1585    match finding.exceeded {
1586        crate::health_types::ExceededThreshold::Cyclomatic => (
1587            "fallow/high-cyclomatic-complexity",
1588            format!(
1589                "'{}' has cyclomatic complexity {} (threshold: {})",
1590                finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
1591            ),
1592        ),
1593        crate::health_types::ExceededThreshold::Cognitive => (
1594            "fallow/high-cognitive-complexity",
1595            format!(
1596                "'{}' has cognitive complexity {} (threshold: {})",
1597                finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
1598            ),
1599        ),
1600        crate::health_types::ExceededThreshold::Both => (
1601            "fallow/high-complexity",
1602            format!(
1603                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
1604                finding.name,
1605                finding.cyclomatic,
1606                report.summary.max_cyclomatic_threshold,
1607                finding.cognitive,
1608                report.summary.max_cognitive_threshold,
1609            ),
1610        ),
1611        crate::health_types::ExceededThreshold::Crap
1612        | crate::health_types::ExceededThreshold::CyclomaticCrap
1613        | crate::health_types::ExceededThreshold::CognitiveCrap
1614        | crate::health_types::ExceededThreshold::All => {
1615            let crap = finding.crap.unwrap_or(0.0);
1616            let coverage = finding
1617                .coverage_pct
1618                .map(|pct| format!(", coverage {pct:.0}%"))
1619                .unwrap_or_default();
1620            (
1621                "fallow/high-crap-score",
1622                format!(
1623                    "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
1624                    finding.name,
1625                    crap,
1626                    report.summary.max_crap_threshold,
1627                    finding.cyclomatic,
1628                    coverage,
1629                ),
1630            )
1631        }
1632    }
1633}
1634
1635fn append_refactoring_target_sarif_results(
1636    sarif_results: &mut Vec<serde_json::Value>,
1637    report: &crate::health_types::HealthReport,
1638    root: &Path,
1639) {
1640    for target in &report.targets {
1641        let uri = relative_uri(&target.path, root);
1642        let message = format!(
1643            "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
1644            target.category.label(),
1645            target.recommendation,
1646            target.priority,
1647            target.efficiency,
1648            target.effort.label(),
1649            target.confidence.label(),
1650        );
1651        sarif_results.push(sarif_result(
1652            "fallow/refactoring-target",
1653            "warning",
1654            &message,
1655            &uri,
1656            None,
1657        ));
1658    }
1659}
1660
1661fn append_coverage_gap_sarif_results(
1662    sarif_results: &mut Vec<serde_json::Value>,
1663    report: &crate::health_types::HealthReport,
1664    root: &Path,
1665    snippets: &mut SourceSnippetCache,
1666) {
1667    let Some(ref gaps) = report.coverage_gaps else {
1668        return;
1669    };
1670    for item in &gaps.files {
1671        let uri = relative_uri(&item.file.path, root);
1672        let message = format!(
1673            "File is runtime-reachable but has no test dependency path ({} value export{})",
1674            item.file.value_export_count,
1675            if item.file.value_export_count == 1 {
1676                ""
1677            } else {
1678                "s"
1679            },
1680        );
1681        sarif_results.push(sarif_result(
1682            "fallow/untested-file",
1683            "warning",
1684            &message,
1685            &uri,
1686            None,
1687        ));
1688    }
1689
1690    for item in &gaps.exports {
1691        let uri = relative_uri(&item.export.path, root);
1692        let message = format!(
1693            "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1694            item.export.export_name
1695        );
1696        let source_snippet = snippets.line(&item.export.path, item.export.line);
1697        sarif_results.push(sarif_result_with_snippet(
1698            "fallow/untested-export",
1699            "warning",
1700            &message,
1701            &uri,
1702            Some((item.export.line, item.export.col + 1)),
1703            source_snippet.as_deref(),
1704        ));
1705    }
1706}
1707
1708fn append_runtime_coverage_sarif_results(
1709    sarif_results: &mut Vec<serde_json::Value>,
1710    production: &crate::health_types::RuntimeCoverageReport,
1711    root: &Path,
1712    snippets: &mut SourceSnippetCache,
1713) {
1714    for finding in &production.findings {
1715        let uri = relative_uri(&finding.path, root);
1716        let rule_id = match finding.verdict {
1717            crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1718                "fallow/runtime-safe-to-delete"
1719            }
1720            crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1721                "fallow/runtime-review-required"
1722            }
1723            crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
1724            crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1725                "fallow/runtime-coverage-unavailable"
1726            }
1727            crate::health_types::RuntimeCoverageVerdict::Active
1728            | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1729        };
1730        let level = match finding.verdict {
1731            crate::health_types::RuntimeCoverageVerdict::SafeToDelete
1732            | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
1733            _ => "note",
1734        };
1735        let invocations_hint = finding.invocations.map_or_else(
1736            || "untracked".to_owned(),
1737            |hits| format!("{hits} invocations"),
1738        );
1739        let message = format!(
1740            "'{}' runtime coverage verdict: {} ({})",
1741            finding.function,
1742            finding.verdict.human_label(),
1743            invocations_hint,
1744        );
1745        let source_snippet = snippets.line(&finding.path, finding.line);
1746        sarif_results.push(sarif_result_with_snippet(
1747            rule_id,
1748            level,
1749            &message,
1750            &uri,
1751            Some((finding.line, 1)),
1752            source_snippet.as_deref(),
1753        ));
1754    }
1755}
1756
1757fn append_coverage_intelligence_sarif_results(
1758    sarif_results: &mut Vec<serde_json::Value>,
1759    intelligence: &crate::health_types::CoverageIntelligenceReport,
1760    root: &Path,
1761    snippets: &mut SourceSnippetCache,
1762) {
1763    for finding in &intelligence.findings {
1764        let rule_id = coverage_intelligence_rule_id(finding.recommendation);
1765        let level = match finding.verdict {
1766            crate::health_types::CoverageIntelligenceVerdict::Clean
1767            | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
1768            _ => "warning",
1769        };
1770        let uri = relative_uri(&finding.path, root);
1771        let identity = finding.identity.as_deref().unwrap_or("code");
1772        let signals = finding
1773            .signals
1774            .iter()
1775            .map(ToString::to_string)
1776            .collect::<Vec<_>>()
1777            .join(", ");
1778        let message = format!(
1779            "'{}' coverage intelligence verdict: {} ({}, signals: {})",
1780            identity, finding.verdict, finding.recommendation, signals,
1781        );
1782        let source_snippet = snippets.line(&finding.path, finding.line);
1783        let mut result = sarif_result_with_snippet(
1784            rule_id,
1785            level,
1786            &message,
1787            &uri,
1788            Some((finding.line, 1)),
1789            source_snippet.as_deref(),
1790        );
1791        result["properties"] = serde_json::json!({
1792            "coverage_intelligence_id": &finding.id,
1793            "verdict": finding.verdict,
1794            "recommendation": finding.recommendation,
1795            "confidence": finding.confidence,
1796            "signals": &finding.signals,
1797            "related_ids": &finding.related_ids,
1798        });
1799        sarif_results.push(result);
1800    }
1801}
1802
1803fn coverage_intelligence_rule_id(
1804    recommendation: crate::health_types::CoverageIntelligenceRecommendation,
1805) -> &'static str {
1806    match recommendation {
1807        crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
1808            "fallow/coverage-intelligence-risky-change"
1809        }
1810        crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
1811            "fallow/coverage-intelligence-delete"
1812        }
1813        crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
1814            "fallow/coverage-intelligence-review"
1815        }
1816        crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
1817            "fallow/coverage-intelligence-refactor"
1818        }
1819    }
1820}
1821
1822pub(super) fn print_health_sarif(
1823    report: &crate::health_types::HealthReport,
1824    root: &Path,
1825) -> ExitCode {
1826    let sarif = build_health_sarif(report, root);
1827    emit_json(&sarif, "SARIF")
1828}
1829
1830/// Print health SARIF with a per-result `properties.group` tag.
1831///
1832/// Mirrors the dead-code grouped SARIF pattern (`print_grouped_sarif`):
1833/// build the standard SARIF first, then post-process each result to inject
1834/// the resolver-derived group key on `properties.group`. Consumers that read
1835/// SARIF (GitHub Code Scanning, GitLab Code Quality) can then partition
1836/// findings per team / package / directory without dropping out of the
1837/// SARIF pipeline. Each finding's URI is decoded (`%5B` -> `[`, `%5D` -> `]`)
1838/// before resolution, matching the dead-code behaviour for paths containing
1839/// brackets like Next.js dynamic routes.
1840#[expect(
1841    clippy::expect_used,
1842    reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
1843)]
1844pub(super) fn print_grouped_health_sarif(
1845    report: &crate::health_types::HealthReport,
1846    root: &Path,
1847    resolver: &OwnershipResolver,
1848) -> ExitCode {
1849    let mut sarif = build_health_sarif(report, root);
1850
1851    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1852        for run in runs {
1853            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1854                for result in results {
1855                    let uri = result
1856                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1857                        .and_then(|v| v.as_str())
1858                        .unwrap_or("");
1859                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1860                    let group =
1861                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1862                    let props = result
1863                        .as_object_mut()
1864                        .expect("SARIF result should be an object")
1865                        .entry("properties")
1866                        .or_insert_with(|| serde_json::json!({}));
1867                    props
1868                        .as_object_mut()
1869                        .expect("properties should be an object")
1870                        .insert("group".to_string(), serde_json::Value::String(group));
1871                }
1872            }
1873        }
1874    }
1875
1876    emit_json(&sarif, "SARIF")
1877}
1878
1879#[cfg(test)]
1880mod tests {
1881    use super::*;
1882    use crate::report::test_helpers::sample_results;
1883    use fallow_core::results::*;
1884    use std::path::PathBuf;
1885
1886    #[test]
1887    fn sarif_has_required_top_level_fields() {
1888        let root = PathBuf::from("/project");
1889        let results = AnalysisResults::default();
1890        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1891
1892        assert_eq!(
1893            sarif["$schema"],
1894            "https://json.schemastore.org/sarif-2.1.0.json"
1895        );
1896        assert_eq!(sarif["version"], "2.1.0");
1897        assert!(sarif["runs"].is_array());
1898    }
1899
1900    #[test]
1901    fn sarif_has_tool_driver_info() {
1902        let root = PathBuf::from("/project");
1903        let results = AnalysisResults::default();
1904        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1905
1906        let driver = &sarif["runs"][0]["tool"]["driver"];
1907        assert_eq!(driver["name"], "fallow");
1908        assert!(driver["version"].is_string());
1909        assert_eq!(
1910            driver["informationUri"],
1911            "https://github.com/fallow-rs/fallow"
1912        );
1913    }
1914
1915    #[test]
1916    fn sarif_declares_all_rules() {
1917        let root = PathBuf::from("/project");
1918        let results = AnalysisResults::default();
1919        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1920
1921        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1922            .as_array()
1923            .expect("rules should be an array");
1924        assert_eq!(rules.len(), 26);
1925
1926        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1927        assert!(rule_ids.contains(&"fallow/unused-file"));
1928        assert!(rule_ids.contains(&"fallow/unused-export"));
1929        assert!(rule_ids.contains(&"fallow/unused-type"));
1930        assert!(rule_ids.contains(&"fallow/private-type-leak"));
1931        assert!(rule_ids.contains(&"fallow/unused-dependency"));
1932        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1933        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1934        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1935        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1936        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1937        assert!(rule_ids.contains(&"fallow/unused-class-member"));
1938        assert!(rule_ids.contains(&"fallow/unresolved-import"));
1939        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1940        assert!(rule_ids.contains(&"fallow/duplicate-export"));
1941        assert!(rule_ids.contains(&"fallow/circular-dependency"));
1942        assert!(rule_ids.contains(&"fallow/re-export-cycle"));
1943        assert!(rule_ids.contains(&"fallow/boundary-violation"));
1944        assert!(rule_ids.contains(&"fallow/boundary-coverage"));
1945        assert!(rule_ids.contains(&"fallow/boundary-call-violation"));
1946        assert!(rule_ids.contains(&"fallow/policy-violation"));
1947        assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
1948        assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
1949        assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
1950        assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
1951        assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
1952    }
1953
1954    #[test]
1955    fn sarif_empty_results_no_results_entries() {
1956        let root = PathBuf::from("/project");
1957        let results = AnalysisResults::default();
1958        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1959
1960        let sarif_results = sarif["runs"][0]["results"]
1961            .as_array()
1962            .expect("results should be an array");
1963        assert!(sarif_results.is_empty());
1964    }
1965
1966    #[test]
1967    fn sarif_unused_file_result() {
1968        let root = PathBuf::from("/project");
1969        let mut results = AnalysisResults::default();
1970        results
1971            .unused_files
1972            .push(UnusedFileFinding::with_actions(UnusedFile {
1973                path: root.join("src/dead.ts"),
1974            }));
1975
1976        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1977        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1978        assert_eq!(entries.len(), 1);
1979
1980        let entry = &entries[0];
1981        assert_eq!(entry["ruleId"], "fallow/unused-file");
1982        assert_eq!(entry["level"], "error");
1983        assert_eq!(
1984            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1985            "src/dead.ts"
1986        );
1987    }
1988
1989    #[test]
1990    fn sarif_unused_export_includes_region() {
1991        let root = PathBuf::from("/project");
1992        let mut results = AnalysisResults::default();
1993        results
1994            .unused_exports
1995            .push(UnusedExportFinding::with_actions(UnusedExport {
1996                path: root.join("src/utils.ts"),
1997                export_name: "helperFn".to_string(),
1998                is_type_only: false,
1999                line: 10,
2000                col: 4,
2001                span_start: 120,
2002                is_re_export: false,
2003            }));
2004
2005        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2006        let entry = &sarif["runs"][0]["results"][0];
2007        assert_eq!(entry["ruleId"], "fallow/unused-export");
2008
2009        let region = &entry["locations"][0]["physicalLocation"]["region"];
2010        assert_eq!(region["startLine"], 10);
2011        assert_eq!(region["startColumn"], 5);
2012    }
2013
2014    #[test]
2015    fn sarif_unresolved_import_is_error_level() {
2016        let root = PathBuf::from("/project");
2017        let mut results = AnalysisResults::default();
2018        results
2019            .unresolved_imports
2020            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2021                path: root.join("src/app.ts"),
2022                specifier: "./missing".to_string(),
2023                line: 1,
2024                col: 0,
2025                specifier_col: 0,
2026            }));
2027
2028        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2029        let entry = &sarif["runs"][0]["results"][0];
2030        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
2031        assert_eq!(entry["level"], "error");
2032    }
2033
2034    #[test]
2035    fn sarif_unlisted_dependency_points_to_import_site() {
2036        let root = PathBuf::from("/project");
2037        let mut results = AnalysisResults::default();
2038        results
2039            .unlisted_dependencies
2040            .push(UnlistedDependencyFinding::with_actions(
2041                UnlistedDependency {
2042                    package_name: "chalk".to_string(),
2043                    imported_from: vec![ImportSite {
2044                        path: root.join("src/cli.ts"),
2045                        line: 3,
2046                        col: 0,
2047                    }],
2048                },
2049            ));
2050
2051        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2052        let entry = &sarif["runs"][0]["results"][0];
2053        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
2054        assert_eq!(entry["level"], "error");
2055        assert_eq!(
2056            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2057            "src/cli.ts"
2058        );
2059        let region = &entry["locations"][0]["physicalLocation"]["region"];
2060        assert_eq!(region["startLine"], 3);
2061        assert_eq!(region["startColumn"], 1);
2062    }
2063
2064    #[test]
2065    fn sarif_dependency_issues_point_to_package_json() {
2066        let root = PathBuf::from("/project");
2067        let mut results = AnalysisResults::default();
2068        results
2069            .unused_dependencies
2070            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2071                package_name: "lodash".to_string(),
2072                location: DependencyLocation::Dependencies,
2073                path: root.join("package.json"),
2074                line: 5,
2075                used_in_workspaces: Vec::new(),
2076            }));
2077        results
2078            .unused_dev_dependencies
2079            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2080                package_name: "jest".to_string(),
2081                location: DependencyLocation::DevDependencies,
2082                path: root.join("package.json"),
2083                line: 5,
2084                used_in_workspaces: Vec::new(),
2085            }));
2086
2087        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2088        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2089        for entry in entries {
2090            assert_eq!(
2091                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2092                "package.json"
2093            );
2094        }
2095    }
2096
2097    #[test]
2098    fn sarif_duplicate_export_emits_one_result_per_location() {
2099        let root = PathBuf::from("/project");
2100        let mut results = AnalysisResults::default();
2101        results
2102            .duplicate_exports
2103            .push(DuplicateExportFinding::with_actions(DuplicateExport {
2104                export_name: "Config".to_string(),
2105                locations: vec![
2106                    DuplicateLocation {
2107                        path: root.join("src/a.ts"),
2108                        line: 15,
2109                        col: 0,
2110                    },
2111                    DuplicateLocation {
2112                        path: root.join("src/b.ts"),
2113                        line: 30,
2114                        col: 0,
2115                    },
2116                ],
2117            }));
2118
2119        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2120        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2121        assert_eq!(entries.len(), 2);
2122        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
2123        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
2124        assert_eq!(
2125            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2126            "src/a.ts"
2127        );
2128        assert_eq!(
2129            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2130            "src/b.ts"
2131        );
2132    }
2133
2134    #[test]
2135    fn sarif_all_issue_types_produce_results() {
2136        let root = PathBuf::from("/project");
2137        let results = sample_results(&root);
2138        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2139
2140        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2141        assert_eq!(entries.len(), results.total_issues() + 1);
2142
2143        let rule_ids: Vec<&str> = entries
2144            .iter()
2145            .map(|e| e["ruleId"].as_str().unwrap())
2146            .collect();
2147        assert!(rule_ids.contains(&"fallow/unused-file"));
2148        assert!(rule_ids.contains(&"fallow/unused-export"));
2149        assert!(rule_ids.contains(&"fallow/unused-type"));
2150        assert!(rule_ids.contains(&"fallow/unused-dependency"));
2151        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2152        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2153        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2154        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2155        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2156        assert!(rule_ids.contains(&"fallow/unused-class-member"));
2157        assert!(rule_ids.contains(&"fallow/unresolved-import"));
2158        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2159        assert!(rule_ids.contains(&"fallow/duplicate-export"));
2160    }
2161
2162    #[test]
2163    fn sarif_serializes_to_valid_json() {
2164        let root = PathBuf::from("/project");
2165        let results = sample_results(&root);
2166        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2167
2168        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2169        let reparsed: serde_json::Value =
2170            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
2171        assert_eq!(reparsed, sarif);
2172    }
2173
2174    #[test]
2175    fn sarif_file_write_produces_valid_sarif() {
2176        let root = PathBuf::from("/project");
2177        let results = sample_results(&root);
2178        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2179        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2180
2181        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
2182        let _ = std::fs::create_dir_all(&dir);
2183        let sarif_path = dir.join("results.sarif");
2184        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
2185
2186        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
2187        let parsed: serde_json::Value =
2188            serde_json::from_str(&contents).expect("file should contain valid JSON");
2189
2190        assert_eq!(parsed["version"], "2.1.0");
2191        assert_eq!(
2192            parsed["$schema"],
2193            "https://json.schemastore.org/sarif-2.1.0.json"
2194        );
2195        let sarif_results = parsed["runs"][0]["results"]
2196            .as_array()
2197            .expect("results should be an array");
2198        assert!(!sarif_results.is_empty());
2199
2200        let _ = std::fs::remove_file(&sarif_path);
2201        let _ = std::fs::remove_dir(&dir);
2202    }
2203
2204    #[test]
2205    fn health_sarif_empty_no_results() {
2206        let root = PathBuf::from("/project");
2207        let report = crate::health_types::HealthReport {
2208            summary: crate::health_types::HealthSummary {
2209                files_analyzed: 10,
2210                functions_analyzed: 50,
2211                ..Default::default()
2212            },
2213            ..Default::default()
2214        };
2215        let sarif = build_health_sarif(&report, &root);
2216        assert_eq!(sarif["version"], "2.1.0");
2217        let results = sarif["runs"][0]["results"].as_array().unwrap();
2218        assert!(results.is_empty());
2219        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2220            .as_array()
2221            .unwrap();
2222        assert_eq!(rules.len(), 16);
2223    }
2224
2225    #[test]
2226    fn health_sarif_coverage_intelligence_preserves_structured_properties() {
2227        use crate::health_types::{
2228            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2229            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2230            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2231            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2232            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2233            HealthReport, HealthSummary,
2234        };
2235
2236        let root = PathBuf::from("/project");
2237        let report = HealthReport {
2238            summary: HealthSummary {
2239                files_analyzed: 10,
2240                functions_analyzed: 50,
2241                ..Default::default()
2242            },
2243            coverage_intelligence: Some(CoverageIntelligenceReport {
2244                schema_version: CoverageIntelligenceSchemaVersion::V1,
2245                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2246                summary: CoverageIntelligenceSummary {
2247                    findings: 1,
2248                    high_confidence_deletes: 1,
2249                    ..Default::default()
2250                },
2251                findings: vec![CoverageIntelligenceFinding {
2252                    id: "fallow:coverage-intel:abc123".to_owned(),
2253                    path: root.join("src/dead.ts"),
2254                    identity: Some("deadPath".to_owned()),
2255                    line: 9,
2256                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2257                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2258                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2259                    confidence: CoverageIntelligenceConfidence::High,
2260                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2261                    evidence: CoverageIntelligenceEvidence {
2262                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2263                        ..Default::default()
2264                    },
2265                    actions: vec![CoverageIntelligenceAction {
2266                        kind: "delete-after-confirming-owner".to_owned(),
2267                        description: "Confirm ownership".to_owned(),
2268                        auto_fixable: false,
2269                    }],
2270                }],
2271            }),
2272            ..Default::default()
2273        };
2274
2275        let sarif = build_health_sarif(&report, &root);
2276        let result = &sarif["runs"][0]["results"][0];
2277        assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
2278        assert_eq!(
2279            result["properties"]["coverage_intelligence_id"],
2280            "fallow:coverage-intel:abc123"
2281        );
2282        assert_eq!(
2283            result["properties"]["recommendation"],
2284            "delete-after-confirming-owner"
2285        );
2286        assert_eq!(result["properties"]["confidence"], "high");
2287        assert_eq!(result["properties"]["signals"][0], "runtime_cold");
2288        assert_eq!(
2289            result["properties"]["related_ids"][0],
2290            "fallow:prod:deadbeef"
2291        );
2292    }
2293
2294    #[test]
2295    fn health_sarif_cyclomatic_only() {
2296        let root = PathBuf::from("/project");
2297        let report = crate::health_types::HealthReport {
2298            findings: vec![
2299                crate::health_types::ComplexityViolation {
2300                    path: root.join("src/utils.ts"),
2301                    name: "parseExpression".to_string(),
2302                    line: 42,
2303                    col: 0,
2304                    cyclomatic: 25,
2305                    cognitive: 10,
2306                    line_count: 80,
2307                    param_count: 0,
2308                    exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
2309                    severity: crate::health_types::FindingSeverity::High,
2310                    crap: None,
2311                    coverage_pct: None,
2312                    coverage_tier: None,
2313                    coverage_source: None,
2314                    inherited_from: None,
2315                    component_rollup: None,
2316                    contributions: Vec::new(),
2317                    effective_thresholds: None,
2318                    threshold_source: None,
2319                }
2320                .into(),
2321            ],
2322            summary: crate::health_types::HealthSummary {
2323                files_analyzed: 5,
2324                functions_analyzed: 20,
2325                functions_above_threshold: 1,
2326                ..Default::default()
2327            },
2328            ..Default::default()
2329        };
2330        let sarif = build_health_sarif(&report, &root);
2331        let entry = &sarif["runs"][0]["results"][0];
2332        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
2333        assert_eq!(entry["level"], "warning");
2334        assert!(
2335            entry["message"]["text"]
2336                .as_str()
2337                .unwrap()
2338                .contains("cyclomatic complexity 25")
2339        );
2340        assert_eq!(
2341            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2342            "src/utils.ts"
2343        );
2344        let region = &entry["locations"][0]["physicalLocation"]["region"];
2345        assert_eq!(region["startLine"], 42);
2346        assert_eq!(region["startColumn"], 1);
2347    }
2348
2349    #[test]
2350    fn health_sarif_cognitive_only() {
2351        let root = PathBuf::from("/project");
2352        let report = crate::health_types::HealthReport {
2353            findings: vec![
2354                crate::health_types::ComplexityViolation {
2355                    path: root.join("src/api.ts"),
2356                    name: "handleRequest".to_string(),
2357                    line: 10,
2358                    col: 4,
2359                    cyclomatic: 8,
2360                    cognitive: 20,
2361                    line_count: 40,
2362                    param_count: 0,
2363                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
2364                    severity: crate::health_types::FindingSeverity::High,
2365                    crap: None,
2366                    coverage_pct: None,
2367                    coverage_tier: None,
2368                    coverage_source: None,
2369                    inherited_from: None,
2370                    component_rollup: None,
2371                    contributions: Vec::new(),
2372                    effective_thresholds: None,
2373                    threshold_source: None,
2374                }
2375                .into(),
2376            ],
2377            summary: crate::health_types::HealthSummary {
2378                files_analyzed: 3,
2379                functions_analyzed: 10,
2380                functions_above_threshold: 1,
2381                ..Default::default()
2382            },
2383            ..Default::default()
2384        };
2385        let sarif = build_health_sarif(&report, &root);
2386        let entry = &sarif["runs"][0]["results"][0];
2387        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
2388        assert!(
2389            entry["message"]["text"]
2390                .as_str()
2391                .unwrap()
2392                .contains("cognitive complexity 20")
2393        );
2394        let region = &entry["locations"][0]["physicalLocation"]["region"];
2395        assert_eq!(region["startColumn"], 5); // col 4 + 1
2396    }
2397
2398    #[test]
2399    fn health_sarif_both_thresholds() {
2400        let root = PathBuf::from("/project");
2401        let report = crate::health_types::HealthReport {
2402            findings: vec![
2403                crate::health_types::ComplexityViolation {
2404                    path: root.join("src/complex.ts"),
2405                    name: "doEverything".to_string(),
2406                    line: 1,
2407                    col: 0,
2408                    cyclomatic: 30,
2409                    cognitive: 45,
2410                    line_count: 100,
2411                    param_count: 0,
2412                    exceeded: crate::health_types::ExceededThreshold::Both,
2413                    severity: crate::health_types::FindingSeverity::High,
2414                    crap: None,
2415                    coverage_pct: None,
2416                    coverage_tier: None,
2417                    coverage_source: None,
2418                    inherited_from: None,
2419                    component_rollup: None,
2420                    contributions: Vec::new(),
2421                    effective_thresholds: None,
2422                    threshold_source: None,
2423                }
2424                .into(),
2425            ],
2426            summary: crate::health_types::HealthSummary {
2427                files_analyzed: 1,
2428                functions_analyzed: 1,
2429                functions_above_threshold: 1,
2430                ..Default::default()
2431            },
2432            ..Default::default()
2433        };
2434        let sarif = build_health_sarif(&report, &root);
2435        let entry = &sarif["runs"][0]["results"][0];
2436        assert_eq!(entry["ruleId"], "fallow/high-complexity");
2437        let msg = entry["message"]["text"].as_str().unwrap();
2438        assert!(msg.contains("cyclomatic complexity 30"));
2439        assert!(msg.contains("cognitive complexity 45"));
2440    }
2441
2442    #[test]
2443    fn health_sarif_crap_only_emits_crap_rule() {
2444        let root = PathBuf::from("/project");
2445        let report = crate::health_types::HealthReport {
2446            findings: vec![
2447                crate::health_types::ComplexityViolation {
2448                    path: root.join("src/untested.ts"),
2449                    name: "risky".to_string(),
2450                    line: 8,
2451                    col: 0,
2452                    cyclomatic: 10,
2453                    cognitive: 10,
2454                    line_count: 20,
2455                    param_count: 1,
2456                    exceeded: crate::health_types::ExceededThreshold::Crap,
2457                    severity: crate::health_types::FindingSeverity::High,
2458                    crap: Some(82.2),
2459                    coverage_pct: Some(12.0),
2460                    coverage_tier: None,
2461                    coverage_source: None,
2462                    inherited_from: None,
2463                    component_rollup: None,
2464                    contributions: Vec::new(),
2465                    effective_thresholds: None,
2466                    threshold_source: None,
2467                }
2468                .into(),
2469            ],
2470            summary: crate::health_types::HealthSummary {
2471                files_analyzed: 1,
2472                functions_analyzed: 1,
2473                functions_above_threshold: 1,
2474                ..Default::default()
2475            },
2476            ..Default::default()
2477        };
2478        let sarif = build_health_sarif(&report, &root);
2479        let entry = &sarif["runs"][0]["results"][0];
2480        assert_eq!(entry["ruleId"], "fallow/high-crap-score");
2481        let msg = entry["message"]["text"].as_str().unwrap();
2482        assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
2483        assert!(msg.contains("coverage 12%"), "msg: {msg}");
2484    }
2485
2486    #[test]
2487    fn health_sarif_cyclomatic_crap_uses_crap_rule() {
2488        let root = PathBuf::from("/project");
2489        let report = crate::health_types::HealthReport {
2490            findings: vec![
2491                crate::health_types::ComplexityViolation {
2492                    path: root.join("src/hot.ts"),
2493                    name: "branchy".to_string(),
2494                    line: 1,
2495                    col: 0,
2496                    cyclomatic: 67,
2497                    cognitive: 12,
2498                    line_count: 80,
2499                    param_count: 1,
2500                    exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2501                    severity: crate::health_types::FindingSeverity::Critical,
2502                    crap: Some(182.0),
2503                    coverage_pct: None,
2504                    coverage_tier: None,
2505                    coverage_source: None,
2506                    inherited_from: None,
2507                    component_rollup: None,
2508                    contributions: Vec::new(),
2509                    effective_thresholds: None,
2510                    threshold_source: None,
2511                }
2512                .into(),
2513            ],
2514            summary: crate::health_types::HealthSummary {
2515                files_analyzed: 1,
2516                functions_analyzed: 1,
2517                functions_above_threshold: 1,
2518                ..Default::default()
2519            },
2520            ..Default::default()
2521        };
2522        let sarif = build_health_sarif(&report, &root);
2523        let results = sarif["runs"][0]["results"].as_array().unwrap();
2524        assert_eq!(
2525            results.len(),
2526            1,
2527            "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
2528        );
2529        assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
2530        let msg = results[0]["message"]["text"].as_str().unwrap();
2531        assert!(msg.contains("CRAP score 182"), "msg: {msg}");
2532        assert!(!msg.contains("coverage"), "msg: {msg}");
2533    }
2534
2535    #[test]
2536    fn severity_to_sarif_level_error() {
2537        assert_eq!(severity_to_sarif_level(Severity::Error), "error");
2538    }
2539
2540    #[test]
2541    fn severity_to_sarif_level_warn() {
2542        assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
2543    }
2544
2545    #[test]
2546    #[should_panic(expected = "internal error: entered unreachable code")]
2547    fn severity_to_sarif_level_off() {
2548        let _ = severity_to_sarif_level(Severity::Off);
2549    }
2550
2551    #[test]
2552    fn sarif_re_export_has_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/index.ts"),
2559                export_name: "reExported".to_string(),
2560                is_type_only: false,
2561                line: 1,
2562                col: 0,
2563                span_start: 0,
2564                is_re_export: true,
2565            }));
2566
2567        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2568        let entry = &sarif["runs"][0]["results"][0];
2569        assert_eq!(entry["properties"]["is_re_export"], true);
2570        let msg = entry["message"]["text"].as_str().unwrap();
2571        assert!(msg.starts_with("Re-export"));
2572    }
2573
2574    #[test]
2575    fn sarif_non_re_export_has_no_properties() {
2576        let root = PathBuf::from("/project");
2577        let mut results = AnalysisResults::default();
2578        results
2579            .unused_exports
2580            .push(UnusedExportFinding::with_actions(UnusedExport {
2581                path: root.join("src/utils.ts"),
2582                export_name: "foo".to_string(),
2583                is_type_only: false,
2584                line: 5,
2585                col: 0,
2586                span_start: 0,
2587                is_re_export: false,
2588            }));
2589
2590        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2591        let entry = &sarif["runs"][0]["results"][0];
2592        assert!(entry.get("properties").is_none());
2593        let msg = entry["message"]["text"].as_str().unwrap();
2594        assert!(msg.starts_with("Export"));
2595    }
2596
2597    #[test]
2598    fn sarif_type_re_export_message() {
2599        let root = PathBuf::from("/project");
2600        let mut results = AnalysisResults::default();
2601        results
2602            .unused_types
2603            .push(UnusedTypeFinding::with_actions(UnusedExport {
2604                path: root.join("src/index.ts"),
2605                export_name: "MyType".to_string(),
2606                is_type_only: true,
2607                line: 1,
2608                col: 0,
2609                span_start: 0,
2610                is_re_export: true,
2611            }));
2612
2613        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2614        let entry = &sarif["runs"][0]["results"][0];
2615        assert_eq!(entry["ruleId"], "fallow/unused-type");
2616        let msg = entry["message"]["text"].as_str().unwrap();
2617        assert!(msg.starts_with("Type re-export"));
2618        assert_eq!(entry["properties"]["is_re_export"], true);
2619    }
2620
2621    #[test]
2622    fn sarif_dependency_line_zero_skips_region() {
2623        let root = PathBuf::from("/project");
2624        let mut results = AnalysisResults::default();
2625        results
2626            .unused_dependencies
2627            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2628                package_name: "lodash".to_string(),
2629                location: DependencyLocation::Dependencies,
2630                path: root.join("package.json"),
2631                line: 0,
2632                used_in_workspaces: Vec::new(),
2633            }));
2634
2635        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2636        let entry = &sarif["runs"][0]["results"][0];
2637        let phys = &entry["locations"][0]["physicalLocation"];
2638        assert!(phys.get("region").is_none());
2639    }
2640
2641    #[test]
2642    fn sarif_dependency_line_nonzero_has_region() {
2643        let root = PathBuf::from("/project");
2644        let mut results = AnalysisResults::default();
2645        results
2646            .unused_dependencies
2647            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2648                package_name: "lodash".to_string(),
2649                location: DependencyLocation::Dependencies,
2650                path: root.join("package.json"),
2651                line: 7,
2652                used_in_workspaces: Vec::new(),
2653            }));
2654
2655        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2656        let entry = &sarif["runs"][0]["results"][0];
2657        let region = &entry["locations"][0]["physicalLocation"]["region"];
2658        assert_eq!(region["startLine"], 7);
2659        assert_eq!(region["startColumn"], 1);
2660    }
2661
2662    #[test]
2663    fn sarif_type_only_dep_line_zero_skips_region() {
2664        let root = PathBuf::from("/project");
2665        let mut results = AnalysisResults::default();
2666        results
2667            .type_only_dependencies
2668            .push(TypeOnlyDependencyFinding::with_actions(
2669                TypeOnlyDependency {
2670                    package_name: "zod".to_string(),
2671                    path: root.join("package.json"),
2672                    line: 0,
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_zero_skips_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: 0,
2693                    col: 0,
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 phys = &entry["locations"][0]["physicalLocation"];
2702        assert!(phys.get("region").is_none());
2703    }
2704
2705    #[test]
2706    fn sarif_circular_dep_line_nonzero_has_region() {
2707        let root = PathBuf::from("/project");
2708        let mut results = AnalysisResults::default();
2709        results
2710            .circular_dependencies
2711            .push(CircularDependencyFinding::with_actions(
2712                CircularDependency {
2713                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2714                    length: 2,
2715                    line: 5,
2716                    col: 2,
2717                    edges: Vec::new(),
2718                    is_cross_package: false,
2719                },
2720            ));
2721
2722        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2723        let entry = &sarif["runs"][0]["results"][0];
2724        let region = &entry["locations"][0]["physicalLocation"]["region"];
2725        assert_eq!(region["startLine"], 5);
2726        assert_eq!(region["startColumn"], 3);
2727    }
2728
2729    #[test]
2730    fn sarif_unused_optional_dependency_result() {
2731        let root = PathBuf::from("/project");
2732        let mut results = AnalysisResults::default();
2733        results
2734            .unused_optional_dependencies
2735            .push(UnusedOptionalDependencyFinding::with_actions(
2736                UnusedDependency {
2737                    package_name: "fsevents".to_string(),
2738                    location: DependencyLocation::OptionalDependencies,
2739                    path: root.join("package.json"),
2740                    line: 12,
2741                    used_in_workspaces: Vec::new(),
2742                },
2743            ));
2744
2745        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2746        let entry = &sarif["runs"][0]["results"][0];
2747        assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
2748        let msg = entry["message"]["text"].as_str().unwrap();
2749        assert!(msg.contains("optionalDependencies"));
2750    }
2751
2752    #[test]
2753    fn sarif_enum_member_message_format() {
2754        let root = PathBuf::from("/project");
2755        let mut results = AnalysisResults::default();
2756        results.unused_enum_members.push(
2757            fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
2758                path: root.join("src/enums.ts"),
2759                parent_name: "Color".to_string(),
2760                member_name: "Purple".to_string(),
2761                kind: fallow_core::extract::MemberKind::EnumMember,
2762                line: 5,
2763                col: 2,
2764            }),
2765        );
2766
2767        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2768        let entry = &sarif["runs"][0]["results"][0];
2769        assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
2770        let msg = entry["message"]["text"].as_str().unwrap();
2771        assert!(msg.contains("Enum member 'Color.Purple'"));
2772        let region = &entry["locations"][0]["physicalLocation"]["region"];
2773        assert_eq!(region["startColumn"], 3); // col 2 + 1
2774    }
2775
2776    #[test]
2777    fn sarif_class_member_message_format() {
2778        let root = PathBuf::from("/project");
2779        let mut results = AnalysisResults::default();
2780        results.unused_class_members.push(
2781            fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
2782                path: root.join("src/service.ts"),
2783                parent_name: "API".to_string(),
2784                member_name: "fetch".to_string(),
2785                kind: fallow_core::extract::MemberKind::ClassMethod,
2786                line: 10,
2787                col: 4,
2788            }),
2789        );
2790
2791        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2792        let entry = &sarif["runs"][0]["results"][0];
2793        assert_eq!(entry["ruleId"], "fallow/unused-class-member");
2794        let msg = entry["message"]["text"].as_str().unwrap();
2795        assert!(msg.contains("Class member 'API.fetch'"));
2796    }
2797
2798    #[test]
2799    #[expect(
2800        clippy::cast_possible_truncation,
2801        reason = "test line/col values are trivially small"
2802    )]
2803    fn duplication_sarif_structure() {
2804        use fallow_core::duplicates::*;
2805
2806        let root = PathBuf::from("/project");
2807        let report = DuplicationReport {
2808            clone_groups: vec![CloneGroup {
2809                instances: vec![
2810                    CloneInstance {
2811                        file: root.join("src/a.ts"),
2812                        start_line: 1,
2813                        end_line: 10,
2814                        start_col: 0,
2815                        end_col: 0,
2816                        fragment: String::new(),
2817                    },
2818                    CloneInstance {
2819                        file: root.join("src/b.ts"),
2820                        start_line: 5,
2821                        end_line: 14,
2822                        start_col: 2,
2823                        end_col: 0,
2824                        fragment: String::new(),
2825                    },
2826                ],
2827                token_count: 50,
2828                line_count: 10,
2829            }],
2830            clone_families: vec![],
2831            mirrored_directories: vec![],
2832            stats: DuplicationStats::default(),
2833        };
2834
2835        let sarif = serde_json::json!({
2836            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2837            "version": "2.1.0",
2838            "runs": [{
2839                "tool": {
2840                    "driver": {
2841                        "name": "fallow",
2842                        "version": env!("CARGO_PKG_VERSION"),
2843                        "informationUri": "https://github.com/fallow-rs/fallow",
2844                        "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2845                    }
2846                },
2847                "results": []
2848            }]
2849        });
2850        let _ = sarif;
2851
2852        let mut sarif_results = Vec::new();
2853        for (i, group) in report.clone_groups.iter().enumerate() {
2854            for instance in &group.instances {
2855                sarif_results.push(sarif_result(
2856                    "fallow/code-duplication",
2857                    "warning",
2858                    &format!(
2859                        "Code clone group {} ({} lines, {} instances)",
2860                        i + 1,
2861                        group.line_count,
2862                        group.instances.len()
2863                    ),
2864                    &super::super::relative_uri(&instance.file, &root),
2865                    Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2866                ));
2867            }
2868        }
2869        assert_eq!(sarif_results.len(), 2);
2870        assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
2871        assert!(
2872            sarif_results[0]["message"]["text"]
2873                .as_str()
2874                .unwrap()
2875                .contains("10 lines")
2876        );
2877        let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
2878        assert_eq!(region0["startLine"], 1);
2879        assert_eq!(region0["startColumn"], 1); // start_col 0 + 1
2880        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
2881        assert_eq!(region1["startLine"], 5);
2882        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
2883    }
2884
2885    #[test]
2886    fn sarif_rule_known_id_has_full_description() {
2887        let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
2888        assert!(rule.get("fullDescription").is_some());
2889        assert!(rule.get("helpUri").is_some());
2890    }
2891
2892    #[test]
2893    fn sarif_rule_unknown_id_uses_fallback() {
2894        let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
2895        assert_eq!(rule["shortDescription"]["text"], "fallback text");
2896        assert!(rule.get("fullDescription").is_none());
2897        assert!(rule.get("helpUri").is_none());
2898        assert_eq!(rule["defaultConfiguration"]["level"], "warning");
2899    }
2900
2901    #[test]
2902    fn sarif_result_no_region_omits_region_key() {
2903        let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
2904        let phys = &result["locations"][0]["physicalLocation"];
2905        assert!(phys.get("region").is_none());
2906        assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
2907    }
2908
2909    #[test]
2910    fn sarif_result_with_region_includes_region() {
2911        let result = sarif_result(
2912            "rule/test",
2913            "error",
2914            "test msg",
2915            "src/file.ts",
2916            Some((10, 5)),
2917        );
2918        let region = &result["locations"][0]["physicalLocation"]["region"];
2919        assert_eq!(region["startLine"], 10);
2920        assert_eq!(region["startColumn"], 5);
2921    }
2922
2923    #[test]
2924    fn sarif_partial_fingerprint_ignores_rendered_message() {
2925        let a = sarif_result(
2926            "rule/test",
2927            "error",
2928            "first message",
2929            "src/file.ts",
2930            Some((10, 5)),
2931        );
2932        let b = sarif_result(
2933            "rule/test",
2934            "error",
2935            "rewritten message",
2936            "src/file.ts",
2937            Some((10, 5)),
2938        );
2939        assert_eq!(
2940            a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2941            b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2942        );
2943    }
2944
2945    #[test]
2946    fn health_sarif_includes_refactoring_targets() {
2947        use crate::health_types::*;
2948
2949        let root = PathBuf::from("/project");
2950        let report = HealthReport {
2951            summary: HealthSummary {
2952                files_analyzed: 10,
2953                functions_analyzed: 50,
2954                ..Default::default()
2955            },
2956            targets: vec![
2957                RefactoringTarget {
2958                    path: root.join("src/complex.ts"),
2959                    priority: 85.0,
2960                    efficiency: 42.5,
2961                    recommendation: "Split high-impact file".into(),
2962                    category: RecommendationCategory::SplitHighImpact,
2963                    effort: EffortEstimate::Medium,
2964                    confidence: Confidence::High,
2965                    factors: vec![],
2966                    evidence: None,
2967                }
2968                .into(),
2969            ],
2970            ..Default::default()
2971        };
2972
2973        let sarif = build_health_sarif(&report, &root);
2974        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2975        assert_eq!(entries.len(), 1);
2976        assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
2977        assert_eq!(entries[0]["level"], "warning");
2978        let msg = entries[0]["message"]["text"].as_str().unwrap();
2979        assert!(msg.contains("high impact"));
2980        assert!(msg.contains("Split high-impact file"));
2981        assert!(msg.contains("42.5"));
2982    }
2983
2984    #[test]
2985    fn health_sarif_includes_coverage_gaps() {
2986        use crate::health_types::*;
2987
2988        let root = PathBuf::from("/project");
2989        let report = HealthReport {
2990            summary: HealthSummary {
2991                files_analyzed: 10,
2992                functions_analyzed: 50,
2993                ..Default::default()
2994            },
2995            coverage_gaps: Some(CoverageGaps {
2996                summary: CoverageGapSummary {
2997                    runtime_files: 2,
2998                    covered_files: 0,
2999                    file_coverage_pct: 0.0,
3000                    untested_files: 1,
3001                    untested_exports: 1,
3002                },
3003                files: vec![UntestedFileFinding::with_actions(
3004                    UntestedFile {
3005                        path: root.join("src/app.ts"),
3006                        value_export_count: 2,
3007                    },
3008                    &root,
3009                )],
3010                exports: vec![UntestedExportFinding::with_actions(
3011                    UntestedExport {
3012                        path: root.join("src/app.ts"),
3013                        export_name: "loader".into(),
3014                        line: 12,
3015                        col: 4,
3016                    },
3017                    &root,
3018                )],
3019            }),
3020            ..Default::default()
3021        };
3022
3023        let sarif = build_health_sarif(&report, &root);
3024        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3025        assert_eq!(entries.len(), 2);
3026        assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
3027        assert_eq!(
3028            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3029            "src/app.ts"
3030        );
3031        assert!(
3032            entries[0]["message"]["text"]
3033                .as_str()
3034                .unwrap()
3035                .contains("2 value exports")
3036        );
3037        assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
3038        assert_eq!(
3039            entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
3040            12
3041        );
3042        assert_eq!(
3043            entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
3044            5
3045        );
3046    }
3047
3048    #[test]
3049    fn health_sarif_rules_have_full_descriptions() {
3050        let root = PathBuf::from("/project");
3051        let report = crate::health_types::HealthReport::default();
3052        let sarif = build_health_sarif(&report, &root);
3053        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
3054            .as_array()
3055            .unwrap();
3056        for rule in rules {
3057            let id = rule["id"].as_str().unwrap();
3058            assert!(
3059                rule.get("fullDescription").is_some(),
3060                "health rule {id} should have fullDescription"
3061            );
3062            assert!(
3063                rule.get("helpUri").is_some(),
3064                "health rule {id} should have helpUri"
3065            );
3066        }
3067    }
3068
3069    #[test]
3070    fn sarif_warn_severity_produces_warning_level() {
3071        let root = PathBuf::from("/project");
3072        let mut results = AnalysisResults::default();
3073        results
3074            .unused_files
3075            .push(UnusedFileFinding::with_actions(UnusedFile {
3076                path: root.join("src/dead.ts"),
3077            }));
3078
3079        let rules = RulesConfig {
3080            unused_files: Severity::Warn,
3081            ..RulesConfig::default()
3082        };
3083
3084        let sarif = build_sarif(&results, &root, &rules);
3085        let entry = &sarif["runs"][0]["results"][0];
3086        assert_eq!(entry["level"], "warning");
3087    }
3088
3089    #[test]
3090    fn sarif_unused_file_has_no_region() {
3091        let root = PathBuf::from("/project");
3092        let mut results = AnalysisResults::default();
3093        results
3094            .unused_files
3095            .push(UnusedFileFinding::with_actions(UnusedFile {
3096                path: root.join("src/dead.ts"),
3097            }));
3098
3099        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3100        let entry = &sarif["runs"][0]["results"][0];
3101        let phys = &entry["locations"][0]["physicalLocation"];
3102        assert!(phys.get("region").is_none());
3103    }
3104
3105    #[test]
3106    fn sarif_unlisted_dep_multiple_import_sites() {
3107        let root = PathBuf::from("/project");
3108        let mut results = AnalysisResults::default();
3109        results
3110            .unlisted_dependencies
3111            .push(UnlistedDependencyFinding::with_actions(
3112                UnlistedDependency {
3113                    package_name: "dotenv".to_string(),
3114                    imported_from: vec![
3115                        ImportSite {
3116                            path: root.join("src/a.ts"),
3117                            line: 1,
3118                            col: 0,
3119                        },
3120                        ImportSite {
3121                            path: root.join("src/b.ts"),
3122                            line: 5,
3123                            col: 0,
3124                        },
3125                    ],
3126                },
3127            ));
3128
3129        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3130        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3131        assert_eq!(entries.len(), 2);
3132        assert_eq!(
3133            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3134            "src/a.ts"
3135        );
3136        assert_eq!(
3137            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3138            "src/b.ts"
3139        );
3140    }
3141
3142    #[test]
3143    fn sarif_unlisted_dep_no_import_sites() {
3144        let root = PathBuf::from("/project");
3145        let mut results = AnalysisResults::default();
3146        results
3147            .unlisted_dependencies
3148            .push(UnlistedDependencyFinding::with_actions(
3149                UnlistedDependency {
3150                    package_name: "phantom".to_string(),
3151                    imported_from: vec![],
3152                },
3153            ));
3154
3155        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3156        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3157        assert!(entries.is_empty());
3158    }
3159}