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