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;
9
10/// Intermediate fields extracted from an issue for SARIF result construction.
11struct SarifFields {
12    rule_id: &'static str,
13    level: &'static str,
14    message: String,
15    uri: String,
16    region: Option<(u32, u32)>,
17    properties: Option<serde_json::Value>,
18}
19
20const fn severity_to_sarif_level(s: Severity) -> &'static str {
21    match s {
22        Severity::Error => "error",
23        Severity::Warn | Severity::Off => "warning",
24    }
25}
26
27/// Build a single SARIF result object.
28///
29/// When `region` is `Some((line, col))`, a `region` block with 1-based
30/// `startLine` and `startColumn` is included in the physical location.
31fn sarif_result(
32    rule_id: &str,
33    level: &str,
34    message: &str,
35    uri: &str,
36    region: Option<(u32, u32)>,
37) -> serde_json::Value {
38    let mut physical_location = serde_json::json!({
39        "artifactLocation": { "uri": uri }
40    });
41    if let Some((line, col)) = region {
42        physical_location["region"] = serde_json::json!({
43            "startLine": line,
44            "startColumn": col
45        });
46    }
47    serde_json::json!({
48        "ruleId": rule_id,
49        "level": level,
50        "message": { "text": message },
51        "locations": [{ "physicalLocation": physical_location }]
52    })
53}
54
55/// Append SARIF results for a slice of items using a closure to extract fields.
56fn push_sarif_results<T>(
57    sarif_results: &mut Vec<serde_json::Value>,
58    items: &[T],
59    extract: impl Fn(&T) -> SarifFields,
60) {
61    for item in items {
62        let fields = extract(item);
63        let mut result = sarif_result(
64            fields.rule_id,
65            fields.level,
66            &fields.message,
67            &fields.uri,
68            fields.region,
69        );
70        if let Some(props) = fields.properties {
71            result["properties"] = props;
72        }
73        sarif_results.push(result);
74    }
75}
76
77pub fn build_sarif(
78    results: &AnalysisResults,
79    root: &Path,
80    rules: &RulesConfig,
81) -> serde_json::Value {
82    let mut sarif_results = Vec::new();
83
84    push_sarif_results(&mut sarif_results, &results.unused_files, |file| {
85        SarifFields {
86            rule_id: "fallow/unused-file",
87            level: severity_to_sarif_level(rules.unused_files),
88            message: "File is not reachable from any entry point".to_string(),
89            uri: relative_uri(&file.path, root),
90            region: None,
91            properties: None,
92        }
93    });
94
95    let sarif_export = |export: &UnusedExport,
96                        rule_id: &'static str,
97                        level: &'static str,
98                        kind: &str,
99                        re_kind: &str|
100     -> SarifFields {
101        let label = if export.is_re_export { re_kind } else { kind };
102        SarifFields {
103            rule_id,
104            level,
105            message: format!(
106                "{} '{}' is never imported by other modules",
107                label, export.export_name
108            ),
109            uri: relative_uri(&export.path, root),
110            region: Some((export.line, export.col + 1)),
111            properties: if export.is_re_export {
112                Some(serde_json::json!({ "is_re_export": true }))
113            } else {
114                None
115            },
116        }
117    };
118
119    push_sarif_results(&mut sarif_results, &results.unused_exports, |export| {
120        sarif_export(
121            export,
122            "fallow/unused-export",
123            severity_to_sarif_level(rules.unused_exports),
124            "Export",
125            "Re-export",
126        )
127    });
128
129    push_sarif_results(&mut sarif_results, &results.unused_types, |export| {
130        sarif_export(
131            export,
132            "fallow/unused-type",
133            severity_to_sarif_level(rules.unused_types),
134            "Type export",
135            "Type re-export",
136        )
137    });
138
139    let sarif_dep = |dep: &UnusedDependency,
140                     rule_id: &'static str,
141                     level: &'static str,
142                     section: &str|
143     -> SarifFields {
144        SarifFields {
145            rule_id,
146            level,
147            message: format!(
148                "Package '{}' is in {} but never imported",
149                dep.package_name, section
150            ),
151            uri: relative_uri(&dep.path, root),
152            region: if dep.line > 0 {
153                Some((dep.line, 1))
154            } else {
155                None
156            },
157            properties: None,
158        }
159    };
160
161    push_sarif_results(&mut sarif_results, &results.unused_dependencies, |dep| {
162        sarif_dep(
163            dep,
164            "fallow/unused-dependency",
165            severity_to_sarif_level(rules.unused_dependencies),
166            "dependencies",
167        )
168    });
169
170    push_sarif_results(
171        &mut sarif_results,
172        &results.unused_dev_dependencies,
173        |dep| {
174            sarif_dep(
175                dep,
176                "fallow/unused-dev-dependency",
177                severity_to_sarif_level(rules.unused_dev_dependencies),
178                "devDependencies",
179            )
180        },
181    );
182
183    push_sarif_results(
184        &mut sarif_results,
185        &results.unused_optional_dependencies,
186        |dep| {
187            sarif_dep(
188                dep,
189                "fallow/unused-optional-dependency",
190                severity_to_sarif_level(rules.unused_optional_dependencies),
191                "optionalDependencies",
192            )
193        },
194    );
195
196    push_sarif_results(&mut sarif_results, &results.type_only_dependencies, |dep| {
197        SarifFields {
198            rule_id: "fallow/type-only-dependency",
199            level: severity_to_sarif_level(rules.type_only_dependencies),
200            message: format!(
201                "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
202                dep.package_name
203            ),
204            uri: relative_uri(&dep.path, root),
205            region: if dep.line > 0 {
206                Some((dep.line, 1))
207            } else {
208                None
209            },
210            properties: None,
211        }
212    });
213
214    let sarif_member = |member: &UnusedMember,
215                        rule_id: &'static str,
216                        level: &'static str,
217                        kind: &str|
218     -> SarifFields {
219        SarifFields {
220            rule_id,
221            level,
222            message: format!(
223                "{} member '{}.{}' is never referenced",
224                kind, member.parent_name, member.member_name
225            ),
226            uri: relative_uri(&member.path, root),
227            region: Some((member.line, member.col + 1)),
228            properties: None,
229        }
230    };
231
232    push_sarif_results(&mut sarif_results, &results.unused_enum_members, |member| {
233        sarif_member(
234            member,
235            "fallow/unused-enum-member",
236            severity_to_sarif_level(rules.unused_enum_members),
237            "Enum",
238        )
239    });
240
241    push_sarif_results(
242        &mut sarif_results,
243        &results.unused_class_members,
244        |member| {
245            sarif_member(
246                member,
247                "fallow/unused-class-member",
248                severity_to_sarif_level(rules.unused_class_members),
249                "Class",
250            )
251        },
252    );
253
254    push_sarif_results(&mut sarif_results, &results.unresolved_imports, |import| {
255        SarifFields {
256            rule_id: "fallow/unresolved-import",
257            level: severity_to_sarif_level(rules.unresolved_imports),
258            message: format!("Import '{}' could not be resolved", import.specifier),
259            uri: relative_uri(&import.path, root),
260            region: Some((import.line, import.col + 1)),
261            properties: None,
262        }
263    });
264
265    // Unlisted deps: one result per importing file (SARIF points to the import site)
266    for dep in &results.unlisted_dependencies {
267        for site in &dep.imported_from {
268            sarif_results.push(sarif_result(
269                "fallow/unlisted-dependency",
270                severity_to_sarif_level(rules.unlisted_dependencies),
271                &format!(
272                    "Package '{}' is imported but not listed in package.json",
273                    dep.package_name
274                ),
275                &relative_uri(&site.path, root),
276                Some((site.line, site.col + 1)),
277            ));
278        }
279    }
280
281    // Duplicate exports: one result per location (SARIF 2.1.0 section 3.27.12)
282    for dup in &results.duplicate_exports {
283        for loc in &dup.locations {
284            sarif_results.push(sarif_result(
285                "fallow/duplicate-export",
286                severity_to_sarif_level(rules.duplicate_exports),
287                &format!("Export '{}' appears in multiple modules", dup.export_name),
288                &relative_uri(&loc.path, root),
289                Some((loc.line, loc.col + 1)),
290            ));
291        }
292    }
293
294    push_sarif_results(
295        &mut sarif_results,
296        &results.circular_dependencies,
297        |cycle| {
298            let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
299            let mut display_chain = chain.clone();
300            if let Some(first) = chain.first() {
301                display_chain.push(first.clone());
302            }
303            let first_uri = chain.first().map_or_else(String::new, Clone::clone);
304            SarifFields {
305                rule_id: "fallow/circular-dependency",
306                level: severity_to_sarif_level(rules.circular_dependencies),
307                message: format!("Circular dependency: {}", display_chain.join(" \u{2192} ")),
308                uri: first_uri,
309                region: if cycle.line > 0 {
310                    Some((cycle.line, cycle.col + 1))
311                } else {
312                    None
313                },
314                properties: None,
315            }
316        },
317    );
318
319    serde_json::json!({
320        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
321        "version": "2.1.0",
322        "runs": [{
323            "tool": {
324                "driver": {
325                    "name": "fallow",
326                    "version": env!("CARGO_PKG_VERSION"),
327                    "informationUri": "https://github.com/fallow-rs/fallow",
328                    "rules": [
329                        {
330                            "id": "fallow/unused-file",
331                            "shortDescription": { "text": "File is not reachable from any entry point" },
332                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_files) }
333                        },
334                        {
335                            "id": "fallow/unused-export",
336                            "shortDescription": { "text": "Export is never imported" },
337                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_exports) }
338                        },
339                        {
340                            "id": "fallow/unused-type",
341                            "shortDescription": { "text": "Type export is never imported" },
342                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_types) }
343                        },
344                        {
345                            "id": "fallow/unused-dependency",
346                            "shortDescription": { "text": "Dependency listed but never imported" },
347                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_dependencies) }
348                        },
349                        {
350                            "id": "fallow/unused-dev-dependency",
351                            "shortDescription": { "text": "Dev dependency listed but never imported" },
352                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_dev_dependencies) }
353                        },
354                        {
355                            "id": "fallow/unused-optional-dependency",
356                            "shortDescription": { "text": "Optional dependency listed but never imported" },
357                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_optional_dependencies) }
358                        },
359                        {
360                            "id": "fallow/type-only-dependency",
361                            "shortDescription": { "text": "Production dependency only used via type-only imports" },
362                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.type_only_dependencies) }
363                        },
364                        {
365                            "id": "fallow/unused-enum-member",
366                            "shortDescription": { "text": "Enum member is never referenced" },
367                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_enum_members) }
368                        },
369                        {
370                            "id": "fallow/unused-class-member",
371                            "shortDescription": { "text": "Class member is never referenced" },
372                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_class_members) }
373                        },
374                        {
375                            "id": "fallow/unresolved-import",
376                            "shortDescription": { "text": "Import could not be resolved" },
377                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unresolved_imports) }
378                        },
379                        {
380                            "id": "fallow/unlisted-dependency",
381                            "shortDescription": { "text": "Dependency used but not in package.json" },
382                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unlisted_dependencies) }
383                        },
384                        {
385                            "id": "fallow/duplicate-export",
386                            "shortDescription": { "text": "Export name appears in multiple modules" },
387                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.duplicate_exports) }
388                        },
389                        {
390                            "id": "fallow/circular-dependency",
391                            "shortDescription": { "text": "Circular dependency chain detected" },
392                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.circular_dependencies) }
393                        }
394                    ]
395                }
396            },
397            "results": sarif_results
398        }]
399    })
400}
401
402pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
403    let sarif = build_sarif(results, root, rules);
404    match serde_json::to_string_pretty(&sarif) {
405        Ok(json) => {
406            println!("{json}");
407            ExitCode::SUCCESS
408        }
409        Err(e) => {
410            eprintln!("Error: failed to serialize SARIF output: {e}");
411            ExitCode::from(2)
412        }
413    }
414}
415
416pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
417    let mut sarif_results = Vec::new();
418
419    for (i, group) in report.clone_groups.iter().enumerate() {
420        for instance in &group.instances {
421            sarif_results.push(sarif_result(
422                "fallow/code-duplication",
423                "warning",
424                &format!(
425                    "Code clone group {} ({} lines, {} instances)",
426                    i + 1,
427                    group.line_count,
428                    group.instances.len()
429                ),
430                &relative_uri(&instance.file, root),
431                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
432            ));
433        }
434    }
435
436    let sarif = serde_json::json!({
437        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
438        "version": "2.1.0",
439        "runs": [{
440            "tool": {
441                "driver": {
442                    "name": "fallow",
443                    "version": env!("CARGO_PKG_VERSION"),
444                    "informationUri": "https://github.com/fallow-rs/fallow",
445                    "rules": [{
446                        "id": "fallow/code-duplication",
447                        "shortDescription": { "text": "Duplicated code block" },
448                        "defaultConfiguration": { "level": "warning" }
449                    }]
450                }
451            },
452            "results": sarif_results
453        }]
454    });
455
456    match serde_json::to_string_pretty(&sarif) {
457        Ok(json) => {
458            println!("{json}");
459            ExitCode::SUCCESS
460        }
461        Err(e) => {
462            eprintln!("Error: failed to serialize SARIF output: {e}");
463            ExitCode::from(2)
464        }
465    }
466}
467
468// ── Health SARIF output ────────────────────────────────────────────
469// Note: file_scores are intentionally omitted from SARIF output.
470// SARIF is designed for diagnostic results (issues/findings), not metric tables.
471// File health scores are available in JSON, human, compact, and markdown formats.
472
473pub fn build_health_sarif(
474    report: &crate::health_types::HealthReport,
475    root: &Path,
476) -> serde_json::Value {
477    use crate::health_types::ExceededThreshold;
478
479    let mut sarif_results = Vec::new();
480
481    for finding in &report.findings {
482        let uri = relative_uri(&finding.path, root);
483        let (rule_id, message) = match finding.exceeded {
484            ExceededThreshold::Cyclomatic => (
485                "fallow/high-cyclomatic-complexity",
486                format!(
487                    "'{}' has cyclomatic complexity {} (threshold: {})",
488                    finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
489                ),
490            ),
491            ExceededThreshold::Cognitive => (
492                "fallow/high-cognitive-complexity",
493                format!(
494                    "'{}' has cognitive complexity {} (threshold: {})",
495                    finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
496                ),
497            ),
498            ExceededThreshold::Both => (
499                "fallow/high-complexity",
500                format!(
501                    "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
502                    finding.name,
503                    finding.cyclomatic,
504                    report.summary.max_cyclomatic_threshold,
505                    finding.cognitive,
506                    report.summary.max_cognitive_threshold,
507                ),
508            ),
509        };
510
511        sarif_results.push(sarif_result(
512            rule_id,
513            "warning",
514            &message,
515            &uri,
516            Some((finding.line, finding.col + 1)),
517        ));
518    }
519
520    serde_json::json!({
521        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
522        "version": "2.1.0",
523        "runs": [{
524            "tool": {
525                "driver": {
526                    "name": "fallow",
527                    "version": env!("CARGO_PKG_VERSION"),
528                    "informationUri": "https://github.com/fallow-rs/fallow",
529                    "rules": [
530                        {
531                            "id": "fallow/high-cyclomatic-complexity",
532                            "shortDescription": { "text": "Function exceeds cyclomatic complexity threshold" },
533                            "defaultConfiguration": { "level": "warning" }
534                        },
535                        {
536                            "id": "fallow/high-cognitive-complexity",
537                            "shortDescription": { "text": "Function exceeds cognitive complexity threshold" },
538                            "defaultConfiguration": { "level": "warning" }
539                        },
540                        {
541                            "id": "fallow/high-complexity",
542                            "shortDescription": { "text": "Function exceeds both cyclomatic and cognitive complexity thresholds" },
543                            "defaultConfiguration": { "level": "warning" }
544                        }
545                    ]
546                }
547            },
548            "results": sarif_results
549        }]
550    })
551}
552
553pub(super) fn print_health_sarif(
554    report: &crate::health_types::HealthReport,
555    root: &Path,
556) -> ExitCode {
557    let sarif = build_health_sarif(report, root);
558    match serde_json::to_string_pretty(&sarif) {
559        Ok(json) => {
560            println!("{json}");
561            ExitCode::SUCCESS
562        }
563        Err(e) => {
564            eprintln!("Error: failed to serialize SARIF output: {e}");
565            ExitCode::from(2)
566        }
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use fallow_core::extract::MemberKind;
574    use fallow_core::results::*;
575    use std::path::PathBuf;
576
577    /// Helper: build an `AnalysisResults` populated with one issue of every type.
578    fn sample_results(root: &Path) -> AnalysisResults {
579        let mut r = AnalysisResults::default();
580
581        r.unused_files.push(UnusedFile {
582            path: root.join("src/dead.ts"),
583        });
584        r.unused_exports.push(UnusedExport {
585            path: root.join("src/utils.ts"),
586            export_name: "helperFn".to_string(),
587            is_type_only: false,
588            line: 10,
589            col: 4,
590            span_start: 120,
591            is_re_export: false,
592        });
593        r.unused_types.push(UnusedExport {
594            path: root.join("src/types.ts"),
595            export_name: "OldType".to_string(),
596            is_type_only: true,
597            line: 5,
598            col: 0,
599            span_start: 60,
600            is_re_export: false,
601        });
602        r.unused_dependencies.push(UnusedDependency {
603            package_name: "lodash".to_string(),
604            location: DependencyLocation::Dependencies,
605            path: root.join("package.json"),
606            line: 5,
607        });
608        r.unused_dev_dependencies.push(UnusedDependency {
609            package_name: "jest".to_string(),
610            location: DependencyLocation::DevDependencies,
611            path: root.join("package.json"),
612            line: 5,
613        });
614        r.unused_enum_members.push(UnusedMember {
615            path: root.join("src/enums.ts"),
616            parent_name: "Status".to_string(),
617            member_name: "Deprecated".to_string(),
618            kind: MemberKind::EnumMember,
619            line: 8,
620            col: 2,
621        });
622        r.unused_class_members.push(UnusedMember {
623            path: root.join("src/service.ts"),
624            parent_name: "UserService".to_string(),
625            member_name: "legacyMethod".to_string(),
626            kind: MemberKind::ClassMethod,
627            line: 42,
628            col: 4,
629        });
630        r.unresolved_imports.push(UnresolvedImport {
631            path: root.join("src/app.ts"),
632            specifier: "./missing-module".to_string(),
633            line: 3,
634            col: 0,
635        });
636        r.unlisted_dependencies.push(UnlistedDependency {
637            package_name: "chalk".to_string(),
638            imported_from: vec![ImportSite {
639                path: root.join("src/cli.ts"),
640                line: 2,
641                col: 0,
642            }],
643        });
644        r.duplicate_exports.push(DuplicateExport {
645            export_name: "Config".to_string(),
646            locations: vec![
647                DuplicateLocation {
648                    path: root.join("src/config.ts"),
649                    line: 15,
650                    col: 0,
651                },
652                DuplicateLocation {
653                    path: root.join("src/types.ts"),
654                    line: 30,
655                    col: 0,
656                },
657            ],
658        });
659        r.type_only_dependencies.push(TypeOnlyDependency {
660            package_name: "zod".to_string(),
661            path: root.join("package.json"),
662            line: 8,
663        });
664        r.circular_dependencies.push(CircularDependency {
665            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
666            length: 2,
667            line: 3,
668            col: 0,
669        });
670
671        r
672    }
673
674    #[test]
675    fn sarif_has_required_top_level_fields() {
676        let root = PathBuf::from("/project");
677        let results = AnalysisResults::default();
678        let sarif = build_sarif(&results, &root, &RulesConfig::default());
679
680        assert_eq!(
681            sarif["$schema"],
682            "https://json.schemastore.org/sarif-2.1.0.json"
683        );
684        assert_eq!(sarif["version"], "2.1.0");
685        assert!(sarif["runs"].is_array());
686    }
687
688    #[test]
689    fn sarif_has_tool_driver_info() {
690        let root = PathBuf::from("/project");
691        let results = AnalysisResults::default();
692        let sarif = build_sarif(&results, &root, &RulesConfig::default());
693
694        let driver = &sarif["runs"][0]["tool"]["driver"];
695        assert_eq!(driver["name"], "fallow");
696        assert!(driver["version"].is_string());
697        assert_eq!(
698            driver["informationUri"],
699            "https://github.com/fallow-rs/fallow"
700        );
701    }
702
703    #[test]
704    fn sarif_declares_all_rules() {
705        let root = PathBuf::from("/project");
706        let results = AnalysisResults::default();
707        let sarif = build_sarif(&results, &root, &RulesConfig::default());
708
709        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
710            .as_array()
711            .expect("rules should be an array");
712        assert_eq!(rules.len(), 13);
713
714        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
715        assert!(rule_ids.contains(&"fallow/unused-file"));
716        assert!(rule_ids.contains(&"fallow/unused-export"));
717        assert!(rule_ids.contains(&"fallow/unused-type"));
718        assert!(rule_ids.contains(&"fallow/unused-dependency"));
719        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
720        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
721        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
722        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
723        assert!(rule_ids.contains(&"fallow/unused-class-member"));
724        assert!(rule_ids.contains(&"fallow/unresolved-import"));
725        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
726        assert!(rule_ids.contains(&"fallow/duplicate-export"));
727        assert!(rule_ids.contains(&"fallow/circular-dependency"));
728    }
729
730    #[test]
731    fn sarif_empty_results_no_results_entries() {
732        let root = PathBuf::from("/project");
733        let results = AnalysisResults::default();
734        let sarif = build_sarif(&results, &root, &RulesConfig::default());
735
736        let sarif_results = sarif["runs"][0]["results"]
737            .as_array()
738            .expect("results should be an array");
739        assert!(sarif_results.is_empty());
740    }
741
742    #[test]
743    fn sarif_unused_file_result() {
744        let root = PathBuf::from("/project");
745        let mut results = AnalysisResults::default();
746        results.unused_files.push(UnusedFile {
747            path: root.join("src/dead.ts"),
748        });
749
750        let sarif = build_sarif(&results, &root, &RulesConfig::default());
751        let entries = sarif["runs"][0]["results"].as_array().unwrap();
752        assert_eq!(entries.len(), 1);
753
754        let entry = &entries[0];
755        assert_eq!(entry["ruleId"], "fallow/unused-file");
756        // Default severity is "error" per RulesConfig::default()
757        assert_eq!(entry["level"], "error");
758        assert_eq!(
759            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
760            "src/dead.ts"
761        );
762    }
763
764    #[test]
765    fn sarif_unused_export_includes_region() {
766        let root = PathBuf::from("/project");
767        let mut results = AnalysisResults::default();
768        results.unused_exports.push(UnusedExport {
769            path: root.join("src/utils.ts"),
770            export_name: "helperFn".to_string(),
771            is_type_only: false,
772            line: 10,
773            col: 4,
774            span_start: 120,
775            is_re_export: false,
776        });
777
778        let sarif = build_sarif(&results, &root, &RulesConfig::default());
779        let entry = &sarif["runs"][0]["results"][0];
780        assert_eq!(entry["ruleId"], "fallow/unused-export");
781
782        let region = &entry["locations"][0]["physicalLocation"]["region"];
783        assert_eq!(region["startLine"], 10);
784        // SARIF columns are 1-based, code adds +1 to the 0-based col
785        assert_eq!(region["startColumn"], 5);
786    }
787
788    #[test]
789    fn sarif_unresolved_import_is_error_level() {
790        let root = PathBuf::from("/project");
791        let mut results = AnalysisResults::default();
792        results.unresolved_imports.push(UnresolvedImport {
793            path: root.join("src/app.ts"),
794            specifier: "./missing".to_string(),
795            line: 1,
796            col: 0,
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/unresolved-import");
802        assert_eq!(entry["level"], "error");
803    }
804
805    #[test]
806    fn sarif_unlisted_dependency_points_to_import_site() {
807        let root = PathBuf::from("/project");
808        let mut results = AnalysisResults::default();
809        results.unlisted_dependencies.push(UnlistedDependency {
810            package_name: "chalk".to_string(),
811            imported_from: vec![ImportSite {
812                path: root.join("src/cli.ts"),
813                line: 3,
814                col: 0,
815            }],
816        });
817
818        let sarif = build_sarif(&results, &root, &RulesConfig::default());
819        let entry = &sarif["runs"][0]["results"][0];
820        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
821        assert_eq!(entry["level"], "error");
822        assert_eq!(
823            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
824            "src/cli.ts"
825        );
826        let region = &entry["locations"][0]["physicalLocation"]["region"];
827        assert_eq!(region["startLine"], 3);
828        assert_eq!(region["startColumn"], 1);
829    }
830
831    #[test]
832    fn sarif_dependency_issues_point_to_package_json() {
833        let root = PathBuf::from("/project");
834        let mut results = AnalysisResults::default();
835        results.unused_dependencies.push(UnusedDependency {
836            package_name: "lodash".to_string(),
837            location: DependencyLocation::Dependencies,
838            path: root.join("package.json"),
839            line: 5,
840        });
841        results.unused_dev_dependencies.push(UnusedDependency {
842            package_name: "jest".to_string(),
843            location: DependencyLocation::DevDependencies,
844            path: root.join("package.json"),
845            line: 5,
846        });
847
848        let sarif = build_sarif(&results, &root, &RulesConfig::default());
849        let entries = sarif["runs"][0]["results"].as_array().unwrap();
850        for entry in entries {
851            assert_eq!(
852                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
853                "package.json"
854            );
855        }
856    }
857
858    #[test]
859    fn sarif_duplicate_export_emits_one_result_per_location() {
860        let root = PathBuf::from("/project");
861        let mut results = AnalysisResults::default();
862        results.duplicate_exports.push(DuplicateExport {
863            export_name: "Config".to_string(),
864            locations: vec![
865                DuplicateLocation {
866                    path: root.join("src/a.ts"),
867                    line: 15,
868                    col: 0,
869                },
870                DuplicateLocation {
871                    path: root.join("src/b.ts"),
872                    line: 30,
873                    col: 0,
874                },
875            ],
876        });
877
878        let sarif = build_sarif(&results, &root, &RulesConfig::default());
879        let entries = sarif["runs"][0]["results"].as_array().unwrap();
880        // One SARIF result per location, not one per DuplicateExport
881        assert_eq!(entries.len(), 2);
882        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
883        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
884        assert_eq!(
885            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
886            "src/a.ts"
887        );
888        assert_eq!(
889            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
890            "src/b.ts"
891        );
892    }
893
894    #[test]
895    fn sarif_all_issue_types_produce_results() {
896        let root = PathBuf::from("/project");
897        let results = sample_results(&root);
898        let sarif = build_sarif(&results, &root, &RulesConfig::default());
899
900        let entries = sarif["runs"][0]["results"].as_array().unwrap();
901        // 12 issues but duplicate_exports has 2 locations => 13 SARIF results
902        assert_eq!(entries.len(), 13);
903
904        let rule_ids: Vec<&str> = entries
905            .iter()
906            .map(|e| e["ruleId"].as_str().unwrap())
907            .collect();
908        assert!(rule_ids.contains(&"fallow/unused-file"));
909        assert!(rule_ids.contains(&"fallow/unused-export"));
910        assert!(rule_ids.contains(&"fallow/unused-type"));
911        assert!(rule_ids.contains(&"fallow/unused-dependency"));
912        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
913        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
914        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
915        assert!(rule_ids.contains(&"fallow/unused-class-member"));
916        assert!(rule_ids.contains(&"fallow/unresolved-import"));
917        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
918        assert!(rule_ids.contains(&"fallow/duplicate-export"));
919    }
920
921    #[test]
922    fn sarif_serializes_to_valid_json() {
923        let root = PathBuf::from("/project");
924        let results = sample_results(&root);
925        let sarif = build_sarif(&results, &root, &RulesConfig::default());
926
927        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
928        let reparsed: serde_json::Value =
929            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
930        assert_eq!(reparsed, sarif);
931    }
932
933    #[test]
934    fn sarif_file_write_produces_valid_sarif() {
935        let root = PathBuf::from("/project");
936        let results = sample_results(&root);
937        let sarif = build_sarif(&results, &root, &RulesConfig::default());
938        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
939
940        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
941        let _ = std::fs::create_dir_all(&dir);
942        let sarif_path = dir.join("results.sarif");
943        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
944
945        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
946        let parsed: serde_json::Value =
947            serde_json::from_str(&contents).expect("file should contain valid JSON");
948
949        assert_eq!(parsed["version"], "2.1.0");
950        assert_eq!(
951            parsed["$schema"],
952            "https://json.schemastore.org/sarif-2.1.0.json"
953        );
954        let sarif_results = parsed["runs"][0]["results"]
955            .as_array()
956            .expect("results should be an array");
957        assert!(!sarif_results.is_empty());
958
959        // Clean up
960        let _ = std::fs::remove_file(&sarif_path);
961        let _ = std::fs::remove_dir(&dir);
962    }
963
964    // ── Health SARIF ──
965
966    #[test]
967    fn health_sarif_empty_no_results() {
968        let root = PathBuf::from("/project");
969        let report = crate::health_types::HealthReport {
970            findings: vec![],
971            summary: crate::health_types::HealthSummary {
972                files_analyzed: 10,
973                functions_analyzed: 50,
974                functions_above_threshold: 0,
975                max_cyclomatic_threshold: 20,
976                max_cognitive_threshold: 15,
977                files_scored: None,
978                average_maintainability: None,
979            },
980            file_scores: vec![],
981            hotspots: vec![],
982            hotspot_summary: None,
983        };
984        let sarif = build_health_sarif(&report, &root);
985        assert_eq!(sarif["version"], "2.1.0");
986        let results = sarif["runs"][0]["results"].as_array().unwrap();
987        assert!(results.is_empty());
988        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
989            .as_array()
990            .unwrap();
991        assert_eq!(rules.len(), 3);
992    }
993
994    #[test]
995    fn health_sarif_cyclomatic_only() {
996        let root = PathBuf::from("/project");
997        let report = crate::health_types::HealthReport {
998            findings: vec![crate::health_types::HealthFinding {
999                path: root.join("src/utils.ts"),
1000                name: "parseExpression".to_string(),
1001                line: 42,
1002                col: 0,
1003                cyclomatic: 25,
1004                cognitive: 10,
1005                line_count: 80,
1006                exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1007            }],
1008            summary: crate::health_types::HealthSummary {
1009                files_analyzed: 5,
1010                functions_analyzed: 20,
1011                functions_above_threshold: 1,
1012                max_cyclomatic_threshold: 20,
1013                max_cognitive_threshold: 15,
1014                files_scored: None,
1015                average_maintainability: None,
1016            },
1017            file_scores: vec![],
1018            hotspots: vec![],
1019            hotspot_summary: None,
1020        };
1021        let sarif = build_health_sarif(&report, &root);
1022        let entry = &sarif["runs"][0]["results"][0];
1023        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1024        assert_eq!(entry["level"], "warning");
1025        assert!(
1026            entry["message"]["text"]
1027                .as_str()
1028                .unwrap()
1029                .contains("cyclomatic complexity 25")
1030        );
1031        assert_eq!(
1032            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1033            "src/utils.ts"
1034        );
1035        let region = &entry["locations"][0]["physicalLocation"]["region"];
1036        assert_eq!(region["startLine"], 42);
1037        assert_eq!(region["startColumn"], 1);
1038    }
1039
1040    #[test]
1041    fn health_sarif_cognitive_only() {
1042        let root = PathBuf::from("/project");
1043        let report = crate::health_types::HealthReport {
1044            findings: vec![crate::health_types::HealthFinding {
1045                path: root.join("src/api.ts"),
1046                name: "handleRequest".to_string(),
1047                line: 10,
1048                col: 4,
1049                cyclomatic: 8,
1050                cognitive: 20,
1051                line_count: 40,
1052                exceeded: crate::health_types::ExceededThreshold::Cognitive,
1053            }],
1054            summary: crate::health_types::HealthSummary {
1055                files_analyzed: 3,
1056                functions_analyzed: 10,
1057                functions_above_threshold: 1,
1058                max_cyclomatic_threshold: 20,
1059                max_cognitive_threshold: 15,
1060                files_scored: None,
1061                average_maintainability: None,
1062            },
1063            file_scores: vec![],
1064            hotspots: vec![],
1065            hotspot_summary: None,
1066        };
1067        let sarif = build_health_sarif(&report, &root);
1068        let entry = &sarif["runs"][0]["results"][0];
1069        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1070        assert!(
1071            entry["message"]["text"]
1072                .as_str()
1073                .unwrap()
1074                .contains("cognitive complexity 20")
1075        );
1076        let region = &entry["locations"][0]["physicalLocation"]["region"];
1077        assert_eq!(region["startColumn"], 5); // col 4 + 1
1078    }
1079
1080    #[test]
1081    fn health_sarif_both_thresholds() {
1082        let root = PathBuf::from("/project");
1083        let report = crate::health_types::HealthReport {
1084            findings: vec![crate::health_types::HealthFinding {
1085                path: root.join("src/complex.ts"),
1086                name: "doEverything".to_string(),
1087                line: 1,
1088                col: 0,
1089                cyclomatic: 30,
1090                cognitive: 45,
1091                line_count: 100,
1092                exceeded: crate::health_types::ExceededThreshold::Both,
1093            }],
1094            summary: crate::health_types::HealthSummary {
1095                files_analyzed: 1,
1096                functions_analyzed: 1,
1097                functions_above_threshold: 1,
1098                max_cyclomatic_threshold: 20,
1099                max_cognitive_threshold: 15,
1100                files_scored: None,
1101                average_maintainability: None,
1102            },
1103            file_scores: vec![],
1104            hotspots: vec![],
1105            hotspot_summary: None,
1106        };
1107        let sarif = build_health_sarif(&report, &root);
1108        let entry = &sarif["runs"][0]["results"][0];
1109        assert_eq!(entry["ruleId"], "fallow/high-complexity");
1110        let msg = entry["message"]["text"].as_str().unwrap();
1111        assert!(msg.contains("cyclomatic complexity 30"));
1112        assert!(msg.contains("cognitive complexity 45"));
1113    }
1114}