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