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::grouping::{self, OwnershipResolver};
9use super::{emit_json, relative_uri};
10use crate::explain;
11
12/// Intermediate fields extracted from an issue for SARIF result construction.
13struct SarifFields {
14    rule_id: &'static str,
15    level: &'static str,
16    message: String,
17    uri: String,
18    region: Option<(u32, u32)>,
19    properties: Option<serde_json::Value>,
20}
21
22const fn severity_to_sarif_level(s: Severity) -> &'static str {
23    match s {
24        Severity::Error => "error",
25        Severity::Warn | Severity::Off => "warning",
26    }
27}
28
29/// Build a single SARIF result object.
30///
31/// When `region` is `Some((line, col))`, a `region` block with 1-based
32/// `startLine` and `startColumn` is included in the physical location.
33fn sarif_result(
34    rule_id: &str,
35    level: &str,
36    message: &str,
37    uri: &str,
38    region: Option<(u32, u32)>,
39) -> serde_json::Value {
40    let mut physical_location = serde_json::json!({
41        "artifactLocation": { "uri": uri }
42    });
43    if let Some((line, col)) = region {
44        physical_location["region"] = serde_json::json!({
45            "startLine": line,
46            "startColumn": col
47        });
48    }
49    serde_json::json!({
50        "ruleId": rule_id,
51        "level": level,
52        "message": { "text": message },
53        "locations": [{ "physicalLocation": physical_location }]
54    })
55}
56
57/// Append SARIF results for a slice of items using a closure to extract fields.
58fn push_sarif_results<T>(
59    sarif_results: &mut Vec<serde_json::Value>,
60    items: &[T],
61    extract: impl Fn(&T) -> SarifFields,
62) {
63    for item in items {
64        let fields = extract(item);
65        let mut result = sarif_result(
66            fields.rule_id,
67            fields.level,
68            &fields.message,
69            &fields.uri,
70            fields.region,
71        );
72        if let Some(props) = fields.properties {
73            result["properties"] = props;
74        }
75        sarif_results.push(result);
76    }
77}
78
79/// Build a SARIF rule definition with optional `fullDescription` and `helpUri`
80/// sourced from the centralized explain module.
81fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
82    explain::rule_by_id(id).map_or_else(
83        || {
84            serde_json::json!({
85                "id": id,
86                "shortDescription": { "text": fallback_short },
87                "defaultConfiguration": { "level": level }
88            })
89        },
90        |def| {
91            serde_json::json!({
92                "id": id,
93                "shortDescription": { "text": def.short },
94                "fullDescription": { "text": def.full },
95                "helpUri": explain::rule_docs_url(def),
96                "defaultConfiguration": { "level": level }
97            })
98        },
99    )
100}
101
102/// Extract SARIF fields for an unused export or type export.
103fn sarif_export_fields(
104    export: &UnusedExport,
105    root: &Path,
106    rule_id: &'static str,
107    level: &'static str,
108    kind: &str,
109    re_kind: &str,
110) -> SarifFields {
111    let label = if export.is_re_export { re_kind } else { kind };
112    SarifFields {
113        rule_id,
114        level,
115        message: format!(
116            "{} '{}' is never imported by other modules",
117            label, export.export_name
118        ),
119        uri: relative_uri(&export.path, root),
120        region: Some((export.line, export.col + 1)),
121        properties: if export.is_re_export {
122            Some(serde_json::json!({ "is_re_export": true }))
123        } else {
124            None
125        },
126    }
127}
128
129/// Extract SARIF fields for an unused dependency.
130fn sarif_dep_fields(
131    dep: &UnusedDependency,
132    root: &Path,
133    rule_id: &'static str,
134    level: &'static str,
135    section: &str,
136) -> SarifFields {
137    SarifFields {
138        rule_id,
139        level,
140        message: format!(
141            "Package '{}' is in {} but never imported",
142            dep.package_name, section
143        ),
144        uri: relative_uri(&dep.path, root),
145        region: if dep.line > 0 {
146            Some((dep.line, 1))
147        } else {
148            None
149        },
150        properties: None,
151    }
152}
153
154/// Extract SARIF fields for an unused enum or class member.
155fn sarif_member_fields(
156    member: &UnusedMember,
157    root: &Path,
158    rule_id: &'static str,
159    level: &'static str,
160    kind: &str,
161) -> SarifFields {
162    SarifFields {
163        rule_id,
164        level,
165        message: format!(
166            "{} member '{}.{}' is never referenced",
167            kind, member.parent_name, member.member_name
168        ),
169        uri: relative_uri(&member.path, root),
170        region: Some((member.line, member.col + 1)),
171        properties: None,
172    }
173}
174
175/// Build the SARIF rules list from the current rules configuration.
176fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
177    vec![
178        sarif_rule(
179            "fallow/unused-file",
180            "File is not reachable from any entry point",
181            severity_to_sarif_level(rules.unused_files),
182        ),
183        sarif_rule(
184            "fallow/unused-export",
185            "Export is never imported",
186            severity_to_sarif_level(rules.unused_exports),
187        ),
188        sarif_rule(
189            "fallow/unused-type",
190            "Type export is never imported",
191            severity_to_sarif_level(rules.unused_types),
192        ),
193        sarif_rule(
194            "fallow/unused-dependency",
195            "Dependency listed but never imported",
196            severity_to_sarif_level(rules.unused_dependencies),
197        ),
198        sarif_rule(
199            "fallow/unused-dev-dependency",
200            "Dev dependency listed but never imported",
201            severity_to_sarif_level(rules.unused_dev_dependencies),
202        ),
203        sarif_rule(
204            "fallow/unused-optional-dependency",
205            "Optional dependency listed but never imported",
206            severity_to_sarif_level(rules.unused_optional_dependencies),
207        ),
208        sarif_rule(
209            "fallow/type-only-dependency",
210            "Production dependency only used via type-only imports",
211            severity_to_sarif_level(rules.type_only_dependencies),
212        ),
213        sarif_rule(
214            "fallow/test-only-dependency",
215            "Production dependency only imported by test files",
216            severity_to_sarif_level(rules.test_only_dependencies),
217        ),
218        sarif_rule(
219            "fallow/unused-enum-member",
220            "Enum member is never referenced",
221            severity_to_sarif_level(rules.unused_enum_members),
222        ),
223        sarif_rule(
224            "fallow/unused-class-member",
225            "Class member is never referenced",
226            severity_to_sarif_level(rules.unused_class_members),
227        ),
228        sarif_rule(
229            "fallow/unresolved-import",
230            "Import could not be resolved",
231            severity_to_sarif_level(rules.unresolved_imports),
232        ),
233        sarif_rule(
234            "fallow/unlisted-dependency",
235            "Dependency used but not in package.json",
236            severity_to_sarif_level(rules.unlisted_dependencies),
237        ),
238        sarif_rule(
239            "fallow/duplicate-export",
240            "Export name appears in multiple modules",
241            severity_to_sarif_level(rules.duplicate_exports),
242        ),
243        sarif_rule(
244            "fallow/circular-dependency",
245            "Circular dependency chain detected",
246            severity_to_sarif_level(rules.circular_dependencies),
247        ),
248        sarif_rule(
249            "fallow/boundary-violation",
250            "Import crosses an architecture boundary",
251            severity_to_sarif_level(rules.boundary_violation),
252        ),
253    ]
254}
255
256#[must_use]
257pub fn build_sarif(
258    results: &AnalysisResults,
259    root: &Path,
260    rules: &RulesConfig,
261) -> serde_json::Value {
262    let mut sarif_results = Vec::new();
263
264    push_sarif_results(&mut sarif_results, &results.unused_files, |file| {
265        SarifFields {
266            rule_id: "fallow/unused-file",
267            level: severity_to_sarif_level(rules.unused_files),
268            message: "File is not reachable from any entry point".to_string(),
269            uri: relative_uri(&file.path, root),
270            region: None,
271            properties: None,
272        }
273    });
274
275    push_sarif_results(&mut sarif_results, &results.unused_exports, |export| {
276        sarif_export_fields(
277            export,
278            root,
279            "fallow/unused-export",
280            severity_to_sarif_level(rules.unused_exports),
281            "Export",
282            "Re-export",
283        )
284    });
285
286    push_sarif_results(&mut sarif_results, &results.unused_types, |export| {
287        sarif_export_fields(
288            export,
289            root,
290            "fallow/unused-type",
291            severity_to_sarif_level(rules.unused_types),
292            "Type export",
293            "Type re-export",
294        )
295    });
296
297    push_sarif_results(&mut sarif_results, &results.unused_dependencies, |dep| {
298        sarif_dep_fields(
299            dep,
300            root,
301            "fallow/unused-dependency",
302            severity_to_sarif_level(rules.unused_dependencies),
303            "dependencies",
304        )
305    });
306
307    push_sarif_results(
308        &mut sarif_results,
309        &results.unused_dev_dependencies,
310        |dep| {
311            sarif_dep_fields(
312                dep,
313                root,
314                "fallow/unused-dev-dependency",
315                severity_to_sarif_level(rules.unused_dev_dependencies),
316                "devDependencies",
317            )
318        },
319    );
320
321    push_sarif_results(
322        &mut sarif_results,
323        &results.unused_optional_dependencies,
324        |dep| {
325            sarif_dep_fields(
326                dep,
327                root,
328                "fallow/unused-optional-dependency",
329                severity_to_sarif_level(rules.unused_optional_dependencies),
330                "optionalDependencies",
331            )
332        },
333    );
334
335    push_sarif_results(&mut sarif_results, &results.type_only_dependencies, |dep| {
336        SarifFields {
337            rule_id: "fallow/type-only-dependency",
338            level: severity_to_sarif_level(rules.type_only_dependencies),
339            message: format!(
340                "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
341                dep.package_name
342            ),
343            uri: relative_uri(&dep.path, root),
344            region: if dep.line > 0 {
345                Some((dep.line, 1))
346            } else {
347                None
348            },
349            properties: None,
350        }
351    });
352
353    push_sarif_results(&mut sarif_results, &results.test_only_dependencies, |dep| {
354        SarifFields {
355            rule_id: "fallow/test-only-dependency",
356            level: severity_to_sarif_level(rules.test_only_dependencies),
357            message: format!(
358                "Package '{}' is only imported by test files (consider moving to devDependencies)",
359                dep.package_name
360            ),
361            uri: relative_uri(&dep.path, root),
362            region: if dep.line > 0 {
363                Some((dep.line, 1))
364            } else {
365                None
366            },
367            properties: None,
368        }
369    });
370
371    push_sarif_results(&mut sarif_results, &results.unused_enum_members, |member| {
372        sarif_member_fields(
373            member,
374            root,
375            "fallow/unused-enum-member",
376            severity_to_sarif_level(rules.unused_enum_members),
377            "Enum",
378        )
379    });
380
381    push_sarif_results(
382        &mut sarif_results,
383        &results.unused_class_members,
384        |member| {
385            sarif_member_fields(
386                member,
387                root,
388                "fallow/unused-class-member",
389                severity_to_sarif_level(rules.unused_class_members),
390                "Class",
391            )
392        },
393    );
394
395    push_sarif_results(&mut sarif_results, &results.unresolved_imports, |import| {
396        SarifFields {
397            rule_id: "fallow/unresolved-import",
398            level: severity_to_sarif_level(rules.unresolved_imports),
399            message: format!("Import '{}' could not be resolved", import.specifier),
400            uri: relative_uri(&import.path, root),
401            region: Some((import.line, import.col + 1)),
402            properties: None,
403        }
404    });
405
406    // Unlisted deps: one result per importing file (SARIF points to the import site)
407    for dep in &results.unlisted_dependencies {
408        for site in &dep.imported_from {
409            sarif_results.push(sarif_result(
410                "fallow/unlisted-dependency",
411                severity_to_sarif_level(rules.unlisted_dependencies),
412                &format!(
413                    "Package '{}' is imported but not listed in package.json",
414                    dep.package_name
415                ),
416                &relative_uri(&site.path, root),
417                Some((site.line, site.col + 1)),
418            ));
419        }
420    }
421
422    // Duplicate exports: one result per location (SARIF 2.1.0 section 3.27.12)
423    for dup in &results.duplicate_exports {
424        for loc in &dup.locations {
425            sarif_results.push(sarif_result(
426                "fallow/duplicate-export",
427                severity_to_sarif_level(rules.duplicate_exports),
428                &format!("Export '{}' appears in multiple modules", dup.export_name),
429                &relative_uri(&loc.path, root),
430                Some((loc.line, loc.col + 1)),
431            ));
432        }
433    }
434
435    push_sarif_results(
436        &mut sarif_results,
437        &results.circular_dependencies,
438        |cycle| {
439            let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
440            let mut display_chain = chain.clone();
441            if let Some(first) = chain.first() {
442                display_chain.push(first.clone());
443            }
444            let first_uri = chain.first().map_or_else(String::new, Clone::clone);
445            SarifFields {
446                rule_id: "fallow/circular-dependency",
447                level: severity_to_sarif_level(rules.circular_dependencies),
448                message: format!(
449                    "Circular dependency{}: {}",
450                    if cycle.is_cross_package {
451                        " (cross-package)"
452                    } else {
453                        ""
454                    },
455                    display_chain.join(" \u{2192} ")
456                ),
457                uri: first_uri,
458                region: if cycle.line > 0 {
459                    Some((cycle.line, cycle.col + 1))
460                } else {
461                    None
462                },
463                properties: None,
464            }
465        },
466    );
467
468    push_sarif_results(
469        &mut sarif_results,
470        &results.boundary_violations,
471        |violation| {
472            let from_uri = relative_uri(&violation.from_path, root);
473            let to_uri = relative_uri(&violation.to_path, root);
474            SarifFields {
475                rule_id: "fallow/boundary-violation",
476                level: severity_to_sarif_level(rules.boundary_violation),
477                message: format!(
478                    "Import from zone '{}' to zone '{}' is not allowed ({})",
479                    violation.from_zone, violation.to_zone, to_uri,
480                ),
481                uri: from_uri,
482                region: if violation.line > 0 {
483                    Some((violation.line, violation.col + 1))
484                } else {
485                    None
486                },
487                properties: None,
488            }
489        },
490    );
491
492    serde_json::json!({
493        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
494        "version": "2.1.0",
495        "runs": [{
496            "tool": {
497                "driver": {
498                    "name": "fallow",
499                    "version": env!("CARGO_PKG_VERSION"),
500                    "informationUri": "https://github.com/fallow-rs/fallow",
501                    "rules": build_sarif_rules(rules)
502                }
503            },
504            "results": sarif_results
505        }]
506    })
507}
508
509pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
510    let sarif = build_sarif(results, root, rules);
511    emit_json(&sarif, "SARIF")
512}
513
514/// Print SARIF output with owner properties added to each result.
515///
516/// Calls `build_sarif` to produce the standard SARIF JSON, then post-processes
517/// each result to add `"properties": { "owner": "@team" }` by resolving the
518/// artifact location URI through the `OwnershipResolver`.
519pub(super) fn print_grouped_sarif(
520    results: &AnalysisResults,
521    root: &Path,
522    rules: &RulesConfig,
523    resolver: &OwnershipResolver,
524) {
525    let mut sarif = build_sarif(results, root, rules);
526
527    // Post-process each result to inject the owner property.
528    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
529        for run in runs {
530            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
531                for result in results {
532                    let uri = result
533                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
534                        .and_then(|v| v.as_str())
535                        .unwrap_or("");
536                    // Decode percent-encoded brackets before ownership lookup
537                    // (SARIF URIs encode `[`/`]` as `%5B`/`%5D`)
538                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
539                    let owner =
540                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
541                    let props = result
542                        .as_object_mut()
543                        .expect("SARIF result should be an object")
544                        .entry("properties")
545                        .or_insert_with(|| serde_json::json!({}));
546                    props
547                        .as_object_mut()
548                        .expect("properties should be an object")
549                        .insert("owner".to_string(), serde_json::Value::String(owner));
550                }
551            }
552        }
553    }
554
555    let _ = emit_json(&sarif, "SARIF");
556}
557
558#[expect(
559    clippy::cast_possible_truncation,
560    reason = "line/col numbers are bounded by source size"
561)]
562pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
563    let mut sarif_results = Vec::new();
564
565    for (i, group) in report.clone_groups.iter().enumerate() {
566        for instance in &group.instances {
567            sarif_results.push(sarif_result(
568                "fallow/code-duplication",
569                "warning",
570                &format!(
571                    "Code clone group {} ({} lines, {} instances)",
572                    i + 1,
573                    group.line_count,
574                    group.instances.len()
575                ),
576                &relative_uri(&instance.file, root),
577                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
578            ));
579        }
580    }
581
582    let sarif = serde_json::json!({
583        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
584        "version": "2.1.0",
585        "runs": [{
586            "tool": {
587                "driver": {
588                    "name": "fallow",
589                    "version": env!("CARGO_PKG_VERSION"),
590                    "informationUri": "https://github.com/fallow-rs/fallow",
591                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
592                }
593            },
594            "results": sarif_results
595        }]
596    });
597
598    emit_json(&sarif, "SARIF")
599}
600
601// ── Health SARIF output ────────────────────────────────────────────
602// Note: file_scores are intentionally omitted from SARIF output.
603// SARIF is designed for diagnostic results (issues/findings), not metric tables.
604// File health scores are available in JSON, human, compact, and markdown formats.
605
606#[must_use]
607pub fn build_health_sarif(
608    report: &crate::health_types::HealthReport,
609    root: &Path,
610) -> serde_json::Value {
611    use crate::health_types::ExceededThreshold;
612
613    let mut sarif_results = Vec::new();
614
615    for finding in &report.findings {
616        let uri = relative_uri(&finding.path, root);
617        let (rule_id, message) = match finding.exceeded {
618            ExceededThreshold::Cyclomatic => (
619                "fallow/high-cyclomatic-complexity",
620                format!(
621                    "'{}' has cyclomatic complexity {} (threshold: {})",
622                    finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
623                ),
624            ),
625            ExceededThreshold::Cognitive => (
626                "fallow/high-cognitive-complexity",
627                format!(
628                    "'{}' has cognitive complexity {} (threshold: {})",
629                    finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
630                ),
631            ),
632            ExceededThreshold::Both => (
633                "fallow/high-complexity",
634                format!(
635                    "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
636                    finding.name,
637                    finding.cyclomatic,
638                    report.summary.max_cyclomatic_threshold,
639                    finding.cognitive,
640                    report.summary.max_cognitive_threshold,
641                ),
642            ),
643        };
644
645        sarif_results.push(sarif_result(
646            rule_id,
647            "warning",
648            &message,
649            &uri,
650            Some((finding.line, finding.col + 1)),
651        ));
652    }
653
654    // Refactoring targets as SARIF results (warning level — advisory recommendations)
655    for target in &report.targets {
656        let uri = relative_uri(&target.path, root);
657        let message = format!(
658            "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
659            target.category.label(),
660            target.recommendation,
661            target.priority,
662            target.efficiency,
663            target.effort.label(),
664            target.confidence.label(),
665        );
666        sarif_results.push(sarif_result(
667            "fallow/refactoring-target",
668            "warning",
669            &message,
670            &uri,
671            None,
672        ));
673    }
674
675    let health_rules = vec![
676        sarif_rule(
677            "fallow/high-cyclomatic-complexity",
678            "Function has high cyclomatic complexity",
679            "warning",
680        ),
681        sarif_rule(
682            "fallow/high-cognitive-complexity",
683            "Function has high cognitive complexity",
684            "warning",
685        ),
686        sarif_rule(
687            "fallow/high-complexity",
688            "Function exceeds both complexity thresholds",
689            "warning",
690        ),
691        sarif_rule(
692            "fallow/refactoring-target",
693            "File identified as a high-priority refactoring candidate",
694            "warning",
695        ),
696    ];
697
698    serde_json::json!({
699        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
700        "version": "2.1.0",
701        "runs": [{
702            "tool": {
703                "driver": {
704                    "name": "fallow",
705                    "version": env!("CARGO_PKG_VERSION"),
706                    "informationUri": "https://github.com/fallow-rs/fallow",
707                    "rules": health_rules
708                }
709            },
710            "results": sarif_results
711        }]
712    })
713}
714
715pub(super) fn print_health_sarif(
716    report: &crate::health_types::HealthReport,
717    root: &Path,
718) -> ExitCode {
719    let sarif = build_health_sarif(report, root);
720    emit_json(&sarif, "SARIF")
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726    use crate::report::test_helpers::sample_results;
727    use fallow_core::results::*;
728    use std::path::PathBuf;
729
730    #[test]
731    fn sarif_has_required_top_level_fields() {
732        let root = PathBuf::from("/project");
733        let results = AnalysisResults::default();
734        let sarif = build_sarif(&results, &root, &RulesConfig::default());
735
736        assert_eq!(
737            sarif["$schema"],
738            "https://json.schemastore.org/sarif-2.1.0.json"
739        );
740        assert_eq!(sarif["version"], "2.1.0");
741        assert!(sarif["runs"].is_array());
742    }
743
744    #[test]
745    fn sarif_has_tool_driver_info() {
746        let root = PathBuf::from("/project");
747        let results = AnalysisResults::default();
748        let sarif = build_sarif(&results, &root, &RulesConfig::default());
749
750        let driver = &sarif["runs"][0]["tool"]["driver"];
751        assert_eq!(driver["name"], "fallow");
752        assert!(driver["version"].is_string());
753        assert_eq!(
754            driver["informationUri"],
755            "https://github.com/fallow-rs/fallow"
756        );
757    }
758
759    #[test]
760    fn sarif_declares_all_rules() {
761        let root = PathBuf::from("/project");
762        let results = AnalysisResults::default();
763        let sarif = build_sarif(&results, &root, &RulesConfig::default());
764
765        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
766            .as_array()
767            .expect("rules should be an array");
768        assert_eq!(rules.len(), 15);
769
770        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
771        assert!(rule_ids.contains(&"fallow/unused-file"));
772        assert!(rule_ids.contains(&"fallow/unused-export"));
773        assert!(rule_ids.contains(&"fallow/unused-type"));
774        assert!(rule_ids.contains(&"fallow/unused-dependency"));
775        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
776        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
777        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
778        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
779        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
780        assert!(rule_ids.contains(&"fallow/unused-class-member"));
781        assert!(rule_ids.contains(&"fallow/unresolved-import"));
782        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
783        assert!(rule_ids.contains(&"fallow/duplicate-export"));
784        assert!(rule_ids.contains(&"fallow/circular-dependency"));
785        assert!(rule_ids.contains(&"fallow/boundary-violation"));
786    }
787
788    #[test]
789    fn sarif_empty_results_no_results_entries() {
790        let root = PathBuf::from("/project");
791        let results = AnalysisResults::default();
792        let sarif = build_sarif(&results, &root, &RulesConfig::default());
793
794        let sarif_results = sarif["runs"][0]["results"]
795            .as_array()
796            .expect("results should be an array");
797        assert!(sarif_results.is_empty());
798    }
799
800    #[test]
801    fn sarif_unused_file_result() {
802        let root = PathBuf::from("/project");
803        let mut results = AnalysisResults::default();
804        results.unused_files.push(UnusedFile {
805            path: root.join("src/dead.ts"),
806        });
807
808        let sarif = build_sarif(&results, &root, &RulesConfig::default());
809        let entries = sarif["runs"][0]["results"].as_array().unwrap();
810        assert_eq!(entries.len(), 1);
811
812        let entry = &entries[0];
813        assert_eq!(entry["ruleId"], "fallow/unused-file");
814        // Default severity is "error" per RulesConfig::default()
815        assert_eq!(entry["level"], "error");
816        assert_eq!(
817            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
818            "src/dead.ts"
819        );
820    }
821
822    #[test]
823    fn sarif_unused_export_includes_region() {
824        let root = PathBuf::from("/project");
825        let mut results = AnalysisResults::default();
826        results.unused_exports.push(UnusedExport {
827            path: root.join("src/utils.ts"),
828            export_name: "helperFn".to_string(),
829            is_type_only: false,
830            line: 10,
831            col: 4,
832            span_start: 120,
833            is_re_export: false,
834        });
835
836        let sarif = build_sarif(&results, &root, &RulesConfig::default());
837        let entry = &sarif["runs"][0]["results"][0];
838        assert_eq!(entry["ruleId"], "fallow/unused-export");
839
840        let region = &entry["locations"][0]["physicalLocation"]["region"];
841        assert_eq!(region["startLine"], 10);
842        // SARIF columns are 1-based, code adds +1 to the 0-based col
843        assert_eq!(region["startColumn"], 5);
844    }
845
846    #[test]
847    fn sarif_unresolved_import_is_error_level() {
848        let root = PathBuf::from("/project");
849        let mut results = AnalysisResults::default();
850        results.unresolved_imports.push(UnresolvedImport {
851            path: root.join("src/app.ts"),
852            specifier: "./missing".to_string(),
853            line: 1,
854            col: 0,
855            specifier_col: 0,
856        });
857
858        let sarif = build_sarif(&results, &root, &RulesConfig::default());
859        let entry = &sarif["runs"][0]["results"][0];
860        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
861        assert_eq!(entry["level"], "error");
862    }
863
864    #[test]
865    fn sarif_unlisted_dependency_points_to_import_site() {
866        let root = PathBuf::from("/project");
867        let mut results = AnalysisResults::default();
868        results.unlisted_dependencies.push(UnlistedDependency {
869            package_name: "chalk".to_string(),
870            imported_from: vec![ImportSite {
871                path: root.join("src/cli.ts"),
872                line: 3,
873                col: 0,
874            }],
875        });
876
877        let sarif = build_sarif(&results, &root, &RulesConfig::default());
878        let entry = &sarif["runs"][0]["results"][0];
879        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
880        assert_eq!(entry["level"], "error");
881        assert_eq!(
882            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
883            "src/cli.ts"
884        );
885        let region = &entry["locations"][0]["physicalLocation"]["region"];
886        assert_eq!(region["startLine"], 3);
887        assert_eq!(region["startColumn"], 1);
888    }
889
890    #[test]
891    fn sarif_dependency_issues_point_to_package_json() {
892        let root = PathBuf::from("/project");
893        let mut results = AnalysisResults::default();
894        results.unused_dependencies.push(UnusedDependency {
895            package_name: "lodash".to_string(),
896            location: DependencyLocation::Dependencies,
897            path: root.join("package.json"),
898            line: 5,
899        });
900        results.unused_dev_dependencies.push(UnusedDependency {
901            package_name: "jest".to_string(),
902            location: DependencyLocation::DevDependencies,
903            path: root.join("package.json"),
904            line: 5,
905        });
906
907        let sarif = build_sarif(&results, &root, &RulesConfig::default());
908        let entries = sarif["runs"][0]["results"].as_array().unwrap();
909        for entry in entries {
910            assert_eq!(
911                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
912                "package.json"
913            );
914        }
915    }
916
917    #[test]
918    fn sarif_duplicate_export_emits_one_result_per_location() {
919        let root = PathBuf::from("/project");
920        let mut results = AnalysisResults::default();
921        results.duplicate_exports.push(DuplicateExport {
922            export_name: "Config".to_string(),
923            locations: vec![
924                DuplicateLocation {
925                    path: root.join("src/a.ts"),
926                    line: 15,
927                    col: 0,
928                },
929                DuplicateLocation {
930                    path: root.join("src/b.ts"),
931                    line: 30,
932                    col: 0,
933                },
934            ],
935        });
936
937        let sarif = build_sarif(&results, &root, &RulesConfig::default());
938        let entries = sarif["runs"][0]["results"].as_array().unwrap();
939        // One SARIF result per location, not one per DuplicateExport
940        assert_eq!(entries.len(), 2);
941        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
942        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
943        assert_eq!(
944            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
945            "src/a.ts"
946        );
947        assert_eq!(
948            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
949            "src/b.ts"
950        );
951    }
952
953    #[test]
954    fn sarif_all_issue_types_produce_results() {
955        let root = PathBuf::from("/project");
956        let results = sample_results(&root);
957        let sarif = build_sarif(&results, &root, &RulesConfig::default());
958
959        let entries = sarif["runs"][0]["results"].as_array().unwrap();
960        // All issue types with one entry each; duplicate_exports has 2 locations => one extra SARIF result
961        assert_eq!(entries.len(), results.total_issues() + 1);
962
963        let rule_ids: Vec<&str> = entries
964            .iter()
965            .map(|e| e["ruleId"].as_str().unwrap())
966            .collect();
967        assert!(rule_ids.contains(&"fallow/unused-file"));
968        assert!(rule_ids.contains(&"fallow/unused-export"));
969        assert!(rule_ids.contains(&"fallow/unused-type"));
970        assert!(rule_ids.contains(&"fallow/unused-dependency"));
971        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
972        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
973        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
974        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
975        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
976        assert!(rule_ids.contains(&"fallow/unused-class-member"));
977        assert!(rule_ids.contains(&"fallow/unresolved-import"));
978        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
979        assert!(rule_ids.contains(&"fallow/duplicate-export"));
980    }
981
982    #[test]
983    fn sarif_serializes_to_valid_json() {
984        let root = PathBuf::from("/project");
985        let results = sample_results(&root);
986        let sarif = build_sarif(&results, &root, &RulesConfig::default());
987
988        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
989        let reparsed: serde_json::Value =
990            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
991        assert_eq!(reparsed, sarif);
992    }
993
994    #[test]
995    fn sarif_file_write_produces_valid_sarif() {
996        let root = PathBuf::from("/project");
997        let results = sample_results(&root);
998        let sarif = build_sarif(&results, &root, &RulesConfig::default());
999        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1000
1001        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1002        let _ = std::fs::create_dir_all(&dir);
1003        let sarif_path = dir.join("results.sarif");
1004        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1005
1006        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1007        let parsed: serde_json::Value =
1008            serde_json::from_str(&contents).expect("file should contain valid JSON");
1009
1010        assert_eq!(parsed["version"], "2.1.0");
1011        assert_eq!(
1012            parsed["$schema"],
1013            "https://json.schemastore.org/sarif-2.1.0.json"
1014        );
1015        let sarif_results = parsed["runs"][0]["results"]
1016            .as_array()
1017            .expect("results should be an array");
1018        assert!(!sarif_results.is_empty());
1019
1020        // Clean up
1021        let _ = std::fs::remove_file(&sarif_path);
1022        let _ = std::fs::remove_dir(&dir);
1023    }
1024
1025    // ── Health SARIF ──
1026
1027    #[test]
1028    fn health_sarif_empty_no_results() {
1029        let root = PathBuf::from("/project");
1030        let report = crate::health_types::HealthReport {
1031            findings: vec![],
1032            summary: crate::health_types::HealthSummary {
1033                files_analyzed: 10,
1034                functions_analyzed: 50,
1035                functions_above_threshold: 0,
1036                max_cyclomatic_threshold: 20,
1037                max_cognitive_threshold: 15,
1038                files_scored: None,
1039                average_maintainability: None,
1040            },
1041            vital_signs: None,
1042            health_score: None,
1043            file_scores: vec![],
1044            hotspots: vec![],
1045            hotspot_summary: None,
1046            targets: vec![],
1047            target_thresholds: None,
1048            health_trend: None,
1049        };
1050        let sarif = build_health_sarif(&report, &root);
1051        assert_eq!(sarif["version"], "2.1.0");
1052        let results = sarif["runs"][0]["results"].as_array().unwrap();
1053        assert!(results.is_empty());
1054        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1055            .as_array()
1056            .unwrap();
1057        assert_eq!(rules.len(), 4);
1058    }
1059
1060    #[test]
1061    fn health_sarif_cyclomatic_only() {
1062        let root = PathBuf::from("/project");
1063        let report = crate::health_types::HealthReport {
1064            findings: vec![crate::health_types::HealthFinding {
1065                path: root.join("src/utils.ts"),
1066                name: "parseExpression".to_string(),
1067                line: 42,
1068                col: 0,
1069                cyclomatic: 25,
1070                cognitive: 10,
1071                line_count: 80,
1072                exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1073            }],
1074            summary: crate::health_types::HealthSummary {
1075                files_analyzed: 5,
1076                functions_analyzed: 20,
1077                functions_above_threshold: 1,
1078                max_cyclomatic_threshold: 20,
1079                max_cognitive_threshold: 15,
1080                files_scored: None,
1081                average_maintainability: None,
1082            },
1083            vital_signs: None,
1084            health_score: None,
1085            file_scores: vec![],
1086            hotspots: vec![],
1087            hotspot_summary: None,
1088            targets: vec![],
1089            target_thresholds: None,
1090            health_trend: None,
1091        };
1092        let sarif = build_health_sarif(&report, &root);
1093        let entry = &sarif["runs"][0]["results"][0];
1094        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1095        assert_eq!(entry["level"], "warning");
1096        assert!(
1097            entry["message"]["text"]
1098                .as_str()
1099                .unwrap()
1100                .contains("cyclomatic complexity 25")
1101        );
1102        assert_eq!(
1103            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1104            "src/utils.ts"
1105        );
1106        let region = &entry["locations"][0]["physicalLocation"]["region"];
1107        assert_eq!(region["startLine"], 42);
1108        assert_eq!(region["startColumn"], 1);
1109    }
1110
1111    #[test]
1112    fn health_sarif_cognitive_only() {
1113        let root = PathBuf::from("/project");
1114        let report = crate::health_types::HealthReport {
1115            findings: vec![crate::health_types::HealthFinding {
1116                path: root.join("src/api.ts"),
1117                name: "handleRequest".to_string(),
1118                line: 10,
1119                col: 4,
1120                cyclomatic: 8,
1121                cognitive: 20,
1122                line_count: 40,
1123                exceeded: crate::health_types::ExceededThreshold::Cognitive,
1124            }],
1125            summary: crate::health_types::HealthSummary {
1126                files_analyzed: 3,
1127                functions_analyzed: 10,
1128                functions_above_threshold: 1,
1129                max_cyclomatic_threshold: 20,
1130                max_cognitive_threshold: 15,
1131                files_scored: None,
1132                average_maintainability: None,
1133            },
1134            vital_signs: None,
1135            health_score: None,
1136            file_scores: vec![],
1137            hotspots: vec![],
1138            hotspot_summary: None,
1139            targets: vec![],
1140            target_thresholds: None,
1141            health_trend: None,
1142        };
1143        let sarif = build_health_sarif(&report, &root);
1144        let entry = &sarif["runs"][0]["results"][0];
1145        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1146        assert!(
1147            entry["message"]["text"]
1148                .as_str()
1149                .unwrap()
1150                .contains("cognitive complexity 20")
1151        );
1152        let region = &entry["locations"][0]["physicalLocation"]["region"];
1153        assert_eq!(region["startColumn"], 5); // col 4 + 1
1154    }
1155
1156    #[test]
1157    fn health_sarif_both_thresholds() {
1158        let root = PathBuf::from("/project");
1159        let report = crate::health_types::HealthReport {
1160            findings: vec![crate::health_types::HealthFinding {
1161                path: root.join("src/complex.ts"),
1162                name: "doEverything".to_string(),
1163                line: 1,
1164                col: 0,
1165                cyclomatic: 30,
1166                cognitive: 45,
1167                line_count: 100,
1168                exceeded: crate::health_types::ExceededThreshold::Both,
1169            }],
1170            summary: crate::health_types::HealthSummary {
1171                files_analyzed: 1,
1172                functions_analyzed: 1,
1173                functions_above_threshold: 1,
1174                max_cyclomatic_threshold: 20,
1175                max_cognitive_threshold: 15,
1176                files_scored: None,
1177                average_maintainability: None,
1178            },
1179            vital_signs: None,
1180            health_score: None,
1181            file_scores: vec![],
1182            hotspots: vec![],
1183            hotspot_summary: None,
1184            targets: vec![],
1185            target_thresholds: None,
1186            health_trend: None,
1187        };
1188        let sarif = build_health_sarif(&report, &root);
1189        let entry = &sarif["runs"][0]["results"][0];
1190        assert_eq!(entry["ruleId"], "fallow/high-complexity");
1191        let msg = entry["message"]["text"].as_str().unwrap();
1192        assert!(msg.contains("cyclomatic complexity 30"));
1193        assert!(msg.contains("cognitive complexity 45"));
1194    }
1195
1196    // ── Severity mapping ──
1197
1198    #[test]
1199    fn severity_to_sarif_level_error() {
1200        assert_eq!(severity_to_sarif_level(Severity::Error), "error");
1201    }
1202
1203    #[test]
1204    fn severity_to_sarif_level_warn() {
1205        assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
1206    }
1207
1208    #[test]
1209    fn severity_to_sarif_level_off() {
1210        assert_eq!(severity_to_sarif_level(Severity::Off), "warning");
1211    }
1212
1213    // ── Re-export properties ──
1214
1215    #[test]
1216    fn sarif_re_export_has_properties() {
1217        let root = PathBuf::from("/project");
1218        let mut results = AnalysisResults::default();
1219        results.unused_exports.push(UnusedExport {
1220            path: root.join("src/index.ts"),
1221            export_name: "reExported".to_string(),
1222            is_type_only: false,
1223            line: 1,
1224            col: 0,
1225            span_start: 0,
1226            is_re_export: true,
1227        });
1228
1229        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1230        let entry = &sarif["runs"][0]["results"][0];
1231        assert_eq!(entry["properties"]["is_re_export"], true);
1232        let msg = entry["message"]["text"].as_str().unwrap();
1233        assert!(msg.starts_with("Re-export"));
1234    }
1235
1236    #[test]
1237    fn sarif_non_re_export_has_no_properties() {
1238        let root = PathBuf::from("/project");
1239        let mut results = AnalysisResults::default();
1240        results.unused_exports.push(UnusedExport {
1241            path: root.join("src/utils.ts"),
1242            export_name: "foo".to_string(),
1243            is_type_only: false,
1244            line: 5,
1245            col: 0,
1246            span_start: 0,
1247            is_re_export: false,
1248        });
1249
1250        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1251        let entry = &sarif["runs"][0]["results"][0];
1252        assert!(entry.get("properties").is_none());
1253        let msg = entry["message"]["text"].as_str().unwrap();
1254        assert!(msg.starts_with("Export"));
1255    }
1256
1257    // ── Type re-export ──
1258
1259    #[test]
1260    fn sarif_type_re_export_message() {
1261        let root = PathBuf::from("/project");
1262        let mut results = AnalysisResults::default();
1263        results.unused_types.push(UnusedExport {
1264            path: root.join("src/index.ts"),
1265            export_name: "MyType".to_string(),
1266            is_type_only: true,
1267            line: 1,
1268            col: 0,
1269            span_start: 0,
1270            is_re_export: true,
1271        });
1272
1273        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1274        let entry = &sarif["runs"][0]["results"][0];
1275        assert_eq!(entry["ruleId"], "fallow/unused-type");
1276        let msg = entry["message"]["text"].as_str().unwrap();
1277        assert!(msg.starts_with("Type re-export"));
1278        assert_eq!(entry["properties"]["is_re_export"], true);
1279    }
1280
1281    // ── Dependency line == 0 skips region ──
1282
1283    #[test]
1284    fn sarif_dependency_line_zero_skips_region() {
1285        let root = PathBuf::from("/project");
1286        let mut results = AnalysisResults::default();
1287        results.unused_dependencies.push(UnusedDependency {
1288            package_name: "lodash".to_string(),
1289            location: DependencyLocation::Dependencies,
1290            path: root.join("package.json"),
1291            line: 0,
1292        });
1293
1294        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1295        let entry = &sarif["runs"][0]["results"][0];
1296        let phys = &entry["locations"][0]["physicalLocation"];
1297        assert!(phys.get("region").is_none());
1298    }
1299
1300    #[test]
1301    fn sarif_dependency_line_nonzero_has_region() {
1302        let root = PathBuf::from("/project");
1303        let mut results = AnalysisResults::default();
1304        results.unused_dependencies.push(UnusedDependency {
1305            package_name: "lodash".to_string(),
1306            location: DependencyLocation::Dependencies,
1307            path: root.join("package.json"),
1308            line: 7,
1309        });
1310
1311        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1312        let entry = &sarif["runs"][0]["results"][0];
1313        let region = &entry["locations"][0]["physicalLocation"]["region"];
1314        assert_eq!(region["startLine"], 7);
1315        assert_eq!(region["startColumn"], 1);
1316    }
1317
1318    // ── Type-only dependency line == 0 skips region ──
1319
1320    #[test]
1321    fn sarif_type_only_dep_line_zero_skips_region() {
1322        let root = PathBuf::from("/project");
1323        let mut results = AnalysisResults::default();
1324        results.type_only_dependencies.push(TypeOnlyDependency {
1325            package_name: "zod".to_string(),
1326            path: root.join("package.json"),
1327            line: 0,
1328        });
1329
1330        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1331        let entry = &sarif["runs"][0]["results"][0];
1332        let phys = &entry["locations"][0]["physicalLocation"];
1333        assert!(phys.get("region").is_none());
1334    }
1335
1336    // ── Circular dependency line == 0 skips region ──
1337
1338    #[test]
1339    fn sarif_circular_dep_line_zero_skips_region() {
1340        let root = PathBuf::from("/project");
1341        let mut results = AnalysisResults::default();
1342        results.circular_dependencies.push(CircularDependency {
1343            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1344            length: 2,
1345            line: 0,
1346            col: 0,
1347            is_cross_package: false,
1348        });
1349
1350        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1351        let entry = &sarif["runs"][0]["results"][0];
1352        let phys = &entry["locations"][0]["physicalLocation"];
1353        assert!(phys.get("region").is_none());
1354    }
1355
1356    #[test]
1357    fn sarif_circular_dep_line_nonzero_has_region() {
1358        let root = PathBuf::from("/project");
1359        let mut results = AnalysisResults::default();
1360        results.circular_dependencies.push(CircularDependency {
1361            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1362            length: 2,
1363            line: 5,
1364            col: 2,
1365            is_cross_package: false,
1366        });
1367
1368        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1369        let entry = &sarif["runs"][0]["results"][0];
1370        let region = &entry["locations"][0]["physicalLocation"]["region"];
1371        assert_eq!(region["startLine"], 5);
1372        assert_eq!(region["startColumn"], 3);
1373    }
1374
1375    // ── Unused optional dependency ──
1376
1377    #[test]
1378    fn sarif_unused_optional_dependency_result() {
1379        let root = PathBuf::from("/project");
1380        let mut results = AnalysisResults::default();
1381        results.unused_optional_dependencies.push(UnusedDependency {
1382            package_name: "fsevents".to_string(),
1383            location: DependencyLocation::OptionalDependencies,
1384            path: root.join("package.json"),
1385            line: 12,
1386        });
1387
1388        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1389        let entry = &sarif["runs"][0]["results"][0];
1390        assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
1391        let msg = entry["message"]["text"].as_str().unwrap();
1392        assert!(msg.contains("optionalDependencies"));
1393    }
1394
1395    // ── Enum and class member SARIF messages ──
1396
1397    #[test]
1398    fn sarif_enum_member_message_format() {
1399        let root = PathBuf::from("/project");
1400        let mut results = AnalysisResults::default();
1401        results
1402            .unused_enum_members
1403            .push(fallow_core::results::UnusedMember {
1404                path: root.join("src/enums.ts"),
1405                parent_name: "Color".to_string(),
1406                member_name: "Purple".to_string(),
1407                kind: fallow_core::extract::MemberKind::EnumMember,
1408                line: 5,
1409                col: 2,
1410            });
1411
1412        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1413        let entry = &sarif["runs"][0]["results"][0];
1414        assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
1415        let msg = entry["message"]["text"].as_str().unwrap();
1416        assert!(msg.contains("Enum member 'Color.Purple'"));
1417        let region = &entry["locations"][0]["physicalLocation"]["region"];
1418        assert_eq!(region["startColumn"], 3); // col 2 + 1
1419    }
1420
1421    #[test]
1422    fn sarif_class_member_message_format() {
1423        let root = PathBuf::from("/project");
1424        let mut results = AnalysisResults::default();
1425        results
1426            .unused_class_members
1427            .push(fallow_core::results::UnusedMember {
1428                path: root.join("src/service.ts"),
1429                parent_name: "API".to_string(),
1430                member_name: "fetch".to_string(),
1431                kind: fallow_core::extract::MemberKind::ClassMethod,
1432                line: 10,
1433                col: 4,
1434            });
1435
1436        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1437        let entry = &sarif["runs"][0]["results"][0];
1438        assert_eq!(entry["ruleId"], "fallow/unused-class-member");
1439        let msg = entry["message"]["text"].as_str().unwrap();
1440        assert!(msg.contains("Class member 'API.fetch'"));
1441    }
1442
1443    // ── Duplication SARIF ──
1444
1445    #[test]
1446    #[expect(
1447        clippy::cast_possible_truncation,
1448        reason = "test line/col values are trivially small"
1449    )]
1450    fn duplication_sarif_structure() {
1451        use fallow_core::duplicates::*;
1452
1453        let root = PathBuf::from("/project");
1454        let report = DuplicationReport {
1455            clone_groups: vec![CloneGroup {
1456                instances: vec![
1457                    CloneInstance {
1458                        file: root.join("src/a.ts"),
1459                        start_line: 1,
1460                        end_line: 10,
1461                        start_col: 0,
1462                        end_col: 0,
1463                        fragment: String::new(),
1464                    },
1465                    CloneInstance {
1466                        file: root.join("src/b.ts"),
1467                        start_line: 5,
1468                        end_line: 14,
1469                        start_col: 2,
1470                        end_col: 0,
1471                        fragment: String::new(),
1472                    },
1473                ],
1474                token_count: 50,
1475                line_count: 10,
1476            }],
1477            clone_families: vec![],
1478            mirrored_directories: vec![],
1479            stats: DuplicationStats::default(),
1480        };
1481
1482        let sarif = serde_json::json!({
1483            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1484            "version": "2.1.0",
1485            "runs": [{
1486                "tool": {
1487                    "driver": {
1488                        "name": "fallow",
1489                        "version": env!("CARGO_PKG_VERSION"),
1490                        "informationUri": "https://github.com/fallow-rs/fallow",
1491                        "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1492                    }
1493                },
1494                "results": []
1495            }]
1496        });
1497        // Just verify the function doesn't panic and produces expected structure
1498        let _ = sarif;
1499
1500        // Test the actual build path through print_duplication_sarif internals
1501        let mut sarif_results = Vec::new();
1502        for (i, group) in report.clone_groups.iter().enumerate() {
1503            for instance in &group.instances {
1504                sarif_results.push(sarif_result(
1505                    "fallow/code-duplication",
1506                    "warning",
1507                    &format!(
1508                        "Code clone group {} ({} lines, {} instances)",
1509                        i + 1,
1510                        group.line_count,
1511                        group.instances.len()
1512                    ),
1513                    &super::super::relative_uri(&instance.file, &root),
1514                    Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1515                ));
1516            }
1517        }
1518        assert_eq!(sarif_results.len(), 2);
1519        assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
1520        assert!(
1521            sarif_results[0]["message"]["text"]
1522                .as_str()
1523                .unwrap()
1524                .contains("10 lines")
1525        );
1526        let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
1527        assert_eq!(region0["startLine"], 1);
1528        assert_eq!(region0["startColumn"], 1); // start_col 0 + 1
1529        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
1530        assert_eq!(region1["startLine"], 5);
1531        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
1532    }
1533
1534    // ── sarif_rule fallback (unknown rule ID) ──
1535
1536    #[test]
1537    fn sarif_rule_known_id_has_full_description() {
1538        let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
1539        assert!(rule.get("fullDescription").is_some());
1540        assert!(rule.get("helpUri").is_some());
1541    }
1542
1543    #[test]
1544    fn sarif_rule_unknown_id_uses_fallback() {
1545        let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
1546        assert_eq!(rule["shortDescription"]["text"], "fallback text");
1547        assert!(rule.get("fullDescription").is_none());
1548        assert!(rule.get("helpUri").is_none());
1549        assert_eq!(rule["defaultConfiguration"]["level"], "warning");
1550    }
1551
1552    // ── sarif_result without region ──
1553
1554    #[test]
1555    fn sarif_result_no_region_omits_region_key() {
1556        let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
1557        let phys = &result["locations"][0]["physicalLocation"];
1558        assert!(phys.get("region").is_none());
1559        assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
1560    }
1561
1562    #[test]
1563    fn sarif_result_with_region_includes_region() {
1564        let result = sarif_result(
1565            "rule/test",
1566            "error",
1567            "test msg",
1568            "src/file.ts",
1569            Some((10, 5)),
1570        );
1571        let region = &result["locations"][0]["physicalLocation"]["region"];
1572        assert_eq!(region["startLine"], 10);
1573        assert_eq!(region["startColumn"], 5);
1574    }
1575
1576    // ── Health SARIF refactoring targets ──
1577
1578    #[test]
1579    fn health_sarif_includes_refactoring_targets() {
1580        use crate::health_types::*;
1581
1582        let root = PathBuf::from("/project");
1583        let report = HealthReport {
1584            findings: vec![],
1585            summary: HealthSummary {
1586                files_analyzed: 10,
1587                functions_analyzed: 50,
1588                functions_above_threshold: 0,
1589                max_cyclomatic_threshold: 20,
1590                max_cognitive_threshold: 15,
1591                files_scored: None,
1592                average_maintainability: None,
1593            },
1594            vital_signs: None,
1595            health_score: None,
1596            file_scores: vec![],
1597            hotspots: vec![],
1598            hotspot_summary: None,
1599            targets: vec![RefactoringTarget {
1600                path: root.join("src/complex.ts"),
1601                priority: 85.0,
1602                efficiency: 42.5,
1603                recommendation: "Split high-impact file".into(),
1604                category: RecommendationCategory::SplitHighImpact,
1605                effort: EffortEstimate::Medium,
1606                confidence: Confidence::High,
1607                factors: vec![],
1608                evidence: None,
1609            }],
1610            target_thresholds: None,
1611            health_trend: None,
1612        };
1613
1614        let sarif = build_health_sarif(&report, &root);
1615        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1616        assert_eq!(entries.len(), 1);
1617        assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
1618        assert_eq!(entries[0]["level"], "warning");
1619        let msg = entries[0]["message"]["text"].as_str().unwrap();
1620        assert!(msg.contains("high impact"));
1621        assert!(msg.contains("Split high-impact file"));
1622        assert!(msg.contains("42.5"));
1623    }
1624
1625    // ── Health SARIF rules include fullDescription from explain module ──
1626
1627    #[test]
1628    fn health_sarif_rules_have_full_descriptions() {
1629        let root = PathBuf::from("/project");
1630        let report = crate::health_types::HealthReport {
1631            findings: vec![],
1632            summary: crate::health_types::HealthSummary {
1633                files_analyzed: 0,
1634                functions_analyzed: 0,
1635                functions_above_threshold: 0,
1636                max_cyclomatic_threshold: 20,
1637                max_cognitive_threshold: 15,
1638                files_scored: None,
1639                average_maintainability: None,
1640            },
1641            vital_signs: None,
1642            health_score: None,
1643            file_scores: vec![],
1644            hotspots: vec![],
1645            hotspot_summary: None,
1646            targets: vec![],
1647            target_thresholds: None,
1648            health_trend: None,
1649        };
1650        let sarif = build_health_sarif(&report, &root);
1651        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1652            .as_array()
1653            .unwrap();
1654        for rule in rules {
1655            let id = rule["id"].as_str().unwrap();
1656            assert!(
1657                rule.get("fullDescription").is_some(),
1658                "health rule {id} should have fullDescription"
1659            );
1660            assert!(
1661                rule.get("helpUri").is_some(),
1662                "health rule {id} should have helpUri"
1663            );
1664        }
1665    }
1666
1667    // ── Warn severity propagates correctly ──
1668
1669    #[test]
1670    fn sarif_warn_severity_produces_warning_level() {
1671        let root = PathBuf::from("/project");
1672        let mut results = AnalysisResults::default();
1673        results.unused_files.push(UnusedFile {
1674            path: root.join("src/dead.ts"),
1675        });
1676
1677        let rules = RulesConfig {
1678            unused_files: Severity::Warn,
1679            ..RulesConfig::default()
1680        };
1681
1682        let sarif = build_sarif(&results, &root, &rules);
1683        let entry = &sarif["runs"][0]["results"][0];
1684        assert_eq!(entry["level"], "warning");
1685    }
1686
1687    // ── Unused file has no region ──
1688
1689    #[test]
1690    fn sarif_unused_file_has_no_region() {
1691        let root = PathBuf::from("/project");
1692        let mut results = AnalysisResults::default();
1693        results.unused_files.push(UnusedFile {
1694            path: root.join("src/dead.ts"),
1695        });
1696
1697        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1698        let entry = &sarif["runs"][0]["results"][0];
1699        let phys = &entry["locations"][0]["physicalLocation"];
1700        assert!(phys.get("region").is_none());
1701    }
1702
1703    // ── Multiple unlisted deps with multiple import sites ──
1704
1705    #[test]
1706    fn sarif_unlisted_dep_multiple_import_sites() {
1707        let root = PathBuf::from("/project");
1708        let mut results = AnalysisResults::default();
1709        results.unlisted_dependencies.push(UnlistedDependency {
1710            package_name: "dotenv".to_string(),
1711            imported_from: vec![
1712                ImportSite {
1713                    path: root.join("src/a.ts"),
1714                    line: 1,
1715                    col: 0,
1716                },
1717                ImportSite {
1718                    path: root.join("src/b.ts"),
1719                    line: 5,
1720                    col: 0,
1721                },
1722            ],
1723        });
1724
1725        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1726        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1727        // One SARIF result per import site
1728        assert_eq!(entries.len(), 2);
1729        assert_eq!(
1730            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1731            "src/a.ts"
1732        );
1733        assert_eq!(
1734            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1735            "src/b.ts"
1736        );
1737    }
1738
1739    // ── Empty unlisted dep (no import sites) produces zero results ──
1740
1741    #[test]
1742    fn sarif_unlisted_dep_no_import_sites() {
1743        let root = PathBuf::from("/project");
1744        let mut results = AnalysisResults::default();
1745        results.unlisted_dependencies.push(UnlistedDependency {
1746            package_name: "phantom".to_string(),
1747            imported_from: vec![],
1748        });
1749
1750        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1751        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1752        // No import sites => no SARIF results for this unlisted dep
1753        assert!(entries.is_empty());
1754    }
1755}