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