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