Skip to main content

fallow_output/
dead_code_sarif.rs

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