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_package_cycle_fields(
444    cycle: &fallow_types::results::PackageCycle,
445    root: &Path,
446    level: &'static str,
447) -> SarifFields {
448    let anchor = cycle.edges.first();
449    let note = if cycle.group_truncated {
450        format!(
451            " ({})",
452            fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
453        )
454    } else {
455        String::new()
456    };
457    let package_roots: Vec<String> = cycle
458        .package_roots
459        .iter()
460        .map(|package_root| relative_uri(package_root, root))
461        .collect();
462    SarifFields {
463        rule_id: "fallow/package-cycle",
464        level,
465        message: format!("Package cycle: {}{note}", cycle.chain(" \u{2192} ")),
466        uri: anchor.map_or_else(String::new, |edge| relative_uri(&edge.path, root)),
467        region: anchor.map(|edge| (edge.line, edge.col + 1)),
468        source_path: anchor.map(|edge| edge.path.clone()),
469        properties: Some(serde_json::json!({
470            "packages": cycle.packages,
471            "package_roots": package_roots,
472            "group_truncated": cycle.group_truncated,
473        })),
474    }
475}
476
477fn sarif_boundary_violation_fields(
478    violation: &BoundaryViolation,
479    root: &Path,
480    level: &'static str,
481) -> SarifFields {
482    let from_uri = relative_uri(&violation.from_path, root);
483    let to_uri = relative_uri(&violation.to_path, root);
484    let via = violation.via_path.as_ref().map_or_else(String::new, |via| {
485        format!(", via {}", relative_uri(via, root))
486    });
487    SarifFields {
488        rule_id: "fallow/boundary-violation",
489        level,
490        message: format!(
491            "Import from zone '{}' to zone '{}' is not allowed ({}{})",
492            violation.from_zone, violation.to_zone, to_uri, via,
493        ),
494        uri: from_uri,
495        region: if violation.line > 0 {
496            Some((violation.line, violation.col + 1))
497        } else {
498            None
499        },
500        source_path: (violation.line > 0).then(|| violation.from_path.clone()),
501        properties: None,
502    }
503}
504
505fn sarif_boundary_coverage_fields(
506    violation: &BoundaryCoverageViolation,
507    root: &Path,
508    level: &'static str,
509) -> SarifFields {
510    SarifFields {
511        rule_id: "fallow/boundary-coverage",
512        level,
513        message: "File does not match any configured architecture boundary zone".to_string(),
514        uri: relative_uri(&violation.path, root),
515        region: Some((violation.line, violation.col + 1)),
516        source_path: Some(violation.path.clone()),
517        properties: None,
518    }
519}
520
521fn sarif_boundary_call_fields(
522    violation: &BoundaryCallViolation,
523    root: &Path,
524    level: &'static str,
525) -> SarifFields {
526    SarifFields {
527        rule_id: "fallow/boundary-call-violation",
528        level,
529        message: format!(
530            "Call to `{}` matches forbidden pattern `{}` in zone '{}'",
531            violation.callee, violation.pattern, violation.zone
532        ),
533        uri: relative_uri(&violation.path, root),
534        region: Some((violation.line, violation.col + 1)),
535        source_path: Some(violation.path.clone()),
536        properties: None,
537    }
538}
539
540fn sarif_policy_violation_fields(violation: &PolicyViolation, root: &Path) -> SarifFields {
541    let level = match violation.severity {
542        PolicyViolationSeverity::Error => "error",
543        PolicyViolationSeverity::Warn => "warning",
544    };
545    let message = match &violation.message {
546        Some(message) => format!(
547            "Policy violation `{}/{}`: `{}` is banned. {message}",
548            violation.pack, violation.rule_id, violation.matched
549        ),
550        None => format!(
551            "Policy violation `{}/{}`: `{}` is banned",
552            violation.pack, violation.rule_id, violation.matched
553        ),
554    };
555    SarifFields {
556        rule_id: "fallow/policy-violation",
557        level,
558        message,
559        uri: relative_uri(&violation.path, root),
560        region: Some((violation.line, violation.col + 1)),
561        source_path: Some(violation.path.clone()),
562        // The SARIF rule id is the static `fallow/policy-violation`; the
563        // per-rule policy identity rides in properties so code-scanning
564        // consumers can group or filter per pack rule without parsing the
565        // message. Dynamic per-rule SARIF rule synthesis is a tracked
566        // follow-up shared with boundary zone rules.
567        properties: Some(serde_json::json!({
568            "policyRule": format!("{}/{}", violation.pack, violation.rule_id),
569        })),
570    }
571}
572
573fn sarif_invalid_client_export_fields(
574    export: &InvalidClientExport,
575    root: &Path,
576    level: &'static str,
577) -> SarifFields {
578    SarifFields {
579        rule_id: "fallow/invalid-client-export",
580        level,
581        message: format!(
582            "Export '{}' is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
583            export.export_name, export.directive
584        ),
585        uri: relative_uri(&export.path, root),
586        region: Some((export.line, export.col + 1)),
587        source_path: Some(export.path.clone()),
588        properties: None,
589    }
590}
591
592fn sarif_mixed_client_server_barrel_fields(
593    barrel: &MixedClientServerBarrel,
594    root: &Path,
595    level: &'static str,
596) -> SarifFields {
597    SarifFields {
598        rule_id: "fallow/mixed-client-server-barrel",
599        level,
600        message: format!(
601            "Barrel re-exports both a \"use client\" module ('{}') and a server-only module ('{}'); one import drags the other's directive across the boundary",
602            barrel.client_origin, barrel.server_origin
603        ),
604        uri: relative_uri(&barrel.path, root),
605        region: Some((barrel.line, barrel.col + 1)),
606        source_path: Some(barrel.path.clone()),
607        properties: None,
608    }
609}
610
611fn sarif_misplaced_directive_fields(
612    directive_site: &MisplacedDirective,
613    root: &Path,
614    level: &'static str,
615) -> SarifFields {
616    SarifFields {
617        rule_id: "fallow/misplaced-directive",
618        level,
619        message: format!(
620            "Directive \"{}\" is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
621            directive_site.directive
622        ),
623        uri: relative_uri(&directive_site.path, root),
624        region: Some((directive_site.line, directive_site.col + 1)),
625        source_path: Some(directive_site.path.clone()),
626        properties: None,
627    }
628}
629
630fn sarif_unprovided_inject_fields(
631    inject: &UnprovidedInject,
632    root: &Path,
633    level: &'static str,
634) -> SarifFields {
635    SarifFields {
636        rule_id: "fallow/unprovided-inject",
637        level,
638        message: format!(
639            "inject(\"{}\") has no matching provide(\"{}\") in this project; at runtime it returns undefined; provide the key or remove this inject",
640            inject.key_name, inject.key_name
641        ),
642        uri: relative_uri(&inject.path, root),
643        region: Some((inject.line, inject.col + 1)),
644        source_path: Some(inject.path.clone()),
645        properties: None,
646    }
647}
648
649fn sarif_unrendered_component_fields(
650    component: &UnrenderedComponent,
651    root: &Path,
652    level: &'static str,
653) -> SarifFields {
654    SarifFields {
655        rule_id: "fallow/unrendered-component",
656        level,
657        message: format!(
658            "component \"{}\" is reachable but rendered nowhere in this project; render it somewhere or remove it",
659            component.component_name
660        ),
661        uri: relative_uri(&component.path, root),
662        region: Some((component.line, component.col + 1)),
663        source_path: Some(component.path.clone()),
664        properties: None,
665    }
666}
667
668fn sarif_unused_component_prop_fields(
669    prop: &UnusedComponentProp,
670    root: &Path,
671    level: &'static str,
672) -> SarifFields {
673    SarifFields {
674        rule_id: "fallow/unused-component-prop",
675        level,
676        message: format!(
677            "prop \"{}\" is declared but referenced nowhere inside component \"{}\"; remove it or use it",
678            prop.prop_name, prop.component_name
679        ),
680        uri: relative_uri(&prop.path, root),
681        region: Some((prop.line, prop.col + 1)),
682        source_path: Some(prop.path.clone()),
683        properties: None,
684    }
685}
686
687fn sarif_unused_component_emit_fields(
688    emit: &UnusedComponentEmit,
689    root: &Path,
690    level: &'static str,
691) -> SarifFields {
692    SarifFields {
693        rule_id: "fallow/unused-component-emit",
694        level,
695        message: format!(
696            "emit \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
697            emit.emit_name, emit.component_name
698        ),
699        uri: relative_uri(&emit.path, root),
700        region: Some((emit.line, emit.col + 1)),
701        source_path: Some(emit.path.clone()),
702        properties: None,
703    }
704}
705
706fn sarif_unused_svelte_event_fields(
707    event: &UnusedSvelteEvent,
708    root: &Path,
709    level: &'static str,
710) -> SarifFields {
711    SarifFields {
712        rule_id: "fallow/unused-svelte-event",
713        level,
714        message: format!(
715            "event \"{}\" is dispatched by component \"{}\" but listened to nowhere in the project; remove it or listen for it",
716            event.event_name, event.component_name
717        ),
718        uri: relative_uri(&event.path, root),
719        region: Some((event.line, event.col + 1)),
720        source_path: Some(event.path.clone()),
721        properties: None,
722    }
723}
724
725fn sarif_unused_component_input_fields(
726    input: &UnusedComponentInput,
727    root: &Path,
728    level: &'static str,
729) -> SarifFields {
730    SarifFields {
731        rule_id: "fallow/unused-component-input",
732        level,
733        message: format!(
734            "input \"{}\" is declared but read nowhere inside component \"{}\"; remove it or use it",
735            input.input_name, input.component_name
736        ),
737        uri: relative_uri(&input.path, root),
738        region: Some((input.line, input.col + 1)),
739        source_path: Some(input.path.clone()),
740        properties: None,
741    }
742}
743
744fn sarif_unused_component_output_fields(
745    output: &UnusedComponentOutput,
746    root: &Path,
747    level: &'static str,
748) -> SarifFields {
749    SarifFields {
750        rule_id: "fallow/unused-component-output",
751        level,
752        message: format!(
753            "output \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
754            output.output_name, output.component_name
755        ),
756        uri: relative_uri(&output.path, root),
757        region: Some((output.line, output.col + 1)),
758        source_path: Some(output.path.clone()),
759        properties: None,
760    }
761}
762
763fn sarif_unused_server_action_fields(
764    action: &UnusedServerAction,
765    root: &Path,
766    level: &'static str,
767) -> SarifFields {
768    SarifFields {
769        rule_id: "fallow/unused-server-action",
770        level,
771        message: format!(
772            "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",
773            action.action_name
774        ),
775        uri: relative_uri(&action.path, root),
776        region: Some((action.line, action.col + 1)),
777        source_path: Some(action.path.clone()),
778        properties: None,
779    }
780}
781
782fn sarif_unused_load_data_key_fields(
783    key: &fallow_types::results::UnusedLoadDataKey,
784    root: &Path,
785    level: &'static str,
786) -> SarifFields {
787    SarifFields {
788        rule_id: "fallow/unused-load-data-key",
789        level,
790        message: format!(
791            "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",
792            key.key_name
793        ),
794        uri: relative_uri(&key.path, root),
795        region: Some((key.line, key.col + 1)),
796        source_path: Some(key.path.clone()),
797        properties: None,
798    }
799}
800
801fn sarif_prop_drilling_fields(
802    chain: &PropDrillingChain,
803    root: &Path,
804    level: &'static str,
805) -> SarifFields {
806    // Anchor at the source hop (the prop owner). Path / line come from the first
807    // hop; the message names the depth and the consumer at the chain tail.
808    let source = chain.hops.first();
809    let consumer = chain.hops.last();
810    let (path, line) = source.map_or((std::path::PathBuf::new(), 1), |h| (h.file.clone(), h.line));
811    let consumer_name = consumer.map_or("a distant component", |h| h.component.as_str());
812    SarifFields {
813        rule_id: "fallow/prop-drilling",
814        level,
815        message: format!(
816            "prop \"{}\" is forwarded unchanged through {} component(s) before \"{}\" consumes it; colocate, lift to context, or compose",
817            chain.prop, chain.depth, consumer_name
818        ),
819        uri: relative_uri(&path, root),
820        region: Some((line, 1)),
821        source_path: Some(path),
822        properties: None,
823    }
824}
825
826fn sarif_thin_wrapper_fields(
827    wrapper: &ThinWrapper,
828    root: &Path,
829    level: &'static str,
830) -> SarifFields {
831    SarifFields {
832        rule_id: "fallow/thin-wrapper",
833        level,
834        message: format!(
835            "\"{}\" is a thin wrapper: its whole body forwards props to \"{}\"; inline it at call sites or delete it",
836            wrapper.component, wrapper.child_component
837        ),
838        uri: relative_uri(&wrapper.file, root),
839        region: Some((wrapper.line, 1)),
840        source_path: Some(wrapper.file.clone()),
841        properties: None,
842    }
843}
844
845fn sarif_duplicate_prop_shape_fields(
846    shape: &DuplicatePropShape,
847    root: &Path,
848    level: &'static str,
849) -> SarifFields {
850    SarifFields {
851        rule_id: "fallow/duplicate-prop-shape",
852        level,
853        message: format!(
854            "\"{}\" shares an identical prop shape {{{}}} with {} other component(s); extract a shared Props type or base component",
855            shape.component,
856            shape.shape.join(", "),
857            shape.group_size.saturating_sub(1)
858        ),
859        uri: relative_uri(&shape.file, root),
860        region: Some((shape.line, 1)),
861        source_path: Some(shape.file.clone()),
862        properties: None,
863    }
864}
865
866fn sarif_route_collision_fields(
867    collision: &RouteCollision,
868    root: &Path,
869    level: &'static str,
870) -> SarifFields {
871    SarifFields {
872        rule_id: "fallow/route-collision",
873        level,
874        message: format!(
875            "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",
876            collision.url,
877            collision.conflicting_paths.len()
878        ),
879        uri: relative_uri(&collision.path, root),
880        region: Some((collision.line, collision.col + 1)),
881        source_path: Some(collision.path.clone()),
882        properties: None,
883    }
884}
885
886fn sarif_dynamic_segment_name_conflict_fields(
887    conflict: &DynamicSegmentNameConflict,
888    root: &Path,
889    level: &'static str,
890) -> SarifFields {
891    SarifFields {
892        rule_id: "fallow/dynamic-segment-name-conflict",
893        level,
894        message: format!(
895            "Dynamic segments at '{}' use different slug names ({}); Next.js requires one consistent name per dynamic path",
896            conflict.position,
897            conflict.conflicting_segments.join(", ")
898        ),
899        uri: relative_uri(&conflict.path, root),
900        region: Some((conflict.line, conflict.col + 1)),
901        source_path: Some(conflict.path.clone()),
902        properties: None,
903    }
904}
905
906fn sarif_stale_suppression_fields(
907    suppression: &StaleSuppression,
908    root: &Path,
909    level: &'static str,
910) -> SarifFields {
911    SarifFields {
912        rule_id: if suppression.missing_reason {
913            "fallow/missing-suppression-reason"
914        } else {
915            "fallow/stale-suppression"
916        },
917        level,
918        message: suppression.display_message(),
919        uri: relative_uri(&suppression.path, root),
920        region: Some((suppression.line, suppression.col + 1)),
921        source_path: Some(suppression.path.clone()),
922        properties: None,
923    }
924}
925
926fn stale_suppression_severity(suppression: &StaleSuppression, rules: &RulesConfig) -> Severity {
927    if suppression.missing_reason {
928        rules.require_suppression_reason
929    } else {
930        rules.stale_suppressions
931    }
932}
933
934fn sarif_unused_catalog_entry_fields(
935    entry: &UnusedCatalogEntryFinding,
936    root: &Path,
937    level: &'static str,
938) -> SarifFields {
939    let entry = &entry.entry;
940    let message = if entry.catalog_name == "default" {
941        format!(
942            "Catalog entry '{}' is not referenced by any workspace package",
943            entry.entry_name
944        )
945    } else {
946        format!(
947            "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
948            entry.entry_name, entry.catalog_name
949        )
950    };
951    SarifFields {
952        rule_id: "fallow/unused-catalog-entry",
953        level,
954        message,
955        uri: relative_uri(&entry.path, root),
956        region: Some((entry.line, 1)),
957        source_path: Some(entry.path.clone()),
958        properties: None,
959    }
960}
961
962fn sarif_unused_dependency_override_fields(
963    finding: &UnusedDependencyOverrideFinding,
964    root: &Path,
965    level: &'static str,
966) -> SarifFields {
967    let finding = &finding.entry;
968    let mut message = format!(
969        "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in the lockfile",
970        finding.raw_key, finding.version_range, finding.target_package,
971    );
972    if let Some(hint) = &finding.hint {
973        use std::fmt::Write as _;
974        let _ = write!(message, " ({hint})");
975    }
976    SarifFields {
977        rule_id: "fallow/unused-dependency-override",
978        level,
979        message,
980        uri: relative_uri(&finding.path, root),
981        region: Some((finding.line, 1)),
982        source_path: Some(finding.path.clone()),
983        properties: None,
984    }
985}
986
987fn sarif_misconfigured_dependency_override_fields(
988    finding: &MisconfiguredDependencyOverrideFinding,
989    root: &Path,
990    level: &'static str,
991) -> SarifFields {
992    let finding = &finding.entry;
993    let message = format!(
994        "Override `{}` -> `{}` is malformed: {}",
995        finding.raw_key,
996        finding.raw_value,
997        finding.reason.describe(),
998    );
999    SarifFields {
1000        rule_id: "fallow/misconfigured-dependency-override",
1001        level,
1002        message,
1003        uri: relative_uri(&finding.path, root),
1004        region: Some((finding.line, 1)),
1005        source_path: Some(finding.path.clone()),
1006        properties: None,
1007    }
1008}
1009
1010fn sarif_unresolved_catalog_reference_fields(
1011    finding: &UnresolvedCatalogReferenceFinding,
1012    root: &Path,
1013    level: &'static str,
1014) -> SarifFields {
1015    let finding = &finding.reference;
1016    let catalog_phrase = if finding.catalog_name == "default" {
1017        "the default catalog".to_string()
1018    } else {
1019        format!("catalog '{}'", finding.catalog_name)
1020    };
1021    let mut message = format!(
1022        "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
1023        finding.entry_name,
1024        if finding.catalog_name == "default" {
1025            ""
1026        } else {
1027            finding.catalog_name.as_str()
1028        },
1029        catalog_phrase,
1030    );
1031    if !finding.available_in_catalogs.is_empty() {
1032        use std::fmt::Write as _;
1033        let _ = write!(
1034            message,
1035            " (available in: {})",
1036            finding.available_in_catalogs.join(", ")
1037        );
1038    }
1039    SarifFields {
1040        rule_id: "fallow/unresolved-catalog-reference",
1041        level,
1042        message,
1043        uri: relative_uri(&finding.path, root),
1044        region: Some((finding.line, 1)),
1045        source_path: Some(finding.path.clone()),
1046        properties: None,
1047    }
1048}
1049
1050fn sarif_empty_catalog_group_fields(
1051    group: &EmptyCatalogGroupFinding,
1052    root: &Path,
1053    level: &'static str,
1054) -> SarifFields {
1055    let group = &group.group;
1056    SarifFields {
1057        rule_id: "fallow/empty-catalog-group",
1058        level,
1059        message: format!("Catalog group '{}' has no entries", group.catalog_name),
1060        uri: relative_uri(&group.path, root),
1061        region: Some((group.line, 1)),
1062        source_path: Some(group.path.clone()),
1063        properties: None,
1064    }
1065}
1066
1067/// Unlisted deps fan out to one SARIF result per import site, so they do not
1068/// fit `push_sarif_results`. Keep the nested-loop shape in its own helper.
1069fn push_sarif_unlisted_deps(
1070    sarif_results: &mut Vec<serde_json::Value>,
1071    deps: &[UnlistedDependencyFinding],
1072    root: &Path,
1073    rule: Severity,
1074    snippets: &mut SourceSnippetCache,
1075) {
1076    for entry in deps {
1077        let level = finding_level(entry, rule);
1078        let dep = &entry.dep;
1079        for site in &dep.imported_from {
1080            let uri = relative_uri(&site.path, root);
1081            let source_snippet = snippets.line(&site.path, site.line);
1082            sarif_results.push(sarif_result_with_snippet(
1083                "fallow/unlisted-dependency",
1084                level,
1085                &format!(
1086                    "Package '{}' is imported but not listed in package.json",
1087                    dep.package_name
1088                ),
1089                &uri,
1090                Some((site.line, site.col + 1)),
1091                source_snippet.as_deref(),
1092            ));
1093        }
1094    }
1095}
1096
1097/// Duplicate exports fan out to one SARIF result per location
1098/// (SARIF 2.1.0 section 3.27.12), so they do not fit `push_sarif_results`.
1099fn push_sarif_duplicate_exports(
1100    sarif_results: &mut Vec<serde_json::Value>,
1101    dups: &[DuplicateExportFinding],
1102    root: &Path,
1103    rule: Severity,
1104    snippets: &mut SourceSnippetCache,
1105) {
1106    for dup in dups {
1107        let level = finding_level(dup, rule);
1108        let dup = &dup.export;
1109        for loc in &dup.locations {
1110            let uri = relative_uri(&loc.path, root);
1111            let source_snippet = snippets.line(&loc.path, loc.line);
1112            sarif_results.push(sarif_result_with_snippet(
1113                "fallow/duplicate-export",
1114                level,
1115                &format!("Export '{}' appears in multiple modules", dup.export_name),
1116                &uri,
1117                Some((loc.line, loc.col + 1)),
1118                source_snippet.as_deref(),
1119            ));
1120        }
1121    }
1122}
1123
1124/// Build the SARIF rules list from the current rules configuration.
1125fn build_sarif_rules(
1126    rules: &RulesConfig,
1127    rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1128) -> Vec<serde_json::Value> {
1129    let mut sarif_rules = Vec::new();
1130    for contract in issue_output_contracts() {
1131        for rule_id in contract.sarif_rule_ids {
1132            let severity = sarif_rule_severity(rules, contract.code, &rule_id);
1133            let description = issue_sarif_rule_description(&rule_id).unwrap_or_else(|| {
1134                panic!("dead-code SARIF rule {rule_id} is missing issue metadata")
1135            });
1136            sarif_rules.push(rule_builder(
1137                &rule_id,
1138                description,
1139                configured_sarif_level(severity),
1140            ));
1141        }
1142    }
1143    sarif_rules
1144}
1145
1146fn sarif_rule_severity(rules: &RulesConfig, issue_code: &str, rule_id: &str) -> Severity {
1147    if rule_id == "fallow/missing-suppression-reason" {
1148        return rules.require_suppression_reason;
1149    }
1150    dead_code_rule_severity(rules, issue_code)
1151        .unwrap_or_else(|| panic!("dead-code SARIF rule {rule_id} has no severity mapping"))
1152}
1153
1154fn dead_code_rule_severity(rules: &RulesConfig, issue_code: &str) -> Option<Severity> {
1155    let severity = match issue_code {
1156        "unused-file" => rules.unused_files,
1157        "unused-export" => rules.unused_exports,
1158        "unused-type" => rules.unused_types,
1159        "private-type-leak" => rules.private_type_leaks,
1160        "deprecated-export-in-use" => rules.deprecated_exports_in_use,
1161        "unused-dependency" => rules.unused_dependencies,
1162        "unused-dev-dependency" => rules.unused_dev_dependencies,
1163        "unused-optional-dependency" => rules.unused_optional_dependencies,
1164        "type-only-dependency" => rules.type_only_dependencies,
1165        "test-only-dependency" => rules.test_only_dependencies,
1166        "dev-dependency-in-production" => rules.dev_dependencies_in_production,
1167        "unused-enum-member" => rules.unused_enum_members,
1168        "unused-class-member" => rules.unused_class_members,
1169        "unused-store-member" => rules.unused_store_members,
1170        "unresolved-import" => rules.unresolved_imports,
1171        "unlisted-dependency" => rules.unlisted_dependencies,
1172        "duplicate-export" => rules.duplicate_exports,
1173        "circular-dependency" => rules.circular_dependencies,
1174        "re-export-cycle" => rules.re_export_cycle,
1175        "package-cycle" => rules.package_cycle,
1176        "boundary-violation" | "boundary-coverage" | "boundary-call-violation" => {
1177            rules.boundary_violation
1178        }
1179        "policy-violation" => rules.policy_violation,
1180        "invalid-client-export" => rules.invalid_client_export,
1181        "mixed-client-server-barrel" => rules.mixed_client_server_barrel,
1182        "misplaced-directive" => rules.misplaced_directive,
1183        "unprovided-inject" => rules.unprovided_injects,
1184        "unrendered-component" => rules.unrendered_components,
1185        "unused-component-prop" => rules.unused_component_props,
1186        "unused-component-emit" => rules.unused_component_emits,
1187        "unused-component-input" => rules.unused_component_inputs,
1188        "unused-component-output" => rules.unused_component_outputs,
1189        "unused-svelte-event" => rules.unused_svelte_events,
1190        "unused-server-action" => rules.unused_server_actions,
1191        "unused-load-data-key" => rules.unused_load_data_keys,
1192        "prop-drilling" => non_gating_severity(rules.prop_drilling),
1193        "thin-wrapper" => non_gating_severity(rules.thin_wrapper),
1194        "duplicate-prop-shape" => non_gating_severity(rules.duplicate_prop_shape),
1195        "route-collision" => rules.route_collision,
1196        "dynamic-segment-name-conflict" => rules.dynamic_segment_name_conflict,
1197        "stale-suppression" => rules.stale_suppressions,
1198        "unused-catalog-entry" => rules.unused_catalog_entries,
1199        "empty-catalog-group" => rules.empty_catalog_groups,
1200        "unresolved-catalog-reference" => rules.unresolved_catalog_references,
1201        "unused-dependency-override" => rules.unused_dependency_overrides,
1202        "misconfigured-dependency-override" => rules.misconfigured_dependency_overrides,
1203        _ => return None,
1204    };
1205    Some(severity)
1206}
1207
1208/// Builds the complete SARIF `run` value for a dead-code analysis.
1209///
1210/// Emits one SARIF result per finding across every dead-code issue kind,
1211/// mapping each configured rule severity to a SARIF level. `rule_builder`
1212/// constructs the tool-driver rule object for a `(rule id, name, help URI)`
1213/// triple so the caller controls rule metadata.
1214#[must_use]
1215pub fn build_dead_code_sarif(
1216    results: &AnalysisResults,
1217    root: &Path,
1218    rules: &RulesConfig,
1219    rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1220) -> serde_json::Value {
1221    let mut sarif_results = Vec::new();
1222    let mut snippets = SourceSnippetCache::with_root(root);
1223    let ctx = SarifCtx {
1224        results,
1225        root,
1226        rules,
1227    };
1228
1229    push_primary_dead_code_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1230    push_dependency_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1231    push_member_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1232    push_sarif_results(
1233        &mut sarif_results,
1234        &results.unresolved_imports,
1235        &mut snippets,
1236        |i| {
1237            sarif_unresolved_import_fields(
1238                &i.import,
1239                root,
1240                finding_level(i, rules.unresolved_imports),
1241            )
1242        },
1243    );
1244    push_misc_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1245    push_graph_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1246    push_catalog_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1247
1248    let sarif_rules = build_sarif_rules(rules, rule_builder);
1249    sarif_document(&sarif_results, &sarif_rules)
1250}
1251
1252fn push_primary_dead_code_sarif_results(
1253    sarif_results: &mut Vec<serde_json::Value>,
1254    ctx: &SarifCtx<'_>,
1255    snippets: &mut SourceSnippetCache,
1256) {
1257    let SarifCtx {
1258        results,
1259        root,
1260        rules,
1261    } = *ctx;
1262
1263    push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1264        with_caveats(
1265            sarif_unused_file_fields(
1266                &finding.file,
1267                root,
1268                finding_level(finding, rules.unused_files),
1269            ),
1270            &finding.reachability_caveats,
1271        )
1272    });
1273    push_sarif_results(
1274        sarif_results,
1275        &results.unused_exports,
1276        snippets,
1277        |finding| {
1278            with_caveats(
1279                sarif_export_fields(
1280                    &finding.export,
1281                    root,
1282                    "fallow/unused-export",
1283                    finding_level(finding, rules.unused_exports),
1284                    "Export",
1285                    "Re-export",
1286                ),
1287                &finding.reachability_caveats,
1288            )
1289        },
1290    );
1291    push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1292        with_caveats(
1293            sarif_export_fields(
1294                &finding.export,
1295                root,
1296                "fallow/unused-type",
1297                finding_level(finding, rules.unused_types),
1298                "Type export",
1299                "Type re-export",
1300            ),
1301            &finding.reachability_caveats,
1302        )
1303    });
1304    push_sarif_results(
1305        sarif_results,
1306        &results.private_type_leaks,
1307        snippets,
1308        |finding| {
1309            sarif_private_type_leak_fields(
1310                &finding.leak,
1311                root,
1312                finding_level(finding, rules.private_type_leaks),
1313            )
1314        },
1315    );
1316    push_sarif_results(
1317        sarif_results,
1318        &results.deprecated_exports_in_use,
1319        snippets,
1320        |finding| {
1321            sarif_deprecated_export_fields(
1322                &finding.export,
1323                root,
1324                finding_level(finding, rules.deprecated_exports_in_use),
1325            )
1326        },
1327    );
1328}
1329
1330fn sarif_document(
1331    sarif_results: &[serde_json::Value],
1332    sarif_rules: &[serde_json::Value],
1333) -> serde_json::Value {
1334    build_sarif_document(SarifDocumentInput {
1335        results: sarif_results,
1336        rules: sarif_rules,
1337        tool_version: env!("CARGO_PKG_VERSION"),
1338    })
1339}
1340
1341fn push_dependency_sarif_results(
1342    sarif_results: &mut Vec<serde_json::Value>,
1343    ctx: &SarifCtx<'_>,
1344    snippets: &mut SourceSnippetCache,
1345) {
1346    push_unused_dependency_sarif_results(sarif_results, ctx, snippets);
1347    push_classified_dependency_sarif_results(sarif_results, ctx, snippets);
1348}
1349
1350/// Push SARIF results for unused runtime, dev, and optional dependencies.
1351fn push_unused_dependency_sarif_results(
1352    sarif_results: &mut Vec<serde_json::Value>,
1353    ctx: &SarifCtx<'_>,
1354    snippets: &mut SourceSnippetCache,
1355) {
1356    let SarifCtx {
1357        results,
1358        root,
1359        rules,
1360    } = *ctx;
1361
1362    let mut columns =
1363        manifest_key_columns(&results.unused_dependencies, snippets, |f| dep_key(&f.dep));
1364    push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1365        with_caveats(
1366            sarif_dep_fields(
1367                &d.dep,
1368                root,
1369                "fallow/unused-dependency",
1370                finding_level(d, rules.unused_dependencies),
1371                "dependencies",
1372                columns.next().unwrap_or(1),
1373            ),
1374            &d.reachability_caveats,
1375        )
1376    });
1377    let mut columns = manifest_key_columns(&results.unused_dev_dependencies, snippets, |f| {
1378        dep_key(&f.dep)
1379    });
1380    push_sarif_results(
1381        sarif_results,
1382        &results.unused_dev_dependencies,
1383        snippets,
1384        |d| {
1385            with_caveats(
1386                sarif_dep_fields(
1387                    &d.dep,
1388                    root,
1389                    "fallow/unused-dev-dependency",
1390                    finding_level(d, rules.unused_dev_dependencies),
1391                    "devDependencies",
1392                    columns.next().unwrap_or(1),
1393                ),
1394                &d.reachability_caveats,
1395            )
1396        },
1397    );
1398    let mut columns = manifest_key_columns(&results.unused_optional_dependencies, snippets, |f| {
1399        dep_key(&f.dep)
1400    });
1401    push_sarif_results(
1402        sarif_results,
1403        &results.unused_optional_dependencies,
1404        snippets,
1405        |d| {
1406            with_caveats(
1407                sarif_dep_fields(
1408                    &d.dep,
1409                    root,
1410                    "fallow/unused-optional-dependency",
1411                    finding_level(d, rules.unused_optional_dependencies),
1412                    "optionalDependencies",
1413                    columns.next().unwrap_or(1),
1414                ),
1415                &d.reachability_caveats,
1416            )
1417        },
1418    );
1419}
1420
1421/// Push SARIF results for type-only and test-only dependency misclassifications.
1422fn push_classified_dependency_sarif_results(
1423    sarif_results: &mut Vec<serde_json::Value>,
1424    ctx: &SarifCtx<'_>,
1425    snippets: &mut SourceSnippetCache,
1426) {
1427    let SarifCtx {
1428        results,
1429        root,
1430        rules,
1431    } = *ctx;
1432
1433    let mut columns = manifest_key_columns(&results.type_only_dependencies, snippets, |f| {
1434        (
1435            f.dep.path.as_path(),
1436            f.dep.line,
1437            f.dep.package_name.as_str(),
1438        )
1439    });
1440    push_sarif_results(
1441        sarif_results,
1442        &results.type_only_dependencies,
1443        snippets,
1444        |d| {
1445            sarif_type_only_dep_fields(
1446                &d.dep,
1447                root,
1448                finding_level(d, rules.type_only_dependencies),
1449                columns.next().unwrap_or(1),
1450            )
1451        },
1452    );
1453    let mut columns = manifest_key_columns(&results.test_only_dependencies, snippets, |f| {
1454        (
1455            f.dep.path.as_path(),
1456            f.dep.line,
1457            f.dep.package_name.as_str(),
1458        )
1459    });
1460    push_sarif_results(
1461        sarif_results,
1462        &results.test_only_dependencies,
1463        snippets,
1464        |d| {
1465            sarif_test_only_dep_fields(
1466                &d.dep,
1467                root,
1468                finding_level(d, rules.test_only_dependencies),
1469                columns.next().unwrap_or(1),
1470            )
1471        },
1472    );
1473    let mut columns =
1474        manifest_key_columns(&results.dev_dependencies_in_production, snippets, |f| {
1475            (
1476                f.dep.path.as_path(),
1477                f.dep.line,
1478                f.dep.package_name.as_str(),
1479            )
1480        });
1481    push_sarif_results(
1482        sarif_results,
1483        &results.dev_dependencies_in_production,
1484        snippets,
1485        |d| {
1486            sarif_dev_dep_in_prod_fields(
1487                &d.dep,
1488                root,
1489                finding_level(d, rules.dev_dependencies_in_production),
1490                columns.next().unwrap_or(1),
1491            )
1492        },
1493    );
1494}
1495
1496fn push_member_sarif_results(
1497    sarif_results: &mut Vec<serde_json::Value>,
1498    ctx: &SarifCtx<'_>,
1499    snippets: &mut SourceSnippetCache,
1500) {
1501    let SarifCtx {
1502        results,
1503        root,
1504        rules,
1505    } = *ctx;
1506
1507    push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1508        with_caveats(
1509            sarif_member_fields(
1510                &m.member,
1511                root,
1512                "fallow/unused-enum-member",
1513                finding_level(m, rules.unused_enum_members),
1514                "Enum",
1515            ),
1516            &m.reachability_caveats,
1517        )
1518    });
1519    push_sarif_results(
1520        sarif_results,
1521        &results.unused_class_members,
1522        snippets,
1523        |m| {
1524            with_caveats(
1525                sarif_member_fields(
1526                    &m.member,
1527                    root,
1528                    "fallow/unused-class-member",
1529                    finding_level(m, rules.unused_class_members),
1530                    "Class",
1531                ),
1532                &m.reachability_caveats,
1533            )
1534        },
1535    );
1536    push_sarif_results(
1537        sarif_results,
1538        &results.unused_store_members,
1539        snippets,
1540        |m| {
1541            with_caveats(
1542                sarif_member_fields(
1543                    &m.member,
1544                    root,
1545                    "fallow/unused-store-member",
1546                    finding_level(m, rules.unused_store_members),
1547                    "Store",
1548                ),
1549                &m.reachability_caveats,
1550            )
1551        },
1552    );
1553}
1554
1555fn push_misc_sarif_results(
1556    sarif_results: &mut Vec<serde_json::Value>,
1557    ctx: &SarifCtx<'_>,
1558    snippets: &mut SourceSnippetCache,
1559) {
1560    let SarifCtx {
1561        results,
1562        root,
1563        rules,
1564    } = *ctx;
1565
1566    if !results.unlisted_dependencies.is_empty() {
1567        push_sarif_unlisted_deps(
1568            sarif_results,
1569            &results.unlisted_dependencies,
1570            root,
1571            rules.unlisted_dependencies,
1572            snippets,
1573        );
1574    }
1575    if !results.duplicate_exports.is_empty() {
1576        push_sarif_duplicate_exports(
1577            sarif_results,
1578            &results.duplicate_exports,
1579            root,
1580            rules.duplicate_exports,
1581            snippets,
1582        );
1583    }
1584}
1585
1586/// Push the component-contract SARIF results (`unused-component-prop` and
1587/// `unused-component-emit`). Extracted from `push_graph_sarif_results` to keep
1588/// that function under the unit-size lint.
1589fn push_component_contract_sarif_results(
1590    sarif_results: &mut Vec<serde_json::Value>,
1591    ctx: &SarifCtx<'_>,
1592    snippets: &mut SourceSnippetCache,
1593) {
1594    push_component_member_sarif_results(sarif_results, ctx, snippets);
1595    push_component_framework_sarif_results(sarif_results, ctx, snippets);
1596    push_component_shape_sarif_results(sarif_results, ctx, snippets);
1597}
1598
1599/// Push SARIF results for unused component props, emits, inputs, and outputs.
1600fn push_component_member_sarif_results(
1601    sarif_results: &mut Vec<serde_json::Value>,
1602    ctx: &SarifCtx<'_>,
1603    snippets: &mut SourceSnippetCache,
1604) {
1605    let SarifCtx {
1606        results,
1607        root,
1608        rules,
1609    } = *ctx;
1610
1611    push_sarif_results(
1612        sarif_results,
1613        &results.unused_component_props,
1614        snippets,
1615        |p| {
1616            sarif_unused_component_prop_fields(
1617                &p.prop,
1618                root,
1619                finding_level(p, rules.unused_component_props),
1620            )
1621        },
1622    );
1623    push_sarif_results(
1624        sarif_results,
1625        &results.unused_component_emits,
1626        snippets,
1627        |e| {
1628            sarif_unused_component_emit_fields(
1629                &e.emit,
1630                root,
1631                finding_level(e, rules.unused_component_emits),
1632            )
1633        },
1634    );
1635    push_sarif_results(
1636        sarif_results,
1637        &results.unused_component_inputs,
1638        snippets,
1639        |i| {
1640            sarif_unused_component_input_fields(
1641                &i.input,
1642                root,
1643                finding_level(i, rules.unused_component_inputs),
1644            )
1645        },
1646    );
1647    push_sarif_results(
1648        sarif_results,
1649        &results.unused_component_outputs,
1650        snippets,
1651        |o| {
1652            sarif_unused_component_output_fields(
1653                &o.output,
1654                root,
1655                finding_level(o, rules.unused_component_outputs),
1656            )
1657        },
1658    );
1659}
1660
1661/// Push SARIF results for Svelte events, server actions, and load-data keys.
1662fn push_component_framework_sarif_results(
1663    sarif_results: &mut Vec<serde_json::Value>,
1664    ctx: &SarifCtx<'_>,
1665    snippets: &mut SourceSnippetCache,
1666) {
1667    let SarifCtx {
1668        results,
1669        root,
1670        rules,
1671    } = *ctx;
1672
1673    push_sarif_results(
1674        sarif_results,
1675        &results.unused_svelte_events,
1676        snippets,
1677        |e| {
1678            sarif_unused_svelte_event_fields(
1679                &e.event,
1680                root,
1681                finding_level(e, rules.unused_svelte_events),
1682            )
1683        },
1684    );
1685    push_sarif_results(
1686        sarif_results,
1687        &results.unused_server_actions,
1688        snippets,
1689        |a| {
1690            sarif_unused_server_action_fields(
1691                &a.action,
1692                root,
1693                finding_level(a, rules.unused_server_actions),
1694            )
1695        },
1696    );
1697    push_sarif_results(
1698        sarif_results,
1699        &results.unused_load_data_keys,
1700        snippets,
1701        |k| {
1702            sarif_unused_load_data_key_fields(
1703                &k.key,
1704                root,
1705                finding_level(k, rules.unused_load_data_keys),
1706            )
1707        },
1708    );
1709}
1710
1711/// Push SARIF results for prop drilling, thin wrappers, and duplicate prop shapes.
1712fn push_component_shape_sarif_results(
1713    sarif_results: &mut Vec<serde_json::Value>,
1714    ctx: &SarifCtx<'_>,
1715    snippets: &mut SourceSnippetCache,
1716) {
1717    let SarifCtx {
1718        results,
1719        root,
1720        rules,
1721    } = *ctx;
1722
1723    push_sarif_results(
1724        sarif_results,
1725        &results.prop_drilling_chains,
1726        snippets,
1727        |c| sarif_prop_drilling_fields(&c.chain, root, non_gating_level(c, rules.prop_drilling)),
1728    );
1729    push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1730        sarif_thin_wrapper_fields(&w.wrapper, root, non_gating_level(w, rules.thin_wrapper))
1731    });
1732    push_sarif_results(
1733        sarif_results,
1734        &results.duplicate_prop_shapes,
1735        snippets,
1736        |d| {
1737            sarif_duplicate_prop_shape_fields(
1738                &d.shape,
1739                root,
1740                non_gating_level(d, rules.duplicate_prop_shape),
1741            )
1742        },
1743    );
1744}
1745
1746fn push_graph_sarif_results(
1747    sarif_results: &mut Vec<serde_json::Value>,
1748    ctx: &SarifCtx<'_>,
1749    snippets: &mut SourceSnippetCache,
1750) {
1751    push_structure_sarif_results(sarif_results, ctx, snippets);
1752    push_framework_sarif_results(sarif_results, ctx, snippets);
1753    push_route_sarif_results(sarif_results, ctx, snippets);
1754    push_suppression_sarif_results(sarif_results, ctx, snippets);
1755}
1756
1757fn push_structure_sarif_results(
1758    sarif_results: &mut Vec<serde_json::Value>,
1759    ctx: &SarifCtx<'_>,
1760    snippets: &mut SourceSnippetCache,
1761) {
1762    push_cycle_sarif_results(sarif_results, ctx, snippets);
1763    push_boundary_sarif_results(sarif_results, ctx, snippets);
1764}
1765
1766/// Push SARIF results for circular dependencies, re-export cycles and
1767/// package cycles.
1768fn push_cycle_sarif_results(
1769    sarif_results: &mut Vec<serde_json::Value>,
1770    ctx: &SarifCtx<'_>,
1771    snippets: &mut SourceSnippetCache,
1772) {
1773    let SarifCtx {
1774        results,
1775        root,
1776        rules,
1777    } = *ctx;
1778
1779    push_sarif_results(
1780        sarif_results,
1781        &results.circular_dependencies,
1782        snippets,
1783        |c| {
1784            sarif_circular_dep_fields(
1785                &c.cycle,
1786                root,
1787                finding_level(c, rules.circular_dependencies),
1788            )
1789        },
1790    );
1791    push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1792        sarif_re_export_cycle_fields(&c.cycle, root, finding_level(c, rules.re_export_cycle))
1793    });
1794    push_sarif_results(sarif_results, &results.package_cycles, snippets, |c| {
1795        sarif_package_cycle_fields(&c.cycle, root, finding_level(c, rules.package_cycle))
1796    });
1797}
1798
1799/// Push SARIF results for boundary violations, coverage, calls, and policy violations.
1800fn push_boundary_sarif_results(
1801    sarif_results: &mut Vec<serde_json::Value>,
1802    ctx: &SarifCtx<'_>,
1803    snippets: &mut SourceSnippetCache,
1804) {
1805    let SarifCtx {
1806        results,
1807        root,
1808        rules,
1809    } = *ctx;
1810
1811    push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1812        sarif_boundary_violation_fields(
1813            &v.violation,
1814            root,
1815            finding_level(v, rules.boundary_violation),
1816        )
1817    });
1818    push_sarif_results(
1819        sarif_results,
1820        &results.boundary_coverage_violations,
1821        snippets,
1822        |v| {
1823            sarif_boundary_coverage_fields(
1824                &v.violation,
1825                root,
1826                finding_level(v, rules.boundary_violation),
1827            )
1828        },
1829    );
1830    push_sarif_results(
1831        sarif_results,
1832        &results.boundary_call_violations,
1833        snippets,
1834        |v| {
1835            sarif_boundary_call_fields(
1836                &v.violation,
1837                root,
1838                finding_level(v, rules.boundary_violation),
1839            )
1840        },
1841    );
1842    push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1843        sarif_policy_violation_fields(&v.violation, root)
1844    });
1845}
1846
1847fn push_framework_sarif_results(
1848    sarif_results: &mut Vec<serde_json::Value>,
1849    ctx: &SarifCtx<'_>,
1850    snippets: &mut SourceSnippetCache,
1851) {
1852    push_framework_boundary_sarif_results(sarif_results, ctx, snippets);
1853    push_component_contract_sarif_results(sarif_results, ctx, snippets);
1854}
1855
1856/// Push SARIF results for client exports, barrels, directives, injects, and unrendered components.
1857fn push_framework_boundary_sarif_results(
1858    sarif_results: &mut Vec<serde_json::Value>,
1859    ctx: &SarifCtx<'_>,
1860    snippets: &mut SourceSnippetCache,
1861) {
1862    let SarifCtx {
1863        results,
1864        root,
1865        rules,
1866    } = *ctx;
1867
1868    push_sarif_results(
1869        sarif_results,
1870        &results.invalid_client_exports,
1871        snippets,
1872        |e| {
1873            sarif_invalid_client_export_fields(
1874                &e.export,
1875                root,
1876                finding_level(e, rules.invalid_client_export),
1877            )
1878        },
1879    );
1880    push_sarif_results(
1881        sarif_results,
1882        &results.mixed_client_server_barrels,
1883        snippets,
1884        |b| {
1885            sarif_mixed_client_server_barrel_fields(
1886                &b.barrel,
1887                root,
1888                finding_level(b, rules.mixed_client_server_barrel),
1889            )
1890        },
1891    );
1892    push_sarif_results(
1893        sarif_results,
1894        &results.misplaced_directives,
1895        snippets,
1896        |d| {
1897            sarif_misplaced_directive_fields(
1898                &d.directive_site,
1899                root,
1900                finding_level(d, rules.misplaced_directive),
1901            )
1902        },
1903    );
1904    push_framework_render_sarif_results(sarif_results, ctx, snippets);
1905}
1906
1907fn push_framework_render_sarif_results(
1908    sarif_results: &mut Vec<serde_json::Value>,
1909    ctx: &SarifCtx<'_>,
1910    snippets: &mut SourceSnippetCache,
1911) {
1912    let SarifCtx {
1913        results,
1914        root,
1915        rules,
1916    } = *ctx;
1917
1918    push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
1919        sarif_unprovided_inject_fields(&i.inject, root, finding_level(i, rules.unprovided_injects))
1920    });
1921    push_sarif_results(
1922        sarif_results,
1923        &results.unrendered_components,
1924        snippets,
1925        |c| {
1926            sarif_unrendered_component_fields(
1927                &c.component,
1928                root,
1929                finding_level(c, rules.unrendered_components),
1930            )
1931        },
1932    );
1933}
1934
1935fn push_route_sarif_results(
1936    sarif_results: &mut Vec<serde_json::Value>,
1937    ctx: &SarifCtx<'_>,
1938    snippets: &mut SourceSnippetCache,
1939) {
1940    let SarifCtx {
1941        results,
1942        root,
1943        rules,
1944    } = *ctx;
1945
1946    push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
1947        sarif_route_collision_fields(&c.collision, root, finding_level(c, rules.route_collision))
1948    });
1949    push_sarif_results(
1950        sarif_results,
1951        &results.dynamic_segment_name_conflicts,
1952        snippets,
1953        |c| {
1954            sarif_dynamic_segment_name_conflict_fields(
1955                &c.conflict,
1956                root,
1957                finding_level(c, rules.dynamic_segment_name_conflict),
1958            )
1959        },
1960    );
1961}
1962
1963fn push_suppression_sarif_results(
1964    sarif_results: &mut Vec<serde_json::Value>,
1965    ctx: &SarifCtx<'_>,
1966    snippets: &mut SourceSnippetCache,
1967) {
1968    let SarifCtx {
1969        results,
1970        root,
1971        rules,
1972    } = *ctx;
1973
1974    push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1975        sarif_stale_suppression_fields(
1976            s,
1977            root,
1978            finding_level(s, stale_suppression_severity(s, rules)),
1979        )
1980    });
1981}
1982
1983fn push_catalog_sarif_results(
1984    sarif_results: &mut Vec<serde_json::Value>,
1985    ctx: &SarifCtx<'_>,
1986    snippets: &mut SourceSnippetCache,
1987) {
1988    push_catalog_entry_sarif_results(sarif_results, ctx, snippets);
1989    push_dependency_override_sarif_results(sarif_results, ctx, snippets);
1990}
1991
1992/// Push SARIF results for unused catalog entries, empty groups, and unresolved references.
1993fn push_catalog_entry_sarif_results(
1994    sarif_results: &mut Vec<serde_json::Value>,
1995    ctx: &SarifCtx<'_>,
1996    snippets: &mut SourceSnippetCache,
1997) {
1998    let SarifCtx {
1999        results,
2000        root,
2001        rules,
2002    } = *ctx;
2003
2004    push_sarif_results(
2005        sarif_results,
2006        &results.unused_catalog_entries,
2007        snippets,
2008        |e| {
2009            sarif_unused_catalog_entry_fields(
2010                e,
2011                root,
2012                finding_level(e, rules.unused_catalog_entries),
2013            )
2014        },
2015    );
2016    push_sarif_results(
2017        sarif_results,
2018        &results.empty_catalog_groups,
2019        snippets,
2020        |g| sarif_empty_catalog_group_fields(g, root, finding_level(g, rules.empty_catalog_groups)),
2021    );
2022    push_sarif_results(
2023        sarif_results,
2024        &results.unresolved_catalog_references,
2025        snippets,
2026        |f| {
2027            sarif_unresolved_catalog_reference_fields(
2028                f,
2029                root,
2030                finding_level(f, rules.unresolved_catalog_references),
2031            )
2032        },
2033    );
2034}
2035
2036/// Push SARIF results for unused and misconfigured dependency overrides.
2037fn push_dependency_override_sarif_results(
2038    sarif_results: &mut Vec<serde_json::Value>,
2039    ctx: &SarifCtx<'_>,
2040    snippets: &mut SourceSnippetCache,
2041) {
2042    let SarifCtx {
2043        results,
2044        root,
2045        rules,
2046    } = *ctx;
2047
2048    push_sarif_results(
2049        sarif_results,
2050        &results.unused_dependency_overrides,
2051        snippets,
2052        |f| {
2053            sarif_unused_dependency_override_fields(
2054                f,
2055                root,
2056                finding_level(f, rules.unused_dependency_overrides),
2057            )
2058        },
2059    );
2060    push_sarif_results(
2061        sarif_results,
2062        &results.misconfigured_dependency_overrides,
2063        snippets,
2064        |f| {
2065            sarif_misconfigured_dependency_override_fields(
2066                f,
2067                root,
2068                finding_level(f, rules.misconfigured_dependency_overrides),
2069            )
2070        },
2071    );
2072}
2073
2074#[cfg(test)]
2075mod tests {
2076    use std::collections::BTreeSet;
2077    use std::path::Path;
2078
2079    use fallow_config::RulesConfig;
2080    use fallow_types::results::AnalysisResults;
2081
2082    use super::*;
2083
2084    fn test_rule_builder(id: &str, description: &str, level: &str) -> serde_json::Value {
2085        serde_json::json!({
2086            "id": id,
2087            "shortDescription": { "text": description },
2088            "defaultConfiguration": { "level": level }
2089        })
2090    }
2091
2092    /// A SARIF consumer reads the message text, not the JSON envelope, so the
2093    /// degraded-parse caveat has to travel in the message. A clean finding
2094    /// keeps the previous text byte-for-byte.
2095    #[test]
2096    fn sarif_messages_name_the_degraded_parse_caveat() {
2097        let mut results = AnalysisResults::default();
2098        results
2099            .unused_files
2100            .push(UnusedFileFinding::with_actions(UnusedFile {
2101                path: Path::new("/p/src/clean.ts").to_path_buf(),
2102            }));
2103        let mut flagged = UnusedFileFinding::with_actions(UnusedFile {
2104            path: Path::new("/p/src/orphan.ts").to_path_buf(),
2105        });
2106        flagged.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2107        results.unused_files.push(flagged);
2108
2109        // Every member array carries the same caveat off the same
2110        // reachability-free access walk, so all three have to reach the
2111        // message. Store members were rendered bare while the array itself was
2112        // stamped.
2113        let member = |parent: &str, name: &str, kind| UnusedMember {
2114            path: Path::new("/p/src/lib.ts").to_path_buf(),
2115            parent_name: parent.to_owned(),
2116            member_name: name.to_owned(),
2117            kind,
2118            line: 7,
2119            col: 2,
2120        };
2121        let mut enum_member = UnusedEnumMemberFinding::with_actions(member(
2122            "Mode",
2123            "Legacy",
2124            fallow_types::extract::MemberKind::EnumMember,
2125        ));
2126        enum_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2127        results.unused_enum_members.push(enum_member);
2128        let mut class_member = UnusedClassMemberFinding::with_actions(member(
2129            "Widget",
2130            "render",
2131            fallow_types::extract::MemberKind::ClassMethod,
2132        ));
2133        class_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2134        results.unused_class_members.push(class_member);
2135        let mut store_member = UnusedStoreMemberFinding::with_actions(member(
2136            "useCart",
2137            "subtotal",
2138            fallow_types::extract::MemberKind::StoreMember,
2139        ));
2140        store_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2141        results.unused_store_members.push(store_member);
2142
2143        let sarif = build_dead_code_sarif(
2144            &results,
2145            Path::new("/p"),
2146            &RulesConfig::default(),
2147            &test_rule_builder,
2148        );
2149        let messages: Vec<String> = sarif
2150            .pointer("/runs/0/results")
2151            .and_then(serde_json::Value::as_array)
2152            .expect("SARIF results")
2153            .iter()
2154            .filter_map(|entry| {
2155                entry
2156                    .pointer("/message/text")
2157                    .and_then(serde_json::Value::as_str)
2158                    .map(str::to_owned)
2159            })
2160            .collect();
2161
2162        assert!(
2163            messages.contains(&"File is not reachable from any entry point".to_owned()),
2164            "a clean finding keeps its exact message: {messages:?}"
2165        );
2166        assert!(
2167            messages.contains(
2168                &"File is not reachable from any entry point (caveat: incomplete import graph)"
2169                    .to_owned()
2170            ),
2171            "a caveated finding names it in the message: {messages:?}"
2172        );
2173        for expected in [
2174            "Enum member 'Mode.Legacy' is never referenced (caveat: incomplete import graph)",
2175            "Class member 'Widget.render' is never referenced (caveat: incomplete import graph)",
2176            "Store member 'useCart.subtotal' is never referenced (caveat: incomplete import graph)",
2177        ] {
2178            assert!(
2179                messages.contains(&expected.to_owned()),
2180                "every member kind names the caveat: {messages:?}"
2181            );
2182        }
2183    }
2184
2185    /// The reported defect: `npm init -y` writes a `package.json` whose
2186    /// dependency block can sit on one line, and `find_dep_line_in_json` also
2187    /// falls back to line 1 for a key it cannot locate. Every unused dependency
2188    /// then reported the same rule id, the same URI, and the same source
2189    /// snippet, which is the whole of a SARIF fingerprint, so GitHub code
2190    /// scanning showed one alert for all of them. CodeClimate never had the
2191    /// defect: it keys on the package name.
2192    #[test]
2193    fn two_dependencies_on_one_manifest_line_are_two_alerts() {
2194        let dir = tempfile::tempdir().expect("temporary project");
2195        let root = dir.path();
2196        std::fs::write(
2197            root.join("package.json"),
2198            r#"{"name":"compact","dependencies":{"lodash":"^4.17.21","chalk":"^5.3.0"}}"#,
2199        )
2200        .expect("write manifest");
2201
2202        let mut results = AnalysisResults::default();
2203        for name in ["lodash", "chalk"] {
2204            results
2205                .unused_dependencies
2206                .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2207                    package_name: name.to_owned(),
2208                    location: fallow_types::results::DependencyLocation::Dependencies,
2209                    path: root.join("package.json"),
2210                    line: 1,
2211                    used_in_workspaces: Vec::new(),
2212                }));
2213        }
2214
2215        let sarif =
2216            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2217        let entries = sarif
2218            .pointer("/runs/0/results")
2219            .and_then(serde_json::Value::as_array)
2220            .expect("SARIF results");
2221
2222        let fingerprints = entries
2223            .iter()
2224            .map(|entry| {
2225                entry
2226                    .pointer("/partialFingerprints/tools.fallow.fingerprint~1v1")
2227                    .and_then(serde_json::Value::as_str)
2228                    .expect("fingerprint")
2229            })
2230            .collect::<BTreeSet<_>>();
2231        assert_eq!(
2232            fingerprints.len(),
2233            entries.len(),
2234            "two dependencies declared on one line are two alerts: {entries:#?}"
2235        );
2236
2237        let columns = entries
2238            .iter()
2239            .map(|entry| {
2240                entry
2241                    .pointer("/locations/0/physicalLocation/region/startColumn")
2242                    .and_then(serde_json::Value::as_u64)
2243                    .expect("start column")
2244            })
2245            .collect::<BTreeSet<_>>();
2246        assert_eq!(
2247            columns.len(),
2248            entries.len(),
2249            "each dependency points at its own key in the manifest line: {entries:#?}"
2250        );
2251    }
2252
2253    /// Why the column and not only the run-level uniqueness pass: that pass
2254    /// separates repeats by occurrence index, so a dependency's identity would
2255    /// depend on its siblings and fixing the first one would renumber, and
2256    /// close, the alert on the second. The column is the dependency's own
2257    /// position, so an unrelated dependency added above it moves neither.
2258    #[test]
2259    fn a_dependency_added_above_leaves_the_others_alert_alone() {
2260        let dir = tempfile::tempdir().expect("temporary project");
2261        let root = dir.path();
2262        let manifest = root.join("package.json");
2263
2264        let chalk_fingerprint = |manifest_text: &str, chalk_line: u32| {
2265            std::fs::write(&manifest, manifest_text).expect("write manifest");
2266            let mut results = AnalysisResults::default();
2267            results
2268                .unused_dependencies
2269                .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2270                    package_name: "chalk".to_owned(),
2271                    location: fallow_types::results::DependencyLocation::Dependencies,
2272                    path: manifest.clone(),
2273                    line: chalk_line,
2274                    used_in_workspaces: Vec::new(),
2275                }));
2276            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder)
2277                .pointer("/runs/0/results/0/partialFingerprints/tools.fallow.fingerprint~1v1")
2278                .and_then(serde_json::Value::as_str)
2279                .expect("chalk fingerprint")
2280                .to_owned()
2281        };
2282
2283        let before = "{\n  \"dependencies\": {\n    \"chalk\": \"^5.3.0\"\n  }\n}\n";
2284        let after = "{\n  \"dependencies\": {\n    \"lodash\": \"^4.17.21\",\n    \"chalk\": \"^5.3.0\"\n  }\n}\n";
2285
2286        assert_eq!(
2287            chalk_fingerprint(before, 3),
2288            chalk_fingerprint(after, 4),
2289            "a dependency declared above it must not move chalk's alert"
2290        );
2291    }
2292
2293    /// `partialFingerprints` is the alert identity GitHub code scanning uses to
2294    /// carry a finding across runs. It is built from rule id plus location (or a
2295    /// normalized snippet), never from the message, so a finding that gains the
2296    /// caveat must keep its fingerprint. If the caveat ever reached the
2297    /// fingerprint inputs, every open alert on a degraded repository would close
2298    /// and reopen as new on the next scan.
2299    #[test]
2300    fn the_caveat_does_not_move_the_sarif_fingerprint() {
2301        let build = |caveated: bool| {
2302            let mut results = AnalysisResults::default();
2303            let mut finding = UnusedFileFinding::with_actions(UnusedFile {
2304                path: Path::new("/p/src/orphan.ts").to_path_buf(),
2305            });
2306            if caveated {
2307                finding.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2308            }
2309            results.unused_files.push(finding);
2310            build_dead_code_sarif(
2311                &results,
2312                Path::new("/p"),
2313                &RulesConfig::default(),
2314                &test_rule_builder,
2315            )
2316        };
2317
2318        let read = |sarif: &serde_json::Value, pointer: &str| {
2319            sarif
2320                .pointer("/runs/0/results")
2321                .and_then(serde_json::Value::as_array)
2322                .expect("SARIF results")
2323                .iter()
2324                .map(|entry| {
2325                    entry
2326                        .pointer(pointer)
2327                        .and_then(serde_json::Value::as_str)
2328                        .expect("SARIF field")
2329                        .to_owned()
2330                })
2331                .collect::<Vec<_>>()
2332        };
2333
2334        let clean = build(false);
2335        let caveated = build(true);
2336
2337        for key in [
2338            "/partialFingerprints/tools.fallow.fingerprint~1v1",
2339            "/partialFingerprints/primaryLocationLineHash~1v1",
2340        ] {
2341            assert_eq!(
2342                read(&clean, key),
2343                read(&caveated, key),
2344                "the caveat must not move {key}"
2345            );
2346        }
2347
2348        assert_ne!(
2349            read(&clean, "/message/text"),
2350            read(&caveated, "/message/text"),
2351            "the guard is only meaningful while the message actually changed"
2352        );
2353    }
2354
2355    #[test]
2356    fn sarif_rule_list_is_backed_by_issue_contracts() {
2357        let sarif = build_dead_code_sarif(
2358            &AnalysisResults::default(),
2359            Path::new("."),
2360            &RulesConfig::default(),
2361            &test_rule_builder,
2362        );
2363        let Some(rules) = sarif
2364            .pointer("/runs/0/tool/driver/rules")
2365            .and_then(serde_json::Value::as_array)
2366        else {
2367            panic!("SARIF document should contain driver rules");
2368        };
2369
2370        let actual_ids = rules
2371            .iter()
2372            .filter_map(|rule| {
2373                rule.get("id")
2374                    .and_then(serde_json::Value::as_str)
2375                    .map(str::to_owned)
2376            })
2377            .collect::<BTreeSet<_>>();
2378        let expected_ids = issue_output_contracts()
2379            .flat_map(|contract| contract.sarif_rule_ids)
2380            .collect::<BTreeSet<_>>();
2381
2382        assert_eq!(actual_ids, expected_ids);
2383
2384        for rule in rules {
2385            let id = rule
2386                .get("id")
2387                .and_then(serde_json::Value::as_str)
2388                .expect("SARIF rule should have id");
2389            let description = rule
2390                .pointer("/shortDescription/text")
2391                .and_then(serde_json::Value::as_str)
2392                .expect("SARIF rule should have short description");
2393            assert_eq!(
2394                description,
2395                issue_sarif_rule_description(id).expect("SARIF rule description should resolve")
2396            );
2397        }
2398    }
2399
2400    const FINDING_ID_POINTER: &str = "/partialFingerprints/fallowFinding~1v1";
2401
2402    /// One finding per single-result kind, plus the two kinds that fan out to
2403    /// one SARIF result per location.
2404    fn identity_results(root: &Path) -> AnalysisResults {
2405        let mut results = AnalysisResults::default();
2406        results
2407            .unused_files
2408            .push(UnusedFileFinding::with_actions(UnusedFile {
2409                path: root.join("src/orphan.ts"),
2410            }));
2411        results
2412            .unused_dependencies
2413            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2414                package_name: "lodash".to_owned(),
2415                location: fallow_types::results::DependencyLocation::Dependencies,
2416                path: root.join("package.json"),
2417                line: 3,
2418                used_in_workspaces: Vec::new(),
2419            }));
2420        results
2421            .unlisted_dependencies
2422            .push(UnlistedDependencyFinding::with_actions(
2423                fallow_types::results::UnlistedDependency {
2424                    package_name: "chalk".to_owned(),
2425                    imported_from: vec![
2426                        fallow_types::results::ImportSite {
2427                            path: root.join("src/a.ts"),
2428                            line: 1,
2429                            col: 0,
2430                        },
2431                        fallow_types::results::ImportSite {
2432                            path: root.join("src/b.ts"),
2433                            line: 2,
2434                            col: 0,
2435                        },
2436                    ],
2437                },
2438            ));
2439        results
2440            .duplicate_exports
2441            .push(DuplicateExportFinding::with_actions(
2442                fallow_types::results::DuplicateExport {
2443                    export_name: "Button".to_owned(),
2444                    locations: vec![
2445                        fallow_types::results::DuplicateLocation {
2446                            path: root.join("src/a.ts"),
2447                            line: 4,
2448                            col: 0,
2449                        },
2450                        fallow_types::results::DuplicateLocation {
2451                            path: root.join("src/b.ts"),
2452                            line: 5,
2453                            col: 0,
2454                        },
2455                    ],
2456                },
2457            ));
2458        results
2459    }
2460
2461    fn sarif_entries(sarif: &serde_json::Value) -> Vec<serde_json::Value> {
2462        sarif
2463            .pointer("/runs/0/results")
2464            .and_then(serde_json::Value::as_array)
2465            .expect("SARIF results")
2466            .clone()
2467    }
2468
2469    /// A finding that maps to one SARIF result carries its `finding_id` under
2470    /// `fallowFinding/v1`, next to the location-based keys.
2471    #[test]
2472    fn a_single_result_finding_carries_its_id_as_a_partial_fingerprint() {
2473        let root = Path::new("/p");
2474        let mut results = identity_results(root);
2475        fallow_types::identity::stamp_dead_code_finding_ids(&mut results, root);
2476        let sarif =
2477            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2478        let entries = sarif_entries(&sarif);
2479
2480        let id_of_rule = |rule: &str| {
2481            entries
2482                .iter()
2483                .find(|entry| entry["ruleId"] == rule)
2484                .and_then(|entry| entry.pointer(FINDING_ID_POINTER))
2485                .and_then(serde_json::Value::as_str)
2486                .map(str::to_owned)
2487        };
2488        assert_eq!(
2489            id_of_rule("fallow/unused-file").as_deref(),
2490            results.unused_files[0].finding_id.as_deref(),
2491        );
2492        assert_eq!(
2493            id_of_rule("fallow/unused-dependency").as_deref(),
2494            results.unused_dependencies[0].finding_id.as_deref(),
2495        );
2496        assert!(
2497            id_of_rule("fallow/unused-file").is_some_and(|id| id.starts_with("dc1:unused-file:")),
2498            "the key holds the finding id: {entries:#?}"
2499        );
2500    }
2501
2502    /// A partial fingerprint must identify one result. An unlisted dependency
2503    /// and a duplicate export give one result per location, so one id would
2504    /// name several results. These results do not carry the key.
2505    #[test]
2506    fn a_fanned_out_finding_does_not_carry_the_id() {
2507        let root = Path::new("/p");
2508        let mut results = identity_results(root);
2509        fallow_types::identity::stamp_dead_code_finding_ids(&mut results, root);
2510        let sarif =
2511            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2512
2513        for entry in sarif_entries(&sarif) {
2514            let rule = entry["ruleId"].as_str().expect("rule id");
2515            if matches!(
2516                rule,
2517                "fallow/unlisted-dependency" | "fallow/duplicate-export"
2518            ) {
2519                assert!(
2520                    entry.pointer(FINDING_ID_POINTER).is_none(),
2521                    "{rule} fans out and must not carry the id: {entry:#?}"
2522                );
2523            }
2524        }
2525    }
2526
2527    /// GitHub code scanning matches alerts on the location-based keys. The new
2528    /// key must not change any other byte of the document, or open alerts
2529    /// close and reopen on the next upload.
2530    #[test]
2531    fn the_finding_id_key_leaves_every_other_sarif_byte_alone() {
2532        let root = Path::new("/p");
2533        let without_ids = identity_results(root);
2534        let mut with_ids = without_ids.clone();
2535        fallow_types::identity::stamp_dead_code_finding_ids(&mut with_ids, root);
2536
2537        let before = build_dead_code_sarif(
2538            &without_ids,
2539            root,
2540            &RulesConfig::default(),
2541            &test_rule_builder,
2542        );
2543        let mut after =
2544            build_dead_code_sarif(&with_ids, root, &RulesConfig::default(), &test_rule_builder);
2545
2546        let mut removed = 0;
2547        for entry in after
2548            .pointer_mut("/runs/0/results")
2549            .and_then(serde_json::Value::as_array_mut)
2550            .expect("SARIF results")
2551        {
2552            let prints = entry["partialFingerprints"]
2553                .as_object_mut()
2554                .expect("partial fingerprints");
2555            if prints.remove("fallowFinding/v1").is_some() {
2556                removed += 1;
2557            }
2558        }
2559        assert!(
2560            removed > 0,
2561            "the guard needs at least one result with the key"
2562        );
2563        assert_eq!(
2564            serde_json::to_string(&before).expect("serialize"),
2565            serde_json::to_string(&after).expect("serialize"),
2566        );
2567    }
2568
2569    /// A saved envelope from an older version has no ids. The key is then
2570    /// absent, not empty and not null.
2571    #[test]
2572    fn a_finding_without_an_id_has_no_finding_id_key() {
2573        let root = Path::new("/p");
2574        let sarif = build_dead_code_sarif(
2575            &identity_results(root),
2576            root,
2577            &RulesConfig::default(),
2578            &test_rule_builder,
2579        );
2580        for entry in sarif_entries(&sarif) {
2581            assert!(
2582                entry["partialFingerprints"]
2583                    .get("fallowFinding/v1")
2584                    .is_none(),
2585                "no id, no key: {entry:#?}"
2586            );
2587        }
2588    }
2589}