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