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