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, DeprecatedExportInUse, DevDependencyInProduction, DuplicatePropShape,
18        DynamicSegmentNameConflict, InvalidClientExport, MisplacedDirective,
19        MixedClientServerBarrel, PolicyViolation, PolicyViolationSeverity, PrivateTypeLeak,
20        PropDrillingChain, RouteCollision, StaleSuppression, TestOnlyDependency, ThinWrapper,
21        TypeOnlyDependency, UnproducedCatchTag, UnprovidedInject, UnrenderedComponent,
22        UnresolvedImport, UnusedComponentEmit, UnusedComponentInput, UnusedComponentOutput,
23        UnusedComponentProp, UnusedDependency, UnusedEffectLayer, UnusedExport, UnusedFile,
24        UnusedMember, UnusedServerAction, UnusedSvelteEvent, UnusedTaggedError,
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
56/// The SARIF level for one finding: its saved severity when the finding
57/// carries one, otherwise the configured rule severity (a saved report from an
58/// older version has no saved severity).
59///
60/// A saved finding exists, so its rule was on when it was reported. When the
61/// config at render time sets the rule `off`, the level is `warning`.
62fn finding_level(finding: &impl GatedFinding, rule: Severity) -> &'static str {
63    match finding.effective_severity() {
64        Some(EffectiveSeverity::Error) => "error",
65        Some(EffectiveSeverity::Warn) => "warning",
66        None => match rule {
67            Severity::Off => "warning",
68            Severity::Error | Severity::Warn => severity_to_sarif_level(rule),
69        },
70    }
71}
72
73fn configured_sarif_level(s: Severity) -> &'static str {
74    match s {
75        Severity::Error | Severity::Warn => severity_to_sarif_level(s),
76        Severity::Off => "none",
77    }
78}
79
80/// Extract SARIF fields for an unused export or type export.
81fn sarif_export_fields(
82    export: &UnusedExport,
83    root: &Path,
84    rule_id: &'static str,
85    level: &'static str,
86    kind: &str,
87    re_kind: &str,
88) -> SarifFields {
89    let label = if export.is_re_export { re_kind } else { kind };
90    SarifFields {
91        rule_id,
92        level,
93        message: format!(
94            "{} '{}' is never imported by other modules",
95            label, export.export_name
96        ),
97        uri: relative_uri(&export.path, root),
98        region: Some((export.line, export.col + 1)),
99        source_path: Some(export.path.clone()),
100        properties: if export.is_re_export {
101            Some(serde_json::json!({ "is_re_export": true }))
102        } else {
103            None
104        },
105    }
106}
107
108fn sarif_private_type_leak_fields(
109    leak: &PrivateTypeLeak,
110    root: &Path,
111    level: &'static str,
112) -> SarifFields {
113    SarifFields {
114        rule_id: "fallow/private-type-leak",
115        level,
116        message: format!(
117            "Export '{}' references private type '{}'",
118            leak.export_name, leak.type_name
119        ),
120        uri: relative_uri(&leak.path, root),
121        region: Some((leak.line, leak.col + 1)),
122        source_path: Some(leak.path.clone()),
123        properties: None,
124    }
125}
126
127fn sarif_deprecated_export_fields(
128    export: &DeprecatedExportInUse,
129    root: &Path,
130    level: &'static str,
131) -> SarifFields {
132    SarifFields {
133        rule_id: "fallow/deprecated-export-in-use",
134        level,
135        message: export.description(),
136        uri: relative_uri(&export.path, root),
137        region: Some((export.line, export.col + 1)),
138        source_path: Some(export.path.clone()),
139        properties: Some(serde_json::json!({
140            "consumer_count": export.consumer_count,
141            "public_api": export.public_api,
142        })),
143    }
144}
145
146/// 1-based column of the `"<name>"` key inside the manifest line a dependency
147/// finding points at, falling back to 1 when the line cannot be read.
148///
149/// Every dependency declared on one line otherwise reports the same location,
150/// and a SARIF fingerprint is rule id plus location plus source snippet: a
151/// compact `package.json` collapsed all of its unused dependencies into one
152/// GitHub code scanning alert.
153fn manifest_key_column(
154    snippets: &mut SourceSnippetCache,
155    path: &Path,
156    line: u32,
157    name: &str,
158) -> u32 {
159    snippets
160        .line(path, line)
161        .and_then(|text| text.find(&format!("\"{name}\"")))
162        .and_then(|offset| u32::try_from(offset).ok())
163        .map_or(1, |offset| offset.saturating_add(1))
164}
165
166/// The manifest coordinates `manifest_key_column` needs from a dependency.
167fn dep_key(dep: &UnusedDependency) -> (&Path, u32, &str) {
168    (dep.path.as_path(), dep.line, dep.package_name.as_str())
169}
170
171/// Resolve one manifest column per finding up front, so the result closure that
172/// needs them does not have to borrow the snippet cache it reads.
173fn manifest_key_columns<'a, T: 'a>(
174    findings: &'a [T],
175    snippets: &mut SourceSnippetCache,
176    key_of: impl Fn(&'a T) -> (&'a Path, u32, &'a str),
177) -> std::vec::IntoIter<u32> {
178    findings
179        .iter()
180        .map(|finding| {
181            let (path, line, name) = key_of(finding);
182            manifest_key_column(snippets, path, line, name)
183        })
184        .collect::<Vec<_>>()
185        .into_iter()
186}
187
188/// Extract SARIF fields for an unused dependency.
189fn sarif_dep_fields(
190    dep: &UnusedDependency,
191    root: &Path,
192    rule_id: &'static str,
193    level: &'static str,
194    section: &str,
195    col: u32,
196) -> SarifFields {
197    let join_workspaces = |paths: &[std::path::PathBuf]| {
198        paths
199            .iter()
200            .map(|path| relative_uri(path, root))
201            .collect::<Vec<_>>()
202            .join(", ")
203    };
204    let workspace_context = if !dep.used_in_workspaces.is_empty() {
205        format!(
206            "; imported in other workspaces: {}",
207            join_workspaces(&dep.used_in_workspaces)
208        )
209    } else if !dep.declared_and_imported_in.is_empty() {
210        format!(
211            "; declared and imported in other workspaces: {}",
212            join_workspaces(&dep.declared_and_imported_in)
213        )
214    } else {
215        String::new()
216    };
217    SarifFields {
218        rule_id,
219        level,
220        message: format!(
221            "Package '{}' is in {} but never imported{}",
222            dep.package_name, section, workspace_context
223        ),
224        uri: relative_uri(&dep.path, root),
225        region: if dep.line > 0 {
226            Some((dep.line, col))
227        } else {
228            None
229        },
230        source_path: (dep.line > 0).then(|| dep.path.clone()),
231        properties: None,
232    }
233}
234
235/// Extract SARIF fields for an unused enum or class member.
236fn sarif_member_fields(
237    member: &UnusedMember,
238    root: &Path,
239    rule_id: &'static str,
240    level: &'static str,
241    kind: &str,
242) -> SarifFields {
243    SarifFields {
244        rule_id,
245        level,
246        message: format!(
247            "{} member '{}.{}' is never referenced",
248            kind, member.parent_name, member.member_name
249        ),
250        uri: relative_uri(&member.path, root),
251        region: Some((member.line, member.col + 1)),
252        source_path: Some(member.path.clone()),
253        properties: None,
254    }
255}
256
257/// Append the degraded-parse caveat to a SARIF result message, so a reviewer
258/// reading an annotation in CI sees the same qualifier the JSON envelope and
259/// the human report carry. Byte-identical when the finding has no caveat.
260fn with_caveats(mut fields: SarifFields, caveats: &[ReachabilityCaveat]) -> SarifFields {
261    if let Some(labels) = caveat_labels(caveats) {
262        fields.message.push_str(" (caveat: ");
263        fields.message.push_str(&labels);
264        fields.message.push(')');
265    }
266    fields
267}
268
269fn sarif_unused_file_fields(file: &UnusedFile, root: &Path, level: &'static str) -> SarifFields {
270    SarifFields {
271        rule_id: "fallow/unused-file",
272        level,
273        message: "File is not reachable from any entry point".to_string(),
274        uri: relative_uri(&file.path, root),
275        region: None,
276        source_path: None,
277        properties: None,
278    }
279}
280
281fn sarif_type_only_dep_fields(
282    dep: &TypeOnlyDependency,
283    root: &Path,
284    level: &'static str,
285    col: u32,
286) -> SarifFields {
287    SarifFields {
288        rule_id: "fallow/type-only-dependency",
289        level,
290        message: format!(
291            "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
292            dep.package_name
293        ),
294        uri: relative_uri(&dep.path, root),
295        region: if dep.line > 0 {
296            Some((dep.line, col))
297        } else {
298            None
299        },
300        source_path: (dep.line > 0).then(|| dep.path.clone()),
301        properties: None,
302    }
303}
304
305fn sarif_test_only_dep_fields(
306    dep: &TestOnlyDependency,
307    root: &Path,
308    level: &'static str,
309    col: u32,
310) -> SarifFields {
311    SarifFields {
312        rule_id: "fallow/test-only-dependency",
313        level,
314        message: format!(
315            "Package '{}' is only imported by test files (consider moving to devDependencies)",
316            dep.package_name
317        ),
318        uri: relative_uri(&dep.path, root),
319        region: if dep.line > 0 {
320            Some((dep.line, col))
321        } else {
322            None
323        },
324        source_path: (dep.line > 0).then(|| dep.path.clone()),
325        properties: None,
326    }
327}
328
329fn sarif_dev_dep_in_prod_fields(
330    dep: &DevDependencyInProduction,
331    root: &Path,
332    level: &'static str,
333    col: u32,
334) -> SarifFields {
335    SarifFields {
336        rule_id: "fallow/dev-dependency-in-production",
337        level,
338        message: format!(
339            "devDependency '{}' is imported by production code at runtime (consider moving to dependencies)",
340            dep.package_name
341        ),
342        uri: relative_uri(&dep.path, root),
343        region: if dep.line > 0 {
344            Some((dep.line, col))
345        } else {
346            None
347        },
348        source_path: (dep.line > 0).then(|| dep.path.clone()),
349        properties: None,
350    }
351}
352
353fn sarif_unresolved_import_fields(
354    import: &UnresolvedImport,
355    root: &Path,
356    level: &'static str,
357) -> SarifFields {
358    SarifFields {
359        rule_id: "fallow/unresolved-import",
360        level,
361        message: format!("Import '{}' could not be resolved", import.specifier),
362        uri: relative_uri(&import.path, root),
363        region: Some((import.line, import.col + 1)),
364        source_path: Some(import.path.clone()),
365        properties: None,
366    }
367}
368
369fn sarif_circular_dep_fields(
370    cycle: &CircularDependency,
371    root: &Path,
372    level: &'static str,
373) -> SarifFields {
374    let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
375    let mut display_chain = chain.clone();
376    if let Some(first) = chain.first() {
377        display_chain.push(first.clone());
378    }
379    let first_uri = chain.first().map_or_else(String::new, Clone::clone);
380    let first_path = cycle.files.first().cloned();
381    SarifFields {
382        rule_id: "fallow/circular-dependency",
383        level,
384        message: format!(
385            "Circular dependency{}: {}",
386            if cycle.is_cross_package {
387                " (cross-package)"
388            } else {
389                ""
390            },
391            display_chain.join(" \u{2192} ")
392        ),
393        uri: first_uri,
394        region: if cycle.line > 0 {
395            Some((cycle.line, cycle.col + 1))
396        } else {
397            None
398        },
399        source_path: (cycle.line > 0).then_some(first_path).flatten(),
400        properties: None,
401    }
402}
403
404fn sarif_re_export_cycle_fields(
405    cycle: &fallow_types::results::ReExportCycle,
406    root: &Path,
407    level: &'static str,
408) -> SarifFields {
409    let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
410    let first_uri = chain.first().map_or_else(String::new, Clone::clone);
411    let first_path = cycle.files.first().cloned();
412    let kind_tag = match cycle.kind {
413        fallow_types::results::ReExportCycleKind::SelfLoop => " (self-loop)",
414        fallow_types::results::ReExportCycleKind::MultiNode => "",
415    };
416    SarifFields {
417        rule_id: "fallow/re-export-cycle",
418        level,
419        message: format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
420        uri: first_uri,
421        region: None,
422        source_path: first_path,
423        properties: None,
424    }
425}
426
427fn sarif_package_cycle_fields(
428    cycle: &fallow_types::results::PackageCycle,
429    root: &Path,
430    level: &'static str,
431) -> SarifFields {
432    let anchor = cycle.edges.first();
433    let note = if cycle.group_truncated {
434        format!(
435            " ({})",
436            fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
437        )
438    } else {
439        String::new()
440    };
441    let package_roots: Vec<String> = cycle
442        .package_roots
443        .iter()
444        .map(|package_root| relative_uri(package_root, root))
445        .collect();
446    SarifFields {
447        rule_id: "fallow/package-cycle",
448        level,
449        message: format!("Package cycle: {}{note}", cycle.chain(" \u{2192} ")),
450        uri: anchor.map_or_else(String::new, |edge| relative_uri(&edge.path, root)),
451        region: anchor.map(|edge| (edge.line, edge.col + 1)),
452        source_path: anchor.map(|edge| edge.path.clone()),
453        properties: Some(serde_json::json!({
454            "packages": cycle.packages,
455            "package_roots": package_roots,
456            "group_truncated": cycle.group_truncated,
457        })),
458    }
459}
460
461fn sarif_boundary_violation_fields(
462    violation: &BoundaryViolation,
463    root: &Path,
464    level: &'static str,
465) -> SarifFields {
466    let from_uri = relative_uri(&violation.from_path, root);
467    let to_uri = relative_uri(&violation.to_path, root);
468    let via = violation.via_path.as_ref().map_or_else(String::new, |via| {
469        format!(", via {}", relative_uri(via, root))
470    });
471    SarifFields {
472        rule_id: "fallow/boundary-violation",
473        level,
474        message: format!(
475            "Import from zone '{}' to zone '{}' is not allowed ({}{})",
476            violation.from_zone, violation.to_zone, to_uri, via,
477        ),
478        uri: from_uri,
479        region: if violation.line > 0 {
480            Some((violation.line, violation.col + 1))
481        } else {
482            None
483        },
484        source_path: (violation.line > 0).then(|| violation.from_path.clone()),
485        properties: None,
486    }
487}
488
489fn sarif_boundary_coverage_fields(
490    violation: &BoundaryCoverageViolation,
491    root: &Path,
492    level: &'static str,
493) -> SarifFields {
494    SarifFields {
495        rule_id: "fallow/boundary-coverage",
496        level,
497        message: "File does not match any configured architecture boundary zone".to_string(),
498        uri: relative_uri(&violation.path, root),
499        region: Some((violation.line, violation.col + 1)),
500        source_path: Some(violation.path.clone()),
501        properties: None,
502    }
503}
504
505fn sarif_boundary_call_fields(
506    violation: &BoundaryCallViolation,
507    root: &Path,
508    level: &'static str,
509) -> SarifFields {
510    SarifFields {
511        rule_id: "fallow/boundary-call-violation",
512        level,
513        message: format!(
514            "Call to `{}` matches forbidden pattern `{}` in zone '{}'",
515            violation.callee, violation.pattern, violation.zone
516        ),
517        uri: relative_uri(&violation.path, root),
518        region: Some((violation.line, violation.col + 1)),
519        source_path: Some(violation.path.clone()),
520        properties: None,
521    }
522}
523
524fn sarif_policy_violation_fields(violation: &PolicyViolation, root: &Path) -> SarifFields {
525    let level = match violation.severity {
526        PolicyViolationSeverity::Error => "error",
527        PolicyViolationSeverity::Warn => "warning",
528    };
529    let message = match &violation.message {
530        Some(message) => format!(
531            "Policy violation `{}/{}`: `{}` is banned. {message}",
532            violation.pack, violation.rule_id, violation.matched
533        ),
534        None => format!(
535            "Policy violation `{}/{}`: `{}` is banned",
536            violation.pack, violation.rule_id, violation.matched
537        ),
538    };
539    SarifFields {
540        rule_id: "fallow/policy-violation",
541        level,
542        message,
543        uri: relative_uri(&violation.path, root),
544        region: Some((violation.line, violation.col + 1)),
545        source_path: Some(violation.path.clone()),
546        // The SARIF rule id is the static `fallow/policy-violation`; the
547        // per-rule policy identity rides in properties so code-scanning
548        // consumers can group or filter per pack rule without parsing the
549        // message. Dynamic per-rule SARIF rule synthesis is a tracked
550        // follow-up shared with boundary zone rules.
551        properties: Some(serde_json::json!({
552            "policyRule": format!("{}/{}", violation.pack, violation.rule_id),
553        })),
554    }
555}
556
557fn sarif_invalid_client_export_fields(
558    export: &InvalidClientExport,
559    root: &Path,
560    level: &'static str,
561) -> SarifFields {
562    SarifFields {
563        rule_id: "fallow/invalid-client-export",
564        level,
565        message: format!(
566            "Export '{}' is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
567            export.export_name, export.directive
568        ),
569        uri: relative_uri(&export.path, root),
570        region: Some((export.line, export.col + 1)),
571        source_path: Some(export.path.clone()),
572        properties: None,
573    }
574}
575
576fn sarif_mixed_client_server_barrel_fields(
577    barrel: &MixedClientServerBarrel,
578    root: &Path,
579    level: &'static str,
580) -> SarifFields {
581    SarifFields {
582        rule_id: "fallow/mixed-client-server-barrel",
583        level,
584        message: format!(
585            "Barrel re-exports both a \"use client\" module ('{}') and a server-only module ('{}'); one import drags the other's directive across the boundary",
586            barrel.client_origin, barrel.server_origin
587        ),
588        uri: relative_uri(&barrel.path, root),
589        region: Some((barrel.line, barrel.col + 1)),
590        source_path: Some(barrel.path.clone()),
591        properties: None,
592    }
593}
594
595fn sarif_misplaced_directive_fields(
596    directive_site: &MisplacedDirective,
597    root: &Path,
598    level: &'static str,
599) -> SarifFields {
600    SarifFields {
601        rule_id: "fallow/misplaced-directive",
602        level,
603        message: format!(
604            "Directive \"{}\" is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
605            directive_site.directive
606        ),
607        uri: relative_uri(&directive_site.path, root),
608        region: Some((directive_site.line, directive_site.col + 1)),
609        source_path: Some(directive_site.path.clone()),
610        properties: None,
611    }
612}
613
614fn sarif_unprovided_inject_fields(
615    inject: &UnprovidedInject,
616    root: &Path,
617    level: &'static str,
618) -> SarifFields {
619    SarifFields {
620        rule_id: "fallow/unprovided-inject",
621        level,
622        message: format!(
623            "inject(\"{}\") has no matching provide(\"{}\") in this project; at runtime it returns undefined; provide the key or remove this inject",
624            inject.key_name, inject.key_name
625        ),
626        uri: relative_uri(&inject.path, root),
627        region: Some((inject.line, inject.col + 1)),
628        source_path: Some(inject.path.clone()),
629        properties: None,
630    }
631}
632
633fn sarif_unrendered_component_fields(
634    component: &UnrenderedComponent,
635    root: &Path,
636    level: &'static str,
637) -> SarifFields {
638    SarifFields {
639        rule_id: "fallow/unrendered-component",
640        level,
641        message: format!(
642            "component \"{}\" is reachable but rendered nowhere in this project; render it somewhere or remove it",
643            component.component_name
644        ),
645        uri: relative_uri(&component.path, root),
646        region: Some((component.line, component.col + 1)),
647        source_path: Some(component.path.clone()),
648        properties: None,
649    }
650}
651
652fn sarif_unused_component_prop_fields(
653    prop: &UnusedComponentProp,
654    root: &Path,
655    level: &'static str,
656) -> SarifFields {
657    SarifFields {
658        rule_id: "fallow/unused-component-prop",
659        level,
660        message: format!(
661            "prop \"{}\" is declared but referenced nowhere inside component \"{}\"; remove it or use it",
662            prop.prop_name, prop.component_name
663        ),
664        uri: relative_uri(&prop.path, root),
665        region: Some((prop.line, prop.col + 1)),
666        source_path: Some(prop.path.clone()),
667        properties: None,
668    }
669}
670
671fn sarif_unused_component_emit_fields(
672    emit: &UnusedComponentEmit,
673    root: &Path,
674    level: &'static str,
675) -> SarifFields {
676    SarifFields {
677        rule_id: "fallow/unused-component-emit",
678        level,
679        message: format!(
680            "emit \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
681            emit.emit_name, emit.component_name
682        ),
683        uri: relative_uri(&emit.path, root),
684        region: Some((emit.line, emit.col + 1)),
685        source_path: Some(emit.path.clone()),
686        properties: None,
687    }
688}
689
690fn sarif_unused_effect_layer_fields(
691    layer: &UnusedEffectLayer,
692    root: &Path,
693    level: &'static str,
694) -> SarifFields {
695    SarifFields {
696        rule_id: "fallow/unused-effect-layer",
697        level,
698        message: format!(
699            "layer \"{}\" is composed only into layers that nothing uses ({}); compose it into a live layer or remove it",
700            layer.name,
701            layer.dead_consumers.join(", ")
702        ),
703        uri: relative_uri(&layer.path, root),
704        region: Some((layer.line, layer.col + 1)),
705        source_path: Some(layer.path.clone()),
706        properties: Some(serde_json::json!({
707            "dead_consumers": layer.dead_consumers,
708        })),
709    }
710}
711
712fn sarif_unused_tagged_error_fields(
713    error: &UnusedTaggedError,
714    root: &Path,
715    level: &'static str,
716) -> SarifFields {
717    SarifFields {
718        rule_id: "fallow/unused-tagged-error",
719        level,
720        message: format!(
721            "tagged error \"{}\" (tag \"{}\") is never constructed or failed with; fail with it or remove it and its {} catchTag handler(s)",
722            error.name,
723            error.tag,
724            error.catch_sites.len()
725        ),
726        uri: relative_uri(&error.path, root),
727        region: Some((error.line, error.col + 1)),
728        source_path: Some(error.path.clone()),
729        properties: Some(serde_json::json!({
730            "tag": error.tag,
731            "catch_sites": error
732                .catch_sites
733                .iter()
734                .map(|site| serde_json::json!({
735                    "uri": relative_uri(&site.path, root),
736                    "line": site.line,
737                    "column": site.col + 1,
738                }))
739                .collect::<Vec<_>>(),
740        })),
741    }
742}
743
744fn sarif_unproduced_catch_tag_fields(
745    catch_tag: &UnproducedCatchTag,
746    root: &Path,
747    level: &'static str,
748) -> SarifFields {
749    SarifFields {
750        rule_id: "fallow/unproduced-catch-tag",
751        level,
752        message: format!(
753            "catchTag handler for \"{}\" never runs: no code produces {}; remove the handler or fail with the error",
754            catch_tag.tag,
755            catch_tag.candidate_classes.join(", ")
756        ),
757        uri: relative_uri(&catch_tag.path, root),
758        region: Some((catch_tag.line, catch_tag.col + 1)),
759        source_path: Some(catch_tag.path.clone()),
760        properties: Some(serde_json::json!({
761            "tag": catch_tag.tag,
762            "candidate_classes": catch_tag.candidate_classes,
763        })),
764    }
765}
766
767fn sarif_unused_svelte_event_fields(
768    event: &UnusedSvelteEvent,
769    root: &Path,
770    level: &'static str,
771) -> SarifFields {
772    SarifFields {
773        rule_id: "fallow/unused-svelte-event",
774        level,
775        message: format!(
776            "event \"{}\" is dispatched by component \"{}\" but listened to nowhere in the project; remove it or listen for it",
777            event.event_name, event.component_name
778        ),
779        uri: relative_uri(&event.path, root),
780        region: Some((event.line, event.col + 1)),
781        source_path: Some(event.path.clone()),
782        properties: None,
783    }
784}
785
786fn sarif_unused_component_input_fields(
787    input: &UnusedComponentInput,
788    root: &Path,
789    level: &'static str,
790) -> SarifFields {
791    SarifFields {
792        rule_id: "fallow/unused-component-input",
793        level,
794        message: format!(
795            "input \"{}\" is declared but read nowhere inside component \"{}\"; remove it or use it",
796            input.input_name, input.component_name
797        ),
798        uri: relative_uri(&input.path, root),
799        region: Some((input.line, input.col + 1)),
800        source_path: Some(input.path.clone()),
801        properties: None,
802    }
803}
804
805fn sarif_unused_component_output_fields(
806    output: &UnusedComponentOutput,
807    root: &Path,
808    level: &'static str,
809) -> SarifFields {
810    SarifFields {
811        rule_id: "fallow/unused-component-output",
812        level,
813        message: format!(
814            "output \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
815            output.output_name, output.component_name
816        ),
817        uri: relative_uri(&output.path, root),
818        region: Some((output.line, output.col + 1)),
819        source_path: Some(output.path.clone()),
820        properties: None,
821    }
822}
823
824fn sarif_unused_server_action_fields(
825    action: &UnusedServerAction,
826    root: &Path,
827    level: &'static str,
828) -> SarifFields {
829    SarifFields {
830        rule_id: "fallow/unused-server-action",
831        level,
832        message: format!(
833            "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",
834            action.action_name
835        ),
836        uri: relative_uri(&action.path, root),
837        region: Some((action.line, action.col + 1)),
838        source_path: Some(action.path.clone()),
839        properties: None,
840    }
841}
842
843fn sarif_unused_load_data_key_fields(
844    key: &fallow_types::results::UnusedLoadDataKey,
845    root: &Path,
846    level: &'static str,
847) -> SarifFields {
848    SarifFields {
849        rule_id: "fallow/unused-load-data-key",
850        level,
851        message: format!(
852            "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",
853            key.key_name
854        ),
855        uri: relative_uri(&key.path, root),
856        region: Some((key.line, key.col + 1)),
857        source_path: Some(key.path.clone()),
858        properties: None,
859    }
860}
861
862fn sarif_prop_drilling_fields(
863    chain: &PropDrillingChain,
864    root: &Path,
865    level: &'static str,
866) -> SarifFields {
867    // Anchor at the source hop (the prop owner). Path / line come from the first
868    // hop; the message names the depth and the consumer at the chain tail.
869    let source = chain.hops.first();
870    let consumer = chain.hops.last();
871    let consumer_name = consumer.map_or("a distant component", |h| h.component.as_str());
872    SarifFields {
873        rule_id: "fallow/prop-drilling",
874        level,
875        message: format!(
876            "prop \"{}\" is forwarded unchanged through {} component(s) before \"{}\" consumes it; colocate, lift to context, or compose",
877            chain.prop, chain.depth, consumer_name
878        ),
879        uri: source.map_or_else(String::new, |hop| relative_uri(&hop.file, root)),
880        region: source.map(|hop| (hop.line, 1)),
881        source_path: source.map(|hop| hop.file.clone()),
882        properties: None,
883    }
884}
885
886fn sarif_thin_wrapper_fields(
887    wrapper: &ThinWrapper,
888    root: &Path,
889    level: &'static str,
890) -> SarifFields {
891    SarifFields {
892        rule_id: "fallow/thin-wrapper",
893        level,
894        message: format!(
895            "\"{}\" is a thin wrapper: its whole body forwards props to \"{}\"; inline it at call sites or delete it",
896            wrapper.component, wrapper.child_component
897        ),
898        uri: relative_uri(&wrapper.file, root),
899        region: Some((wrapper.line, 1)),
900        source_path: Some(wrapper.file.clone()),
901        properties: None,
902    }
903}
904
905fn sarif_duplicate_prop_shape_fields(
906    shape: &DuplicatePropShape,
907    root: &Path,
908    level: &'static str,
909) -> SarifFields {
910    SarifFields {
911        rule_id: "fallow/duplicate-prop-shape",
912        level,
913        message: format!(
914            "\"{}\" shares an identical prop shape {{{}}} with {} other component(s); extract a shared Props type or base component",
915            shape.component,
916            shape.shape.join(", "),
917            shape.group_size.saturating_sub(1)
918        ),
919        uri: relative_uri(&shape.file, root),
920        region: Some((shape.line, 1)),
921        source_path: Some(shape.file.clone()),
922        properties: None,
923    }
924}
925
926fn sarif_route_collision_fields(
927    collision: &RouteCollision,
928    root: &Path,
929    level: &'static str,
930) -> SarifFields {
931    SarifFields {
932        rule_id: "fallow/route-collision",
933        level,
934        message: format!(
935            "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",
936            collision.url,
937            collision.conflicting_paths.len()
938        ),
939        uri: relative_uri(&collision.path, root),
940        region: Some((collision.line, collision.col + 1)),
941        source_path: Some(collision.path.clone()),
942        properties: None,
943    }
944}
945
946fn sarif_dynamic_segment_name_conflict_fields(
947    conflict: &DynamicSegmentNameConflict,
948    root: &Path,
949    level: &'static str,
950) -> SarifFields {
951    SarifFields {
952        rule_id: "fallow/dynamic-segment-name-conflict",
953        level,
954        message: format!(
955            "Dynamic segments at '{}' use different slug names ({}); Next.js requires one consistent name per dynamic path",
956            conflict.position,
957            conflict.conflicting_segments.join(", ")
958        ),
959        uri: relative_uri(&conflict.path, root),
960        region: Some((conflict.line, conflict.col + 1)),
961        source_path: Some(conflict.path.clone()),
962        properties: None,
963    }
964}
965
966fn sarif_stale_suppression_fields(
967    suppression: &StaleSuppression,
968    root: &Path,
969    level: &'static str,
970) -> SarifFields {
971    SarifFields {
972        rule_id: if suppression.missing_reason {
973            "fallow/missing-suppression-reason"
974        } else {
975            "fallow/stale-suppression"
976        },
977        level,
978        message: suppression.display_message(),
979        uri: relative_uri(&suppression.path, root),
980        region: Some((suppression.line, suppression.col + 1)),
981        source_path: Some(suppression.path.clone()),
982        properties: None,
983    }
984}
985
986fn stale_suppression_severity(suppression: &StaleSuppression, rules: &RulesConfig) -> Severity {
987    if suppression.missing_reason {
988        rules.require_suppression_reason
989    } else {
990        rules.stale_suppressions
991    }
992}
993
994fn sarif_unused_catalog_entry_fields(
995    entry: &UnusedCatalogEntryFinding,
996    root: &Path,
997    level: &'static str,
998) -> SarifFields {
999    let entry = &entry.entry;
1000    let message = if entry.catalog_name == "default" {
1001        format!(
1002            "Catalog entry '{}' is not referenced by any workspace package",
1003            entry.entry_name
1004        )
1005    } else {
1006        format!(
1007            "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1008            entry.entry_name, entry.catalog_name
1009        )
1010    };
1011    SarifFields {
1012        rule_id: "fallow/unused-catalog-entry",
1013        level,
1014        message,
1015        uri: relative_uri(&entry.path, root),
1016        region: Some((entry.line, 1)),
1017        source_path: Some(entry.path.clone()),
1018        properties: None,
1019    }
1020}
1021
1022fn sarif_unused_dependency_override_fields(
1023    finding: &UnusedDependencyOverrideFinding,
1024    root: &Path,
1025    level: &'static str,
1026) -> SarifFields {
1027    let finding = &finding.entry;
1028    let mut message = format!(
1029        "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in the lockfile",
1030        finding.raw_key, finding.version_range, finding.target_package,
1031    );
1032    if let Some(hint) = &finding.hint {
1033        use std::fmt::Write as _;
1034        let _ = write!(message, " ({hint})");
1035    }
1036    SarifFields {
1037        rule_id: "fallow/unused-dependency-override",
1038        level,
1039        message,
1040        uri: relative_uri(&finding.path, root),
1041        region: Some((finding.line, 1)),
1042        source_path: Some(finding.path.clone()),
1043        properties: None,
1044    }
1045}
1046
1047fn sarif_misconfigured_dependency_override_fields(
1048    finding: &MisconfiguredDependencyOverrideFinding,
1049    root: &Path,
1050    level: &'static str,
1051) -> SarifFields {
1052    let finding = &finding.entry;
1053    let message = format!(
1054        "Override `{}` -> `{}` is malformed: {}",
1055        finding.raw_key,
1056        finding.raw_value,
1057        finding.reason.describe(),
1058    );
1059    SarifFields {
1060        rule_id: "fallow/misconfigured-dependency-override",
1061        level,
1062        message,
1063        uri: relative_uri(&finding.path, root),
1064        region: Some((finding.line, 1)),
1065        source_path: Some(finding.path.clone()),
1066        properties: None,
1067    }
1068}
1069
1070fn sarif_unresolved_catalog_reference_fields(
1071    finding: &UnresolvedCatalogReferenceFinding,
1072    root: &Path,
1073    level: &'static str,
1074) -> SarifFields {
1075    let finding = &finding.reference;
1076    let catalog_phrase = if finding.catalog_name == "default" {
1077        "the default catalog".to_string()
1078    } else {
1079        format!("catalog '{}'", finding.catalog_name)
1080    };
1081    let mut message = format!(
1082        "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
1083        finding.entry_name,
1084        if finding.catalog_name == "default" {
1085            ""
1086        } else {
1087            finding.catalog_name.as_str()
1088        },
1089        catalog_phrase,
1090    );
1091    if !finding.available_in_catalogs.is_empty() {
1092        use std::fmt::Write as _;
1093        let _ = write!(
1094            message,
1095            " (available in: {})",
1096            finding.available_in_catalogs.join(", ")
1097        );
1098    }
1099    SarifFields {
1100        rule_id: "fallow/unresolved-catalog-reference",
1101        level,
1102        message,
1103        uri: relative_uri(&finding.path, root),
1104        region: Some((finding.line, 1)),
1105        source_path: Some(finding.path.clone()),
1106        properties: None,
1107    }
1108}
1109
1110fn sarif_empty_catalog_group_fields(
1111    group: &EmptyCatalogGroupFinding,
1112    root: &Path,
1113    level: &'static str,
1114) -> SarifFields {
1115    let group = &group.group;
1116    SarifFields {
1117        rule_id: "fallow/empty-catalog-group",
1118        level,
1119        message: format!("Catalog group '{}' has no entries", group.catalog_name),
1120        uri: relative_uri(&group.path, root),
1121        region: Some((group.line, 1)),
1122        source_path: Some(group.path.clone()),
1123        properties: None,
1124    }
1125}
1126
1127/// Unlisted deps fan out to one SARIF result per import site, so they do not
1128/// fit `push_sarif_results`. Keep the nested-loop shape in its own helper.
1129fn push_sarif_unlisted_deps(
1130    sarif_results: &mut Vec<serde_json::Value>,
1131    deps: &[UnlistedDependencyFinding],
1132    root: &Path,
1133    rule: Severity,
1134    snippets: &mut SourceSnippetCache,
1135) {
1136    for entry in deps {
1137        let level = finding_level(entry, rule);
1138        let dep = &entry.dep;
1139        for site in &dep.imported_from {
1140            let uri = relative_uri(&site.path, root);
1141            let source_snippet = snippets.line(&site.path, site.line);
1142            sarif_results.push(sarif_result_with_snippet(
1143                "fallow/unlisted-dependency",
1144                level,
1145                &format!(
1146                    "Package '{}' is imported but not listed in package.json",
1147                    dep.package_name
1148                ),
1149                &uri,
1150                Some((site.line, site.col + 1)),
1151                source_snippet.as_deref(),
1152            ));
1153        }
1154    }
1155}
1156
1157/// Duplicate exports fan out to one SARIF result per location
1158/// (SARIF 2.1.0 section 3.27.12), so they do not fit `push_sarif_results`.
1159fn push_sarif_duplicate_exports(
1160    sarif_results: &mut Vec<serde_json::Value>,
1161    dups: &[DuplicateExportFinding],
1162    root: &Path,
1163    rule: Severity,
1164    snippets: &mut SourceSnippetCache,
1165) {
1166    for dup in dups {
1167        let level = finding_level(dup, rule);
1168        let dup = &dup.export;
1169        for loc in &dup.locations {
1170            let uri = relative_uri(&loc.path, root);
1171            let source_snippet = snippets.line(&loc.path, loc.line);
1172            sarif_results.push(sarif_result_with_snippet(
1173                "fallow/duplicate-export",
1174                level,
1175                &format!("Export '{}' appears in multiple modules", dup.export_name),
1176                &uri,
1177                Some((loc.line, loc.col + 1)),
1178                source_snippet.as_deref(),
1179            ));
1180        }
1181    }
1182}
1183
1184/// Build the SARIF rules list from the current rules configuration.
1185fn build_sarif_rules(
1186    rules: &RulesConfig,
1187    rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1188) -> Vec<serde_json::Value> {
1189    let mut sarif_rules = Vec::new();
1190    for contract in issue_output_contracts() {
1191        for rule_id in contract.sarif_rule_ids {
1192            let severity = sarif_rule_severity(rules, contract.code, &rule_id);
1193            let description = issue_sarif_rule_description(&rule_id).unwrap_or_else(|| {
1194                panic!("dead-code SARIF rule {rule_id} is missing issue metadata")
1195            });
1196            sarif_rules.push(rule_builder(
1197                &rule_id,
1198                description,
1199                configured_sarif_level(severity),
1200            ));
1201        }
1202    }
1203    sarif_rules
1204}
1205
1206fn sarif_rule_severity(rules: &RulesConfig, issue_code: &str, rule_id: &str) -> Severity {
1207    if rule_id == "fallow/missing-suppression-reason" {
1208        return rules.require_suppression_reason;
1209    }
1210    dead_code_rule_severity(rules, issue_code)
1211        .unwrap_or_else(|| panic!("dead-code SARIF rule {rule_id} has no severity mapping"))
1212}
1213
1214fn dead_code_rule_severity(rules: &RulesConfig, issue_code: &str) -> Option<Severity> {
1215    let severity = match issue_code {
1216        "unused-file" => rules.unused_files,
1217        "unused-export" => rules.unused_exports,
1218        "unused-type" => rules.unused_types,
1219        "private-type-leak" => rules.private_type_leaks,
1220        "deprecated-export-in-use" => rules.deprecated_exports_in_use,
1221        "unused-dependency" => rules.unused_dependencies,
1222        "unused-dev-dependency" => rules.unused_dev_dependencies,
1223        "unused-optional-dependency" => rules.unused_optional_dependencies,
1224        "type-only-dependency" => rules.type_only_dependencies,
1225        "test-only-dependency" => rules.test_only_dependencies,
1226        "dev-dependency-in-production" => rules.dev_dependencies_in_production,
1227        "unused-enum-member" => rules.unused_enum_members,
1228        "unused-class-member" => rules.unused_class_members,
1229        "unused-store-member" => rules.unused_store_members,
1230        "unresolved-import" => rules.unresolved_imports,
1231        "unlisted-dependency" => rules.unlisted_dependencies,
1232        "duplicate-export" => rules.duplicate_exports,
1233        "circular-dependency" => rules.circular_dependencies,
1234        "re-export-cycle" => rules.re_export_cycle,
1235        "package-cycle" => rules.package_cycle,
1236        "boundary-violation" | "boundary-coverage" | "boundary-call-violation" => {
1237            rules.boundary_violation
1238        }
1239        "policy-violation" => rules.policy_violation,
1240        "invalid-client-export" => rules.invalid_client_export,
1241        "mixed-client-server-barrel" => rules.mixed_client_server_barrel,
1242        "misplaced-directive" => rules.misplaced_directive,
1243        "unprovided-inject" => rules.unprovided_injects,
1244        "unrendered-component" => rules.unrendered_components,
1245        "unused-component-prop" => rules.unused_component_props,
1246        "absent-component-prop" => rules.absent_component_props,
1247        "unused-component-emit" => rules.unused_component_emits,
1248        "unused-component-input" => rules.unused_component_inputs,
1249        "unused-component-output" => rules.unused_component_outputs,
1250        "unused-svelte-event" => rules.unused_svelte_events,
1251        "unused-effect-layer" => rules.unused_effect_layers,
1252        "unused-tagged-error" => rules.unused_tagged_errors,
1253        "unproduced-catch-tag" => rules.unproduced_catch_tags,
1254        "unused-server-action" => rules.unused_server_actions,
1255        "unused-load-data-key" => rules.unused_load_data_keys,
1256        "prop-drilling" => rules.prop_drilling,
1257        "thin-wrapper" => rules.thin_wrapper,
1258        "duplicate-prop-shape" => rules.duplicate_prop_shape,
1259        "route-collision" => rules.route_collision,
1260        "dynamic-segment-name-conflict" => rules.dynamic_segment_name_conflict,
1261        "stale-suppression" => rules.stale_suppressions,
1262        "unused-catalog-entry" => rules.unused_catalog_entries,
1263        "empty-catalog-group" => rules.empty_catalog_groups,
1264        "unresolved-catalog-reference" => rules.unresolved_catalog_references,
1265        "unused-dependency-override" => rules.unused_dependency_overrides,
1266        "misconfigured-dependency-override" => rules.misconfigured_dependency_overrides,
1267        _ => return None,
1268    };
1269    Some(severity)
1270}
1271
1272/// Builds the complete SARIF `run` value for a dead-code analysis.
1273///
1274/// Emits one SARIF result per finding across every dead-code issue kind,
1275/// mapping each configured rule severity to a SARIF level. `rule_builder`
1276/// constructs the tool-driver rule object for a `(rule id, name, help URI)`
1277/// triple so the caller controls rule metadata.
1278#[must_use]
1279pub fn build_dead_code_sarif(
1280    results: &AnalysisResults,
1281    root: &Path,
1282    rules: &RulesConfig,
1283    rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1284) -> serde_json::Value {
1285    let mut sarif_results = Vec::new();
1286    let mut snippets = SourceSnippetCache::with_root(root);
1287    let ctx = SarifCtx {
1288        results,
1289        root,
1290        rules,
1291    };
1292
1293    push_primary_dead_code_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1294    push_dependency_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1295    push_member_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1296    push_sarif_results(
1297        &mut sarif_results,
1298        &results.unresolved_imports,
1299        &mut snippets,
1300        |i| {
1301            sarif_unresolved_import_fields(
1302                &i.import,
1303                root,
1304                finding_level(i, rules.unresolved_imports),
1305            )
1306        },
1307    );
1308    push_misc_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1309    push_graph_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1310    push_catalog_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1311
1312    let sarif_rules = build_sarif_rules(rules, rule_builder);
1313    let mut sarif = sarif_document(&sarif_results, &sarif_rules);
1314    annotate_cascade_hidden(&mut sarif, results.cascade_hidden);
1315    sarif
1316}
1317
1318/// Record the number of findings that the cascade filter hid in the run
1319/// `properties`, as `cascadeHidden`. SARIF has no result for a hidden
1320/// finding, so this property is the only sign of them. Absent when zero.
1321fn annotate_cascade_hidden(sarif: &mut serde_json::Value, hidden: usize) {
1322    if hidden == 0 {
1323        return;
1324    }
1325    let Some(run) = sarif
1326        .get_mut("runs")
1327        .and_then(serde_json::Value::as_array_mut)
1328        .and_then(|runs| runs.first_mut())
1329        .and_then(serde_json::Value::as_object_mut)
1330    else {
1331        return;
1332    };
1333    let properties = run
1334        .entry("properties")
1335        .or_insert_with(|| serde_json::json!({}));
1336    if let Some(properties) = properties.as_object_mut() {
1337        properties.insert("cascadeHidden".to_owned(), serde_json::json!(hidden));
1338    }
1339}
1340
1341fn push_primary_dead_code_sarif_results(
1342    sarif_results: &mut Vec<serde_json::Value>,
1343    ctx: &SarifCtx<'_>,
1344    snippets: &mut SourceSnippetCache,
1345) {
1346    let SarifCtx {
1347        results,
1348        root,
1349        rules,
1350    } = *ctx;
1351
1352    push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1353        with_caveats(
1354            sarif_unused_file_fields(
1355                &finding.file,
1356                root,
1357                finding_level(finding, rules.unused_files),
1358            ),
1359            &finding.reachability_caveats,
1360        )
1361    });
1362    push_sarif_results(
1363        sarif_results,
1364        &results.unused_exports,
1365        snippets,
1366        |finding| {
1367            with_caveats(
1368                sarif_export_fields(
1369                    &finding.export,
1370                    root,
1371                    "fallow/unused-export",
1372                    finding_level(finding, rules.unused_exports),
1373                    "Export",
1374                    "Re-export",
1375                ),
1376                &finding.reachability_caveats,
1377            )
1378        },
1379    );
1380    push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1381        with_caveats(
1382            sarif_export_fields(
1383                &finding.export,
1384                root,
1385                "fallow/unused-type",
1386                finding_level(finding, rules.unused_types),
1387                "Type export",
1388                "Type re-export",
1389            ),
1390            &finding.reachability_caveats,
1391        )
1392    });
1393    push_sarif_results(
1394        sarif_results,
1395        &results.private_type_leaks,
1396        snippets,
1397        |finding| {
1398            sarif_private_type_leak_fields(
1399                &finding.leak,
1400                root,
1401                finding_level(finding, rules.private_type_leaks),
1402            )
1403        },
1404    );
1405    push_sarif_results(
1406        sarif_results,
1407        &results.deprecated_exports_in_use,
1408        snippets,
1409        |finding| {
1410            sarif_deprecated_export_fields(
1411                &finding.export,
1412                root,
1413                finding_level(finding, rules.deprecated_exports_in_use),
1414            )
1415        },
1416    );
1417}
1418
1419fn sarif_document(
1420    sarif_results: &[serde_json::Value],
1421    sarif_rules: &[serde_json::Value],
1422) -> serde_json::Value {
1423    build_sarif_document(SarifDocumentInput {
1424        results: sarif_results,
1425        rules: sarif_rules,
1426        tool_version: env!("CARGO_PKG_VERSION"),
1427    })
1428}
1429
1430fn push_dependency_sarif_results(
1431    sarif_results: &mut Vec<serde_json::Value>,
1432    ctx: &SarifCtx<'_>,
1433    snippets: &mut SourceSnippetCache,
1434) {
1435    push_unused_dependency_sarif_results(sarif_results, ctx, snippets);
1436    push_classified_dependency_sarif_results(sarif_results, ctx, snippets);
1437}
1438
1439/// Push SARIF results for unused runtime, dev, and optional dependencies.
1440fn push_unused_dependency_sarif_results(
1441    sarif_results: &mut Vec<serde_json::Value>,
1442    ctx: &SarifCtx<'_>,
1443    snippets: &mut SourceSnippetCache,
1444) {
1445    let SarifCtx {
1446        results,
1447        root,
1448        rules,
1449    } = *ctx;
1450
1451    let mut columns =
1452        manifest_key_columns(&results.unused_dependencies, snippets, |f| dep_key(&f.dep));
1453    push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1454        with_caveats(
1455            sarif_dep_fields(
1456                &d.dep,
1457                root,
1458                "fallow/unused-dependency",
1459                finding_level(d, rules.unused_dependencies),
1460                "dependencies",
1461                columns.next().unwrap_or(1),
1462            ),
1463            &d.reachability_caveats,
1464        )
1465    });
1466    let mut columns = manifest_key_columns(&results.unused_dev_dependencies, snippets, |f| {
1467        dep_key(&f.dep)
1468    });
1469    push_sarif_results(
1470        sarif_results,
1471        &results.unused_dev_dependencies,
1472        snippets,
1473        |d| {
1474            with_caveats(
1475                sarif_dep_fields(
1476                    &d.dep,
1477                    root,
1478                    "fallow/unused-dev-dependency",
1479                    finding_level(d, rules.unused_dev_dependencies),
1480                    "devDependencies",
1481                    columns.next().unwrap_or(1),
1482                ),
1483                &d.reachability_caveats,
1484            )
1485        },
1486    );
1487    let mut columns = manifest_key_columns(&results.unused_optional_dependencies, snippets, |f| {
1488        dep_key(&f.dep)
1489    });
1490    push_sarif_results(
1491        sarif_results,
1492        &results.unused_optional_dependencies,
1493        snippets,
1494        |d| {
1495            with_caveats(
1496                sarif_dep_fields(
1497                    &d.dep,
1498                    root,
1499                    "fallow/unused-optional-dependency",
1500                    finding_level(d, rules.unused_optional_dependencies),
1501                    "optionalDependencies",
1502                    columns.next().unwrap_or(1),
1503                ),
1504                &d.reachability_caveats,
1505            )
1506        },
1507    );
1508}
1509
1510/// Push SARIF results for type-only and test-only dependency misclassifications.
1511fn push_classified_dependency_sarif_results(
1512    sarif_results: &mut Vec<serde_json::Value>,
1513    ctx: &SarifCtx<'_>,
1514    snippets: &mut SourceSnippetCache,
1515) {
1516    let SarifCtx {
1517        results,
1518        root,
1519        rules,
1520    } = *ctx;
1521
1522    let mut columns = manifest_key_columns(&results.type_only_dependencies, snippets, |f| {
1523        (
1524            f.dep.path.as_path(),
1525            f.dep.line,
1526            f.dep.package_name.as_str(),
1527        )
1528    });
1529    push_sarif_results(
1530        sarif_results,
1531        &results.type_only_dependencies,
1532        snippets,
1533        |d| {
1534            sarif_type_only_dep_fields(
1535                &d.dep,
1536                root,
1537                finding_level(d, rules.type_only_dependencies),
1538                columns.next().unwrap_or(1),
1539            )
1540        },
1541    );
1542    let mut columns = manifest_key_columns(&results.test_only_dependencies, snippets, |f| {
1543        (
1544            f.dep.path.as_path(),
1545            f.dep.line,
1546            f.dep.package_name.as_str(),
1547        )
1548    });
1549    push_sarif_results(
1550        sarif_results,
1551        &results.test_only_dependencies,
1552        snippets,
1553        |d| {
1554            sarif_test_only_dep_fields(
1555                &d.dep,
1556                root,
1557                finding_level(d, rules.test_only_dependencies),
1558                columns.next().unwrap_or(1),
1559            )
1560        },
1561    );
1562    let mut columns =
1563        manifest_key_columns(&results.dev_dependencies_in_production, snippets, |f| {
1564            (
1565                f.dep.path.as_path(),
1566                f.dep.line,
1567                f.dep.package_name.as_str(),
1568            )
1569        });
1570    push_sarif_results(
1571        sarif_results,
1572        &results.dev_dependencies_in_production,
1573        snippets,
1574        |d| {
1575            sarif_dev_dep_in_prod_fields(
1576                &d.dep,
1577                root,
1578                finding_level(d, rules.dev_dependencies_in_production),
1579                columns.next().unwrap_or(1),
1580            )
1581        },
1582    );
1583}
1584
1585fn push_member_sarif_results(
1586    sarif_results: &mut Vec<serde_json::Value>,
1587    ctx: &SarifCtx<'_>,
1588    snippets: &mut SourceSnippetCache,
1589) {
1590    let SarifCtx {
1591        results,
1592        root,
1593        rules,
1594    } = *ctx;
1595
1596    push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1597        with_caveats(
1598            sarif_member_fields(
1599                &m.member,
1600                root,
1601                "fallow/unused-enum-member",
1602                finding_level(m, rules.unused_enum_members),
1603                "Enum",
1604            ),
1605            &m.reachability_caveats,
1606        )
1607    });
1608    push_sarif_results(
1609        sarif_results,
1610        &results.unused_class_members,
1611        snippets,
1612        |m| {
1613            with_caveats(
1614                sarif_member_fields(
1615                    &m.member,
1616                    root,
1617                    "fallow/unused-class-member",
1618                    finding_level(m, rules.unused_class_members),
1619                    "Class",
1620                ),
1621                &m.reachability_caveats,
1622            )
1623        },
1624    );
1625    push_sarif_results(
1626        sarif_results,
1627        &results.unused_store_members,
1628        snippets,
1629        |m| {
1630            with_caveats(
1631                sarif_member_fields(
1632                    &m.member,
1633                    root,
1634                    "fallow/unused-store-member",
1635                    finding_level(m, rules.unused_store_members),
1636                    "Store",
1637                ),
1638                &m.reachability_caveats,
1639            )
1640        },
1641    );
1642}
1643
1644fn push_misc_sarif_results(
1645    sarif_results: &mut Vec<serde_json::Value>,
1646    ctx: &SarifCtx<'_>,
1647    snippets: &mut SourceSnippetCache,
1648) {
1649    let SarifCtx {
1650        results,
1651        root,
1652        rules,
1653    } = *ctx;
1654
1655    if !results.unlisted_dependencies.is_empty() {
1656        push_sarif_unlisted_deps(
1657            sarif_results,
1658            &results.unlisted_dependencies,
1659            root,
1660            rules.unlisted_dependencies,
1661            snippets,
1662        );
1663    }
1664    if !results.duplicate_exports.is_empty() {
1665        push_sarif_duplicate_exports(
1666            sarif_results,
1667            &results.duplicate_exports,
1668            root,
1669            rules.duplicate_exports,
1670            snippets,
1671        );
1672    }
1673}
1674
1675/// Push the component-contract SARIF results (`unused-component-prop` and
1676/// `unused-component-emit`). Extracted from `push_graph_sarif_results` to keep
1677/// that function under the unit-size lint.
1678fn push_component_contract_sarif_results(
1679    sarif_results: &mut Vec<serde_json::Value>,
1680    ctx: &SarifCtx<'_>,
1681    snippets: &mut SourceSnippetCache,
1682) {
1683    push_component_member_sarif_results(sarif_results, ctx, snippets);
1684    push_component_framework_sarif_results(sarif_results, ctx, snippets);
1685    push_component_shape_sarif_results(sarif_results, ctx, snippets);
1686}
1687
1688/// Push SARIF results for unused component props, emits, inputs, and outputs.
1689fn candidate_sarif_region(
1690    snippets: &mut SourceSnippetCache,
1691    path: &Path,
1692    line: u32,
1693    byte_col: u32,
1694) -> serde_json::Value {
1695    let mut region = serde_json::json!({"startLine":line});
1696    if let Some(source) = snippets.line(path, line)
1697        && let Some(prefix) = source.get(..byte_col as usize)
1698    {
1699        // SARIF defaults to UTF-16 code units when the run omits columnKind.
1700        region["startColumn"] = serde_json::json!(prefix.encode_utf16().count() + 1);
1701    }
1702    region
1703}
1704
1705fn push_absent_component_prop_sarif_results(
1706    sarif_results: &mut Vec<serde_json::Value>,
1707    ctx: &SarifCtx<'_>,
1708    snippets: &mut SourceSnippetCache,
1709) {
1710    let start = sarif_results.len();
1711    push_sarif_results(
1712        sarif_results,
1713        &ctx.results.absent_component_props,
1714        snippets,
1715        |finding| {
1716            let prop = &finding.prop;
1717            SarifFields {
1718                rule_id: "fallow/absent-component-prop",
1719                level: finding_level(finding, ctx.rules.absent_component_props),
1720                message: format!(
1721                    "Optional prop '{}.{}' is absent from inspected callers. {}",
1722                    prop.component_name, prop.prop_name, prop.explanation
1723                ),
1724                uri: relative_uri(&prop.path, ctx.root),
1725                region: Some((prop.line, prop.col + 1)),
1726                source_path: Some(prop.path.clone()),
1727                properties: Some(
1728                    serde_json::json!({"framework":prop.framework,"hasDefault":prop.has_default}),
1729                ),
1730            }
1731        },
1732    );
1733    for (result, finding) in sarif_results[start..]
1734        .iter_mut()
1735        .zip(&ctx.results.absent_component_props)
1736    {
1737        result["locations"][0]["physicalLocation"]["region"] = candidate_sarif_region(
1738            snippets,
1739            &finding.prop.path,
1740            finding.prop.line,
1741            finding.prop.col,
1742        );
1743        result["relatedLocations"] = serde_json::Value::Array(finding.prop.inspected_call_sites.iter().enumerate().map(|(index, site)| serde_json::json!({
1744            "id": index + 1,
1745            "message": {"text":"Inspected caller does not supply this optional prop"},
1746            "physicalLocation": {"artifactLocation":{"uri":relative_uri(&site.path,ctx.root)},"region":candidate_sarif_region(snippets,&site.path,site.line,site.col)},
1747        })).collect());
1748    }
1749}
1750
1751fn push_component_member_sarif_results(
1752    sarif_results: &mut Vec<serde_json::Value>,
1753    ctx: &SarifCtx<'_>,
1754    snippets: &mut SourceSnippetCache,
1755) {
1756    let SarifCtx {
1757        results,
1758        root,
1759        rules,
1760    } = *ctx;
1761    push_absent_component_prop_sarif_results(sarif_results, ctx, snippets);
1762
1763    push_sarif_results(
1764        sarif_results,
1765        &results.unused_component_props,
1766        snippets,
1767        |p| {
1768            sarif_unused_component_prop_fields(
1769                &p.prop,
1770                root,
1771                finding_level(p, rules.unused_component_props),
1772            )
1773        },
1774    );
1775    push_sarif_results(
1776        sarif_results,
1777        &results.unused_component_emits,
1778        snippets,
1779        |e| {
1780            sarif_unused_component_emit_fields(
1781                &e.emit,
1782                root,
1783                finding_level(e, rules.unused_component_emits),
1784            )
1785        },
1786    );
1787    push_sarif_results(
1788        sarif_results,
1789        &results.unused_component_inputs,
1790        snippets,
1791        |i| {
1792            sarif_unused_component_input_fields(
1793                &i.input,
1794                root,
1795                finding_level(i, rules.unused_component_inputs),
1796            )
1797        },
1798    );
1799    push_sarif_results(
1800        sarif_results,
1801        &results.unused_component_outputs,
1802        snippets,
1803        |o| {
1804            sarif_unused_component_output_fields(
1805                &o.output,
1806                root,
1807                finding_level(o, rules.unused_component_outputs),
1808            )
1809        },
1810    );
1811}
1812
1813/// Push SARIF results for Svelte events, server actions, and load-data keys.
1814fn push_component_framework_sarif_results(
1815    sarif_results: &mut Vec<serde_json::Value>,
1816    ctx: &SarifCtx<'_>,
1817    snippets: &mut SourceSnippetCache,
1818) {
1819    let SarifCtx {
1820        results,
1821        root,
1822        rules,
1823    } = *ctx;
1824
1825    push_sarif_results(
1826        sarif_results,
1827        &results.unused_svelte_events,
1828        snippets,
1829        |e| {
1830            sarif_unused_svelte_event_fields(
1831                &e.event,
1832                root,
1833                finding_level(e, rules.unused_svelte_events),
1834            )
1835        },
1836    );
1837    push_sarif_results(
1838        sarif_results,
1839        &results.unused_effect_layers,
1840        snippets,
1841        |e| {
1842            sarif_unused_effect_layer_fields(
1843                &e.layer,
1844                root,
1845                finding_level(e, rules.unused_effect_layers),
1846            )
1847        },
1848    );
1849    push_sarif_results(
1850        sarif_results,
1851        &results.unused_tagged_errors,
1852        snippets,
1853        |e| {
1854            sarif_unused_tagged_error_fields(
1855                &e.error,
1856                root,
1857                finding_level(e, rules.unused_tagged_errors),
1858            )
1859        },
1860    );
1861    push_sarif_results(
1862        sarif_results,
1863        &results.unproduced_catch_tags,
1864        snippets,
1865        |e| {
1866            sarif_unproduced_catch_tag_fields(
1867                &e.catch_tag,
1868                root,
1869                finding_level(e, rules.unproduced_catch_tags),
1870            )
1871        },
1872    );
1873    push_sarif_results(
1874        sarif_results,
1875        &results.unused_server_actions,
1876        snippets,
1877        |a| {
1878            sarif_unused_server_action_fields(
1879                &a.action,
1880                root,
1881                finding_level(a, rules.unused_server_actions),
1882            )
1883        },
1884    );
1885    push_sarif_results(
1886        sarif_results,
1887        &results.unused_load_data_keys,
1888        snippets,
1889        |k| {
1890            sarif_unused_load_data_key_fields(
1891                &k.key,
1892                root,
1893                finding_level(k, rules.unused_load_data_keys),
1894            )
1895        },
1896    );
1897}
1898
1899/// Push SARIF results for prop drilling, thin wrappers, and duplicate prop shapes.
1900fn push_component_shape_sarif_results(
1901    sarif_results: &mut Vec<serde_json::Value>,
1902    ctx: &SarifCtx<'_>,
1903    snippets: &mut SourceSnippetCache,
1904) {
1905    let SarifCtx {
1906        results,
1907        root,
1908        rules,
1909    } = *ctx;
1910
1911    push_sarif_results(
1912        sarif_results,
1913        &results.prop_drilling_chains,
1914        snippets,
1915        |c| sarif_prop_drilling_fields(&c.chain, root, finding_level(c, rules.prop_drilling)),
1916    );
1917    push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1918        sarif_thin_wrapper_fields(&w.wrapper, root, finding_level(w, rules.thin_wrapper))
1919    });
1920    push_sarif_results(
1921        sarif_results,
1922        &results.duplicate_prop_shapes,
1923        snippets,
1924        |d| {
1925            sarif_duplicate_prop_shape_fields(
1926                &d.shape,
1927                root,
1928                finding_level(d, rules.duplicate_prop_shape),
1929            )
1930        },
1931    );
1932}
1933
1934fn push_graph_sarif_results(
1935    sarif_results: &mut Vec<serde_json::Value>,
1936    ctx: &SarifCtx<'_>,
1937    snippets: &mut SourceSnippetCache,
1938) {
1939    push_structure_sarif_results(sarif_results, ctx, snippets);
1940    push_framework_sarif_results(sarif_results, ctx, snippets);
1941    push_route_sarif_results(sarif_results, ctx, snippets);
1942    push_suppression_sarif_results(sarif_results, ctx, snippets);
1943}
1944
1945fn push_structure_sarif_results(
1946    sarif_results: &mut Vec<serde_json::Value>,
1947    ctx: &SarifCtx<'_>,
1948    snippets: &mut SourceSnippetCache,
1949) {
1950    push_cycle_sarif_results(sarif_results, ctx, snippets);
1951    push_boundary_sarif_results(sarif_results, ctx, snippets);
1952}
1953
1954/// Push SARIF results for circular dependencies, re-export cycles and
1955/// package cycles.
1956fn push_cycle_sarif_results(
1957    sarif_results: &mut Vec<serde_json::Value>,
1958    ctx: &SarifCtx<'_>,
1959    snippets: &mut SourceSnippetCache,
1960) {
1961    let SarifCtx {
1962        results,
1963        root,
1964        rules,
1965    } = *ctx;
1966
1967    push_sarif_results(
1968        sarif_results,
1969        &results.circular_dependencies,
1970        snippets,
1971        |c| {
1972            sarif_circular_dep_fields(
1973                &c.cycle,
1974                root,
1975                finding_level(c, rules.circular_dependencies),
1976            )
1977        },
1978    );
1979    push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1980        sarif_re_export_cycle_fields(&c.cycle, root, finding_level(c, rules.re_export_cycle))
1981    });
1982    push_sarif_results(sarif_results, &results.package_cycles, snippets, |c| {
1983        sarif_package_cycle_fields(&c.cycle, root, finding_level(c, rules.package_cycle))
1984    });
1985}
1986
1987/// Push SARIF results for boundary violations, coverage, calls, and policy violations.
1988fn push_boundary_sarif_results(
1989    sarif_results: &mut Vec<serde_json::Value>,
1990    ctx: &SarifCtx<'_>,
1991    snippets: &mut SourceSnippetCache,
1992) {
1993    let SarifCtx {
1994        results,
1995        root,
1996        rules,
1997    } = *ctx;
1998
1999    push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
2000        sarif_boundary_violation_fields(
2001            &v.violation,
2002            root,
2003            finding_level(v, rules.boundary_violation),
2004        )
2005    });
2006    push_sarif_results(
2007        sarif_results,
2008        &results.boundary_coverage_violations,
2009        snippets,
2010        |v| {
2011            sarif_boundary_coverage_fields(
2012                &v.violation,
2013                root,
2014                finding_level(v, rules.boundary_violation),
2015            )
2016        },
2017    );
2018    push_sarif_results(
2019        sarif_results,
2020        &results.boundary_call_violations,
2021        snippets,
2022        |v| {
2023            sarif_boundary_call_fields(
2024                &v.violation,
2025                root,
2026                finding_level(v, rules.boundary_violation),
2027            )
2028        },
2029    );
2030    push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
2031        sarif_policy_violation_fields(&v.violation, root)
2032    });
2033}
2034
2035fn push_framework_sarif_results(
2036    sarif_results: &mut Vec<serde_json::Value>,
2037    ctx: &SarifCtx<'_>,
2038    snippets: &mut SourceSnippetCache,
2039) {
2040    push_framework_boundary_sarif_results(sarif_results, ctx, snippets);
2041    push_component_contract_sarif_results(sarif_results, ctx, snippets);
2042}
2043
2044/// Push SARIF results for client exports, barrels, directives, injects, and unrendered components.
2045fn push_framework_boundary_sarif_results(
2046    sarif_results: &mut Vec<serde_json::Value>,
2047    ctx: &SarifCtx<'_>,
2048    snippets: &mut SourceSnippetCache,
2049) {
2050    let SarifCtx {
2051        results,
2052        root,
2053        rules,
2054    } = *ctx;
2055
2056    push_sarif_results(
2057        sarif_results,
2058        &results.invalid_client_exports,
2059        snippets,
2060        |e| {
2061            sarif_invalid_client_export_fields(
2062                &e.export,
2063                root,
2064                finding_level(e, rules.invalid_client_export),
2065            )
2066        },
2067    );
2068    push_sarif_results(
2069        sarif_results,
2070        &results.mixed_client_server_barrels,
2071        snippets,
2072        |b| {
2073            sarif_mixed_client_server_barrel_fields(
2074                &b.barrel,
2075                root,
2076                finding_level(b, rules.mixed_client_server_barrel),
2077            )
2078        },
2079    );
2080    push_sarif_results(
2081        sarif_results,
2082        &results.misplaced_directives,
2083        snippets,
2084        |d| {
2085            sarif_misplaced_directive_fields(
2086                &d.directive_site,
2087                root,
2088                finding_level(d, rules.misplaced_directive),
2089            )
2090        },
2091    );
2092    push_framework_render_sarif_results(sarif_results, ctx, snippets);
2093}
2094
2095fn push_framework_render_sarif_results(
2096    sarif_results: &mut Vec<serde_json::Value>,
2097    ctx: &SarifCtx<'_>,
2098    snippets: &mut SourceSnippetCache,
2099) {
2100    let SarifCtx {
2101        results,
2102        root,
2103        rules,
2104    } = *ctx;
2105
2106    push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
2107        sarif_unprovided_inject_fields(&i.inject, root, finding_level(i, rules.unprovided_injects))
2108    });
2109    push_sarif_results(
2110        sarif_results,
2111        &results.unrendered_components,
2112        snippets,
2113        |c| {
2114            sarif_unrendered_component_fields(
2115                &c.component,
2116                root,
2117                finding_level(c, rules.unrendered_components),
2118            )
2119        },
2120    );
2121}
2122
2123fn push_route_sarif_results(
2124    sarif_results: &mut Vec<serde_json::Value>,
2125    ctx: &SarifCtx<'_>,
2126    snippets: &mut SourceSnippetCache,
2127) {
2128    let SarifCtx {
2129        results,
2130        root,
2131        rules,
2132    } = *ctx;
2133
2134    push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
2135        sarif_route_collision_fields(&c.collision, root, finding_level(c, rules.route_collision))
2136    });
2137    push_sarif_results(
2138        sarif_results,
2139        &results.dynamic_segment_name_conflicts,
2140        snippets,
2141        |c| {
2142            sarif_dynamic_segment_name_conflict_fields(
2143                &c.conflict,
2144                root,
2145                finding_level(c, rules.dynamic_segment_name_conflict),
2146            )
2147        },
2148    );
2149}
2150
2151fn push_suppression_sarif_results(
2152    sarif_results: &mut Vec<serde_json::Value>,
2153    ctx: &SarifCtx<'_>,
2154    snippets: &mut SourceSnippetCache,
2155) {
2156    let SarifCtx {
2157        results,
2158        root,
2159        rules,
2160    } = *ctx;
2161
2162    push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
2163        sarif_stale_suppression_fields(
2164            s,
2165            root,
2166            finding_level(s, stale_suppression_severity(s, rules)),
2167        )
2168    });
2169}
2170
2171fn push_catalog_sarif_results(
2172    sarif_results: &mut Vec<serde_json::Value>,
2173    ctx: &SarifCtx<'_>,
2174    snippets: &mut SourceSnippetCache,
2175) {
2176    push_catalog_entry_sarif_results(sarif_results, ctx, snippets);
2177    push_dependency_override_sarif_results(sarif_results, ctx, snippets);
2178}
2179
2180/// Push SARIF results for unused catalog entries, empty groups, and unresolved references.
2181fn push_catalog_entry_sarif_results(
2182    sarif_results: &mut Vec<serde_json::Value>,
2183    ctx: &SarifCtx<'_>,
2184    snippets: &mut SourceSnippetCache,
2185) {
2186    let SarifCtx {
2187        results,
2188        root,
2189        rules,
2190    } = *ctx;
2191
2192    push_sarif_results(
2193        sarif_results,
2194        &results.unused_catalog_entries,
2195        snippets,
2196        |e| {
2197            sarif_unused_catalog_entry_fields(
2198                e,
2199                root,
2200                finding_level(e, rules.unused_catalog_entries),
2201            )
2202        },
2203    );
2204    push_sarif_results(
2205        sarif_results,
2206        &results.empty_catalog_groups,
2207        snippets,
2208        |g| sarif_empty_catalog_group_fields(g, root, finding_level(g, rules.empty_catalog_groups)),
2209    );
2210    push_sarif_results(
2211        sarif_results,
2212        &results.unresolved_catalog_references,
2213        snippets,
2214        |f| {
2215            sarif_unresolved_catalog_reference_fields(
2216                f,
2217                root,
2218                finding_level(f, rules.unresolved_catalog_references),
2219            )
2220        },
2221    );
2222}
2223
2224/// Push SARIF results for unused and misconfigured dependency overrides.
2225fn push_dependency_override_sarif_results(
2226    sarif_results: &mut Vec<serde_json::Value>,
2227    ctx: &SarifCtx<'_>,
2228    snippets: &mut SourceSnippetCache,
2229) {
2230    let SarifCtx {
2231        results,
2232        root,
2233        rules,
2234    } = *ctx;
2235
2236    push_sarif_results(
2237        sarif_results,
2238        &results.unused_dependency_overrides,
2239        snippets,
2240        |f| {
2241            sarif_unused_dependency_override_fields(
2242                f,
2243                root,
2244                finding_level(f, rules.unused_dependency_overrides),
2245            )
2246        },
2247    );
2248    push_sarif_results(
2249        sarif_results,
2250        &results.misconfigured_dependency_overrides,
2251        snippets,
2252        |f| {
2253            sarif_misconfigured_dependency_override_fields(
2254                f,
2255                root,
2256                finding_level(f, rules.misconfigured_dependency_overrides),
2257            )
2258        },
2259    );
2260}
2261
2262#[cfg(test)]
2263mod tests {
2264    use std::collections::BTreeSet;
2265    use std::path::Path;
2266
2267    use fallow_config::RulesConfig;
2268    use fallow_types::results::AnalysisResults;
2269
2270    use super::*;
2271
2272    fn test_rule_builder(id: &str, description: &str, level: &str) -> serde_json::Value {
2273        serde_json::json!({
2274            "id": id,
2275            "shortDescription": { "text": description },
2276            "defaultConfiguration": { "level": level }
2277        })
2278    }
2279
2280    /// A SARIF consumer reads the message text, not the JSON envelope, so the
2281    /// degraded-parse caveat has to travel in the message. A clean finding
2282    /// keeps the previous text byte-for-byte.
2283    #[test]
2284    fn sarif_messages_name_the_degraded_parse_caveat() {
2285        let mut results = AnalysisResults::default();
2286        results
2287            .unused_files
2288            .push(UnusedFileFinding::with_actions(UnusedFile {
2289                path: Path::new("/p/src/clean.ts").to_path_buf(),
2290            }));
2291        let mut flagged = UnusedFileFinding::with_actions(UnusedFile {
2292            path: Path::new("/p/src/orphan.ts").to_path_buf(),
2293        });
2294        flagged.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2295        results.unused_files.push(flagged);
2296
2297        // Every member array carries the same caveat off the same
2298        // reachability-free access walk, so all three have to reach the
2299        // message. Store members were rendered bare while the array itself was
2300        // stamped.
2301        let member = |parent: &str, name: &str, kind| UnusedMember {
2302            path: Path::new("/p/src/lib.ts").to_path_buf(),
2303            parent_name: parent.to_owned(),
2304            member_name: name.to_owned(),
2305            kind,
2306            line: 7,
2307            col: 2,
2308        };
2309        let mut enum_member = UnusedEnumMemberFinding::with_actions(member(
2310            "Mode",
2311            "Legacy",
2312            fallow_types::extract::MemberKind::EnumMember,
2313        ));
2314        enum_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2315        results.unused_enum_members.push(enum_member);
2316        let mut class_member = UnusedClassMemberFinding::with_actions(member(
2317            "Widget",
2318            "render",
2319            fallow_types::extract::MemberKind::ClassMethod,
2320        ));
2321        class_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2322        results.unused_class_members.push(class_member);
2323        let mut store_member = UnusedStoreMemberFinding::with_actions(member(
2324            "useCart",
2325            "subtotal",
2326            fallow_types::extract::MemberKind::StoreMember,
2327        ));
2328        store_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2329        results.unused_store_members.push(store_member);
2330
2331        let sarif = build_dead_code_sarif(
2332            &results,
2333            Path::new("/p"),
2334            &RulesConfig::default(),
2335            &test_rule_builder,
2336        );
2337        let messages: Vec<String> = sarif
2338            .pointer("/runs/0/results")
2339            .and_then(serde_json::Value::as_array)
2340            .expect("SARIF results")
2341            .iter()
2342            .filter_map(|entry| {
2343                entry
2344                    .pointer("/message/text")
2345                    .and_then(serde_json::Value::as_str)
2346                    .map(str::to_owned)
2347            })
2348            .collect();
2349
2350        assert!(
2351            messages.contains(&"File is not reachable from any entry point".to_owned()),
2352            "a clean finding keeps its exact message: {messages:?}"
2353        );
2354        assert!(
2355            messages.contains(
2356                &"File is not reachable from any entry point (caveat: incomplete import graph)"
2357                    .to_owned()
2358            ),
2359            "a caveated finding names it in the message: {messages:?}"
2360        );
2361        for expected in [
2362            "Enum member 'Mode.Legacy' is never referenced (caveat: incomplete import graph)",
2363            "Class member 'Widget.render' is never referenced (caveat: incomplete import graph)",
2364            "Store member 'useCart.subtotal' is never referenced (caveat: incomplete import graph)",
2365        ] {
2366            assert!(
2367                messages.contains(&expected.to_owned()),
2368                "every member kind names the caveat: {messages:?}"
2369            );
2370        }
2371    }
2372
2373    #[test]
2374    fn root_dependency_message_names_the_workspaces_that_declare_and_import_it() {
2375        let root = Path::new("/project");
2376        let mut results = AnalysisResults::default();
2377        results
2378            .unused_dependencies
2379            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2380                package_name: "left-pad".to_owned(),
2381                location: fallow_types::results::DependencyLocation::Dependencies,
2382                path: root.join("package.json"),
2383                line: 0,
2384                used_in_workspaces: Vec::new(),
2385                declared_and_imported_in: vec![root.join("packages/app")],
2386            }));
2387
2388        let sarif =
2389            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2390        let message = sarif
2391            .pointer("/runs/0/results/0/message/text")
2392            .and_then(serde_json::Value::as_str)
2393            .expect("SARIF message");
2394        assert_eq!(
2395            message,
2396            "Package 'left-pad' is in dependencies but never imported; declared and imported in other workspaces: packages/app"
2397        );
2398    }
2399
2400    /// The reported defect: `npm init -y` writes a `package.json` whose
2401    /// dependency block can sit on one line, and `find_dep_line_in_json` also
2402    /// falls back to line 1 for a key it cannot locate. Every unused dependency
2403    /// then reported the same rule id, the same URI, and the same source
2404    /// snippet, which is the whole of a SARIF fingerprint, so GitHub code
2405    /// scanning showed one alert for all of them. CodeClimate never had the
2406    /// defect: it keys on the package name.
2407    #[test]
2408    fn two_dependencies_on_one_manifest_line_are_two_alerts() {
2409        let dir = tempfile::tempdir().expect("temporary project");
2410        let root = dir.path();
2411        std::fs::write(
2412            root.join("package.json"),
2413            r#"{"name":"compact","dependencies":{"lodash":"^4.17.21","chalk":"^5.3.0"}}"#,
2414        )
2415        .expect("write manifest");
2416
2417        let mut results = AnalysisResults::default();
2418        for name in ["lodash", "chalk"] {
2419            results
2420                .unused_dependencies
2421                .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2422                    package_name: name.to_owned(),
2423                    location: fallow_types::results::DependencyLocation::Dependencies,
2424                    path: root.join("package.json"),
2425                    line: 1,
2426                    used_in_workspaces: Vec::new(),
2427                    declared_and_imported_in: Vec::new(),
2428                }));
2429        }
2430
2431        let sarif =
2432            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2433        let entries = sarif
2434            .pointer("/runs/0/results")
2435            .and_then(serde_json::Value::as_array)
2436            .expect("SARIF results");
2437
2438        let fingerprints = entries
2439            .iter()
2440            .map(|entry| {
2441                entry
2442                    .pointer("/partialFingerprints/tools.fallow.fingerprint~1v1")
2443                    .and_then(serde_json::Value::as_str)
2444                    .expect("fingerprint")
2445            })
2446            .collect::<BTreeSet<_>>();
2447        assert_eq!(
2448            fingerprints.len(),
2449            entries.len(),
2450            "two dependencies declared on one line are two alerts: {entries:#?}"
2451        );
2452
2453        let columns = entries
2454            .iter()
2455            .map(|entry| {
2456                entry
2457                    .pointer("/locations/0/physicalLocation/region/startColumn")
2458                    .and_then(serde_json::Value::as_u64)
2459                    .expect("start column")
2460            })
2461            .collect::<BTreeSet<_>>();
2462        assert_eq!(
2463            columns.len(),
2464            entries.len(),
2465            "each dependency points at its own key in the manifest line: {entries:#?}"
2466        );
2467    }
2468
2469    /// Why the column and not only the run-level uniqueness pass: that pass
2470    /// separates repeats by occurrence index, so a dependency's identity would
2471    /// depend on its siblings and fixing the first one would renumber, and
2472    /// close, the alert on the second. The column is the dependency's own
2473    /// position, so an unrelated dependency added above it moves neither.
2474    #[test]
2475    fn a_dependency_added_above_leaves_the_others_alert_alone() {
2476        let dir = tempfile::tempdir().expect("temporary project");
2477        let root = dir.path();
2478        let manifest = root.join("package.json");
2479
2480        let chalk_fingerprint = |manifest_text: &str, chalk_line: u32| {
2481            std::fs::write(&manifest, manifest_text).expect("write manifest");
2482            let mut results = AnalysisResults::default();
2483            results
2484                .unused_dependencies
2485                .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2486                    package_name: "chalk".to_owned(),
2487                    location: fallow_types::results::DependencyLocation::Dependencies,
2488                    path: manifest.clone(),
2489                    line: chalk_line,
2490                    used_in_workspaces: Vec::new(),
2491                    declared_and_imported_in: Vec::new(),
2492                }));
2493            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder)
2494                .pointer("/runs/0/results/0/partialFingerprints/tools.fallow.fingerprint~1v1")
2495                .and_then(serde_json::Value::as_str)
2496                .expect("chalk fingerprint")
2497                .to_owned()
2498        };
2499
2500        let before = "{\n  \"dependencies\": {\n    \"chalk\": \"^5.3.0\"\n  }\n}\n";
2501        let after = "{\n  \"dependencies\": {\n    \"lodash\": \"^4.17.21\",\n    \"chalk\": \"^5.3.0\"\n  }\n}\n";
2502
2503        assert_eq!(
2504            chalk_fingerprint(before, 3),
2505            chalk_fingerprint(after, 4),
2506            "a dependency declared above it must not move chalk's alert"
2507        );
2508    }
2509
2510    /// `partialFingerprints` is the alert identity GitHub code scanning uses to
2511    /// carry a finding across runs. It is built from rule id plus location (or a
2512    /// normalized snippet), never from the message, so a finding that gains the
2513    /// caveat must keep its fingerprint. If the caveat ever reached the
2514    /// fingerprint inputs, every open alert on a degraded repository would close
2515    /// and reopen as new on the next scan.
2516    #[test]
2517    fn the_caveat_does_not_move_the_sarif_fingerprint() {
2518        let build = |caveated: bool| {
2519            let mut results = AnalysisResults::default();
2520            let mut finding = UnusedFileFinding::with_actions(UnusedFile {
2521                path: Path::new("/p/src/orphan.ts").to_path_buf(),
2522            });
2523            if caveated {
2524                finding.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2525            }
2526            results.unused_files.push(finding);
2527            build_dead_code_sarif(
2528                &results,
2529                Path::new("/p"),
2530                &RulesConfig::default(),
2531                &test_rule_builder,
2532            )
2533        };
2534
2535        let read = |sarif: &serde_json::Value, pointer: &str| {
2536            sarif
2537                .pointer("/runs/0/results")
2538                .and_then(serde_json::Value::as_array)
2539                .expect("SARIF results")
2540                .iter()
2541                .map(|entry| {
2542                    entry
2543                        .pointer(pointer)
2544                        .and_then(serde_json::Value::as_str)
2545                        .expect("SARIF field")
2546                        .to_owned()
2547                })
2548                .collect::<Vec<_>>()
2549        };
2550
2551        let clean = build(false);
2552        let caveated = build(true);
2553
2554        for key in [
2555            "/partialFingerprints/tools.fallow.fingerprint~1v1",
2556            "/partialFingerprints/primaryLocationLineHash~1v1",
2557        ] {
2558            assert_eq!(
2559                read(&clean, key),
2560                read(&caveated, key),
2561                "the caveat must not move {key}"
2562            );
2563        }
2564
2565        assert_ne!(
2566            read(&clean, "/message/text"),
2567            read(&caveated, "/message/text"),
2568            "the guard is only meaningful while the message actually changed"
2569        );
2570    }
2571
2572    #[test]
2573    fn sarif_rule_list_is_backed_by_issue_contracts() {
2574        let sarif = build_dead_code_sarif(
2575            &AnalysisResults::default(),
2576            Path::new("."),
2577            &RulesConfig::default(),
2578            &test_rule_builder,
2579        );
2580        let Some(rules) = sarif
2581            .pointer("/runs/0/tool/driver/rules")
2582            .and_then(serde_json::Value::as_array)
2583        else {
2584            panic!("SARIF document should contain driver rules");
2585        };
2586
2587        let actual_ids = rules
2588            .iter()
2589            .filter_map(|rule| {
2590                rule.get("id")
2591                    .and_then(serde_json::Value::as_str)
2592                    .map(str::to_owned)
2593            })
2594            .collect::<BTreeSet<_>>();
2595        let expected_ids = issue_output_contracts()
2596            .flat_map(|contract| contract.sarif_rule_ids)
2597            .collect::<BTreeSet<_>>();
2598
2599        assert_eq!(actual_ids, expected_ids);
2600
2601        for rule in rules {
2602            let id = rule
2603                .get("id")
2604                .and_then(serde_json::Value::as_str)
2605                .expect("SARIF rule should have id");
2606            let description = rule
2607                .pointer("/shortDescription/text")
2608                .and_then(serde_json::Value::as_str)
2609                .expect("SARIF rule should have short description");
2610            assert_eq!(
2611                description,
2612                issue_sarif_rule_description(id).expect("SARIF rule description should resolve")
2613            );
2614        }
2615    }
2616
2617    const FINDING_ID_POINTER: &str = "/partialFingerprints/fallowFinding~1v1";
2618
2619    /// One finding per single-result kind, plus the two kinds that fan out to
2620    /// one SARIF result per location.
2621    fn identity_results(root: &Path) -> AnalysisResults {
2622        let mut results = AnalysisResults::default();
2623        results
2624            .unused_files
2625            .push(UnusedFileFinding::with_actions(UnusedFile {
2626                path: root.join("src/orphan.ts"),
2627            }));
2628        results
2629            .unused_dependencies
2630            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2631                package_name: "lodash".to_owned(),
2632                location: fallow_types::results::DependencyLocation::Dependencies,
2633                path: root.join("package.json"),
2634                line: 3,
2635                used_in_workspaces: Vec::new(),
2636                declared_and_imported_in: Vec::new(),
2637            }));
2638        results
2639            .unlisted_dependencies
2640            .push(UnlistedDependencyFinding::with_actions(
2641                fallow_types::results::UnlistedDependency {
2642                    package_name: "chalk".to_owned(),
2643                    imported_from: vec![
2644                        fallow_types::results::ImportSite {
2645                            path: root.join("src/a.ts"),
2646                            line: 1,
2647                            col: 0,
2648                        },
2649                        fallow_types::results::ImportSite {
2650                            path: root.join("src/b.ts"),
2651                            line: 2,
2652                            col: 0,
2653                        },
2654                    ],
2655                },
2656            ));
2657        results
2658            .duplicate_exports
2659            .push(DuplicateExportFinding::with_actions(
2660                fallow_types::results::DuplicateExport {
2661                    export_name: "Button".to_owned(),
2662                    locations: vec![
2663                        fallow_types::results::DuplicateLocation {
2664                            path: root.join("src/a.ts"),
2665                            line: 4,
2666                            col: 0,
2667                        },
2668                        fallow_types::results::DuplicateLocation {
2669                            path: root.join("src/b.ts"),
2670                            line: 5,
2671                            col: 0,
2672                        },
2673                    ],
2674                },
2675            ));
2676        results
2677    }
2678
2679    fn sarif_entries(sarif: &serde_json::Value) -> Vec<serde_json::Value> {
2680        sarif
2681            .pointer("/runs/0/results")
2682            .and_then(serde_json::Value::as_array)
2683            .expect("SARIF results")
2684            .clone()
2685    }
2686
2687    /// A finding that maps to one SARIF result carries its `finding_id` under
2688    /// `fallowFinding/v1`, next to the location-based keys.
2689    #[test]
2690    fn a_single_result_finding_carries_its_id_as_a_partial_fingerprint() {
2691        let root = Path::new("/p");
2692        let mut results = identity_results(root);
2693        fallow_types::identity::stamp_dead_code_finding_ids(&mut results, root);
2694        let sarif =
2695            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2696        let entries = sarif_entries(&sarif);
2697
2698        let id_of_rule = |rule: &str| {
2699            entries
2700                .iter()
2701                .find(|entry| entry["ruleId"] == rule)
2702                .and_then(|entry| entry.pointer(FINDING_ID_POINTER))
2703                .and_then(serde_json::Value::as_str)
2704                .map(str::to_owned)
2705        };
2706        assert_eq!(
2707            id_of_rule("fallow/unused-file").as_deref(),
2708            results.unused_files[0].finding_id.as_deref(),
2709        );
2710        assert_eq!(
2711            id_of_rule("fallow/unused-dependency").as_deref(),
2712            results.unused_dependencies[0].finding_id.as_deref(),
2713        );
2714        assert!(
2715            id_of_rule("fallow/unused-file").is_some_and(|id| id.starts_with("dc1:unused-file:")),
2716            "the key holds the finding id: {entries:#?}"
2717        );
2718    }
2719
2720    /// A partial fingerprint must identify one result. An unlisted dependency
2721    /// and a duplicate export give one result per location, so one id would
2722    /// name several results. These results do not carry the key.
2723    #[test]
2724    fn a_fanned_out_finding_does_not_carry_the_id() {
2725        let root = Path::new("/p");
2726        let mut results = identity_results(root);
2727        fallow_types::identity::stamp_dead_code_finding_ids(&mut results, root);
2728        let sarif =
2729            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2730
2731        for entry in sarif_entries(&sarif) {
2732            let rule = entry["ruleId"].as_str().expect("rule id");
2733            if matches!(
2734                rule,
2735                "fallow/unlisted-dependency" | "fallow/duplicate-export"
2736            ) {
2737                assert!(
2738                    entry.pointer(FINDING_ID_POINTER).is_none(),
2739                    "{rule} fans out and must not carry the id: {entry:#?}"
2740                );
2741            }
2742        }
2743    }
2744
2745    /// GitHub code scanning matches alerts on the location-based keys. The new
2746    /// key must not change any other byte of the document, or open alerts
2747    /// close and reopen on the next upload.
2748    #[test]
2749    fn the_finding_id_key_leaves_every_other_sarif_byte_alone() {
2750        let root = Path::new("/p");
2751        let without_ids = identity_results(root);
2752        let mut with_ids = without_ids.clone();
2753        fallow_types::identity::stamp_dead_code_finding_ids(&mut with_ids, root);
2754
2755        let before = build_dead_code_sarif(
2756            &without_ids,
2757            root,
2758            &RulesConfig::default(),
2759            &test_rule_builder,
2760        );
2761        let mut after =
2762            build_dead_code_sarif(&with_ids, root, &RulesConfig::default(), &test_rule_builder);
2763
2764        let mut removed = 0;
2765        for entry in after
2766            .pointer_mut("/runs/0/results")
2767            .and_then(serde_json::Value::as_array_mut)
2768            .expect("SARIF results")
2769        {
2770            let prints = entry["partialFingerprints"]
2771                .as_object_mut()
2772                .expect("partial fingerprints");
2773            if prints.remove("fallowFinding/v1").is_some() {
2774                removed += 1;
2775            }
2776        }
2777        assert!(
2778            removed > 0,
2779            "the guard needs at least one result with the key"
2780        );
2781        assert_eq!(
2782            serde_json::to_string(&before).expect("serialize"),
2783            serde_json::to_string(&after).expect("serialize"),
2784        );
2785    }
2786
2787    /// A saved envelope from an older version has no ids. The key is then
2788    /// absent, not empty and not null.
2789    #[test]
2790    fn a_finding_without_an_id_has_no_finding_id_key() {
2791        let root = Path::new("/p");
2792        let sarif = build_dead_code_sarif(
2793            &identity_results(root),
2794            root,
2795            &RulesConfig::default(),
2796            &test_rule_builder,
2797        );
2798        for entry in sarif_entries(&sarif) {
2799            assert!(
2800                entry["partialFingerprints"]
2801                    .get("fallowFinding/v1")
2802                    .is_none(),
2803                "no id, no key: {entry:#?}"
2804            );
2805        }
2806    }
2807}