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) -> ExitCode {
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    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    if let Some(ref gaps) = report.coverage_gaps {
676        for item in &gaps.files {
677            let uri = relative_uri(&item.path, root);
678            let message = format!(
679                "File is runtime-reachable but has no test dependency path ({} value export{})",
680                item.value_export_count,
681                if item.value_export_count == 1 {
682                    ""
683                } else {
684                    "s"
685                },
686            );
687            sarif_results.push(sarif_result(
688                "fallow/untested-file",
689                "warning",
690                &message,
691                &uri,
692                None,
693            ));
694        }
695
696        for item in &gaps.exports {
697            let uri = relative_uri(&item.path, root);
698            let message = format!(
699                "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
700                item.export_name
701            );
702            sarif_results.push(sarif_result(
703                "fallow/untested-export",
704                "warning",
705                &message,
706                &uri,
707                Some((item.line, item.col + 1)),
708            ));
709        }
710    }
711
712    let health_rules = vec![
713        sarif_rule(
714            "fallow/high-cyclomatic-complexity",
715            "Function has high cyclomatic complexity",
716            "warning",
717        ),
718        sarif_rule(
719            "fallow/high-cognitive-complexity",
720            "Function has high cognitive complexity",
721            "warning",
722        ),
723        sarif_rule(
724            "fallow/high-complexity",
725            "Function exceeds both complexity thresholds",
726            "warning",
727        ),
728        sarif_rule(
729            "fallow/refactoring-target",
730            "File identified as a high-priority refactoring candidate",
731            "warning",
732        ),
733        sarif_rule(
734            "fallow/untested-file",
735            "Runtime-reachable file has no test dependency path",
736            "warning",
737        ),
738        sarif_rule(
739            "fallow/untested-export",
740            "Runtime-reachable export has no test dependency path",
741            "warning",
742        ),
743    ];
744
745    serde_json::json!({
746        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
747        "version": "2.1.0",
748        "runs": [{
749            "tool": {
750                "driver": {
751                    "name": "fallow",
752                    "version": env!("CARGO_PKG_VERSION"),
753                    "informationUri": "https://github.com/fallow-rs/fallow",
754                    "rules": health_rules
755                }
756            },
757            "results": sarif_results
758        }]
759    })
760}
761
762pub(super) fn print_health_sarif(
763    report: &crate::health_types::HealthReport,
764    root: &Path,
765) -> ExitCode {
766    let sarif = build_health_sarif(report, root);
767    emit_json(&sarif, "SARIF")
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use crate::report::test_helpers::sample_results;
774    use fallow_core::results::*;
775    use std::path::PathBuf;
776
777    #[test]
778    fn sarif_has_required_top_level_fields() {
779        let root = PathBuf::from("/project");
780        let results = AnalysisResults::default();
781        let sarif = build_sarif(&results, &root, &RulesConfig::default());
782
783        assert_eq!(
784            sarif["$schema"],
785            "https://json.schemastore.org/sarif-2.1.0.json"
786        );
787        assert_eq!(sarif["version"], "2.1.0");
788        assert!(sarif["runs"].is_array());
789    }
790
791    #[test]
792    fn sarif_has_tool_driver_info() {
793        let root = PathBuf::from("/project");
794        let results = AnalysisResults::default();
795        let sarif = build_sarif(&results, &root, &RulesConfig::default());
796
797        let driver = &sarif["runs"][0]["tool"]["driver"];
798        assert_eq!(driver["name"], "fallow");
799        assert!(driver["version"].is_string());
800        assert_eq!(
801            driver["informationUri"],
802            "https://github.com/fallow-rs/fallow"
803        );
804    }
805
806    #[test]
807    fn sarif_declares_all_rules() {
808        let root = PathBuf::from("/project");
809        let results = AnalysisResults::default();
810        let sarif = build_sarif(&results, &root, &RulesConfig::default());
811
812        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
813            .as_array()
814            .expect("rules should be an array");
815        assert_eq!(rules.len(), 15);
816
817        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
818        assert!(rule_ids.contains(&"fallow/unused-file"));
819        assert!(rule_ids.contains(&"fallow/unused-export"));
820        assert!(rule_ids.contains(&"fallow/unused-type"));
821        assert!(rule_ids.contains(&"fallow/unused-dependency"));
822        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
823        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
824        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
825        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
826        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
827        assert!(rule_ids.contains(&"fallow/unused-class-member"));
828        assert!(rule_ids.contains(&"fallow/unresolved-import"));
829        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
830        assert!(rule_ids.contains(&"fallow/duplicate-export"));
831        assert!(rule_ids.contains(&"fallow/circular-dependency"));
832        assert!(rule_ids.contains(&"fallow/boundary-violation"));
833    }
834
835    #[test]
836    fn sarif_empty_results_no_results_entries() {
837        let root = PathBuf::from("/project");
838        let results = AnalysisResults::default();
839        let sarif = build_sarif(&results, &root, &RulesConfig::default());
840
841        let sarif_results = sarif["runs"][0]["results"]
842            .as_array()
843            .expect("results should be an array");
844        assert!(sarif_results.is_empty());
845    }
846
847    #[test]
848    fn sarif_unused_file_result() {
849        let root = PathBuf::from("/project");
850        let mut results = AnalysisResults::default();
851        results.unused_files.push(UnusedFile {
852            path: root.join("src/dead.ts"),
853        });
854
855        let sarif = build_sarif(&results, &root, &RulesConfig::default());
856        let entries = sarif["runs"][0]["results"].as_array().unwrap();
857        assert_eq!(entries.len(), 1);
858
859        let entry = &entries[0];
860        assert_eq!(entry["ruleId"], "fallow/unused-file");
861        // Default severity is "error" per RulesConfig::default()
862        assert_eq!(entry["level"], "error");
863        assert_eq!(
864            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
865            "src/dead.ts"
866        );
867    }
868
869    #[test]
870    fn sarif_unused_export_includes_region() {
871        let root = PathBuf::from("/project");
872        let mut results = AnalysisResults::default();
873        results.unused_exports.push(UnusedExport {
874            path: root.join("src/utils.ts"),
875            export_name: "helperFn".to_string(),
876            is_type_only: false,
877            line: 10,
878            col: 4,
879            span_start: 120,
880            is_re_export: false,
881        });
882
883        let sarif = build_sarif(&results, &root, &RulesConfig::default());
884        let entry = &sarif["runs"][0]["results"][0];
885        assert_eq!(entry["ruleId"], "fallow/unused-export");
886
887        let region = &entry["locations"][0]["physicalLocation"]["region"];
888        assert_eq!(region["startLine"], 10);
889        // SARIF columns are 1-based, code adds +1 to the 0-based col
890        assert_eq!(region["startColumn"], 5);
891    }
892
893    #[test]
894    fn sarif_unresolved_import_is_error_level() {
895        let root = PathBuf::from("/project");
896        let mut results = AnalysisResults::default();
897        results.unresolved_imports.push(UnresolvedImport {
898            path: root.join("src/app.ts"),
899            specifier: "./missing".to_string(),
900            line: 1,
901            col: 0,
902            specifier_col: 0,
903        });
904
905        let sarif = build_sarif(&results, &root, &RulesConfig::default());
906        let entry = &sarif["runs"][0]["results"][0];
907        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
908        assert_eq!(entry["level"], "error");
909    }
910
911    #[test]
912    fn sarif_unlisted_dependency_points_to_import_site() {
913        let root = PathBuf::from("/project");
914        let mut results = AnalysisResults::default();
915        results.unlisted_dependencies.push(UnlistedDependency {
916            package_name: "chalk".to_string(),
917            imported_from: vec![ImportSite {
918                path: root.join("src/cli.ts"),
919                line: 3,
920                col: 0,
921            }],
922        });
923
924        let sarif = build_sarif(&results, &root, &RulesConfig::default());
925        let entry = &sarif["runs"][0]["results"][0];
926        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
927        assert_eq!(entry["level"], "error");
928        assert_eq!(
929            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
930            "src/cli.ts"
931        );
932        let region = &entry["locations"][0]["physicalLocation"]["region"];
933        assert_eq!(region["startLine"], 3);
934        assert_eq!(region["startColumn"], 1);
935    }
936
937    #[test]
938    fn sarif_dependency_issues_point_to_package_json() {
939        let root = PathBuf::from("/project");
940        let mut results = AnalysisResults::default();
941        results.unused_dependencies.push(UnusedDependency {
942            package_name: "lodash".to_string(),
943            location: DependencyLocation::Dependencies,
944            path: root.join("package.json"),
945            line: 5,
946        });
947        results.unused_dev_dependencies.push(UnusedDependency {
948            package_name: "jest".to_string(),
949            location: DependencyLocation::DevDependencies,
950            path: root.join("package.json"),
951            line: 5,
952        });
953
954        let sarif = build_sarif(&results, &root, &RulesConfig::default());
955        let entries = sarif["runs"][0]["results"].as_array().unwrap();
956        for entry in entries {
957            assert_eq!(
958                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
959                "package.json"
960            );
961        }
962    }
963
964    #[test]
965    fn sarif_duplicate_export_emits_one_result_per_location() {
966        let root = PathBuf::from("/project");
967        let mut results = AnalysisResults::default();
968        results.duplicate_exports.push(DuplicateExport {
969            export_name: "Config".to_string(),
970            locations: vec![
971                DuplicateLocation {
972                    path: root.join("src/a.ts"),
973                    line: 15,
974                    col: 0,
975                },
976                DuplicateLocation {
977                    path: root.join("src/b.ts"),
978                    line: 30,
979                    col: 0,
980                },
981            ],
982        });
983
984        let sarif = build_sarif(&results, &root, &RulesConfig::default());
985        let entries = sarif["runs"][0]["results"].as_array().unwrap();
986        // One SARIF result per location, not one per DuplicateExport
987        assert_eq!(entries.len(), 2);
988        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
989        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
990        assert_eq!(
991            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
992            "src/a.ts"
993        );
994        assert_eq!(
995            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
996            "src/b.ts"
997        );
998    }
999
1000    #[test]
1001    fn sarif_all_issue_types_produce_results() {
1002        let root = PathBuf::from("/project");
1003        let results = sample_results(&root);
1004        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1005
1006        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1007        // All issue types with one entry each; duplicate_exports has 2 locations => one extra SARIF result
1008        assert_eq!(entries.len(), results.total_issues() + 1);
1009
1010        let rule_ids: Vec<&str> = entries
1011            .iter()
1012            .map(|e| e["ruleId"].as_str().unwrap())
1013            .collect();
1014        assert!(rule_ids.contains(&"fallow/unused-file"));
1015        assert!(rule_ids.contains(&"fallow/unused-export"));
1016        assert!(rule_ids.contains(&"fallow/unused-type"));
1017        assert!(rule_ids.contains(&"fallow/unused-dependency"));
1018        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1019        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1020        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1021        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1022        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1023        assert!(rule_ids.contains(&"fallow/unused-class-member"));
1024        assert!(rule_ids.contains(&"fallow/unresolved-import"));
1025        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1026        assert!(rule_ids.contains(&"fallow/duplicate-export"));
1027    }
1028
1029    #[test]
1030    fn sarif_serializes_to_valid_json() {
1031        let root = PathBuf::from("/project");
1032        let results = sample_results(&root);
1033        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1034
1035        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1036        let reparsed: serde_json::Value =
1037            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1038        assert_eq!(reparsed, sarif);
1039    }
1040
1041    #[test]
1042    fn sarif_file_write_produces_valid_sarif() {
1043        let root = PathBuf::from("/project");
1044        let results = sample_results(&root);
1045        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1046        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1047
1048        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1049        let _ = std::fs::create_dir_all(&dir);
1050        let sarif_path = dir.join("results.sarif");
1051        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1052
1053        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1054        let parsed: serde_json::Value =
1055            serde_json::from_str(&contents).expect("file should contain valid JSON");
1056
1057        assert_eq!(parsed["version"], "2.1.0");
1058        assert_eq!(
1059            parsed["$schema"],
1060            "https://json.schemastore.org/sarif-2.1.0.json"
1061        );
1062        let sarif_results = parsed["runs"][0]["results"]
1063            .as_array()
1064            .expect("results should be an array");
1065        assert!(!sarif_results.is_empty());
1066
1067        // Clean up
1068        let _ = std::fs::remove_file(&sarif_path);
1069        let _ = std::fs::remove_dir(&dir);
1070    }
1071
1072    // ── Health SARIF ──
1073
1074    #[test]
1075    fn health_sarif_empty_no_results() {
1076        let root = PathBuf::from("/project");
1077        let report = crate::health_types::HealthReport {
1078            findings: vec![],
1079            summary: crate::health_types::HealthSummary {
1080                files_analyzed: 10,
1081                functions_analyzed: 50,
1082                functions_above_threshold: 0,
1083                max_cyclomatic_threshold: 20,
1084                max_cognitive_threshold: 15,
1085                files_scored: None,
1086                average_maintainability: None,
1087                coverage_model: None,
1088                istanbul_matched: None,
1089                istanbul_total: None,
1090            },
1091            vital_signs: None,
1092            health_score: None,
1093            file_scores: vec![],
1094            coverage_gaps: None,
1095            hotspots: vec![],
1096            hotspot_summary: None,
1097            targets: vec![],
1098            target_thresholds: None,
1099            health_trend: None,
1100        };
1101        let sarif = build_health_sarif(&report, &root);
1102        assert_eq!(sarif["version"], "2.1.0");
1103        let results = sarif["runs"][0]["results"].as_array().unwrap();
1104        assert!(results.is_empty());
1105        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1106            .as_array()
1107            .unwrap();
1108        assert_eq!(rules.len(), 6);
1109    }
1110
1111    #[test]
1112    fn health_sarif_cyclomatic_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/utils.ts"),
1117                name: "parseExpression".to_string(),
1118                line: 42,
1119                col: 0,
1120                cyclomatic: 25,
1121                cognitive: 10,
1122                line_count: 80,
1123                param_count: 0,
1124                exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1125            }],
1126            summary: crate::health_types::HealthSummary {
1127                files_analyzed: 5,
1128                functions_analyzed: 20,
1129                functions_above_threshold: 1,
1130                max_cyclomatic_threshold: 20,
1131                max_cognitive_threshold: 15,
1132                files_scored: None,
1133                average_maintainability: None,
1134                coverage_model: None,
1135                istanbul_matched: None,
1136                istanbul_total: None,
1137            },
1138            vital_signs: None,
1139            health_score: None,
1140            file_scores: vec![],
1141            coverage_gaps: None,
1142            hotspots: vec![],
1143            hotspot_summary: None,
1144            targets: vec![],
1145            target_thresholds: None,
1146            health_trend: None,
1147        };
1148        let sarif = build_health_sarif(&report, &root);
1149        let entry = &sarif["runs"][0]["results"][0];
1150        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1151        assert_eq!(entry["level"], "warning");
1152        assert!(
1153            entry["message"]["text"]
1154                .as_str()
1155                .unwrap()
1156                .contains("cyclomatic complexity 25")
1157        );
1158        assert_eq!(
1159            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1160            "src/utils.ts"
1161        );
1162        let region = &entry["locations"][0]["physicalLocation"]["region"];
1163        assert_eq!(region["startLine"], 42);
1164        assert_eq!(region["startColumn"], 1);
1165    }
1166
1167    #[test]
1168    fn health_sarif_cognitive_only() {
1169        let root = PathBuf::from("/project");
1170        let report = crate::health_types::HealthReport {
1171            findings: vec![crate::health_types::HealthFinding {
1172                path: root.join("src/api.ts"),
1173                name: "handleRequest".to_string(),
1174                line: 10,
1175                col: 4,
1176                cyclomatic: 8,
1177                cognitive: 20,
1178                line_count: 40,
1179                param_count: 0,
1180                exceeded: crate::health_types::ExceededThreshold::Cognitive,
1181            }],
1182            summary: crate::health_types::HealthSummary {
1183                files_analyzed: 3,
1184                functions_analyzed: 10,
1185                functions_above_threshold: 1,
1186                max_cyclomatic_threshold: 20,
1187                max_cognitive_threshold: 15,
1188                files_scored: None,
1189                average_maintainability: None,
1190                coverage_model: None,
1191                istanbul_matched: None,
1192                istanbul_total: None,
1193            },
1194            vital_signs: None,
1195            health_score: None,
1196            file_scores: vec![],
1197            coverage_gaps: None,
1198            hotspots: vec![],
1199            hotspot_summary: None,
1200            targets: vec![],
1201            target_thresholds: None,
1202            health_trend: None,
1203        };
1204        let sarif = build_health_sarif(&report, &root);
1205        let entry = &sarif["runs"][0]["results"][0];
1206        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1207        assert!(
1208            entry["message"]["text"]
1209                .as_str()
1210                .unwrap()
1211                .contains("cognitive complexity 20")
1212        );
1213        let region = &entry["locations"][0]["physicalLocation"]["region"];
1214        assert_eq!(region["startColumn"], 5); // col 4 + 1
1215    }
1216
1217    #[test]
1218    fn health_sarif_both_thresholds() {
1219        let root = PathBuf::from("/project");
1220        let report = crate::health_types::HealthReport {
1221            findings: vec![crate::health_types::HealthFinding {
1222                path: root.join("src/complex.ts"),
1223                name: "doEverything".to_string(),
1224                line: 1,
1225                col: 0,
1226                cyclomatic: 30,
1227                cognitive: 45,
1228                line_count: 100,
1229                param_count: 0,
1230                exceeded: crate::health_types::ExceededThreshold::Both,
1231            }],
1232            summary: crate::health_types::HealthSummary {
1233                files_analyzed: 1,
1234                functions_analyzed: 1,
1235                functions_above_threshold: 1,
1236                max_cyclomatic_threshold: 20,
1237                max_cognitive_threshold: 15,
1238                files_scored: None,
1239                average_maintainability: None,
1240                coverage_model: None,
1241                istanbul_matched: None,
1242                istanbul_total: None,
1243            },
1244            vital_signs: None,
1245            health_score: None,
1246            file_scores: vec![],
1247            coverage_gaps: None,
1248            hotspots: vec![],
1249            hotspot_summary: None,
1250            targets: vec![],
1251            target_thresholds: None,
1252            health_trend: None,
1253        };
1254        let sarif = build_health_sarif(&report, &root);
1255        let entry = &sarif["runs"][0]["results"][0];
1256        assert_eq!(entry["ruleId"], "fallow/high-complexity");
1257        let msg = entry["message"]["text"].as_str().unwrap();
1258        assert!(msg.contains("cyclomatic complexity 30"));
1259        assert!(msg.contains("cognitive complexity 45"));
1260    }
1261
1262    // ── Severity mapping ──
1263
1264    #[test]
1265    fn severity_to_sarif_level_error() {
1266        assert_eq!(severity_to_sarif_level(Severity::Error), "error");
1267    }
1268
1269    #[test]
1270    fn severity_to_sarif_level_warn() {
1271        assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
1272    }
1273
1274    #[test]
1275    fn severity_to_sarif_level_off() {
1276        assert_eq!(severity_to_sarif_level(Severity::Off), "warning");
1277    }
1278
1279    // ── Re-export properties ──
1280
1281    #[test]
1282    fn sarif_re_export_has_properties() {
1283        let root = PathBuf::from("/project");
1284        let mut results = AnalysisResults::default();
1285        results.unused_exports.push(UnusedExport {
1286            path: root.join("src/index.ts"),
1287            export_name: "reExported".to_string(),
1288            is_type_only: false,
1289            line: 1,
1290            col: 0,
1291            span_start: 0,
1292            is_re_export: true,
1293        });
1294
1295        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1296        let entry = &sarif["runs"][0]["results"][0];
1297        assert_eq!(entry["properties"]["is_re_export"], true);
1298        let msg = entry["message"]["text"].as_str().unwrap();
1299        assert!(msg.starts_with("Re-export"));
1300    }
1301
1302    #[test]
1303    fn sarif_non_re_export_has_no_properties() {
1304        let root = PathBuf::from("/project");
1305        let mut results = AnalysisResults::default();
1306        results.unused_exports.push(UnusedExport {
1307            path: root.join("src/utils.ts"),
1308            export_name: "foo".to_string(),
1309            is_type_only: false,
1310            line: 5,
1311            col: 0,
1312            span_start: 0,
1313            is_re_export: false,
1314        });
1315
1316        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1317        let entry = &sarif["runs"][0]["results"][0];
1318        assert!(entry.get("properties").is_none());
1319        let msg = entry["message"]["text"].as_str().unwrap();
1320        assert!(msg.starts_with("Export"));
1321    }
1322
1323    // ── Type re-export ──
1324
1325    #[test]
1326    fn sarif_type_re_export_message() {
1327        let root = PathBuf::from("/project");
1328        let mut results = AnalysisResults::default();
1329        results.unused_types.push(UnusedExport {
1330            path: root.join("src/index.ts"),
1331            export_name: "MyType".to_string(),
1332            is_type_only: true,
1333            line: 1,
1334            col: 0,
1335            span_start: 0,
1336            is_re_export: true,
1337        });
1338
1339        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1340        let entry = &sarif["runs"][0]["results"][0];
1341        assert_eq!(entry["ruleId"], "fallow/unused-type");
1342        let msg = entry["message"]["text"].as_str().unwrap();
1343        assert!(msg.starts_with("Type re-export"));
1344        assert_eq!(entry["properties"]["is_re_export"], true);
1345    }
1346
1347    // ── Dependency line == 0 skips region ──
1348
1349    #[test]
1350    fn sarif_dependency_line_zero_skips_region() {
1351        let root = PathBuf::from("/project");
1352        let mut results = AnalysisResults::default();
1353        results.unused_dependencies.push(UnusedDependency {
1354            package_name: "lodash".to_string(),
1355            location: DependencyLocation::Dependencies,
1356            path: root.join("package.json"),
1357            line: 0,
1358        });
1359
1360        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1361        let entry = &sarif["runs"][0]["results"][0];
1362        let phys = &entry["locations"][0]["physicalLocation"];
1363        assert!(phys.get("region").is_none());
1364    }
1365
1366    #[test]
1367    fn sarif_dependency_line_nonzero_has_region() {
1368        let root = PathBuf::from("/project");
1369        let mut results = AnalysisResults::default();
1370        results.unused_dependencies.push(UnusedDependency {
1371            package_name: "lodash".to_string(),
1372            location: DependencyLocation::Dependencies,
1373            path: root.join("package.json"),
1374            line: 7,
1375        });
1376
1377        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1378        let entry = &sarif["runs"][0]["results"][0];
1379        let region = &entry["locations"][0]["physicalLocation"]["region"];
1380        assert_eq!(region["startLine"], 7);
1381        assert_eq!(region["startColumn"], 1);
1382    }
1383
1384    // ── Type-only dependency line == 0 skips region ──
1385
1386    #[test]
1387    fn sarif_type_only_dep_line_zero_skips_region() {
1388        let root = PathBuf::from("/project");
1389        let mut results = AnalysisResults::default();
1390        results.type_only_dependencies.push(TypeOnlyDependency {
1391            package_name: "zod".to_string(),
1392            path: root.join("package.json"),
1393            line: 0,
1394        });
1395
1396        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1397        let entry = &sarif["runs"][0]["results"][0];
1398        let phys = &entry["locations"][0]["physicalLocation"];
1399        assert!(phys.get("region").is_none());
1400    }
1401
1402    // ── Circular dependency line == 0 skips region ──
1403
1404    #[test]
1405    fn sarif_circular_dep_line_zero_skips_region() {
1406        let root = PathBuf::from("/project");
1407        let mut results = AnalysisResults::default();
1408        results.circular_dependencies.push(CircularDependency {
1409            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1410            length: 2,
1411            line: 0,
1412            col: 0,
1413            is_cross_package: false,
1414        });
1415
1416        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1417        let entry = &sarif["runs"][0]["results"][0];
1418        let phys = &entry["locations"][0]["physicalLocation"];
1419        assert!(phys.get("region").is_none());
1420    }
1421
1422    #[test]
1423    fn sarif_circular_dep_line_nonzero_has_region() {
1424        let root = PathBuf::from("/project");
1425        let mut results = AnalysisResults::default();
1426        results.circular_dependencies.push(CircularDependency {
1427            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1428            length: 2,
1429            line: 5,
1430            col: 2,
1431            is_cross_package: false,
1432        });
1433
1434        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1435        let entry = &sarif["runs"][0]["results"][0];
1436        let region = &entry["locations"][0]["physicalLocation"]["region"];
1437        assert_eq!(region["startLine"], 5);
1438        assert_eq!(region["startColumn"], 3);
1439    }
1440
1441    // ── Unused optional dependency ──
1442
1443    #[test]
1444    fn sarif_unused_optional_dependency_result() {
1445        let root = PathBuf::from("/project");
1446        let mut results = AnalysisResults::default();
1447        results.unused_optional_dependencies.push(UnusedDependency {
1448            package_name: "fsevents".to_string(),
1449            location: DependencyLocation::OptionalDependencies,
1450            path: root.join("package.json"),
1451            line: 12,
1452        });
1453
1454        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1455        let entry = &sarif["runs"][0]["results"][0];
1456        assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
1457        let msg = entry["message"]["text"].as_str().unwrap();
1458        assert!(msg.contains("optionalDependencies"));
1459    }
1460
1461    // ── Enum and class member SARIF messages ──
1462
1463    #[test]
1464    fn sarif_enum_member_message_format() {
1465        let root = PathBuf::from("/project");
1466        let mut results = AnalysisResults::default();
1467        results
1468            .unused_enum_members
1469            .push(fallow_core::results::UnusedMember {
1470                path: root.join("src/enums.ts"),
1471                parent_name: "Color".to_string(),
1472                member_name: "Purple".to_string(),
1473                kind: fallow_core::extract::MemberKind::EnumMember,
1474                line: 5,
1475                col: 2,
1476            });
1477
1478        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1479        let entry = &sarif["runs"][0]["results"][0];
1480        assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
1481        let msg = entry["message"]["text"].as_str().unwrap();
1482        assert!(msg.contains("Enum member 'Color.Purple'"));
1483        let region = &entry["locations"][0]["physicalLocation"]["region"];
1484        assert_eq!(region["startColumn"], 3); // col 2 + 1
1485    }
1486
1487    #[test]
1488    fn sarif_class_member_message_format() {
1489        let root = PathBuf::from("/project");
1490        let mut results = AnalysisResults::default();
1491        results
1492            .unused_class_members
1493            .push(fallow_core::results::UnusedMember {
1494                path: root.join("src/service.ts"),
1495                parent_name: "API".to_string(),
1496                member_name: "fetch".to_string(),
1497                kind: fallow_core::extract::MemberKind::ClassMethod,
1498                line: 10,
1499                col: 4,
1500            });
1501
1502        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1503        let entry = &sarif["runs"][0]["results"][0];
1504        assert_eq!(entry["ruleId"], "fallow/unused-class-member");
1505        let msg = entry["message"]["text"].as_str().unwrap();
1506        assert!(msg.contains("Class member 'API.fetch'"));
1507    }
1508
1509    // ── Duplication SARIF ──
1510
1511    #[test]
1512    #[expect(
1513        clippy::cast_possible_truncation,
1514        reason = "test line/col values are trivially small"
1515    )]
1516    fn duplication_sarif_structure() {
1517        use fallow_core::duplicates::*;
1518
1519        let root = PathBuf::from("/project");
1520        let report = DuplicationReport {
1521            clone_groups: vec![CloneGroup {
1522                instances: vec![
1523                    CloneInstance {
1524                        file: root.join("src/a.ts"),
1525                        start_line: 1,
1526                        end_line: 10,
1527                        start_col: 0,
1528                        end_col: 0,
1529                        fragment: String::new(),
1530                    },
1531                    CloneInstance {
1532                        file: root.join("src/b.ts"),
1533                        start_line: 5,
1534                        end_line: 14,
1535                        start_col: 2,
1536                        end_col: 0,
1537                        fragment: String::new(),
1538                    },
1539                ],
1540                token_count: 50,
1541                line_count: 10,
1542            }],
1543            clone_families: vec![],
1544            mirrored_directories: vec![],
1545            stats: DuplicationStats::default(),
1546        };
1547
1548        let sarif = serde_json::json!({
1549            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1550            "version": "2.1.0",
1551            "runs": [{
1552                "tool": {
1553                    "driver": {
1554                        "name": "fallow",
1555                        "version": env!("CARGO_PKG_VERSION"),
1556                        "informationUri": "https://github.com/fallow-rs/fallow",
1557                        "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1558                    }
1559                },
1560                "results": []
1561            }]
1562        });
1563        // Just verify the function doesn't panic and produces expected structure
1564        let _ = sarif;
1565
1566        // Test the actual build path through print_duplication_sarif internals
1567        let mut sarif_results = Vec::new();
1568        for (i, group) in report.clone_groups.iter().enumerate() {
1569            for instance in &group.instances {
1570                sarif_results.push(sarif_result(
1571                    "fallow/code-duplication",
1572                    "warning",
1573                    &format!(
1574                        "Code clone group {} ({} lines, {} instances)",
1575                        i + 1,
1576                        group.line_count,
1577                        group.instances.len()
1578                    ),
1579                    &super::super::relative_uri(&instance.file, &root),
1580                    Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1581                ));
1582            }
1583        }
1584        assert_eq!(sarif_results.len(), 2);
1585        assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
1586        assert!(
1587            sarif_results[0]["message"]["text"]
1588                .as_str()
1589                .unwrap()
1590                .contains("10 lines")
1591        );
1592        let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
1593        assert_eq!(region0["startLine"], 1);
1594        assert_eq!(region0["startColumn"], 1); // start_col 0 + 1
1595        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
1596        assert_eq!(region1["startLine"], 5);
1597        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
1598    }
1599
1600    // ── sarif_rule fallback (unknown rule ID) ──
1601
1602    #[test]
1603    fn sarif_rule_known_id_has_full_description() {
1604        let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
1605        assert!(rule.get("fullDescription").is_some());
1606        assert!(rule.get("helpUri").is_some());
1607    }
1608
1609    #[test]
1610    fn sarif_rule_unknown_id_uses_fallback() {
1611        let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
1612        assert_eq!(rule["shortDescription"]["text"], "fallback text");
1613        assert!(rule.get("fullDescription").is_none());
1614        assert!(rule.get("helpUri").is_none());
1615        assert_eq!(rule["defaultConfiguration"]["level"], "warning");
1616    }
1617
1618    // ── sarif_result without region ──
1619
1620    #[test]
1621    fn sarif_result_no_region_omits_region_key() {
1622        let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
1623        let phys = &result["locations"][0]["physicalLocation"];
1624        assert!(phys.get("region").is_none());
1625        assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
1626    }
1627
1628    #[test]
1629    fn sarif_result_with_region_includes_region() {
1630        let result = sarif_result(
1631            "rule/test",
1632            "error",
1633            "test msg",
1634            "src/file.ts",
1635            Some((10, 5)),
1636        );
1637        let region = &result["locations"][0]["physicalLocation"]["region"];
1638        assert_eq!(region["startLine"], 10);
1639        assert_eq!(region["startColumn"], 5);
1640    }
1641
1642    // ── Health SARIF refactoring targets ──
1643
1644    #[test]
1645    fn health_sarif_includes_refactoring_targets() {
1646        use crate::health_types::*;
1647
1648        let root = PathBuf::from("/project");
1649        let report = HealthReport {
1650            findings: vec![],
1651            summary: HealthSummary {
1652                files_analyzed: 10,
1653                functions_analyzed: 50,
1654                functions_above_threshold: 0,
1655                max_cyclomatic_threshold: 20,
1656                max_cognitive_threshold: 15,
1657                files_scored: None,
1658                average_maintainability: None,
1659                coverage_model: None,
1660                istanbul_matched: None,
1661                istanbul_total: None,
1662            },
1663            vital_signs: None,
1664            health_score: None,
1665            file_scores: vec![],
1666            coverage_gaps: None,
1667            hotspots: vec![],
1668            hotspot_summary: None,
1669            targets: vec![RefactoringTarget {
1670                path: root.join("src/complex.ts"),
1671                priority: 85.0,
1672                efficiency: 42.5,
1673                recommendation: "Split high-impact file".into(),
1674                category: RecommendationCategory::SplitHighImpact,
1675                effort: EffortEstimate::Medium,
1676                confidence: Confidence::High,
1677                factors: vec![],
1678                evidence: None,
1679            }],
1680            target_thresholds: None,
1681            health_trend: None,
1682        };
1683
1684        let sarif = build_health_sarif(&report, &root);
1685        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1686        assert_eq!(entries.len(), 1);
1687        assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
1688        assert_eq!(entries[0]["level"], "warning");
1689        let msg = entries[0]["message"]["text"].as_str().unwrap();
1690        assert!(msg.contains("high impact"));
1691        assert!(msg.contains("Split high-impact file"));
1692        assert!(msg.contains("42.5"));
1693    }
1694
1695    #[test]
1696    fn health_sarif_includes_coverage_gaps() {
1697        use crate::health_types::*;
1698
1699        let root = PathBuf::from("/project");
1700        let report = HealthReport {
1701            findings: vec![],
1702            summary: HealthSummary {
1703                files_analyzed: 10,
1704                functions_analyzed: 50,
1705                functions_above_threshold: 0,
1706                max_cyclomatic_threshold: 20,
1707                max_cognitive_threshold: 15,
1708                files_scored: None,
1709                average_maintainability: None,
1710                coverage_model: None,
1711                istanbul_matched: None,
1712                istanbul_total: None,
1713            },
1714            vital_signs: None,
1715            health_score: None,
1716            file_scores: vec![],
1717            coverage_gaps: Some(CoverageGaps {
1718                summary: CoverageGapSummary {
1719                    runtime_files: 2,
1720                    covered_files: 0,
1721                    file_coverage_pct: 0.0,
1722                    untested_files: 1,
1723                    untested_exports: 1,
1724                },
1725                files: vec![UntestedFile {
1726                    path: root.join("src/app.ts"),
1727                    value_export_count: 2,
1728                }],
1729                exports: vec![UntestedExport {
1730                    path: root.join("src/app.ts"),
1731                    export_name: "loader".into(),
1732                    line: 12,
1733                    col: 4,
1734                }],
1735            }),
1736            hotspots: vec![],
1737            hotspot_summary: None,
1738            targets: vec![],
1739            target_thresholds: None,
1740            health_trend: None,
1741        };
1742
1743        let sarif = build_health_sarif(&report, &root);
1744        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1745        assert_eq!(entries.len(), 2);
1746        assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
1747        assert_eq!(
1748            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1749            "src/app.ts"
1750        );
1751        assert!(
1752            entries[0]["message"]["text"]
1753                .as_str()
1754                .unwrap()
1755                .contains("2 value exports")
1756        );
1757        assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
1758        assert_eq!(
1759            entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
1760            12
1761        );
1762        assert_eq!(
1763            entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
1764            5
1765        );
1766    }
1767
1768    // ── Health SARIF rules include fullDescription from explain module ──
1769
1770    #[test]
1771    fn health_sarif_rules_have_full_descriptions() {
1772        let root = PathBuf::from("/project");
1773        let report = crate::health_types::HealthReport {
1774            findings: vec![],
1775            summary: crate::health_types::HealthSummary {
1776                files_analyzed: 0,
1777                functions_analyzed: 0,
1778                functions_above_threshold: 0,
1779                max_cyclomatic_threshold: 20,
1780                max_cognitive_threshold: 15,
1781                files_scored: None,
1782                average_maintainability: None,
1783                coverage_model: None,
1784                istanbul_matched: None,
1785                istanbul_total: None,
1786            },
1787            vital_signs: None,
1788            health_score: None,
1789            file_scores: vec![],
1790            coverage_gaps: None,
1791            hotspots: vec![],
1792            hotspot_summary: None,
1793            targets: vec![],
1794            target_thresholds: None,
1795            health_trend: None,
1796        };
1797        let sarif = build_health_sarif(&report, &root);
1798        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1799            .as_array()
1800            .unwrap();
1801        for rule in rules {
1802            let id = rule["id"].as_str().unwrap();
1803            assert!(
1804                rule.get("fullDescription").is_some(),
1805                "health rule {id} should have fullDescription"
1806            );
1807            assert!(
1808                rule.get("helpUri").is_some(),
1809                "health rule {id} should have helpUri"
1810            );
1811        }
1812    }
1813
1814    // ── Warn severity propagates correctly ──
1815
1816    #[test]
1817    fn sarif_warn_severity_produces_warning_level() {
1818        let root = PathBuf::from("/project");
1819        let mut results = AnalysisResults::default();
1820        results.unused_files.push(UnusedFile {
1821            path: root.join("src/dead.ts"),
1822        });
1823
1824        let rules = RulesConfig {
1825            unused_files: Severity::Warn,
1826            ..RulesConfig::default()
1827        };
1828
1829        let sarif = build_sarif(&results, &root, &rules);
1830        let entry = &sarif["runs"][0]["results"][0];
1831        assert_eq!(entry["level"], "warning");
1832    }
1833
1834    // ── Unused file has no region ──
1835
1836    #[test]
1837    fn sarif_unused_file_has_no_region() {
1838        let root = PathBuf::from("/project");
1839        let mut results = AnalysisResults::default();
1840        results.unused_files.push(UnusedFile {
1841            path: root.join("src/dead.ts"),
1842        });
1843
1844        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1845        let entry = &sarif["runs"][0]["results"][0];
1846        let phys = &entry["locations"][0]["physicalLocation"];
1847        assert!(phys.get("region").is_none());
1848    }
1849
1850    // ── Multiple unlisted deps with multiple import sites ──
1851
1852    #[test]
1853    fn sarif_unlisted_dep_multiple_import_sites() {
1854        let root = PathBuf::from("/project");
1855        let mut results = AnalysisResults::default();
1856        results.unlisted_dependencies.push(UnlistedDependency {
1857            package_name: "dotenv".to_string(),
1858            imported_from: vec![
1859                ImportSite {
1860                    path: root.join("src/a.ts"),
1861                    line: 1,
1862                    col: 0,
1863                },
1864                ImportSite {
1865                    path: root.join("src/b.ts"),
1866                    line: 5,
1867                    col: 0,
1868                },
1869            ],
1870        });
1871
1872        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1873        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1874        // One SARIF result per import site
1875        assert_eq!(entries.len(), 2);
1876        assert_eq!(
1877            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1878            "src/a.ts"
1879        );
1880        assert_eq!(
1881            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1882            "src/b.ts"
1883        );
1884    }
1885
1886    // ── Empty unlisted dep (no import sites) produces zero results ──
1887
1888    #[test]
1889    fn sarif_unlisted_dep_no_import_sites() {
1890        let root = PathBuf::from("/project");
1891        let mut results = AnalysisResults::default();
1892        results.unlisted_dependencies.push(UnlistedDependency {
1893            package_name: "phantom".to_string(),
1894            imported_from: vec![],
1895        });
1896
1897        let sarif = build_sarif(&results, &root, &RulesConfig::default());
1898        let entries = sarif["runs"][0]["results"].as_array().unwrap();
1899        // No import sites => no SARIF results for this unlisted dep
1900        assert!(entries.is_empty());
1901    }
1902}