Skip to main content

fallow_cli/report/
sarif.rs

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