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