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::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    match serde_json::to_string_pretty(&sarif) {
428        Ok(json) => {
429            println!("{json}");
430            ExitCode::SUCCESS
431        }
432        Err(e) => {
433            eprintln!("Error: failed to serialize SARIF output: {e}");
434            ExitCode::from(2)
435        }
436    }
437}
438
439pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
440    let mut sarif_results = Vec::new();
441
442    for (i, group) in report.clone_groups.iter().enumerate() {
443        for instance in &group.instances {
444            sarif_results.push(sarif_result(
445                "fallow/code-duplication",
446                "warning",
447                &format!(
448                    "Code clone group {} ({} lines, {} instances)",
449                    i + 1,
450                    group.line_count,
451                    group.instances.len()
452                ),
453                &relative_uri(&instance.file, root),
454                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
455            ));
456        }
457    }
458
459    let sarif = serde_json::json!({
460        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
461        "version": "2.1.0",
462        "runs": [{
463            "tool": {
464                "driver": {
465                    "name": "fallow",
466                    "version": env!("CARGO_PKG_VERSION"),
467                    "informationUri": "https://github.com/fallow-rs/fallow",
468                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
469                }
470            },
471            "results": sarif_results
472        }]
473    });
474
475    match serde_json::to_string_pretty(&sarif) {
476        Ok(json) => {
477            println!("{json}");
478            ExitCode::SUCCESS
479        }
480        Err(e) => {
481            eprintln!("Error: failed to serialize SARIF output: {e}");
482            ExitCode::from(2)
483        }
484    }
485}
486
487// ── Health SARIF output ────────────────────────────────────────────
488// Note: file_scores are intentionally omitted from SARIF output.
489// SARIF is designed for diagnostic results (issues/findings), not metric tables.
490// File health scores are available in JSON, human, compact, and markdown formats.
491
492pub fn build_health_sarif(
493    report: &crate::health_types::HealthReport,
494    root: &Path,
495) -> serde_json::Value {
496    use crate::health_types::ExceededThreshold;
497
498    let mut sarif_results = Vec::new();
499
500    for finding in &report.findings {
501        let uri = relative_uri(&finding.path, root);
502        let (rule_id, message) = match finding.exceeded {
503            ExceededThreshold::Cyclomatic => (
504                "fallow/high-cyclomatic-complexity",
505                format!(
506                    "'{}' has cyclomatic complexity {} (threshold: {})",
507                    finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
508                ),
509            ),
510            ExceededThreshold::Cognitive => (
511                "fallow/high-cognitive-complexity",
512                format!(
513                    "'{}' has cognitive complexity {} (threshold: {})",
514                    finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
515                ),
516            ),
517            ExceededThreshold::Both => (
518                "fallow/high-complexity",
519                format!(
520                    "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
521                    finding.name,
522                    finding.cyclomatic,
523                    report.summary.max_cyclomatic_threshold,
524                    finding.cognitive,
525                    report.summary.max_cognitive_threshold,
526                ),
527            ),
528        };
529
530        sarif_results.push(sarif_result(
531            rule_id,
532            "warning",
533            &message,
534            &uri,
535            Some((finding.line, finding.col + 1)),
536        ));
537    }
538
539    let health_rules = vec![
540        sarif_rule(
541            "fallow/high-cyclomatic-complexity",
542            "Function has high cyclomatic complexity",
543            "warning",
544        ),
545        sarif_rule(
546            "fallow/high-cognitive-complexity",
547            "Function has high cognitive complexity",
548            "warning",
549        ),
550        sarif_rule(
551            "fallow/high-complexity",
552            "Function exceeds both complexity thresholds",
553            "warning",
554        ),
555    ];
556
557    serde_json::json!({
558        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
559        "version": "2.1.0",
560        "runs": [{
561            "tool": {
562                "driver": {
563                    "name": "fallow",
564                    "version": env!("CARGO_PKG_VERSION"),
565                    "informationUri": "https://github.com/fallow-rs/fallow",
566                    "rules": health_rules
567                }
568            },
569            "results": sarif_results
570        }]
571    })
572}
573
574pub(super) fn print_health_sarif(
575    report: &crate::health_types::HealthReport,
576    root: &Path,
577) -> ExitCode {
578    let sarif = build_health_sarif(report, root);
579    match serde_json::to_string_pretty(&sarif) {
580        Ok(json) => {
581            println!("{json}");
582            ExitCode::SUCCESS
583        }
584        Err(e) => {
585            eprintln!("Error: failed to serialize SARIF output: {e}");
586            ExitCode::from(2)
587        }
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use fallow_core::extract::MemberKind;
595    use fallow_core::results::*;
596    use std::path::PathBuf;
597
598    /// Helper: build an `AnalysisResults` populated with one issue of every type.
599    fn sample_results(root: &Path) -> AnalysisResults {
600        let mut r = AnalysisResults::default();
601
602        r.unused_files.push(UnusedFile {
603            path: root.join("src/dead.ts"),
604        });
605        r.unused_exports.push(UnusedExport {
606            path: root.join("src/utils.ts"),
607            export_name: "helperFn".to_string(),
608            is_type_only: false,
609            line: 10,
610            col: 4,
611            span_start: 120,
612            is_re_export: false,
613        });
614        r.unused_types.push(UnusedExport {
615            path: root.join("src/types.ts"),
616            export_name: "OldType".to_string(),
617            is_type_only: true,
618            line: 5,
619            col: 0,
620            span_start: 60,
621            is_re_export: false,
622        });
623        r.unused_dependencies.push(UnusedDependency {
624            package_name: "lodash".to_string(),
625            location: DependencyLocation::Dependencies,
626            path: root.join("package.json"),
627            line: 5,
628        });
629        r.unused_dev_dependencies.push(UnusedDependency {
630            package_name: "jest".to_string(),
631            location: DependencyLocation::DevDependencies,
632            path: root.join("package.json"),
633            line: 5,
634        });
635        r.unused_enum_members.push(UnusedMember {
636            path: root.join("src/enums.ts"),
637            parent_name: "Status".to_string(),
638            member_name: "Deprecated".to_string(),
639            kind: MemberKind::EnumMember,
640            line: 8,
641            col: 2,
642        });
643        r.unused_class_members.push(UnusedMember {
644            path: root.join("src/service.ts"),
645            parent_name: "UserService".to_string(),
646            member_name: "legacyMethod".to_string(),
647            kind: MemberKind::ClassMethod,
648            line: 42,
649            col: 4,
650        });
651        r.unresolved_imports.push(UnresolvedImport {
652            path: root.join("src/app.ts"),
653            specifier: "./missing-module".to_string(),
654            line: 3,
655            col: 0,
656        });
657        r.unlisted_dependencies.push(UnlistedDependency {
658            package_name: "chalk".to_string(),
659            imported_from: vec![ImportSite {
660                path: root.join("src/cli.ts"),
661                line: 2,
662                col: 0,
663            }],
664        });
665        r.duplicate_exports.push(DuplicateExport {
666            export_name: "Config".to_string(),
667            locations: vec![
668                DuplicateLocation {
669                    path: root.join("src/config.ts"),
670                    line: 15,
671                    col: 0,
672                },
673                DuplicateLocation {
674                    path: root.join("src/types.ts"),
675                    line: 30,
676                    col: 0,
677                },
678            ],
679        });
680        r.type_only_dependencies.push(TypeOnlyDependency {
681            package_name: "zod".to_string(),
682            path: root.join("package.json"),
683            line: 8,
684        });
685        r.circular_dependencies.push(CircularDependency {
686            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
687            length: 2,
688            line: 3,
689            col: 0,
690        });
691
692        r
693    }
694
695    #[test]
696    fn sarif_has_required_top_level_fields() {
697        let root = PathBuf::from("/project");
698        let results = AnalysisResults::default();
699        let sarif = build_sarif(&results, &root, &RulesConfig::default());
700
701        assert_eq!(
702            sarif["$schema"],
703            "https://json.schemastore.org/sarif-2.1.0.json"
704        );
705        assert_eq!(sarif["version"], "2.1.0");
706        assert!(sarif["runs"].is_array());
707    }
708
709    #[test]
710    fn sarif_has_tool_driver_info() {
711        let root = PathBuf::from("/project");
712        let results = AnalysisResults::default();
713        let sarif = build_sarif(&results, &root, &RulesConfig::default());
714
715        let driver = &sarif["runs"][0]["tool"]["driver"];
716        assert_eq!(driver["name"], "fallow");
717        assert!(driver["version"].is_string());
718        assert_eq!(
719            driver["informationUri"],
720            "https://github.com/fallow-rs/fallow"
721        );
722    }
723
724    #[test]
725    fn sarif_declares_all_rules() {
726        let root = PathBuf::from("/project");
727        let results = AnalysisResults::default();
728        let sarif = build_sarif(&results, &root, &RulesConfig::default());
729
730        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
731            .as_array()
732            .expect("rules should be an array");
733        assert_eq!(rules.len(), 13);
734
735        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
736        assert!(rule_ids.contains(&"fallow/unused-file"));
737        assert!(rule_ids.contains(&"fallow/unused-export"));
738        assert!(rule_ids.contains(&"fallow/unused-type"));
739        assert!(rule_ids.contains(&"fallow/unused-dependency"));
740        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
741        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
742        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
743        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
744        assert!(rule_ids.contains(&"fallow/unused-class-member"));
745        assert!(rule_ids.contains(&"fallow/unresolved-import"));
746        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
747        assert!(rule_ids.contains(&"fallow/duplicate-export"));
748        assert!(rule_ids.contains(&"fallow/circular-dependency"));
749    }
750
751    #[test]
752    fn sarif_empty_results_no_results_entries() {
753        let root = PathBuf::from("/project");
754        let results = AnalysisResults::default();
755        let sarif = build_sarif(&results, &root, &RulesConfig::default());
756
757        let sarif_results = sarif["runs"][0]["results"]
758            .as_array()
759            .expect("results should be an array");
760        assert!(sarif_results.is_empty());
761    }
762
763    #[test]
764    fn sarif_unused_file_result() {
765        let root = PathBuf::from("/project");
766        let mut results = AnalysisResults::default();
767        results.unused_files.push(UnusedFile {
768            path: root.join("src/dead.ts"),
769        });
770
771        let sarif = build_sarif(&results, &root, &RulesConfig::default());
772        let entries = sarif["runs"][0]["results"].as_array().unwrap();
773        assert_eq!(entries.len(), 1);
774
775        let entry = &entries[0];
776        assert_eq!(entry["ruleId"], "fallow/unused-file");
777        // Default severity is "error" per RulesConfig::default()
778        assert_eq!(entry["level"], "error");
779        assert_eq!(
780            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
781            "src/dead.ts"
782        );
783    }
784
785    #[test]
786    fn sarif_unused_export_includes_region() {
787        let root = PathBuf::from("/project");
788        let mut results = AnalysisResults::default();
789        results.unused_exports.push(UnusedExport {
790            path: root.join("src/utils.ts"),
791            export_name: "helperFn".to_string(),
792            is_type_only: false,
793            line: 10,
794            col: 4,
795            span_start: 120,
796            is_re_export: false,
797        });
798
799        let sarif = build_sarif(&results, &root, &RulesConfig::default());
800        let entry = &sarif["runs"][0]["results"][0];
801        assert_eq!(entry["ruleId"], "fallow/unused-export");
802
803        let region = &entry["locations"][0]["physicalLocation"]["region"];
804        assert_eq!(region["startLine"], 10);
805        // SARIF columns are 1-based, code adds +1 to the 0-based col
806        assert_eq!(region["startColumn"], 5);
807    }
808
809    #[test]
810    fn sarif_unresolved_import_is_error_level() {
811        let root = PathBuf::from("/project");
812        let mut results = AnalysisResults::default();
813        results.unresolved_imports.push(UnresolvedImport {
814            path: root.join("src/app.ts"),
815            specifier: "./missing".to_string(),
816            line: 1,
817            col: 0,
818        });
819
820        let sarif = build_sarif(&results, &root, &RulesConfig::default());
821        let entry = &sarif["runs"][0]["results"][0];
822        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
823        assert_eq!(entry["level"], "error");
824    }
825
826    #[test]
827    fn sarif_unlisted_dependency_points_to_import_site() {
828        let root = PathBuf::from("/project");
829        let mut results = AnalysisResults::default();
830        results.unlisted_dependencies.push(UnlistedDependency {
831            package_name: "chalk".to_string(),
832            imported_from: vec![ImportSite {
833                path: root.join("src/cli.ts"),
834                line: 3,
835                col: 0,
836            }],
837        });
838
839        let sarif = build_sarif(&results, &root, &RulesConfig::default());
840        let entry = &sarif["runs"][0]["results"][0];
841        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
842        assert_eq!(entry["level"], "error");
843        assert_eq!(
844            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
845            "src/cli.ts"
846        );
847        let region = &entry["locations"][0]["physicalLocation"]["region"];
848        assert_eq!(region["startLine"], 3);
849        assert_eq!(region["startColumn"], 1);
850    }
851
852    #[test]
853    fn sarif_dependency_issues_point_to_package_json() {
854        let root = PathBuf::from("/project");
855        let mut results = AnalysisResults::default();
856        results.unused_dependencies.push(UnusedDependency {
857            package_name: "lodash".to_string(),
858            location: DependencyLocation::Dependencies,
859            path: root.join("package.json"),
860            line: 5,
861        });
862        results.unused_dev_dependencies.push(UnusedDependency {
863            package_name: "jest".to_string(),
864            location: DependencyLocation::DevDependencies,
865            path: root.join("package.json"),
866            line: 5,
867        });
868
869        let sarif = build_sarif(&results, &root, &RulesConfig::default());
870        let entries = sarif["runs"][0]["results"].as_array().unwrap();
871        for entry in entries {
872            assert_eq!(
873                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
874                "package.json"
875            );
876        }
877    }
878
879    #[test]
880    fn sarif_duplicate_export_emits_one_result_per_location() {
881        let root = PathBuf::from("/project");
882        let mut results = AnalysisResults::default();
883        results.duplicate_exports.push(DuplicateExport {
884            export_name: "Config".to_string(),
885            locations: vec![
886                DuplicateLocation {
887                    path: root.join("src/a.ts"),
888                    line: 15,
889                    col: 0,
890                },
891                DuplicateLocation {
892                    path: root.join("src/b.ts"),
893                    line: 30,
894                    col: 0,
895                },
896            ],
897        });
898
899        let sarif = build_sarif(&results, &root, &RulesConfig::default());
900        let entries = sarif["runs"][0]["results"].as_array().unwrap();
901        // One SARIF result per location, not one per DuplicateExport
902        assert_eq!(entries.len(), 2);
903        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
904        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
905        assert_eq!(
906            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
907            "src/a.ts"
908        );
909        assert_eq!(
910            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
911            "src/b.ts"
912        );
913    }
914
915    #[test]
916    fn sarif_all_issue_types_produce_results() {
917        let root = PathBuf::from("/project");
918        let results = sample_results(&root);
919        let sarif = build_sarif(&results, &root, &RulesConfig::default());
920
921        let entries = sarif["runs"][0]["results"].as_array().unwrap();
922        // 12 issues but duplicate_exports has 2 locations => 13 SARIF results
923        assert_eq!(entries.len(), 13);
924
925        let rule_ids: Vec<&str> = entries
926            .iter()
927            .map(|e| e["ruleId"].as_str().unwrap())
928            .collect();
929        assert!(rule_ids.contains(&"fallow/unused-file"));
930        assert!(rule_ids.contains(&"fallow/unused-export"));
931        assert!(rule_ids.contains(&"fallow/unused-type"));
932        assert!(rule_ids.contains(&"fallow/unused-dependency"));
933        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
934        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
935        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
936        assert!(rule_ids.contains(&"fallow/unused-class-member"));
937        assert!(rule_ids.contains(&"fallow/unresolved-import"));
938        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
939        assert!(rule_ids.contains(&"fallow/duplicate-export"));
940    }
941
942    #[test]
943    fn sarif_serializes_to_valid_json() {
944        let root = PathBuf::from("/project");
945        let results = sample_results(&root);
946        let sarif = build_sarif(&results, &root, &RulesConfig::default());
947
948        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
949        let reparsed: serde_json::Value =
950            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
951        assert_eq!(reparsed, sarif);
952    }
953
954    #[test]
955    fn sarif_file_write_produces_valid_sarif() {
956        let root = PathBuf::from("/project");
957        let results = sample_results(&root);
958        let sarif = build_sarif(&results, &root, &RulesConfig::default());
959        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
960
961        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
962        let _ = std::fs::create_dir_all(&dir);
963        let sarif_path = dir.join("results.sarif");
964        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
965
966        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
967        let parsed: serde_json::Value =
968            serde_json::from_str(&contents).expect("file should contain valid JSON");
969
970        assert_eq!(parsed["version"], "2.1.0");
971        assert_eq!(
972            parsed["$schema"],
973            "https://json.schemastore.org/sarif-2.1.0.json"
974        );
975        let sarif_results = parsed["runs"][0]["results"]
976            .as_array()
977            .expect("results should be an array");
978        assert!(!sarif_results.is_empty());
979
980        // Clean up
981        let _ = std::fs::remove_file(&sarif_path);
982        let _ = std::fs::remove_dir(&dir);
983    }
984
985    // ── Health SARIF ──
986
987    #[test]
988    fn health_sarif_empty_no_results() {
989        let root = PathBuf::from("/project");
990        let report = crate::health_types::HealthReport {
991            findings: vec![],
992            summary: crate::health_types::HealthSummary {
993                files_analyzed: 10,
994                functions_analyzed: 50,
995                functions_above_threshold: 0,
996                max_cyclomatic_threshold: 20,
997                max_cognitive_threshold: 15,
998                files_scored: None,
999                average_maintainability: None,
1000            },
1001            file_scores: vec![],
1002            hotspots: vec![],
1003            hotspot_summary: None,
1004        };
1005        let sarif = build_health_sarif(&report, &root);
1006        assert_eq!(sarif["version"], "2.1.0");
1007        let results = sarif["runs"][0]["results"].as_array().unwrap();
1008        assert!(results.is_empty());
1009        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1010            .as_array()
1011            .unwrap();
1012        assert_eq!(rules.len(), 3);
1013    }
1014
1015    #[test]
1016    fn health_sarif_cyclomatic_only() {
1017        let root = PathBuf::from("/project");
1018        let report = crate::health_types::HealthReport {
1019            findings: vec![crate::health_types::HealthFinding {
1020                path: root.join("src/utils.ts"),
1021                name: "parseExpression".to_string(),
1022                line: 42,
1023                col: 0,
1024                cyclomatic: 25,
1025                cognitive: 10,
1026                line_count: 80,
1027                exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1028            }],
1029            summary: crate::health_types::HealthSummary {
1030                files_analyzed: 5,
1031                functions_analyzed: 20,
1032                functions_above_threshold: 1,
1033                max_cyclomatic_threshold: 20,
1034                max_cognitive_threshold: 15,
1035                files_scored: None,
1036                average_maintainability: None,
1037            },
1038            file_scores: vec![],
1039            hotspots: vec![],
1040            hotspot_summary: None,
1041        };
1042        let sarif = build_health_sarif(&report, &root);
1043        let entry = &sarif["runs"][0]["results"][0];
1044        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1045        assert_eq!(entry["level"], "warning");
1046        assert!(
1047            entry["message"]["text"]
1048                .as_str()
1049                .unwrap()
1050                .contains("cyclomatic complexity 25")
1051        );
1052        assert_eq!(
1053            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1054            "src/utils.ts"
1055        );
1056        let region = &entry["locations"][0]["physicalLocation"]["region"];
1057        assert_eq!(region["startLine"], 42);
1058        assert_eq!(region["startColumn"], 1);
1059    }
1060
1061    #[test]
1062    fn health_sarif_cognitive_only() {
1063        let root = PathBuf::from("/project");
1064        let report = crate::health_types::HealthReport {
1065            findings: vec![crate::health_types::HealthFinding {
1066                path: root.join("src/api.ts"),
1067                name: "handleRequest".to_string(),
1068                line: 10,
1069                col: 4,
1070                cyclomatic: 8,
1071                cognitive: 20,
1072                line_count: 40,
1073                exceeded: crate::health_types::ExceededThreshold::Cognitive,
1074            }],
1075            summary: crate::health_types::HealthSummary {
1076                files_analyzed: 3,
1077                functions_analyzed: 10,
1078                functions_above_threshold: 1,
1079                max_cyclomatic_threshold: 20,
1080                max_cognitive_threshold: 15,
1081                files_scored: None,
1082                average_maintainability: None,
1083            },
1084            file_scores: vec![],
1085            hotspots: vec![],
1086            hotspot_summary: None,
1087        };
1088        let sarif = build_health_sarif(&report, &root);
1089        let entry = &sarif["runs"][0]["results"][0];
1090        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1091        assert!(
1092            entry["message"]["text"]
1093                .as_str()
1094                .unwrap()
1095                .contains("cognitive complexity 20")
1096        );
1097        let region = &entry["locations"][0]["physicalLocation"]["region"];
1098        assert_eq!(region["startColumn"], 5); // col 4 + 1
1099    }
1100
1101    #[test]
1102    fn health_sarif_both_thresholds() {
1103        let root = PathBuf::from("/project");
1104        let report = crate::health_types::HealthReport {
1105            findings: vec![crate::health_types::HealthFinding {
1106                path: root.join("src/complex.ts"),
1107                name: "doEverything".to_string(),
1108                line: 1,
1109                col: 0,
1110                cyclomatic: 30,
1111                cognitive: 45,
1112                line_count: 100,
1113                exceeded: crate::health_types::ExceededThreshold::Both,
1114            }],
1115            summary: crate::health_types::HealthSummary {
1116                files_analyzed: 1,
1117                functions_analyzed: 1,
1118                functions_above_threshold: 1,
1119                max_cyclomatic_threshold: 20,
1120                max_cognitive_threshold: 15,
1121                files_scored: None,
1122                average_maintainability: None,
1123            },
1124            file_scores: vec![],
1125            hotspots: vec![],
1126            hotspot_summary: None,
1127        };
1128        let sarif = build_health_sarif(&report, &root);
1129        let entry = &sarif["runs"][0]["results"][0];
1130        assert_eq!(entry["ruleId"], "fallow/high-complexity");
1131        let msg = entry["message"]["text"].as_str().unwrap();
1132        assert!(msg.contains("cyclomatic complexity 30"));
1133        assert!(msg.contains("cognitive complexity 45"));
1134    }
1135}