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