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