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    [
711        (
712            "fallow/unused-file",
713            "File is not reachable from any entry point",
714            rules.unused_files,
715        ),
716        (
717            "fallow/unused-export",
718            "Export is never imported",
719            rules.unused_exports,
720        ),
721        (
722            "fallow/unused-type",
723            "Type export is never imported",
724            rules.unused_types,
725        ),
726        (
727            "fallow/private-type-leak",
728            "Exported signature references a same-file private type",
729            rules.private_type_leaks,
730        ),
731        (
732            "fallow/unused-dependency",
733            "Dependency listed but never imported",
734            rules.unused_dependencies,
735        ),
736        (
737            "fallow/unused-dev-dependency",
738            "Dev dependency listed but never imported",
739            rules.unused_dev_dependencies,
740        ),
741        (
742            "fallow/unused-optional-dependency",
743            "Optional dependency listed but never imported",
744            rules.unused_optional_dependencies,
745        ),
746        (
747            "fallow/type-only-dependency",
748            "Production dependency only used via type-only imports",
749            rules.type_only_dependencies,
750        ),
751        (
752            "fallow/test-only-dependency",
753            "Production dependency only imported by test files",
754            rules.test_only_dependencies,
755        ),
756        (
757            "fallow/unused-enum-member",
758            "Enum member is never referenced",
759            rules.unused_enum_members,
760        ),
761        (
762            "fallow/unused-class-member",
763            "Class member is never referenced",
764            rules.unused_class_members,
765        ),
766        (
767            "fallow/unresolved-import",
768            "Import could not be resolved",
769            rules.unresolved_imports,
770        ),
771        (
772            "fallow/unlisted-dependency",
773            "Dependency used but not in package.json",
774            rules.unlisted_dependencies,
775        ),
776        (
777            "fallow/duplicate-export",
778            "Export name appears in multiple modules",
779            rules.duplicate_exports,
780        ),
781        (
782            "fallow/circular-dependency",
783            "Circular dependency chain detected",
784            rules.circular_dependencies,
785        ),
786        (
787            "fallow/re-export-cycle",
788            "Two or more barrel files re-export from each other in a loop",
789            rules.re_export_cycle,
790        ),
791        (
792            "fallow/boundary-violation",
793            "Import crosses an architecture boundary",
794            rules.boundary_violation,
795        ),
796        (
797            "fallow/boundary-coverage",
798            "Source file matches no architecture boundary zone",
799            rules.boundary_violation,
800        ),
801        (
802            "fallow/boundary-call-violation",
803            "Zoned file calls a callee its zone forbids",
804            rules.boundary_violation,
805        ),
806        (
807            "fallow/policy-violation",
808            "Banned call or import matched a rule-pack rule",
809            rules.policy_violation,
810        ),
811        (
812            "fallow/stale-suppression",
813            "Suppression comment or tag no longer matches any issue",
814            rules.stale_suppressions,
815        ),
816        (
817            "fallow/unused-catalog-entry",
818            "pnpm catalog entry not referenced by any workspace package",
819            rules.unused_catalog_entries,
820        ),
821        (
822            "fallow/empty-catalog-group",
823            "pnpm named catalog group has no entries",
824            rules.empty_catalog_groups,
825        ),
826        (
827            "fallow/unresolved-catalog-reference",
828            "package.json catalog reference points at a catalog that does not declare the package",
829            rules.unresolved_catalog_references,
830        ),
831        (
832            "fallow/unused-dependency-override",
833            "pnpm dependency override target is not declared or lockfile-resolved",
834            rules.unused_dependency_overrides,
835        ),
836        (
837            "fallow/misconfigured-dependency-override",
838            "pnpm dependency override key or value is malformed",
839            rules.misconfigured_dependency_overrides,
840        ),
841    ]
842    .into_iter()
843    .map(|(id, description, rule_severity)| {
844        sarif_rule(id, description, configured_sarif_level(rule_severity))
845    })
846    .collect()
847}
848
849#[must_use]
850pub fn build_sarif(
851    results: &AnalysisResults,
852    root: &Path,
853    rules: &RulesConfig,
854) -> serde_json::Value {
855    let mut sarif_results = Vec::new();
856    let mut snippets = SourceSnippetCache::default();
857
858    push_sarif_results(
859        &mut sarif_results,
860        &results.unused_files,
861        &mut snippets,
862        |f| sarif_unused_file_fields(&f.file, root, severity_to_sarif_level(rules.unused_files)),
863    );
864    push_sarif_results(
865        &mut sarif_results,
866        &results.unused_exports,
867        &mut snippets,
868        |e| {
869            sarif_export_fields(
870                &e.export,
871                root,
872                "fallow/unused-export",
873                severity_to_sarif_level(rules.unused_exports),
874                "Export",
875                "Re-export",
876            )
877        },
878    );
879    push_sarif_results(
880        &mut sarif_results,
881        &results.unused_types,
882        &mut snippets,
883        |e| {
884            sarif_export_fields(
885                &e.export,
886                root,
887                "fallow/unused-type",
888                severity_to_sarif_level(rules.unused_types),
889                "Type export",
890                "Type re-export",
891            )
892        },
893    );
894    push_sarif_results(
895        &mut sarif_results,
896        &results.private_type_leaks,
897        &mut snippets,
898        |e| {
899            sarif_private_type_leak_fields(
900                &e.leak,
901                root,
902                severity_to_sarif_level(rules.private_type_leaks),
903            )
904        },
905    );
906    push_dependency_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
907    push_member_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
908    push_sarif_results(
909        &mut sarif_results,
910        &results.unresolved_imports,
911        &mut snippets,
912        |i| {
913            sarif_unresolved_import_fields(
914                &i.import,
915                root,
916                severity_to_sarif_level(rules.unresolved_imports),
917            )
918        },
919    );
920    push_misc_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
921    push_graph_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
922    push_catalog_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
923
924    serde_json::json!({
925        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
926        "version": "2.1.0",
927        "runs": [{
928            "tool": {
929                "driver": {
930                    "name": "fallow",
931                    "version": env!("CARGO_PKG_VERSION"),
932                    "informationUri": "https://github.com/fallow-rs/fallow",
933                    "rules": build_sarif_rules(rules)
934                }
935            },
936            "results": sarif_results
937        }]
938    })
939}
940
941fn push_dependency_sarif_results(
942    sarif_results: &mut Vec<serde_json::Value>,
943    results: &AnalysisResults,
944    root: &Path,
945    rules: &RulesConfig,
946    snippets: &mut SourceSnippetCache,
947) {
948    push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
949        sarif_dep_fields(
950            &d.dep,
951            root,
952            "fallow/unused-dependency",
953            severity_to_sarif_level(rules.unused_dependencies),
954            "dependencies",
955        )
956    });
957    push_sarif_results(
958        sarif_results,
959        &results.unused_dev_dependencies,
960        snippets,
961        |d| {
962            sarif_dep_fields(
963                &d.dep,
964                root,
965                "fallow/unused-dev-dependency",
966                severity_to_sarif_level(rules.unused_dev_dependencies),
967                "devDependencies",
968            )
969        },
970    );
971    push_sarif_results(
972        sarif_results,
973        &results.unused_optional_dependencies,
974        snippets,
975        |d| {
976            sarif_dep_fields(
977                &d.dep,
978                root,
979                "fallow/unused-optional-dependency",
980                severity_to_sarif_level(rules.unused_optional_dependencies),
981                "optionalDependencies",
982            )
983        },
984    );
985    push_sarif_results(
986        sarif_results,
987        &results.type_only_dependencies,
988        snippets,
989        |d| {
990            sarif_type_only_dep_fields(
991                &d.dep,
992                root,
993                severity_to_sarif_level(rules.type_only_dependencies),
994            )
995        },
996    );
997    push_sarif_results(
998        sarif_results,
999        &results.test_only_dependencies,
1000        snippets,
1001        |d| {
1002            sarif_test_only_dep_fields(
1003                &d.dep,
1004                root,
1005                severity_to_sarif_level(rules.test_only_dependencies),
1006            )
1007        },
1008    );
1009}
1010
1011fn push_member_sarif_results(
1012    sarif_results: &mut Vec<serde_json::Value>,
1013    results: &AnalysisResults,
1014    root: &Path,
1015    rules: &RulesConfig,
1016    snippets: &mut SourceSnippetCache,
1017) {
1018    push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1019        sarif_member_fields(
1020            &m.member,
1021            root,
1022            "fallow/unused-enum-member",
1023            severity_to_sarif_level(rules.unused_enum_members),
1024            "Enum",
1025        )
1026    });
1027    push_sarif_results(
1028        sarif_results,
1029        &results.unused_class_members,
1030        snippets,
1031        |m| {
1032            sarif_member_fields(
1033                &m.member,
1034                root,
1035                "fallow/unused-class-member",
1036                severity_to_sarif_level(rules.unused_class_members),
1037                "Class",
1038            )
1039        },
1040    );
1041}
1042
1043fn push_misc_sarif_results(
1044    sarif_results: &mut Vec<serde_json::Value>,
1045    results: &AnalysisResults,
1046    root: &Path,
1047    rules: &RulesConfig,
1048    snippets: &mut SourceSnippetCache,
1049) {
1050    if !results.unlisted_dependencies.is_empty() {
1051        push_sarif_unlisted_deps(
1052            sarif_results,
1053            &results.unlisted_dependencies,
1054            root,
1055            severity_to_sarif_level(rules.unlisted_dependencies),
1056            snippets,
1057        );
1058    }
1059    if !results.duplicate_exports.is_empty() {
1060        push_sarif_duplicate_exports(
1061            sarif_results,
1062            &results.duplicate_exports,
1063            root,
1064            severity_to_sarif_level(rules.duplicate_exports),
1065            snippets,
1066        );
1067    }
1068}
1069
1070fn push_graph_sarif_results(
1071    sarif_results: &mut Vec<serde_json::Value>,
1072    results: &AnalysisResults,
1073    root: &Path,
1074    rules: &RulesConfig,
1075    snippets: &mut SourceSnippetCache,
1076) {
1077    push_sarif_results(
1078        sarif_results,
1079        &results.circular_dependencies,
1080        snippets,
1081        |c| {
1082            sarif_circular_dep_fields(
1083                &c.cycle,
1084                root,
1085                severity_to_sarif_level(rules.circular_dependencies),
1086            )
1087        },
1088    );
1089    push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1090        sarif_re_export_cycle_fields(
1091            &c.cycle,
1092            root,
1093            severity_to_sarif_level(rules.re_export_cycle),
1094        )
1095    });
1096    push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1097        sarif_boundary_violation_fields(
1098            &v.violation,
1099            root,
1100            severity_to_sarif_level(rules.boundary_violation),
1101        )
1102    });
1103    push_sarif_results(
1104        sarif_results,
1105        &results.boundary_coverage_violations,
1106        snippets,
1107        |v| {
1108            sarif_boundary_coverage_fields(
1109                &v.violation,
1110                root,
1111                severity_to_sarif_level(rules.boundary_violation),
1112            )
1113        },
1114    );
1115    push_sarif_results(
1116        sarif_results,
1117        &results.boundary_call_violations,
1118        snippets,
1119        |v| {
1120            sarif_boundary_call_fields(
1121                &v.violation,
1122                root,
1123                severity_to_sarif_level(rules.boundary_violation),
1124            )
1125        },
1126    );
1127    push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1128        sarif_policy_violation_fields(&v.violation, root)
1129    });
1130    push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1131        sarif_stale_suppression_fields(s, root, severity_to_sarif_level(rules.stale_suppressions))
1132    });
1133}
1134
1135fn push_catalog_sarif_results(
1136    sarif_results: &mut Vec<serde_json::Value>,
1137    results: &AnalysisResults,
1138    root: &Path,
1139    rules: &RulesConfig,
1140    snippets: &mut SourceSnippetCache,
1141) {
1142    push_sarif_results(
1143        sarif_results,
1144        &results.unused_catalog_entries,
1145        snippets,
1146        |e| {
1147            sarif_unused_catalog_entry_fields(
1148                e,
1149                root,
1150                severity_to_sarif_level(rules.unused_catalog_entries),
1151            )
1152        },
1153    );
1154    push_sarif_results(
1155        sarif_results,
1156        &results.empty_catalog_groups,
1157        snippets,
1158        |g| {
1159            sarif_empty_catalog_group_fields(
1160                g,
1161                root,
1162                severity_to_sarif_level(rules.empty_catalog_groups),
1163            )
1164        },
1165    );
1166    push_sarif_results(
1167        sarif_results,
1168        &results.unresolved_catalog_references,
1169        snippets,
1170        |f| {
1171            sarif_unresolved_catalog_reference_fields(
1172                f,
1173                root,
1174                severity_to_sarif_level(rules.unresolved_catalog_references),
1175            )
1176        },
1177    );
1178    push_sarif_results(
1179        sarif_results,
1180        &results.unused_dependency_overrides,
1181        snippets,
1182        |f| {
1183            sarif_unused_dependency_override_fields(
1184                f,
1185                root,
1186                severity_to_sarif_level(rules.unused_dependency_overrides),
1187            )
1188        },
1189    );
1190    push_sarif_results(
1191        sarif_results,
1192        &results.misconfigured_dependency_overrides,
1193        snippets,
1194        |f| {
1195            sarif_misconfigured_dependency_override_fields(
1196                f,
1197                root,
1198                severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1199            )
1200        },
1201    );
1202}
1203
1204pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
1205    let sarif = build_sarif(results, root, rules);
1206    emit_json(&sarif, "SARIF")
1207}
1208
1209/// Print SARIF output with owner properties added to each result.
1210///
1211/// Calls `build_sarif` to produce the standard SARIF JSON, then post-processes
1212/// each result to add `"properties": { "owner": "@team" }` by resolving the
1213/// artifact location URI through the `OwnershipResolver`.
1214#[expect(
1215    clippy::expect_used,
1216    reason = "grouped SARIF entries are JSON objects created by build_sarif"
1217)]
1218pub(super) fn print_grouped_sarif(
1219    results: &AnalysisResults,
1220    root: &Path,
1221    rules: &RulesConfig,
1222    resolver: &OwnershipResolver,
1223) -> ExitCode {
1224    let mut sarif = build_sarif(results, root, rules);
1225
1226    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1227        for run in runs {
1228            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1229                for result in results {
1230                    let uri = result
1231                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1232                        .and_then(|v| v.as_str())
1233                        .unwrap_or("");
1234                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1235                    let owner =
1236                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1237                    let props = result
1238                        .as_object_mut()
1239                        .expect("SARIF result should be an object")
1240                        .entry("properties")
1241                        .or_insert_with(|| serde_json::json!({}));
1242                    props
1243                        .as_object_mut()
1244                        .expect("properties should be an object")
1245                        .insert("owner".to_string(), serde_json::Value::String(owner));
1246                }
1247            }
1248        }
1249    }
1250
1251    emit_json(&sarif, "SARIF")
1252}
1253
1254#[expect(
1255    clippy::cast_possible_truncation,
1256    reason = "line/col numbers are bounded by source size"
1257)]
1258pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
1259    let mut sarif_results = Vec::new();
1260    let mut snippets = SourceSnippetCache::default();
1261
1262    for (i, group) in report.clone_groups.iter().enumerate() {
1263        for instance in &group.instances {
1264            let uri = relative_uri(&instance.file, root);
1265            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1266            sarif_results.push(sarif_result_with_snippet(
1267                "fallow/code-duplication",
1268                "warning",
1269                &format!(
1270                    "Code clone group {} ({} lines, {} instances)",
1271                    i + 1,
1272                    group.line_count,
1273                    group.instances.len()
1274                ),
1275                &uri,
1276                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1277                source_snippet.as_deref(),
1278            ));
1279        }
1280    }
1281
1282    let sarif = serde_json::json!({
1283        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1284        "version": "2.1.0",
1285        "runs": [{
1286            "tool": {
1287                "driver": {
1288                    "name": "fallow",
1289                    "version": env!("CARGO_PKG_VERSION"),
1290                    "informationUri": "https://github.com/fallow-rs/fallow",
1291                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1292                }
1293            },
1294            "results": sarif_results
1295        }]
1296    });
1297
1298    emit_json(&sarif, "SARIF")
1299}
1300
1301/// Print SARIF duplication output with a `properties.group` tag on every
1302/// result.
1303///
1304/// Each clone group is attributed to its largest owner (most instances; ties
1305/// broken alphabetically) via [`super::dupes_grouping::largest_owner`], and
1306/// every result emitted for that group's instances carries the same
1307/// `properties.group` value. This mirrors the health SARIF convention
1308/// (`print_grouped_health_sarif`) so consumers (GitHub Code Scanning, GitLab
1309/// Code Quality) can partition findings per team / package / directory
1310/// without re-resolving ownership.
1311#[expect(
1312    clippy::cast_possible_truncation,
1313    reason = "line/col numbers are bounded by source size"
1314)]
1315#[expect(
1316    clippy::expect_used,
1317    reason = "duplication SARIF entries are JSON objects created by sarif_result_with_snippet"
1318)]
1319pub(super) fn print_grouped_duplication_sarif(
1320    report: &DuplicationReport,
1321    root: &Path,
1322    resolver: &OwnershipResolver,
1323) -> ExitCode {
1324    let mut sarif_results = Vec::new();
1325    let mut snippets = SourceSnippetCache::default();
1326
1327    for (i, group) in report.clone_groups.iter().enumerate() {
1328        let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
1329        for instance in &group.instances {
1330            let uri = relative_uri(&instance.file, root);
1331            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1332            let mut result = sarif_result_with_snippet(
1333                "fallow/code-duplication",
1334                "warning",
1335                &format!(
1336                    "Code clone group {} ({} lines, {} instances)",
1337                    i + 1,
1338                    group.line_count,
1339                    group.instances.len()
1340                ),
1341                &uri,
1342                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1343                source_snippet.as_deref(),
1344            );
1345            let props = result
1346                .as_object_mut()
1347                .expect("SARIF result should be an object")
1348                .entry("properties")
1349                .or_insert_with(|| serde_json::json!({}));
1350            props
1351                .as_object_mut()
1352                .expect("properties should be an object")
1353                .insert(
1354                    "group".to_string(),
1355                    serde_json::Value::String(primary_owner.clone()),
1356                );
1357            sarif_results.push(result);
1358        }
1359    }
1360
1361    let sarif = serde_json::json!({
1362        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1363        "version": "2.1.0",
1364        "runs": [{
1365            "tool": {
1366                "driver": {
1367                    "name": "fallow",
1368                    "version": env!("CARGO_PKG_VERSION"),
1369                    "informationUri": "https://github.com/fallow-rs/fallow",
1370                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1371                }
1372            },
1373            "results": sarif_results
1374        }]
1375    });
1376
1377    emit_json(&sarif, "SARIF")
1378}
1379
1380#[must_use]
1381pub fn build_health_sarif(
1382    report: &crate::health_types::HealthReport,
1383    root: &Path,
1384) -> serde_json::Value {
1385    let mut sarif_results = Vec::new();
1386    let mut snippets = SourceSnippetCache::default();
1387
1388    append_complexity_sarif_results(&mut sarif_results, report, root, &mut snippets);
1389
1390    if let Some(ref production) = report.runtime_coverage {
1391        append_runtime_coverage_sarif_results(&mut sarif_results, production, root, &mut snippets);
1392    }
1393    if let Some(ref intelligence) = report.coverage_intelligence {
1394        append_coverage_intelligence_sarif_results(
1395            &mut sarif_results,
1396            intelligence,
1397            root,
1398            &mut snippets,
1399        );
1400    }
1401
1402    append_refactoring_target_sarif_results(&mut sarif_results, report, root);
1403    append_coverage_gap_sarif_results(&mut sarif_results, report, root, &mut snippets);
1404
1405    let health_rules = vec![
1406        sarif_rule(
1407            "fallow/high-cyclomatic-complexity",
1408            "Function has high cyclomatic complexity",
1409            "note",
1410        ),
1411        sarif_rule(
1412            "fallow/high-cognitive-complexity",
1413            "Function has high cognitive complexity",
1414            "note",
1415        ),
1416        sarif_rule(
1417            "fallow/high-complexity",
1418            "Function exceeds both complexity thresholds",
1419            "note",
1420        ),
1421        sarif_rule(
1422            "fallow/high-crap-score",
1423            "Function has a high CRAP score (high complexity combined with low coverage)",
1424            "warning",
1425        ),
1426        sarif_rule(
1427            "fallow/refactoring-target",
1428            "File identified as a high-priority refactoring candidate",
1429            "warning",
1430        ),
1431        sarif_rule(
1432            "fallow/untested-file",
1433            "Runtime-reachable file has no test dependency path",
1434            "warning",
1435        ),
1436        sarif_rule(
1437            "fallow/untested-export",
1438            "Runtime-reachable export has no test dependency path",
1439            "warning",
1440        ),
1441        sarif_rule(
1442            "fallow/runtime-safe-to-delete",
1443            "Function is statically unused and was never invoked in production",
1444            "warning",
1445        ),
1446        sarif_rule(
1447            "fallow/runtime-review-required",
1448            "Function is statically used but was never invoked in production",
1449            "warning",
1450        ),
1451        sarif_rule(
1452            "fallow/runtime-low-traffic",
1453            "Function was invoked below the low-traffic threshold relative to total trace count",
1454            "note",
1455        ),
1456        sarif_rule(
1457            "fallow/runtime-coverage-unavailable",
1458            "Runtime coverage could not be resolved for this function",
1459            "note",
1460        ),
1461        sarif_rule(
1462            "fallow/runtime-coverage",
1463            "Runtime coverage finding",
1464            "note",
1465        ),
1466        sarif_rule(
1467            "fallow/coverage-intelligence-risky-change",
1468            "Changed hot path combines high CRAP and low test coverage",
1469            "warning",
1470        ),
1471        sarif_rule(
1472            "fallow/coverage-intelligence-delete",
1473            "Static and runtime evidence indicate code can be deleted",
1474            "warning",
1475        ),
1476        sarif_rule(
1477            "fallow/coverage-intelligence-review",
1478            "Cold reachable uncovered code needs owner review",
1479            "warning",
1480        ),
1481        sarif_rule(
1482            "fallow/coverage-intelligence-refactor",
1483            "Hot covered code has high CRAP and should be refactored carefully",
1484            "warning",
1485        ),
1486    ];
1487
1488    serde_json::json!({
1489        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1490        "version": "2.1.0",
1491        "runs": [{
1492            "tool": {
1493                "driver": {
1494                    "name": "fallow",
1495                    "version": env!("CARGO_PKG_VERSION"),
1496                    "informationUri": "https://github.com/fallow-rs/fallow",
1497                    "rules": health_rules
1498                }
1499            },
1500            "results": sarif_results
1501        }]
1502    })
1503}
1504
1505fn append_complexity_sarif_results(
1506    sarif_results: &mut Vec<serde_json::Value>,
1507    report: &crate::health_types::HealthReport,
1508    root: &Path,
1509    snippets: &mut SourceSnippetCache,
1510) {
1511    for finding in &report.findings {
1512        let uri = relative_uri(&finding.path, root);
1513        let (rule_id, message) = health_complexity_sarif_message(finding, report);
1514        let level = match finding.severity {
1515            crate::health_types::FindingSeverity::Critical => "error",
1516            crate::health_types::FindingSeverity::High => "warning",
1517            crate::health_types::FindingSeverity::Moderate => "note",
1518        };
1519        let source_snippet = snippets.line(&finding.path, finding.line);
1520        sarif_results.push(sarif_result_with_snippet(
1521            rule_id,
1522            level,
1523            &message,
1524            &uri,
1525            Some((finding.line, finding.col + 1)),
1526            source_snippet.as_deref(),
1527        ));
1528    }
1529}
1530
1531fn health_complexity_sarif_message(
1532    finding: &crate::health_types::ComplexityViolation,
1533    report: &crate::health_types::HealthReport,
1534) -> (&'static str, String) {
1535    match finding.exceeded {
1536        crate::health_types::ExceededThreshold::Cyclomatic => (
1537            "fallow/high-cyclomatic-complexity",
1538            format!(
1539                "'{}' has cyclomatic complexity {} (threshold: {})",
1540                finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
1541            ),
1542        ),
1543        crate::health_types::ExceededThreshold::Cognitive => (
1544            "fallow/high-cognitive-complexity",
1545            format!(
1546                "'{}' has cognitive complexity {} (threshold: {})",
1547                finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
1548            ),
1549        ),
1550        crate::health_types::ExceededThreshold::Both => (
1551            "fallow/high-complexity",
1552            format!(
1553                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
1554                finding.name,
1555                finding.cyclomatic,
1556                report.summary.max_cyclomatic_threshold,
1557                finding.cognitive,
1558                report.summary.max_cognitive_threshold,
1559            ),
1560        ),
1561        crate::health_types::ExceededThreshold::Crap
1562        | crate::health_types::ExceededThreshold::CyclomaticCrap
1563        | crate::health_types::ExceededThreshold::CognitiveCrap
1564        | crate::health_types::ExceededThreshold::All => {
1565            let crap = finding.crap.unwrap_or(0.0);
1566            let coverage = finding
1567                .coverage_pct
1568                .map(|pct| format!(", coverage {pct:.0}%"))
1569                .unwrap_or_default();
1570            (
1571                "fallow/high-crap-score",
1572                format!(
1573                    "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
1574                    finding.name,
1575                    crap,
1576                    report.summary.max_crap_threshold,
1577                    finding.cyclomatic,
1578                    coverage,
1579                ),
1580            )
1581        }
1582    }
1583}
1584
1585fn append_refactoring_target_sarif_results(
1586    sarif_results: &mut Vec<serde_json::Value>,
1587    report: &crate::health_types::HealthReport,
1588    root: &Path,
1589) {
1590    for target in &report.targets {
1591        let uri = relative_uri(&target.path, root);
1592        let message = format!(
1593            "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
1594            target.category.label(),
1595            target.recommendation,
1596            target.priority,
1597            target.efficiency,
1598            target.effort.label(),
1599            target.confidence.label(),
1600        );
1601        sarif_results.push(sarif_result(
1602            "fallow/refactoring-target",
1603            "warning",
1604            &message,
1605            &uri,
1606            None,
1607        ));
1608    }
1609}
1610
1611fn append_coverage_gap_sarif_results(
1612    sarif_results: &mut Vec<serde_json::Value>,
1613    report: &crate::health_types::HealthReport,
1614    root: &Path,
1615    snippets: &mut SourceSnippetCache,
1616) {
1617    let Some(ref gaps) = report.coverage_gaps else {
1618        return;
1619    };
1620    for item in &gaps.files {
1621        let uri = relative_uri(&item.file.path, root);
1622        let message = format!(
1623            "File is runtime-reachable but has no test dependency path ({} value export{})",
1624            item.file.value_export_count,
1625            if item.file.value_export_count == 1 {
1626                ""
1627            } else {
1628                "s"
1629            },
1630        );
1631        sarif_results.push(sarif_result(
1632            "fallow/untested-file",
1633            "warning",
1634            &message,
1635            &uri,
1636            None,
1637        ));
1638    }
1639
1640    for item in &gaps.exports {
1641        let uri = relative_uri(&item.export.path, root);
1642        let message = format!(
1643            "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1644            item.export.export_name
1645        );
1646        let source_snippet = snippets.line(&item.export.path, item.export.line);
1647        sarif_results.push(sarif_result_with_snippet(
1648            "fallow/untested-export",
1649            "warning",
1650            &message,
1651            &uri,
1652            Some((item.export.line, item.export.col + 1)),
1653            source_snippet.as_deref(),
1654        ));
1655    }
1656}
1657
1658fn append_runtime_coverage_sarif_results(
1659    sarif_results: &mut Vec<serde_json::Value>,
1660    production: &crate::health_types::RuntimeCoverageReport,
1661    root: &Path,
1662    snippets: &mut SourceSnippetCache,
1663) {
1664    for finding in &production.findings {
1665        let uri = relative_uri(&finding.path, root);
1666        let rule_id = match finding.verdict {
1667            crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1668                "fallow/runtime-safe-to-delete"
1669            }
1670            crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1671                "fallow/runtime-review-required"
1672            }
1673            crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
1674            crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1675                "fallow/runtime-coverage-unavailable"
1676            }
1677            crate::health_types::RuntimeCoverageVerdict::Active
1678            | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1679        };
1680        let level = match finding.verdict {
1681            crate::health_types::RuntimeCoverageVerdict::SafeToDelete
1682            | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
1683            _ => "note",
1684        };
1685        let invocations_hint = finding.invocations.map_or_else(
1686            || "untracked".to_owned(),
1687            |hits| format!("{hits} invocations"),
1688        );
1689        let message = format!(
1690            "'{}' runtime coverage verdict: {} ({})",
1691            finding.function,
1692            finding.verdict.human_label(),
1693            invocations_hint,
1694        );
1695        let source_snippet = snippets.line(&finding.path, finding.line);
1696        sarif_results.push(sarif_result_with_snippet(
1697            rule_id,
1698            level,
1699            &message,
1700            &uri,
1701            Some((finding.line, 1)),
1702            source_snippet.as_deref(),
1703        ));
1704    }
1705}
1706
1707fn append_coverage_intelligence_sarif_results(
1708    sarif_results: &mut Vec<serde_json::Value>,
1709    intelligence: &crate::health_types::CoverageIntelligenceReport,
1710    root: &Path,
1711    snippets: &mut SourceSnippetCache,
1712) {
1713    for finding in &intelligence.findings {
1714        let rule_id = coverage_intelligence_rule_id(finding.recommendation);
1715        let level = match finding.verdict {
1716            crate::health_types::CoverageIntelligenceVerdict::Clean
1717            | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
1718            _ => "warning",
1719        };
1720        let uri = relative_uri(&finding.path, root);
1721        let identity = finding.identity.as_deref().unwrap_or("code");
1722        let signals = finding
1723            .signals
1724            .iter()
1725            .map(ToString::to_string)
1726            .collect::<Vec<_>>()
1727            .join(", ");
1728        let message = format!(
1729            "'{}' coverage intelligence verdict: {} ({}, signals: {})",
1730            identity, finding.verdict, finding.recommendation, signals,
1731        );
1732        let source_snippet = snippets.line(&finding.path, finding.line);
1733        let mut result = sarif_result_with_snippet(
1734            rule_id,
1735            level,
1736            &message,
1737            &uri,
1738            Some((finding.line, 1)),
1739            source_snippet.as_deref(),
1740        );
1741        result["properties"] = serde_json::json!({
1742            "coverage_intelligence_id": &finding.id,
1743            "verdict": finding.verdict,
1744            "recommendation": finding.recommendation,
1745            "confidence": finding.confidence,
1746            "signals": &finding.signals,
1747            "related_ids": &finding.related_ids,
1748        });
1749        sarif_results.push(result);
1750    }
1751}
1752
1753fn coverage_intelligence_rule_id(
1754    recommendation: crate::health_types::CoverageIntelligenceRecommendation,
1755) -> &'static str {
1756    match recommendation {
1757        crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
1758            "fallow/coverage-intelligence-risky-change"
1759        }
1760        crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
1761            "fallow/coverage-intelligence-delete"
1762        }
1763        crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
1764            "fallow/coverage-intelligence-review"
1765        }
1766        crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
1767            "fallow/coverage-intelligence-refactor"
1768        }
1769    }
1770}
1771
1772pub(super) fn print_health_sarif(
1773    report: &crate::health_types::HealthReport,
1774    root: &Path,
1775) -> ExitCode {
1776    let sarif = build_health_sarif(report, root);
1777    emit_json(&sarif, "SARIF")
1778}
1779
1780/// Print health SARIF with a per-result `properties.group` tag.
1781///
1782/// Mirrors the dead-code grouped SARIF pattern (`print_grouped_sarif`):
1783/// build the standard SARIF first, then post-process each result to inject
1784/// the resolver-derived group key on `properties.group`. Consumers that read
1785/// SARIF (GitHub Code Scanning, GitLab Code Quality) can then partition
1786/// findings per team / package / directory without dropping out of the
1787/// SARIF pipeline. Each finding's URI is decoded (`%5B` -> `[`, `%5D` -> `]`)
1788/// before resolution, matching the dead-code behaviour for paths containing
1789/// brackets like Next.js dynamic routes.
1790#[expect(
1791    clippy::expect_used,
1792    reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
1793)]
1794pub(super) fn print_grouped_health_sarif(
1795    report: &crate::health_types::HealthReport,
1796    root: &Path,
1797    resolver: &OwnershipResolver,
1798) -> ExitCode {
1799    let mut sarif = build_health_sarif(report, root);
1800
1801    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1802        for run in runs {
1803            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1804                for result in results {
1805                    let uri = result
1806                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1807                        .and_then(|v| v.as_str())
1808                        .unwrap_or("");
1809                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1810                    let group =
1811                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1812                    let props = result
1813                        .as_object_mut()
1814                        .expect("SARIF result should be an object")
1815                        .entry("properties")
1816                        .or_insert_with(|| serde_json::json!({}));
1817                    props
1818                        .as_object_mut()
1819                        .expect("properties should be an object")
1820                        .insert("group".to_string(), serde_json::Value::String(group));
1821                }
1822            }
1823        }
1824    }
1825
1826    emit_json(&sarif, "SARIF")
1827}
1828
1829#[cfg(test)]
1830mod tests {
1831    use super::*;
1832    use crate::report::test_helpers::sample_results;
1833    use fallow_core::results::*;
1834    use std::path::PathBuf;
1835
1836    #[test]
1837    fn sarif_has_required_top_level_fields() {
1838        let root = PathBuf::from("/project");
1839        let results = AnalysisResults::default();
1840        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1841
1842        assert_eq!(
1843            sarif["$schema"],
1844            "https://json.schemastore.org/sarif-2.1.0.json"
1845        );
1846        assert_eq!(sarif["version"], "2.1.0");
1847        assert!(sarif["runs"].is_array());
1848    }
1849
1850    #[test]
1851    fn sarif_has_tool_driver_info() {
1852        let root = PathBuf::from("/project");
1853        let results = AnalysisResults::default();
1854        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1855
1856        let driver = &sarif["runs"][0]["tool"]["driver"];
1857        assert_eq!(driver["name"], "fallow");
1858        assert!(driver["version"].is_string());
1859        assert_eq!(
1860            driver["informationUri"],
1861            "https://github.com/fallow-rs/fallow"
1862        );
1863    }
1864
1865    #[test]
1866    fn sarif_declares_all_rules() {
1867        let root = PathBuf::from("/project");
1868        let results = AnalysisResults::default();
1869        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1870
1871        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1872            .as_array()
1873            .expect("rules should be an array");
1874        assert_eq!(rules.len(), 26);
1875
1876        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1877        assert!(rule_ids.contains(&"fallow/unused-file"));
1878        assert!(rule_ids.contains(&"fallow/unused-export"));
1879        assert!(rule_ids.contains(&"fallow/unused-type"));
1880        assert!(rule_ids.contains(&"fallow/private-type-leak"));
1881        assert!(rule_ids.contains(&"fallow/unused-dependency"));
1882        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1883        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1884        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1885        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1886        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1887        assert!(rule_ids.contains(&"fallow/unused-class-member"));
1888        assert!(rule_ids.contains(&"fallow/unresolved-import"));
1889        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1890        assert!(rule_ids.contains(&"fallow/duplicate-export"));
1891        assert!(rule_ids.contains(&"fallow/circular-dependency"));
1892        assert!(rule_ids.contains(&"fallow/re-export-cycle"));
1893        assert!(rule_ids.contains(&"fallow/boundary-violation"));
1894        assert!(rule_ids.contains(&"fallow/boundary-coverage"));
1895        assert!(rule_ids.contains(&"fallow/boundary-call-violation"));
1896        assert!(rule_ids.contains(&"fallow/policy-violation"));
1897        assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
1898        assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
1899        assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
1900        assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
1901        assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
1902    }
1903
1904    #[test]
1905    fn sarif_empty_results_no_results_entries() {
1906        let root = PathBuf::from("/project");
1907        let results = AnalysisResults::default();
1908        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1909
1910        let sarif_results = sarif["runs"][0]["results"]
1911            .as_array()
1912            .expect("results should be an array");
1913        assert!(sarif_results.is_empty());
1914    }
1915
1916    #[test]
1917    fn sarif_unused_file_result() {
1918        let root = PathBuf::from("/project");
1919        let mut results = AnalysisResults::default();
1920        results
1921            .unused_files
1922            .push(UnusedFileFinding::with_actions(UnusedFile {
1923                path: root.join("src/dead.ts"),
1924            }));
1925
1926        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1927        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1928        assert_eq!(entries.len(), 1);
1929
1930        let entry = &entries[0];
1931        assert_eq!(entry["ruleId"], "fallow/unused-file");
1932        assert_eq!(entry["level"], "error");
1933        assert_eq!(
1934            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1935            "src/dead.ts"
1936        );
1937    }
1938
1939    #[test]
1940    fn sarif_unused_export_includes_region() {
1941        let root = PathBuf::from("/project");
1942        let mut results = AnalysisResults::default();
1943        results
1944            .unused_exports
1945            .push(UnusedExportFinding::with_actions(UnusedExport {
1946                path: root.join("src/utils.ts"),
1947                export_name: "helperFn".to_string(),
1948                is_type_only: false,
1949                line: 10,
1950                col: 4,
1951                span_start: 120,
1952                is_re_export: false,
1953            }));
1954
1955        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1956        let entry = &sarif["runs"][0]["results"][0];
1957        assert_eq!(entry["ruleId"], "fallow/unused-export");
1958
1959        let region = &entry["locations"][0]["physicalLocation"]["region"];
1960        assert_eq!(region["startLine"], 10);
1961        assert_eq!(region["startColumn"], 5);
1962    }
1963
1964    #[test]
1965    fn sarif_unresolved_import_is_error_level() {
1966        let root = PathBuf::from("/project");
1967        let mut results = AnalysisResults::default();
1968        results
1969            .unresolved_imports
1970            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1971                path: root.join("src/app.ts"),
1972                specifier: "./missing".to_string(),
1973                line: 1,
1974                col: 0,
1975                specifier_col: 0,
1976            }));
1977
1978        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1979        let entry = &sarif["runs"][0]["results"][0];
1980        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1981        assert_eq!(entry["level"], "error");
1982    }
1983
1984    #[test]
1985    fn sarif_unlisted_dependency_points_to_import_site() {
1986        let root = PathBuf::from("/project");
1987        let mut results = AnalysisResults::default();
1988        results
1989            .unlisted_dependencies
1990            .push(UnlistedDependencyFinding::with_actions(
1991                UnlistedDependency {
1992                    package_name: "chalk".to_string(),
1993                    imported_from: vec![ImportSite {
1994                        path: root.join("src/cli.ts"),
1995                        line: 3,
1996                        col: 0,
1997                    }],
1998                },
1999            ));
2000
2001        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2002        let entry = &sarif["runs"][0]["results"][0];
2003        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
2004        assert_eq!(entry["level"], "error");
2005        assert_eq!(
2006            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2007            "src/cli.ts"
2008        );
2009        let region = &entry["locations"][0]["physicalLocation"]["region"];
2010        assert_eq!(region["startLine"], 3);
2011        assert_eq!(region["startColumn"], 1);
2012    }
2013
2014    #[test]
2015    fn sarif_dependency_issues_point_to_package_json() {
2016        let root = PathBuf::from("/project");
2017        let mut results = AnalysisResults::default();
2018        results
2019            .unused_dependencies
2020            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2021                package_name: "lodash".to_string(),
2022                location: DependencyLocation::Dependencies,
2023                path: root.join("package.json"),
2024                line: 5,
2025                used_in_workspaces: Vec::new(),
2026            }));
2027        results
2028            .unused_dev_dependencies
2029            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2030                package_name: "jest".to_string(),
2031                location: DependencyLocation::DevDependencies,
2032                path: root.join("package.json"),
2033                line: 5,
2034                used_in_workspaces: Vec::new(),
2035            }));
2036
2037        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2038        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2039        for entry in entries {
2040            assert_eq!(
2041                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2042                "package.json"
2043            );
2044        }
2045    }
2046
2047    #[test]
2048    fn sarif_duplicate_export_emits_one_result_per_location() {
2049        let root = PathBuf::from("/project");
2050        let mut results = AnalysisResults::default();
2051        results
2052            .duplicate_exports
2053            .push(DuplicateExportFinding::with_actions(DuplicateExport {
2054                export_name: "Config".to_string(),
2055                locations: vec![
2056                    DuplicateLocation {
2057                        path: root.join("src/a.ts"),
2058                        line: 15,
2059                        col: 0,
2060                    },
2061                    DuplicateLocation {
2062                        path: root.join("src/b.ts"),
2063                        line: 30,
2064                        col: 0,
2065                    },
2066                ],
2067            }));
2068
2069        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2070        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2071        assert_eq!(entries.len(), 2);
2072        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
2073        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
2074        assert_eq!(
2075            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2076            "src/a.ts"
2077        );
2078        assert_eq!(
2079            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2080            "src/b.ts"
2081        );
2082    }
2083
2084    #[test]
2085    fn sarif_all_issue_types_produce_results() {
2086        let root = PathBuf::from("/project");
2087        let results = sample_results(&root);
2088        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2089
2090        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2091        assert_eq!(entries.len(), results.total_issues() + 1);
2092
2093        let rule_ids: Vec<&str> = entries
2094            .iter()
2095            .map(|e| e["ruleId"].as_str().unwrap())
2096            .collect();
2097        assert!(rule_ids.contains(&"fallow/unused-file"));
2098        assert!(rule_ids.contains(&"fallow/unused-export"));
2099        assert!(rule_ids.contains(&"fallow/unused-type"));
2100        assert!(rule_ids.contains(&"fallow/unused-dependency"));
2101        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2102        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2103        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2104        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2105        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2106        assert!(rule_ids.contains(&"fallow/unused-class-member"));
2107        assert!(rule_ids.contains(&"fallow/unresolved-import"));
2108        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2109        assert!(rule_ids.contains(&"fallow/duplicate-export"));
2110    }
2111
2112    #[test]
2113    fn sarif_serializes_to_valid_json() {
2114        let root = PathBuf::from("/project");
2115        let results = sample_results(&root);
2116        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2117
2118        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2119        let reparsed: serde_json::Value =
2120            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
2121        assert_eq!(reparsed, sarif);
2122    }
2123
2124    #[test]
2125    fn sarif_file_write_produces_valid_sarif() {
2126        let root = PathBuf::from("/project");
2127        let results = sample_results(&root);
2128        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2129        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2130
2131        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
2132        let _ = std::fs::create_dir_all(&dir);
2133        let sarif_path = dir.join("results.sarif");
2134        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
2135
2136        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
2137        let parsed: serde_json::Value =
2138            serde_json::from_str(&contents).expect("file should contain valid JSON");
2139
2140        assert_eq!(parsed["version"], "2.1.0");
2141        assert_eq!(
2142            parsed["$schema"],
2143            "https://json.schemastore.org/sarif-2.1.0.json"
2144        );
2145        let sarif_results = parsed["runs"][0]["results"]
2146            .as_array()
2147            .expect("results should be an array");
2148        assert!(!sarif_results.is_empty());
2149
2150        let _ = std::fs::remove_file(&sarif_path);
2151        let _ = std::fs::remove_dir(&dir);
2152    }
2153
2154    #[test]
2155    fn health_sarif_empty_no_results() {
2156        let root = PathBuf::from("/project");
2157        let report = crate::health_types::HealthReport {
2158            summary: crate::health_types::HealthSummary {
2159                files_analyzed: 10,
2160                functions_analyzed: 50,
2161                ..Default::default()
2162            },
2163            ..Default::default()
2164        };
2165        let sarif = build_health_sarif(&report, &root);
2166        assert_eq!(sarif["version"], "2.1.0");
2167        let results = sarif["runs"][0]["results"].as_array().unwrap();
2168        assert!(results.is_empty());
2169        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2170            .as_array()
2171            .unwrap();
2172        assert_eq!(rules.len(), 16);
2173    }
2174
2175    #[test]
2176    fn health_sarif_coverage_intelligence_preserves_structured_properties() {
2177        use crate::health_types::{
2178            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2179            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2180            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2181            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2182            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2183            HealthReport, HealthSummary,
2184        };
2185
2186        let root = PathBuf::from("/project");
2187        let report = HealthReport {
2188            summary: HealthSummary {
2189                files_analyzed: 10,
2190                functions_analyzed: 50,
2191                ..Default::default()
2192            },
2193            coverage_intelligence: Some(CoverageIntelligenceReport {
2194                schema_version: CoverageIntelligenceSchemaVersion::V1,
2195                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2196                summary: CoverageIntelligenceSummary {
2197                    findings: 1,
2198                    high_confidence_deletes: 1,
2199                    ..Default::default()
2200                },
2201                findings: vec![CoverageIntelligenceFinding {
2202                    id: "fallow:coverage-intel:abc123".to_owned(),
2203                    path: root.join("src/dead.ts"),
2204                    identity: Some("deadPath".to_owned()),
2205                    line: 9,
2206                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2207                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2208                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2209                    confidence: CoverageIntelligenceConfidence::High,
2210                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2211                    evidence: CoverageIntelligenceEvidence {
2212                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2213                        ..Default::default()
2214                    },
2215                    actions: vec![CoverageIntelligenceAction {
2216                        kind: "delete-after-confirming-owner".to_owned(),
2217                        description: "Confirm ownership".to_owned(),
2218                        auto_fixable: false,
2219                    }],
2220                }],
2221            }),
2222            ..Default::default()
2223        };
2224
2225        let sarif = build_health_sarif(&report, &root);
2226        let result = &sarif["runs"][0]["results"][0];
2227        assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
2228        assert_eq!(
2229            result["properties"]["coverage_intelligence_id"],
2230            "fallow:coverage-intel:abc123"
2231        );
2232        assert_eq!(
2233            result["properties"]["recommendation"],
2234            "delete-after-confirming-owner"
2235        );
2236        assert_eq!(result["properties"]["confidence"], "high");
2237        assert_eq!(result["properties"]["signals"][0], "runtime_cold");
2238        assert_eq!(
2239            result["properties"]["related_ids"][0],
2240            "fallow:prod:deadbeef"
2241        );
2242    }
2243
2244    #[test]
2245    fn health_sarif_cyclomatic_only() {
2246        let root = PathBuf::from("/project");
2247        let report = crate::health_types::HealthReport {
2248            findings: vec![
2249                crate::health_types::ComplexityViolation {
2250                    path: root.join("src/utils.ts"),
2251                    name: "parseExpression".to_string(),
2252                    line: 42,
2253                    col: 0,
2254                    cyclomatic: 25,
2255                    cognitive: 10,
2256                    line_count: 80,
2257                    param_count: 0,
2258                    exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
2259                    severity: crate::health_types::FindingSeverity::High,
2260                    crap: None,
2261                    coverage_pct: None,
2262                    coverage_tier: None,
2263                    coverage_source: None,
2264                    inherited_from: None,
2265                    component_rollup: None,
2266                    contributions: Vec::new(),
2267                }
2268                .into(),
2269            ],
2270            summary: crate::health_types::HealthSummary {
2271                files_analyzed: 5,
2272                functions_analyzed: 20,
2273                functions_above_threshold: 1,
2274                ..Default::default()
2275            },
2276            ..Default::default()
2277        };
2278        let sarif = build_health_sarif(&report, &root);
2279        let entry = &sarif["runs"][0]["results"][0];
2280        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
2281        assert_eq!(entry["level"], "warning");
2282        assert!(
2283            entry["message"]["text"]
2284                .as_str()
2285                .unwrap()
2286                .contains("cyclomatic complexity 25")
2287        );
2288        assert_eq!(
2289            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2290            "src/utils.ts"
2291        );
2292        let region = &entry["locations"][0]["physicalLocation"]["region"];
2293        assert_eq!(region["startLine"], 42);
2294        assert_eq!(region["startColumn"], 1);
2295    }
2296
2297    #[test]
2298    fn health_sarif_cognitive_only() {
2299        let root = PathBuf::from("/project");
2300        let report = crate::health_types::HealthReport {
2301            findings: vec![
2302                crate::health_types::ComplexityViolation {
2303                    path: root.join("src/api.ts"),
2304                    name: "handleRequest".to_string(),
2305                    line: 10,
2306                    col: 4,
2307                    cyclomatic: 8,
2308                    cognitive: 20,
2309                    line_count: 40,
2310                    param_count: 0,
2311                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
2312                    severity: crate::health_types::FindingSeverity::High,
2313                    crap: None,
2314                    coverage_pct: None,
2315                    coverage_tier: None,
2316                    coverage_source: None,
2317                    inherited_from: None,
2318                    component_rollup: None,
2319                    contributions: Vec::new(),
2320                }
2321                .into(),
2322            ],
2323            summary: crate::health_types::HealthSummary {
2324                files_analyzed: 3,
2325                functions_analyzed: 10,
2326                functions_above_threshold: 1,
2327                ..Default::default()
2328            },
2329            ..Default::default()
2330        };
2331        let sarif = build_health_sarif(&report, &root);
2332        let entry = &sarif["runs"][0]["results"][0];
2333        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
2334        assert!(
2335            entry["message"]["text"]
2336                .as_str()
2337                .unwrap()
2338                .contains("cognitive complexity 20")
2339        );
2340        let region = &entry["locations"][0]["physicalLocation"]["region"];
2341        assert_eq!(region["startColumn"], 5); // col 4 + 1
2342    }
2343
2344    #[test]
2345    fn health_sarif_both_thresholds() {
2346        let root = PathBuf::from("/project");
2347        let report = crate::health_types::HealthReport {
2348            findings: vec![
2349                crate::health_types::ComplexityViolation {
2350                    path: root.join("src/complex.ts"),
2351                    name: "doEverything".to_string(),
2352                    line: 1,
2353                    col: 0,
2354                    cyclomatic: 30,
2355                    cognitive: 45,
2356                    line_count: 100,
2357                    param_count: 0,
2358                    exceeded: crate::health_types::ExceededThreshold::Both,
2359                    severity: crate::health_types::FindingSeverity::High,
2360                    crap: None,
2361                    coverage_pct: None,
2362                    coverage_tier: None,
2363                    coverage_source: None,
2364                    inherited_from: None,
2365                    component_rollup: None,
2366                    contributions: Vec::new(),
2367                }
2368                .into(),
2369            ],
2370            summary: crate::health_types::HealthSummary {
2371                files_analyzed: 1,
2372                functions_analyzed: 1,
2373                functions_above_threshold: 1,
2374                ..Default::default()
2375            },
2376            ..Default::default()
2377        };
2378        let sarif = build_health_sarif(&report, &root);
2379        let entry = &sarif["runs"][0]["results"][0];
2380        assert_eq!(entry["ruleId"], "fallow/high-complexity");
2381        let msg = entry["message"]["text"].as_str().unwrap();
2382        assert!(msg.contains("cyclomatic complexity 30"));
2383        assert!(msg.contains("cognitive complexity 45"));
2384    }
2385
2386    #[test]
2387    fn health_sarif_crap_only_emits_crap_rule() {
2388        let root = PathBuf::from("/project");
2389        let report = crate::health_types::HealthReport {
2390            findings: vec![
2391                crate::health_types::ComplexityViolation {
2392                    path: root.join("src/untested.ts"),
2393                    name: "risky".to_string(),
2394                    line: 8,
2395                    col: 0,
2396                    cyclomatic: 10,
2397                    cognitive: 10,
2398                    line_count: 20,
2399                    param_count: 1,
2400                    exceeded: crate::health_types::ExceededThreshold::Crap,
2401                    severity: crate::health_types::FindingSeverity::High,
2402                    crap: Some(82.2),
2403                    coverage_pct: Some(12.0),
2404                    coverage_tier: None,
2405                    coverage_source: None,
2406                    inherited_from: None,
2407                    component_rollup: None,
2408                    contributions: Vec::new(),
2409                }
2410                .into(),
2411            ],
2412            summary: crate::health_types::HealthSummary {
2413                files_analyzed: 1,
2414                functions_analyzed: 1,
2415                functions_above_threshold: 1,
2416                ..Default::default()
2417            },
2418            ..Default::default()
2419        };
2420        let sarif = build_health_sarif(&report, &root);
2421        let entry = &sarif["runs"][0]["results"][0];
2422        assert_eq!(entry["ruleId"], "fallow/high-crap-score");
2423        let msg = entry["message"]["text"].as_str().unwrap();
2424        assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
2425        assert!(msg.contains("coverage 12%"), "msg: {msg}");
2426    }
2427
2428    #[test]
2429    fn health_sarif_cyclomatic_crap_uses_crap_rule() {
2430        let root = PathBuf::from("/project");
2431        let report = crate::health_types::HealthReport {
2432            findings: vec![
2433                crate::health_types::ComplexityViolation {
2434                    path: root.join("src/hot.ts"),
2435                    name: "branchy".to_string(),
2436                    line: 1,
2437                    col: 0,
2438                    cyclomatic: 67,
2439                    cognitive: 12,
2440                    line_count: 80,
2441                    param_count: 1,
2442                    exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2443                    severity: crate::health_types::FindingSeverity::Critical,
2444                    crap: Some(182.0),
2445                    coverage_pct: None,
2446                    coverage_tier: None,
2447                    coverage_source: None,
2448                    inherited_from: None,
2449                    component_rollup: None,
2450                    contributions: Vec::new(),
2451                }
2452                .into(),
2453            ],
2454            summary: crate::health_types::HealthSummary {
2455                files_analyzed: 1,
2456                functions_analyzed: 1,
2457                functions_above_threshold: 1,
2458                ..Default::default()
2459            },
2460            ..Default::default()
2461        };
2462        let sarif = build_health_sarif(&report, &root);
2463        let results = sarif["runs"][0]["results"].as_array().unwrap();
2464        assert_eq!(
2465            results.len(),
2466            1,
2467            "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
2468        );
2469        assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
2470        let msg = results[0]["message"]["text"].as_str().unwrap();
2471        assert!(msg.contains("CRAP score 182"), "msg: {msg}");
2472        assert!(!msg.contains("coverage"), "msg: {msg}");
2473    }
2474
2475    #[test]
2476    fn severity_to_sarif_level_error() {
2477        assert_eq!(severity_to_sarif_level(Severity::Error), "error");
2478    }
2479
2480    #[test]
2481    fn severity_to_sarif_level_warn() {
2482        assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
2483    }
2484
2485    #[test]
2486    #[should_panic(expected = "internal error: entered unreachable code")]
2487    fn severity_to_sarif_level_off() {
2488        let _ = severity_to_sarif_level(Severity::Off);
2489    }
2490
2491    #[test]
2492    fn sarif_re_export_has_properties() {
2493        let root = PathBuf::from("/project");
2494        let mut results = AnalysisResults::default();
2495        results
2496            .unused_exports
2497            .push(UnusedExportFinding::with_actions(UnusedExport {
2498                path: root.join("src/index.ts"),
2499                export_name: "reExported".to_string(),
2500                is_type_only: false,
2501                line: 1,
2502                col: 0,
2503                span_start: 0,
2504                is_re_export: true,
2505            }));
2506
2507        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2508        let entry = &sarif["runs"][0]["results"][0];
2509        assert_eq!(entry["properties"]["is_re_export"], true);
2510        let msg = entry["message"]["text"].as_str().unwrap();
2511        assert!(msg.starts_with("Re-export"));
2512    }
2513
2514    #[test]
2515    fn sarif_non_re_export_has_no_properties() {
2516        let root = PathBuf::from("/project");
2517        let mut results = AnalysisResults::default();
2518        results
2519            .unused_exports
2520            .push(UnusedExportFinding::with_actions(UnusedExport {
2521                path: root.join("src/utils.ts"),
2522                export_name: "foo".to_string(),
2523                is_type_only: false,
2524                line: 5,
2525                col: 0,
2526                span_start: 0,
2527                is_re_export: false,
2528            }));
2529
2530        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2531        let entry = &sarif["runs"][0]["results"][0];
2532        assert!(entry.get("properties").is_none());
2533        let msg = entry["message"]["text"].as_str().unwrap();
2534        assert!(msg.starts_with("Export"));
2535    }
2536
2537    #[test]
2538    fn sarif_type_re_export_message() {
2539        let root = PathBuf::from("/project");
2540        let mut results = AnalysisResults::default();
2541        results
2542            .unused_types
2543            .push(UnusedTypeFinding::with_actions(UnusedExport {
2544                path: root.join("src/index.ts"),
2545                export_name: "MyType".to_string(),
2546                is_type_only: true,
2547                line: 1,
2548                col: 0,
2549                span_start: 0,
2550                is_re_export: true,
2551            }));
2552
2553        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2554        let entry = &sarif["runs"][0]["results"][0];
2555        assert_eq!(entry["ruleId"], "fallow/unused-type");
2556        let msg = entry["message"]["text"].as_str().unwrap();
2557        assert!(msg.starts_with("Type re-export"));
2558        assert_eq!(entry["properties"]["is_re_export"], true);
2559    }
2560
2561    #[test]
2562    fn sarif_dependency_line_zero_skips_region() {
2563        let root = PathBuf::from("/project");
2564        let mut results = AnalysisResults::default();
2565        results
2566            .unused_dependencies
2567            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2568                package_name: "lodash".to_string(),
2569                location: DependencyLocation::Dependencies,
2570                path: root.join("package.json"),
2571                line: 0,
2572                used_in_workspaces: Vec::new(),
2573            }));
2574
2575        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2576        let entry = &sarif["runs"][0]["results"][0];
2577        let phys = &entry["locations"][0]["physicalLocation"];
2578        assert!(phys.get("region").is_none());
2579    }
2580
2581    #[test]
2582    fn sarif_dependency_line_nonzero_has_region() {
2583        let root = PathBuf::from("/project");
2584        let mut results = AnalysisResults::default();
2585        results
2586            .unused_dependencies
2587            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2588                package_name: "lodash".to_string(),
2589                location: DependencyLocation::Dependencies,
2590                path: root.join("package.json"),
2591                line: 7,
2592                used_in_workspaces: Vec::new(),
2593            }));
2594
2595        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2596        let entry = &sarif["runs"][0]["results"][0];
2597        let region = &entry["locations"][0]["physicalLocation"]["region"];
2598        assert_eq!(region["startLine"], 7);
2599        assert_eq!(region["startColumn"], 1);
2600    }
2601
2602    #[test]
2603    fn sarif_type_only_dep_line_zero_skips_region() {
2604        let root = PathBuf::from("/project");
2605        let mut results = AnalysisResults::default();
2606        results
2607            .type_only_dependencies
2608            .push(TypeOnlyDependencyFinding::with_actions(
2609                TypeOnlyDependency {
2610                    package_name: "zod".to_string(),
2611                    path: root.join("package.json"),
2612                    line: 0,
2613                },
2614            ));
2615
2616        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2617        let entry = &sarif["runs"][0]["results"][0];
2618        let phys = &entry["locations"][0]["physicalLocation"];
2619        assert!(phys.get("region").is_none());
2620    }
2621
2622    #[test]
2623    fn sarif_circular_dep_line_zero_skips_region() {
2624        let root = PathBuf::from("/project");
2625        let mut results = AnalysisResults::default();
2626        results
2627            .circular_dependencies
2628            .push(CircularDependencyFinding::with_actions(
2629                CircularDependency {
2630                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2631                    length: 2,
2632                    line: 0,
2633                    col: 0,
2634                    edges: Vec::new(),
2635                    is_cross_package: false,
2636                },
2637            ));
2638
2639        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2640        let entry = &sarif["runs"][0]["results"][0];
2641        let phys = &entry["locations"][0]["physicalLocation"];
2642        assert!(phys.get("region").is_none());
2643    }
2644
2645    #[test]
2646    fn sarif_circular_dep_line_nonzero_has_region() {
2647        let root = PathBuf::from("/project");
2648        let mut results = AnalysisResults::default();
2649        results
2650            .circular_dependencies
2651            .push(CircularDependencyFinding::with_actions(
2652                CircularDependency {
2653                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2654                    length: 2,
2655                    line: 5,
2656                    col: 2,
2657                    edges: Vec::new(),
2658                    is_cross_package: false,
2659                },
2660            ));
2661
2662        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2663        let entry = &sarif["runs"][0]["results"][0];
2664        let region = &entry["locations"][0]["physicalLocation"]["region"];
2665        assert_eq!(region["startLine"], 5);
2666        assert_eq!(region["startColumn"], 3);
2667    }
2668
2669    #[test]
2670    fn sarif_unused_optional_dependency_result() {
2671        let root = PathBuf::from("/project");
2672        let mut results = AnalysisResults::default();
2673        results
2674            .unused_optional_dependencies
2675            .push(UnusedOptionalDependencyFinding::with_actions(
2676                UnusedDependency {
2677                    package_name: "fsevents".to_string(),
2678                    location: DependencyLocation::OptionalDependencies,
2679                    path: root.join("package.json"),
2680                    line: 12,
2681                    used_in_workspaces: Vec::new(),
2682                },
2683            ));
2684
2685        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2686        let entry = &sarif["runs"][0]["results"][0];
2687        assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
2688        let msg = entry["message"]["text"].as_str().unwrap();
2689        assert!(msg.contains("optionalDependencies"));
2690    }
2691
2692    #[test]
2693    fn sarif_enum_member_message_format() {
2694        let root = PathBuf::from("/project");
2695        let mut results = AnalysisResults::default();
2696        results.unused_enum_members.push(
2697            fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
2698                path: root.join("src/enums.ts"),
2699                parent_name: "Color".to_string(),
2700                member_name: "Purple".to_string(),
2701                kind: fallow_core::extract::MemberKind::EnumMember,
2702                line: 5,
2703                col: 2,
2704            }),
2705        );
2706
2707        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2708        let entry = &sarif["runs"][0]["results"][0];
2709        assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
2710        let msg = entry["message"]["text"].as_str().unwrap();
2711        assert!(msg.contains("Enum member 'Color.Purple'"));
2712        let region = &entry["locations"][0]["physicalLocation"]["region"];
2713        assert_eq!(region["startColumn"], 3); // col 2 + 1
2714    }
2715
2716    #[test]
2717    fn sarif_class_member_message_format() {
2718        let root = PathBuf::from("/project");
2719        let mut results = AnalysisResults::default();
2720        results.unused_class_members.push(
2721            fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
2722                path: root.join("src/service.ts"),
2723                parent_name: "API".to_string(),
2724                member_name: "fetch".to_string(),
2725                kind: fallow_core::extract::MemberKind::ClassMethod,
2726                line: 10,
2727                col: 4,
2728            }),
2729        );
2730
2731        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2732        let entry = &sarif["runs"][0]["results"][0];
2733        assert_eq!(entry["ruleId"], "fallow/unused-class-member");
2734        let msg = entry["message"]["text"].as_str().unwrap();
2735        assert!(msg.contains("Class member 'API.fetch'"));
2736    }
2737
2738    #[test]
2739    #[expect(
2740        clippy::cast_possible_truncation,
2741        reason = "test line/col values are trivially small"
2742    )]
2743    fn duplication_sarif_structure() {
2744        use fallow_core::duplicates::*;
2745
2746        let root = PathBuf::from("/project");
2747        let report = DuplicationReport {
2748            clone_groups: vec![CloneGroup {
2749                instances: vec![
2750                    CloneInstance {
2751                        file: root.join("src/a.ts"),
2752                        start_line: 1,
2753                        end_line: 10,
2754                        start_col: 0,
2755                        end_col: 0,
2756                        fragment: String::new(),
2757                    },
2758                    CloneInstance {
2759                        file: root.join("src/b.ts"),
2760                        start_line: 5,
2761                        end_line: 14,
2762                        start_col: 2,
2763                        end_col: 0,
2764                        fragment: String::new(),
2765                    },
2766                ],
2767                token_count: 50,
2768                line_count: 10,
2769            }],
2770            clone_families: vec![],
2771            mirrored_directories: vec![],
2772            stats: DuplicationStats::default(),
2773        };
2774
2775        let sarif = serde_json::json!({
2776            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2777            "version": "2.1.0",
2778            "runs": [{
2779                "tool": {
2780                    "driver": {
2781                        "name": "fallow",
2782                        "version": env!("CARGO_PKG_VERSION"),
2783                        "informationUri": "https://github.com/fallow-rs/fallow",
2784                        "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2785                    }
2786                },
2787                "results": []
2788            }]
2789        });
2790        let _ = sarif;
2791
2792        let mut sarif_results = Vec::new();
2793        for (i, group) in report.clone_groups.iter().enumerate() {
2794            for instance in &group.instances {
2795                sarif_results.push(sarif_result(
2796                    "fallow/code-duplication",
2797                    "warning",
2798                    &format!(
2799                        "Code clone group {} ({} lines, {} instances)",
2800                        i + 1,
2801                        group.line_count,
2802                        group.instances.len()
2803                    ),
2804                    &super::super::relative_uri(&instance.file, &root),
2805                    Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2806                ));
2807            }
2808        }
2809        assert_eq!(sarif_results.len(), 2);
2810        assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
2811        assert!(
2812            sarif_results[0]["message"]["text"]
2813                .as_str()
2814                .unwrap()
2815                .contains("10 lines")
2816        );
2817        let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
2818        assert_eq!(region0["startLine"], 1);
2819        assert_eq!(region0["startColumn"], 1); // start_col 0 + 1
2820        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
2821        assert_eq!(region1["startLine"], 5);
2822        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
2823    }
2824
2825    #[test]
2826    fn sarif_rule_known_id_has_full_description() {
2827        let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
2828        assert!(rule.get("fullDescription").is_some());
2829        assert!(rule.get("helpUri").is_some());
2830    }
2831
2832    #[test]
2833    fn sarif_rule_unknown_id_uses_fallback() {
2834        let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
2835        assert_eq!(rule["shortDescription"]["text"], "fallback text");
2836        assert!(rule.get("fullDescription").is_none());
2837        assert!(rule.get("helpUri").is_none());
2838        assert_eq!(rule["defaultConfiguration"]["level"], "warning");
2839    }
2840
2841    #[test]
2842    fn sarif_result_no_region_omits_region_key() {
2843        let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
2844        let phys = &result["locations"][0]["physicalLocation"];
2845        assert!(phys.get("region").is_none());
2846        assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
2847    }
2848
2849    #[test]
2850    fn sarif_result_with_region_includes_region() {
2851        let result = sarif_result(
2852            "rule/test",
2853            "error",
2854            "test msg",
2855            "src/file.ts",
2856            Some((10, 5)),
2857        );
2858        let region = &result["locations"][0]["physicalLocation"]["region"];
2859        assert_eq!(region["startLine"], 10);
2860        assert_eq!(region["startColumn"], 5);
2861    }
2862
2863    #[test]
2864    fn sarif_partial_fingerprint_ignores_rendered_message() {
2865        let a = sarif_result(
2866            "rule/test",
2867            "error",
2868            "first message",
2869            "src/file.ts",
2870            Some((10, 5)),
2871        );
2872        let b = sarif_result(
2873            "rule/test",
2874            "error",
2875            "rewritten message",
2876            "src/file.ts",
2877            Some((10, 5)),
2878        );
2879        assert_eq!(
2880            a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2881            b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2882        );
2883    }
2884
2885    #[test]
2886    fn health_sarif_includes_refactoring_targets() {
2887        use crate::health_types::*;
2888
2889        let root = PathBuf::from("/project");
2890        let report = HealthReport {
2891            summary: HealthSummary {
2892                files_analyzed: 10,
2893                functions_analyzed: 50,
2894                ..Default::default()
2895            },
2896            targets: vec![
2897                RefactoringTarget {
2898                    path: root.join("src/complex.ts"),
2899                    priority: 85.0,
2900                    efficiency: 42.5,
2901                    recommendation: "Split high-impact file".into(),
2902                    category: RecommendationCategory::SplitHighImpact,
2903                    effort: EffortEstimate::Medium,
2904                    confidence: Confidence::High,
2905                    factors: vec![],
2906                    evidence: None,
2907                }
2908                .into(),
2909            ],
2910            ..Default::default()
2911        };
2912
2913        let sarif = build_health_sarif(&report, &root);
2914        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2915        assert_eq!(entries.len(), 1);
2916        assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
2917        assert_eq!(entries[0]["level"], "warning");
2918        let msg = entries[0]["message"]["text"].as_str().unwrap();
2919        assert!(msg.contains("high impact"));
2920        assert!(msg.contains("Split high-impact file"));
2921        assert!(msg.contains("42.5"));
2922    }
2923
2924    #[test]
2925    fn health_sarif_includes_coverage_gaps() {
2926        use crate::health_types::*;
2927
2928        let root = PathBuf::from("/project");
2929        let report = HealthReport {
2930            summary: HealthSummary {
2931                files_analyzed: 10,
2932                functions_analyzed: 50,
2933                ..Default::default()
2934            },
2935            coverage_gaps: Some(CoverageGaps {
2936                summary: CoverageGapSummary {
2937                    runtime_files: 2,
2938                    covered_files: 0,
2939                    file_coverage_pct: 0.0,
2940                    untested_files: 1,
2941                    untested_exports: 1,
2942                },
2943                files: vec![UntestedFileFinding::with_actions(
2944                    UntestedFile {
2945                        path: root.join("src/app.ts"),
2946                        value_export_count: 2,
2947                    },
2948                    &root,
2949                )],
2950                exports: vec![UntestedExportFinding::with_actions(
2951                    UntestedExport {
2952                        path: root.join("src/app.ts"),
2953                        export_name: "loader".into(),
2954                        line: 12,
2955                        col: 4,
2956                    },
2957                    &root,
2958                )],
2959            }),
2960            ..Default::default()
2961        };
2962
2963        let sarif = build_health_sarif(&report, &root);
2964        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2965        assert_eq!(entries.len(), 2);
2966        assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
2967        assert_eq!(
2968            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2969            "src/app.ts"
2970        );
2971        assert!(
2972            entries[0]["message"]["text"]
2973                .as_str()
2974                .unwrap()
2975                .contains("2 value exports")
2976        );
2977        assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
2978        assert_eq!(
2979            entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
2980            12
2981        );
2982        assert_eq!(
2983            entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
2984            5
2985        );
2986    }
2987
2988    #[test]
2989    fn health_sarif_rules_have_full_descriptions() {
2990        let root = PathBuf::from("/project");
2991        let report = crate::health_types::HealthReport::default();
2992        let sarif = build_health_sarif(&report, &root);
2993        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2994            .as_array()
2995            .unwrap();
2996        for rule in rules {
2997            let id = rule["id"].as_str().unwrap();
2998            assert!(
2999                rule.get("fullDescription").is_some(),
3000                "health rule {id} should have fullDescription"
3001            );
3002            assert!(
3003                rule.get("helpUri").is_some(),
3004                "health rule {id} should have helpUri"
3005            );
3006        }
3007    }
3008
3009    #[test]
3010    fn sarif_warn_severity_produces_warning_level() {
3011        let root = PathBuf::from("/project");
3012        let mut results = AnalysisResults::default();
3013        results
3014            .unused_files
3015            .push(UnusedFileFinding::with_actions(UnusedFile {
3016                path: root.join("src/dead.ts"),
3017            }));
3018
3019        let rules = RulesConfig {
3020            unused_files: Severity::Warn,
3021            ..RulesConfig::default()
3022        };
3023
3024        let sarif = build_sarif(&results, &root, &rules);
3025        let entry = &sarif["runs"][0]["results"][0];
3026        assert_eq!(entry["level"], "warning");
3027    }
3028
3029    #[test]
3030    fn sarif_unused_file_has_no_region() {
3031        let root = PathBuf::from("/project");
3032        let mut results = AnalysisResults::default();
3033        results
3034            .unused_files
3035            .push(UnusedFileFinding::with_actions(UnusedFile {
3036                path: root.join("src/dead.ts"),
3037            }));
3038
3039        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3040        let entry = &sarif["runs"][0]["results"][0];
3041        let phys = &entry["locations"][0]["physicalLocation"];
3042        assert!(phys.get("region").is_none());
3043    }
3044
3045    #[test]
3046    fn sarif_unlisted_dep_multiple_import_sites() {
3047        let root = PathBuf::from("/project");
3048        let mut results = AnalysisResults::default();
3049        results
3050            .unlisted_dependencies
3051            .push(UnlistedDependencyFinding::with_actions(
3052                UnlistedDependency {
3053                    package_name: "dotenv".to_string(),
3054                    imported_from: vec![
3055                        ImportSite {
3056                            path: root.join("src/a.ts"),
3057                            line: 1,
3058                            col: 0,
3059                        },
3060                        ImportSite {
3061                            path: root.join("src/b.ts"),
3062                            line: 5,
3063                            col: 0,
3064                        },
3065                    ],
3066                },
3067            ));
3068
3069        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3070        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3071        assert_eq!(entries.len(), 2);
3072        assert_eq!(
3073            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3074            "src/a.ts"
3075        );
3076        assert_eq!(
3077            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3078            "src/b.ts"
3079        );
3080    }
3081
3082    #[test]
3083    fn sarif_unlisted_dep_no_import_sites() {
3084        let root = PathBuf::from("/project");
3085        let mut results = AnalysisResults::default();
3086        results
3087            .unlisted_dependencies
3088            .push(UnlistedDependencyFinding::with_actions(
3089                UnlistedDependency {
3090                    package_name: "phantom".to_string(),
3091                    imported_from: vec![],
3092                },
3093            ));
3094
3095        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3096        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3097        assert!(entries.is_empty());
3098    }
3099}