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