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