Skip to main content

fallow_api/
dead_code_sarif.rs

1//! Shared dead-code SARIF output assembly.
2
3use std::path::{Path, PathBuf};
4
5use fallow_config::{RulesConfig, Severity};
6use fallow_output::{
7    SarifDocumentInput, SarifResultInput, build_sarif_document, build_sarif_result,
8    issue_output_contracts, normalize_uri,
9};
10use fallow_types::{
11    issue_meta::issue_sarif_rule_description,
12    output_dead_code::*,
13    results::{
14        AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation, BoundaryViolation,
15        CircularDependency, DuplicatePropShape, DynamicSegmentNameConflict, InvalidClientExport,
16        MisplacedDirective, MixedClientServerBarrel, PolicyViolation, PolicyViolationSeverity,
17        PrivateTypeLeak, PropDrillingChain, RouteCollision, StaleSuppression, TestOnlyDependency,
18        ThinWrapper, TypeOnlyDependency, UnprovidedInject, UnrenderedComponent, UnresolvedImport,
19        UnusedComponentEmit, UnusedComponentInput, UnusedComponentOutput, UnusedComponentProp,
20        UnusedDependency, UnusedExport, UnusedFile, UnusedMember, UnusedServerAction,
21        UnusedSvelteEvent,
22    },
23};
24use rustc_hash::FxHashMap;
25
26fn relative_uri(path: &Path, root: &Path) -> String {
27    normalize_uri(
28        &path
29            .strip_prefix(root)
30            .unwrap_or(path)
31            .display()
32            .to_string(),
33    )
34}
35
36/// Intermediate fields extracted from an issue for SARIF result construction.
37struct SarifFields {
38    rule_id: &'static str,
39    level: &'static str,
40    message: String,
41    uri: String,
42    region: Option<(u32, u32)>,
43    source_path: Option<PathBuf>,
44    properties: Option<serde_json::Value>,
45}
46
47#[derive(Default)]
48struct SourceSnippetCache {
49    files: FxHashMap<PathBuf, Vec<String>>,
50}
51
52impl SourceSnippetCache {
53    fn line(&mut self, path: &Path, line: u32) -> Option<String> {
54        if line == 0 {
55            return None;
56        }
57        if !self.files.contains_key(path) {
58            let lines = std::fs::read_to_string(path)
59                .ok()
60                .map(|source| source.lines().map(str::to_owned).collect())
61                .unwrap_or_default();
62            self.files.insert(path.to_path_buf(), lines);
63        }
64        self.files
65            .get(path)
66            .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
67            .cloned()
68    }
69}
70
71/// Read-only context threaded through the SARIF result builders: the
72/// analysis results, project root, and rule severities. Bundled so the
73/// `push_*_sarif_results` family shares one parameter instead of three.
74#[derive(Clone, Copy)]
75struct SarifCtx<'a> {
76    results: &'a AnalysisResults,
77    root: &'a Path,
78    rules: &'a RulesConfig,
79}
80
81fn severity_to_sarif_level(s: Severity) -> &'static str {
82    match s {
83        Severity::Error => "error",
84        Severity::Warn => "warning",
85        Severity::Off => unreachable!(),
86    }
87}
88
89fn configured_sarif_level(s: Severity) -> &'static str {
90    match s {
91        Severity::Error | Severity::Warn => severity_to_sarif_level(s),
92        Severity::Off => "none",
93    }
94}
95
96fn sarif_result_with_snippet(
97    rule_id: &str,
98    level: &str,
99    message: &str,
100    uri: &str,
101    region: Option<(u32, u32)>,
102    snippet: Option<&str>,
103) -> serde_json::Value {
104    build_sarif_result(SarifResultInput {
105        rule_id,
106        level,
107        message,
108        uri,
109        region,
110        snippet,
111    })
112}
113
114/// Append SARIF results for a slice of items using a closure to extract fields.
115fn push_sarif_results<T>(
116    sarif_results: &mut Vec<serde_json::Value>,
117    items: &[T],
118    snippets: &mut SourceSnippetCache,
119    mut extract: impl FnMut(&T) -> SarifFields,
120) {
121    for item in items {
122        let fields = extract(item);
123        let source_snippet = fields
124            .source_path
125            .as_deref()
126            .zip(fields.region)
127            .and_then(|(path, (line, _))| snippets.line(path, line));
128        let mut result = sarif_result_with_snippet(
129            fields.rule_id,
130            fields.level,
131            &fields.message,
132            &fields.uri,
133            fields.region,
134            source_snippet.as_deref(),
135        );
136        if let Some(props) = fields.properties {
137            result["properties"] = props;
138        }
139        sarif_results.push(result);
140    }
141}
142
143/// Extract SARIF fields for an unused export or type export.
144fn sarif_export_fields(
145    export: &UnusedExport,
146    root: &Path,
147    rule_id: &'static str,
148    level: &'static str,
149    kind: &str,
150    re_kind: &str,
151) -> SarifFields {
152    let label = if export.is_re_export { re_kind } else { kind };
153    SarifFields {
154        rule_id,
155        level,
156        message: format!(
157            "{} '{}' is never imported by other modules",
158            label, export.export_name
159        ),
160        uri: relative_uri(&export.path, root),
161        region: Some((export.line, export.col + 1)),
162        source_path: Some(export.path.clone()),
163        properties: if export.is_re_export {
164            Some(serde_json::json!({ "is_re_export": true }))
165        } else {
166            None
167        },
168    }
169}
170
171fn sarif_private_type_leak_fields(
172    leak: &PrivateTypeLeak,
173    root: &Path,
174    level: &'static str,
175) -> SarifFields {
176    SarifFields {
177        rule_id: "fallow/private-type-leak",
178        level,
179        message: format!(
180            "Export '{}' references private type '{}'",
181            leak.export_name, leak.type_name
182        ),
183        uri: relative_uri(&leak.path, root),
184        region: Some((leak.line, leak.col + 1)),
185        source_path: Some(leak.path.clone()),
186        properties: None,
187    }
188}
189
190/// Extract SARIF fields for an unused dependency.
191fn sarif_dep_fields(
192    dep: &UnusedDependency,
193    root: &Path,
194    rule_id: &'static str,
195    level: &'static str,
196    section: &str,
197) -> SarifFields {
198    let workspace_context = if dep.used_in_workspaces.is_empty() {
199        String::new()
200    } else {
201        let workspaces = dep
202            .used_in_workspaces
203            .iter()
204            .map(|path| relative_uri(path, root))
205            .collect::<Vec<_>>()
206            .join(", ");
207        format!("; imported in other workspaces: {workspaces}")
208    };
209    SarifFields {
210        rule_id,
211        level,
212        message: format!(
213            "Package '{}' is in {} but never imported{}",
214            dep.package_name, section, workspace_context
215        ),
216        uri: relative_uri(&dep.path, root),
217        region: if dep.line > 0 {
218            Some((dep.line, 1))
219        } else {
220            None
221        },
222        source_path: (dep.line > 0).then(|| dep.path.clone()),
223        properties: None,
224    }
225}
226
227/// Extract SARIF fields for an unused enum or class member.
228fn sarif_member_fields(
229    member: &UnusedMember,
230    root: &Path,
231    rule_id: &'static str,
232    level: &'static str,
233    kind: &str,
234) -> SarifFields {
235    SarifFields {
236        rule_id,
237        level,
238        message: format!(
239            "{} member '{}.{}' is never referenced",
240            kind, member.parent_name, member.member_name
241        ),
242        uri: relative_uri(&member.path, root),
243        region: Some((member.line, member.col + 1)),
244        source_path: Some(member.path.clone()),
245        properties: None,
246    }
247}
248
249fn sarif_unused_file_fields(file: &UnusedFile, root: &Path, level: &'static str) -> SarifFields {
250    SarifFields {
251        rule_id: "fallow/unused-file",
252        level,
253        message: "File is not reachable from any entry point".to_string(),
254        uri: relative_uri(&file.path, root),
255        region: None,
256        source_path: None,
257        properties: None,
258    }
259}
260
261fn sarif_type_only_dep_fields(
262    dep: &TypeOnlyDependency,
263    root: &Path,
264    level: &'static str,
265) -> SarifFields {
266    SarifFields {
267        rule_id: "fallow/type-only-dependency",
268        level,
269        message: format!(
270            "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
271            dep.package_name
272        ),
273        uri: relative_uri(&dep.path, root),
274        region: if dep.line > 0 {
275            Some((dep.line, 1))
276        } else {
277            None
278        },
279        source_path: (dep.line > 0).then(|| dep.path.clone()),
280        properties: None,
281    }
282}
283
284fn sarif_test_only_dep_fields(
285    dep: &TestOnlyDependency,
286    root: &Path,
287    level: &'static str,
288) -> SarifFields {
289    SarifFields {
290        rule_id: "fallow/test-only-dependency",
291        level,
292        message: format!(
293            "Package '{}' is only imported by test files (consider moving to devDependencies)",
294            dep.package_name
295        ),
296        uri: relative_uri(&dep.path, root),
297        region: if dep.line > 0 {
298            Some((dep.line, 1))
299        } else {
300            None
301        },
302        source_path: (dep.line > 0).then(|| dep.path.clone()),
303        properties: None,
304    }
305}
306
307fn sarif_unresolved_import_fields(
308    import: &UnresolvedImport,
309    root: &Path,
310    level: &'static str,
311) -> SarifFields {
312    SarifFields {
313        rule_id: "fallow/unresolved-import",
314        level,
315        message: format!("Import '{}' could not be resolved", import.specifier),
316        uri: relative_uri(&import.path, root),
317        region: Some((import.line, import.col + 1)),
318        source_path: Some(import.path.clone()),
319        properties: None,
320    }
321}
322
323fn sarif_circular_dep_fields(
324    cycle: &CircularDependency,
325    root: &Path,
326    level: &'static str,
327) -> SarifFields {
328    let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
329    let mut display_chain = chain.clone();
330    if let Some(first) = chain.first() {
331        display_chain.push(first.clone());
332    }
333    let first_uri = chain.first().map_or_else(String::new, Clone::clone);
334    let first_path = cycle.files.first().cloned();
335    SarifFields {
336        rule_id: "fallow/circular-dependency",
337        level,
338        message: format!(
339            "Circular dependency{}: {}",
340            if cycle.is_cross_package {
341                " (cross-package)"
342            } else {
343                ""
344            },
345            display_chain.join(" \u{2192} ")
346        ),
347        uri: first_uri,
348        region: if cycle.line > 0 {
349            Some((cycle.line, cycle.col + 1))
350        } else {
351            None
352        },
353        source_path: (cycle.line > 0).then_some(first_path).flatten(),
354        properties: None,
355    }
356}
357
358fn sarif_re_export_cycle_fields(
359    cycle: &fallow_types::results::ReExportCycle,
360    root: &Path,
361    level: &'static str,
362) -> SarifFields {
363    let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
364    let first_uri = chain.first().map_or_else(String::new, Clone::clone);
365    let first_path = cycle.files.first().cloned();
366    let kind_tag = match cycle.kind {
367        fallow_types::results::ReExportCycleKind::SelfLoop => " (self-loop)",
368        fallow_types::results::ReExportCycleKind::MultiNode => "",
369    };
370    SarifFields {
371        rule_id: "fallow/re-export-cycle",
372        level,
373        message: format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
374        uri: first_uri,
375        region: None,
376        source_path: first_path,
377        properties: None,
378    }
379}
380
381fn sarif_boundary_violation_fields(
382    violation: &BoundaryViolation,
383    root: &Path,
384    level: &'static str,
385) -> SarifFields {
386    let from_uri = relative_uri(&violation.from_path, root);
387    let to_uri = relative_uri(&violation.to_path, root);
388    SarifFields {
389        rule_id: "fallow/boundary-violation",
390        level,
391        message: format!(
392            "Import from zone '{}' to zone '{}' is not allowed ({})",
393            violation.from_zone, violation.to_zone, to_uri,
394        ),
395        uri: from_uri,
396        region: if violation.line > 0 {
397            Some((violation.line, violation.col + 1))
398        } else {
399            None
400        },
401        source_path: (violation.line > 0).then(|| violation.from_path.clone()),
402        properties: None,
403    }
404}
405
406fn sarif_boundary_coverage_fields(
407    violation: &BoundaryCoverageViolation,
408    root: &Path,
409    level: &'static str,
410) -> SarifFields {
411    SarifFields {
412        rule_id: "fallow/boundary-coverage",
413        level,
414        message: "File does not match any configured architecture boundary zone".to_string(),
415        uri: relative_uri(&violation.path, root),
416        region: Some((violation.line, violation.col + 1)),
417        source_path: Some(violation.path.clone()),
418        properties: None,
419    }
420}
421
422fn sarif_boundary_call_fields(
423    violation: &BoundaryCallViolation,
424    root: &Path,
425    level: &'static str,
426) -> SarifFields {
427    SarifFields {
428        rule_id: "fallow/boundary-call-violation",
429        level,
430        message: format!(
431            "Call to `{}` matches forbidden pattern `{}` in zone '{}'",
432            violation.callee, violation.pattern, violation.zone
433        ),
434        uri: relative_uri(&violation.path, root),
435        region: Some((violation.line, violation.col + 1)),
436        source_path: Some(violation.path.clone()),
437        properties: None,
438    }
439}
440
441fn sarif_policy_violation_fields(violation: &PolicyViolation, root: &Path) -> SarifFields {
442    let level = match violation.severity {
443        PolicyViolationSeverity::Error => "error",
444        PolicyViolationSeverity::Warn => "warning",
445    };
446    let message = match &violation.message {
447        Some(message) => format!(
448            "Policy violation `{}/{}`: `{}` is banned. {message}",
449            violation.pack, violation.rule_id, violation.matched
450        ),
451        None => format!(
452            "Policy violation `{}/{}`: `{}` is banned",
453            violation.pack, violation.rule_id, violation.matched
454        ),
455    };
456    SarifFields {
457        rule_id: "fallow/policy-violation",
458        level,
459        message,
460        uri: relative_uri(&violation.path, root),
461        region: Some((violation.line, violation.col + 1)),
462        source_path: Some(violation.path.clone()),
463        // The SARIF rule id is the static `fallow/policy-violation`; the
464        // per-rule policy identity rides in properties so code-scanning
465        // consumers can group or filter per pack rule without parsing the
466        // message. Dynamic per-rule SARIF rule synthesis is a tracked
467        // follow-up shared with boundary zone rules.
468        properties: Some(serde_json::json!({
469            "policyRule": format!("{}/{}", violation.pack, violation.rule_id),
470        })),
471    }
472}
473
474fn sarif_invalid_client_export_fields(
475    export: &InvalidClientExport,
476    root: &Path,
477    level: &'static str,
478) -> SarifFields {
479    SarifFields {
480        rule_id: "fallow/invalid-client-export",
481        level,
482        message: format!(
483            "Export '{}' is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
484            export.export_name, export.directive
485        ),
486        uri: relative_uri(&export.path, root),
487        region: Some((export.line, export.col + 1)),
488        source_path: Some(export.path.clone()),
489        properties: None,
490    }
491}
492
493fn sarif_mixed_client_server_barrel_fields(
494    barrel: &MixedClientServerBarrel,
495    root: &Path,
496    level: &'static str,
497) -> SarifFields {
498    SarifFields {
499        rule_id: "fallow/mixed-client-server-barrel",
500        level,
501        message: format!(
502            "Barrel re-exports both a \"use client\" module ('{}') and a server-only module ('{}'); one import drags the other's directive across the boundary",
503            barrel.client_origin, barrel.server_origin
504        ),
505        uri: relative_uri(&barrel.path, root),
506        region: Some((barrel.line, barrel.col + 1)),
507        source_path: Some(barrel.path.clone()),
508        properties: None,
509    }
510}
511
512fn sarif_misplaced_directive_fields(
513    directive_site: &MisplacedDirective,
514    root: &Path,
515    level: &'static str,
516) -> SarifFields {
517    SarifFields {
518        rule_id: "fallow/misplaced-directive",
519        level,
520        message: format!(
521            "Directive \"{}\" is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
522            directive_site.directive
523        ),
524        uri: relative_uri(&directive_site.path, root),
525        region: Some((directive_site.line, directive_site.col + 1)),
526        source_path: Some(directive_site.path.clone()),
527        properties: None,
528    }
529}
530
531fn sarif_unprovided_inject_fields(
532    inject: &UnprovidedInject,
533    root: &Path,
534    level: &'static str,
535) -> SarifFields {
536    SarifFields {
537        rule_id: "fallow/unprovided-inject",
538        level,
539        message: format!(
540            "inject(\"{}\") has no matching provide(\"{}\") in this project; at runtime it returns undefined; provide the key or remove this inject",
541            inject.key_name, inject.key_name
542        ),
543        uri: relative_uri(&inject.path, root),
544        region: Some((inject.line, inject.col + 1)),
545        source_path: Some(inject.path.clone()),
546        properties: None,
547    }
548}
549
550fn sarif_unrendered_component_fields(
551    component: &UnrenderedComponent,
552    root: &Path,
553    level: &'static str,
554) -> SarifFields {
555    SarifFields {
556        rule_id: "fallow/unrendered-component",
557        level,
558        message: format!(
559            "component \"{}\" is reachable but rendered nowhere in this project; render it somewhere or remove it",
560            component.component_name
561        ),
562        uri: relative_uri(&component.path, root),
563        region: Some((component.line, component.col + 1)),
564        source_path: Some(component.path.clone()),
565        properties: None,
566    }
567}
568
569fn sarif_unused_component_prop_fields(
570    prop: &UnusedComponentProp,
571    root: &Path,
572    level: &'static str,
573) -> SarifFields {
574    SarifFields {
575        rule_id: "fallow/unused-component-prop",
576        level,
577        message: format!(
578            "prop \"{}\" is declared but referenced nowhere inside component \"{}\"; remove it or use it",
579            prop.prop_name, prop.component_name
580        ),
581        uri: relative_uri(&prop.path, root),
582        region: Some((prop.line, prop.col + 1)),
583        source_path: Some(prop.path.clone()),
584        properties: None,
585    }
586}
587
588fn sarif_unused_component_emit_fields(
589    emit: &UnusedComponentEmit,
590    root: &Path,
591    level: &'static str,
592) -> SarifFields {
593    SarifFields {
594        rule_id: "fallow/unused-component-emit",
595        level,
596        message: format!(
597            "emit \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
598            emit.emit_name, emit.component_name
599        ),
600        uri: relative_uri(&emit.path, root),
601        region: Some((emit.line, emit.col + 1)),
602        source_path: Some(emit.path.clone()),
603        properties: None,
604    }
605}
606
607fn sarif_unused_svelte_event_fields(
608    event: &UnusedSvelteEvent,
609    root: &Path,
610    level: &'static str,
611) -> SarifFields {
612    SarifFields {
613        rule_id: "fallow/unused-svelte-event",
614        level,
615        message: format!(
616            "event \"{}\" is dispatched by component \"{}\" but listened to nowhere in the project; remove it or listen for it",
617            event.event_name, event.component_name
618        ),
619        uri: relative_uri(&event.path, root),
620        region: Some((event.line, event.col + 1)),
621        source_path: Some(event.path.clone()),
622        properties: None,
623    }
624}
625
626fn sarif_unused_component_input_fields(
627    input: &UnusedComponentInput,
628    root: &Path,
629    level: &'static str,
630) -> SarifFields {
631    SarifFields {
632        rule_id: "fallow/unused-component-input",
633        level,
634        message: format!(
635            "input \"{}\" is declared but read nowhere inside component \"{}\"; remove it or use it",
636            input.input_name, input.component_name
637        ),
638        uri: relative_uri(&input.path, root),
639        region: Some((input.line, input.col + 1)),
640        source_path: Some(input.path.clone()),
641        properties: None,
642    }
643}
644
645fn sarif_unused_component_output_fields(
646    output: &UnusedComponentOutput,
647    root: &Path,
648    level: &'static str,
649) -> SarifFields {
650    SarifFields {
651        rule_id: "fallow/unused-component-output",
652        level,
653        message: format!(
654            "output \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
655            output.output_name, output.component_name
656        ),
657        uri: relative_uri(&output.path, root),
658        region: Some((output.line, output.col + 1)),
659        source_path: Some(output.path.clone()),
660        properties: None,
661    }
662}
663
664fn sarif_unused_server_action_fields(
665    action: &UnusedServerAction,
666    root: &Path,
667    level: &'static str,
668) -> SarifFields {
669    SarifFields {
670        rule_id: "fallow/unused-server-action",
671        level,
672        message: format!(
673            "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",
674            action.action_name
675        ),
676        uri: relative_uri(&action.path, root),
677        region: Some((action.line, action.col + 1)),
678        source_path: Some(action.path.clone()),
679        properties: None,
680    }
681}
682
683fn sarif_unused_load_data_key_fields(
684    key: &fallow_types::results::UnusedLoadDataKey,
685    root: &Path,
686    level: &'static str,
687) -> SarifFields {
688    SarifFields {
689        rule_id: "fallow/unused-load-data-key",
690        level,
691        message: format!(
692            "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",
693            key.key_name
694        ),
695        uri: relative_uri(&key.path, root),
696        region: Some((key.line, key.col + 1)),
697        source_path: Some(key.path.clone()),
698        properties: None,
699    }
700}
701
702fn sarif_prop_drilling_fields(
703    chain: &PropDrillingChain,
704    root: &Path,
705    level: &'static str,
706) -> SarifFields {
707    // Anchor at the source hop (the prop owner). Path / line come from the first
708    // hop; the message names the depth and the consumer at the chain tail.
709    let source = chain.hops.first();
710    let consumer = chain.hops.last();
711    let (path, line) = source.map_or((std::path::PathBuf::new(), 1), |h| (h.file.clone(), h.line));
712    let consumer_name = consumer.map_or("a distant component", |h| h.component.as_str());
713    SarifFields {
714        rule_id: "fallow/prop-drilling",
715        level,
716        message: format!(
717            "prop \"{}\" is forwarded unchanged through {} component(s) before \"{}\" consumes it; colocate, lift to context, or compose",
718            chain.prop, chain.depth, consumer_name
719        ),
720        uri: relative_uri(&path, root),
721        region: Some((line, 1)),
722        source_path: Some(path),
723        properties: None,
724    }
725}
726
727fn sarif_thin_wrapper_fields(
728    wrapper: &ThinWrapper,
729    root: &Path,
730    level: &'static str,
731) -> SarifFields {
732    SarifFields {
733        rule_id: "fallow/thin-wrapper",
734        level,
735        message: format!(
736            "\"{}\" is a thin wrapper: its whole body forwards props to \"{}\"; inline it at call sites or delete it",
737            wrapper.component, wrapper.child_component
738        ),
739        uri: relative_uri(&wrapper.file, root),
740        region: Some((wrapper.line, 1)),
741        source_path: Some(wrapper.file.clone()),
742        properties: None,
743    }
744}
745
746fn sarif_duplicate_prop_shape_fields(
747    shape: &DuplicatePropShape,
748    root: &Path,
749    level: &'static str,
750) -> SarifFields {
751    SarifFields {
752        rule_id: "fallow/duplicate-prop-shape",
753        level,
754        message: format!(
755            "\"{}\" shares an identical prop shape {{{}}} with {} other component(s); extract a shared Props type or base component",
756            shape.component,
757            shape.shape.join(", "),
758            shape.group_size.saturating_sub(1)
759        ),
760        uri: relative_uri(&shape.file, root),
761        region: Some((shape.line, 1)),
762        source_path: Some(shape.file.clone()),
763        properties: None,
764    }
765}
766
767fn sarif_route_collision_fields(
768    collision: &RouteCollision,
769    root: &Path,
770    level: &'static str,
771) -> SarifFields {
772    SarifFields {
773        rule_id: "fallow/route-collision",
774        level,
775        message: format!(
776            "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",
777            collision.url,
778            collision.conflicting_paths.len()
779        ),
780        uri: relative_uri(&collision.path, root),
781        region: Some((collision.line, collision.col + 1)),
782        source_path: Some(collision.path.clone()),
783        properties: None,
784    }
785}
786
787fn sarif_dynamic_segment_name_conflict_fields(
788    conflict: &DynamicSegmentNameConflict,
789    root: &Path,
790    level: &'static str,
791) -> SarifFields {
792    SarifFields {
793        rule_id: "fallow/dynamic-segment-name-conflict",
794        level,
795        message: format!(
796            "Dynamic segments at '{}' use different slug names ({}); Next.js requires one consistent name per dynamic path",
797            conflict.position,
798            conflict.conflicting_segments.join(", ")
799        ),
800        uri: relative_uri(&conflict.path, root),
801        region: Some((conflict.line, conflict.col + 1)),
802        source_path: Some(conflict.path.clone()),
803        properties: None,
804    }
805}
806
807fn sarif_stale_suppression_fields(
808    suppression: &StaleSuppression,
809    root: &Path,
810    level: &'static str,
811) -> SarifFields {
812    SarifFields {
813        rule_id: if suppression.missing_reason {
814            "fallow/missing-suppression-reason"
815        } else {
816            "fallow/stale-suppression"
817        },
818        level,
819        message: suppression.display_message(),
820        uri: relative_uri(&suppression.path, root),
821        region: Some((suppression.line, suppression.col + 1)),
822        source_path: Some(suppression.path.clone()),
823        properties: None,
824    }
825}
826
827fn stale_suppression_severity(suppression: &StaleSuppression, rules: &RulesConfig) -> Severity {
828    if suppression.missing_reason {
829        rules.require_suppression_reason
830    } else {
831        rules.stale_suppressions
832    }
833}
834
835fn sarif_unused_catalog_entry_fields(
836    entry: &UnusedCatalogEntryFinding,
837    root: &Path,
838    level: &'static str,
839) -> SarifFields {
840    let entry = &entry.entry;
841    let message = if entry.catalog_name == "default" {
842        format!(
843            "Catalog entry '{}' is not referenced by any workspace package",
844            entry.entry_name
845        )
846    } else {
847        format!(
848            "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
849            entry.entry_name, entry.catalog_name
850        )
851    };
852    SarifFields {
853        rule_id: "fallow/unused-catalog-entry",
854        level,
855        message,
856        uri: relative_uri(&entry.path, root),
857        region: Some((entry.line, 1)),
858        source_path: Some(entry.path.clone()),
859        properties: None,
860    }
861}
862
863fn sarif_unused_dependency_override_fields(
864    finding: &UnusedDependencyOverrideFinding,
865    root: &Path,
866    level: &'static str,
867) -> SarifFields {
868    let finding = &finding.entry;
869    let mut message = format!(
870        "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
871        finding.raw_key, finding.version_range, finding.target_package,
872    );
873    if let Some(hint) = &finding.hint {
874        use std::fmt::Write as _;
875        let _ = write!(message, " ({hint})");
876    }
877    SarifFields {
878        rule_id: "fallow/unused-dependency-override",
879        level,
880        message,
881        uri: relative_uri(&finding.path, root),
882        region: Some((finding.line, 1)),
883        source_path: Some(finding.path.clone()),
884        properties: None,
885    }
886}
887
888fn sarif_misconfigured_dependency_override_fields(
889    finding: &MisconfiguredDependencyOverrideFinding,
890    root: &Path,
891    level: &'static str,
892) -> SarifFields {
893    let finding = &finding.entry;
894    let message = format!(
895        "Override `{}` -> `{}` is malformed: {}",
896        finding.raw_key,
897        finding.raw_value,
898        finding.reason.describe(),
899    );
900    SarifFields {
901        rule_id: "fallow/misconfigured-dependency-override",
902        level,
903        message,
904        uri: relative_uri(&finding.path, root),
905        region: Some((finding.line, 1)),
906        source_path: Some(finding.path.clone()),
907        properties: None,
908    }
909}
910
911fn sarif_unresolved_catalog_reference_fields(
912    finding: &UnresolvedCatalogReferenceFinding,
913    root: &Path,
914    level: &'static str,
915) -> SarifFields {
916    let finding = &finding.reference;
917    let catalog_phrase = if finding.catalog_name == "default" {
918        "the default catalog".to_string()
919    } else {
920        format!("catalog '{}'", finding.catalog_name)
921    };
922    let mut message = format!(
923        "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
924        finding.entry_name,
925        if finding.catalog_name == "default" {
926            ""
927        } else {
928            finding.catalog_name.as_str()
929        },
930        catalog_phrase,
931    );
932    if !finding.available_in_catalogs.is_empty() {
933        use std::fmt::Write as _;
934        let _ = write!(
935            message,
936            " (available in: {})",
937            finding.available_in_catalogs.join(", ")
938        );
939    }
940    SarifFields {
941        rule_id: "fallow/unresolved-catalog-reference",
942        level,
943        message,
944        uri: relative_uri(&finding.path, root),
945        region: Some((finding.line, 1)),
946        source_path: Some(finding.path.clone()),
947        properties: None,
948    }
949}
950
951fn sarif_empty_catalog_group_fields(
952    group: &EmptyCatalogGroupFinding,
953    root: &Path,
954    level: &'static str,
955) -> SarifFields {
956    let group = &group.group;
957    SarifFields {
958        rule_id: "fallow/empty-catalog-group",
959        level,
960        message: format!("Catalog group '{}' has no entries", group.catalog_name),
961        uri: relative_uri(&group.path, root),
962        region: Some((group.line, 1)),
963        source_path: Some(group.path.clone()),
964        properties: None,
965    }
966}
967
968/// Unlisted deps fan out to one SARIF result per import site, so they do not
969/// fit `push_sarif_results`. Keep the nested-loop shape in its own helper.
970fn push_sarif_unlisted_deps(
971    sarif_results: &mut Vec<serde_json::Value>,
972    deps: &[UnlistedDependencyFinding],
973    root: &Path,
974    level: &'static str,
975    snippets: &mut SourceSnippetCache,
976) {
977    for entry in deps {
978        let dep = &entry.dep;
979        for site in &dep.imported_from {
980            let uri = relative_uri(&site.path, root);
981            let source_snippet = snippets.line(&site.path, site.line);
982            sarif_results.push(sarif_result_with_snippet(
983                "fallow/unlisted-dependency",
984                level,
985                &format!(
986                    "Package '{}' is imported but not listed in package.json",
987                    dep.package_name
988                ),
989                &uri,
990                Some((site.line, site.col + 1)),
991                source_snippet.as_deref(),
992            ));
993        }
994    }
995}
996
997/// Duplicate exports fan out to one SARIF result per location
998/// (SARIF 2.1.0 section 3.27.12), so they do not fit `push_sarif_results`.
999fn push_sarif_duplicate_exports(
1000    sarif_results: &mut Vec<serde_json::Value>,
1001    dups: &[DuplicateExportFinding],
1002    root: &Path,
1003    level: &'static str,
1004    snippets: &mut SourceSnippetCache,
1005) {
1006    for dup in dups {
1007        let dup = &dup.export;
1008        for loc in &dup.locations {
1009            let uri = relative_uri(&loc.path, root);
1010            let source_snippet = snippets.line(&loc.path, loc.line);
1011            sarif_results.push(sarif_result_with_snippet(
1012                "fallow/duplicate-export",
1013                level,
1014                &format!("Export '{}' appears in multiple modules", dup.export_name),
1015                &uri,
1016                Some((loc.line, loc.col + 1)),
1017                source_snippet.as_deref(),
1018            ));
1019        }
1020    }
1021}
1022
1023/// Build the SARIF rules list from the current rules configuration.
1024fn build_sarif_rules(
1025    rules: &RulesConfig,
1026    rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1027) -> Vec<serde_json::Value> {
1028    let mut sarif_rules = Vec::new();
1029    for contract in issue_output_contracts() {
1030        for rule_id in contract.sarif_rule_ids {
1031            let severity = sarif_rule_severity(rules, contract.code, &rule_id);
1032            let description = issue_sarif_rule_description(&rule_id).unwrap_or_else(|| {
1033                panic!("dead-code SARIF rule {rule_id} is missing issue metadata")
1034            });
1035            sarif_rules.push(rule_builder(
1036                &rule_id,
1037                description,
1038                configured_sarif_level(severity),
1039            ));
1040        }
1041    }
1042    sarif_rules
1043}
1044
1045fn sarif_rule_severity(rules: &RulesConfig, issue_code: &str, rule_id: &str) -> Severity {
1046    if rule_id == "fallow/missing-suppression-reason" {
1047        return rules.require_suppression_reason;
1048    }
1049    dead_code_rule_severity(rules, issue_code)
1050        .unwrap_or_else(|| panic!("dead-code SARIF rule {rule_id} has no severity mapping"))
1051}
1052
1053fn dead_code_rule_severity(rules: &RulesConfig, issue_code: &str) -> Option<Severity> {
1054    let severity = match issue_code {
1055        "unused-file" => rules.unused_files,
1056        "unused-export" => rules.unused_exports,
1057        "unused-type" => rules.unused_types,
1058        "private-type-leak" => rules.private_type_leaks,
1059        "unused-dependency" => rules.unused_dependencies,
1060        "unused-dev-dependency" => rules.unused_dev_dependencies,
1061        "unused-optional-dependency" => rules.unused_optional_dependencies,
1062        "type-only-dependency" => rules.type_only_dependencies,
1063        "test-only-dependency" => rules.test_only_dependencies,
1064        "unused-enum-member" => rules.unused_enum_members,
1065        "unused-class-member" => rules.unused_class_members,
1066        "unused-store-member" => rules.unused_store_members,
1067        "unresolved-import" => rules.unresolved_imports,
1068        "unlisted-dependency" => rules.unlisted_dependencies,
1069        "duplicate-export" => rules.duplicate_exports,
1070        "circular-dependency" => rules.circular_dependencies,
1071        "re-export-cycle" => rules.re_export_cycle,
1072        "boundary-violation" | "boundary-coverage" | "boundary-call-violation" => {
1073            rules.boundary_violation
1074        }
1075        "policy-violation" => rules.policy_violation,
1076        "invalid-client-export" => rules.invalid_client_export,
1077        "mixed-client-server-barrel" => rules.mixed_client_server_barrel,
1078        "misplaced-directive" => rules.misplaced_directive,
1079        "unprovided-inject" => rules.unprovided_injects,
1080        "unrendered-component" => rules.unrendered_components,
1081        "unused-component-prop" => rules.unused_component_props,
1082        "unused-component-emit" => rules.unused_component_emits,
1083        "unused-component-input" => rules.unused_component_inputs,
1084        "unused-component-output" => rules.unused_component_outputs,
1085        "unused-svelte-event" => rules.unused_svelte_events,
1086        "unused-server-action" => rules.unused_server_actions,
1087        "unused-load-data-key" => rules.unused_load_data_keys,
1088        "prop-drilling" => rules.prop_drilling,
1089        "thin-wrapper" => rules.thin_wrapper,
1090        "duplicate-prop-shape" => rules.duplicate_prop_shape,
1091        "route-collision" => rules.route_collision,
1092        "dynamic-segment-name-conflict" => rules.dynamic_segment_name_conflict,
1093        "stale-suppression" => rules.stale_suppressions,
1094        "unused-catalog-entry" => rules.unused_catalog_entries,
1095        "empty-catalog-group" => rules.empty_catalog_groups,
1096        "unresolved-catalog-reference" => rules.unresolved_catalog_references,
1097        "unused-dependency-override" => rules.unused_dependency_overrides,
1098        "misconfigured-dependency-override" => rules.misconfigured_dependency_overrides,
1099        _ => return None,
1100    };
1101    Some(severity)
1102}
1103
1104#[must_use]
1105pub fn build_sarif(
1106    results: &AnalysisResults,
1107    root: &Path,
1108    rules: &RulesConfig,
1109    rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1110) -> serde_json::Value {
1111    let mut sarif_results = Vec::new();
1112    let mut snippets = SourceSnippetCache::default();
1113    let ctx = SarifCtx {
1114        results,
1115        root,
1116        rules,
1117    };
1118
1119    push_primary_dead_code_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1120    push_dependency_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1121    push_member_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1122    push_sarif_results(
1123        &mut sarif_results,
1124        &results.unresolved_imports,
1125        &mut snippets,
1126        |i| {
1127            sarif_unresolved_import_fields(
1128                &i.import,
1129                root,
1130                severity_to_sarif_level(rules.unresolved_imports),
1131            )
1132        },
1133    );
1134    push_misc_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1135    push_graph_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1136    push_catalog_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1137
1138    let sarif_rules = build_sarif_rules(rules, rule_builder);
1139    sarif_document(&sarif_results, &sarif_rules)
1140}
1141
1142fn push_primary_dead_code_sarif_results(
1143    sarif_results: &mut Vec<serde_json::Value>,
1144    ctx: &SarifCtx<'_>,
1145    snippets: &mut SourceSnippetCache,
1146) {
1147    let SarifCtx {
1148        results,
1149        root,
1150        rules,
1151    } = *ctx;
1152
1153    push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1154        sarif_unused_file_fields(
1155            &finding.file,
1156            root,
1157            severity_to_sarif_level(rules.unused_files),
1158        )
1159    });
1160    push_sarif_results(
1161        sarif_results,
1162        &results.unused_exports,
1163        snippets,
1164        |finding| {
1165            sarif_export_fields(
1166                &finding.export,
1167                root,
1168                "fallow/unused-export",
1169                severity_to_sarif_level(rules.unused_exports),
1170                "Export",
1171                "Re-export",
1172            )
1173        },
1174    );
1175    push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1176        sarif_export_fields(
1177            &finding.export,
1178            root,
1179            "fallow/unused-type",
1180            severity_to_sarif_level(rules.unused_types),
1181            "Type export",
1182            "Type re-export",
1183        )
1184    });
1185    push_sarif_results(
1186        sarif_results,
1187        &results.private_type_leaks,
1188        snippets,
1189        |finding| {
1190            sarif_private_type_leak_fields(
1191                &finding.leak,
1192                root,
1193                severity_to_sarif_level(rules.private_type_leaks),
1194            )
1195        },
1196    );
1197}
1198
1199fn sarif_document(
1200    sarif_results: &[serde_json::Value],
1201    sarif_rules: &[serde_json::Value],
1202) -> serde_json::Value {
1203    build_sarif_document(SarifDocumentInput {
1204        results: sarif_results,
1205        rules: sarif_rules,
1206        tool_version: env!("CARGO_PKG_VERSION"),
1207    })
1208}
1209
1210fn push_dependency_sarif_results(
1211    sarif_results: &mut Vec<serde_json::Value>,
1212    ctx: &SarifCtx<'_>,
1213    snippets: &mut SourceSnippetCache,
1214) {
1215    push_unused_dependency_sarif_results(sarif_results, ctx, snippets);
1216    push_classified_dependency_sarif_results(sarif_results, ctx, snippets);
1217}
1218
1219/// Push SARIF results for unused runtime, dev, and optional dependencies.
1220fn push_unused_dependency_sarif_results(
1221    sarif_results: &mut Vec<serde_json::Value>,
1222    ctx: &SarifCtx<'_>,
1223    snippets: &mut SourceSnippetCache,
1224) {
1225    let SarifCtx {
1226        results,
1227        root,
1228        rules,
1229    } = *ctx;
1230
1231    push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1232        sarif_dep_fields(
1233            &d.dep,
1234            root,
1235            "fallow/unused-dependency",
1236            severity_to_sarif_level(rules.unused_dependencies),
1237            "dependencies",
1238        )
1239    });
1240    push_sarif_results(
1241        sarif_results,
1242        &results.unused_dev_dependencies,
1243        snippets,
1244        |d| {
1245            sarif_dep_fields(
1246                &d.dep,
1247                root,
1248                "fallow/unused-dev-dependency",
1249                severity_to_sarif_level(rules.unused_dev_dependencies),
1250                "devDependencies",
1251            )
1252        },
1253    );
1254    push_sarif_results(
1255        sarif_results,
1256        &results.unused_optional_dependencies,
1257        snippets,
1258        |d| {
1259            sarif_dep_fields(
1260                &d.dep,
1261                root,
1262                "fallow/unused-optional-dependency",
1263                severity_to_sarif_level(rules.unused_optional_dependencies),
1264                "optionalDependencies",
1265            )
1266        },
1267    );
1268}
1269
1270/// Push SARIF results for type-only and test-only dependency misclassifications.
1271fn push_classified_dependency_sarif_results(
1272    sarif_results: &mut Vec<serde_json::Value>,
1273    ctx: &SarifCtx<'_>,
1274    snippets: &mut SourceSnippetCache,
1275) {
1276    let SarifCtx {
1277        results,
1278        root,
1279        rules,
1280    } = *ctx;
1281
1282    push_sarif_results(
1283        sarif_results,
1284        &results.type_only_dependencies,
1285        snippets,
1286        |d| {
1287            sarif_type_only_dep_fields(
1288                &d.dep,
1289                root,
1290                severity_to_sarif_level(rules.type_only_dependencies),
1291            )
1292        },
1293    );
1294    push_sarif_results(
1295        sarif_results,
1296        &results.test_only_dependencies,
1297        snippets,
1298        |d| {
1299            sarif_test_only_dep_fields(
1300                &d.dep,
1301                root,
1302                severity_to_sarif_level(rules.test_only_dependencies),
1303            )
1304        },
1305    );
1306}
1307
1308fn push_member_sarif_results(
1309    sarif_results: &mut Vec<serde_json::Value>,
1310    ctx: &SarifCtx<'_>,
1311    snippets: &mut SourceSnippetCache,
1312) {
1313    let SarifCtx {
1314        results,
1315        root,
1316        rules,
1317    } = *ctx;
1318
1319    push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1320        sarif_member_fields(
1321            &m.member,
1322            root,
1323            "fallow/unused-enum-member",
1324            severity_to_sarif_level(rules.unused_enum_members),
1325            "Enum",
1326        )
1327    });
1328    push_sarif_results(
1329        sarif_results,
1330        &results.unused_class_members,
1331        snippets,
1332        |m| {
1333            sarif_member_fields(
1334                &m.member,
1335                root,
1336                "fallow/unused-class-member",
1337                severity_to_sarif_level(rules.unused_class_members),
1338                "Class",
1339            )
1340        },
1341    );
1342    push_sarif_results(
1343        sarif_results,
1344        &results.unused_store_members,
1345        snippets,
1346        |m| {
1347            sarif_member_fields(
1348                &m.member,
1349                root,
1350                "fallow/unused-store-member",
1351                severity_to_sarif_level(rules.unused_store_members),
1352                "Store",
1353            )
1354        },
1355    );
1356}
1357
1358fn push_misc_sarif_results(
1359    sarif_results: &mut Vec<serde_json::Value>,
1360    ctx: &SarifCtx<'_>,
1361    snippets: &mut SourceSnippetCache,
1362) {
1363    let SarifCtx {
1364        results,
1365        root,
1366        rules,
1367    } = *ctx;
1368
1369    if !results.unlisted_dependencies.is_empty() {
1370        push_sarif_unlisted_deps(
1371            sarif_results,
1372            &results.unlisted_dependencies,
1373            root,
1374            severity_to_sarif_level(rules.unlisted_dependencies),
1375            snippets,
1376        );
1377    }
1378    if !results.duplicate_exports.is_empty() {
1379        push_sarif_duplicate_exports(
1380            sarif_results,
1381            &results.duplicate_exports,
1382            root,
1383            severity_to_sarif_level(rules.duplicate_exports),
1384            snippets,
1385        );
1386    }
1387}
1388
1389/// Push the component-contract SARIF results (`unused-component-prop` and
1390/// `unused-component-emit`). Extracted from `push_graph_sarif_results` to keep
1391/// that function under the unit-size lint.
1392fn push_component_contract_sarif_results(
1393    sarif_results: &mut Vec<serde_json::Value>,
1394    ctx: &SarifCtx<'_>,
1395    snippets: &mut SourceSnippetCache,
1396) {
1397    push_component_member_sarif_results(sarif_results, ctx, snippets);
1398    push_component_framework_sarif_results(sarif_results, ctx, snippets);
1399    push_component_shape_sarif_results(sarif_results, ctx, snippets);
1400}
1401
1402/// Push SARIF results for unused component props, emits, inputs, and outputs.
1403fn push_component_member_sarif_results(
1404    sarif_results: &mut Vec<serde_json::Value>,
1405    ctx: &SarifCtx<'_>,
1406    snippets: &mut SourceSnippetCache,
1407) {
1408    let SarifCtx {
1409        results,
1410        root,
1411        rules,
1412    } = *ctx;
1413
1414    push_sarif_results(
1415        sarif_results,
1416        &results.unused_component_props,
1417        snippets,
1418        |p| {
1419            sarif_unused_component_prop_fields(
1420                &p.prop,
1421                root,
1422                severity_to_sarif_level(rules.unused_component_props),
1423            )
1424        },
1425    );
1426    push_sarif_results(
1427        sarif_results,
1428        &results.unused_component_emits,
1429        snippets,
1430        |e| {
1431            sarif_unused_component_emit_fields(
1432                &e.emit,
1433                root,
1434                severity_to_sarif_level(rules.unused_component_emits),
1435            )
1436        },
1437    );
1438    push_sarif_results(
1439        sarif_results,
1440        &results.unused_component_inputs,
1441        snippets,
1442        |i| {
1443            sarif_unused_component_input_fields(
1444                &i.input,
1445                root,
1446                severity_to_sarif_level(rules.unused_component_inputs),
1447            )
1448        },
1449    );
1450    push_sarif_results(
1451        sarif_results,
1452        &results.unused_component_outputs,
1453        snippets,
1454        |o| {
1455            sarif_unused_component_output_fields(
1456                &o.output,
1457                root,
1458                severity_to_sarif_level(rules.unused_component_outputs),
1459            )
1460        },
1461    );
1462}
1463
1464/// Push SARIF results for Svelte events, server actions, and load-data keys.
1465fn push_component_framework_sarif_results(
1466    sarif_results: &mut Vec<serde_json::Value>,
1467    ctx: &SarifCtx<'_>,
1468    snippets: &mut SourceSnippetCache,
1469) {
1470    let SarifCtx {
1471        results,
1472        root,
1473        rules,
1474    } = *ctx;
1475
1476    push_sarif_results(
1477        sarif_results,
1478        &results.unused_svelte_events,
1479        snippets,
1480        |e| {
1481            sarif_unused_svelte_event_fields(
1482                &e.event,
1483                root,
1484                severity_to_sarif_level(rules.unused_svelte_events),
1485            )
1486        },
1487    );
1488    push_sarif_results(
1489        sarif_results,
1490        &results.unused_server_actions,
1491        snippets,
1492        |a| {
1493            sarif_unused_server_action_fields(
1494                &a.action,
1495                root,
1496                severity_to_sarif_level(rules.unused_server_actions),
1497            )
1498        },
1499    );
1500    push_sarif_results(
1501        sarif_results,
1502        &results.unused_load_data_keys,
1503        snippets,
1504        |k| {
1505            sarif_unused_load_data_key_fields(
1506                &k.key,
1507                root,
1508                severity_to_sarif_level(rules.unused_load_data_keys),
1509            )
1510        },
1511    );
1512}
1513
1514/// Push SARIF results for prop drilling, thin wrappers, and duplicate prop shapes.
1515fn push_component_shape_sarif_results(
1516    sarif_results: &mut Vec<serde_json::Value>,
1517    ctx: &SarifCtx<'_>,
1518    snippets: &mut SourceSnippetCache,
1519) {
1520    let SarifCtx {
1521        results,
1522        root,
1523        rules,
1524    } = *ctx;
1525
1526    push_sarif_results(
1527        sarif_results,
1528        &results.prop_drilling_chains,
1529        snippets,
1530        |c| {
1531            sarif_prop_drilling_fields(&c.chain, root, severity_to_sarif_level(rules.prop_drilling))
1532        },
1533    );
1534    push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1535        sarif_thin_wrapper_fields(
1536            &w.wrapper,
1537            root,
1538            severity_to_sarif_level(rules.thin_wrapper),
1539        )
1540    });
1541    push_sarif_results(
1542        sarif_results,
1543        &results.duplicate_prop_shapes,
1544        snippets,
1545        |d| {
1546            sarif_duplicate_prop_shape_fields(
1547                &d.shape,
1548                root,
1549                severity_to_sarif_level(rules.duplicate_prop_shape),
1550            )
1551        },
1552    );
1553}
1554
1555fn push_graph_sarif_results(
1556    sarif_results: &mut Vec<serde_json::Value>,
1557    ctx: &SarifCtx<'_>,
1558    snippets: &mut SourceSnippetCache,
1559) {
1560    push_structure_sarif_results(sarif_results, ctx, snippets);
1561    push_framework_sarif_results(sarif_results, ctx, snippets);
1562    push_route_sarif_results(sarif_results, ctx, snippets);
1563    push_suppression_sarif_results(sarif_results, ctx, snippets);
1564}
1565
1566fn push_structure_sarif_results(
1567    sarif_results: &mut Vec<serde_json::Value>,
1568    ctx: &SarifCtx<'_>,
1569    snippets: &mut SourceSnippetCache,
1570) {
1571    push_cycle_sarif_results(sarif_results, ctx, snippets);
1572    push_boundary_sarif_results(sarif_results, ctx, snippets);
1573}
1574
1575/// Push SARIF results for circular dependencies and re-export cycles.
1576fn push_cycle_sarif_results(
1577    sarif_results: &mut Vec<serde_json::Value>,
1578    ctx: &SarifCtx<'_>,
1579    snippets: &mut SourceSnippetCache,
1580) {
1581    let SarifCtx {
1582        results,
1583        root,
1584        rules,
1585    } = *ctx;
1586
1587    push_sarif_results(
1588        sarif_results,
1589        &results.circular_dependencies,
1590        snippets,
1591        |c| {
1592            sarif_circular_dep_fields(
1593                &c.cycle,
1594                root,
1595                severity_to_sarif_level(rules.circular_dependencies),
1596            )
1597        },
1598    );
1599    push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1600        sarif_re_export_cycle_fields(
1601            &c.cycle,
1602            root,
1603            severity_to_sarif_level(rules.re_export_cycle),
1604        )
1605    });
1606}
1607
1608/// Push SARIF results for boundary violations, coverage, calls, and policy violations.
1609fn push_boundary_sarif_results(
1610    sarif_results: &mut Vec<serde_json::Value>,
1611    ctx: &SarifCtx<'_>,
1612    snippets: &mut SourceSnippetCache,
1613) {
1614    let SarifCtx {
1615        results,
1616        root,
1617        rules,
1618    } = *ctx;
1619
1620    push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1621        sarif_boundary_violation_fields(
1622            &v.violation,
1623            root,
1624            severity_to_sarif_level(rules.boundary_violation),
1625        )
1626    });
1627    push_sarif_results(
1628        sarif_results,
1629        &results.boundary_coverage_violations,
1630        snippets,
1631        |v| {
1632            sarif_boundary_coverage_fields(
1633                &v.violation,
1634                root,
1635                severity_to_sarif_level(rules.boundary_violation),
1636            )
1637        },
1638    );
1639    push_sarif_results(
1640        sarif_results,
1641        &results.boundary_call_violations,
1642        snippets,
1643        |v| {
1644            sarif_boundary_call_fields(
1645                &v.violation,
1646                root,
1647                severity_to_sarif_level(rules.boundary_violation),
1648            )
1649        },
1650    );
1651    push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1652        sarif_policy_violation_fields(&v.violation, root)
1653    });
1654}
1655
1656fn push_framework_sarif_results(
1657    sarif_results: &mut Vec<serde_json::Value>,
1658    ctx: &SarifCtx<'_>,
1659    snippets: &mut SourceSnippetCache,
1660) {
1661    push_framework_boundary_sarif_results(sarif_results, ctx, snippets);
1662    push_component_contract_sarif_results(sarif_results, ctx, snippets);
1663}
1664
1665/// Push SARIF results for client exports, barrels, directives, injects, and unrendered components.
1666fn push_framework_boundary_sarif_results(
1667    sarif_results: &mut Vec<serde_json::Value>,
1668    ctx: &SarifCtx<'_>,
1669    snippets: &mut SourceSnippetCache,
1670) {
1671    let SarifCtx {
1672        results,
1673        root,
1674        rules,
1675    } = *ctx;
1676
1677    push_sarif_results(
1678        sarif_results,
1679        &results.invalid_client_exports,
1680        snippets,
1681        |e| {
1682            sarif_invalid_client_export_fields(
1683                &e.export,
1684                root,
1685                severity_to_sarif_level(rules.invalid_client_export),
1686            )
1687        },
1688    );
1689    push_sarif_results(
1690        sarif_results,
1691        &results.mixed_client_server_barrels,
1692        snippets,
1693        |b| {
1694            sarif_mixed_client_server_barrel_fields(
1695                &b.barrel,
1696                root,
1697                severity_to_sarif_level(rules.mixed_client_server_barrel),
1698            )
1699        },
1700    );
1701    push_sarif_results(
1702        sarif_results,
1703        &results.misplaced_directives,
1704        snippets,
1705        |d| {
1706            sarif_misplaced_directive_fields(
1707                &d.directive_site,
1708                root,
1709                severity_to_sarif_level(rules.misplaced_directive),
1710            )
1711        },
1712    );
1713    push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
1714        sarif_unprovided_inject_fields(
1715            &i.inject,
1716            root,
1717            severity_to_sarif_level(rules.unprovided_injects),
1718        )
1719    });
1720    push_sarif_results(
1721        sarif_results,
1722        &results.unrendered_components,
1723        snippets,
1724        |c| {
1725            sarif_unrendered_component_fields(
1726                &c.component,
1727                root,
1728                severity_to_sarif_level(rules.unrendered_components),
1729            )
1730        },
1731    );
1732}
1733
1734fn push_route_sarif_results(
1735    sarif_results: &mut Vec<serde_json::Value>,
1736    ctx: &SarifCtx<'_>,
1737    snippets: &mut SourceSnippetCache,
1738) {
1739    let SarifCtx {
1740        results,
1741        root,
1742        rules,
1743    } = *ctx;
1744
1745    push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
1746        sarif_route_collision_fields(
1747            &c.collision,
1748            root,
1749            severity_to_sarif_level(rules.route_collision),
1750        )
1751    });
1752    push_sarif_results(
1753        sarif_results,
1754        &results.dynamic_segment_name_conflicts,
1755        snippets,
1756        |c| {
1757            sarif_dynamic_segment_name_conflict_fields(
1758                &c.conflict,
1759                root,
1760                severity_to_sarif_level(rules.dynamic_segment_name_conflict),
1761            )
1762        },
1763    );
1764}
1765
1766fn push_suppression_sarif_results(
1767    sarif_results: &mut Vec<serde_json::Value>,
1768    ctx: &SarifCtx<'_>,
1769    snippets: &mut SourceSnippetCache,
1770) {
1771    let SarifCtx {
1772        results,
1773        root,
1774        rules,
1775    } = *ctx;
1776
1777    push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1778        sarif_stale_suppression_fields(
1779            s,
1780            root,
1781            severity_to_sarif_level(stale_suppression_severity(s, rules)),
1782        )
1783    });
1784}
1785
1786fn push_catalog_sarif_results(
1787    sarif_results: &mut Vec<serde_json::Value>,
1788    ctx: &SarifCtx<'_>,
1789    snippets: &mut SourceSnippetCache,
1790) {
1791    push_catalog_entry_sarif_results(sarif_results, ctx, snippets);
1792    push_dependency_override_sarif_results(sarif_results, ctx, snippets);
1793}
1794
1795/// Push SARIF results for unused catalog entries, empty groups, and unresolved references.
1796fn push_catalog_entry_sarif_results(
1797    sarif_results: &mut Vec<serde_json::Value>,
1798    ctx: &SarifCtx<'_>,
1799    snippets: &mut SourceSnippetCache,
1800) {
1801    let SarifCtx {
1802        results,
1803        root,
1804        rules,
1805    } = *ctx;
1806
1807    push_sarif_results(
1808        sarif_results,
1809        &results.unused_catalog_entries,
1810        snippets,
1811        |e| {
1812            sarif_unused_catalog_entry_fields(
1813                e,
1814                root,
1815                severity_to_sarif_level(rules.unused_catalog_entries),
1816            )
1817        },
1818    );
1819    push_sarif_results(
1820        sarif_results,
1821        &results.empty_catalog_groups,
1822        snippets,
1823        |g| {
1824            sarif_empty_catalog_group_fields(
1825                g,
1826                root,
1827                severity_to_sarif_level(rules.empty_catalog_groups),
1828            )
1829        },
1830    );
1831    push_sarif_results(
1832        sarif_results,
1833        &results.unresolved_catalog_references,
1834        snippets,
1835        |f| {
1836            sarif_unresolved_catalog_reference_fields(
1837                f,
1838                root,
1839                severity_to_sarif_level(rules.unresolved_catalog_references),
1840            )
1841        },
1842    );
1843}
1844
1845/// Push SARIF results for unused and misconfigured dependency overrides.
1846fn push_dependency_override_sarif_results(
1847    sarif_results: &mut Vec<serde_json::Value>,
1848    ctx: &SarifCtx<'_>,
1849    snippets: &mut SourceSnippetCache,
1850) {
1851    let SarifCtx {
1852        results,
1853        root,
1854        rules,
1855    } = *ctx;
1856
1857    push_sarif_results(
1858        sarif_results,
1859        &results.unused_dependency_overrides,
1860        snippets,
1861        |f| {
1862            sarif_unused_dependency_override_fields(
1863                f,
1864                root,
1865                severity_to_sarif_level(rules.unused_dependency_overrides),
1866            )
1867        },
1868    );
1869    push_sarif_results(
1870        sarif_results,
1871        &results.misconfigured_dependency_overrides,
1872        snippets,
1873        |f| {
1874            sarif_misconfigured_dependency_override_fields(
1875                f,
1876                root,
1877                severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1878            )
1879        },
1880    );
1881}
1882
1883#[cfg(test)]
1884mod tests {
1885    use std::collections::BTreeSet;
1886    use std::path::Path;
1887
1888    use fallow_config::RulesConfig;
1889    use fallow_types::results::AnalysisResults;
1890
1891    use super::*;
1892
1893    fn test_rule_builder(id: &str, description: &str, level: &str) -> serde_json::Value {
1894        serde_json::json!({
1895            "id": id,
1896            "shortDescription": { "text": description },
1897            "defaultConfiguration": { "level": level }
1898        })
1899    }
1900
1901    #[test]
1902    fn sarif_rule_list_is_backed_by_issue_contracts() {
1903        let sarif = build_sarif(
1904            &AnalysisResults::default(),
1905            Path::new("."),
1906            &RulesConfig::default(),
1907            &test_rule_builder,
1908        );
1909        let Some(rules) = sarif
1910            .pointer("/runs/0/tool/driver/rules")
1911            .and_then(serde_json::Value::as_array)
1912        else {
1913            panic!("SARIF document should contain driver rules");
1914        };
1915
1916        let actual_ids = rules
1917            .iter()
1918            .filter_map(|rule| {
1919                rule.get("id")
1920                    .and_then(serde_json::Value::as_str)
1921                    .map(str::to_owned)
1922            })
1923            .collect::<BTreeSet<_>>();
1924        let expected_ids = issue_output_contracts()
1925            .flat_map(|contract| contract.sarif_rule_ids)
1926            .collect::<BTreeSet<_>>();
1927
1928        assert_eq!(actual_ids, expected_ids);
1929
1930        for rule in rules {
1931            let id = rule
1932                .get("id")
1933                .and_then(serde_json::Value::as_str)
1934                .expect("SARIF rule should have id");
1935            let description = rule
1936                .pointer("/shortDescription/text")
1937                .and_then(serde_json::Value::as_str)
1938                .expect("SARIF rule should have short description");
1939            assert_eq!(
1940                description,
1941                issue_sarif_rule_description(id).expect("SARIF rule description should resolve")
1942            );
1943        }
1944    }
1945
1946    #[test]
1947    fn sarif_registry_descriptions_match_explain_short_descriptions() {
1948        let mismatches = issue_output_contracts()
1949            .flat_map(|contract| contract.sarif_rule_ids)
1950            .filter_map(|rule_id| {
1951                let registry = issue_sarif_rule_description(&rule_id)?;
1952                let explain = crate::explain::rule_by_id(&rule_id)?;
1953                (registry != explain.short).then_some(format!(
1954                    "{rule_id}: registry={registry:?}, explain={:?}",
1955                    explain.short
1956                ))
1957            })
1958            .collect::<Vec<_>>();
1959
1960        assert!(
1961            mismatches.is_empty(),
1962            "SARIF registry descriptions drifted from explain short descriptions:\n{}",
1963            mismatches.join("\n")
1964        );
1965    }
1966}