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