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/// Builds the complete SARIF `run` value for a dead-code analysis.
1049///
1050/// Emits one SARIF result per finding across every dead-code issue kind,
1051/// mapping each configured rule severity to a SARIF level. `rule_builder`
1052/// constructs the tool-driver rule object for a `(rule id, name, help URI)`
1053/// triple so the caller controls rule metadata.
1054#[must_use]
1055pub fn build_dead_code_sarif(
1056    results: &AnalysisResults,
1057    root: &Path,
1058    rules: &RulesConfig,
1059    rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1060) -> serde_json::Value {
1061    let mut sarif_results = Vec::new();
1062    let mut snippets = SourceSnippetCache::with_root(root);
1063    let ctx = SarifCtx {
1064        results,
1065        root,
1066        rules,
1067    };
1068
1069    push_primary_dead_code_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1070    push_dependency_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1071    push_member_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1072    push_sarif_results(
1073        &mut sarif_results,
1074        &results.unresolved_imports,
1075        &mut snippets,
1076        |i| {
1077            sarif_unresolved_import_fields(
1078                &i.import,
1079                root,
1080                severity_to_sarif_level(rules.unresolved_imports),
1081            )
1082        },
1083    );
1084    push_misc_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1085    push_graph_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1086    push_catalog_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1087
1088    let sarif_rules = build_sarif_rules(rules, rule_builder);
1089    sarif_document(&sarif_results, &sarif_rules)
1090}
1091
1092fn push_primary_dead_code_sarif_results(
1093    sarif_results: &mut Vec<serde_json::Value>,
1094    ctx: &SarifCtx<'_>,
1095    snippets: &mut SourceSnippetCache,
1096) {
1097    let SarifCtx {
1098        results,
1099        root,
1100        rules,
1101    } = *ctx;
1102
1103    push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1104        sarif_unused_file_fields(
1105            &finding.file,
1106            root,
1107            severity_to_sarif_level(rules.unused_files),
1108        )
1109    });
1110    push_sarif_results(
1111        sarif_results,
1112        &results.unused_exports,
1113        snippets,
1114        |finding| {
1115            sarif_export_fields(
1116                &finding.export,
1117                root,
1118                "fallow/unused-export",
1119                severity_to_sarif_level(rules.unused_exports),
1120                "Export",
1121                "Re-export",
1122            )
1123        },
1124    );
1125    push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1126        sarif_export_fields(
1127            &finding.export,
1128            root,
1129            "fallow/unused-type",
1130            severity_to_sarif_level(rules.unused_types),
1131            "Type export",
1132            "Type re-export",
1133        )
1134    });
1135    push_sarif_results(
1136        sarif_results,
1137        &results.private_type_leaks,
1138        snippets,
1139        |finding| {
1140            sarif_private_type_leak_fields(
1141                &finding.leak,
1142                root,
1143                severity_to_sarif_level(rules.private_type_leaks),
1144            )
1145        },
1146    );
1147}
1148
1149fn sarif_document(
1150    sarif_results: &[serde_json::Value],
1151    sarif_rules: &[serde_json::Value],
1152) -> serde_json::Value {
1153    build_sarif_document(SarifDocumentInput {
1154        results: sarif_results,
1155        rules: sarif_rules,
1156        tool_version: env!("CARGO_PKG_VERSION"),
1157    })
1158}
1159
1160fn push_dependency_sarif_results(
1161    sarif_results: &mut Vec<serde_json::Value>,
1162    ctx: &SarifCtx<'_>,
1163    snippets: &mut SourceSnippetCache,
1164) {
1165    push_unused_dependency_sarif_results(sarif_results, ctx, snippets);
1166    push_classified_dependency_sarif_results(sarif_results, ctx, snippets);
1167}
1168
1169/// Push SARIF results for unused runtime, dev, and optional dependencies.
1170fn push_unused_dependency_sarif_results(
1171    sarif_results: &mut Vec<serde_json::Value>,
1172    ctx: &SarifCtx<'_>,
1173    snippets: &mut SourceSnippetCache,
1174) {
1175    let SarifCtx {
1176        results,
1177        root,
1178        rules,
1179    } = *ctx;
1180
1181    push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1182        sarif_dep_fields(
1183            &d.dep,
1184            root,
1185            "fallow/unused-dependency",
1186            severity_to_sarif_level(rules.unused_dependencies),
1187            "dependencies",
1188        )
1189    });
1190    push_sarif_results(
1191        sarif_results,
1192        &results.unused_dev_dependencies,
1193        snippets,
1194        |d| {
1195            sarif_dep_fields(
1196                &d.dep,
1197                root,
1198                "fallow/unused-dev-dependency",
1199                severity_to_sarif_level(rules.unused_dev_dependencies),
1200                "devDependencies",
1201            )
1202        },
1203    );
1204    push_sarif_results(
1205        sarif_results,
1206        &results.unused_optional_dependencies,
1207        snippets,
1208        |d| {
1209            sarif_dep_fields(
1210                &d.dep,
1211                root,
1212                "fallow/unused-optional-dependency",
1213                severity_to_sarif_level(rules.unused_optional_dependencies),
1214                "optionalDependencies",
1215            )
1216        },
1217    );
1218}
1219
1220/// Push SARIF results for type-only and test-only dependency misclassifications.
1221fn push_classified_dependency_sarif_results(
1222    sarif_results: &mut Vec<serde_json::Value>,
1223    ctx: &SarifCtx<'_>,
1224    snippets: &mut SourceSnippetCache,
1225) {
1226    let SarifCtx {
1227        results,
1228        root,
1229        rules,
1230    } = *ctx;
1231
1232    push_sarif_results(
1233        sarif_results,
1234        &results.type_only_dependencies,
1235        snippets,
1236        |d| {
1237            sarif_type_only_dep_fields(
1238                &d.dep,
1239                root,
1240                severity_to_sarif_level(rules.type_only_dependencies),
1241            )
1242        },
1243    );
1244    push_sarif_results(
1245        sarif_results,
1246        &results.test_only_dependencies,
1247        snippets,
1248        |d| {
1249            sarif_test_only_dep_fields(
1250                &d.dep,
1251                root,
1252                severity_to_sarif_level(rules.test_only_dependencies),
1253            )
1254        },
1255    );
1256    push_sarif_results(
1257        sarif_results,
1258        &results.dev_dependencies_in_production,
1259        snippets,
1260        |d| {
1261            sarif_dev_dep_in_prod_fields(
1262                &d.dep,
1263                root,
1264                severity_to_sarif_level(rules.dev_dependencies_in_production),
1265            )
1266        },
1267    );
1268}
1269
1270fn push_member_sarif_results(
1271    sarif_results: &mut Vec<serde_json::Value>,
1272    ctx: &SarifCtx<'_>,
1273    snippets: &mut SourceSnippetCache,
1274) {
1275    let SarifCtx {
1276        results,
1277        root,
1278        rules,
1279    } = *ctx;
1280
1281    push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1282        sarif_member_fields(
1283            &m.member,
1284            root,
1285            "fallow/unused-enum-member",
1286            severity_to_sarif_level(rules.unused_enum_members),
1287            "Enum",
1288        )
1289    });
1290    push_sarif_results(
1291        sarif_results,
1292        &results.unused_class_members,
1293        snippets,
1294        |m| {
1295            sarif_member_fields(
1296                &m.member,
1297                root,
1298                "fallow/unused-class-member",
1299                severity_to_sarif_level(rules.unused_class_members),
1300                "Class",
1301            )
1302        },
1303    );
1304    push_sarif_results(
1305        sarif_results,
1306        &results.unused_store_members,
1307        snippets,
1308        |m| {
1309            sarif_member_fields(
1310                &m.member,
1311                root,
1312                "fallow/unused-store-member",
1313                severity_to_sarif_level(rules.unused_store_members),
1314                "Store",
1315            )
1316        },
1317    );
1318}
1319
1320fn push_misc_sarif_results(
1321    sarif_results: &mut Vec<serde_json::Value>,
1322    ctx: &SarifCtx<'_>,
1323    snippets: &mut SourceSnippetCache,
1324) {
1325    let SarifCtx {
1326        results,
1327        root,
1328        rules,
1329    } = *ctx;
1330
1331    if !results.unlisted_dependencies.is_empty() {
1332        push_sarif_unlisted_deps(
1333            sarif_results,
1334            &results.unlisted_dependencies,
1335            root,
1336            severity_to_sarif_level(rules.unlisted_dependencies),
1337            snippets,
1338        );
1339    }
1340    if !results.duplicate_exports.is_empty() {
1341        push_sarif_duplicate_exports(
1342            sarif_results,
1343            &results.duplicate_exports,
1344            root,
1345            severity_to_sarif_level(rules.duplicate_exports),
1346            snippets,
1347        );
1348    }
1349}
1350
1351/// Push the component-contract SARIF results (`unused-component-prop` and
1352/// `unused-component-emit`). Extracted from `push_graph_sarif_results` to keep
1353/// that function under the unit-size lint.
1354fn push_component_contract_sarif_results(
1355    sarif_results: &mut Vec<serde_json::Value>,
1356    ctx: &SarifCtx<'_>,
1357    snippets: &mut SourceSnippetCache,
1358) {
1359    push_component_member_sarif_results(sarif_results, ctx, snippets);
1360    push_component_framework_sarif_results(sarif_results, ctx, snippets);
1361    push_component_shape_sarif_results(sarif_results, ctx, snippets);
1362}
1363
1364/// Push SARIF results for unused component props, emits, inputs, and outputs.
1365fn push_component_member_sarif_results(
1366    sarif_results: &mut Vec<serde_json::Value>,
1367    ctx: &SarifCtx<'_>,
1368    snippets: &mut SourceSnippetCache,
1369) {
1370    let SarifCtx {
1371        results,
1372        root,
1373        rules,
1374    } = *ctx;
1375
1376    push_sarif_results(
1377        sarif_results,
1378        &results.unused_component_props,
1379        snippets,
1380        |p| {
1381            sarif_unused_component_prop_fields(
1382                &p.prop,
1383                root,
1384                severity_to_sarif_level(rules.unused_component_props),
1385            )
1386        },
1387    );
1388    push_sarif_results(
1389        sarif_results,
1390        &results.unused_component_emits,
1391        snippets,
1392        |e| {
1393            sarif_unused_component_emit_fields(
1394                &e.emit,
1395                root,
1396                severity_to_sarif_level(rules.unused_component_emits),
1397            )
1398        },
1399    );
1400    push_sarif_results(
1401        sarif_results,
1402        &results.unused_component_inputs,
1403        snippets,
1404        |i| {
1405            sarif_unused_component_input_fields(
1406                &i.input,
1407                root,
1408                severity_to_sarif_level(rules.unused_component_inputs),
1409            )
1410        },
1411    );
1412    push_sarif_results(
1413        sarif_results,
1414        &results.unused_component_outputs,
1415        snippets,
1416        |o| {
1417            sarif_unused_component_output_fields(
1418                &o.output,
1419                root,
1420                severity_to_sarif_level(rules.unused_component_outputs),
1421            )
1422        },
1423    );
1424}
1425
1426/// Push SARIF results for Svelte events, server actions, and load-data keys.
1427fn push_component_framework_sarif_results(
1428    sarif_results: &mut Vec<serde_json::Value>,
1429    ctx: &SarifCtx<'_>,
1430    snippets: &mut SourceSnippetCache,
1431) {
1432    let SarifCtx {
1433        results,
1434        root,
1435        rules,
1436    } = *ctx;
1437
1438    push_sarif_results(
1439        sarif_results,
1440        &results.unused_svelte_events,
1441        snippets,
1442        |e| {
1443            sarif_unused_svelte_event_fields(
1444                &e.event,
1445                root,
1446                severity_to_sarif_level(rules.unused_svelte_events),
1447            )
1448        },
1449    );
1450    push_sarif_results(
1451        sarif_results,
1452        &results.unused_server_actions,
1453        snippets,
1454        |a| {
1455            sarif_unused_server_action_fields(
1456                &a.action,
1457                root,
1458                severity_to_sarif_level(rules.unused_server_actions),
1459            )
1460        },
1461    );
1462    push_sarif_results(
1463        sarif_results,
1464        &results.unused_load_data_keys,
1465        snippets,
1466        |k| {
1467            sarif_unused_load_data_key_fields(
1468                &k.key,
1469                root,
1470                severity_to_sarif_level(rules.unused_load_data_keys),
1471            )
1472        },
1473    );
1474}
1475
1476/// Push SARIF results for prop drilling, thin wrappers, and duplicate prop shapes.
1477fn push_component_shape_sarif_results(
1478    sarif_results: &mut Vec<serde_json::Value>,
1479    ctx: &SarifCtx<'_>,
1480    snippets: &mut SourceSnippetCache,
1481) {
1482    let SarifCtx {
1483        results,
1484        root,
1485        rules,
1486    } = *ctx;
1487
1488    push_sarif_results(
1489        sarif_results,
1490        &results.prop_drilling_chains,
1491        snippets,
1492        |c| {
1493            sarif_prop_drilling_fields(&c.chain, root, severity_to_sarif_level(rules.prop_drilling))
1494        },
1495    );
1496    push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1497        sarif_thin_wrapper_fields(
1498            &w.wrapper,
1499            root,
1500            severity_to_sarif_level(rules.thin_wrapper),
1501        )
1502    });
1503    push_sarif_results(
1504        sarif_results,
1505        &results.duplicate_prop_shapes,
1506        snippets,
1507        |d| {
1508            sarif_duplicate_prop_shape_fields(
1509                &d.shape,
1510                root,
1511                severity_to_sarif_level(rules.duplicate_prop_shape),
1512            )
1513        },
1514    );
1515}
1516
1517fn push_graph_sarif_results(
1518    sarif_results: &mut Vec<serde_json::Value>,
1519    ctx: &SarifCtx<'_>,
1520    snippets: &mut SourceSnippetCache,
1521) {
1522    push_structure_sarif_results(sarif_results, ctx, snippets);
1523    push_framework_sarif_results(sarif_results, ctx, snippets);
1524    push_route_sarif_results(sarif_results, ctx, snippets);
1525    push_suppression_sarif_results(sarif_results, ctx, snippets);
1526}
1527
1528fn push_structure_sarif_results(
1529    sarif_results: &mut Vec<serde_json::Value>,
1530    ctx: &SarifCtx<'_>,
1531    snippets: &mut SourceSnippetCache,
1532) {
1533    push_cycle_sarif_results(sarif_results, ctx, snippets);
1534    push_boundary_sarif_results(sarif_results, ctx, snippets);
1535}
1536
1537/// Push SARIF results for circular dependencies and re-export cycles.
1538fn push_cycle_sarif_results(
1539    sarif_results: &mut Vec<serde_json::Value>,
1540    ctx: &SarifCtx<'_>,
1541    snippets: &mut SourceSnippetCache,
1542) {
1543    let SarifCtx {
1544        results,
1545        root,
1546        rules,
1547    } = *ctx;
1548
1549    push_sarif_results(
1550        sarif_results,
1551        &results.circular_dependencies,
1552        snippets,
1553        |c| {
1554            sarif_circular_dep_fields(
1555                &c.cycle,
1556                root,
1557                severity_to_sarif_level(rules.circular_dependencies),
1558            )
1559        },
1560    );
1561    push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1562        sarif_re_export_cycle_fields(
1563            &c.cycle,
1564            root,
1565            severity_to_sarif_level(rules.re_export_cycle),
1566        )
1567    });
1568}
1569
1570/// Push SARIF results for boundary violations, coverage, calls, and policy violations.
1571fn push_boundary_sarif_results(
1572    sarif_results: &mut Vec<serde_json::Value>,
1573    ctx: &SarifCtx<'_>,
1574    snippets: &mut SourceSnippetCache,
1575) {
1576    let SarifCtx {
1577        results,
1578        root,
1579        rules,
1580    } = *ctx;
1581
1582    push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1583        sarif_boundary_violation_fields(
1584            &v.violation,
1585            root,
1586            severity_to_sarif_level(rules.boundary_violation),
1587        )
1588    });
1589    push_sarif_results(
1590        sarif_results,
1591        &results.boundary_coverage_violations,
1592        snippets,
1593        |v| {
1594            sarif_boundary_coverage_fields(
1595                &v.violation,
1596                root,
1597                severity_to_sarif_level(rules.boundary_violation),
1598            )
1599        },
1600    );
1601    push_sarif_results(
1602        sarif_results,
1603        &results.boundary_call_violations,
1604        snippets,
1605        |v| {
1606            sarif_boundary_call_fields(
1607                &v.violation,
1608                root,
1609                severity_to_sarif_level(rules.boundary_violation),
1610            )
1611        },
1612    );
1613    push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1614        sarif_policy_violation_fields(&v.violation, root)
1615    });
1616}
1617
1618fn push_framework_sarif_results(
1619    sarif_results: &mut Vec<serde_json::Value>,
1620    ctx: &SarifCtx<'_>,
1621    snippets: &mut SourceSnippetCache,
1622) {
1623    push_framework_boundary_sarif_results(sarif_results, ctx, snippets);
1624    push_component_contract_sarif_results(sarif_results, ctx, snippets);
1625}
1626
1627/// Push SARIF results for client exports, barrels, directives, injects, and unrendered components.
1628fn push_framework_boundary_sarif_results(
1629    sarif_results: &mut Vec<serde_json::Value>,
1630    ctx: &SarifCtx<'_>,
1631    snippets: &mut SourceSnippetCache,
1632) {
1633    let SarifCtx {
1634        results,
1635        root,
1636        rules,
1637    } = *ctx;
1638
1639    push_sarif_results(
1640        sarif_results,
1641        &results.invalid_client_exports,
1642        snippets,
1643        |e| {
1644            sarif_invalid_client_export_fields(
1645                &e.export,
1646                root,
1647                severity_to_sarif_level(rules.invalid_client_export),
1648            )
1649        },
1650    );
1651    push_sarif_results(
1652        sarif_results,
1653        &results.mixed_client_server_barrels,
1654        snippets,
1655        |b| {
1656            sarif_mixed_client_server_barrel_fields(
1657                &b.barrel,
1658                root,
1659                severity_to_sarif_level(rules.mixed_client_server_barrel),
1660            )
1661        },
1662    );
1663    push_sarif_results(
1664        sarif_results,
1665        &results.misplaced_directives,
1666        snippets,
1667        |d| {
1668            sarif_misplaced_directive_fields(
1669                &d.directive_site,
1670                root,
1671                severity_to_sarif_level(rules.misplaced_directive),
1672            )
1673        },
1674    );
1675    push_framework_render_sarif_results(sarif_results, ctx, snippets);
1676}
1677
1678fn push_framework_render_sarif_results(
1679    sarif_results: &mut Vec<serde_json::Value>,
1680    ctx: &SarifCtx<'_>,
1681    snippets: &mut SourceSnippetCache,
1682) {
1683    let SarifCtx {
1684        results,
1685        root,
1686        rules,
1687    } = *ctx;
1688
1689    push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
1690        sarif_unprovided_inject_fields(
1691            &i.inject,
1692            root,
1693            severity_to_sarif_level(rules.unprovided_injects),
1694        )
1695    });
1696    push_sarif_results(
1697        sarif_results,
1698        &results.unrendered_components,
1699        snippets,
1700        |c| {
1701            sarif_unrendered_component_fields(
1702                &c.component,
1703                root,
1704                severity_to_sarif_level(rules.unrendered_components),
1705            )
1706        },
1707    );
1708}
1709
1710fn push_route_sarif_results(
1711    sarif_results: &mut Vec<serde_json::Value>,
1712    ctx: &SarifCtx<'_>,
1713    snippets: &mut SourceSnippetCache,
1714) {
1715    let SarifCtx {
1716        results,
1717        root,
1718        rules,
1719    } = *ctx;
1720
1721    push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
1722        sarif_route_collision_fields(
1723            &c.collision,
1724            root,
1725            severity_to_sarif_level(rules.route_collision),
1726        )
1727    });
1728    push_sarif_results(
1729        sarif_results,
1730        &results.dynamic_segment_name_conflicts,
1731        snippets,
1732        |c| {
1733            sarif_dynamic_segment_name_conflict_fields(
1734                &c.conflict,
1735                root,
1736                severity_to_sarif_level(rules.dynamic_segment_name_conflict),
1737            )
1738        },
1739    );
1740}
1741
1742fn push_suppression_sarif_results(
1743    sarif_results: &mut Vec<serde_json::Value>,
1744    ctx: &SarifCtx<'_>,
1745    snippets: &mut SourceSnippetCache,
1746) {
1747    let SarifCtx {
1748        results,
1749        root,
1750        rules,
1751    } = *ctx;
1752
1753    push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1754        sarif_stale_suppression_fields(
1755            s,
1756            root,
1757            severity_to_sarif_level(stale_suppression_severity(s, rules)),
1758        )
1759    });
1760}
1761
1762fn push_catalog_sarif_results(
1763    sarif_results: &mut Vec<serde_json::Value>,
1764    ctx: &SarifCtx<'_>,
1765    snippets: &mut SourceSnippetCache,
1766) {
1767    push_catalog_entry_sarif_results(sarif_results, ctx, snippets);
1768    push_dependency_override_sarif_results(sarif_results, ctx, snippets);
1769}
1770
1771/// Push SARIF results for unused catalog entries, empty groups, and unresolved references.
1772fn push_catalog_entry_sarif_results(
1773    sarif_results: &mut Vec<serde_json::Value>,
1774    ctx: &SarifCtx<'_>,
1775    snippets: &mut SourceSnippetCache,
1776) {
1777    let SarifCtx {
1778        results,
1779        root,
1780        rules,
1781    } = *ctx;
1782
1783    push_sarif_results(
1784        sarif_results,
1785        &results.unused_catalog_entries,
1786        snippets,
1787        |e| {
1788            sarif_unused_catalog_entry_fields(
1789                e,
1790                root,
1791                severity_to_sarif_level(rules.unused_catalog_entries),
1792            )
1793        },
1794    );
1795    push_sarif_results(
1796        sarif_results,
1797        &results.empty_catalog_groups,
1798        snippets,
1799        |g| {
1800            sarif_empty_catalog_group_fields(
1801                g,
1802                root,
1803                severity_to_sarif_level(rules.empty_catalog_groups),
1804            )
1805        },
1806    );
1807    push_sarif_results(
1808        sarif_results,
1809        &results.unresolved_catalog_references,
1810        snippets,
1811        |f| {
1812            sarif_unresolved_catalog_reference_fields(
1813                f,
1814                root,
1815                severity_to_sarif_level(rules.unresolved_catalog_references),
1816            )
1817        },
1818    );
1819}
1820
1821/// Push SARIF results for unused and misconfigured dependency overrides.
1822fn push_dependency_override_sarif_results(
1823    sarif_results: &mut Vec<serde_json::Value>,
1824    ctx: &SarifCtx<'_>,
1825    snippets: &mut SourceSnippetCache,
1826) {
1827    let SarifCtx {
1828        results,
1829        root,
1830        rules,
1831    } = *ctx;
1832
1833    push_sarif_results(
1834        sarif_results,
1835        &results.unused_dependency_overrides,
1836        snippets,
1837        |f| {
1838            sarif_unused_dependency_override_fields(
1839                f,
1840                root,
1841                severity_to_sarif_level(rules.unused_dependency_overrides),
1842            )
1843        },
1844    );
1845    push_sarif_results(
1846        sarif_results,
1847        &results.misconfigured_dependency_overrides,
1848        snippets,
1849        |f| {
1850            sarif_misconfigured_dependency_override_fields(
1851                f,
1852                root,
1853                severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1854            )
1855        },
1856    );
1857}
1858
1859#[cfg(test)]
1860mod tests {
1861    use std::collections::BTreeSet;
1862    use std::path::Path;
1863
1864    use fallow_config::RulesConfig;
1865    use fallow_types::results::AnalysisResults;
1866
1867    use super::*;
1868
1869    fn test_rule_builder(id: &str, description: &str, level: &str) -> serde_json::Value {
1870        serde_json::json!({
1871            "id": id,
1872            "shortDescription": { "text": description },
1873            "defaultConfiguration": { "level": level }
1874        })
1875    }
1876
1877    #[test]
1878    fn sarif_rule_list_is_backed_by_issue_contracts() {
1879        let sarif = build_dead_code_sarif(
1880            &AnalysisResults::default(),
1881            Path::new("."),
1882            &RulesConfig::default(),
1883            &test_rule_builder,
1884        );
1885        let Some(rules) = sarif
1886            .pointer("/runs/0/tool/driver/rules")
1887            .and_then(serde_json::Value::as_array)
1888        else {
1889            panic!("SARIF document should contain driver rules");
1890        };
1891
1892        let actual_ids = rules
1893            .iter()
1894            .filter_map(|rule| {
1895                rule.get("id")
1896                    .and_then(serde_json::Value::as_str)
1897                    .map(str::to_owned)
1898            })
1899            .collect::<BTreeSet<_>>();
1900        let expected_ids = issue_output_contracts()
1901            .flat_map(|contract| contract.sarif_rule_ids)
1902            .collect::<BTreeSet<_>>();
1903
1904        assert_eq!(actual_ids, expected_ids);
1905
1906        for rule in rules {
1907            let id = rule
1908                .get("id")
1909                .and_then(serde_json::Value::as_str)
1910                .expect("SARIF rule should have id");
1911            let description = rule
1912                .pointer("/shortDescription/text")
1913                .and_then(serde_json::Value::as_str)
1914                .expect("SARIF rule should have short description");
1915            assert_eq!(
1916                description,
1917                issue_sarif_rule_description(id).expect("SARIF rule description should resolve")
1918            );
1919        }
1920    }
1921}