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