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