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// ── Health SARIF output ────────────────────────────────────────────
689// Note: file_scores are intentionally omitted from SARIF output.
690// SARIF is designed for diagnostic results (issues/findings), not metric tables.
691// File health scores are available in JSON, human, compact, and markdown formats.
692
693#[must_use]
694#[expect(
695    clippy::too_many_lines,
696    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"
697)]
698pub fn build_health_sarif(
699    report: &crate::health_types::HealthReport,
700    root: &Path,
701) -> serde_json::Value {
702    use crate::health_types::ExceededThreshold;
703
704    let mut sarif_results = Vec::new();
705
706    for finding in &report.findings {
707        let uri = relative_uri(&finding.path, root);
708        // When CRAP contributes alongside complexity, use the CRAP rule as the
709        // most actionable identifier (CRAP combines complexity and coverage)
710        // and surface all exceeded dimensions in the message.
711        let (rule_id, message) = match finding.exceeded {
712            ExceededThreshold::Cyclomatic => (
713                "fallow/high-cyclomatic-complexity",
714                format!(
715                    "'{}' has cyclomatic complexity {} (threshold: {})",
716                    finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
717                ),
718            ),
719            ExceededThreshold::Cognitive => (
720                "fallow/high-cognitive-complexity",
721                format!(
722                    "'{}' has cognitive complexity {} (threshold: {})",
723                    finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
724                ),
725            ),
726            ExceededThreshold::Both => (
727                "fallow/high-complexity",
728                format!(
729                    "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
730                    finding.name,
731                    finding.cyclomatic,
732                    report.summary.max_cyclomatic_threshold,
733                    finding.cognitive,
734                    report.summary.max_cognitive_threshold,
735                ),
736            ),
737            ExceededThreshold::Crap
738            | ExceededThreshold::CyclomaticCrap
739            | ExceededThreshold::CognitiveCrap
740            | ExceededThreshold::All => {
741                let crap = finding.crap.unwrap_or(0.0);
742                let coverage = finding
743                    .coverage_pct
744                    .map(|pct| format!(", coverage {pct:.0}%"))
745                    .unwrap_or_default();
746                (
747                    "fallow/high-crap-score",
748                    format!(
749                        "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
750                        finding.name,
751                        crap,
752                        report.summary.max_crap_threshold,
753                        finding.cyclomatic,
754                        coverage,
755                    ),
756                )
757            }
758        };
759
760        let level = match finding.severity {
761            crate::health_types::FindingSeverity::Critical => "error",
762            crate::health_types::FindingSeverity::High => "warning",
763            crate::health_types::FindingSeverity::Moderate => "note",
764        };
765        sarif_results.push(sarif_result(
766            rule_id,
767            level,
768            &message,
769            &uri,
770            Some((finding.line, finding.col + 1)),
771        ));
772    }
773
774    if let Some(ref production) = report.runtime_coverage {
775        append_runtime_coverage_sarif_results(&mut sarif_results, production, root);
776    }
777
778    // Refactoring targets as SARIF results (warning level — advisory recommendations)
779    for target in &report.targets {
780        let uri = relative_uri(&target.path, root);
781        let message = format!(
782            "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
783            target.category.label(),
784            target.recommendation,
785            target.priority,
786            target.efficiency,
787            target.effort.label(),
788            target.confidence.label(),
789        );
790        sarif_results.push(sarif_result(
791            "fallow/refactoring-target",
792            "warning",
793            &message,
794            &uri,
795            None,
796        ));
797    }
798
799    if let Some(ref gaps) = report.coverage_gaps {
800        for item in &gaps.files {
801            let uri = relative_uri(&item.path, root);
802            let message = format!(
803                "File is runtime-reachable but has no test dependency path ({} value export{})",
804                item.value_export_count,
805                if item.value_export_count == 1 {
806                    ""
807                } else {
808                    "s"
809                },
810            );
811            sarif_results.push(sarif_result(
812                "fallow/untested-file",
813                "warning",
814                &message,
815                &uri,
816                None,
817            ));
818        }
819
820        for item in &gaps.exports {
821            let uri = relative_uri(&item.path, root);
822            let message = format!(
823                "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
824                item.export_name
825            );
826            sarif_results.push(sarif_result(
827                "fallow/untested-export",
828                "warning",
829                &message,
830                &uri,
831                Some((item.line, item.col + 1)),
832            ));
833        }
834    }
835
836    let health_rules = vec![
837        sarif_rule(
838            "fallow/high-cyclomatic-complexity",
839            "Function has high cyclomatic complexity",
840            "note",
841        ),
842        sarif_rule(
843            "fallow/high-cognitive-complexity",
844            "Function has high cognitive complexity",
845            "note",
846        ),
847        sarif_rule(
848            "fallow/high-complexity",
849            "Function exceeds both complexity thresholds",
850            "note",
851        ),
852        sarif_rule(
853            "fallow/high-crap-score",
854            "Function has a high CRAP score (high complexity combined with low coverage)",
855            "warning",
856        ),
857        sarif_rule(
858            "fallow/refactoring-target",
859            "File identified as a high-priority refactoring candidate",
860            "warning",
861        ),
862        sarif_rule(
863            "fallow/untested-file",
864            "Runtime-reachable file has no test dependency path",
865            "warning",
866        ),
867        sarif_rule(
868            "fallow/untested-export",
869            "Runtime-reachable export has no test dependency path",
870            "warning",
871        ),
872        sarif_rule(
873            "fallow/runtime-safe-to-delete",
874            "Function is statically unused and was never invoked in production",
875            "warning",
876        ),
877        sarif_rule(
878            "fallow/runtime-review-required",
879            "Function is statically used but was never invoked in production",
880            "warning",
881        ),
882        sarif_rule(
883            "fallow/runtime-low-traffic",
884            "Function was invoked below the low-traffic threshold relative to total trace count",
885            "note",
886        ),
887        sarif_rule(
888            "fallow/runtime-coverage-unavailable",
889            "Runtime coverage could not be resolved for this function",
890            "note",
891        ),
892        sarif_rule(
893            "fallow/runtime-coverage",
894            "Runtime coverage finding",
895            "note",
896        ),
897    ];
898
899    serde_json::json!({
900        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
901        "version": "2.1.0",
902        "runs": [{
903            "tool": {
904                "driver": {
905                    "name": "fallow",
906                    "version": env!("CARGO_PKG_VERSION"),
907                    "informationUri": "https://github.com/fallow-rs/fallow",
908                    "rules": health_rules
909                }
910            },
911            "results": sarif_results
912        }]
913    })
914}
915
916fn append_runtime_coverage_sarif_results(
917    sarif_results: &mut Vec<serde_json::Value>,
918    production: &crate::health_types::RuntimeCoverageReport,
919    root: &Path,
920) {
921    for finding in &production.findings {
922        let uri = relative_uri(&finding.path, root);
923        let rule_id = match finding.verdict {
924            crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
925                "fallow/runtime-safe-to-delete"
926            }
927            crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
928                "fallow/runtime-review-required"
929            }
930            crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
931            crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
932                "fallow/runtime-coverage-unavailable"
933            }
934            crate::health_types::RuntimeCoverageVerdict::Active
935            | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
936        };
937        let level = match finding.verdict {
938            crate::health_types::RuntimeCoverageVerdict::SafeToDelete
939            | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
940            _ => "note",
941        };
942        let invocations_hint = finding.invocations.map_or_else(
943            || "untracked".to_owned(),
944            |hits| format!("{hits} invocations"),
945        );
946        let message = format!(
947            "'{}' runtime coverage verdict: {} ({})",
948            finding.function,
949            finding.verdict.human_label(),
950            invocations_hint,
951        );
952        sarif_results.push(sarif_result(
953            rule_id,
954            level,
955            &message,
956            &uri,
957            Some((finding.line, 1)),
958        ));
959    }
960}
961
962pub(super) fn print_health_sarif(
963    report: &crate::health_types::HealthReport,
964    root: &Path,
965) -> ExitCode {
966    let sarif = build_health_sarif(report, root);
967    emit_json(&sarif, "SARIF")
968}
969
970/// Print health SARIF with a per-result `properties.group` tag.
971///
972/// Mirrors the dead-code grouped SARIF pattern (`print_grouped_sarif`):
973/// build the standard SARIF first, then post-process each result to inject
974/// the resolver-derived group key on `properties.group`. Consumers that read
975/// SARIF (GitHub Code Scanning, GitLab Code Quality) can then partition
976/// findings per team / package / directory without dropping out of the
977/// SARIF pipeline. Each finding's URI is decoded (`%5B` -> `[`, `%5D` -> `]`)
978/// before resolution, matching the dead-code behaviour for paths containing
979/// brackets like Next.js dynamic routes.
980pub(super) fn print_grouped_health_sarif(
981    report: &crate::health_types::HealthReport,
982    root: &Path,
983    resolver: &OwnershipResolver,
984) -> ExitCode {
985    let mut sarif = build_health_sarif(report, root);
986
987    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
988        for run in runs {
989            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
990                for result in results {
991                    let uri = result
992                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
993                        .and_then(|v| v.as_str())
994                        .unwrap_or("");
995                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
996                    let group =
997                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
998                    let props = result
999                        .as_object_mut()
1000                        .expect("SARIF result should be an object")
1001                        .entry("properties")
1002                        .or_insert_with(|| serde_json::json!({}));
1003                    props
1004                        .as_object_mut()
1005                        .expect("properties should be an object")
1006                        .insert("group".to_string(), serde_json::Value::String(group));
1007                }
1008            }
1009        }
1010    }
1011
1012    emit_json(&sarif, "SARIF")
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017    use super::*;
1018    use crate::report::test_helpers::sample_results;
1019    use fallow_core::results::*;
1020    use std::path::PathBuf;
1021
1022    #[test]
1023    fn sarif_has_required_top_level_fields() {
1024        let root = PathBuf::from("/project");
1025        let results = AnalysisResults::default();
1026        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1027
1028        assert_eq!(
1029            sarif["$schema"],
1030            "https://json.schemastore.org/sarif-2.1.0.json"
1031        );
1032        assert_eq!(sarif["version"], "2.1.0");
1033        assert!(sarif["runs"].is_array());
1034    }
1035
1036    #[test]
1037    fn sarif_has_tool_driver_info() {
1038        let root = PathBuf::from("/project");
1039        let results = AnalysisResults::default();
1040        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1041
1042        let driver = &sarif["runs"][0]["tool"]["driver"];
1043        assert_eq!(driver["name"], "fallow");
1044        assert!(driver["version"].is_string());
1045        assert_eq!(
1046            driver["informationUri"],
1047            "https://github.com/fallow-rs/fallow"
1048        );
1049    }
1050
1051    #[test]
1052    fn sarif_declares_all_rules() {
1053        let root = PathBuf::from("/project");
1054        let results = AnalysisResults::default();
1055        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1056
1057        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1058            .as_array()
1059            .expect("rules should be an array");
1060        assert_eq!(rules.len(), 16);
1061
1062        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1063        assert!(rule_ids.contains(&"fallow/unused-file"));
1064        assert!(rule_ids.contains(&"fallow/unused-export"));
1065        assert!(rule_ids.contains(&"fallow/unused-type"));
1066        assert!(rule_ids.contains(&"fallow/unused-dependency"));
1067        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1068        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1069        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1070        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1071        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1072        assert!(rule_ids.contains(&"fallow/unused-class-member"));
1073        assert!(rule_ids.contains(&"fallow/unresolved-import"));
1074        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1075        assert!(rule_ids.contains(&"fallow/duplicate-export"));
1076        assert!(rule_ids.contains(&"fallow/circular-dependency"));
1077        assert!(rule_ids.contains(&"fallow/boundary-violation"));
1078    }
1079
1080    #[test]
1081    fn sarif_empty_results_no_results_entries() {
1082        let root = PathBuf::from("/project");
1083        let results = AnalysisResults::default();
1084        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1085
1086        let sarif_results = sarif["runs"][0]["results"]
1087            .as_array()
1088            .expect("results should be an array");
1089        assert!(sarif_results.is_empty());
1090    }
1091
1092    #[test]
1093    fn sarif_unused_file_result() {
1094        let root = PathBuf::from("/project");
1095        let mut results = AnalysisResults::default();
1096        results.unused_files.push(UnusedFile {
1097            path: root.join("src/dead.ts"),
1098        });
1099
1100        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1101        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1102        assert_eq!(entries.len(), 1);
1103
1104        let entry = &entries[0];
1105        assert_eq!(entry["ruleId"], "fallow/unused-file");
1106        // Default severity is "error" per RulesConfig::default()
1107        assert_eq!(entry["level"], "error");
1108        assert_eq!(
1109            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1110            "src/dead.ts"
1111        );
1112    }
1113
1114    #[test]
1115    fn sarif_unused_export_includes_region() {
1116        let root = PathBuf::from("/project");
1117        let mut results = AnalysisResults::default();
1118        results.unused_exports.push(UnusedExport {
1119            path: root.join("src/utils.ts"),
1120            export_name: "helperFn".to_string(),
1121            is_type_only: false,
1122            line: 10,
1123            col: 4,
1124            span_start: 120,
1125            is_re_export: false,
1126        });
1127
1128        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1129        let entry = &sarif["runs"][0]["results"][0];
1130        assert_eq!(entry["ruleId"], "fallow/unused-export");
1131
1132        let region = &entry["locations"][0]["physicalLocation"]["region"];
1133        assert_eq!(region["startLine"], 10);
1134        // SARIF columns are 1-based, code adds +1 to the 0-based col
1135        assert_eq!(region["startColumn"], 5);
1136    }
1137
1138    #[test]
1139    fn sarif_unresolved_import_is_error_level() {
1140        let root = PathBuf::from("/project");
1141        let mut results = AnalysisResults::default();
1142        results.unresolved_imports.push(UnresolvedImport {
1143            path: root.join("src/app.ts"),
1144            specifier: "./missing".to_string(),
1145            line: 1,
1146            col: 0,
1147            specifier_col: 0,
1148        });
1149
1150        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1151        let entry = &sarif["runs"][0]["results"][0];
1152        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1153        assert_eq!(entry["level"], "error");
1154    }
1155
1156    #[test]
1157    fn sarif_unlisted_dependency_points_to_import_site() {
1158        let root = PathBuf::from("/project");
1159        let mut results = AnalysisResults::default();
1160        results.unlisted_dependencies.push(UnlistedDependency {
1161            package_name: "chalk".to_string(),
1162            imported_from: vec![ImportSite {
1163                path: root.join("src/cli.ts"),
1164                line: 3,
1165                col: 0,
1166            }],
1167        });
1168
1169        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1170        let entry = &sarif["runs"][0]["results"][0];
1171        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1172        assert_eq!(entry["level"], "error");
1173        assert_eq!(
1174            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1175            "src/cli.ts"
1176        );
1177        let region = &entry["locations"][0]["physicalLocation"]["region"];
1178        assert_eq!(region["startLine"], 3);
1179        assert_eq!(region["startColumn"], 1);
1180    }
1181
1182    #[test]
1183    fn sarif_dependency_issues_point_to_package_json() {
1184        let root = PathBuf::from("/project");
1185        let mut results = AnalysisResults::default();
1186        results.unused_dependencies.push(UnusedDependency {
1187            package_name: "lodash".to_string(),
1188            location: DependencyLocation::Dependencies,
1189            path: root.join("package.json"),
1190            line: 5,
1191        });
1192        results.unused_dev_dependencies.push(UnusedDependency {
1193            package_name: "jest".to_string(),
1194            location: DependencyLocation::DevDependencies,
1195            path: root.join("package.json"),
1196            line: 5,
1197        });
1198
1199        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1200        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1201        for entry in entries {
1202            assert_eq!(
1203                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1204                "package.json"
1205            );
1206        }
1207    }
1208
1209    #[test]
1210    fn sarif_duplicate_export_emits_one_result_per_location() {
1211        let root = PathBuf::from("/project");
1212        let mut results = AnalysisResults::default();
1213        results.duplicate_exports.push(DuplicateExport {
1214            export_name: "Config".to_string(),
1215            locations: vec![
1216                DuplicateLocation {
1217                    path: root.join("src/a.ts"),
1218                    line: 15,
1219                    col: 0,
1220                },
1221                DuplicateLocation {
1222                    path: root.join("src/b.ts"),
1223                    line: 30,
1224                    col: 0,
1225                },
1226            ],
1227        });
1228
1229        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1230        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1231        // One SARIF result per location, not one per DuplicateExport
1232        assert_eq!(entries.len(), 2);
1233        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1234        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1235        assert_eq!(
1236            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1237            "src/a.ts"
1238        );
1239        assert_eq!(
1240            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1241            "src/b.ts"
1242        );
1243    }
1244
1245    #[test]
1246    fn sarif_all_issue_types_produce_results() {
1247        let root = PathBuf::from("/project");
1248        let results = sample_results(&root);
1249        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1250
1251        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1252        // All issue types with one entry each; duplicate_exports has 2 locations => one extra SARIF result
1253        assert_eq!(entries.len(), results.total_issues() + 1);
1254
1255        let rule_ids: Vec<&str> = entries
1256            .iter()
1257            .map(|e| e["ruleId"].as_str().unwrap())
1258            .collect();
1259        assert!(rule_ids.contains(&"fallow/unused-file"));
1260        assert!(rule_ids.contains(&"fallow/unused-export"));
1261        assert!(rule_ids.contains(&"fallow/unused-type"));
1262        assert!(rule_ids.contains(&"fallow/unused-dependency"));
1263        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1264        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1265        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1266        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1267        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1268        assert!(rule_ids.contains(&"fallow/unused-class-member"));
1269        assert!(rule_ids.contains(&"fallow/unresolved-import"));
1270        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1271        assert!(rule_ids.contains(&"fallow/duplicate-export"));
1272    }
1273
1274    #[test]
1275    fn sarif_serializes_to_valid_json() {
1276        let root = PathBuf::from("/project");
1277        let results = sample_results(&root);
1278        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1279
1280        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1281        let reparsed: serde_json::Value =
1282            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1283        assert_eq!(reparsed, sarif);
1284    }
1285
1286    #[test]
1287    fn sarif_file_write_produces_valid_sarif() {
1288        let root = PathBuf::from("/project");
1289        let results = sample_results(&root);
1290        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1291        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1292
1293        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1294        let _ = std::fs::create_dir_all(&dir);
1295        let sarif_path = dir.join("results.sarif");
1296        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1297
1298        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1299        let parsed: serde_json::Value =
1300            serde_json::from_str(&contents).expect("file should contain valid JSON");
1301
1302        assert_eq!(parsed["version"], "2.1.0");
1303        assert_eq!(
1304            parsed["$schema"],
1305            "https://json.schemastore.org/sarif-2.1.0.json"
1306        );
1307        let sarif_results = parsed["runs"][0]["results"]
1308            .as_array()
1309            .expect("results should be an array");
1310        assert!(!sarif_results.is_empty());
1311
1312        // Clean up
1313        let _ = std::fs::remove_file(&sarif_path);
1314        let _ = std::fs::remove_dir(&dir);
1315    }
1316
1317    // ── Health SARIF ──
1318
1319    #[test]
1320    fn health_sarif_empty_no_results() {
1321        let root = PathBuf::from("/project");
1322        let report = crate::health_types::HealthReport {
1323            summary: crate::health_types::HealthSummary {
1324                files_analyzed: 10,
1325                functions_analyzed: 50,
1326                ..Default::default()
1327            },
1328            ..Default::default()
1329        };
1330        let sarif = build_health_sarif(&report, &root);
1331        assert_eq!(sarif["version"], "2.1.0");
1332        let results = sarif["runs"][0]["results"].as_array().unwrap();
1333        assert!(results.is_empty());
1334        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1335            .as_array()
1336            .unwrap();
1337        assert_eq!(rules.len(), 12);
1338    }
1339
1340    #[test]
1341    fn health_sarif_cyclomatic_only() {
1342        let root = PathBuf::from("/project");
1343        let report = crate::health_types::HealthReport {
1344            findings: vec![crate::health_types::HealthFinding {
1345                path: root.join("src/utils.ts"),
1346                name: "parseExpression".to_string(),
1347                line: 42,
1348                col: 0,
1349                cyclomatic: 25,
1350                cognitive: 10,
1351                line_count: 80,
1352                param_count: 0,
1353                exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1354                severity: crate::health_types::FindingSeverity::High,
1355                crap: None,
1356                coverage_pct: None,
1357                coverage_tier: None,
1358            }],
1359            summary: crate::health_types::HealthSummary {
1360                files_analyzed: 5,
1361                functions_analyzed: 20,
1362                functions_above_threshold: 1,
1363                ..Default::default()
1364            },
1365            ..Default::default()
1366        };
1367        let sarif = build_health_sarif(&report, &root);
1368        let entry = &sarif["runs"][0]["results"][0];
1369        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1370        assert_eq!(entry["level"], "warning");
1371        assert!(
1372            entry["message"]["text"]
1373                .as_str()
1374                .unwrap()
1375                .contains("cyclomatic complexity 25")
1376        );
1377        assert_eq!(
1378            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1379            "src/utils.ts"
1380        );
1381        let region = &entry["locations"][0]["physicalLocation"]["region"];
1382        assert_eq!(region["startLine"], 42);
1383        assert_eq!(region["startColumn"], 1);
1384    }
1385
1386    #[test]
1387    fn health_sarif_cognitive_only() {
1388        let root = PathBuf::from("/project");
1389        let report = crate::health_types::HealthReport {
1390            findings: vec![crate::health_types::HealthFinding {
1391                path: root.join("src/api.ts"),
1392                name: "handleRequest".to_string(),
1393                line: 10,
1394                col: 4,
1395                cyclomatic: 8,
1396                cognitive: 20,
1397                line_count: 40,
1398                param_count: 0,
1399                exceeded: crate::health_types::ExceededThreshold::Cognitive,
1400                severity: crate::health_types::FindingSeverity::High,
1401                crap: None,
1402                coverage_pct: None,
1403                coverage_tier: None,
1404            }],
1405            summary: crate::health_types::HealthSummary {
1406                files_analyzed: 3,
1407                functions_analyzed: 10,
1408                functions_above_threshold: 1,
1409                ..Default::default()
1410            },
1411            ..Default::default()
1412        };
1413        let sarif = build_health_sarif(&report, &root);
1414        let entry = &sarif["runs"][0]["results"][0];
1415        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1416        assert!(
1417            entry["message"]["text"]
1418                .as_str()
1419                .unwrap()
1420                .contains("cognitive complexity 20")
1421        );
1422        let region = &entry["locations"][0]["physicalLocation"]["region"];
1423        assert_eq!(region["startColumn"], 5); // col 4 + 1
1424    }
1425
1426    #[test]
1427    fn health_sarif_both_thresholds() {
1428        let root = PathBuf::from("/project");
1429        let report = crate::health_types::HealthReport {
1430            findings: vec![crate::health_types::HealthFinding {
1431                path: root.join("src/complex.ts"),
1432                name: "doEverything".to_string(),
1433                line: 1,
1434                col: 0,
1435                cyclomatic: 30,
1436                cognitive: 45,
1437                line_count: 100,
1438                param_count: 0,
1439                exceeded: crate::health_types::ExceededThreshold::Both,
1440                severity: crate::health_types::FindingSeverity::High,
1441                crap: None,
1442                coverage_pct: None,
1443                coverage_tier: None,
1444            }],
1445            summary: crate::health_types::HealthSummary {
1446                files_analyzed: 1,
1447                functions_analyzed: 1,
1448                functions_above_threshold: 1,
1449                ..Default::default()
1450            },
1451            ..Default::default()
1452        };
1453        let sarif = build_health_sarif(&report, &root);
1454        let entry = &sarif["runs"][0]["results"][0];
1455        assert_eq!(entry["ruleId"], "fallow/high-complexity");
1456        let msg = entry["message"]["text"].as_str().unwrap();
1457        assert!(msg.contains("cyclomatic complexity 30"));
1458        assert!(msg.contains("cognitive complexity 45"));
1459    }
1460
1461    #[test]
1462    fn health_sarif_crap_only_emits_crap_rule() {
1463        // CRAP-only: cyclomatic + cognitive below their thresholds, CRAP at or
1464        // above the CRAP threshold. Rule must be `fallow/high-crap-score`.
1465        let root = PathBuf::from("/project");
1466        let report = crate::health_types::HealthReport {
1467            findings: vec![crate::health_types::HealthFinding {
1468                path: root.join("src/untested.ts"),
1469                name: "risky".to_string(),
1470                line: 8,
1471                col: 0,
1472                cyclomatic: 10,
1473                cognitive: 10,
1474                line_count: 20,
1475                param_count: 1,
1476                exceeded: crate::health_types::ExceededThreshold::Crap,
1477                severity: crate::health_types::FindingSeverity::High,
1478                crap: Some(82.2),
1479                coverage_pct: Some(12.0),
1480                coverage_tier: None,
1481            }],
1482            summary: crate::health_types::HealthSummary {
1483                files_analyzed: 1,
1484                functions_analyzed: 1,
1485                functions_above_threshold: 1,
1486                ..Default::default()
1487            },
1488            ..Default::default()
1489        };
1490        let sarif = build_health_sarif(&report, &root);
1491        let entry = &sarif["runs"][0]["results"][0];
1492        assert_eq!(entry["ruleId"], "fallow/high-crap-score");
1493        let msg = entry["message"]["text"].as_str().unwrap();
1494        assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
1495        assert!(msg.contains("coverage 12%"), "msg: {msg}");
1496    }
1497
1498    #[test]
1499    fn health_sarif_cyclomatic_crap_uses_crap_rule() {
1500        // Cyclomatic + CRAP both exceeded. The CRAP-centric rule subsumes
1501        // the cyclomatic breach; only one SARIF result is emitted.
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/hot.ts"),
1506                name: "branchy".to_string(),
1507                line: 1,
1508                col: 0,
1509                cyclomatic: 67,
1510                cognitive: 12,
1511                line_count: 80,
1512                param_count: 1,
1513                exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
1514                severity: crate::health_types::FindingSeverity::Critical,
1515                crap: Some(182.0),
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 results = sarif["runs"][0]["results"].as_array().unwrap();
1529        assert_eq!(
1530            results.len(),
1531            1,
1532            "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
1533        );
1534        assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
1535        let msg = results[0]["message"]["text"].as_str().unwrap();
1536        assert!(msg.contains("CRAP score 182"), "msg: {msg}");
1537        // coverage_pct absent => no coverage suffix
1538        assert!(!msg.contains("coverage"), "msg: {msg}");
1539    }
1540
1541    // ── Severity mapping ──
1542
1543    #[test]
1544    fn severity_to_sarif_level_error() {
1545        assert_eq!(severity_to_sarif_level(Severity::Error), "error");
1546    }
1547
1548    #[test]
1549    fn severity_to_sarif_level_warn() {
1550        assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
1551    }
1552
1553    #[test]
1554    fn severity_to_sarif_level_off() {
1555        assert_eq!(severity_to_sarif_level(Severity::Off), "warning");
1556    }
1557
1558    // ── Re-export properties ──
1559
1560    #[test]
1561    fn sarif_re_export_has_properties() {
1562        let root = PathBuf::from("/project");
1563        let mut results = AnalysisResults::default();
1564        results.unused_exports.push(UnusedExport {
1565            path: root.join("src/index.ts"),
1566            export_name: "reExported".to_string(),
1567            is_type_only: false,
1568            line: 1,
1569            col: 0,
1570            span_start: 0,
1571            is_re_export: true,
1572        });
1573
1574        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1575        let entry = &sarif["runs"][0]["results"][0];
1576        assert_eq!(entry["properties"]["is_re_export"], true);
1577        let msg = entry["message"]["text"].as_str().unwrap();
1578        assert!(msg.starts_with("Re-export"));
1579    }
1580
1581    #[test]
1582    fn sarif_non_re_export_has_no_properties() {
1583        let root = PathBuf::from("/project");
1584        let mut results = AnalysisResults::default();
1585        results.unused_exports.push(UnusedExport {
1586            path: root.join("src/utils.ts"),
1587            export_name: "foo".to_string(),
1588            is_type_only: false,
1589            line: 5,
1590            col: 0,
1591            span_start: 0,
1592            is_re_export: false,
1593        });
1594
1595        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1596        let entry = &sarif["runs"][0]["results"][0];
1597        assert!(entry.get("properties").is_none());
1598        let msg = entry["message"]["text"].as_str().unwrap();
1599        assert!(msg.starts_with("Export"));
1600    }
1601
1602    // ── Type re-export ──
1603
1604    #[test]
1605    fn sarif_type_re_export_message() {
1606        let root = PathBuf::from("/project");
1607        let mut results = AnalysisResults::default();
1608        results.unused_types.push(UnusedExport {
1609            path: root.join("src/index.ts"),
1610            export_name: "MyType".to_string(),
1611            is_type_only: true,
1612            line: 1,
1613            col: 0,
1614            span_start: 0,
1615            is_re_export: true,
1616        });
1617
1618        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1619        let entry = &sarif["runs"][0]["results"][0];
1620        assert_eq!(entry["ruleId"], "fallow/unused-type");
1621        let msg = entry["message"]["text"].as_str().unwrap();
1622        assert!(msg.starts_with("Type re-export"));
1623        assert_eq!(entry["properties"]["is_re_export"], true);
1624    }
1625
1626    // ── Dependency line == 0 skips region ──
1627
1628    #[test]
1629    fn sarif_dependency_line_zero_skips_region() {
1630        let root = PathBuf::from("/project");
1631        let mut results = AnalysisResults::default();
1632        results.unused_dependencies.push(UnusedDependency {
1633            package_name: "lodash".to_string(),
1634            location: DependencyLocation::Dependencies,
1635            path: root.join("package.json"),
1636            line: 0,
1637        });
1638
1639        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1640        let entry = &sarif["runs"][0]["results"][0];
1641        let phys = &entry["locations"][0]["physicalLocation"];
1642        assert!(phys.get("region").is_none());
1643    }
1644
1645    #[test]
1646    fn sarif_dependency_line_nonzero_has_region() {
1647        let root = PathBuf::from("/project");
1648        let mut results = AnalysisResults::default();
1649        results.unused_dependencies.push(UnusedDependency {
1650            package_name: "lodash".to_string(),
1651            location: DependencyLocation::Dependencies,
1652            path: root.join("package.json"),
1653            line: 7,
1654        });
1655
1656        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1657        let entry = &sarif["runs"][0]["results"][0];
1658        let region = &entry["locations"][0]["physicalLocation"]["region"];
1659        assert_eq!(region["startLine"], 7);
1660        assert_eq!(region["startColumn"], 1);
1661    }
1662
1663    // ── Type-only dependency line == 0 skips region ──
1664
1665    #[test]
1666    fn sarif_type_only_dep_line_zero_skips_region() {
1667        let root = PathBuf::from("/project");
1668        let mut results = AnalysisResults::default();
1669        results.type_only_dependencies.push(TypeOnlyDependency {
1670            package_name: "zod".to_string(),
1671            path: root.join("package.json"),
1672            line: 0,
1673        });
1674
1675        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1676        let entry = &sarif["runs"][0]["results"][0];
1677        let phys = &entry["locations"][0]["physicalLocation"];
1678        assert!(phys.get("region").is_none());
1679    }
1680
1681    // ── Circular dependency line == 0 skips region ──
1682
1683    #[test]
1684    fn sarif_circular_dep_line_zero_skips_region() {
1685        let root = PathBuf::from("/project");
1686        let mut results = AnalysisResults::default();
1687        results.circular_dependencies.push(CircularDependency {
1688            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1689            length: 2,
1690            line: 0,
1691            col: 0,
1692            is_cross_package: false,
1693        });
1694
1695        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1696        let entry = &sarif["runs"][0]["results"][0];
1697        let phys = &entry["locations"][0]["physicalLocation"];
1698        assert!(phys.get("region").is_none());
1699    }
1700
1701    #[test]
1702    fn sarif_circular_dep_line_nonzero_has_region() {
1703        let root = PathBuf::from("/project");
1704        let mut results = AnalysisResults::default();
1705        results.circular_dependencies.push(CircularDependency {
1706            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1707            length: 2,
1708            line: 5,
1709            col: 2,
1710            is_cross_package: false,
1711        });
1712
1713        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1714        let entry = &sarif["runs"][0]["results"][0];
1715        let region = &entry["locations"][0]["physicalLocation"]["region"];
1716        assert_eq!(region["startLine"], 5);
1717        assert_eq!(region["startColumn"], 3);
1718    }
1719
1720    // ── Unused optional dependency ──
1721
1722    #[test]
1723    fn sarif_unused_optional_dependency_result() {
1724        let root = PathBuf::from("/project");
1725        let mut results = AnalysisResults::default();
1726        results.unused_optional_dependencies.push(UnusedDependency {
1727            package_name: "fsevents".to_string(),
1728            location: DependencyLocation::OptionalDependencies,
1729            path: root.join("package.json"),
1730            line: 12,
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-optional-dependency");
1736        let msg = entry["message"]["text"].as_str().unwrap();
1737        assert!(msg.contains("optionalDependencies"));
1738    }
1739
1740    // ── Enum and class member SARIF messages ──
1741
1742    #[test]
1743    fn sarif_enum_member_message_format() {
1744        let root = PathBuf::from("/project");
1745        let mut results = AnalysisResults::default();
1746        results
1747            .unused_enum_members
1748            .push(fallow_core::results::UnusedMember {
1749                path: root.join("src/enums.ts"),
1750                parent_name: "Color".to_string(),
1751                member_name: "Purple".to_string(),
1752                kind: fallow_core::extract::MemberKind::EnumMember,
1753                line: 5,
1754                col: 2,
1755            });
1756
1757        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1758        let entry = &sarif["runs"][0]["results"][0];
1759        assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
1760        let msg = entry["message"]["text"].as_str().unwrap();
1761        assert!(msg.contains("Enum member 'Color.Purple'"));
1762        let region = &entry["locations"][0]["physicalLocation"]["region"];
1763        assert_eq!(region["startColumn"], 3); // col 2 + 1
1764    }
1765
1766    #[test]
1767    fn sarif_class_member_message_format() {
1768        let root = PathBuf::from("/project");
1769        let mut results = AnalysisResults::default();
1770        results
1771            .unused_class_members
1772            .push(fallow_core::results::UnusedMember {
1773                path: root.join("src/service.ts"),
1774                parent_name: "API".to_string(),
1775                member_name: "fetch".to_string(),
1776                kind: fallow_core::extract::MemberKind::ClassMethod,
1777                line: 10,
1778                col: 4,
1779            });
1780
1781        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1782        let entry = &sarif["runs"][0]["results"][0];
1783        assert_eq!(entry["ruleId"], "fallow/unused-class-member");
1784        let msg = entry["message"]["text"].as_str().unwrap();
1785        assert!(msg.contains("Class member 'API.fetch'"));
1786    }
1787
1788    // ── Duplication SARIF ──
1789
1790    #[test]
1791    #[expect(
1792        clippy::cast_possible_truncation,
1793        reason = "test line/col values are trivially small"
1794    )]
1795    fn duplication_sarif_structure() {
1796        use fallow_core::duplicates::*;
1797
1798        let root = PathBuf::from("/project");
1799        let report = DuplicationReport {
1800            clone_groups: vec![CloneGroup {
1801                instances: vec![
1802                    CloneInstance {
1803                        file: root.join("src/a.ts"),
1804                        start_line: 1,
1805                        end_line: 10,
1806                        start_col: 0,
1807                        end_col: 0,
1808                        fragment: String::new(),
1809                    },
1810                    CloneInstance {
1811                        file: root.join("src/b.ts"),
1812                        start_line: 5,
1813                        end_line: 14,
1814                        start_col: 2,
1815                        end_col: 0,
1816                        fragment: String::new(),
1817                    },
1818                ],
1819                token_count: 50,
1820                line_count: 10,
1821            }],
1822            clone_families: vec![],
1823            mirrored_directories: vec![],
1824            stats: DuplicationStats::default(),
1825        };
1826
1827        let sarif = serde_json::json!({
1828            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1829            "version": "2.1.0",
1830            "runs": [{
1831                "tool": {
1832                    "driver": {
1833                        "name": "fallow",
1834                        "version": env!("CARGO_PKG_VERSION"),
1835                        "informationUri": "https://github.com/fallow-rs/fallow",
1836                        "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1837                    }
1838                },
1839                "results": []
1840            }]
1841        });
1842        // Just verify the function doesn't panic and produces expected structure
1843        let _ = sarif;
1844
1845        // Test the actual build path through print_duplication_sarif internals
1846        let mut sarif_results = Vec::new();
1847        for (i, group) in report.clone_groups.iter().enumerate() {
1848            for instance in &group.instances {
1849                sarif_results.push(sarif_result(
1850                    "fallow/code-duplication",
1851                    "warning",
1852                    &format!(
1853                        "Code clone group {} ({} lines, {} instances)",
1854                        i + 1,
1855                        group.line_count,
1856                        group.instances.len()
1857                    ),
1858                    &super::super::relative_uri(&instance.file, &root),
1859                    Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1860                ));
1861            }
1862        }
1863        assert_eq!(sarif_results.len(), 2);
1864        assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
1865        assert!(
1866            sarif_results[0]["message"]["text"]
1867                .as_str()
1868                .unwrap()
1869                .contains("10 lines")
1870        );
1871        let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
1872        assert_eq!(region0["startLine"], 1);
1873        assert_eq!(region0["startColumn"], 1); // start_col 0 + 1
1874        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
1875        assert_eq!(region1["startLine"], 5);
1876        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
1877    }
1878
1879    // ── sarif_rule fallback (unknown rule ID) ──
1880
1881    #[test]
1882    fn sarif_rule_known_id_has_full_description() {
1883        let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
1884        assert!(rule.get("fullDescription").is_some());
1885        assert!(rule.get("helpUri").is_some());
1886    }
1887
1888    #[test]
1889    fn sarif_rule_unknown_id_uses_fallback() {
1890        let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
1891        assert_eq!(rule["shortDescription"]["text"], "fallback text");
1892        assert!(rule.get("fullDescription").is_none());
1893        assert!(rule.get("helpUri").is_none());
1894        assert_eq!(rule["defaultConfiguration"]["level"], "warning");
1895    }
1896
1897    // ── sarif_result without region ──
1898
1899    #[test]
1900    fn sarif_result_no_region_omits_region_key() {
1901        let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
1902        let phys = &result["locations"][0]["physicalLocation"];
1903        assert!(phys.get("region").is_none());
1904        assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
1905    }
1906
1907    #[test]
1908    fn sarif_result_with_region_includes_region() {
1909        let result = sarif_result(
1910            "rule/test",
1911            "error",
1912            "test msg",
1913            "src/file.ts",
1914            Some((10, 5)),
1915        );
1916        let region = &result["locations"][0]["physicalLocation"]["region"];
1917        assert_eq!(region["startLine"], 10);
1918        assert_eq!(region["startColumn"], 5);
1919    }
1920
1921    // ── Health SARIF refactoring targets ──
1922
1923    #[test]
1924    fn health_sarif_includes_refactoring_targets() {
1925        use crate::health_types::*;
1926
1927        let root = PathBuf::from("/project");
1928        let report = HealthReport {
1929            summary: HealthSummary {
1930                files_analyzed: 10,
1931                functions_analyzed: 50,
1932                ..Default::default()
1933            },
1934            targets: vec![RefactoringTarget {
1935                path: root.join("src/complex.ts"),
1936                priority: 85.0,
1937                efficiency: 42.5,
1938                recommendation: "Split high-impact file".into(),
1939                category: RecommendationCategory::SplitHighImpact,
1940                effort: EffortEstimate::Medium,
1941                confidence: Confidence::High,
1942                factors: vec![],
1943                evidence: None,
1944            }],
1945            ..Default::default()
1946        };
1947
1948        let sarif = build_health_sarif(&report, &root);
1949        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1950        assert_eq!(entries.len(), 1);
1951        assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
1952        assert_eq!(entries[0]["level"], "warning");
1953        let msg = entries[0]["message"]["text"].as_str().unwrap();
1954        assert!(msg.contains("high impact"));
1955        assert!(msg.contains("Split high-impact file"));
1956        assert!(msg.contains("42.5"));
1957    }
1958
1959    #[test]
1960    fn health_sarif_includes_coverage_gaps() {
1961        use crate::health_types::*;
1962
1963        let root = PathBuf::from("/project");
1964        let report = HealthReport {
1965            summary: HealthSummary {
1966                files_analyzed: 10,
1967                functions_analyzed: 50,
1968                ..Default::default()
1969            },
1970            coverage_gaps: Some(CoverageGaps {
1971                summary: CoverageGapSummary {
1972                    runtime_files: 2,
1973                    covered_files: 0,
1974                    file_coverage_pct: 0.0,
1975                    untested_files: 1,
1976                    untested_exports: 1,
1977                },
1978                files: vec![UntestedFile {
1979                    path: root.join("src/app.ts"),
1980                    value_export_count: 2,
1981                }],
1982                exports: vec![UntestedExport {
1983                    path: root.join("src/app.ts"),
1984                    export_name: "loader".into(),
1985                    line: 12,
1986                    col: 4,
1987                }],
1988            }),
1989            ..Default::default()
1990        };
1991
1992        let sarif = build_health_sarif(&report, &root);
1993        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1994        assert_eq!(entries.len(), 2);
1995        assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
1996        assert_eq!(
1997            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1998            "src/app.ts"
1999        );
2000        assert!(
2001            entries[0]["message"]["text"]
2002                .as_str()
2003                .unwrap()
2004                .contains("2 value exports")
2005        );
2006        assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
2007        assert_eq!(
2008            entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
2009            12
2010        );
2011        assert_eq!(
2012            entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
2013            5
2014        );
2015    }
2016
2017    // ── Health SARIF rules include fullDescription from explain module ──
2018
2019    #[test]
2020    fn health_sarif_rules_have_full_descriptions() {
2021        let root = PathBuf::from("/project");
2022        let report = crate::health_types::HealthReport::default();
2023        let sarif = build_health_sarif(&report, &root);
2024        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2025            .as_array()
2026            .unwrap();
2027        for rule in rules {
2028            let id = rule["id"].as_str().unwrap();
2029            assert!(
2030                rule.get("fullDescription").is_some(),
2031                "health rule {id} should have fullDescription"
2032            );
2033            assert!(
2034                rule.get("helpUri").is_some(),
2035                "health rule {id} should have helpUri"
2036            );
2037        }
2038    }
2039
2040    // ── Warn severity propagates correctly ──
2041
2042    #[test]
2043    fn sarif_warn_severity_produces_warning_level() {
2044        let root = PathBuf::from("/project");
2045        let mut results = AnalysisResults::default();
2046        results.unused_files.push(UnusedFile {
2047            path: root.join("src/dead.ts"),
2048        });
2049
2050        let rules = RulesConfig {
2051            unused_files: Severity::Warn,
2052            ..RulesConfig::default()
2053        };
2054
2055        let sarif = build_sarif(&results, &root, &rules);
2056        let entry = &sarif["runs"][0]["results"][0];
2057        assert_eq!(entry["level"], "warning");
2058    }
2059
2060    // ── Unused file has no region ──
2061
2062    #[test]
2063    fn sarif_unused_file_has_no_region() {
2064        let root = PathBuf::from("/project");
2065        let mut results = AnalysisResults::default();
2066        results.unused_files.push(UnusedFile {
2067            path: root.join("src/dead.ts"),
2068        });
2069
2070        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2071        let entry = &sarif["runs"][0]["results"][0];
2072        let phys = &entry["locations"][0]["physicalLocation"];
2073        assert!(phys.get("region").is_none());
2074    }
2075
2076    // ── Multiple unlisted deps with multiple import sites ──
2077
2078    #[test]
2079    fn sarif_unlisted_dep_multiple_import_sites() {
2080        let root = PathBuf::from("/project");
2081        let mut results = AnalysisResults::default();
2082        results.unlisted_dependencies.push(UnlistedDependency {
2083            package_name: "dotenv".to_string(),
2084            imported_from: vec![
2085                ImportSite {
2086                    path: root.join("src/a.ts"),
2087                    line: 1,
2088                    col: 0,
2089                },
2090                ImportSite {
2091                    path: root.join("src/b.ts"),
2092                    line: 5,
2093                    col: 0,
2094                },
2095            ],
2096        });
2097
2098        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2099        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2100        // One SARIF result per import site
2101        assert_eq!(entries.len(), 2);
2102        assert_eq!(
2103            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2104            "src/a.ts"
2105        );
2106        assert_eq!(
2107            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2108            "src/b.ts"
2109        );
2110    }
2111
2112    // ── Empty unlisted dep (no import sites) produces zero results ──
2113
2114    #[test]
2115    fn sarif_unlisted_dep_no_import_sites() {
2116        let root = PathBuf::from("/project");
2117        let mut results = AnalysisResults::default();
2118        results.unlisted_dependencies.push(UnlistedDependency {
2119            package_name: "phantom".to_string(),
2120            imported_from: vec![],
2121        });
2122
2123        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2124        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2125        // No import sites => no SARIF results for this unlisted dep
2126        assert!(entries.is_empty());
2127    }
2128}