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