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: None,
153            properties: None,
154        }
155    };
156
157    push_sarif_results(&mut sarif_results, &results.unused_dependencies, |dep| {
158        sarif_dep(
159            dep,
160            "fallow/unused-dependency",
161            severity_to_sarif_level(rules.unused_dependencies),
162            "dependencies",
163        )
164    });
165
166    push_sarif_results(
167        &mut sarif_results,
168        &results.unused_dev_dependencies,
169        |dep| {
170            sarif_dep(
171                dep,
172                "fallow/unused-dev-dependency",
173                severity_to_sarif_level(rules.unused_dev_dependencies),
174                "devDependencies",
175            )
176        },
177    );
178
179    push_sarif_results(
180        &mut sarif_results,
181        &results.unused_optional_dependencies,
182        |dep| {
183            sarif_dep(
184                dep,
185                "fallow/unused-optional-dependency",
186                severity_to_sarif_level(rules.unused_optional_dependencies),
187                "optionalDependencies",
188            )
189        },
190    );
191
192    let sarif_member = |member: &UnusedMember,
193                        rule_id: &'static str,
194                        level: &'static str,
195                        kind: &str|
196     -> SarifFields {
197        SarifFields {
198            rule_id,
199            level,
200            message: format!(
201                "{} member '{}.{}' is never referenced",
202                kind, member.parent_name, member.member_name
203            ),
204            uri: relative_uri(&member.path, root),
205            region: Some((member.line, member.col + 1)),
206            properties: None,
207        }
208    };
209
210    push_sarif_results(&mut sarif_results, &results.unused_enum_members, |member| {
211        sarif_member(
212            member,
213            "fallow/unused-enum-member",
214            severity_to_sarif_level(rules.unused_enum_members),
215            "Enum",
216        )
217    });
218
219    push_sarif_results(
220        &mut sarif_results,
221        &results.unused_class_members,
222        |member| {
223            sarif_member(
224                member,
225                "fallow/unused-class-member",
226                severity_to_sarif_level(rules.unused_class_members),
227                "Class",
228            )
229        },
230    );
231
232    push_sarif_results(&mut sarif_results, &results.unresolved_imports, |import| {
233        SarifFields {
234            rule_id: "fallow/unresolved-import",
235            level: severity_to_sarif_level(rules.unresolved_imports),
236            message: format!("Import '{}' could not be resolved", import.specifier),
237            uri: relative_uri(&import.path, root),
238            region: Some((import.line, import.col + 1)),
239            properties: None,
240        }
241    });
242
243    push_sarif_results(&mut sarif_results, &results.unlisted_dependencies, |dep| {
244        SarifFields {
245            rule_id: "fallow/unlisted-dependency",
246            level: severity_to_sarif_level(rules.unlisted_dependencies),
247            message: format!(
248                "Package '{}' is imported but not listed in package.json",
249                dep.package_name
250            ),
251            uri: "package.json".to_string(),
252            region: None,
253            properties: None,
254        }
255    });
256
257    // Duplicate exports: one result per location (SARIF 2.1.0 section 3.27.12)
258    for dup in &results.duplicate_exports {
259        for loc_path in &dup.locations {
260            sarif_results.push(sarif_result(
261                "fallow/duplicate-export",
262                severity_to_sarif_level(rules.duplicate_exports),
263                &format!("Export '{}' appears in multiple modules", dup.export_name),
264                &relative_uri(loc_path, root),
265                None,
266            ));
267        }
268    }
269
270    push_sarif_results(
271        &mut sarif_results,
272        &results.circular_dependencies,
273        |cycle| {
274            let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
275            let mut display_chain = chain.clone();
276            if let Some(first) = chain.first() {
277                display_chain.push(first.clone());
278            }
279            let first_uri = chain.first().map_or_else(String::new, Clone::clone);
280            SarifFields {
281                rule_id: "fallow/circular-dependency",
282                level: severity_to_sarif_level(rules.circular_dependencies),
283                message: format!("Circular dependency: {}", display_chain.join(" \u{2192} ")),
284                uri: first_uri,
285                region: None,
286                properties: None,
287            }
288        },
289    );
290
291    serde_json::json!({
292        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
293        "version": "2.1.0",
294        "runs": [{
295            "tool": {
296                "driver": {
297                    "name": "fallow",
298                    "version": env!("CARGO_PKG_VERSION"),
299                    "informationUri": "https://github.com/fallow-rs/fallow",
300                    "rules": [
301                        {
302                            "id": "fallow/unused-file",
303                            "shortDescription": { "text": "File is not reachable from any entry point" },
304                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_files) }
305                        },
306                        {
307                            "id": "fallow/unused-export",
308                            "shortDescription": { "text": "Export is never imported" },
309                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_exports) }
310                        },
311                        {
312                            "id": "fallow/unused-type",
313                            "shortDescription": { "text": "Type export is never imported" },
314                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_types) }
315                        },
316                        {
317                            "id": "fallow/unused-dependency",
318                            "shortDescription": { "text": "Dependency listed but never imported" },
319                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_dependencies) }
320                        },
321                        {
322                            "id": "fallow/unused-dev-dependency",
323                            "shortDescription": { "text": "Dev dependency listed but never imported" },
324                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_dev_dependencies) }
325                        },
326                        {
327                            "id": "fallow/unused-optional-dependency",
328                            "shortDescription": { "text": "Optional dependency listed but never imported" },
329                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_optional_dependencies) }
330                        },
331                        {
332                            "id": "fallow/unused-enum-member",
333                            "shortDescription": { "text": "Enum member is never referenced" },
334                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_enum_members) }
335                        },
336                        {
337                            "id": "fallow/unused-class-member",
338                            "shortDescription": { "text": "Class member is never referenced" },
339                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_class_members) }
340                        },
341                        {
342                            "id": "fallow/unresolved-import",
343                            "shortDescription": { "text": "Import could not be resolved" },
344                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unresolved_imports) }
345                        },
346                        {
347                            "id": "fallow/unlisted-dependency",
348                            "shortDescription": { "text": "Dependency used but not in package.json" },
349                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.unlisted_dependencies) }
350                        },
351                        {
352                            "id": "fallow/duplicate-export",
353                            "shortDescription": { "text": "Export name appears in multiple modules" },
354                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.duplicate_exports) }
355                        },
356                        {
357                            "id": "fallow/circular-dependency",
358                            "shortDescription": { "text": "Circular dependency chain detected" },
359                            "defaultConfiguration": { "level": severity_to_sarif_level(rules.circular_dependencies) }
360                        }
361                    ]
362                }
363            },
364            "results": sarif_results
365        }]
366    })
367}
368
369pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
370    let sarif = build_sarif(results, root, rules);
371    match serde_json::to_string_pretty(&sarif) {
372        Ok(json) => {
373            println!("{json}");
374            ExitCode::SUCCESS
375        }
376        Err(e) => {
377            eprintln!("Error: failed to serialize SARIF output: {e}");
378            ExitCode::from(2)
379        }
380    }
381}
382
383pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
384    let mut sarif_results = Vec::new();
385
386    for (i, group) in report.clone_groups.iter().enumerate() {
387        for instance in &group.instances {
388            sarif_results.push(sarif_result(
389                "fallow/code-duplication",
390                "warning",
391                &format!(
392                    "Code clone group {} ({} lines, {} instances)",
393                    i + 1,
394                    group.line_count,
395                    group.instances.len()
396                ),
397                &relative_uri(&instance.file, root),
398                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
399            ));
400        }
401    }
402
403    let sarif = serde_json::json!({
404        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
405        "version": "2.1.0",
406        "runs": [{
407            "tool": {
408                "driver": {
409                    "name": "fallow",
410                    "version": env!("CARGO_PKG_VERSION"),
411                    "informationUri": "https://github.com/fallow-rs/fallow",
412                    "rules": [{
413                        "id": "fallow/code-duplication",
414                        "shortDescription": { "text": "Duplicated code block" },
415                        "defaultConfiguration": { "level": "warning" }
416                    }]
417                }
418            },
419            "results": sarif_results
420        }]
421    });
422
423    match serde_json::to_string_pretty(&sarif) {
424        Ok(json) => {
425            println!("{json}");
426            ExitCode::SUCCESS
427        }
428        Err(e) => {
429            eprintln!("Error: failed to serialize SARIF output: {e}");
430            ExitCode::from(2)
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use fallow_core::extract::MemberKind;
439    use fallow_core::results::*;
440    use std::path::PathBuf;
441
442    /// Helper: build an `AnalysisResults` populated with one issue of every type.
443    fn sample_results(root: &Path) -> AnalysisResults {
444        let mut r = AnalysisResults::default();
445
446        r.unused_files.push(UnusedFile {
447            path: root.join("src/dead.ts"),
448        });
449        r.unused_exports.push(UnusedExport {
450            path: root.join("src/utils.ts"),
451            export_name: "helperFn".to_string(),
452            is_type_only: false,
453            line: 10,
454            col: 4,
455            span_start: 120,
456            is_re_export: false,
457        });
458        r.unused_types.push(UnusedExport {
459            path: root.join("src/types.ts"),
460            export_name: "OldType".to_string(),
461            is_type_only: true,
462            line: 5,
463            col: 0,
464            span_start: 60,
465            is_re_export: false,
466        });
467        r.unused_dependencies.push(UnusedDependency {
468            package_name: "lodash".to_string(),
469            location: DependencyLocation::Dependencies,
470            path: root.join("package.json"),
471        });
472        r.unused_dev_dependencies.push(UnusedDependency {
473            package_name: "jest".to_string(),
474            location: DependencyLocation::DevDependencies,
475            path: root.join("package.json"),
476        });
477        r.unused_enum_members.push(UnusedMember {
478            path: root.join("src/enums.ts"),
479            parent_name: "Status".to_string(),
480            member_name: "Deprecated".to_string(),
481            kind: MemberKind::EnumMember,
482            line: 8,
483            col: 2,
484        });
485        r.unused_class_members.push(UnusedMember {
486            path: root.join("src/service.ts"),
487            parent_name: "UserService".to_string(),
488            member_name: "legacyMethod".to_string(),
489            kind: MemberKind::ClassMethod,
490            line: 42,
491            col: 4,
492        });
493        r.unresolved_imports.push(UnresolvedImport {
494            path: root.join("src/app.ts"),
495            specifier: "./missing-module".to_string(),
496            line: 3,
497            col: 0,
498        });
499        r.unlisted_dependencies.push(UnlistedDependency {
500            package_name: "chalk".to_string(),
501            imported_from: vec![root.join("src/cli.ts")],
502        });
503        r.duplicate_exports.push(DuplicateExport {
504            export_name: "Config".to_string(),
505            locations: vec![root.join("src/config.ts"), root.join("src/types.ts")],
506        });
507        r.circular_dependencies.push(CircularDependency {
508            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
509            length: 2,
510        });
511
512        r
513    }
514
515    #[test]
516    fn sarif_has_required_top_level_fields() {
517        let root = PathBuf::from("/project");
518        let results = AnalysisResults::default();
519        let sarif = build_sarif(&results, &root, &RulesConfig::default());
520
521        assert_eq!(
522            sarif["$schema"],
523            "https://json.schemastore.org/sarif-2.1.0.json"
524        );
525        assert_eq!(sarif["version"], "2.1.0");
526        assert!(sarif["runs"].is_array());
527    }
528
529    #[test]
530    fn sarif_has_tool_driver_info() {
531        let root = PathBuf::from("/project");
532        let results = AnalysisResults::default();
533        let sarif = build_sarif(&results, &root, &RulesConfig::default());
534
535        let driver = &sarif["runs"][0]["tool"]["driver"];
536        assert_eq!(driver["name"], "fallow");
537        assert!(driver["version"].is_string());
538        assert_eq!(
539            driver["informationUri"],
540            "https://github.com/fallow-rs/fallow"
541        );
542    }
543
544    #[test]
545    fn sarif_declares_all_rules() {
546        let root = PathBuf::from("/project");
547        let results = AnalysisResults::default();
548        let sarif = build_sarif(&results, &root, &RulesConfig::default());
549
550        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
551            .as_array()
552            .expect("rules should be an array");
553        assert_eq!(rules.len(), 12);
554
555        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
556        assert!(rule_ids.contains(&"fallow/unused-file"));
557        assert!(rule_ids.contains(&"fallow/unused-export"));
558        assert!(rule_ids.contains(&"fallow/unused-type"));
559        assert!(rule_ids.contains(&"fallow/unused-dependency"));
560        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
561        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
562        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
563        assert!(rule_ids.contains(&"fallow/unused-class-member"));
564        assert!(rule_ids.contains(&"fallow/unresolved-import"));
565        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
566        assert!(rule_ids.contains(&"fallow/duplicate-export"));
567        assert!(rule_ids.contains(&"fallow/circular-dependency"));
568    }
569
570    #[test]
571    fn sarif_empty_results_no_results_entries() {
572        let root = PathBuf::from("/project");
573        let results = AnalysisResults::default();
574        let sarif = build_sarif(&results, &root, &RulesConfig::default());
575
576        let sarif_results = sarif["runs"][0]["results"]
577            .as_array()
578            .expect("results should be an array");
579        assert!(sarif_results.is_empty());
580    }
581
582    #[test]
583    fn sarif_unused_file_result() {
584        let root = PathBuf::from("/project");
585        let mut results = AnalysisResults::default();
586        results.unused_files.push(UnusedFile {
587            path: root.join("src/dead.ts"),
588        });
589
590        let sarif = build_sarif(&results, &root, &RulesConfig::default());
591        let entries = sarif["runs"][0]["results"].as_array().unwrap();
592        assert_eq!(entries.len(), 1);
593
594        let entry = &entries[0];
595        assert_eq!(entry["ruleId"], "fallow/unused-file");
596        // Default severity is "error" per RulesConfig::default()
597        assert_eq!(entry["level"], "error");
598        assert_eq!(
599            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
600            "src/dead.ts"
601        );
602    }
603
604    #[test]
605    fn sarif_unused_export_includes_region() {
606        let root = PathBuf::from("/project");
607        let mut results = AnalysisResults::default();
608        results.unused_exports.push(UnusedExport {
609            path: root.join("src/utils.ts"),
610            export_name: "helperFn".to_string(),
611            is_type_only: false,
612            line: 10,
613            col: 4,
614            span_start: 120,
615            is_re_export: false,
616        });
617
618        let sarif = build_sarif(&results, &root, &RulesConfig::default());
619        let entry = &sarif["runs"][0]["results"][0];
620        assert_eq!(entry["ruleId"], "fallow/unused-export");
621
622        let region = &entry["locations"][0]["physicalLocation"]["region"];
623        assert_eq!(region["startLine"], 10);
624        // SARIF columns are 1-based, code adds +1 to the 0-based col
625        assert_eq!(region["startColumn"], 5);
626    }
627
628    #[test]
629    fn sarif_unresolved_import_is_error_level() {
630        let root = PathBuf::from("/project");
631        let mut results = AnalysisResults::default();
632        results.unresolved_imports.push(UnresolvedImport {
633            path: root.join("src/app.ts"),
634            specifier: "./missing".to_string(),
635            line: 1,
636            col: 0,
637        });
638
639        let sarif = build_sarif(&results, &root, &RulesConfig::default());
640        let entry = &sarif["runs"][0]["results"][0];
641        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
642        assert_eq!(entry["level"], "error");
643    }
644
645    #[test]
646    fn sarif_unlisted_dependency_is_error_level() {
647        let root = PathBuf::from("/project");
648        let mut results = AnalysisResults::default();
649        results.unlisted_dependencies.push(UnlistedDependency {
650            package_name: "chalk".to_string(),
651            imported_from: vec![],
652        });
653
654        let sarif = build_sarif(&results, &root, &RulesConfig::default());
655        let entry = &sarif["runs"][0]["results"][0];
656        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
657        assert_eq!(entry["level"], "error");
658        assert_eq!(
659            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
660            "package.json"
661        );
662    }
663
664    #[test]
665    fn sarif_dependency_issues_point_to_package_json() {
666        let root = PathBuf::from("/project");
667        let mut results = AnalysisResults::default();
668        results.unused_dependencies.push(UnusedDependency {
669            package_name: "lodash".to_string(),
670            location: DependencyLocation::Dependencies,
671            path: root.join("package.json"),
672        });
673        results.unused_dev_dependencies.push(UnusedDependency {
674            package_name: "jest".to_string(),
675            location: DependencyLocation::DevDependencies,
676            path: root.join("package.json"),
677        });
678
679        let sarif = build_sarif(&results, &root, &RulesConfig::default());
680        let entries = sarif["runs"][0]["results"].as_array().unwrap();
681        for entry in entries {
682            assert_eq!(
683                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
684                "package.json"
685            );
686        }
687    }
688
689    #[test]
690    fn sarif_duplicate_export_emits_one_result_per_location() {
691        let root = PathBuf::from("/project");
692        let mut results = AnalysisResults::default();
693        results.duplicate_exports.push(DuplicateExport {
694            export_name: "Config".to_string(),
695            locations: vec![root.join("src/a.ts"), root.join("src/b.ts")],
696        });
697
698        let sarif = build_sarif(&results, &root, &RulesConfig::default());
699        let entries = sarif["runs"][0]["results"].as_array().unwrap();
700        // One SARIF result per location, not one per DuplicateExport
701        assert_eq!(entries.len(), 2);
702        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
703        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
704        assert_eq!(
705            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
706            "src/a.ts"
707        );
708        assert_eq!(
709            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
710            "src/b.ts"
711        );
712    }
713
714    #[test]
715    fn sarif_all_issue_types_produce_results() {
716        let root = PathBuf::from("/project");
717        let results = sample_results(&root);
718        let sarif = build_sarif(&results, &root, &RulesConfig::default());
719
720        let entries = sarif["runs"][0]["results"].as_array().unwrap();
721        // 11 issues but duplicate_exports has 2 locations => 12 SARIF results
722        assert_eq!(entries.len(), 12);
723
724        let rule_ids: Vec<&str> = entries
725            .iter()
726            .map(|e| e["ruleId"].as_str().unwrap())
727            .collect();
728        assert!(rule_ids.contains(&"fallow/unused-file"));
729        assert!(rule_ids.contains(&"fallow/unused-export"));
730        assert!(rule_ids.contains(&"fallow/unused-type"));
731        assert!(rule_ids.contains(&"fallow/unused-dependency"));
732        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
733        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
734        assert!(rule_ids.contains(&"fallow/unused-class-member"));
735        assert!(rule_ids.contains(&"fallow/unresolved-import"));
736        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
737        assert!(rule_ids.contains(&"fallow/duplicate-export"));
738    }
739
740    #[test]
741    fn sarif_serializes_to_valid_json() {
742        let root = PathBuf::from("/project");
743        let results = sample_results(&root);
744        let sarif = build_sarif(&results, &root, &RulesConfig::default());
745
746        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
747        let reparsed: serde_json::Value =
748            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
749        assert_eq!(reparsed, sarif);
750    }
751
752    #[test]
753    fn sarif_file_write_produces_valid_sarif() {
754        let root = PathBuf::from("/project");
755        let results = sample_results(&root);
756        let sarif = build_sarif(&results, &root, &RulesConfig::default());
757        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
758
759        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
760        let _ = std::fs::create_dir_all(&dir);
761        let sarif_path = dir.join("results.sarif");
762        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
763
764        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
765        let parsed: serde_json::Value =
766            serde_json::from_str(&contents).expect("file should contain valid JSON");
767
768        assert_eq!(parsed["version"], "2.1.0");
769        assert_eq!(
770            parsed["$schema"],
771            "https://json.schemastore.org/sarif-2.1.0.json"
772        );
773        let sarif_results = parsed["runs"][0]["results"]
774            .as_array()
775            .expect("results should be an array");
776        assert!(!sarif_results.is_empty());
777
778        // Clean up
779        let _ = std::fs::remove_file(&sarif_path);
780        let _ = std::fs::remove_dir(&dir);
781    }
782}