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