Skip to main content

fallow_cli/report/
sarif.rs

1use std::path::{Path, PathBuf};
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::{
7    AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation, BoundaryViolation,
8    CircularDependency, DuplicateExportFinding, DuplicatePropShape, DynamicSegmentNameConflict,
9    EmptyCatalogGroupFinding, InvalidClientExport, MisconfiguredDependencyOverrideFinding,
10    MisplacedDirective, MixedClientServerBarrel, PolicyViolation, PolicyViolationSeverity,
11    PrivateTypeLeak, PropDrillingChain, RouteCollision, StaleSuppression, TestOnlyDependency,
12    ThinWrapper, TypeOnlyDependency, UnlistedDependencyFinding, UnprovidedInject,
13    UnrenderedComponent, UnresolvedCatalogReferenceFinding, UnresolvedImport,
14    UnusedCatalogEntryFinding, UnusedComponentEmit, UnusedComponentProp, UnusedDependency,
15    UnusedDependencyOverrideFinding, UnusedExport, UnusedFile, UnusedMember, UnusedServerAction,
16};
17use rustc_hash::FxHashMap;
18
19use super::ci::{fingerprint, severity};
20use super::grouping::{self, OwnershipResolver};
21use super::{emit_json, relative_uri};
22use crate::explain;
23
24/// Intermediate fields extracted from an issue for SARIF result construction.
25struct SarifFields {
26    rule_id: &'static str,
27    level: &'static str,
28    message: String,
29    uri: String,
30    region: Option<(u32, u32)>,
31    source_path: Option<PathBuf>,
32    properties: Option<serde_json::Value>,
33}
34
35#[derive(Default)]
36struct SourceSnippetCache {
37    files: FxHashMap<PathBuf, Vec<String>>,
38}
39
40impl SourceSnippetCache {
41    fn line(&mut self, path: &Path, line: u32) -> Option<String> {
42        if line == 0 {
43            return None;
44        }
45        if !self.files.contains_key(path) {
46            let lines = std::fs::read_to_string(path)
47                .ok()
48                .map(|source| source.lines().map(str::to_owned).collect())
49                .unwrap_or_default();
50            self.files.insert(path.to_path_buf(), lines);
51        }
52        self.files
53            .get(path)
54            .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
55            .cloned()
56    }
57}
58
59fn severity_to_sarif_level(s: Severity) -> &'static str {
60    severity::sarif_level(s)
61}
62
63fn configured_sarif_level(s: Severity) -> &'static str {
64    match s {
65        Severity::Error | Severity::Warn => severity_to_sarif_level(s),
66        Severity::Off => "none",
67    }
68}
69
70/// Build a single SARIF result object.
71///
72/// When `region` is `Some((line, col))`, a `region` block with 1-based
73/// `startLine` and `startColumn` is included in the physical location.
74fn sarif_result(
75    rule_id: &str,
76    level: &str,
77    message: &str,
78    uri: &str,
79    region: Option<(u32, u32)>,
80) -> serde_json::Value {
81    sarif_result_with_snippet(rule_id, level, message, uri, region, None)
82}
83
84fn sarif_result_with_snippet(
85    rule_id: &str,
86    level: &str,
87    message: &str,
88    uri: &str,
89    region: Option<(u32, u32)>,
90    snippet: Option<&str>,
91) -> serde_json::Value {
92    let mut physical_location = serde_json::json!({
93        "artifactLocation": { "uri": uri }
94    });
95    if let Some((line, col)) = region {
96        physical_location["region"] = serde_json::json!({
97            "startLine": line,
98            "startColumn": col
99        });
100    }
101    let line = region.map_or_else(String::new, |(line, _)| line.to_string());
102    let col = region.map_or_else(String::new, |(_, col)| col.to_string());
103    let normalized_snippet = snippet
104        .map(fingerprint::normalize_snippet)
105        .filter(|snippet| !snippet.is_empty());
106    let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
107        || fingerprint::fingerprint_hash(&[rule_id, uri, &line, &col]),
108        |snippet| fingerprint::finding_fingerprint(rule_id, uri, snippet),
109    );
110    let partial_fingerprint_ghas = partial_fingerprint.clone();
111    serde_json::json!({
112        "ruleId": rule_id,
113        "level": level,
114        "message": { "text": message },
115        "locations": [{ "physicalLocation": physical_location }],
116        "partialFingerprints": {
117            fingerprint::FINGERPRINT_KEY: partial_fingerprint,
118            fingerprint::GHAS_FINGERPRINT_KEY: partial_fingerprint_ghas
119        }
120    })
121}
122
123/// Append SARIF results for a slice of items using a closure to extract fields.
124fn push_sarif_results<T>(
125    sarif_results: &mut Vec<serde_json::Value>,
126    items: &[T],
127    snippets: &mut SourceSnippetCache,
128    mut extract: impl FnMut(&T) -> SarifFields,
129) {
130    for item in items {
131        let fields = extract(item);
132        let source_snippet = fields
133            .source_path
134            .as_deref()
135            .zip(fields.region)
136            .and_then(|(path, (line, _))| snippets.line(path, line));
137        let mut result = sarif_result_with_snippet(
138            fields.rule_id,
139            fields.level,
140            &fields.message,
141            &fields.uri,
142            fields.region,
143            source_snippet.as_deref(),
144        );
145        if let Some(props) = fields.properties {
146            result["properties"] = props;
147        }
148        sarif_results.push(result);
149    }
150}
151
152/// Build a SARIF rule definition with optional `fullDescription` and `helpUri`
153/// sourced from the centralized explain module.
154fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
155    explain::rule_by_id(id).map_or_else(
156        || {
157            serde_json::json!({
158                "id": id,
159                "shortDescription": { "text": fallback_short },
160                "defaultConfiguration": { "level": level }
161            })
162        },
163        |def| {
164            serde_json::json!({
165                "id": id,
166                "shortDescription": { "text": def.short },
167                "fullDescription": { "text": def.full },
168                "helpUri": explain::rule_docs_url(def),
169                "defaultConfiguration": { "level": level }
170            })
171        },
172    )
173}
174
175/// Extract SARIF fields for an unused export or type export.
176fn sarif_export_fields(
177    export: &UnusedExport,
178    root: &Path,
179    rule_id: &'static str,
180    level: &'static str,
181    kind: &str,
182    re_kind: &str,
183) -> SarifFields {
184    let label = if export.is_re_export { re_kind } else { kind };
185    SarifFields {
186        rule_id,
187        level,
188        message: format!(
189            "{} '{}' is never imported by other modules",
190            label, export.export_name
191        ),
192        uri: relative_uri(&export.path, root),
193        region: Some((export.line, export.col + 1)),
194        source_path: Some(export.path.clone()),
195        properties: if export.is_re_export {
196            Some(serde_json::json!({ "is_re_export": true }))
197        } else {
198            None
199        },
200    }
201}
202
203fn sarif_private_type_leak_fields(
204    leak: &PrivateTypeLeak,
205    root: &Path,
206    level: &'static str,
207) -> SarifFields {
208    SarifFields {
209        rule_id: "fallow/private-type-leak",
210        level,
211        message: format!(
212            "Export '{}' references private type '{}'",
213            leak.export_name, leak.type_name
214        ),
215        uri: relative_uri(&leak.path, root),
216        region: Some((leak.line, leak.col + 1)),
217        source_path: Some(leak.path.clone()),
218        properties: None,
219    }
220}
221
222/// Extract SARIF fields for an unused dependency.
223fn sarif_dep_fields(
224    dep: &UnusedDependency,
225    root: &Path,
226    rule_id: &'static str,
227    level: &'static str,
228    section: &str,
229) -> SarifFields {
230    let workspace_context = if dep.used_in_workspaces.is_empty() {
231        String::new()
232    } else {
233        let workspaces = dep
234            .used_in_workspaces
235            .iter()
236            .map(|path| relative_uri(path, root))
237            .collect::<Vec<_>>()
238            .join(", ");
239        format!("; imported in other workspaces: {workspaces}")
240    };
241    SarifFields {
242        rule_id,
243        level,
244        message: format!(
245            "Package '{}' is in {} but never imported{}",
246            dep.package_name, section, workspace_context
247        ),
248        uri: relative_uri(&dep.path, root),
249        region: if dep.line > 0 {
250            Some((dep.line, 1))
251        } else {
252            None
253        },
254        source_path: (dep.line > 0).then(|| dep.path.clone()),
255        properties: None,
256    }
257}
258
259/// Extract SARIF fields for an unused enum or class member.
260fn sarif_member_fields(
261    member: &UnusedMember,
262    root: &Path,
263    rule_id: &'static str,
264    level: &'static str,
265    kind: &str,
266) -> SarifFields {
267    SarifFields {
268        rule_id,
269        level,
270        message: format!(
271            "{} member '{}.{}' is never referenced",
272            kind, member.parent_name, member.member_name
273        ),
274        uri: relative_uri(&member.path, root),
275        region: Some((member.line, member.col + 1)),
276        source_path: Some(member.path.clone()),
277        properties: None,
278    }
279}
280
281fn sarif_unused_file_fields(file: &UnusedFile, root: &Path, level: &'static str) -> SarifFields {
282    SarifFields {
283        rule_id: "fallow/unused-file",
284        level,
285        message: "File is not reachable from any entry point".to_string(),
286        uri: relative_uri(&file.path, root),
287        region: None,
288        source_path: None,
289        properties: None,
290    }
291}
292
293fn sarif_type_only_dep_fields(
294    dep: &TypeOnlyDependency,
295    root: &Path,
296    level: &'static str,
297) -> SarifFields {
298    SarifFields {
299        rule_id: "fallow/type-only-dependency",
300        level,
301        message: format!(
302            "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
303            dep.package_name
304        ),
305        uri: relative_uri(&dep.path, root),
306        region: if dep.line > 0 {
307            Some((dep.line, 1))
308        } else {
309            None
310        },
311        source_path: (dep.line > 0).then(|| dep.path.clone()),
312        properties: None,
313    }
314}
315
316fn sarif_test_only_dep_fields(
317    dep: &TestOnlyDependency,
318    root: &Path,
319    level: &'static str,
320) -> SarifFields {
321    SarifFields {
322        rule_id: "fallow/test-only-dependency",
323        level,
324        message: format!(
325            "Package '{}' is only imported by test files (consider moving to devDependencies)",
326            dep.package_name
327        ),
328        uri: relative_uri(&dep.path, root),
329        region: if dep.line > 0 {
330            Some((dep.line, 1))
331        } else {
332            None
333        },
334        source_path: (dep.line > 0).then(|| dep.path.clone()),
335        properties: None,
336    }
337}
338
339fn sarif_unresolved_import_fields(
340    import: &UnresolvedImport,
341    root: &Path,
342    level: &'static str,
343) -> SarifFields {
344    SarifFields {
345        rule_id: "fallow/unresolved-import",
346        level,
347        message: format!("Import '{}' could not be resolved", import.specifier),
348        uri: relative_uri(&import.path, root),
349        region: Some((import.line, import.col + 1)),
350        source_path: Some(import.path.clone()),
351        properties: None,
352    }
353}
354
355fn sarif_circular_dep_fields(
356    cycle: &CircularDependency,
357    root: &Path,
358    level: &'static str,
359) -> SarifFields {
360    let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
361    let mut display_chain = chain.clone();
362    if let Some(first) = chain.first() {
363        display_chain.push(first.clone());
364    }
365    let first_uri = chain.first().map_or_else(String::new, Clone::clone);
366    let first_path = cycle.files.first().cloned();
367    SarifFields {
368        rule_id: "fallow/circular-dependency",
369        level,
370        message: format!(
371            "Circular dependency{}: {}",
372            if cycle.is_cross_package {
373                " (cross-package)"
374            } else {
375                ""
376            },
377            display_chain.join(" \u{2192} ")
378        ),
379        uri: first_uri,
380        region: if cycle.line > 0 {
381            Some((cycle.line, cycle.col + 1))
382        } else {
383            None
384        },
385        source_path: (cycle.line > 0).then_some(first_path).flatten(),
386        properties: None,
387    }
388}
389
390fn sarif_re_export_cycle_fields(
391    cycle: &fallow_core::results::ReExportCycle,
392    root: &Path,
393    level: &'static str,
394) -> SarifFields {
395    let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
396    let first_uri = chain.first().map_or_else(String::new, Clone::clone);
397    let first_path = cycle.files.first().cloned();
398    let kind_tag = match cycle.kind {
399        fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
400        fallow_core::results::ReExportCycleKind::MultiNode => "",
401    };
402    SarifFields {
403        rule_id: "fallow/re-export-cycle",
404        level,
405        message: format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
406        uri: first_uri,
407        region: None,
408        source_path: first_path,
409        properties: None,
410    }
411}
412
413fn sarif_boundary_violation_fields(
414    violation: &BoundaryViolation,
415    root: &Path,
416    level: &'static str,
417) -> SarifFields {
418    let from_uri = relative_uri(&violation.from_path, root);
419    let to_uri = relative_uri(&violation.to_path, root);
420    SarifFields {
421        rule_id: "fallow/boundary-violation",
422        level,
423        message: format!(
424            "Import from zone '{}' to zone '{}' is not allowed ({})",
425            violation.from_zone, violation.to_zone, to_uri,
426        ),
427        uri: from_uri,
428        region: if violation.line > 0 {
429            Some((violation.line, violation.col + 1))
430        } else {
431            None
432        },
433        source_path: (violation.line > 0).then(|| violation.from_path.clone()),
434        properties: None,
435    }
436}
437
438fn sarif_boundary_coverage_fields(
439    violation: &BoundaryCoverageViolation,
440    root: &Path,
441    level: &'static str,
442) -> SarifFields {
443    SarifFields {
444        rule_id: "fallow/boundary-coverage",
445        level,
446        message: "File does not match any configured architecture boundary zone".to_string(),
447        uri: relative_uri(&violation.path, root),
448        region: Some((violation.line, violation.col + 1)),
449        source_path: Some(violation.path.clone()),
450        properties: None,
451    }
452}
453
454fn sarif_boundary_call_fields(
455    violation: &BoundaryCallViolation,
456    root: &Path,
457    level: &'static str,
458) -> SarifFields {
459    SarifFields {
460        rule_id: "fallow/boundary-call-violation",
461        level,
462        message: format!(
463            "Call to `{}` matches forbidden pattern `{}` in zone '{}'",
464            violation.callee, violation.pattern, violation.zone
465        ),
466        uri: relative_uri(&violation.path, root),
467        region: Some((violation.line, violation.col + 1)),
468        source_path: Some(violation.path.clone()),
469        properties: None,
470    }
471}
472
473fn sarif_policy_violation_fields(violation: &PolicyViolation, root: &Path) -> SarifFields {
474    let level = match violation.severity {
475        PolicyViolationSeverity::Error => "error",
476        PolicyViolationSeverity::Warn => "warning",
477    };
478    let message = match &violation.message {
479        Some(message) => format!(
480            "Policy violation `{}/{}`: `{}` is banned. {message}",
481            violation.pack, violation.rule_id, violation.matched
482        ),
483        None => format!(
484            "Policy violation `{}/{}`: `{}` is banned",
485            violation.pack, violation.rule_id, violation.matched
486        ),
487    };
488    SarifFields {
489        rule_id: "fallow/policy-violation",
490        level,
491        message,
492        uri: relative_uri(&violation.path, root),
493        region: Some((violation.line, violation.col + 1)),
494        source_path: Some(violation.path.clone()),
495        // The SARIF rule id is the static `fallow/policy-violation`; the
496        // per-rule policy identity rides in properties so code-scanning
497        // consumers can group or filter per pack rule without parsing the
498        // message. Dynamic per-rule SARIF rule synthesis is a tracked
499        // follow-up shared with boundary zone rules.
500        properties: Some(serde_json::json!({
501            "policyRule": format!("{}/{}", violation.pack, violation.rule_id),
502        })),
503    }
504}
505
506fn sarif_invalid_client_export_fields(
507    export: &InvalidClientExport,
508    root: &Path,
509    level: &'static str,
510) -> SarifFields {
511    SarifFields {
512        rule_id: "fallow/invalid-client-export",
513        level,
514        message: format!(
515            "Export '{}' is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
516            export.export_name, export.directive
517        ),
518        uri: relative_uri(&export.path, root),
519        region: Some((export.line, export.col + 1)),
520        source_path: Some(export.path.clone()),
521        properties: None,
522    }
523}
524
525fn sarif_mixed_client_server_barrel_fields(
526    barrel: &MixedClientServerBarrel,
527    root: &Path,
528    level: &'static str,
529) -> SarifFields {
530    SarifFields {
531        rule_id: "fallow/mixed-client-server-barrel",
532        level,
533        message: format!(
534            "Barrel re-exports both a \"use client\" module ('{}') and a server-only module ('{}'); one import drags the other's directive across the boundary",
535            barrel.client_origin, barrel.server_origin
536        ),
537        uri: relative_uri(&barrel.path, root),
538        region: Some((barrel.line, barrel.col + 1)),
539        source_path: Some(barrel.path.clone()),
540        properties: None,
541    }
542}
543
544fn sarif_misplaced_directive_fields(
545    directive_site: &MisplacedDirective,
546    root: &Path,
547    level: &'static str,
548) -> SarifFields {
549    SarifFields {
550        rule_id: "fallow/misplaced-directive",
551        level,
552        message: format!(
553            "Directive \"{}\" is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
554            directive_site.directive
555        ),
556        uri: relative_uri(&directive_site.path, root),
557        region: Some((directive_site.line, directive_site.col + 1)),
558        source_path: Some(directive_site.path.clone()),
559        properties: None,
560    }
561}
562
563fn sarif_unprovided_inject_fields(
564    inject: &UnprovidedInject,
565    root: &Path,
566    level: &'static str,
567) -> SarifFields {
568    SarifFields {
569        rule_id: "fallow/unprovided-inject",
570        level,
571        message: format!(
572            "inject(\"{}\") has no matching provide(\"{}\") in this project; at runtime it returns undefined; provide the key or remove this inject",
573            inject.key_name, inject.key_name
574        ),
575        uri: relative_uri(&inject.path, root),
576        region: Some((inject.line, inject.col + 1)),
577        source_path: Some(inject.path.clone()),
578        properties: None,
579    }
580}
581
582fn sarif_unrendered_component_fields(
583    component: &UnrenderedComponent,
584    root: &Path,
585    level: &'static str,
586) -> SarifFields {
587    SarifFields {
588        rule_id: "fallow/unrendered-component",
589        level,
590        message: format!(
591            "component \"{}\" is reachable but rendered nowhere in this project; render it somewhere or remove it",
592            component.component_name
593        ),
594        uri: relative_uri(&component.path, root),
595        region: Some((component.line, component.col + 1)),
596        source_path: Some(component.path.clone()),
597        properties: None,
598    }
599}
600
601fn sarif_unused_component_prop_fields(
602    prop: &UnusedComponentProp,
603    root: &Path,
604    level: &'static str,
605) -> SarifFields {
606    SarifFields {
607        rule_id: "fallow/unused-component-prop",
608        level,
609        message: format!(
610            "prop \"{}\" is declared but referenced nowhere inside component \"{}\"; remove it or use it",
611            prop.prop_name, prop.component_name
612        ),
613        uri: relative_uri(&prop.path, root),
614        region: Some((prop.line, prop.col + 1)),
615        source_path: Some(prop.path.clone()),
616        properties: None,
617    }
618}
619
620fn sarif_unused_component_emit_fields(
621    emit: &UnusedComponentEmit,
622    root: &Path,
623    level: &'static str,
624) -> SarifFields {
625    SarifFields {
626        rule_id: "fallow/unused-component-emit",
627        level,
628        message: format!(
629            "emit \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
630            emit.emit_name, emit.component_name
631        ),
632        uri: relative_uri(&emit.path, root),
633        region: Some((emit.line, emit.col + 1)),
634        source_path: Some(emit.path.clone()),
635        properties: None,
636    }
637}
638
639fn sarif_unused_server_action_fields(
640    action: &UnusedServerAction,
641    root: &Path,
642    level: &'static str,
643) -> SarifFields {
644    SarifFields {
645        rule_id: "fallow/unused-server-action",
646        level,
647        message: format!(
648            "server action \"{}\" is exported from a \"use server\" file but no code in this project references it; wire it to a consumer or remove it",
649            action.action_name
650        ),
651        uri: relative_uri(&action.path, root),
652        region: Some((action.line, action.col + 1)),
653        source_path: Some(action.path.clone()),
654        properties: None,
655    }
656}
657
658fn sarif_unused_load_data_key_fields(
659    key: &fallow_core::results::UnusedLoadDataKey,
660    root: &Path,
661    level: &'static str,
662) -> SarifFields {
663    SarifFields {
664        rule_id: "fallow/unused-load-data-key",
665        level,
666        message: format!(
667            "load() return key \"{}\" is read by no consumer (sibling +page.svelte data.<key> or project-wide page.data.<key>); delete the key or wire a consumer",
668            key.key_name
669        ),
670        uri: relative_uri(&key.path, root),
671        region: Some((key.line, key.col + 1)),
672        source_path: Some(key.path.clone()),
673        properties: None,
674    }
675}
676
677fn sarif_prop_drilling_fields(
678    chain: &PropDrillingChain,
679    root: &Path,
680    level: &'static str,
681) -> SarifFields {
682    // Anchor at the source hop (the prop owner). Path / line come from the first
683    // hop; the message names the depth and the consumer at the chain tail.
684    let source = chain.hops.first();
685    let consumer = chain.hops.last();
686    let (path, line) = source.map_or((std::path::PathBuf::new(), 1), |h| (h.file.clone(), h.line));
687    let consumer_name = consumer.map_or("a distant component", |h| h.component.as_str());
688    SarifFields {
689        rule_id: "fallow/prop-drilling",
690        level,
691        message: format!(
692            "prop \"{}\" is forwarded unchanged through {} component(s) before \"{}\" consumes it; colocate, lift to context, or compose",
693            chain.prop, chain.depth, consumer_name
694        ),
695        uri: relative_uri(&path, root),
696        region: Some((line, 1)),
697        source_path: Some(path),
698        properties: None,
699    }
700}
701
702fn sarif_thin_wrapper_fields(
703    wrapper: &ThinWrapper,
704    root: &Path,
705    level: &'static str,
706) -> SarifFields {
707    SarifFields {
708        rule_id: "fallow/thin-wrapper",
709        level,
710        message: format!(
711            "\"{}\" is a thin wrapper: its whole body forwards props to \"{}\"; inline it at call sites or delete it",
712            wrapper.component, wrapper.child_component
713        ),
714        uri: relative_uri(&wrapper.file, root),
715        region: Some((wrapper.line, 1)),
716        source_path: Some(wrapper.file.clone()),
717        properties: None,
718    }
719}
720
721fn sarif_duplicate_prop_shape_fields(
722    shape: &DuplicatePropShape,
723    root: &Path,
724    level: &'static str,
725) -> SarifFields {
726    SarifFields {
727        rule_id: "fallow/duplicate-prop-shape",
728        level,
729        message: format!(
730            "\"{}\" shares an identical prop shape {{{}}} with {} other component(s); extract a shared Props type or base component",
731            shape.component,
732            shape.shape.join(", "),
733            shape.group_size.saturating_sub(1)
734        ),
735        uri: relative_uri(&shape.file, root),
736        region: Some((shape.line, 1)),
737        source_path: Some(shape.file.clone()),
738        properties: None,
739    }
740}
741
742fn sarif_route_collision_fields(
743    collision: &RouteCollision,
744    root: &Path,
745    level: &'static str,
746) -> SarifFields {
747    SarifFields {
748        rule_id: "fallow/route-collision",
749        level,
750        message: format!(
751            "Route file resolves to '{}', which is also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
752            collision.url,
753            collision.conflicting_paths.len()
754        ),
755        uri: relative_uri(&collision.path, root),
756        region: Some((collision.line, collision.col + 1)),
757        source_path: Some(collision.path.clone()),
758        properties: None,
759    }
760}
761
762fn sarif_dynamic_segment_name_conflict_fields(
763    conflict: &DynamicSegmentNameConflict,
764    root: &Path,
765    level: &'static str,
766) -> SarifFields {
767    SarifFields {
768        rule_id: "fallow/dynamic-segment-name-conflict",
769        level,
770        message: format!(
771            "Dynamic segments at '{}' use different slug names ({}); Next.js requires one consistent name per dynamic path",
772            conflict.position,
773            conflict.conflicting_segments.join(", ")
774        ),
775        uri: relative_uri(&conflict.path, root),
776        region: Some((conflict.line, conflict.col + 1)),
777        source_path: Some(conflict.path.clone()),
778        properties: None,
779    }
780}
781
782fn sarif_stale_suppression_fields(
783    suppression: &StaleSuppression,
784    root: &Path,
785    level: &'static str,
786) -> SarifFields {
787    SarifFields {
788        rule_id: "fallow/stale-suppression",
789        level,
790        message: suppression.display_message(),
791        uri: relative_uri(&suppression.path, root),
792        region: Some((suppression.line, suppression.col + 1)),
793        source_path: Some(suppression.path.clone()),
794        properties: None,
795    }
796}
797
798fn sarif_unused_catalog_entry_fields(
799    entry: &UnusedCatalogEntryFinding,
800    root: &Path,
801    level: &'static str,
802) -> SarifFields {
803    let entry = &entry.entry;
804    let message = if entry.catalog_name == "default" {
805        format!(
806            "Catalog entry '{}' is not referenced by any workspace package",
807            entry.entry_name
808        )
809    } else {
810        format!(
811            "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
812            entry.entry_name, entry.catalog_name
813        )
814    };
815    SarifFields {
816        rule_id: "fallow/unused-catalog-entry",
817        level,
818        message,
819        uri: relative_uri(&entry.path, root),
820        region: Some((entry.line, 1)),
821        source_path: Some(entry.path.clone()),
822        properties: None,
823    }
824}
825
826fn sarif_unused_dependency_override_fields(
827    finding: &UnusedDependencyOverrideFinding,
828    root: &Path,
829    level: &'static str,
830) -> SarifFields {
831    let finding = &finding.entry;
832    let mut message = format!(
833        "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
834        finding.raw_key, finding.version_range, finding.target_package,
835    );
836    if let Some(hint) = &finding.hint {
837        use std::fmt::Write as _;
838        let _ = write!(message, " ({hint})");
839    }
840    SarifFields {
841        rule_id: "fallow/unused-dependency-override",
842        level,
843        message,
844        uri: relative_uri(&finding.path, root),
845        region: Some((finding.line, 1)),
846        source_path: Some(finding.path.clone()),
847        properties: None,
848    }
849}
850
851fn sarif_misconfigured_dependency_override_fields(
852    finding: &MisconfiguredDependencyOverrideFinding,
853    root: &Path,
854    level: &'static str,
855) -> SarifFields {
856    let finding = &finding.entry;
857    let message = format!(
858        "Override `{}` -> `{}` is malformed: {}",
859        finding.raw_key,
860        finding.raw_value,
861        finding.reason.describe(),
862    );
863    SarifFields {
864        rule_id: "fallow/misconfigured-dependency-override",
865        level,
866        message,
867        uri: relative_uri(&finding.path, root),
868        region: Some((finding.line, 1)),
869        source_path: Some(finding.path.clone()),
870        properties: None,
871    }
872}
873
874fn sarif_unresolved_catalog_reference_fields(
875    finding: &UnresolvedCatalogReferenceFinding,
876    root: &Path,
877    level: &'static str,
878) -> SarifFields {
879    let finding = &finding.reference;
880    let catalog_phrase = if finding.catalog_name == "default" {
881        "the default catalog".to_string()
882    } else {
883        format!("catalog '{}'", finding.catalog_name)
884    };
885    let mut message = format!(
886        "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
887        finding.entry_name,
888        if finding.catalog_name == "default" {
889            ""
890        } else {
891            finding.catalog_name.as_str()
892        },
893        catalog_phrase,
894    );
895    if !finding.available_in_catalogs.is_empty() {
896        use std::fmt::Write as _;
897        let _ = write!(
898            message,
899            " (available in: {})",
900            finding.available_in_catalogs.join(", ")
901        );
902    }
903    SarifFields {
904        rule_id: "fallow/unresolved-catalog-reference",
905        level,
906        message,
907        uri: relative_uri(&finding.path, root),
908        region: Some((finding.line, 1)),
909        source_path: Some(finding.path.clone()),
910        properties: None,
911    }
912}
913
914fn sarif_empty_catalog_group_fields(
915    group: &EmptyCatalogGroupFinding,
916    root: &Path,
917    level: &'static str,
918) -> SarifFields {
919    let group = &group.group;
920    SarifFields {
921        rule_id: "fallow/empty-catalog-group",
922        level,
923        message: format!("Catalog group '{}' has no entries", group.catalog_name),
924        uri: relative_uri(&group.path, root),
925        region: Some((group.line, 1)),
926        source_path: Some(group.path.clone()),
927        properties: None,
928    }
929}
930
931/// Unlisted deps fan out to one SARIF result per import site, so they do not
932/// fit `push_sarif_results`. Keep the nested-loop shape in its own helper.
933fn push_sarif_unlisted_deps(
934    sarif_results: &mut Vec<serde_json::Value>,
935    deps: &[UnlistedDependencyFinding],
936    root: &Path,
937    level: &'static str,
938    snippets: &mut SourceSnippetCache,
939) {
940    for entry in deps {
941        let dep = &entry.dep;
942        for site in &dep.imported_from {
943            let uri = relative_uri(&site.path, root);
944            let source_snippet = snippets.line(&site.path, site.line);
945            sarif_results.push(sarif_result_with_snippet(
946                "fallow/unlisted-dependency",
947                level,
948                &format!(
949                    "Package '{}' is imported but not listed in package.json",
950                    dep.package_name
951                ),
952                &uri,
953                Some((site.line, site.col + 1)),
954                source_snippet.as_deref(),
955            ));
956        }
957    }
958}
959
960/// Duplicate exports fan out to one SARIF result per location
961/// (SARIF 2.1.0 section 3.27.12), so they do not fit `push_sarif_results`.
962fn push_sarif_duplicate_exports(
963    sarif_results: &mut Vec<serde_json::Value>,
964    dups: &[DuplicateExportFinding],
965    root: &Path,
966    level: &'static str,
967    snippets: &mut SourceSnippetCache,
968) {
969    for dup in dups {
970        let dup = &dup.export;
971        for loc in &dup.locations {
972            let uri = relative_uri(&loc.path, root);
973            let source_snippet = snippets.line(&loc.path, loc.line);
974            sarif_results.push(sarif_result_with_snippet(
975                "fallow/duplicate-export",
976                level,
977                &format!("Export '{}' appears in multiple modules", dup.export_name),
978                &uri,
979                Some((loc.line, loc.col + 1)),
980                source_snippet.as_deref(),
981            ));
982        }
983    }
984}
985
986/// Build the SARIF rules list from the current rules configuration.
987fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
988    let mut specs = Vec::new();
989    specs.extend(sarif_core_rule_specs(rules));
990    specs.extend(sarif_dependency_rule_specs(rules));
991    specs.extend(sarif_member_import_rule_specs(rules));
992    specs.extend(sarif_graph_rule_specs(rules));
993    specs.extend(sarif_workspace_rule_specs(rules));
994    specs
995        .into_iter()
996        .map(|(id, description, rule_severity)| {
997            sarif_rule(id, description, configured_sarif_level(rule_severity))
998        })
999        .collect()
1000}
1001
1002type SarifRuleSpec = (&'static str, &'static str, Severity);
1003
1004fn sarif_core_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1005    [
1006        (
1007            "fallow/unused-file",
1008            "File is not reachable from any entry point",
1009            rules.unused_files,
1010        ),
1011        (
1012            "fallow/unused-export",
1013            "Export is never imported",
1014            rules.unused_exports,
1015        ),
1016        (
1017            "fallow/unused-type",
1018            "Type export is never imported",
1019            rules.unused_types,
1020        ),
1021        (
1022            "fallow/private-type-leak",
1023            "Exported signature references a same-file private type",
1024            rules.private_type_leaks,
1025        ),
1026    ]
1027    .into()
1028}
1029
1030fn sarif_dependency_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1031    [
1032        (
1033            "fallow/unused-dependency",
1034            "Dependency listed but never imported",
1035            rules.unused_dependencies,
1036        ),
1037        (
1038            "fallow/unused-dev-dependency",
1039            "Dev dependency listed but never imported",
1040            rules.unused_dev_dependencies,
1041        ),
1042        (
1043            "fallow/unused-optional-dependency",
1044            "Optional dependency listed but never imported",
1045            rules.unused_optional_dependencies,
1046        ),
1047        (
1048            "fallow/type-only-dependency",
1049            "Production dependency only used via type-only imports",
1050            rules.type_only_dependencies,
1051        ),
1052        (
1053            "fallow/test-only-dependency",
1054            "Production dependency only imported by test files",
1055            rules.test_only_dependencies,
1056        ),
1057    ]
1058    .into()
1059}
1060
1061fn sarif_member_import_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1062    [
1063        (
1064            "fallow/unused-enum-member",
1065            "Enum member is never referenced",
1066            rules.unused_enum_members,
1067        ),
1068        (
1069            "fallow/unused-class-member",
1070            "Class member is never referenced",
1071            rules.unused_class_members,
1072        ),
1073        (
1074            "fallow/unused-store-member",
1075            "Store member is never referenced",
1076            rules.unused_store_members,
1077        ),
1078        (
1079            "fallow/unresolved-import",
1080            "Import could not be resolved",
1081            rules.unresolved_imports,
1082        ),
1083        (
1084            "fallow/unlisted-dependency",
1085            "Dependency used but not in package.json",
1086            rules.unlisted_dependencies,
1087        ),
1088        (
1089            "fallow/duplicate-export",
1090            "Export name appears in multiple modules",
1091            rules.duplicate_exports,
1092        ),
1093    ]
1094    .into()
1095}
1096
1097fn sarif_graph_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1098    let mut specs = sarif_cycle_rule_specs(rules);
1099    specs.extend(sarif_boundary_rule_specs(rules));
1100    specs.extend(sarif_framework_rule_specs(rules));
1101    specs.extend(sarif_component_rule_specs(rules));
1102    specs.push((
1103        "fallow/stale-suppression",
1104        "Suppression comment or tag no longer matches any issue",
1105        rules.stale_suppressions,
1106    ));
1107    specs
1108}
1109
1110fn sarif_cycle_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1111    vec![
1112        (
1113            "fallow/circular-dependency",
1114            "Circular dependency chain detected",
1115            rules.circular_dependencies,
1116        ),
1117        (
1118            "fallow/re-export-cycle",
1119            "Two or more barrel files re-export from each other in a loop",
1120            rules.re_export_cycle,
1121        ),
1122    ]
1123}
1124
1125fn sarif_boundary_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1126    vec![
1127        (
1128            "fallow/boundary-violation",
1129            "Import crosses an architecture boundary",
1130            rules.boundary_violation,
1131        ),
1132        (
1133            "fallow/boundary-coverage",
1134            "Source file matches no architecture boundary zone",
1135            rules.boundary_violation,
1136        ),
1137        (
1138            "fallow/boundary-call-violation",
1139            "Zoned file calls a callee its zone forbids",
1140            rules.boundary_violation,
1141        ),
1142        (
1143            "fallow/policy-violation",
1144            "Banned call or import matched a rule-pack rule",
1145            rules.policy_violation,
1146        ),
1147    ]
1148}
1149
1150fn sarif_framework_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1151    vec![
1152        (
1153            "fallow/invalid-client-export",
1154            "\"use client\" file exports a server-only / route-config name",
1155            rules.invalid_client_export,
1156        ),
1157        (
1158            "fallow/mixed-client-server-barrel",
1159            "Barrel re-exports both a \"use client\" module and a server-only module",
1160            rules.mixed_client_server_barrel,
1161        ),
1162        (
1163            "fallow/misplaced-directive",
1164            "\"use client\" / \"use server\" directive is not in the leading position and is ignored",
1165            rules.misplaced_directive,
1166        ),
1167    ]
1168}
1169
1170fn sarif_component_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1171    vec![
1172        (
1173            "fallow/unprovided-inject",
1174            "A Vue inject / Svelte getContext whose key is provided nowhere in the project",
1175            rules.unprovided_injects,
1176        ),
1177        (
1178            "fallow/unrendered-component",
1179            "A Vue / Svelte component reachable through a barrel but rendered nowhere in the project",
1180            rules.unrendered_components,
1181        ),
1182        (
1183            "fallow/unused-component-prop",
1184            "A Vue <script setup> defineProps prop referenced nowhere inside its own component",
1185            rules.unused_component_props,
1186        ),
1187        (
1188            "fallow/unused-component-emit",
1189            "A Vue <script setup> defineEmits event emitted nowhere inside its own component",
1190            rules.unused_component_emits,
1191        ),
1192        (
1193            "fallow/unused-server-action",
1194            "A Next.js Server Action exported from a \"use server\" file that no code in the project references",
1195            rules.unused_server_actions,
1196        ),
1197        (
1198            "fallow/unused-load-data-key",
1199            "A SvelteKit load() return-object key that no consumer reads (sibling +page.svelte data.<key> or project-wide page.data.<key>)",
1200            rules.unused_load_data_keys,
1201        ),
1202        (
1203            "fallow/prop-drilling",
1204            "A React/Preact prop forwarded unchanged through 3+ pass-through components to a distant consumer",
1205            rules.prop_drilling,
1206        ),
1207        (
1208            "fallow/thin-wrapper",
1209            "A React/Preact component whose whole body is a single spread-forwarded child render (a candidate for inlining)",
1210            rules.thin_wrapper,
1211        ),
1212        (
1213            "fallow/duplicate-prop-shape",
1214            "Three or more React/Preact components across two or more files declare an identical prop-name set (a missing shared Props type)",
1215            rules.duplicate_prop_shape,
1216        ),
1217        (
1218            "fallow/route-collision",
1219            "Two or more Next.js App Router route files resolve to the same URL",
1220            rules.route_collision,
1221        ),
1222        (
1223            "fallow/dynamic-segment-name-conflict",
1224            "Sibling Next.js dynamic route segments use different slug names at the same position",
1225            rules.dynamic_segment_name_conflict,
1226        ),
1227    ]
1228}
1229
1230fn sarif_workspace_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1231    [
1232        (
1233            "fallow/unused-catalog-entry",
1234            "pnpm catalog entry not referenced by any workspace package",
1235            rules.unused_catalog_entries,
1236        ),
1237        (
1238            "fallow/empty-catalog-group",
1239            "pnpm named catalog group has no entries",
1240            rules.empty_catalog_groups,
1241        ),
1242        (
1243            "fallow/unresolved-catalog-reference",
1244            "package.json catalog reference points at a catalog that does not declare the package",
1245            rules.unresolved_catalog_references,
1246        ),
1247        (
1248            "fallow/unused-dependency-override",
1249            "pnpm dependency override target is not declared or lockfile-resolved",
1250            rules.unused_dependency_overrides,
1251        ),
1252        (
1253            "fallow/misconfigured-dependency-override",
1254            "pnpm dependency override key or value is malformed",
1255            rules.misconfigured_dependency_overrides,
1256        ),
1257    ]
1258    .into()
1259}
1260
1261#[must_use]
1262pub fn build_sarif(
1263    results: &AnalysisResults,
1264    root: &Path,
1265    rules: &RulesConfig,
1266) -> serde_json::Value {
1267    let mut sarif_results = Vec::new();
1268    let mut snippets = SourceSnippetCache::default();
1269
1270    push_primary_dead_code_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1271    push_dependency_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1272    push_member_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1273    push_sarif_results(
1274        &mut sarif_results,
1275        &results.unresolved_imports,
1276        &mut snippets,
1277        |i| {
1278            sarif_unresolved_import_fields(
1279                &i.import,
1280                root,
1281                severity_to_sarif_level(rules.unresolved_imports),
1282            )
1283        },
1284    );
1285    push_misc_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1286    push_graph_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1287    push_catalog_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1288
1289    let sarif_rules = build_sarif_rules(rules);
1290    sarif_document(&sarif_results, &sarif_rules)
1291}
1292
1293fn push_primary_dead_code_sarif_results(
1294    sarif_results: &mut Vec<serde_json::Value>,
1295    results: &AnalysisResults,
1296    root: &Path,
1297    rules: &RulesConfig,
1298    snippets: &mut SourceSnippetCache,
1299) {
1300    push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1301        sarif_unused_file_fields(
1302            &finding.file,
1303            root,
1304            severity_to_sarif_level(rules.unused_files),
1305        )
1306    });
1307    push_sarif_results(
1308        sarif_results,
1309        &results.unused_exports,
1310        snippets,
1311        |finding| {
1312            sarif_export_fields(
1313                &finding.export,
1314                root,
1315                "fallow/unused-export",
1316                severity_to_sarif_level(rules.unused_exports),
1317                "Export",
1318                "Re-export",
1319            )
1320        },
1321    );
1322    push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1323        sarif_export_fields(
1324            &finding.export,
1325            root,
1326            "fallow/unused-type",
1327            severity_to_sarif_level(rules.unused_types),
1328            "Type export",
1329            "Type re-export",
1330        )
1331    });
1332    push_sarif_results(
1333        sarif_results,
1334        &results.private_type_leaks,
1335        snippets,
1336        |finding| {
1337            sarif_private_type_leak_fields(
1338                &finding.leak,
1339                root,
1340                severity_to_sarif_level(rules.private_type_leaks),
1341            )
1342        },
1343    );
1344}
1345
1346fn sarif_document(
1347    sarif_results: &[serde_json::Value],
1348    sarif_rules: &[serde_json::Value],
1349) -> serde_json::Value {
1350    serde_json::json!({
1351        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1352        "version": "2.1.0",
1353        "runs": [{
1354            "tool": {
1355                "driver": {
1356                    "name": "fallow",
1357                    "version": env!("CARGO_PKG_VERSION"),
1358                    "informationUri": "https://github.com/fallow-rs/fallow",
1359                    "rules": sarif_rules
1360                }
1361            },
1362            "results": sarif_results
1363        }]
1364    })
1365}
1366
1367fn push_dependency_sarif_results(
1368    sarif_results: &mut Vec<serde_json::Value>,
1369    results: &AnalysisResults,
1370    root: &Path,
1371    rules: &RulesConfig,
1372    snippets: &mut SourceSnippetCache,
1373) {
1374    push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1375        sarif_dep_fields(
1376            &d.dep,
1377            root,
1378            "fallow/unused-dependency",
1379            severity_to_sarif_level(rules.unused_dependencies),
1380            "dependencies",
1381        )
1382    });
1383    push_sarif_results(
1384        sarif_results,
1385        &results.unused_dev_dependencies,
1386        snippets,
1387        |d| {
1388            sarif_dep_fields(
1389                &d.dep,
1390                root,
1391                "fallow/unused-dev-dependency",
1392                severity_to_sarif_level(rules.unused_dev_dependencies),
1393                "devDependencies",
1394            )
1395        },
1396    );
1397    push_sarif_results(
1398        sarif_results,
1399        &results.unused_optional_dependencies,
1400        snippets,
1401        |d| {
1402            sarif_dep_fields(
1403                &d.dep,
1404                root,
1405                "fallow/unused-optional-dependency",
1406                severity_to_sarif_level(rules.unused_optional_dependencies),
1407                "optionalDependencies",
1408            )
1409        },
1410    );
1411    push_sarif_results(
1412        sarif_results,
1413        &results.type_only_dependencies,
1414        snippets,
1415        |d| {
1416            sarif_type_only_dep_fields(
1417                &d.dep,
1418                root,
1419                severity_to_sarif_level(rules.type_only_dependencies),
1420            )
1421        },
1422    );
1423    push_sarif_results(
1424        sarif_results,
1425        &results.test_only_dependencies,
1426        snippets,
1427        |d| {
1428            sarif_test_only_dep_fields(
1429                &d.dep,
1430                root,
1431                severity_to_sarif_level(rules.test_only_dependencies),
1432            )
1433        },
1434    );
1435}
1436
1437fn push_member_sarif_results(
1438    sarif_results: &mut Vec<serde_json::Value>,
1439    results: &AnalysisResults,
1440    root: &Path,
1441    rules: &RulesConfig,
1442    snippets: &mut SourceSnippetCache,
1443) {
1444    push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1445        sarif_member_fields(
1446            &m.member,
1447            root,
1448            "fallow/unused-enum-member",
1449            severity_to_sarif_level(rules.unused_enum_members),
1450            "Enum",
1451        )
1452    });
1453    push_sarif_results(
1454        sarif_results,
1455        &results.unused_class_members,
1456        snippets,
1457        |m| {
1458            sarif_member_fields(
1459                &m.member,
1460                root,
1461                "fallow/unused-class-member",
1462                severity_to_sarif_level(rules.unused_class_members),
1463                "Class",
1464            )
1465        },
1466    );
1467    push_sarif_results(
1468        sarif_results,
1469        &results.unused_store_members,
1470        snippets,
1471        |m| {
1472            sarif_member_fields(
1473                &m.member,
1474                root,
1475                "fallow/unused-store-member",
1476                severity_to_sarif_level(rules.unused_store_members),
1477                "Store",
1478            )
1479        },
1480    );
1481}
1482
1483fn push_misc_sarif_results(
1484    sarif_results: &mut Vec<serde_json::Value>,
1485    results: &AnalysisResults,
1486    root: &Path,
1487    rules: &RulesConfig,
1488    snippets: &mut SourceSnippetCache,
1489) {
1490    if !results.unlisted_dependencies.is_empty() {
1491        push_sarif_unlisted_deps(
1492            sarif_results,
1493            &results.unlisted_dependencies,
1494            root,
1495            severity_to_sarif_level(rules.unlisted_dependencies),
1496            snippets,
1497        );
1498    }
1499    if !results.duplicate_exports.is_empty() {
1500        push_sarif_duplicate_exports(
1501            sarif_results,
1502            &results.duplicate_exports,
1503            root,
1504            severity_to_sarif_level(rules.duplicate_exports),
1505            snippets,
1506        );
1507    }
1508}
1509
1510/// Push the component-contract SARIF results (`unused-component-prop` and
1511/// `unused-component-emit`). Extracted from `push_graph_sarif_results` to keep
1512/// that function under the unit-size lint.
1513fn push_component_contract_sarif_results(
1514    sarif_results: &mut Vec<serde_json::Value>,
1515    results: &AnalysisResults,
1516    root: &Path,
1517    rules: &RulesConfig,
1518    snippets: &mut SourceSnippetCache,
1519) {
1520    push_sarif_results(
1521        sarif_results,
1522        &results.unused_component_props,
1523        snippets,
1524        |p| {
1525            sarif_unused_component_prop_fields(
1526                &p.prop,
1527                root,
1528                severity_to_sarif_level(rules.unused_component_props),
1529            )
1530        },
1531    );
1532    push_sarif_results(
1533        sarif_results,
1534        &results.unused_component_emits,
1535        snippets,
1536        |e| {
1537            sarif_unused_component_emit_fields(
1538                &e.emit,
1539                root,
1540                severity_to_sarif_level(rules.unused_component_emits),
1541            )
1542        },
1543    );
1544    push_sarif_results(
1545        sarif_results,
1546        &results.unused_server_actions,
1547        snippets,
1548        |a| {
1549            sarif_unused_server_action_fields(
1550                &a.action,
1551                root,
1552                severity_to_sarif_level(rules.unused_server_actions),
1553            )
1554        },
1555    );
1556    push_sarif_results(
1557        sarif_results,
1558        &results.unused_load_data_keys,
1559        snippets,
1560        |k| {
1561            sarif_unused_load_data_key_fields(
1562                &k.key,
1563                root,
1564                severity_to_sarif_level(rules.unused_load_data_keys),
1565            )
1566        },
1567    );
1568    push_sarif_results(
1569        sarif_results,
1570        &results.prop_drilling_chains,
1571        snippets,
1572        |c| {
1573            sarif_prop_drilling_fields(&c.chain, root, severity_to_sarif_level(rules.prop_drilling))
1574        },
1575    );
1576    push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1577        sarif_thin_wrapper_fields(
1578            &w.wrapper,
1579            root,
1580            severity_to_sarif_level(rules.thin_wrapper),
1581        )
1582    });
1583    push_sarif_results(
1584        sarif_results,
1585        &results.duplicate_prop_shapes,
1586        snippets,
1587        |d| {
1588            sarif_duplicate_prop_shape_fields(
1589                &d.shape,
1590                root,
1591                severity_to_sarif_level(rules.duplicate_prop_shape),
1592            )
1593        },
1594    );
1595}
1596
1597fn push_graph_sarif_results(
1598    sarif_results: &mut Vec<serde_json::Value>,
1599    results: &AnalysisResults,
1600    root: &Path,
1601    rules: &RulesConfig,
1602    snippets: &mut SourceSnippetCache,
1603) {
1604    push_structure_sarif_results(sarif_results, results, root, rules, snippets);
1605    push_framework_sarif_results(sarif_results, results, root, rules, snippets);
1606    push_route_sarif_results(sarif_results, results, root, rules, snippets);
1607    push_suppression_sarif_results(sarif_results, results, root, rules, snippets);
1608}
1609
1610fn push_structure_sarif_results(
1611    sarif_results: &mut Vec<serde_json::Value>,
1612    results: &AnalysisResults,
1613    root: &Path,
1614    rules: &RulesConfig,
1615    snippets: &mut SourceSnippetCache,
1616) {
1617    push_sarif_results(
1618        sarif_results,
1619        &results.circular_dependencies,
1620        snippets,
1621        |c| {
1622            sarif_circular_dep_fields(
1623                &c.cycle,
1624                root,
1625                severity_to_sarif_level(rules.circular_dependencies),
1626            )
1627        },
1628    );
1629    push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1630        sarif_re_export_cycle_fields(
1631            &c.cycle,
1632            root,
1633            severity_to_sarif_level(rules.re_export_cycle),
1634        )
1635    });
1636    push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1637        sarif_boundary_violation_fields(
1638            &v.violation,
1639            root,
1640            severity_to_sarif_level(rules.boundary_violation),
1641        )
1642    });
1643    push_sarif_results(
1644        sarif_results,
1645        &results.boundary_coverage_violations,
1646        snippets,
1647        |v| {
1648            sarif_boundary_coverage_fields(
1649                &v.violation,
1650                root,
1651                severity_to_sarif_level(rules.boundary_violation),
1652            )
1653        },
1654    );
1655    push_sarif_results(
1656        sarif_results,
1657        &results.boundary_call_violations,
1658        snippets,
1659        |v| {
1660            sarif_boundary_call_fields(
1661                &v.violation,
1662                root,
1663                severity_to_sarif_level(rules.boundary_violation),
1664            )
1665        },
1666    );
1667    push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1668        sarif_policy_violation_fields(&v.violation, root)
1669    });
1670}
1671
1672fn push_framework_sarif_results(
1673    sarif_results: &mut Vec<serde_json::Value>,
1674    results: &AnalysisResults,
1675    root: &Path,
1676    rules: &RulesConfig,
1677    snippets: &mut SourceSnippetCache,
1678) {
1679    push_sarif_results(
1680        sarif_results,
1681        &results.invalid_client_exports,
1682        snippets,
1683        |e| {
1684            sarif_invalid_client_export_fields(
1685                &e.export,
1686                root,
1687                severity_to_sarif_level(rules.invalid_client_export),
1688            )
1689        },
1690    );
1691    push_sarif_results(
1692        sarif_results,
1693        &results.mixed_client_server_barrels,
1694        snippets,
1695        |b| {
1696            sarif_mixed_client_server_barrel_fields(
1697                &b.barrel,
1698                root,
1699                severity_to_sarif_level(rules.mixed_client_server_barrel),
1700            )
1701        },
1702    );
1703    push_sarif_results(
1704        sarif_results,
1705        &results.misplaced_directives,
1706        snippets,
1707        |d| {
1708            sarif_misplaced_directive_fields(
1709                &d.directive_site,
1710                root,
1711                severity_to_sarif_level(rules.misplaced_directive),
1712            )
1713        },
1714    );
1715    push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
1716        sarif_unprovided_inject_fields(
1717            &i.inject,
1718            root,
1719            severity_to_sarif_level(rules.unprovided_injects),
1720        )
1721    });
1722    push_sarif_results(
1723        sarif_results,
1724        &results.unrendered_components,
1725        snippets,
1726        |c| {
1727            sarif_unrendered_component_fields(
1728                &c.component,
1729                root,
1730                severity_to_sarif_level(rules.unrendered_components),
1731            )
1732        },
1733    );
1734    push_component_contract_sarif_results(sarif_results, results, root, rules, snippets);
1735}
1736
1737fn push_route_sarif_results(
1738    sarif_results: &mut Vec<serde_json::Value>,
1739    results: &AnalysisResults,
1740    root: &Path,
1741    rules: &RulesConfig,
1742    snippets: &mut SourceSnippetCache,
1743) {
1744    push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
1745        sarif_route_collision_fields(
1746            &c.collision,
1747            root,
1748            severity_to_sarif_level(rules.route_collision),
1749        )
1750    });
1751    push_sarif_results(
1752        sarif_results,
1753        &results.dynamic_segment_name_conflicts,
1754        snippets,
1755        |c| {
1756            sarif_dynamic_segment_name_conflict_fields(
1757                &c.conflict,
1758                root,
1759                severity_to_sarif_level(rules.dynamic_segment_name_conflict),
1760            )
1761        },
1762    );
1763}
1764
1765fn push_suppression_sarif_results(
1766    sarif_results: &mut Vec<serde_json::Value>,
1767    results: &AnalysisResults,
1768    root: &Path,
1769    rules: &RulesConfig,
1770    snippets: &mut SourceSnippetCache,
1771) {
1772    push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1773        sarif_stale_suppression_fields(s, root, severity_to_sarif_level(rules.stale_suppressions))
1774    });
1775}
1776
1777fn push_catalog_sarif_results(
1778    sarif_results: &mut Vec<serde_json::Value>,
1779    results: &AnalysisResults,
1780    root: &Path,
1781    rules: &RulesConfig,
1782    snippets: &mut SourceSnippetCache,
1783) {
1784    push_sarif_results(
1785        sarif_results,
1786        &results.unused_catalog_entries,
1787        snippets,
1788        |e| {
1789            sarif_unused_catalog_entry_fields(
1790                e,
1791                root,
1792                severity_to_sarif_level(rules.unused_catalog_entries),
1793            )
1794        },
1795    );
1796    push_sarif_results(
1797        sarif_results,
1798        &results.empty_catalog_groups,
1799        snippets,
1800        |g| {
1801            sarif_empty_catalog_group_fields(
1802                g,
1803                root,
1804                severity_to_sarif_level(rules.empty_catalog_groups),
1805            )
1806        },
1807    );
1808    push_sarif_results(
1809        sarif_results,
1810        &results.unresolved_catalog_references,
1811        snippets,
1812        |f| {
1813            sarif_unresolved_catalog_reference_fields(
1814                f,
1815                root,
1816                severity_to_sarif_level(rules.unresolved_catalog_references),
1817            )
1818        },
1819    );
1820    push_sarif_results(
1821        sarif_results,
1822        &results.unused_dependency_overrides,
1823        snippets,
1824        |f| {
1825            sarif_unused_dependency_override_fields(
1826                f,
1827                root,
1828                severity_to_sarif_level(rules.unused_dependency_overrides),
1829            )
1830        },
1831    );
1832    push_sarif_results(
1833        sarif_results,
1834        &results.misconfigured_dependency_overrides,
1835        snippets,
1836        |f| {
1837            sarif_misconfigured_dependency_override_fields(
1838                f,
1839                root,
1840                severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1841            )
1842        },
1843    );
1844}
1845
1846pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
1847    let sarif = build_sarif(results, root, rules);
1848    emit_json(&sarif, "SARIF")
1849}
1850
1851/// Print SARIF output with owner properties added to each result.
1852///
1853/// Calls `build_sarif` to produce the standard SARIF JSON, then post-processes
1854/// each result to add `"properties": { "owner": "@team" }` by resolving the
1855/// artifact location URI through the `OwnershipResolver`.
1856#[expect(
1857    clippy::expect_used,
1858    reason = "grouped SARIF entries are JSON objects created by build_sarif"
1859)]
1860pub(super) fn print_grouped_sarif(
1861    results: &AnalysisResults,
1862    root: &Path,
1863    rules: &RulesConfig,
1864    resolver: &OwnershipResolver,
1865) -> ExitCode {
1866    let mut sarif = build_sarif(results, root, rules);
1867
1868    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1869        for run in runs {
1870            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1871                for result in results {
1872                    let uri = result
1873                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1874                        .and_then(|v| v.as_str())
1875                        .unwrap_or("");
1876                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1877                    let owner =
1878                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1879                    let props = result
1880                        .as_object_mut()
1881                        .expect("SARIF result should be an object")
1882                        .entry("properties")
1883                        .or_insert_with(|| serde_json::json!({}));
1884                    props
1885                        .as_object_mut()
1886                        .expect("properties should be an object")
1887                        .insert("owner".to_string(), serde_json::Value::String(owner));
1888                }
1889            }
1890        }
1891    }
1892
1893    emit_json(&sarif, "SARIF")
1894}
1895
1896#[expect(
1897    clippy::cast_possible_truncation,
1898    reason = "line/col numbers are bounded by source size"
1899)]
1900pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
1901    let mut sarif_results = Vec::new();
1902    let mut snippets = SourceSnippetCache::default();
1903
1904    for (i, group) in report.clone_groups.iter().enumerate() {
1905        for instance in &group.instances {
1906            let uri = relative_uri(&instance.file, root);
1907            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1908            sarif_results.push(sarif_result_with_snippet(
1909                "fallow/code-duplication",
1910                "warning",
1911                &format!(
1912                    "Code clone group {} ({} lines, {} instances)",
1913                    i + 1,
1914                    group.line_count,
1915                    group.instances.len()
1916                ),
1917                &uri,
1918                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1919                source_snippet.as_deref(),
1920            ));
1921        }
1922    }
1923
1924    let sarif = serde_json::json!({
1925        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1926        "version": "2.1.0",
1927        "runs": [{
1928            "tool": {
1929                "driver": {
1930                    "name": "fallow",
1931                    "version": env!("CARGO_PKG_VERSION"),
1932                    "informationUri": "https://github.com/fallow-rs/fallow",
1933                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1934                }
1935            },
1936            "results": sarif_results
1937        }]
1938    });
1939
1940    emit_json(&sarif, "SARIF")
1941}
1942
1943/// Print SARIF duplication output with a `properties.group` tag on every
1944/// result.
1945///
1946/// Each clone group is attributed to its largest owner (most instances; ties
1947/// broken alphabetically) via [`super::dupes_grouping::largest_owner`], and
1948/// every result emitted for that group's instances carries the same
1949/// `properties.group` value. This mirrors the health SARIF convention
1950/// (`print_grouped_health_sarif`) so consumers (GitHub Code Scanning, GitLab
1951/// Code Quality) can partition findings per team / package / directory
1952/// without re-resolving ownership.
1953#[expect(
1954    clippy::cast_possible_truncation,
1955    reason = "line/col numbers are bounded by source size"
1956)]
1957#[expect(
1958    clippy::expect_used,
1959    reason = "duplication SARIF entries are JSON objects created by sarif_result_with_snippet"
1960)]
1961pub(super) fn print_grouped_duplication_sarif(
1962    report: &DuplicationReport,
1963    root: &Path,
1964    resolver: &OwnershipResolver,
1965) -> ExitCode {
1966    let mut sarif_results = Vec::new();
1967    let mut snippets = SourceSnippetCache::default();
1968
1969    for (i, group) in report.clone_groups.iter().enumerate() {
1970        let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
1971        for instance in &group.instances {
1972            let uri = relative_uri(&instance.file, root);
1973            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1974            let mut result = sarif_result_with_snippet(
1975                "fallow/code-duplication",
1976                "warning",
1977                &format!(
1978                    "Code clone group {} ({} lines, {} instances)",
1979                    i + 1,
1980                    group.line_count,
1981                    group.instances.len()
1982                ),
1983                &uri,
1984                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1985                source_snippet.as_deref(),
1986            );
1987            let props = result
1988                .as_object_mut()
1989                .expect("SARIF result should be an object")
1990                .entry("properties")
1991                .or_insert_with(|| serde_json::json!({}));
1992            props
1993                .as_object_mut()
1994                .expect("properties should be an object")
1995                .insert(
1996                    "group".to_string(),
1997                    serde_json::Value::String(primary_owner.clone()),
1998                );
1999            sarif_results.push(result);
2000        }
2001    }
2002
2003    let sarif = serde_json::json!({
2004        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2005        "version": "2.1.0",
2006        "runs": [{
2007            "tool": {
2008                "driver": {
2009                    "name": "fallow",
2010                    "version": env!("CARGO_PKG_VERSION"),
2011                    "informationUri": "https://github.com/fallow-rs/fallow",
2012                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2013                }
2014            },
2015            "results": sarif_results
2016        }]
2017    });
2018
2019    emit_json(&sarif, "SARIF")
2020}
2021
2022#[must_use]
2023pub fn build_health_sarif(
2024    report: &crate::health_types::HealthReport,
2025    root: &Path,
2026) -> serde_json::Value {
2027    let mut sarif_results = Vec::new();
2028    let mut snippets = SourceSnippetCache::default();
2029
2030    append_health_sarif_results(report, root, &mut sarif_results, &mut snippets);
2031    let health_rules = health_sarif_rules();
2032    health_sarif_document(&sarif_results, &health_rules)
2033}
2034
2035fn append_health_sarif_results(
2036    report: &crate::health_types::HealthReport,
2037    root: &Path,
2038    sarif_results: &mut Vec<serde_json::Value>,
2039    snippets: &mut SourceSnippetCache,
2040) {
2041    append_complexity_sarif_results(sarif_results, report, root, snippets);
2042
2043    if let Some(ref production) = report.runtime_coverage {
2044        append_runtime_coverage_sarif_results(sarif_results, production, root, snippets);
2045    }
2046    if let Some(ref intelligence) = report.coverage_intelligence {
2047        append_coverage_intelligence_sarif_results(sarif_results, intelligence, root, snippets);
2048    }
2049
2050    append_refactoring_target_sarif_results(sarif_results, report, root);
2051    append_coverage_gap_sarif_results(sarif_results, report, root, snippets);
2052}
2053
2054fn health_sarif_rules() -> Vec<serde_json::Value> {
2055    let mut rules = health_complexity_sarif_rules();
2056    rules.extend(health_runtime_sarif_rules());
2057    rules.extend(health_coverage_intelligence_sarif_rules());
2058    rules
2059}
2060
2061fn health_complexity_sarif_rules() -> Vec<serde_json::Value> {
2062    vec![
2063        sarif_rule(
2064            "fallow/high-cyclomatic-complexity",
2065            "Function has high cyclomatic complexity",
2066            "note",
2067        ),
2068        sarif_rule(
2069            "fallow/high-cognitive-complexity",
2070            "Function has high cognitive complexity",
2071            "note",
2072        ),
2073        sarif_rule(
2074            "fallow/high-complexity",
2075            "Function exceeds both complexity thresholds",
2076            "note",
2077        ),
2078        sarif_rule(
2079            "fallow/high-crap-score",
2080            "Function has a high CRAP score (high complexity combined with low coverage)",
2081            "warning",
2082        ),
2083        sarif_rule(
2084            "fallow/refactoring-target",
2085            "File identified as a high-priority refactoring candidate",
2086            "warning",
2087        ),
2088    ]
2089}
2090
2091fn health_runtime_sarif_rules() -> Vec<serde_json::Value> {
2092    vec![
2093        sarif_rule(
2094            "fallow/untested-file",
2095            "Runtime-reachable file has no test dependency path",
2096            "warning",
2097        ),
2098        sarif_rule(
2099            "fallow/untested-export",
2100            "Runtime-reachable export has no test dependency path",
2101            "warning",
2102        ),
2103        sarif_rule(
2104            "fallow/runtime-safe-to-delete",
2105            "Function is statically unused and was never invoked in production",
2106            "warning",
2107        ),
2108        sarif_rule(
2109            "fallow/runtime-review-required",
2110            "Function is statically used but was never invoked in production",
2111            "warning",
2112        ),
2113        sarif_rule(
2114            "fallow/runtime-low-traffic",
2115            "Function was invoked below the low-traffic threshold relative to total trace count",
2116            "note",
2117        ),
2118        sarif_rule(
2119            "fallow/runtime-coverage-unavailable",
2120            "Runtime coverage could not be resolved for this function",
2121            "note",
2122        ),
2123        sarif_rule(
2124            "fallow/runtime-coverage",
2125            "Runtime coverage finding",
2126            "note",
2127        ),
2128    ]
2129}
2130
2131fn health_coverage_intelligence_sarif_rules() -> Vec<serde_json::Value> {
2132    vec![
2133        sarif_rule(
2134            "fallow/coverage-intelligence-risky-change",
2135            "Changed hot path combines high CRAP and low test coverage",
2136            "warning",
2137        ),
2138        sarif_rule(
2139            "fallow/coverage-intelligence-delete",
2140            "Static and runtime evidence indicate code can be deleted",
2141            "warning",
2142        ),
2143        sarif_rule(
2144            "fallow/coverage-intelligence-review",
2145            "Cold reachable uncovered code needs owner review",
2146            "warning",
2147        ),
2148        sarif_rule(
2149            "fallow/coverage-intelligence-refactor",
2150            "Hot covered code has high CRAP and should be refactored carefully",
2151            "warning",
2152        ),
2153    ]
2154}
2155
2156fn health_sarif_document(
2157    sarif_results: &[serde_json::Value],
2158    health_rules: &[serde_json::Value],
2159) -> serde_json::Value {
2160    serde_json::json!({
2161        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2162        "version": "2.1.0",
2163        "runs": [{
2164            "tool": {
2165                "driver": {
2166                    "name": "fallow",
2167                    "version": env!("CARGO_PKG_VERSION"),
2168                    "informationUri": "https://github.com/fallow-rs/fallow",
2169                    "rules": health_rules
2170                }
2171            },
2172            "results": sarif_results
2173        }]
2174    })
2175}
2176
2177fn append_complexity_sarif_results(
2178    sarif_results: &mut Vec<serde_json::Value>,
2179    report: &crate::health_types::HealthReport,
2180    root: &Path,
2181    snippets: &mut SourceSnippetCache,
2182) {
2183    for finding in &report.findings {
2184        let uri = relative_uri(&finding.path, root);
2185        let (rule_id, message) = health_complexity_sarif_message(finding, report);
2186        let level = match finding.severity {
2187            crate::health_types::FindingSeverity::Critical => "error",
2188            crate::health_types::FindingSeverity::High => "warning",
2189            crate::health_types::FindingSeverity::Moderate => "note",
2190        };
2191        let source_snippet = snippets.line(&finding.path, finding.line);
2192        sarif_results.push(sarif_result_with_snippet(
2193            rule_id,
2194            level,
2195            &message,
2196            &uri,
2197            Some((finding.line, finding.col + 1)),
2198            source_snippet.as_deref(),
2199        ));
2200    }
2201}
2202
2203fn health_complexity_sarif_message(
2204    finding: &crate::health_types::ComplexityViolation,
2205    report: &crate::health_types::HealthReport,
2206) -> (&'static str, String) {
2207    match finding.exceeded {
2208        crate::health_types::ExceededThreshold::Cyclomatic => (
2209            "fallow/high-cyclomatic-complexity",
2210            format!(
2211                "'{}' has cyclomatic complexity {} (threshold: {})",
2212                finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
2213            ),
2214        ),
2215        crate::health_types::ExceededThreshold::Cognitive => (
2216            "fallow/high-cognitive-complexity",
2217            format!(
2218                "'{}' has cognitive complexity {} (threshold: {})",
2219                finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
2220            ),
2221        ),
2222        crate::health_types::ExceededThreshold::Both => (
2223            "fallow/high-complexity",
2224            format!(
2225                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
2226                finding.name,
2227                finding.cyclomatic,
2228                report.summary.max_cyclomatic_threshold,
2229                finding.cognitive,
2230                report.summary.max_cognitive_threshold,
2231            ),
2232        ),
2233        crate::health_types::ExceededThreshold::Crap
2234        | crate::health_types::ExceededThreshold::CyclomaticCrap
2235        | crate::health_types::ExceededThreshold::CognitiveCrap
2236        | crate::health_types::ExceededThreshold::All => {
2237            let crap = finding.crap.unwrap_or(0.0);
2238            let coverage = finding
2239                .coverage_pct
2240                .map(|pct| format!(", coverage {pct:.0}%"))
2241                .unwrap_or_default();
2242            (
2243                "fallow/high-crap-score",
2244                format!(
2245                    "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
2246                    finding.name,
2247                    crap,
2248                    report.summary.max_crap_threshold,
2249                    finding.cyclomatic,
2250                    coverage,
2251                ),
2252            )
2253        }
2254    }
2255}
2256
2257fn append_refactoring_target_sarif_results(
2258    sarif_results: &mut Vec<serde_json::Value>,
2259    report: &crate::health_types::HealthReport,
2260    root: &Path,
2261) {
2262    for target in &report.targets {
2263        let uri = relative_uri(&target.path, root);
2264        let message = format!(
2265            "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
2266            target.category.label(),
2267            target.recommendation,
2268            target.priority,
2269            target.efficiency,
2270            target.effort.label(),
2271            target.confidence.label(),
2272        );
2273        sarif_results.push(sarif_result(
2274            "fallow/refactoring-target",
2275            "warning",
2276            &message,
2277            &uri,
2278            None,
2279        ));
2280    }
2281}
2282
2283fn append_coverage_gap_sarif_results(
2284    sarif_results: &mut Vec<serde_json::Value>,
2285    report: &crate::health_types::HealthReport,
2286    root: &Path,
2287    snippets: &mut SourceSnippetCache,
2288) {
2289    let Some(ref gaps) = report.coverage_gaps else {
2290        return;
2291    };
2292    for item in &gaps.files {
2293        let uri = relative_uri(&item.file.path, root);
2294        let message = format!(
2295            "File is runtime-reachable but has no test dependency path ({} value export{})",
2296            item.file.value_export_count,
2297            if item.file.value_export_count == 1 {
2298                ""
2299            } else {
2300                "s"
2301            },
2302        );
2303        sarif_results.push(sarif_result(
2304            "fallow/untested-file",
2305            "warning",
2306            &message,
2307            &uri,
2308            None,
2309        ));
2310    }
2311
2312    for item in &gaps.exports {
2313        let uri = relative_uri(&item.export.path, root);
2314        let message = format!(
2315            "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
2316            item.export.export_name
2317        );
2318        let source_snippet = snippets.line(&item.export.path, item.export.line);
2319        sarif_results.push(sarif_result_with_snippet(
2320            "fallow/untested-export",
2321            "warning",
2322            &message,
2323            &uri,
2324            Some((item.export.line, item.export.col + 1)),
2325            source_snippet.as_deref(),
2326        ));
2327    }
2328}
2329
2330fn append_runtime_coverage_sarif_results(
2331    sarif_results: &mut Vec<serde_json::Value>,
2332    production: &crate::health_types::RuntimeCoverageReport,
2333    root: &Path,
2334    snippets: &mut SourceSnippetCache,
2335) {
2336    for finding in &production.findings {
2337        let uri = relative_uri(&finding.path, root);
2338        let rule_id = match finding.verdict {
2339            crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
2340                "fallow/runtime-safe-to-delete"
2341            }
2342            crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
2343                "fallow/runtime-review-required"
2344            }
2345            crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
2346            crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
2347                "fallow/runtime-coverage-unavailable"
2348            }
2349            crate::health_types::RuntimeCoverageVerdict::Active
2350            | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
2351        };
2352        let level = match finding.verdict {
2353            crate::health_types::RuntimeCoverageVerdict::SafeToDelete
2354            | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
2355            _ => "note",
2356        };
2357        let invocations_hint = finding.invocations.map_or_else(
2358            || "untracked".to_owned(),
2359            |hits| format!("{hits} invocations"),
2360        );
2361        let message = format!(
2362            "'{}' runtime coverage verdict: {} ({})",
2363            finding.function,
2364            finding.verdict.human_label(),
2365            invocations_hint,
2366        );
2367        let source_snippet = snippets.line(&finding.path, finding.line);
2368        sarif_results.push(sarif_result_with_snippet(
2369            rule_id,
2370            level,
2371            &message,
2372            &uri,
2373            Some((finding.line, 1)),
2374            source_snippet.as_deref(),
2375        ));
2376    }
2377}
2378
2379fn append_coverage_intelligence_sarif_results(
2380    sarif_results: &mut Vec<serde_json::Value>,
2381    intelligence: &crate::health_types::CoverageIntelligenceReport,
2382    root: &Path,
2383    snippets: &mut SourceSnippetCache,
2384) {
2385    for finding in &intelligence.findings {
2386        let rule_id = coverage_intelligence_rule_id(finding.recommendation);
2387        let level = match finding.verdict {
2388            crate::health_types::CoverageIntelligenceVerdict::Clean
2389            | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
2390            _ => "warning",
2391        };
2392        let uri = relative_uri(&finding.path, root);
2393        let identity = finding.identity.as_deref().unwrap_or("code");
2394        let signals = finding
2395            .signals
2396            .iter()
2397            .map(ToString::to_string)
2398            .collect::<Vec<_>>()
2399            .join(", ");
2400        let message = format!(
2401            "'{}' coverage intelligence verdict: {} ({}, signals: {})",
2402            identity, finding.verdict, finding.recommendation, signals,
2403        );
2404        let source_snippet = snippets.line(&finding.path, finding.line);
2405        let mut result = sarif_result_with_snippet(
2406            rule_id,
2407            level,
2408            &message,
2409            &uri,
2410            Some((finding.line, 1)),
2411            source_snippet.as_deref(),
2412        );
2413        result["properties"] = serde_json::json!({
2414            "coverage_intelligence_id": &finding.id,
2415            "verdict": finding.verdict,
2416            "recommendation": finding.recommendation,
2417            "confidence": finding.confidence,
2418            "signals": &finding.signals,
2419            "related_ids": &finding.related_ids,
2420        });
2421        sarif_results.push(result);
2422    }
2423}
2424
2425fn coverage_intelligence_rule_id(
2426    recommendation: crate::health_types::CoverageIntelligenceRecommendation,
2427) -> &'static str {
2428    match recommendation {
2429        crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
2430            "fallow/coverage-intelligence-risky-change"
2431        }
2432        crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
2433            "fallow/coverage-intelligence-delete"
2434        }
2435        crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
2436            "fallow/coverage-intelligence-review"
2437        }
2438        crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
2439            "fallow/coverage-intelligence-refactor"
2440        }
2441    }
2442}
2443
2444pub(super) fn print_health_sarif(
2445    report: &crate::health_types::HealthReport,
2446    root: &Path,
2447) -> ExitCode {
2448    let sarif = build_health_sarif(report, root);
2449    emit_json(&sarif, "SARIF")
2450}
2451
2452/// Print health SARIF with a per-result `properties.group` tag.
2453///
2454/// Mirrors the dead-code grouped SARIF pattern (`print_grouped_sarif`):
2455/// build the standard SARIF first, then post-process each result to inject
2456/// the resolver-derived group key on `properties.group`. Consumers that read
2457/// SARIF (GitHub Code Scanning, GitLab Code Quality) can then partition
2458/// findings per team / package / directory without dropping out of the
2459/// SARIF pipeline. Each finding's URI is decoded (`%5B` -> `[`, `%5D` -> `]`)
2460/// before resolution, matching the dead-code behaviour for paths containing
2461/// brackets like Next.js dynamic routes.
2462#[expect(
2463    clippy::expect_used,
2464    reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
2465)]
2466pub(super) fn print_grouped_health_sarif(
2467    report: &crate::health_types::HealthReport,
2468    root: &Path,
2469    resolver: &OwnershipResolver,
2470) -> ExitCode {
2471    let mut sarif = build_health_sarif(report, root);
2472
2473    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
2474        for run in runs {
2475            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
2476                for result in results {
2477                    let uri = result
2478                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
2479                        .and_then(|v| v.as_str())
2480                        .unwrap_or("");
2481                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
2482                    let group =
2483                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
2484                    let props = result
2485                        .as_object_mut()
2486                        .expect("SARIF result should be an object")
2487                        .entry("properties")
2488                        .or_insert_with(|| serde_json::json!({}));
2489                    props
2490                        .as_object_mut()
2491                        .expect("properties should be an object")
2492                        .insert("group".to_string(), serde_json::Value::String(group));
2493                }
2494            }
2495        }
2496    }
2497
2498    emit_json(&sarif, "SARIF")
2499}
2500
2501#[cfg(test)]
2502mod tests {
2503    use super::*;
2504    use crate::report::test_helpers::sample_results;
2505    use fallow_core::results::*;
2506    use std::path::PathBuf;
2507
2508    #[test]
2509    fn sarif_has_required_top_level_fields() {
2510        let root = PathBuf::from("/project");
2511        let results = AnalysisResults::default();
2512        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2513
2514        assert_eq!(
2515            sarif["$schema"],
2516            "https://json.schemastore.org/sarif-2.1.0.json"
2517        );
2518        assert_eq!(sarif["version"], "2.1.0");
2519        assert!(sarif["runs"].is_array());
2520    }
2521
2522    #[test]
2523    fn sarif_has_tool_driver_info() {
2524        let root = PathBuf::from("/project");
2525        let results = AnalysisResults::default();
2526        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2527
2528        let driver = &sarif["runs"][0]["tool"]["driver"];
2529        assert_eq!(driver["name"], "fallow");
2530        assert!(driver["version"].is_string());
2531        assert_eq!(
2532            driver["informationUri"],
2533            "https://github.com/fallow-rs/fallow"
2534        );
2535    }
2536
2537    #[test]
2538    fn sarif_declares_all_rules() {
2539        let root = PathBuf::from("/project");
2540        let results = AnalysisResults::default();
2541        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2542
2543        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2544            .as_array()
2545            .expect("rules should be an array");
2546        assert_eq!(rules.len(), 41);
2547
2548        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
2549        assert!(rule_ids.contains(&"fallow/duplicate-prop-shape"));
2550        assert!(rule_ids.contains(&"fallow/thin-wrapper"));
2551        assert!(rule_ids.contains(&"fallow/unrendered-component"));
2552        assert!(rule_ids.contains(&"fallow/unused-component-prop"));
2553        assert!(rule_ids.contains(&"fallow/unused-component-emit"));
2554        assert!(rule_ids.contains(&"fallow/unused-server-action"));
2555        assert!(rule_ids.contains(&"fallow/unused-load-data-key"));
2556        assert!(rule_ids.contains(&"fallow/prop-drilling"));
2557        assert!(rule_ids.contains(&"fallow/route-collision"));
2558        assert!(rule_ids.contains(&"fallow/dynamic-segment-name-conflict"));
2559        assert!(rule_ids.contains(&"fallow/unused-file"));
2560        assert!(rule_ids.contains(&"fallow/unused-export"));
2561        assert!(rule_ids.contains(&"fallow/unused-type"));
2562        assert!(rule_ids.contains(&"fallow/private-type-leak"));
2563        assert!(rule_ids.contains(&"fallow/unused-dependency"));
2564        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2565        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2566        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2567        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2568        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2569        assert!(rule_ids.contains(&"fallow/unused-class-member"));
2570        assert!(rule_ids.contains(&"fallow/unused-store-member"));
2571        assert!(rule_ids.contains(&"fallow/unresolved-import"));
2572        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2573        assert!(rule_ids.contains(&"fallow/duplicate-export"));
2574        assert!(rule_ids.contains(&"fallow/circular-dependency"));
2575        assert!(rule_ids.contains(&"fallow/re-export-cycle"));
2576        assert!(rule_ids.contains(&"fallow/boundary-violation"));
2577        assert!(rule_ids.contains(&"fallow/boundary-coverage"));
2578        assert!(rule_ids.contains(&"fallow/boundary-call-violation"));
2579        assert!(rule_ids.contains(&"fallow/policy-violation"));
2580        assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
2581        assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
2582        assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
2583        assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
2584        assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
2585        assert!(rule_ids.contains(&"fallow/invalid-client-export"));
2586        assert!(rule_ids.contains(&"fallow/mixed-client-server-barrel"));
2587        assert!(rule_ids.contains(&"fallow/misplaced-directive"));
2588        assert!(rule_ids.contains(&"fallow/unprovided-inject"));
2589    }
2590
2591    #[test]
2592    fn sarif_empty_results_no_results_entries() {
2593        let root = PathBuf::from("/project");
2594        let results = AnalysisResults::default();
2595        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2596
2597        let sarif_results = sarif["runs"][0]["results"]
2598            .as_array()
2599            .expect("results should be an array");
2600        assert!(sarif_results.is_empty());
2601    }
2602
2603    #[test]
2604    fn sarif_unused_file_result() {
2605        let root = PathBuf::from("/project");
2606        let mut results = AnalysisResults::default();
2607        results
2608            .unused_files
2609            .push(UnusedFileFinding::with_actions(UnusedFile {
2610                path: root.join("src/dead.ts"),
2611            }));
2612
2613        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2614        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2615        assert_eq!(entries.len(), 1);
2616
2617        let entry = &entries[0];
2618        assert_eq!(entry["ruleId"], "fallow/unused-file");
2619        assert_eq!(entry["level"], "error");
2620        assert_eq!(
2621            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2622            "src/dead.ts"
2623        );
2624    }
2625
2626    #[test]
2627    fn sarif_unused_export_includes_region() {
2628        let root = PathBuf::from("/project");
2629        let mut results = AnalysisResults::default();
2630        results
2631            .unused_exports
2632            .push(UnusedExportFinding::with_actions(UnusedExport {
2633                path: root.join("src/utils.ts"),
2634                export_name: "helperFn".to_string(),
2635                is_type_only: false,
2636                line: 10,
2637                col: 4,
2638                span_start: 120,
2639                is_re_export: false,
2640            }));
2641
2642        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2643        let entry = &sarif["runs"][0]["results"][0];
2644        assert_eq!(entry["ruleId"], "fallow/unused-export");
2645
2646        let region = &entry["locations"][0]["physicalLocation"]["region"];
2647        assert_eq!(region["startLine"], 10);
2648        assert_eq!(region["startColumn"], 5);
2649    }
2650
2651    #[test]
2652    fn sarif_unresolved_import_is_error_level() {
2653        let root = PathBuf::from("/project");
2654        let mut results = AnalysisResults::default();
2655        results
2656            .unresolved_imports
2657            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2658                path: root.join("src/app.ts"),
2659                specifier: "./missing".to_string(),
2660                line: 1,
2661                col: 0,
2662                specifier_col: 0,
2663            }));
2664
2665        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2666        let entry = &sarif["runs"][0]["results"][0];
2667        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
2668        assert_eq!(entry["level"], "error");
2669    }
2670
2671    #[test]
2672    fn sarif_unlisted_dependency_points_to_import_site() {
2673        let root = PathBuf::from("/project");
2674        let mut results = AnalysisResults::default();
2675        results
2676            .unlisted_dependencies
2677            .push(UnlistedDependencyFinding::with_actions(
2678                UnlistedDependency {
2679                    package_name: "chalk".to_string(),
2680                    imported_from: vec![ImportSite {
2681                        path: root.join("src/cli.ts"),
2682                        line: 3,
2683                        col: 0,
2684                    }],
2685                },
2686            ));
2687
2688        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2689        let entry = &sarif["runs"][0]["results"][0];
2690        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
2691        assert_eq!(entry["level"], "error");
2692        assert_eq!(
2693            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2694            "src/cli.ts"
2695        );
2696        let region = &entry["locations"][0]["physicalLocation"]["region"];
2697        assert_eq!(region["startLine"], 3);
2698        assert_eq!(region["startColumn"], 1);
2699    }
2700
2701    #[test]
2702    fn sarif_dependency_issues_point_to_package_json() {
2703        let root = PathBuf::from("/project");
2704        let mut results = AnalysisResults::default();
2705        results
2706            .unused_dependencies
2707            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2708                package_name: "lodash".to_string(),
2709                location: DependencyLocation::Dependencies,
2710                path: root.join("package.json"),
2711                line: 5,
2712                used_in_workspaces: Vec::new(),
2713            }));
2714        results
2715            .unused_dev_dependencies
2716            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2717                package_name: "jest".to_string(),
2718                location: DependencyLocation::DevDependencies,
2719                path: root.join("package.json"),
2720                line: 5,
2721                used_in_workspaces: Vec::new(),
2722            }));
2723
2724        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2725        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2726        for entry in entries {
2727            assert_eq!(
2728                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2729                "package.json"
2730            );
2731        }
2732    }
2733
2734    #[test]
2735    fn sarif_duplicate_export_emits_one_result_per_location() {
2736        let root = PathBuf::from("/project");
2737        let mut results = AnalysisResults::default();
2738        results
2739            .duplicate_exports
2740            .push(DuplicateExportFinding::with_actions(DuplicateExport {
2741                export_name: "Config".to_string(),
2742                locations: vec![
2743                    DuplicateLocation {
2744                        path: root.join("src/a.ts"),
2745                        line: 15,
2746                        col: 0,
2747                    },
2748                    DuplicateLocation {
2749                        path: root.join("src/b.ts"),
2750                        line: 30,
2751                        col: 0,
2752                    },
2753                ],
2754            }));
2755
2756        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2757        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2758        assert_eq!(entries.len(), 2);
2759        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
2760        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
2761        assert_eq!(
2762            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2763            "src/a.ts"
2764        );
2765        assert_eq!(
2766            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2767            "src/b.ts"
2768        );
2769    }
2770
2771    #[test]
2772    fn sarif_all_issue_types_produce_results() {
2773        let root = PathBuf::from("/project");
2774        let results = sample_results(&root);
2775        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2776
2777        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2778        assert_eq!(entries.len(), results.total_issues() + 1);
2779
2780        let rule_ids: Vec<&str> = entries
2781            .iter()
2782            .map(|e| e["ruleId"].as_str().unwrap())
2783            .collect();
2784        assert!(rule_ids.contains(&"fallow/unused-file"));
2785        assert!(rule_ids.contains(&"fallow/unused-export"));
2786        assert!(rule_ids.contains(&"fallow/unused-type"));
2787        assert!(rule_ids.contains(&"fallow/unused-dependency"));
2788        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2789        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2790        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2791        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2792        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2793        assert!(rule_ids.contains(&"fallow/unused-class-member"));
2794        assert!(rule_ids.contains(&"fallow/unused-store-member"));
2795        assert!(rule_ids.contains(&"fallow/unresolved-import"));
2796        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2797        assert!(rule_ids.contains(&"fallow/duplicate-export"));
2798        assert!(rule_ids.contains(&"fallow/unprovided-inject"));
2799    }
2800
2801    #[test]
2802    fn sarif_serializes_to_valid_json() {
2803        let root = PathBuf::from("/project");
2804        let results = sample_results(&root);
2805        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2806
2807        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2808        let reparsed: serde_json::Value =
2809            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
2810        assert_eq!(reparsed, sarif);
2811    }
2812
2813    #[test]
2814    fn sarif_file_write_produces_valid_sarif() {
2815        let root = PathBuf::from("/project");
2816        let results = sample_results(&root);
2817        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2818        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2819
2820        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
2821        let _ = std::fs::create_dir_all(&dir);
2822        let sarif_path = dir.join("results.sarif");
2823        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
2824
2825        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
2826        let parsed: serde_json::Value =
2827            serde_json::from_str(&contents).expect("file should contain valid JSON");
2828
2829        assert_eq!(parsed["version"], "2.1.0");
2830        assert_eq!(
2831            parsed["$schema"],
2832            "https://json.schemastore.org/sarif-2.1.0.json"
2833        );
2834        let sarif_results = parsed["runs"][0]["results"]
2835            .as_array()
2836            .expect("results should be an array");
2837        assert!(!sarif_results.is_empty());
2838
2839        let _ = std::fs::remove_file(&sarif_path);
2840        let _ = std::fs::remove_dir(&dir);
2841    }
2842
2843    #[test]
2844    fn health_sarif_empty_no_results() {
2845        let root = PathBuf::from("/project");
2846        let report = crate::health_types::HealthReport {
2847            summary: crate::health_types::HealthSummary {
2848                files_analyzed: 10,
2849                functions_analyzed: 50,
2850                ..Default::default()
2851            },
2852            ..Default::default()
2853        };
2854        let sarif = build_health_sarif(&report, &root);
2855        assert_eq!(sarif["version"], "2.1.0");
2856        let results = sarif["runs"][0]["results"].as_array().unwrap();
2857        assert!(results.is_empty());
2858        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2859            .as_array()
2860            .unwrap();
2861        assert_eq!(rules.len(), 16);
2862    }
2863
2864    #[test]
2865    fn health_sarif_coverage_intelligence_preserves_structured_properties() {
2866        use crate::health_types::{
2867            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2868            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2869            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2870            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2871            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2872            HealthReport, HealthSummary,
2873        };
2874
2875        let root = PathBuf::from("/project");
2876        let report = HealthReport {
2877            summary: HealthSummary {
2878                files_analyzed: 10,
2879                functions_analyzed: 50,
2880                ..Default::default()
2881            },
2882            coverage_intelligence: Some(CoverageIntelligenceReport {
2883                schema_version: CoverageIntelligenceSchemaVersion::V1,
2884                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2885                summary: CoverageIntelligenceSummary {
2886                    findings: 1,
2887                    high_confidence_deletes: 1,
2888                    ..Default::default()
2889                },
2890                findings: vec![CoverageIntelligenceFinding {
2891                    id: "fallow:coverage-intel:abc123".to_owned(),
2892                    path: root.join("src/dead.ts"),
2893                    identity: Some("deadPath".to_owned()),
2894                    line: 9,
2895                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2896                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2897                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2898                    confidence: CoverageIntelligenceConfidence::High,
2899                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2900                    evidence: CoverageIntelligenceEvidence {
2901                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2902                        ..Default::default()
2903                    },
2904                    actions: vec![CoverageIntelligenceAction {
2905                        kind: "delete-after-confirming-owner".to_owned(),
2906                        description: "Confirm ownership".to_owned(),
2907                        auto_fixable: false,
2908                    }],
2909                }],
2910            }),
2911            ..Default::default()
2912        };
2913
2914        let sarif = build_health_sarif(&report, &root);
2915        let result = &sarif["runs"][0]["results"][0];
2916        assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
2917        assert_eq!(
2918            result["properties"]["coverage_intelligence_id"],
2919            "fallow:coverage-intel:abc123"
2920        );
2921        assert_eq!(
2922            result["properties"]["recommendation"],
2923            "delete-after-confirming-owner"
2924        );
2925        assert_eq!(result["properties"]["confidence"], "high");
2926        assert_eq!(result["properties"]["signals"][0], "runtime_cold");
2927        assert_eq!(
2928            result["properties"]["related_ids"][0],
2929            "fallow:prod:deadbeef"
2930        );
2931    }
2932
2933    #[test]
2934    fn health_sarif_cyclomatic_only() {
2935        let root = PathBuf::from("/project");
2936        let report = crate::health_types::HealthReport {
2937            findings: vec![
2938                crate::health_types::ComplexityViolation {
2939                    path: root.join("src/utils.ts"),
2940                    name: "parseExpression".to_string(),
2941                    line: 42,
2942                    col: 0,
2943                    cyclomatic: 25,
2944                    cognitive: 10,
2945                    line_count: 80,
2946                    param_count: 0,
2947                    react_hook_count: 0,
2948                    react_jsx_max_depth: 0,
2949                    react_prop_count: 0,
2950                    react_hook_profile: None,
2951                    exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
2952                    severity: crate::health_types::FindingSeverity::High,
2953                    crap: None,
2954                    coverage_pct: None,
2955                    coverage_tier: None,
2956                    coverage_source: None,
2957                    inherited_from: None,
2958                    component_rollup: None,
2959                    contributions: Vec::new(),
2960                    effective_thresholds: None,
2961                    threshold_source: None,
2962                }
2963                .into(),
2964            ],
2965            summary: crate::health_types::HealthSummary {
2966                files_analyzed: 5,
2967                functions_analyzed: 20,
2968                functions_above_threshold: 1,
2969                ..Default::default()
2970            },
2971            ..Default::default()
2972        };
2973        let sarif = build_health_sarif(&report, &root);
2974        let entry = &sarif["runs"][0]["results"][0];
2975        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
2976        assert_eq!(entry["level"], "warning");
2977        assert!(
2978            entry["message"]["text"]
2979                .as_str()
2980                .unwrap()
2981                .contains("cyclomatic complexity 25")
2982        );
2983        assert_eq!(
2984            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2985            "src/utils.ts"
2986        );
2987        let region = &entry["locations"][0]["physicalLocation"]["region"];
2988        assert_eq!(region["startLine"], 42);
2989        assert_eq!(region["startColumn"], 1);
2990    }
2991
2992    #[test]
2993    fn health_sarif_cognitive_only() {
2994        let root = PathBuf::from("/project");
2995        let report = crate::health_types::HealthReport {
2996            findings: vec![
2997                crate::health_types::ComplexityViolation {
2998                    path: root.join("src/api.ts"),
2999                    name: "handleRequest".to_string(),
3000                    line: 10,
3001                    col: 4,
3002                    cyclomatic: 8,
3003                    cognitive: 20,
3004                    line_count: 40,
3005                    param_count: 0,
3006                    react_hook_count: 0,
3007                    react_jsx_max_depth: 0,
3008                    react_prop_count: 0,
3009                    react_hook_profile: None,
3010                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
3011                    severity: crate::health_types::FindingSeverity::High,
3012                    crap: None,
3013                    coverage_pct: None,
3014                    coverage_tier: None,
3015                    coverage_source: None,
3016                    inherited_from: None,
3017                    component_rollup: None,
3018                    contributions: Vec::new(),
3019                    effective_thresholds: None,
3020                    threshold_source: None,
3021                }
3022                .into(),
3023            ],
3024            summary: crate::health_types::HealthSummary {
3025                files_analyzed: 3,
3026                functions_analyzed: 10,
3027                functions_above_threshold: 1,
3028                ..Default::default()
3029            },
3030            ..Default::default()
3031        };
3032        let sarif = build_health_sarif(&report, &root);
3033        let entry = &sarif["runs"][0]["results"][0];
3034        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
3035        assert!(
3036            entry["message"]["text"]
3037                .as_str()
3038                .unwrap()
3039                .contains("cognitive complexity 20")
3040        );
3041        let region = &entry["locations"][0]["physicalLocation"]["region"];
3042        assert_eq!(region["startColumn"], 5); // col 4 + 1
3043    }
3044
3045    #[test]
3046    fn health_sarif_both_thresholds() {
3047        let root = PathBuf::from("/project");
3048        let report = crate::health_types::HealthReport {
3049            findings: vec![
3050                crate::health_types::ComplexityViolation {
3051                    path: root.join("src/complex.ts"),
3052                    name: "doEverything".to_string(),
3053                    line: 1,
3054                    col: 0,
3055                    cyclomatic: 30,
3056                    cognitive: 45,
3057                    line_count: 100,
3058                    param_count: 0,
3059                    react_hook_count: 0,
3060                    react_jsx_max_depth: 0,
3061                    react_prop_count: 0,
3062                    react_hook_profile: None,
3063                    exceeded: crate::health_types::ExceededThreshold::Both,
3064                    severity: crate::health_types::FindingSeverity::High,
3065                    crap: None,
3066                    coverage_pct: None,
3067                    coverage_tier: None,
3068                    coverage_source: None,
3069                    inherited_from: None,
3070                    component_rollup: None,
3071                    contributions: Vec::new(),
3072                    effective_thresholds: None,
3073                    threshold_source: None,
3074                }
3075                .into(),
3076            ],
3077            summary: crate::health_types::HealthSummary {
3078                files_analyzed: 1,
3079                functions_analyzed: 1,
3080                functions_above_threshold: 1,
3081                ..Default::default()
3082            },
3083            ..Default::default()
3084        };
3085        let sarif = build_health_sarif(&report, &root);
3086        let entry = &sarif["runs"][0]["results"][0];
3087        assert_eq!(entry["ruleId"], "fallow/high-complexity");
3088        let msg = entry["message"]["text"].as_str().unwrap();
3089        assert!(msg.contains("cyclomatic complexity 30"));
3090        assert!(msg.contains("cognitive complexity 45"));
3091    }
3092
3093    #[test]
3094    fn health_sarif_crap_only_emits_crap_rule() {
3095        let root = PathBuf::from("/project");
3096        let report = crate::health_types::HealthReport {
3097            findings: vec![
3098                crate::health_types::ComplexityViolation {
3099                    path: root.join("src/untested.ts"),
3100                    name: "risky".to_string(),
3101                    line: 8,
3102                    col: 0,
3103                    cyclomatic: 10,
3104                    cognitive: 10,
3105                    line_count: 20,
3106                    param_count: 1,
3107                    react_hook_count: 0,
3108                    react_jsx_max_depth: 0,
3109                    react_prop_count: 0,
3110                    react_hook_profile: None,
3111                    exceeded: crate::health_types::ExceededThreshold::Crap,
3112                    severity: crate::health_types::FindingSeverity::High,
3113                    crap: Some(82.2),
3114                    coverage_pct: Some(12.0),
3115                    coverage_tier: None,
3116                    coverage_source: None,
3117                    inherited_from: None,
3118                    component_rollup: None,
3119                    contributions: Vec::new(),
3120                    effective_thresholds: None,
3121                    threshold_source: None,
3122                }
3123                .into(),
3124            ],
3125            summary: crate::health_types::HealthSummary {
3126                files_analyzed: 1,
3127                functions_analyzed: 1,
3128                functions_above_threshold: 1,
3129                ..Default::default()
3130            },
3131            ..Default::default()
3132        };
3133        let sarif = build_health_sarif(&report, &root);
3134        let entry = &sarif["runs"][0]["results"][0];
3135        assert_eq!(entry["ruleId"], "fallow/high-crap-score");
3136        let msg = entry["message"]["text"].as_str().unwrap();
3137        assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
3138        assert!(msg.contains("coverage 12%"), "msg: {msg}");
3139    }
3140
3141    #[test]
3142    fn health_sarif_cyclomatic_crap_uses_crap_rule() {
3143        let root = PathBuf::from("/project");
3144        let report = crate::health_types::HealthReport {
3145            findings: vec![
3146                crate::health_types::ComplexityViolation {
3147                    path: root.join("src/hot.ts"),
3148                    name: "branchy".to_string(),
3149                    line: 1,
3150                    col: 0,
3151                    cyclomatic: 67,
3152                    cognitive: 12,
3153                    line_count: 80,
3154                    param_count: 1,
3155                    react_hook_count: 0,
3156                    react_jsx_max_depth: 0,
3157                    react_prop_count: 0,
3158                    react_hook_profile: None,
3159                    exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
3160                    severity: crate::health_types::FindingSeverity::Critical,
3161                    crap: Some(182.0),
3162                    coverage_pct: None,
3163                    coverage_tier: None,
3164                    coverage_source: None,
3165                    inherited_from: None,
3166                    component_rollup: None,
3167                    contributions: Vec::new(),
3168                    effective_thresholds: None,
3169                    threshold_source: None,
3170                }
3171                .into(),
3172            ],
3173            summary: crate::health_types::HealthSummary {
3174                files_analyzed: 1,
3175                functions_analyzed: 1,
3176                functions_above_threshold: 1,
3177                ..Default::default()
3178            },
3179            ..Default::default()
3180        };
3181        let sarif = build_health_sarif(&report, &root);
3182        let results = sarif["runs"][0]["results"].as_array().unwrap();
3183        assert_eq!(
3184            results.len(),
3185            1,
3186            "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
3187        );
3188        assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
3189        let msg = results[0]["message"]["text"].as_str().unwrap();
3190        assert!(msg.contains("CRAP score 182"), "msg: {msg}");
3191        assert!(!msg.contains("coverage"), "msg: {msg}");
3192    }
3193
3194    #[test]
3195    fn severity_to_sarif_level_error() {
3196        assert_eq!(severity_to_sarif_level(Severity::Error), "error");
3197    }
3198
3199    #[test]
3200    fn severity_to_sarif_level_warn() {
3201        assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
3202    }
3203
3204    #[test]
3205    #[should_panic(expected = "internal error: entered unreachable code")]
3206    fn severity_to_sarif_level_off() {
3207        let _ = severity_to_sarif_level(Severity::Off);
3208    }
3209
3210    #[test]
3211    fn sarif_re_export_has_properties() {
3212        let root = PathBuf::from("/project");
3213        let mut results = AnalysisResults::default();
3214        results
3215            .unused_exports
3216            .push(UnusedExportFinding::with_actions(UnusedExport {
3217                path: root.join("src/index.ts"),
3218                export_name: "reExported".to_string(),
3219                is_type_only: false,
3220                line: 1,
3221                col: 0,
3222                span_start: 0,
3223                is_re_export: true,
3224            }));
3225
3226        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3227        let entry = &sarif["runs"][0]["results"][0];
3228        assert_eq!(entry["properties"]["is_re_export"], true);
3229        let msg = entry["message"]["text"].as_str().unwrap();
3230        assert!(msg.starts_with("Re-export"));
3231    }
3232
3233    #[test]
3234    fn sarif_non_re_export_has_no_properties() {
3235        let root = PathBuf::from("/project");
3236        let mut results = AnalysisResults::default();
3237        results
3238            .unused_exports
3239            .push(UnusedExportFinding::with_actions(UnusedExport {
3240                path: root.join("src/utils.ts"),
3241                export_name: "foo".to_string(),
3242                is_type_only: false,
3243                line: 5,
3244                col: 0,
3245                span_start: 0,
3246                is_re_export: false,
3247            }));
3248
3249        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3250        let entry = &sarif["runs"][0]["results"][0];
3251        assert!(entry.get("properties").is_none());
3252        let msg = entry["message"]["text"].as_str().unwrap();
3253        assert!(msg.starts_with("Export"));
3254    }
3255
3256    #[test]
3257    fn sarif_type_re_export_message() {
3258        let root = PathBuf::from("/project");
3259        let mut results = AnalysisResults::default();
3260        results
3261            .unused_types
3262            .push(UnusedTypeFinding::with_actions(UnusedExport {
3263                path: root.join("src/index.ts"),
3264                export_name: "MyType".to_string(),
3265                is_type_only: true,
3266                line: 1,
3267                col: 0,
3268                span_start: 0,
3269                is_re_export: true,
3270            }));
3271
3272        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3273        let entry = &sarif["runs"][0]["results"][0];
3274        assert_eq!(entry["ruleId"], "fallow/unused-type");
3275        let msg = entry["message"]["text"].as_str().unwrap();
3276        assert!(msg.starts_with("Type re-export"));
3277        assert_eq!(entry["properties"]["is_re_export"], true);
3278    }
3279
3280    #[test]
3281    fn sarif_dependency_line_zero_skips_region() {
3282        let root = PathBuf::from("/project");
3283        let mut results = AnalysisResults::default();
3284        results
3285            .unused_dependencies
3286            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3287                package_name: "lodash".to_string(),
3288                location: DependencyLocation::Dependencies,
3289                path: root.join("package.json"),
3290                line: 0,
3291                used_in_workspaces: Vec::new(),
3292            }));
3293
3294        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3295        let entry = &sarif["runs"][0]["results"][0];
3296        let phys = &entry["locations"][0]["physicalLocation"];
3297        assert!(phys.get("region").is_none());
3298    }
3299
3300    #[test]
3301    fn sarif_dependency_line_nonzero_has_region() {
3302        let root = PathBuf::from("/project");
3303        let mut results = AnalysisResults::default();
3304        results
3305            .unused_dependencies
3306            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3307                package_name: "lodash".to_string(),
3308                location: DependencyLocation::Dependencies,
3309                path: root.join("package.json"),
3310                line: 7,
3311                used_in_workspaces: Vec::new(),
3312            }));
3313
3314        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3315        let entry = &sarif["runs"][0]["results"][0];
3316        let region = &entry["locations"][0]["physicalLocation"]["region"];
3317        assert_eq!(region["startLine"], 7);
3318        assert_eq!(region["startColumn"], 1);
3319    }
3320
3321    #[test]
3322    fn sarif_type_only_dep_line_zero_skips_region() {
3323        let root = PathBuf::from("/project");
3324        let mut results = AnalysisResults::default();
3325        results
3326            .type_only_dependencies
3327            .push(TypeOnlyDependencyFinding::with_actions(
3328                TypeOnlyDependency {
3329                    package_name: "zod".to_string(),
3330                    path: root.join("package.json"),
3331                    line: 0,
3332                },
3333            ));
3334
3335        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3336        let entry = &sarif["runs"][0]["results"][0];
3337        let phys = &entry["locations"][0]["physicalLocation"];
3338        assert!(phys.get("region").is_none());
3339    }
3340
3341    #[test]
3342    fn sarif_circular_dep_line_zero_skips_region() {
3343        let root = PathBuf::from("/project");
3344        let mut results = AnalysisResults::default();
3345        results
3346            .circular_dependencies
3347            .push(CircularDependencyFinding::with_actions(
3348                CircularDependency {
3349                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3350                    length: 2,
3351                    line: 0,
3352                    col: 0,
3353                    edges: Vec::new(),
3354                    is_cross_package: false,
3355                },
3356            ));
3357
3358        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3359        let entry = &sarif["runs"][0]["results"][0];
3360        let phys = &entry["locations"][0]["physicalLocation"];
3361        assert!(phys.get("region").is_none());
3362    }
3363
3364    #[test]
3365    fn sarif_circular_dep_line_nonzero_has_region() {
3366        let root = PathBuf::from("/project");
3367        let mut results = AnalysisResults::default();
3368        results
3369            .circular_dependencies
3370            .push(CircularDependencyFinding::with_actions(
3371                CircularDependency {
3372                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3373                    length: 2,
3374                    line: 5,
3375                    col: 2,
3376                    edges: Vec::new(),
3377                    is_cross_package: false,
3378                },
3379            ));
3380
3381        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3382        let entry = &sarif["runs"][0]["results"][0];
3383        let region = &entry["locations"][0]["physicalLocation"]["region"];
3384        assert_eq!(region["startLine"], 5);
3385        assert_eq!(region["startColumn"], 3);
3386    }
3387
3388    #[test]
3389    fn sarif_unused_optional_dependency_result() {
3390        let root = PathBuf::from("/project");
3391        let mut results = AnalysisResults::default();
3392        results
3393            .unused_optional_dependencies
3394            .push(UnusedOptionalDependencyFinding::with_actions(
3395                UnusedDependency {
3396                    package_name: "fsevents".to_string(),
3397                    location: DependencyLocation::OptionalDependencies,
3398                    path: root.join("package.json"),
3399                    line: 12,
3400                    used_in_workspaces: Vec::new(),
3401                },
3402            ));
3403
3404        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3405        let entry = &sarif["runs"][0]["results"][0];
3406        assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
3407        let msg = entry["message"]["text"].as_str().unwrap();
3408        assert!(msg.contains("optionalDependencies"));
3409    }
3410
3411    #[test]
3412    fn sarif_enum_member_message_format() {
3413        let root = PathBuf::from("/project");
3414        let mut results = AnalysisResults::default();
3415        results.unused_enum_members.push(
3416            fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
3417                path: root.join("src/enums.ts"),
3418                parent_name: "Color".to_string(),
3419                member_name: "Purple".to_string(),
3420                kind: fallow_core::extract::MemberKind::EnumMember,
3421                line: 5,
3422                col: 2,
3423            }),
3424        );
3425
3426        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3427        let entry = &sarif["runs"][0]["results"][0];
3428        assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
3429        let msg = entry["message"]["text"].as_str().unwrap();
3430        assert!(msg.contains("Enum member 'Color.Purple'"));
3431        let region = &entry["locations"][0]["physicalLocation"]["region"];
3432        assert_eq!(region["startColumn"], 3); // col 2 + 1
3433    }
3434
3435    #[test]
3436    fn sarif_class_member_message_format() {
3437        let root = PathBuf::from("/project");
3438        let mut results = AnalysisResults::default();
3439        results.unused_class_members.push(
3440            fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
3441                path: root.join("src/service.ts"),
3442                parent_name: "API".to_string(),
3443                member_name: "fetch".to_string(),
3444                kind: fallow_core::extract::MemberKind::ClassMethod,
3445                line: 10,
3446                col: 4,
3447            }),
3448        );
3449
3450        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3451        let entry = &sarif["runs"][0]["results"][0];
3452        assert_eq!(entry["ruleId"], "fallow/unused-class-member");
3453        let msg = entry["message"]["text"].as_str().unwrap();
3454        assert!(msg.contains("Class member 'API.fetch'"));
3455    }
3456
3457    #[test]
3458    #[expect(
3459        clippy::cast_possible_truncation,
3460        reason = "test line/col values are trivially small"
3461    )]
3462    fn duplication_sarif_structure() {
3463        use fallow_core::duplicates::*;
3464
3465        let root = PathBuf::from("/project");
3466        let report = DuplicationReport {
3467            clone_groups: vec![CloneGroup {
3468                instances: vec![
3469                    CloneInstance {
3470                        file: root.join("src/a.ts"),
3471                        start_line: 1,
3472                        end_line: 10,
3473                        start_col: 0,
3474                        end_col: 0,
3475                        fragment: String::new(),
3476                    },
3477                    CloneInstance {
3478                        file: root.join("src/b.ts"),
3479                        start_line: 5,
3480                        end_line: 14,
3481                        start_col: 2,
3482                        end_col: 0,
3483                        fragment: String::new(),
3484                    },
3485                ],
3486                token_count: 50,
3487                line_count: 10,
3488            }],
3489            clone_families: vec![],
3490            mirrored_directories: vec![],
3491            stats: DuplicationStats::default(),
3492        };
3493
3494        let sarif = serde_json::json!({
3495            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
3496            "version": "2.1.0",
3497            "runs": [{
3498                "tool": {
3499                    "driver": {
3500                        "name": "fallow",
3501                        "version": env!("CARGO_PKG_VERSION"),
3502                        "informationUri": "https://github.com/fallow-rs/fallow",
3503                        "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
3504                    }
3505                },
3506                "results": []
3507            }]
3508        });
3509        let _ = sarif;
3510
3511        let mut sarif_results = Vec::new();
3512        for (i, group) in report.clone_groups.iter().enumerate() {
3513            for instance in &group.instances {
3514                sarif_results.push(sarif_result(
3515                    "fallow/code-duplication",
3516                    "warning",
3517                    &format!(
3518                        "Code clone group {} ({} lines, {} instances)",
3519                        i + 1,
3520                        group.line_count,
3521                        group.instances.len()
3522                    ),
3523                    &super::super::relative_uri(&instance.file, &root),
3524                    Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
3525                ));
3526            }
3527        }
3528        assert_eq!(sarif_results.len(), 2);
3529        assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
3530        assert!(
3531            sarif_results[0]["message"]["text"]
3532                .as_str()
3533                .unwrap()
3534                .contains("10 lines")
3535        );
3536        let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
3537        assert_eq!(region0["startLine"], 1);
3538        assert_eq!(region0["startColumn"], 1); // start_col 0 + 1
3539        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
3540        assert_eq!(region1["startLine"], 5);
3541        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
3542    }
3543
3544    #[test]
3545    fn sarif_rule_known_id_has_full_description() {
3546        let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
3547        assert!(rule.get("fullDescription").is_some());
3548        assert!(rule.get("helpUri").is_some());
3549    }
3550
3551    #[test]
3552    fn sarif_rule_unknown_id_uses_fallback() {
3553        let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
3554        assert_eq!(rule["shortDescription"]["text"], "fallback text");
3555        assert!(rule.get("fullDescription").is_none());
3556        assert!(rule.get("helpUri").is_none());
3557        assert_eq!(rule["defaultConfiguration"]["level"], "warning");
3558    }
3559
3560    #[test]
3561    fn sarif_result_no_region_omits_region_key() {
3562        let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
3563        let phys = &result["locations"][0]["physicalLocation"];
3564        assert!(phys.get("region").is_none());
3565        assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
3566    }
3567
3568    #[test]
3569    fn sarif_result_with_region_includes_region() {
3570        let result = sarif_result(
3571            "rule/test",
3572            "error",
3573            "test msg",
3574            "src/file.ts",
3575            Some((10, 5)),
3576        );
3577        let region = &result["locations"][0]["physicalLocation"]["region"];
3578        assert_eq!(region["startLine"], 10);
3579        assert_eq!(region["startColumn"], 5);
3580    }
3581
3582    #[test]
3583    fn sarif_partial_fingerprint_ignores_rendered_message() {
3584        let a = sarif_result(
3585            "rule/test",
3586            "error",
3587            "first message",
3588            "src/file.ts",
3589            Some((10, 5)),
3590        );
3591        let b = sarif_result(
3592            "rule/test",
3593            "error",
3594            "rewritten message",
3595            "src/file.ts",
3596            Some((10, 5)),
3597        );
3598        assert_eq!(
3599            a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
3600            b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
3601        );
3602    }
3603
3604    #[test]
3605    fn health_sarif_includes_refactoring_targets() {
3606        use crate::health_types::*;
3607
3608        let root = PathBuf::from("/project");
3609        let report = HealthReport {
3610            summary: HealthSummary {
3611                files_analyzed: 10,
3612                functions_analyzed: 50,
3613                ..Default::default()
3614            },
3615            targets: vec![
3616                RefactoringTarget {
3617                    path: root.join("src/complex.ts"),
3618                    priority: 85.0,
3619                    efficiency: 42.5,
3620                    recommendation: "Split high-impact file".into(),
3621                    category: RecommendationCategory::SplitHighImpact,
3622                    effort: EffortEstimate::Medium,
3623                    confidence: Confidence::High,
3624                    factors: vec![],
3625                    evidence: None,
3626                }
3627                .into(),
3628            ],
3629            ..Default::default()
3630        };
3631
3632        let sarif = build_health_sarif(&report, &root);
3633        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3634        assert_eq!(entries.len(), 1);
3635        assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
3636        assert_eq!(entries[0]["level"], "warning");
3637        let msg = entries[0]["message"]["text"].as_str().unwrap();
3638        assert!(msg.contains("high impact"));
3639        assert!(msg.contains("Split high-impact file"));
3640        assert!(msg.contains("42.5"));
3641    }
3642
3643    #[test]
3644    fn health_sarif_includes_coverage_gaps() {
3645        use crate::health_types::*;
3646
3647        let root = PathBuf::from("/project");
3648        let report = HealthReport {
3649            summary: HealthSummary {
3650                files_analyzed: 10,
3651                functions_analyzed: 50,
3652                ..Default::default()
3653            },
3654            coverage_gaps: Some(CoverageGaps {
3655                summary: CoverageGapSummary {
3656                    runtime_files: 2,
3657                    covered_files: 0,
3658                    file_coverage_pct: 0.0,
3659                    untested_files: 1,
3660                    untested_exports: 1,
3661                },
3662                files: vec![UntestedFileFinding::with_actions(
3663                    UntestedFile {
3664                        path: root.join("src/app.ts"),
3665                        value_export_count: 2,
3666                    },
3667                    &root,
3668                )],
3669                exports: vec![UntestedExportFinding::with_actions(
3670                    UntestedExport {
3671                        path: root.join("src/app.ts"),
3672                        export_name: "loader".into(),
3673                        line: 12,
3674                        col: 4,
3675                    },
3676                    &root,
3677                )],
3678            }),
3679            ..Default::default()
3680        };
3681
3682        let sarif = build_health_sarif(&report, &root);
3683        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3684        assert_eq!(entries.len(), 2);
3685        assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
3686        assert_eq!(
3687            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3688            "src/app.ts"
3689        );
3690        assert!(
3691            entries[0]["message"]["text"]
3692                .as_str()
3693                .unwrap()
3694                .contains("2 value exports")
3695        );
3696        assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
3697        assert_eq!(
3698            entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
3699            12
3700        );
3701        assert_eq!(
3702            entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
3703            5
3704        );
3705    }
3706
3707    #[test]
3708    fn health_sarif_rules_have_full_descriptions() {
3709        let root = PathBuf::from("/project");
3710        let report = crate::health_types::HealthReport::default();
3711        let sarif = build_health_sarif(&report, &root);
3712        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
3713            .as_array()
3714            .unwrap();
3715        for rule in rules {
3716            let id = rule["id"].as_str().unwrap();
3717            assert!(
3718                rule.get("fullDescription").is_some(),
3719                "health rule {id} should have fullDescription"
3720            );
3721            assert!(
3722                rule.get("helpUri").is_some(),
3723                "health rule {id} should have helpUri"
3724            );
3725        }
3726    }
3727
3728    #[test]
3729    fn sarif_warn_severity_produces_warning_level() {
3730        let root = PathBuf::from("/project");
3731        let mut results = AnalysisResults::default();
3732        results
3733            .unused_files
3734            .push(UnusedFileFinding::with_actions(UnusedFile {
3735                path: root.join("src/dead.ts"),
3736            }));
3737
3738        let rules = RulesConfig {
3739            unused_files: Severity::Warn,
3740            ..RulesConfig::default()
3741        };
3742
3743        let sarif = build_sarif(&results, &root, &rules);
3744        let entry = &sarif["runs"][0]["results"][0];
3745        assert_eq!(entry["level"], "warning");
3746    }
3747
3748    #[test]
3749    fn sarif_unused_file_has_no_region() {
3750        let root = PathBuf::from("/project");
3751        let mut results = AnalysisResults::default();
3752        results
3753            .unused_files
3754            .push(UnusedFileFinding::with_actions(UnusedFile {
3755                path: root.join("src/dead.ts"),
3756            }));
3757
3758        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3759        let entry = &sarif["runs"][0]["results"][0];
3760        let phys = &entry["locations"][0]["physicalLocation"];
3761        assert!(phys.get("region").is_none());
3762    }
3763
3764    #[test]
3765    fn sarif_unlisted_dep_multiple_import_sites() {
3766        let root = PathBuf::from("/project");
3767        let mut results = AnalysisResults::default();
3768        results
3769            .unlisted_dependencies
3770            .push(UnlistedDependencyFinding::with_actions(
3771                UnlistedDependency {
3772                    package_name: "dotenv".to_string(),
3773                    imported_from: vec![
3774                        ImportSite {
3775                            path: root.join("src/a.ts"),
3776                            line: 1,
3777                            col: 0,
3778                        },
3779                        ImportSite {
3780                            path: root.join("src/b.ts"),
3781                            line: 5,
3782                            col: 0,
3783                        },
3784                    ],
3785                },
3786            ));
3787
3788        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3789        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3790        assert_eq!(entries.len(), 2);
3791        assert_eq!(
3792            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3793            "src/a.ts"
3794        );
3795        assert_eq!(
3796            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3797            "src/b.ts"
3798        );
3799    }
3800
3801    #[test]
3802    fn sarif_unlisted_dep_no_import_sites() {
3803        let root = PathBuf::from("/project");
3804        let mut results = AnalysisResults::default();
3805        results
3806            .unlisted_dependencies
3807            .push(UnlistedDependencyFinding::with_actions(
3808                UnlistedDependency {
3809                    package_name: "phantom".to_string(),
3810                    imported_from: vec![],
3811                },
3812            ));
3813
3814        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3815        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3816        assert!(entries.is_empty());
3817    }
3818}