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