Skip to main content

fallow_output/
dead_code_sarif.rs

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