Skip to main content

fallow_output/
dead_code_sarif.rs

1//! Shared dead-code SARIF output assembly.
2
3use std::path::Path;
4
5use crate::{
6    SarifDocumentInput, SarifFindingFields as SarifFields,
7    SarifSourceSnippetCache as SourceSnippetCache, append_sarif_findings as push_sarif_results,
8    build_sarif_document, build_sarif_result_with_snippet as sarif_result_with_snippet,
9    issue_output_contracts, normalize_uri,
10};
11use fallow_config::{RulesConfig, Severity};
12use fallow_types::{
13    issue_meta::issue_sarif_rule_description,
14    output_dead_code::*,
15    results::{
16        AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation, BoundaryViolation,
17        CircularDependency, DeprecatedExportInUse, DevDependencyInProduction, DuplicatePropShape,
18        DynamicSegmentNameConflict, InvalidClientExport, MisplacedDirective,
19        MixedClientServerBarrel, PolicyViolation, PolicyViolationSeverity, PrivateTypeLeak,
20        PropDrillingChain, RouteCollision, StaleSuppression, TestOnlyDependency, ThinWrapper,
21        TypeOnlyDependency, UnprovidedInject, UnrenderedComponent, UnresolvedImport,
22        UnusedComponentEmit, UnusedComponentInput, UnusedComponentOutput, UnusedComponentProp,
23        UnusedDependency, UnusedExport, UnusedFile, UnusedMember, UnusedServerAction,
24        UnusedSvelteEvent,
25    },
26};
27
28fn relative_uri(path: &Path, root: &Path) -> String {
29    normalize_uri(
30        &path
31            .strip_prefix(root)
32            .unwrap_or(path)
33            .display()
34            .to_string(),
35    )
36}
37
38/// Read-only context threaded through the SARIF result builders: the
39/// analysis results, project root, and rule severities. Bundled so the
40/// `push_*_sarif_results` family shares one parameter instead of three.
41#[derive(Clone, Copy)]
42struct SarifCtx<'a> {
43    results: &'a AnalysisResults,
44    root: &'a Path,
45    rules: &'a RulesConfig,
46}
47
48fn severity_to_sarif_level(s: Severity) -> &'static str {
49    match s {
50        Severity::Error => "error",
51        Severity::Warn => "warning",
52        Severity::Off => unreachable!(),
53    }
54}
55
56/// The CI severity of a type that never gates the exit code.
57///
58/// Prop-drilling, thin-wrapper and duplicate-prop-shape findings are health
59/// signals: they never fail the run. An `error` rule is capped at `warn`, so
60/// a CI system that reads the level never shows an error for a run that
61/// passed. `off` stays `off`.
62const fn non_gating_severity(rule: Severity) -> Severity {
63    match rule {
64        Severity::Error => Severity::Warn,
65        other => other,
66    }
67}
68
69/// The SARIF level for one finding: its saved severity when the finding
70/// carries one, otherwise the configured rule severity (a saved report from an
71/// older version has no saved severity).
72///
73/// A saved finding exists, so its rule was on when it was reported. When the
74/// config at render time sets the rule `off`, the level is `warning`.
75fn finding_level(finding: &impl GatedFinding, rule: Severity) -> &'static str {
76    match finding.effective_severity() {
77        Some(EffectiveSeverity::Error) => "error",
78        Some(EffectiveSeverity::Warn) => "warning",
79        None => match rule {
80            Severity::Off => "warning",
81            Severity::Error | Severity::Warn => severity_to_sarif_level(rule),
82        },
83    }
84}
85
86fn configured_sarif_level(s: Severity) -> &'static str {
87    match s {
88        Severity::Error | Severity::Warn => severity_to_sarif_level(s),
89        Severity::Off => "none",
90    }
91}
92
93/// The SARIF level of a finding type that never gates the exit code: its
94/// saved level, capped at `warning` (see [`non_gating_severity`]).
95///
96/// The cap applies to the saved severity too, because a saved `error` of
97/// these types still never failed the run.
98fn non_gating_level(finding: &impl GatedFinding, rule: Severity) -> &'static str {
99    match finding_level(finding, non_gating_severity(rule)) {
100        "error" => "warning",
101        level => level,
102    }
103}
104
105/// Extract SARIF fields for an unused export or type export.
106fn sarif_export_fields(
107    export: &UnusedExport,
108    root: &Path,
109    rule_id: &'static str,
110    level: &'static str,
111    kind: &str,
112    re_kind: &str,
113) -> SarifFields {
114    let label = if export.is_re_export { re_kind } else { kind };
115    SarifFields {
116        rule_id,
117        level,
118        message: format!(
119            "{} '{}' is never imported by other modules",
120            label, export.export_name
121        ),
122        uri: relative_uri(&export.path, root),
123        region: Some((export.line, export.col + 1)),
124        source_path: Some(export.path.clone()),
125        properties: if export.is_re_export {
126            Some(serde_json::json!({ "is_re_export": true }))
127        } else {
128            None
129        },
130    }
131}
132
133fn sarif_private_type_leak_fields(
134    leak: &PrivateTypeLeak,
135    root: &Path,
136    level: &'static str,
137) -> SarifFields {
138    SarifFields {
139        rule_id: "fallow/private-type-leak",
140        level,
141        message: format!(
142            "Export '{}' references private type '{}'",
143            leak.export_name, leak.type_name
144        ),
145        uri: relative_uri(&leak.path, root),
146        region: Some((leak.line, leak.col + 1)),
147        source_path: Some(leak.path.clone()),
148        properties: None,
149    }
150}
151
152fn sarif_deprecated_export_fields(
153    export: &DeprecatedExportInUse,
154    root: &Path,
155    level: &'static str,
156) -> SarifFields {
157    SarifFields {
158        rule_id: "fallow/deprecated-export-in-use",
159        level,
160        message: export.description(),
161        uri: relative_uri(&export.path, root),
162        region: Some((export.line, export.col + 1)),
163        source_path: Some(export.path.clone()),
164        properties: Some(serde_json::json!({
165            "consumer_count": export.consumer_count,
166            "public_api": export.public_api,
167        })),
168    }
169}
170
171/// 1-based column of the `"<name>"` key inside the manifest line a dependency
172/// finding points at, falling back to 1 when the line cannot be read.
173///
174/// Every dependency declared on one line otherwise reports the same location,
175/// and a SARIF fingerprint is rule id plus location plus source snippet: a
176/// compact `package.json` collapsed all of its unused dependencies into one
177/// GitHub code scanning alert.
178fn manifest_key_column(
179    snippets: &mut SourceSnippetCache,
180    path: &Path,
181    line: u32,
182    name: &str,
183) -> u32 {
184    snippets
185        .line(path, line)
186        .and_then(|text| text.find(&format!("\"{name}\"")))
187        .and_then(|offset| u32::try_from(offset).ok())
188        .map_or(1, |offset| offset.saturating_add(1))
189}
190
191/// The manifest coordinates `manifest_key_column` needs from a dependency.
192fn dep_key(dep: &UnusedDependency) -> (&Path, u32, &str) {
193    (dep.path.as_path(), dep.line, dep.package_name.as_str())
194}
195
196/// Resolve one manifest column per finding up front, so the result closure that
197/// needs them does not have to borrow the snippet cache it reads.
198fn manifest_key_columns<'a, T: 'a>(
199    findings: &'a [T],
200    snippets: &mut SourceSnippetCache,
201    key_of: impl Fn(&'a T) -> (&'a Path, u32, &'a str),
202) -> std::vec::IntoIter<u32> {
203    findings
204        .iter()
205        .map(|finding| {
206            let (path, line, name) = key_of(finding);
207            manifest_key_column(snippets, path, line, name)
208        })
209        .collect::<Vec<_>>()
210        .into_iter()
211}
212
213/// Extract SARIF fields for an unused dependency.
214fn sarif_dep_fields(
215    dep: &UnusedDependency,
216    root: &Path,
217    rule_id: &'static str,
218    level: &'static str,
219    section: &str,
220    col: u32,
221) -> SarifFields {
222    let 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_svelte_event_fields(
716    event: &UnusedSvelteEvent,
717    root: &Path,
718    level: &'static str,
719) -> SarifFields {
720    SarifFields {
721        rule_id: "fallow/unused-svelte-event",
722        level,
723        message: format!(
724            "event \"{}\" is dispatched by component \"{}\" but listened to nowhere in the project; remove it or listen for it",
725            event.event_name, event.component_name
726        ),
727        uri: relative_uri(&event.path, root),
728        region: Some((event.line, event.col + 1)),
729        source_path: Some(event.path.clone()),
730        properties: None,
731    }
732}
733
734fn sarif_unused_component_input_fields(
735    input: &UnusedComponentInput,
736    root: &Path,
737    level: &'static str,
738) -> SarifFields {
739    SarifFields {
740        rule_id: "fallow/unused-component-input",
741        level,
742        message: format!(
743            "input \"{}\" is declared but read nowhere inside component \"{}\"; remove it or use it",
744            input.input_name, input.component_name
745        ),
746        uri: relative_uri(&input.path, root),
747        region: Some((input.line, input.col + 1)),
748        source_path: Some(input.path.clone()),
749        properties: None,
750    }
751}
752
753fn sarif_unused_component_output_fields(
754    output: &UnusedComponentOutput,
755    root: &Path,
756    level: &'static str,
757) -> SarifFields {
758    SarifFields {
759        rule_id: "fallow/unused-component-output",
760        level,
761        message: format!(
762            "output \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
763            output.output_name, output.component_name
764        ),
765        uri: relative_uri(&output.path, root),
766        region: Some((output.line, output.col + 1)),
767        source_path: Some(output.path.clone()),
768        properties: None,
769    }
770}
771
772fn sarif_unused_server_action_fields(
773    action: &UnusedServerAction,
774    root: &Path,
775    level: &'static str,
776) -> SarifFields {
777    SarifFields {
778        rule_id: "fallow/unused-server-action",
779        level,
780        message: format!(
781            "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",
782            action.action_name
783        ),
784        uri: relative_uri(&action.path, root),
785        region: Some((action.line, action.col + 1)),
786        source_path: Some(action.path.clone()),
787        properties: None,
788    }
789}
790
791fn sarif_unused_load_data_key_fields(
792    key: &fallow_types::results::UnusedLoadDataKey,
793    root: &Path,
794    level: &'static str,
795) -> SarifFields {
796    SarifFields {
797        rule_id: "fallow/unused-load-data-key",
798        level,
799        message: format!(
800            "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",
801            key.key_name
802        ),
803        uri: relative_uri(&key.path, root),
804        region: Some((key.line, key.col + 1)),
805        source_path: Some(key.path.clone()),
806        properties: None,
807    }
808}
809
810fn sarif_prop_drilling_fields(
811    chain: &PropDrillingChain,
812    root: &Path,
813    level: &'static str,
814) -> SarifFields {
815    // Anchor at the source hop (the prop owner). Path / line come from the first
816    // hop; the message names the depth and the consumer at the chain tail.
817    let source = chain.hops.first();
818    let consumer = chain.hops.last();
819    let (path, line) = source.map_or((std::path::PathBuf::new(), 1), |h| (h.file.clone(), h.line));
820    let consumer_name = consumer.map_or("a distant component", |h| h.component.as_str());
821    SarifFields {
822        rule_id: "fallow/prop-drilling",
823        level,
824        message: format!(
825            "prop \"{}\" is forwarded unchanged through {} component(s) before \"{}\" consumes it; colocate, lift to context, or compose",
826            chain.prop, chain.depth, consumer_name
827        ),
828        uri: relative_uri(&path, root),
829        region: Some((line, 1)),
830        source_path: Some(path),
831        properties: None,
832    }
833}
834
835fn sarif_thin_wrapper_fields(
836    wrapper: &ThinWrapper,
837    root: &Path,
838    level: &'static str,
839) -> SarifFields {
840    SarifFields {
841        rule_id: "fallow/thin-wrapper",
842        level,
843        message: format!(
844            "\"{}\" is a thin wrapper: its whole body forwards props to \"{}\"; inline it at call sites or delete it",
845            wrapper.component, wrapper.child_component
846        ),
847        uri: relative_uri(&wrapper.file, root),
848        region: Some((wrapper.line, 1)),
849        source_path: Some(wrapper.file.clone()),
850        properties: None,
851    }
852}
853
854fn sarif_duplicate_prop_shape_fields(
855    shape: &DuplicatePropShape,
856    root: &Path,
857    level: &'static str,
858) -> SarifFields {
859    SarifFields {
860        rule_id: "fallow/duplicate-prop-shape",
861        level,
862        message: format!(
863            "\"{}\" shares an identical prop shape {{{}}} with {} other component(s); extract a shared Props type or base component",
864            shape.component,
865            shape.shape.join(", "),
866            shape.group_size.saturating_sub(1)
867        ),
868        uri: relative_uri(&shape.file, root),
869        region: Some((shape.line, 1)),
870        source_path: Some(shape.file.clone()),
871        properties: None,
872    }
873}
874
875fn sarif_route_collision_fields(
876    collision: &RouteCollision,
877    root: &Path,
878    level: &'static str,
879) -> SarifFields {
880    SarifFields {
881        rule_id: "fallow/route-collision",
882        level,
883        message: format!(
884            "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",
885            collision.url,
886            collision.conflicting_paths.len()
887        ),
888        uri: relative_uri(&collision.path, root),
889        region: Some((collision.line, collision.col + 1)),
890        source_path: Some(collision.path.clone()),
891        properties: None,
892    }
893}
894
895fn sarif_dynamic_segment_name_conflict_fields(
896    conflict: &DynamicSegmentNameConflict,
897    root: &Path,
898    level: &'static str,
899) -> SarifFields {
900    SarifFields {
901        rule_id: "fallow/dynamic-segment-name-conflict",
902        level,
903        message: format!(
904            "Dynamic segments at '{}' use different slug names ({}); Next.js requires one consistent name per dynamic path",
905            conflict.position,
906            conflict.conflicting_segments.join(", ")
907        ),
908        uri: relative_uri(&conflict.path, root),
909        region: Some((conflict.line, conflict.col + 1)),
910        source_path: Some(conflict.path.clone()),
911        properties: None,
912    }
913}
914
915fn sarif_stale_suppression_fields(
916    suppression: &StaleSuppression,
917    root: &Path,
918    level: &'static str,
919) -> SarifFields {
920    SarifFields {
921        rule_id: if suppression.missing_reason {
922            "fallow/missing-suppression-reason"
923        } else {
924            "fallow/stale-suppression"
925        },
926        level,
927        message: suppression.display_message(),
928        uri: relative_uri(&suppression.path, root),
929        region: Some((suppression.line, suppression.col + 1)),
930        source_path: Some(suppression.path.clone()),
931        properties: None,
932    }
933}
934
935fn stale_suppression_severity(suppression: &StaleSuppression, rules: &RulesConfig) -> Severity {
936    if suppression.missing_reason {
937        rules.require_suppression_reason
938    } else {
939        rules.stale_suppressions
940    }
941}
942
943fn sarif_unused_catalog_entry_fields(
944    entry: &UnusedCatalogEntryFinding,
945    root: &Path,
946    level: &'static str,
947) -> SarifFields {
948    let entry = &entry.entry;
949    let message = if entry.catalog_name == "default" {
950        format!(
951            "Catalog entry '{}' is not referenced by any workspace package",
952            entry.entry_name
953        )
954    } else {
955        format!(
956            "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
957            entry.entry_name, entry.catalog_name
958        )
959    };
960    SarifFields {
961        rule_id: "fallow/unused-catalog-entry",
962        level,
963        message,
964        uri: relative_uri(&entry.path, root),
965        region: Some((entry.line, 1)),
966        source_path: Some(entry.path.clone()),
967        properties: None,
968    }
969}
970
971fn sarif_unused_dependency_override_fields(
972    finding: &UnusedDependencyOverrideFinding,
973    root: &Path,
974    level: &'static str,
975) -> SarifFields {
976    let finding = &finding.entry;
977    let mut message = format!(
978        "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in the lockfile",
979        finding.raw_key, finding.version_range, finding.target_package,
980    );
981    if let Some(hint) = &finding.hint {
982        use std::fmt::Write as _;
983        let _ = write!(message, " ({hint})");
984    }
985    SarifFields {
986        rule_id: "fallow/unused-dependency-override",
987        level,
988        message,
989        uri: relative_uri(&finding.path, root),
990        region: Some((finding.line, 1)),
991        source_path: Some(finding.path.clone()),
992        properties: None,
993    }
994}
995
996fn sarif_misconfigured_dependency_override_fields(
997    finding: &MisconfiguredDependencyOverrideFinding,
998    root: &Path,
999    level: &'static str,
1000) -> SarifFields {
1001    let finding = &finding.entry;
1002    let message = format!(
1003        "Override `{}` -> `{}` is malformed: {}",
1004        finding.raw_key,
1005        finding.raw_value,
1006        finding.reason.describe(),
1007    );
1008    SarifFields {
1009        rule_id: "fallow/misconfigured-dependency-override",
1010        level,
1011        message,
1012        uri: relative_uri(&finding.path, root),
1013        region: Some((finding.line, 1)),
1014        source_path: Some(finding.path.clone()),
1015        properties: None,
1016    }
1017}
1018
1019fn sarif_unresolved_catalog_reference_fields(
1020    finding: &UnresolvedCatalogReferenceFinding,
1021    root: &Path,
1022    level: &'static str,
1023) -> SarifFields {
1024    let finding = &finding.reference;
1025    let catalog_phrase = if finding.catalog_name == "default" {
1026        "the default catalog".to_string()
1027    } else {
1028        format!("catalog '{}'", finding.catalog_name)
1029    };
1030    let mut message = format!(
1031        "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
1032        finding.entry_name,
1033        if finding.catalog_name == "default" {
1034            ""
1035        } else {
1036            finding.catalog_name.as_str()
1037        },
1038        catalog_phrase,
1039    );
1040    if !finding.available_in_catalogs.is_empty() {
1041        use std::fmt::Write as _;
1042        let _ = write!(
1043            message,
1044            " (available in: {})",
1045            finding.available_in_catalogs.join(", ")
1046        );
1047    }
1048    SarifFields {
1049        rule_id: "fallow/unresolved-catalog-reference",
1050        level,
1051        message,
1052        uri: relative_uri(&finding.path, root),
1053        region: Some((finding.line, 1)),
1054        source_path: Some(finding.path.clone()),
1055        properties: None,
1056    }
1057}
1058
1059fn sarif_empty_catalog_group_fields(
1060    group: &EmptyCatalogGroupFinding,
1061    root: &Path,
1062    level: &'static str,
1063) -> SarifFields {
1064    let group = &group.group;
1065    SarifFields {
1066        rule_id: "fallow/empty-catalog-group",
1067        level,
1068        message: format!("Catalog group '{}' has no entries", group.catalog_name),
1069        uri: relative_uri(&group.path, root),
1070        region: Some((group.line, 1)),
1071        source_path: Some(group.path.clone()),
1072        properties: None,
1073    }
1074}
1075
1076/// Unlisted deps fan out to one SARIF result per import site, so they do not
1077/// fit `push_sarif_results`. Keep the nested-loop shape in its own helper.
1078fn push_sarif_unlisted_deps(
1079    sarif_results: &mut Vec<serde_json::Value>,
1080    deps: &[UnlistedDependencyFinding],
1081    root: &Path,
1082    rule: Severity,
1083    snippets: &mut SourceSnippetCache,
1084) {
1085    for entry in deps {
1086        let level = finding_level(entry, rule);
1087        let dep = &entry.dep;
1088        for site in &dep.imported_from {
1089            let uri = relative_uri(&site.path, root);
1090            let source_snippet = snippets.line(&site.path, site.line);
1091            sarif_results.push(sarif_result_with_snippet(
1092                "fallow/unlisted-dependency",
1093                level,
1094                &format!(
1095                    "Package '{}' is imported but not listed in package.json",
1096                    dep.package_name
1097                ),
1098                &uri,
1099                Some((site.line, site.col + 1)),
1100                source_snippet.as_deref(),
1101            ));
1102        }
1103    }
1104}
1105
1106/// Duplicate exports fan out to one SARIF result per location
1107/// (SARIF 2.1.0 section 3.27.12), so they do not fit `push_sarif_results`.
1108fn push_sarif_duplicate_exports(
1109    sarif_results: &mut Vec<serde_json::Value>,
1110    dups: &[DuplicateExportFinding],
1111    root: &Path,
1112    rule: Severity,
1113    snippets: &mut SourceSnippetCache,
1114) {
1115    for dup in dups {
1116        let level = finding_level(dup, rule);
1117        let dup = &dup.export;
1118        for loc in &dup.locations {
1119            let uri = relative_uri(&loc.path, root);
1120            let source_snippet = snippets.line(&loc.path, loc.line);
1121            sarif_results.push(sarif_result_with_snippet(
1122                "fallow/duplicate-export",
1123                level,
1124                &format!("Export '{}' appears in multiple modules", dup.export_name),
1125                &uri,
1126                Some((loc.line, loc.col + 1)),
1127                source_snippet.as_deref(),
1128            ));
1129        }
1130    }
1131}
1132
1133/// Build the SARIF rules list from the current rules configuration.
1134fn build_sarif_rules(
1135    rules: &RulesConfig,
1136    rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1137) -> Vec<serde_json::Value> {
1138    let mut sarif_rules = Vec::new();
1139    for contract in issue_output_contracts() {
1140        for rule_id in contract.sarif_rule_ids {
1141            let severity = sarif_rule_severity(rules, contract.code, &rule_id);
1142            let description = issue_sarif_rule_description(&rule_id).unwrap_or_else(|| {
1143                panic!("dead-code SARIF rule {rule_id} is missing issue metadata")
1144            });
1145            sarif_rules.push(rule_builder(
1146                &rule_id,
1147                description,
1148                configured_sarif_level(severity),
1149            ));
1150        }
1151    }
1152    sarif_rules
1153}
1154
1155fn sarif_rule_severity(rules: &RulesConfig, issue_code: &str, rule_id: &str) -> Severity {
1156    if rule_id == "fallow/missing-suppression-reason" {
1157        return rules.require_suppression_reason;
1158    }
1159    dead_code_rule_severity(rules, issue_code)
1160        .unwrap_or_else(|| panic!("dead-code SARIF rule {rule_id} has no severity mapping"))
1161}
1162
1163fn dead_code_rule_severity(rules: &RulesConfig, issue_code: &str) -> Option<Severity> {
1164    let severity = match issue_code {
1165        "unused-file" => rules.unused_files,
1166        "unused-export" => rules.unused_exports,
1167        "unused-type" => rules.unused_types,
1168        "private-type-leak" => rules.private_type_leaks,
1169        "deprecated-export-in-use" => rules.deprecated_exports_in_use,
1170        "unused-dependency" => rules.unused_dependencies,
1171        "unused-dev-dependency" => rules.unused_dev_dependencies,
1172        "unused-optional-dependency" => rules.unused_optional_dependencies,
1173        "type-only-dependency" => rules.type_only_dependencies,
1174        "test-only-dependency" => rules.test_only_dependencies,
1175        "dev-dependency-in-production" => rules.dev_dependencies_in_production,
1176        "unused-enum-member" => rules.unused_enum_members,
1177        "unused-class-member" => rules.unused_class_members,
1178        "unused-store-member" => rules.unused_store_members,
1179        "unresolved-import" => rules.unresolved_imports,
1180        "unlisted-dependency" => rules.unlisted_dependencies,
1181        "duplicate-export" => rules.duplicate_exports,
1182        "circular-dependency" => rules.circular_dependencies,
1183        "re-export-cycle" => rules.re_export_cycle,
1184        "package-cycle" => rules.package_cycle,
1185        "boundary-violation" | "boundary-coverage" | "boundary-call-violation" => {
1186            rules.boundary_violation
1187        }
1188        "policy-violation" => rules.policy_violation,
1189        "invalid-client-export" => rules.invalid_client_export,
1190        "mixed-client-server-barrel" => rules.mixed_client_server_barrel,
1191        "misplaced-directive" => rules.misplaced_directive,
1192        "unprovided-inject" => rules.unprovided_injects,
1193        "unrendered-component" => rules.unrendered_components,
1194        "unused-component-prop" => rules.unused_component_props,
1195        "absent-component-prop" => rules.absent_component_props,
1196        "unused-component-emit" => rules.unused_component_emits,
1197        "unused-component-input" => rules.unused_component_inputs,
1198        "unused-component-output" => rules.unused_component_outputs,
1199        "unused-svelte-event" => rules.unused_svelte_events,
1200        "unused-server-action" => rules.unused_server_actions,
1201        "unused-load-data-key" => rules.unused_load_data_keys,
1202        "prop-drilling" => non_gating_severity(rules.prop_drilling),
1203        "thin-wrapper" => non_gating_severity(rules.thin_wrapper),
1204        "duplicate-prop-shape" => non_gating_severity(rules.duplicate_prop_shape),
1205        "route-collision" => rules.route_collision,
1206        "dynamic-segment-name-conflict" => rules.dynamic_segment_name_conflict,
1207        "stale-suppression" => rules.stale_suppressions,
1208        "unused-catalog-entry" => rules.unused_catalog_entries,
1209        "empty-catalog-group" => rules.empty_catalog_groups,
1210        "unresolved-catalog-reference" => rules.unresolved_catalog_references,
1211        "unused-dependency-override" => rules.unused_dependency_overrides,
1212        "misconfigured-dependency-override" => rules.misconfigured_dependency_overrides,
1213        _ => return None,
1214    };
1215    Some(severity)
1216}
1217
1218/// Builds the complete SARIF `run` value for a dead-code analysis.
1219///
1220/// Emits one SARIF result per finding across every dead-code issue kind,
1221/// mapping each configured rule severity to a SARIF level. `rule_builder`
1222/// constructs the tool-driver rule object for a `(rule id, name, help URI)`
1223/// triple so the caller controls rule metadata.
1224#[must_use]
1225pub fn build_dead_code_sarif(
1226    results: &AnalysisResults,
1227    root: &Path,
1228    rules: &RulesConfig,
1229    rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1230) -> serde_json::Value {
1231    let mut sarif_results = Vec::new();
1232    let mut snippets = SourceSnippetCache::with_root(root);
1233    let ctx = SarifCtx {
1234        results,
1235        root,
1236        rules,
1237    };
1238
1239    push_primary_dead_code_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1240    push_dependency_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1241    push_member_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1242    push_sarif_results(
1243        &mut sarif_results,
1244        &results.unresolved_imports,
1245        &mut snippets,
1246        |i| {
1247            sarif_unresolved_import_fields(
1248                &i.import,
1249                root,
1250                finding_level(i, rules.unresolved_imports),
1251            )
1252        },
1253    );
1254    push_misc_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1255    push_graph_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1256    push_catalog_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1257
1258    let sarif_rules = build_sarif_rules(rules, rule_builder);
1259    sarif_document(&sarif_results, &sarif_rules)
1260}
1261
1262fn push_primary_dead_code_sarif_results(
1263    sarif_results: &mut Vec<serde_json::Value>,
1264    ctx: &SarifCtx<'_>,
1265    snippets: &mut SourceSnippetCache,
1266) {
1267    let SarifCtx {
1268        results,
1269        root,
1270        rules,
1271    } = *ctx;
1272
1273    push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1274        with_caveats(
1275            sarif_unused_file_fields(
1276                &finding.file,
1277                root,
1278                finding_level(finding, rules.unused_files),
1279            ),
1280            &finding.reachability_caveats,
1281        )
1282    });
1283    push_sarif_results(
1284        sarif_results,
1285        &results.unused_exports,
1286        snippets,
1287        |finding| {
1288            with_caveats(
1289                sarif_export_fields(
1290                    &finding.export,
1291                    root,
1292                    "fallow/unused-export",
1293                    finding_level(finding, rules.unused_exports),
1294                    "Export",
1295                    "Re-export",
1296                ),
1297                &finding.reachability_caveats,
1298            )
1299        },
1300    );
1301    push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1302        with_caveats(
1303            sarif_export_fields(
1304                &finding.export,
1305                root,
1306                "fallow/unused-type",
1307                finding_level(finding, rules.unused_types),
1308                "Type export",
1309                "Type re-export",
1310            ),
1311            &finding.reachability_caveats,
1312        )
1313    });
1314    push_sarif_results(
1315        sarif_results,
1316        &results.private_type_leaks,
1317        snippets,
1318        |finding| {
1319            sarif_private_type_leak_fields(
1320                &finding.leak,
1321                root,
1322                finding_level(finding, rules.private_type_leaks),
1323            )
1324        },
1325    );
1326    push_sarif_results(
1327        sarif_results,
1328        &results.deprecated_exports_in_use,
1329        snippets,
1330        |finding| {
1331            sarif_deprecated_export_fields(
1332                &finding.export,
1333                root,
1334                finding_level(finding, rules.deprecated_exports_in_use),
1335            )
1336        },
1337    );
1338}
1339
1340fn sarif_document(
1341    sarif_results: &[serde_json::Value],
1342    sarif_rules: &[serde_json::Value],
1343) -> serde_json::Value {
1344    build_sarif_document(SarifDocumentInput {
1345        results: sarif_results,
1346        rules: sarif_rules,
1347        tool_version: env!("CARGO_PKG_VERSION"),
1348    })
1349}
1350
1351fn push_dependency_sarif_results(
1352    sarif_results: &mut Vec<serde_json::Value>,
1353    ctx: &SarifCtx<'_>,
1354    snippets: &mut SourceSnippetCache,
1355) {
1356    push_unused_dependency_sarif_results(sarif_results, ctx, snippets);
1357    push_classified_dependency_sarif_results(sarif_results, ctx, snippets);
1358}
1359
1360/// Push SARIF results for unused runtime, dev, and optional dependencies.
1361fn push_unused_dependency_sarif_results(
1362    sarif_results: &mut Vec<serde_json::Value>,
1363    ctx: &SarifCtx<'_>,
1364    snippets: &mut SourceSnippetCache,
1365) {
1366    let SarifCtx {
1367        results,
1368        root,
1369        rules,
1370    } = *ctx;
1371
1372    let mut columns =
1373        manifest_key_columns(&results.unused_dependencies, snippets, |f| dep_key(&f.dep));
1374    push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1375        with_caveats(
1376            sarif_dep_fields(
1377                &d.dep,
1378                root,
1379                "fallow/unused-dependency",
1380                finding_level(d, rules.unused_dependencies),
1381                "dependencies",
1382                columns.next().unwrap_or(1),
1383            ),
1384            &d.reachability_caveats,
1385        )
1386    });
1387    let mut columns = manifest_key_columns(&results.unused_dev_dependencies, snippets, |f| {
1388        dep_key(&f.dep)
1389    });
1390    push_sarif_results(
1391        sarif_results,
1392        &results.unused_dev_dependencies,
1393        snippets,
1394        |d| {
1395            with_caveats(
1396                sarif_dep_fields(
1397                    &d.dep,
1398                    root,
1399                    "fallow/unused-dev-dependency",
1400                    finding_level(d, rules.unused_dev_dependencies),
1401                    "devDependencies",
1402                    columns.next().unwrap_or(1),
1403                ),
1404                &d.reachability_caveats,
1405            )
1406        },
1407    );
1408    let mut columns = manifest_key_columns(&results.unused_optional_dependencies, snippets, |f| {
1409        dep_key(&f.dep)
1410    });
1411    push_sarif_results(
1412        sarif_results,
1413        &results.unused_optional_dependencies,
1414        snippets,
1415        |d| {
1416            with_caveats(
1417                sarif_dep_fields(
1418                    &d.dep,
1419                    root,
1420                    "fallow/unused-optional-dependency",
1421                    finding_level(d, rules.unused_optional_dependencies),
1422                    "optionalDependencies",
1423                    columns.next().unwrap_or(1),
1424                ),
1425                &d.reachability_caveats,
1426            )
1427        },
1428    );
1429}
1430
1431/// Push SARIF results for type-only and test-only dependency misclassifications.
1432fn push_classified_dependency_sarif_results(
1433    sarif_results: &mut Vec<serde_json::Value>,
1434    ctx: &SarifCtx<'_>,
1435    snippets: &mut SourceSnippetCache,
1436) {
1437    let SarifCtx {
1438        results,
1439        root,
1440        rules,
1441    } = *ctx;
1442
1443    let mut columns = manifest_key_columns(&results.type_only_dependencies, snippets, |f| {
1444        (
1445            f.dep.path.as_path(),
1446            f.dep.line,
1447            f.dep.package_name.as_str(),
1448        )
1449    });
1450    push_sarif_results(
1451        sarif_results,
1452        &results.type_only_dependencies,
1453        snippets,
1454        |d| {
1455            sarif_type_only_dep_fields(
1456                &d.dep,
1457                root,
1458                finding_level(d, rules.type_only_dependencies),
1459                columns.next().unwrap_or(1),
1460            )
1461        },
1462    );
1463    let mut columns = manifest_key_columns(&results.test_only_dependencies, snippets, |f| {
1464        (
1465            f.dep.path.as_path(),
1466            f.dep.line,
1467            f.dep.package_name.as_str(),
1468        )
1469    });
1470    push_sarif_results(
1471        sarif_results,
1472        &results.test_only_dependencies,
1473        snippets,
1474        |d| {
1475            sarif_test_only_dep_fields(
1476                &d.dep,
1477                root,
1478                finding_level(d, rules.test_only_dependencies),
1479                columns.next().unwrap_or(1),
1480            )
1481        },
1482    );
1483    let mut columns =
1484        manifest_key_columns(&results.dev_dependencies_in_production, snippets, |f| {
1485            (
1486                f.dep.path.as_path(),
1487                f.dep.line,
1488                f.dep.package_name.as_str(),
1489            )
1490        });
1491    push_sarif_results(
1492        sarif_results,
1493        &results.dev_dependencies_in_production,
1494        snippets,
1495        |d| {
1496            sarif_dev_dep_in_prod_fields(
1497                &d.dep,
1498                root,
1499                finding_level(d, rules.dev_dependencies_in_production),
1500                columns.next().unwrap_or(1),
1501            )
1502        },
1503    );
1504}
1505
1506fn push_member_sarif_results(
1507    sarif_results: &mut Vec<serde_json::Value>,
1508    ctx: &SarifCtx<'_>,
1509    snippets: &mut SourceSnippetCache,
1510) {
1511    let SarifCtx {
1512        results,
1513        root,
1514        rules,
1515    } = *ctx;
1516
1517    push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1518        with_caveats(
1519            sarif_member_fields(
1520                &m.member,
1521                root,
1522                "fallow/unused-enum-member",
1523                finding_level(m, rules.unused_enum_members),
1524                "Enum",
1525            ),
1526            &m.reachability_caveats,
1527        )
1528    });
1529    push_sarif_results(
1530        sarif_results,
1531        &results.unused_class_members,
1532        snippets,
1533        |m| {
1534            with_caveats(
1535                sarif_member_fields(
1536                    &m.member,
1537                    root,
1538                    "fallow/unused-class-member",
1539                    finding_level(m, rules.unused_class_members),
1540                    "Class",
1541                ),
1542                &m.reachability_caveats,
1543            )
1544        },
1545    );
1546    push_sarif_results(
1547        sarif_results,
1548        &results.unused_store_members,
1549        snippets,
1550        |m| {
1551            with_caveats(
1552                sarif_member_fields(
1553                    &m.member,
1554                    root,
1555                    "fallow/unused-store-member",
1556                    finding_level(m, rules.unused_store_members),
1557                    "Store",
1558                ),
1559                &m.reachability_caveats,
1560            )
1561        },
1562    );
1563}
1564
1565fn push_misc_sarif_results(
1566    sarif_results: &mut Vec<serde_json::Value>,
1567    ctx: &SarifCtx<'_>,
1568    snippets: &mut SourceSnippetCache,
1569) {
1570    let SarifCtx {
1571        results,
1572        root,
1573        rules,
1574    } = *ctx;
1575
1576    if !results.unlisted_dependencies.is_empty() {
1577        push_sarif_unlisted_deps(
1578            sarif_results,
1579            &results.unlisted_dependencies,
1580            root,
1581            rules.unlisted_dependencies,
1582            snippets,
1583        );
1584    }
1585    if !results.duplicate_exports.is_empty() {
1586        push_sarif_duplicate_exports(
1587            sarif_results,
1588            &results.duplicate_exports,
1589            root,
1590            rules.duplicate_exports,
1591            snippets,
1592        );
1593    }
1594}
1595
1596/// Push the component-contract SARIF results (`unused-component-prop` and
1597/// `unused-component-emit`). Extracted from `push_graph_sarif_results` to keep
1598/// that function under the unit-size lint.
1599fn push_component_contract_sarif_results(
1600    sarif_results: &mut Vec<serde_json::Value>,
1601    ctx: &SarifCtx<'_>,
1602    snippets: &mut SourceSnippetCache,
1603) {
1604    push_component_member_sarif_results(sarif_results, ctx, snippets);
1605    push_component_framework_sarif_results(sarif_results, ctx, snippets);
1606    push_component_shape_sarif_results(sarif_results, ctx, snippets);
1607}
1608
1609/// Push SARIF results for unused component props, emits, inputs, and outputs.
1610fn candidate_sarif_region(
1611    snippets: &mut SourceSnippetCache,
1612    path: &Path,
1613    line: u32,
1614    byte_col: u32,
1615) -> serde_json::Value {
1616    let mut region = serde_json::json!({"startLine":line});
1617    if let Some(source) = snippets.line(path, line)
1618        && let Some(prefix) = source.get(..byte_col as usize)
1619    {
1620        // SARIF defaults to UTF-16 code units when the run omits columnKind.
1621        region["startColumn"] = serde_json::json!(prefix.encode_utf16().count() + 1);
1622    }
1623    region
1624}
1625
1626fn push_absent_component_prop_sarif_results(
1627    sarif_results: &mut Vec<serde_json::Value>,
1628    ctx: &SarifCtx<'_>,
1629    snippets: &mut SourceSnippetCache,
1630) {
1631    let start = sarif_results.len();
1632    push_sarif_results(
1633        sarif_results,
1634        &ctx.results.absent_component_props,
1635        snippets,
1636        |finding| {
1637            let prop = &finding.prop;
1638            SarifFields {
1639                rule_id: "fallow/absent-component-prop",
1640                level: finding_level(finding, ctx.rules.absent_component_props),
1641                message: format!(
1642                    "Optional prop '{}.{}' is absent from inspected callers. {}",
1643                    prop.component_name, prop.prop_name, prop.explanation
1644                ),
1645                uri: relative_uri(&prop.path, ctx.root),
1646                region: Some((prop.line, prop.col + 1)),
1647                source_path: Some(prop.path.clone()),
1648                properties: Some(
1649                    serde_json::json!({"framework":prop.framework,"hasDefault":prop.has_default}),
1650                ),
1651            }
1652        },
1653    );
1654    for (result, finding) in sarif_results[start..]
1655        .iter_mut()
1656        .zip(&ctx.results.absent_component_props)
1657    {
1658        result["locations"][0]["physicalLocation"]["region"] = candidate_sarif_region(
1659            snippets,
1660            &finding.prop.path,
1661            finding.prop.line,
1662            finding.prop.col,
1663        );
1664        result["relatedLocations"] = serde_json::Value::Array(finding.prop.inspected_call_sites.iter().enumerate().map(|(index, site)| serde_json::json!({
1665            "id": index + 1,
1666            "message": {"text":"Inspected caller does not supply this optional prop"},
1667            "physicalLocation": {"artifactLocation":{"uri":relative_uri(&site.path,ctx.root)},"region":candidate_sarif_region(snippets,&site.path,site.line,site.col)},
1668        })).collect());
1669    }
1670}
1671
1672fn push_component_member_sarif_results(
1673    sarif_results: &mut Vec<serde_json::Value>,
1674    ctx: &SarifCtx<'_>,
1675    snippets: &mut SourceSnippetCache,
1676) {
1677    let SarifCtx {
1678        results,
1679        root,
1680        rules,
1681    } = *ctx;
1682    push_absent_component_prop_sarif_results(sarif_results, ctx, snippets);
1683
1684    push_sarif_results(
1685        sarif_results,
1686        &results.unused_component_props,
1687        snippets,
1688        |p| {
1689            sarif_unused_component_prop_fields(
1690                &p.prop,
1691                root,
1692                finding_level(p, rules.unused_component_props),
1693            )
1694        },
1695    );
1696    push_sarif_results(
1697        sarif_results,
1698        &results.unused_component_emits,
1699        snippets,
1700        |e| {
1701            sarif_unused_component_emit_fields(
1702                &e.emit,
1703                root,
1704                finding_level(e, rules.unused_component_emits),
1705            )
1706        },
1707    );
1708    push_sarif_results(
1709        sarif_results,
1710        &results.unused_component_inputs,
1711        snippets,
1712        |i| {
1713            sarif_unused_component_input_fields(
1714                &i.input,
1715                root,
1716                finding_level(i, rules.unused_component_inputs),
1717            )
1718        },
1719    );
1720    push_sarif_results(
1721        sarif_results,
1722        &results.unused_component_outputs,
1723        snippets,
1724        |o| {
1725            sarif_unused_component_output_fields(
1726                &o.output,
1727                root,
1728                finding_level(o, rules.unused_component_outputs),
1729            )
1730        },
1731    );
1732}
1733
1734/// Push SARIF results for Svelte events, server actions, and load-data keys.
1735fn push_component_framework_sarif_results(
1736    sarif_results: &mut Vec<serde_json::Value>,
1737    ctx: &SarifCtx<'_>,
1738    snippets: &mut SourceSnippetCache,
1739) {
1740    let SarifCtx {
1741        results,
1742        root,
1743        rules,
1744    } = *ctx;
1745
1746    push_sarif_results(
1747        sarif_results,
1748        &results.unused_svelte_events,
1749        snippets,
1750        |e| {
1751            sarif_unused_svelte_event_fields(
1752                &e.event,
1753                root,
1754                finding_level(e, rules.unused_svelte_events),
1755            )
1756        },
1757    );
1758    push_sarif_results(
1759        sarif_results,
1760        &results.unused_server_actions,
1761        snippets,
1762        |a| {
1763            sarif_unused_server_action_fields(
1764                &a.action,
1765                root,
1766                finding_level(a, rules.unused_server_actions),
1767            )
1768        },
1769    );
1770    push_sarif_results(
1771        sarif_results,
1772        &results.unused_load_data_keys,
1773        snippets,
1774        |k| {
1775            sarif_unused_load_data_key_fields(
1776                &k.key,
1777                root,
1778                finding_level(k, rules.unused_load_data_keys),
1779            )
1780        },
1781    );
1782}
1783
1784/// Push SARIF results for prop drilling, thin wrappers, and duplicate prop shapes.
1785fn push_component_shape_sarif_results(
1786    sarif_results: &mut Vec<serde_json::Value>,
1787    ctx: &SarifCtx<'_>,
1788    snippets: &mut SourceSnippetCache,
1789) {
1790    let SarifCtx {
1791        results,
1792        root,
1793        rules,
1794    } = *ctx;
1795
1796    push_sarif_results(
1797        sarif_results,
1798        &results.prop_drilling_chains,
1799        snippets,
1800        |c| sarif_prop_drilling_fields(&c.chain, root, non_gating_level(c, rules.prop_drilling)),
1801    );
1802    push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1803        sarif_thin_wrapper_fields(&w.wrapper, root, non_gating_level(w, rules.thin_wrapper))
1804    });
1805    push_sarif_results(
1806        sarif_results,
1807        &results.duplicate_prop_shapes,
1808        snippets,
1809        |d| {
1810            sarif_duplicate_prop_shape_fields(
1811                &d.shape,
1812                root,
1813                non_gating_level(d, rules.duplicate_prop_shape),
1814            )
1815        },
1816    );
1817}
1818
1819fn push_graph_sarif_results(
1820    sarif_results: &mut Vec<serde_json::Value>,
1821    ctx: &SarifCtx<'_>,
1822    snippets: &mut SourceSnippetCache,
1823) {
1824    push_structure_sarif_results(sarif_results, ctx, snippets);
1825    push_framework_sarif_results(sarif_results, ctx, snippets);
1826    push_route_sarif_results(sarif_results, ctx, snippets);
1827    push_suppression_sarif_results(sarif_results, ctx, snippets);
1828}
1829
1830fn push_structure_sarif_results(
1831    sarif_results: &mut Vec<serde_json::Value>,
1832    ctx: &SarifCtx<'_>,
1833    snippets: &mut SourceSnippetCache,
1834) {
1835    push_cycle_sarif_results(sarif_results, ctx, snippets);
1836    push_boundary_sarif_results(sarif_results, ctx, snippets);
1837}
1838
1839/// Push SARIF results for circular dependencies, re-export cycles and
1840/// package cycles.
1841fn push_cycle_sarif_results(
1842    sarif_results: &mut Vec<serde_json::Value>,
1843    ctx: &SarifCtx<'_>,
1844    snippets: &mut SourceSnippetCache,
1845) {
1846    let SarifCtx {
1847        results,
1848        root,
1849        rules,
1850    } = *ctx;
1851
1852    push_sarif_results(
1853        sarif_results,
1854        &results.circular_dependencies,
1855        snippets,
1856        |c| {
1857            sarif_circular_dep_fields(
1858                &c.cycle,
1859                root,
1860                finding_level(c, rules.circular_dependencies),
1861            )
1862        },
1863    );
1864    push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1865        sarif_re_export_cycle_fields(&c.cycle, root, finding_level(c, rules.re_export_cycle))
1866    });
1867    push_sarif_results(sarif_results, &results.package_cycles, snippets, |c| {
1868        sarif_package_cycle_fields(&c.cycle, root, finding_level(c, rules.package_cycle))
1869    });
1870}
1871
1872/// Push SARIF results for boundary violations, coverage, calls, and policy violations.
1873fn push_boundary_sarif_results(
1874    sarif_results: &mut Vec<serde_json::Value>,
1875    ctx: &SarifCtx<'_>,
1876    snippets: &mut SourceSnippetCache,
1877) {
1878    let SarifCtx {
1879        results,
1880        root,
1881        rules,
1882    } = *ctx;
1883
1884    push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1885        sarif_boundary_violation_fields(
1886            &v.violation,
1887            root,
1888            finding_level(v, rules.boundary_violation),
1889        )
1890    });
1891    push_sarif_results(
1892        sarif_results,
1893        &results.boundary_coverage_violations,
1894        snippets,
1895        |v| {
1896            sarif_boundary_coverage_fields(
1897                &v.violation,
1898                root,
1899                finding_level(v, rules.boundary_violation),
1900            )
1901        },
1902    );
1903    push_sarif_results(
1904        sarif_results,
1905        &results.boundary_call_violations,
1906        snippets,
1907        |v| {
1908            sarif_boundary_call_fields(
1909                &v.violation,
1910                root,
1911                finding_level(v, rules.boundary_violation),
1912            )
1913        },
1914    );
1915    push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1916        sarif_policy_violation_fields(&v.violation, root)
1917    });
1918}
1919
1920fn push_framework_sarif_results(
1921    sarif_results: &mut Vec<serde_json::Value>,
1922    ctx: &SarifCtx<'_>,
1923    snippets: &mut SourceSnippetCache,
1924) {
1925    push_framework_boundary_sarif_results(sarif_results, ctx, snippets);
1926    push_component_contract_sarif_results(sarif_results, ctx, snippets);
1927}
1928
1929/// Push SARIF results for client exports, barrels, directives, injects, and unrendered components.
1930fn push_framework_boundary_sarif_results(
1931    sarif_results: &mut Vec<serde_json::Value>,
1932    ctx: &SarifCtx<'_>,
1933    snippets: &mut SourceSnippetCache,
1934) {
1935    let SarifCtx {
1936        results,
1937        root,
1938        rules,
1939    } = *ctx;
1940
1941    push_sarif_results(
1942        sarif_results,
1943        &results.invalid_client_exports,
1944        snippets,
1945        |e| {
1946            sarif_invalid_client_export_fields(
1947                &e.export,
1948                root,
1949                finding_level(e, rules.invalid_client_export),
1950            )
1951        },
1952    );
1953    push_sarif_results(
1954        sarif_results,
1955        &results.mixed_client_server_barrels,
1956        snippets,
1957        |b| {
1958            sarif_mixed_client_server_barrel_fields(
1959                &b.barrel,
1960                root,
1961                finding_level(b, rules.mixed_client_server_barrel),
1962            )
1963        },
1964    );
1965    push_sarif_results(
1966        sarif_results,
1967        &results.misplaced_directives,
1968        snippets,
1969        |d| {
1970            sarif_misplaced_directive_fields(
1971                &d.directive_site,
1972                root,
1973                finding_level(d, rules.misplaced_directive),
1974            )
1975        },
1976    );
1977    push_framework_render_sarif_results(sarif_results, ctx, snippets);
1978}
1979
1980fn push_framework_render_sarif_results(
1981    sarif_results: &mut Vec<serde_json::Value>,
1982    ctx: &SarifCtx<'_>,
1983    snippets: &mut SourceSnippetCache,
1984) {
1985    let SarifCtx {
1986        results,
1987        root,
1988        rules,
1989    } = *ctx;
1990
1991    push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
1992        sarif_unprovided_inject_fields(&i.inject, root, finding_level(i, rules.unprovided_injects))
1993    });
1994    push_sarif_results(
1995        sarif_results,
1996        &results.unrendered_components,
1997        snippets,
1998        |c| {
1999            sarif_unrendered_component_fields(
2000                &c.component,
2001                root,
2002                finding_level(c, rules.unrendered_components),
2003            )
2004        },
2005    );
2006}
2007
2008fn push_route_sarif_results(
2009    sarif_results: &mut Vec<serde_json::Value>,
2010    ctx: &SarifCtx<'_>,
2011    snippets: &mut SourceSnippetCache,
2012) {
2013    let SarifCtx {
2014        results,
2015        root,
2016        rules,
2017    } = *ctx;
2018
2019    push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
2020        sarif_route_collision_fields(&c.collision, root, finding_level(c, rules.route_collision))
2021    });
2022    push_sarif_results(
2023        sarif_results,
2024        &results.dynamic_segment_name_conflicts,
2025        snippets,
2026        |c| {
2027            sarif_dynamic_segment_name_conflict_fields(
2028                &c.conflict,
2029                root,
2030                finding_level(c, rules.dynamic_segment_name_conflict),
2031            )
2032        },
2033    );
2034}
2035
2036fn push_suppression_sarif_results(
2037    sarif_results: &mut Vec<serde_json::Value>,
2038    ctx: &SarifCtx<'_>,
2039    snippets: &mut SourceSnippetCache,
2040) {
2041    let SarifCtx {
2042        results,
2043        root,
2044        rules,
2045    } = *ctx;
2046
2047    push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
2048        sarif_stale_suppression_fields(
2049            s,
2050            root,
2051            finding_level(s, stale_suppression_severity(s, rules)),
2052        )
2053    });
2054}
2055
2056fn push_catalog_sarif_results(
2057    sarif_results: &mut Vec<serde_json::Value>,
2058    ctx: &SarifCtx<'_>,
2059    snippets: &mut SourceSnippetCache,
2060) {
2061    push_catalog_entry_sarif_results(sarif_results, ctx, snippets);
2062    push_dependency_override_sarif_results(sarif_results, ctx, snippets);
2063}
2064
2065/// Push SARIF results for unused catalog entries, empty groups, and unresolved references.
2066fn push_catalog_entry_sarif_results(
2067    sarif_results: &mut Vec<serde_json::Value>,
2068    ctx: &SarifCtx<'_>,
2069    snippets: &mut SourceSnippetCache,
2070) {
2071    let SarifCtx {
2072        results,
2073        root,
2074        rules,
2075    } = *ctx;
2076
2077    push_sarif_results(
2078        sarif_results,
2079        &results.unused_catalog_entries,
2080        snippets,
2081        |e| {
2082            sarif_unused_catalog_entry_fields(
2083                e,
2084                root,
2085                finding_level(e, rules.unused_catalog_entries),
2086            )
2087        },
2088    );
2089    push_sarif_results(
2090        sarif_results,
2091        &results.empty_catalog_groups,
2092        snippets,
2093        |g| sarif_empty_catalog_group_fields(g, root, finding_level(g, rules.empty_catalog_groups)),
2094    );
2095    push_sarif_results(
2096        sarif_results,
2097        &results.unresolved_catalog_references,
2098        snippets,
2099        |f| {
2100            sarif_unresolved_catalog_reference_fields(
2101                f,
2102                root,
2103                finding_level(f, rules.unresolved_catalog_references),
2104            )
2105        },
2106    );
2107}
2108
2109/// Push SARIF results for unused and misconfigured dependency overrides.
2110fn push_dependency_override_sarif_results(
2111    sarif_results: &mut Vec<serde_json::Value>,
2112    ctx: &SarifCtx<'_>,
2113    snippets: &mut SourceSnippetCache,
2114) {
2115    let SarifCtx {
2116        results,
2117        root,
2118        rules,
2119    } = *ctx;
2120
2121    push_sarif_results(
2122        sarif_results,
2123        &results.unused_dependency_overrides,
2124        snippets,
2125        |f| {
2126            sarif_unused_dependency_override_fields(
2127                f,
2128                root,
2129                finding_level(f, rules.unused_dependency_overrides),
2130            )
2131        },
2132    );
2133    push_sarif_results(
2134        sarif_results,
2135        &results.misconfigured_dependency_overrides,
2136        snippets,
2137        |f| {
2138            sarif_misconfigured_dependency_override_fields(
2139                f,
2140                root,
2141                finding_level(f, rules.misconfigured_dependency_overrides),
2142            )
2143        },
2144    );
2145}
2146
2147#[cfg(test)]
2148mod tests {
2149    use std::collections::BTreeSet;
2150    use std::path::Path;
2151
2152    use fallow_config::RulesConfig;
2153    use fallow_types::results::AnalysisResults;
2154
2155    use super::*;
2156
2157    fn test_rule_builder(id: &str, description: &str, level: &str) -> serde_json::Value {
2158        serde_json::json!({
2159            "id": id,
2160            "shortDescription": { "text": description },
2161            "defaultConfiguration": { "level": level }
2162        })
2163    }
2164
2165    /// A SARIF consumer reads the message text, not the JSON envelope, so the
2166    /// degraded-parse caveat has to travel in the message. A clean finding
2167    /// keeps the previous text byte-for-byte.
2168    #[test]
2169    fn sarif_messages_name_the_degraded_parse_caveat() {
2170        let mut results = AnalysisResults::default();
2171        results
2172            .unused_files
2173            .push(UnusedFileFinding::with_actions(UnusedFile {
2174                path: Path::new("/p/src/clean.ts").to_path_buf(),
2175            }));
2176        let mut flagged = UnusedFileFinding::with_actions(UnusedFile {
2177            path: Path::new("/p/src/orphan.ts").to_path_buf(),
2178        });
2179        flagged.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2180        results.unused_files.push(flagged);
2181
2182        // Every member array carries the same caveat off the same
2183        // reachability-free access walk, so all three have to reach the
2184        // message. Store members were rendered bare while the array itself was
2185        // stamped.
2186        let member = |parent: &str, name: &str, kind| UnusedMember {
2187            path: Path::new("/p/src/lib.ts").to_path_buf(),
2188            parent_name: parent.to_owned(),
2189            member_name: name.to_owned(),
2190            kind,
2191            line: 7,
2192            col: 2,
2193        };
2194        let mut enum_member = UnusedEnumMemberFinding::with_actions(member(
2195            "Mode",
2196            "Legacy",
2197            fallow_types::extract::MemberKind::EnumMember,
2198        ));
2199        enum_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2200        results.unused_enum_members.push(enum_member);
2201        let mut class_member = UnusedClassMemberFinding::with_actions(member(
2202            "Widget",
2203            "render",
2204            fallow_types::extract::MemberKind::ClassMethod,
2205        ));
2206        class_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2207        results.unused_class_members.push(class_member);
2208        let mut store_member = UnusedStoreMemberFinding::with_actions(member(
2209            "useCart",
2210            "subtotal",
2211            fallow_types::extract::MemberKind::StoreMember,
2212        ));
2213        store_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2214        results.unused_store_members.push(store_member);
2215
2216        let sarif = build_dead_code_sarif(
2217            &results,
2218            Path::new("/p"),
2219            &RulesConfig::default(),
2220            &test_rule_builder,
2221        );
2222        let messages: Vec<String> = sarif
2223            .pointer("/runs/0/results")
2224            .and_then(serde_json::Value::as_array)
2225            .expect("SARIF results")
2226            .iter()
2227            .filter_map(|entry| {
2228                entry
2229                    .pointer("/message/text")
2230                    .and_then(serde_json::Value::as_str)
2231                    .map(str::to_owned)
2232            })
2233            .collect();
2234
2235        assert!(
2236            messages.contains(&"File is not reachable from any entry point".to_owned()),
2237            "a clean finding keeps its exact message: {messages:?}"
2238        );
2239        assert!(
2240            messages.contains(
2241                &"File is not reachable from any entry point (caveat: incomplete import graph)"
2242                    .to_owned()
2243            ),
2244            "a caveated finding names it in the message: {messages:?}"
2245        );
2246        for expected in [
2247            "Enum member 'Mode.Legacy' is never referenced (caveat: incomplete import graph)",
2248            "Class member 'Widget.render' is never referenced (caveat: incomplete import graph)",
2249            "Store member 'useCart.subtotal' is never referenced (caveat: incomplete import graph)",
2250        ] {
2251            assert!(
2252                messages.contains(&expected.to_owned()),
2253                "every member kind names the caveat: {messages:?}"
2254            );
2255        }
2256    }
2257
2258    #[test]
2259    fn root_dependency_message_names_the_workspaces_that_declare_and_import_it() {
2260        let root = Path::new("/project");
2261        let mut results = AnalysisResults::default();
2262        results
2263            .unused_dependencies
2264            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2265                package_name: "left-pad".to_owned(),
2266                location: fallow_types::results::DependencyLocation::Dependencies,
2267                path: root.join("package.json"),
2268                line: 0,
2269                used_in_workspaces: Vec::new(),
2270                declared_and_imported_in: vec![root.join("packages/app")],
2271            }));
2272
2273        let sarif =
2274            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2275        let message = sarif
2276            .pointer("/runs/0/results/0/message/text")
2277            .and_then(serde_json::Value::as_str)
2278            .expect("SARIF message");
2279        assert_eq!(
2280            message,
2281            "Package 'left-pad' is in dependencies but never imported; declared and imported in other workspaces: packages/app"
2282        );
2283    }
2284
2285    /// The reported defect: `npm init -y` writes a `package.json` whose
2286    /// dependency block can sit on one line, and `find_dep_line_in_json` also
2287    /// falls back to line 1 for a key it cannot locate. Every unused dependency
2288    /// then reported the same rule id, the same URI, and the same source
2289    /// snippet, which is the whole of a SARIF fingerprint, so GitHub code
2290    /// scanning showed one alert for all of them. CodeClimate never had the
2291    /// defect: it keys on the package name.
2292    #[test]
2293    fn two_dependencies_on_one_manifest_line_are_two_alerts() {
2294        let dir = tempfile::tempdir().expect("temporary project");
2295        let root = dir.path();
2296        std::fs::write(
2297            root.join("package.json"),
2298            r#"{"name":"compact","dependencies":{"lodash":"^4.17.21","chalk":"^5.3.0"}}"#,
2299        )
2300        .expect("write manifest");
2301
2302        let mut results = AnalysisResults::default();
2303        for name in ["lodash", "chalk"] {
2304            results
2305                .unused_dependencies
2306                .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2307                    package_name: name.to_owned(),
2308                    location: fallow_types::results::DependencyLocation::Dependencies,
2309                    path: root.join("package.json"),
2310                    line: 1,
2311                    used_in_workspaces: Vec::new(),
2312                    declared_and_imported_in: Vec::new(),
2313                }));
2314        }
2315
2316        let sarif =
2317            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2318        let entries = sarif
2319            .pointer("/runs/0/results")
2320            .and_then(serde_json::Value::as_array)
2321            .expect("SARIF results");
2322
2323        let fingerprints = entries
2324            .iter()
2325            .map(|entry| {
2326                entry
2327                    .pointer("/partialFingerprints/tools.fallow.fingerprint~1v1")
2328                    .and_then(serde_json::Value::as_str)
2329                    .expect("fingerprint")
2330            })
2331            .collect::<BTreeSet<_>>();
2332        assert_eq!(
2333            fingerprints.len(),
2334            entries.len(),
2335            "two dependencies declared on one line are two alerts: {entries:#?}"
2336        );
2337
2338        let columns = entries
2339            .iter()
2340            .map(|entry| {
2341                entry
2342                    .pointer("/locations/0/physicalLocation/region/startColumn")
2343                    .and_then(serde_json::Value::as_u64)
2344                    .expect("start column")
2345            })
2346            .collect::<BTreeSet<_>>();
2347        assert_eq!(
2348            columns.len(),
2349            entries.len(),
2350            "each dependency points at its own key in the manifest line: {entries:#?}"
2351        );
2352    }
2353
2354    /// Why the column and not only the run-level uniqueness pass: that pass
2355    /// separates repeats by occurrence index, so a dependency's identity would
2356    /// depend on its siblings and fixing the first one would renumber, and
2357    /// close, the alert on the second. The column is the dependency's own
2358    /// position, so an unrelated dependency added above it moves neither.
2359    #[test]
2360    fn a_dependency_added_above_leaves_the_others_alert_alone() {
2361        let dir = tempfile::tempdir().expect("temporary project");
2362        let root = dir.path();
2363        let manifest = root.join("package.json");
2364
2365        let chalk_fingerprint = |manifest_text: &str, chalk_line: u32| {
2366            std::fs::write(&manifest, manifest_text).expect("write manifest");
2367            let mut results = AnalysisResults::default();
2368            results
2369                .unused_dependencies
2370                .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2371                    package_name: "chalk".to_owned(),
2372                    location: fallow_types::results::DependencyLocation::Dependencies,
2373                    path: manifest.clone(),
2374                    line: chalk_line,
2375                    used_in_workspaces: Vec::new(),
2376                    declared_and_imported_in: Vec::new(),
2377                }));
2378            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder)
2379                .pointer("/runs/0/results/0/partialFingerprints/tools.fallow.fingerprint~1v1")
2380                .and_then(serde_json::Value::as_str)
2381                .expect("chalk fingerprint")
2382                .to_owned()
2383        };
2384
2385        let before = "{\n  \"dependencies\": {\n    \"chalk\": \"^5.3.0\"\n  }\n}\n";
2386        let after = "{\n  \"dependencies\": {\n    \"lodash\": \"^4.17.21\",\n    \"chalk\": \"^5.3.0\"\n  }\n}\n";
2387
2388        assert_eq!(
2389            chalk_fingerprint(before, 3),
2390            chalk_fingerprint(after, 4),
2391            "a dependency declared above it must not move chalk's alert"
2392        );
2393    }
2394
2395    /// `partialFingerprints` is the alert identity GitHub code scanning uses to
2396    /// carry a finding across runs. It is built from rule id plus location (or a
2397    /// normalized snippet), never from the message, so a finding that gains the
2398    /// caveat must keep its fingerprint. If the caveat ever reached the
2399    /// fingerprint inputs, every open alert on a degraded repository would close
2400    /// and reopen as new on the next scan.
2401    #[test]
2402    fn the_caveat_does_not_move_the_sarif_fingerprint() {
2403        let build = |caveated: bool| {
2404            let mut results = AnalysisResults::default();
2405            let mut finding = UnusedFileFinding::with_actions(UnusedFile {
2406                path: Path::new("/p/src/orphan.ts").to_path_buf(),
2407            });
2408            if caveated {
2409                finding.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2410            }
2411            results.unused_files.push(finding);
2412            build_dead_code_sarif(
2413                &results,
2414                Path::new("/p"),
2415                &RulesConfig::default(),
2416                &test_rule_builder,
2417            )
2418        };
2419
2420        let read = |sarif: &serde_json::Value, pointer: &str| {
2421            sarif
2422                .pointer("/runs/0/results")
2423                .and_then(serde_json::Value::as_array)
2424                .expect("SARIF results")
2425                .iter()
2426                .map(|entry| {
2427                    entry
2428                        .pointer(pointer)
2429                        .and_then(serde_json::Value::as_str)
2430                        .expect("SARIF field")
2431                        .to_owned()
2432                })
2433                .collect::<Vec<_>>()
2434        };
2435
2436        let clean = build(false);
2437        let caveated = build(true);
2438
2439        for key in [
2440            "/partialFingerprints/tools.fallow.fingerprint~1v1",
2441            "/partialFingerprints/primaryLocationLineHash~1v1",
2442        ] {
2443            assert_eq!(
2444                read(&clean, key),
2445                read(&caveated, key),
2446                "the caveat must not move {key}"
2447            );
2448        }
2449
2450        assert_ne!(
2451            read(&clean, "/message/text"),
2452            read(&caveated, "/message/text"),
2453            "the guard is only meaningful while the message actually changed"
2454        );
2455    }
2456
2457    #[test]
2458    fn sarif_rule_list_is_backed_by_issue_contracts() {
2459        let sarif = build_dead_code_sarif(
2460            &AnalysisResults::default(),
2461            Path::new("."),
2462            &RulesConfig::default(),
2463            &test_rule_builder,
2464        );
2465        let Some(rules) = sarif
2466            .pointer("/runs/0/tool/driver/rules")
2467            .and_then(serde_json::Value::as_array)
2468        else {
2469            panic!("SARIF document should contain driver rules");
2470        };
2471
2472        let actual_ids = rules
2473            .iter()
2474            .filter_map(|rule| {
2475                rule.get("id")
2476                    .and_then(serde_json::Value::as_str)
2477                    .map(str::to_owned)
2478            })
2479            .collect::<BTreeSet<_>>();
2480        let expected_ids = issue_output_contracts()
2481            .flat_map(|contract| contract.sarif_rule_ids)
2482            .collect::<BTreeSet<_>>();
2483
2484        assert_eq!(actual_ids, expected_ids);
2485
2486        for rule in rules {
2487            let id = rule
2488                .get("id")
2489                .and_then(serde_json::Value::as_str)
2490                .expect("SARIF rule should have id");
2491            let description = rule
2492                .pointer("/shortDescription/text")
2493                .and_then(serde_json::Value::as_str)
2494                .expect("SARIF rule should have short description");
2495            assert_eq!(
2496                description,
2497                issue_sarif_rule_description(id).expect("SARIF rule description should resolve")
2498            );
2499        }
2500    }
2501
2502    const FINDING_ID_POINTER: &str = "/partialFingerprints/fallowFinding~1v1";
2503
2504    /// One finding per single-result kind, plus the two kinds that fan out to
2505    /// one SARIF result per location.
2506    fn identity_results(root: &Path) -> AnalysisResults {
2507        let mut results = AnalysisResults::default();
2508        results
2509            .unused_files
2510            .push(UnusedFileFinding::with_actions(UnusedFile {
2511                path: root.join("src/orphan.ts"),
2512            }));
2513        results
2514            .unused_dependencies
2515            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2516                package_name: "lodash".to_owned(),
2517                location: fallow_types::results::DependencyLocation::Dependencies,
2518                path: root.join("package.json"),
2519                line: 3,
2520                used_in_workspaces: Vec::new(),
2521                declared_and_imported_in: Vec::new(),
2522            }));
2523        results
2524            .unlisted_dependencies
2525            .push(UnlistedDependencyFinding::with_actions(
2526                fallow_types::results::UnlistedDependency {
2527                    package_name: "chalk".to_owned(),
2528                    imported_from: vec![
2529                        fallow_types::results::ImportSite {
2530                            path: root.join("src/a.ts"),
2531                            line: 1,
2532                            col: 0,
2533                        },
2534                        fallow_types::results::ImportSite {
2535                            path: root.join("src/b.ts"),
2536                            line: 2,
2537                            col: 0,
2538                        },
2539                    ],
2540                },
2541            ));
2542        results
2543            .duplicate_exports
2544            .push(DuplicateExportFinding::with_actions(
2545                fallow_types::results::DuplicateExport {
2546                    export_name: "Button".to_owned(),
2547                    locations: vec![
2548                        fallow_types::results::DuplicateLocation {
2549                            path: root.join("src/a.ts"),
2550                            line: 4,
2551                            col: 0,
2552                        },
2553                        fallow_types::results::DuplicateLocation {
2554                            path: root.join("src/b.ts"),
2555                            line: 5,
2556                            col: 0,
2557                        },
2558                    ],
2559                },
2560            ));
2561        results
2562    }
2563
2564    fn sarif_entries(sarif: &serde_json::Value) -> Vec<serde_json::Value> {
2565        sarif
2566            .pointer("/runs/0/results")
2567            .and_then(serde_json::Value::as_array)
2568            .expect("SARIF results")
2569            .clone()
2570    }
2571
2572    /// A finding that maps to one SARIF result carries its `finding_id` under
2573    /// `fallowFinding/v1`, next to the location-based keys.
2574    #[test]
2575    fn a_single_result_finding_carries_its_id_as_a_partial_fingerprint() {
2576        let root = Path::new("/p");
2577        let mut results = identity_results(root);
2578        fallow_types::identity::stamp_dead_code_finding_ids(&mut results, root);
2579        let sarif =
2580            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2581        let entries = sarif_entries(&sarif);
2582
2583        let id_of_rule = |rule: &str| {
2584            entries
2585                .iter()
2586                .find(|entry| entry["ruleId"] == rule)
2587                .and_then(|entry| entry.pointer(FINDING_ID_POINTER))
2588                .and_then(serde_json::Value::as_str)
2589                .map(str::to_owned)
2590        };
2591        assert_eq!(
2592            id_of_rule("fallow/unused-file").as_deref(),
2593            results.unused_files[0].finding_id.as_deref(),
2594        );
2595        assert_eq!(
2596            id_of_rule("fallow/unused-dependency").as_deref(),
2597            results.unused_dependencies[0].finding_id.as_deref(),
2598        );
2599        assert!(
2600            id_of_rule("fallow/unused-file").is_some_and(|id| id.starts_with("dc1:unused-file:")),
2601            "the key holds the finding id: {entries:#?}"
2602        );
2603    }
2604
2605    /// A partial fingerprint must identify one result. An unlisted dependency
2606    /// and a duplicate export give one result per location, so one id would
2607    /// name several results. These results do not carry the key.
2608    #[test]
2609    fn a_fanned_out_finding_does_not_carry_the_id() {
2610        let root = Path::new("/p");
2611        let mut results = identity_results(root);
2612        fallow_types::identity::stamp_dead_code_finding_ids(&mut results, root);
2613        let sarif =
2614            build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2615
2616        for entry in sarif_entries(&sarif) {
2617            let rule = entry["ruleId"].as_str().expect("rule id");
2618            if matches!(
2619                rule,
2620                "fallow/unlisted-dependency" | "fallow/duplicate-export"
2621            ) {
2622                assert!(
2623                    entry.pointer(FINDING_ID_POINTER).is_none(),
2624                    "{rule} fans out and must not carry the id: {entry:#?}"
2625                );
2626            }
2627        }
2628    }
2629
2630    /// GitHub code scanning matches alerts on the location-based keys. The new
2631    /// key must not change any other byte of the document, or open alerts
2632    /// close and reopen on the next upload.
2633    #[test]
2634    fn the_finding_id_key_leaves_every_other_sarif_byte_alone() {
2635        let root = Path::new("/p");
2636        let without_ids = identity_results(root);
2637        let mut with_ids = without_ids.clone();
2638        fallow_types::identity::stamp_dead_code_finding_ids(&mut with_ids, root);
2639
2640        let before = build_dead_code_sarif(
2641            &without_ids,
2642            root,
2643            &RulesConfig::default(),
2644            &test_rule_builder,
2645        );
2646        let mut after =
2647            build_dead_code_sarif(&with_ids, root, &RulesConfig::default(), &test_rule_builder);
2648
2649        let mut removed = 0;
2650        for entry in after
2651            .pointer_mut("/runs/0/results")
2652            .and_then(serde_json::Value::as_array_mut)
2653            .expect("SARIF results")
2654        {
2655            let prints = entry["partialFingerprints"]
2656                .as_object_mut()
2657                .expect("partial fingerprints");
2658            if prints.remove("fallowFinding/v1").is_some() {
2659                removed += 1;
2660            }
2661        }
2662        assert!(
2663            removed > 0,
2664            "the guard needs at least one result with the key"
2665        );
2666        assert_eq!(
2667            serde_json::to_string(&before).expect("serialize"),
2668            serde_json::to_string(&after).expect("serialize"),
2669        );
2670    }
2671
2672    /// A saved envelope from an older version has no ids. The key is then
2673    /// absent, not empty and not null.
2674    #[test]
2675    fn a_finding_without_an_id_has_no_finding_id_key() {
2676        let root = Path::new("/p");
2677        let sarif = build_dead_code_sarif(
2678            &identity_results(root),
2679            root,
2680            &RulesConfig::default(),
2681            &test_rule_builder,
2682        );
2683        for entry in sarif_entries(&sarif) {
2684            assert!(
2685                entry["partialFingerprints"]
2686                    .get("fallowFinding/v1")
2687                    .is_none(),
2688                "no id, no key: {entry:#?}"
2689            );
2690        }
2691    }
2692}