1use 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#[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
63fn 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
110fn 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
130fn dep_key(dep: &UnusedDependency) -> (&Path, u32, &str) {
132 (dep.path.as_path(), dep.line, dep.package_name.as_str())
133}
134
135fn 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
152fn 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
190fn 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
212fn 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 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 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
969fn 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
998fn 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
1024fn 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#[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
1236fn 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
1307fn 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
1472fn 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
1485fn 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
1547fn 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
1597fn 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
1658fn 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
1691fn 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
1748fn 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
1892fn 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
1942fn 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 #[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 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 #[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 #[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 #[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}