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