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}