Skip to main content

fallow_api/
dead_code_sarif.rs

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