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
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_effect_layer_fields(
716 layer: &UnusedEffectLayer,
717 root: &Path,
718 level: &'static str,
719) -> SarifFields {
720 SarifFields {
721 rule_id: "fallow/unused-effect-layer",
722 level,
723 message: format!(
724 "layer \"{}\" is composed only into layers that nothing uses ({}); compose it into a live layer or remove it",
725 layer.name,
726 layer.dead_consumers.join(", ")
727 ),
728 uri: relative_uri(&layer.path, root),
729 region: Some((layer.line, layer.col + 1)),
730 source_path: Some(layer.path.clone()),
731 properties: Some(serde_json::json!({
732 "dead_consumers": layer.dead_consumers,
733 })),
734 }
735}
736
737fn sarif_unused_tagged_error_fields(
738 error: &UnusedTaggedError,
739 root: &Path,
740 level: &'static str,
741) -> SarifFields {
742 SarifFields {
743 rule_id: "fallow/unused-tagged-error",
744 level,
745 message: format!(
746 "tagged error \"{}\" (tag \"{}\") is never constructed or failed with; fail with it or remove it and its {} catchTag handler(s)",
747 error.name,
748 error.tag,
749 error.catch_sites.len()
750 ),
751 uri: relative_uri(&error.path, root),
752 region: Some((error.line, error.col + 1)),
753 source_path: Some(error.path.clone()),
754 properties: Some(serde_json::json!({
755 "tag": error.tag,
756 "catch_sites": error
757 .catch_sites
758 .iter()
759 .map(|site| serde_json::json!({
760 "uri": relative_uri(&site.path, root),
761 "line": site.line,
762 "column": site.col + 1,
763 }))
764 .collect::<Vec<_>>(),
765 })),
766 }
767}
768
769fn sarif_unproduced_catch_tag_fields(
770 catch_tag: &UnproducedCatchTag,
771 root: &Path,
772 level: &'static str,
773) -> SarifFields {
774 SarifFields {
775 rule_id: "fallow/unproduced-catch-tag",
776 level,
777 message: format!(
778 "catchTag handler for \"{}\" never runs: no code produces {}; remove the handler or fail with the error",
779 catch_tag.tag,
780 catch_tag.candidate_classes.join(", ")
781 ),
782 uri: relative_uri(&catch_tag.path, root),
783 region: Some((catch_tag.line, catch_tag.col + 1)),
784 source_path: Some(catch_tag.path.clone()),
785 properties: Some(serde_json::json!({
786 "tag": catch_tag.tag,
787 "candidate_classes": catch_tag.candidate_classes,
788 })),
789 }
790}
791
792fn sarif_unused_svelte_event_fields(
793 event: &UnusedSvelteEvent,
794 root: &Path,
795 level: &'static str,
796) -> SarifFields {
797 SarifFields {
798 rule_id: "fallow/unused-svelte-event",
799 level,
800 message: format!(
801 "event \"{}\" is dispatched by component \"{}\" but listened to nowhere in the project; remove it or listen for it",
802 event.event_name, event.component_name
803 ),
804 uri: relative_uri(&event.path, root),
805 region: Some((event.line, event.col + 1)),
806 source_path: Some(event.path.clone()),
807 properties: None,
808 }
809}
810
811fn sarif_unused_component_input_fields(
812 input: &UnusedComponentInput,
813 root: &Path,
814 level: &'static str,
815) -> SarifFields {
816 SarifFields {
817 rule_id: "fallow/unused-component-input",
818 level,
819 message: format!(
820 "input \"{}\" is declared but read nowhere inside component \"{}\"; remove it or use it",
821 input.input_name, input.component_name
822 ),
823 uri: relative_uri(&input.path, root),
824 region: Some((input.line, input.col + 1)),
825 source_path: Some(input.path.clone()),
826 properties: None,
827 }
828}
829
830fn sarif_unused_component_output_fields(
831 output: &UnusedComponentOutput,
832 root: &Path,
833 level: &'static str,
834) -> SarifFields {
835 SarifFields {
836 rule_id: "fallow/unused-component-output",
837 level,
838 message: format!(
839 "output \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
840 output.output_name, output.component_name
841 ),
842 uri: relative_uri(&output.path, root),
843 region: Some((output.line, output.col + 1)),
844 source_path: Some(output.path.clone()),
845 properties: None,
846 }
847}
848
849fn sarif_unused_server_action_fields(
850 action: &UnusedServerAction,
851 root: &Path,
852 level: &'static str,
853) -> SarifFields {
854 SarifFields {
855 rule_id: "fallow/unused-server-action",
856 level,
857 message: format!(
858 "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",
859 action.action_name
860 ),
861 uri: relative_uri(&action.path, root),
862 region: Some((action.line, action.col + 1)),
863 source_path: Some(action.path.clone()),
864 properties: None,
865 }
866}
867
868fn sarif_unused_load_data_key_fields(
869 key: &fallow_types::results::UnusedLoadDataKey,
870 root: &Path,
871 level: &'static str,
872) -> SarifFields {
873 SarifFields {
874 rule_id: "fallow/unused-load-data-key",
875 level,
876 message: format!(
877 "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",
878 key.key_name
879 ),
880 uri: relative_uri(&key.path, root),
881 region: Some((key.line, key.col + 1)),
882 source_path: Some(key.path.clone()),
883 properties: None,
884 }
885}
886
887fn sarif_prop_drilling_fields(
888 chain: &PropDrillingChain,
889 root: &Path,
890 level: &'static str,
891) -> SarifFields {
892 let source = chain.hops.first();
895 let consumer = chain.hops.last();
896 let consumer_name = consumer.map_or("a distant component", |h| h.component.as_str());
897 SarifFields {
898 rule_id: "fallow/prop-drilling",
899 level,
900 message: format!(
901 "prop \"{}\" is forwarded unchanged through {} component(s) before \"{}\" consumes it; colocate, lift to context, or compose",
902 chain.prop, chain.depth, consumer_name
903 ),
904 uri: source.map_or_else(String::new, |hop| relative_uri(&hop.file, root)),
905 region: source.map(|hop| (hop.line, 1)),
906 source_path: source.map(|hop| hop.file.clone()),
907 properties: None,
908 }
909}
910
911fn sarif_thin_wrapper_fields(
912 wrapper: &ThinWrapper,
913 root: &Path,
914 level: &'static str,
915) -> SarifFields {
916 SarifFields {
917 rule_id: "fallow/thin-wrapper",
918 level,
919 message: format!(
920 "\"{}\" is a thin wrapper: its whole body forwards props to \"{}\"; inline it at call sites or delete it",
921 wrapper.component, wrapper.child_component
922 ),
923 uri: relative_uri(&wrapper.file, root),
924 region: Some((wrapper.line, 1)),
925 source_path: Some(wrapper.file.clone()),
926 properties: None,
927 }
928}
929
930fn sarif_duplicate_prop_shape_fields(
931 shape: &DuplicatePropShape,
932 root: &Path,
933 level: &'static str,
934) -> SarifFields {
935 SarifFields {
936 rule_id: "fallow/duplicate-prop-shape",
937 level,
938 message: format!(
939 "\"{}\" shares an identical prop shape {{{}}} with {} other component(s); extract a shared Props type or base component",
940 shape.component,
941 shape.shape.join(", "),
942 shape.group_size.saturating_sub(1)
943 ),
944 uri: relative_uri(&shape.file, root),
945 region: Some((shape.line, 1)),
946 source_path: Some(shape.file.clone()),
947 properties: None,
948 }
949}
950
951fn sarif_route_collision_fields(
952 collision: &RouteCollision,
953 root: &Path,
954 level: &'static str,
955) -> SarifFields {
956 SarifFields {
957 rule_id: "fallow/route-collision",
958 level,
959 message: format!(
960 "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",
961 collision.url,
962 collision.conflicting_paths.len()
963 ),
964 uri: relative_uri(&collision.path, root),
965 region: Some((collision.line, collision.col + 1)),
966 source_path: Some(collision.path.clone()),
967 properties: None,
968 }
969}
970
971fn sarif_dynamic_segment_name_conflict_fields(
972 conflict: &DynamicSegmentNameConflict,
973 root: &Path,
974 level: &'static str,
975) -> SarifFields {
976 SarifFields {
977 rule_id: "fallow/dynamic-segment-name-conflict",
978 level,
979 message: format!(
980 "Dynamic segments at '{}' use different slug names ({}); Next.js requires one consistent name per dynamic path",
981 conflict.position,
982 conflict.conflicting_segments.join(", ")
983 ),
984 uri: relative_uri(&conflict.path, root),
985 region: Some((conflict.line, conflict.col + 1)),
986 source_path: Some(conflict.path.clone()),
987 properties: None,
988 }
989}
990
991fn sarif_stale_suppression_fields(
992 suppression: &StaleSuppression,
993 root: &Path,
994 level: &'static str,
995) -> SarifFields {
996 SarifFields {
997 rule_id: if suppression.missing_reason {
998 "fallow/missing-suppression-reason"
999 } else {
1000 "fallow/stale-suppression"
1001 },
1002 level,
1003 message: suppression.display_message(),
1004 uri: relative_uri(&suppression.path, root),
1005 region: Some((suppression.line, suppression.col + 1)),
1006 source_path: Some(suppression.path.clone()),
1007 properties: None,
1008 }
1009}
1010
1011fn stale_suppression_severity(suppression: &StaleSuppression, rules: &RulesConfig) -> Severity {
1012 if suppression.missing_reason {
1013 rules.require_suppression_reason
1014 } else {
1015 rules.stale_suppressions
1016 }
1017}
1018
1019fn sarif_unused_catalog_entry_fields(
1020 entry: &UnusedCatalogEntryFinding,
1021 root: &Path,
1022 level: &'static str,
1023) -> SarifFields {
1024 let entry = &entry.entry;
1025 let message = if entry.catalog_name == "default" {
1026 format!(
1027 "Catalog entry '{}' is not referenced by any workspace package",
1028 entry.entry_name
1029 )
1030 } else {
1031 format!(
1032 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
1033 entry.entry_name, entry.catalog_name
1034 )
1035 };
1036 SarifFields {
1037 rule_id: "fallow/unused-catalog-entry",
1038 level,
1039 message,
1040 uri: relative_uri(&entry.path, root),
1041 region: Some((entry.line, 1)),
1042 source_path: Some(entry.path.clone()),
1043 properties: None,
1044 }
1045}
1046
1047fn sarif_unused_dependency_override_fields(
1048 finding: &UnusedDependencyOverrideFinding,
1049 root: &Path,
1050 level: &'static str,
1051) -> SarifFields {
1052 let finding = &finding.entry;
1053 let mut message = format!(
1054 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in the lockfile",
1055 finding.raw_key, finding.version_range, finding.target_package,
1056 );
1057 if let Some(hint) = &finding.hint {
1058 use std::fmt::Write as _;
1059 let _ = write!(message, " ({hint})");
1060 }
1061 SarifFields {
1062 rule_id: "fallow/unused-dependency-override",
1063 level,
1064 message,
1065 uri: relative_uri(&finding.path, root),
1066 region: Some((finding.line, 1)),
1067 source_path: Some(finding.path.clone()),
1068 properties: None,
1069 }
1070}
1071
1072fn sarif_misconfigured_dependency_override_fields(
1073 finding: &MisconfiguredDependencyOverrideFinding,
1074 root: &Path,
1075 level: &'static str,
1076) -> SarifFields {
1077 let finding = &finding.entry;
1078 let message = format!(
1079 "Override `{}` -> `{}` is malformed: {}",
1080 finding.raw_key,
1081 finding.raw_value,
1082 finding.reason.describe(),
1083 );
1084 SarifFields {
1085 rule_id: "fallow/misconfigured-dependency-override",
1086 level,
1087 message,
1088 uri: relative_uri(&finding.path, root),
1089 region: Some((finding.line, 1)),
1090 source_path: Some(finding.path.clone()),
1091 properties: None,
1092 }
1093}
1094
1095fn sarif_unresolved_catalog_reference_fields(
1096 finding: &UnresolvedCatalogReferenceFinding,
1097 root: &Path,
1098 level: &'static str,
1099) -> SarifFields {
1100 let finding = &finding.reference;
1101 let catalog_phrase = if finding.catalog_name == "default" {
1102 "the default catalog".to_string()
1103 } else {
1104 format!("catalog '{}'", finding.catalog_name)
1105 };
1106 let mut message = format!(
1107 "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
1108 finding.entry_name,
1109 if finding.catalog_name == "default" {
1110 ""
1111 } else {
1112 finding.catalog_name.as_str()
1113 },
1114 catalog_phrase,
1115 );
1116 if !finding.available_in_catalogs.is_empty() {
1117 use std::fmt::Write as _;
1118 let _ = write!(
1119 message,
1120 " (available in: {})",
1121 finding.available_in_catalogs.join(", ")
1122 );
1123 }
1124 SarifFields {
1125 rule_id: "fallow/unresolved-catalog-reference",
1126 level,
1127 message,
1128 uri: relative_uri(&finding.path, root),
1129 region: Some((finding.line, 1)),
1130 source_path: Some(finding.path.clone()),
1131 properties: None,
1132 }
1133}
1134
1135fn sarif_empty_catalog_group_fields(
1136 group: &EmptyCatalogGroupFinding,
1137 root: &Path,
1138 level: &'static str,
1139) -> SarifFields {
1140 let group = &group.group;
1141 SarifFields {
1142 rule_id: "fallow/empty-catalog-group",
1143 level,
1144 message: format!("Catalog group '{}' has no entries", group.catalog_name),
1145 uri: relative_uri(&group.path, root),
1146 region: Some((group.line, 1)),
1147 source_path: Some(group.path.clone()),
1148 properties: None,
1149 }
1150}
1151
1152fn push_sarif_unlisted_deps(
1155 sarif_results: &mut Vec<serde_json::Value>,
1156 deps: &[UnlistedDependencyFinding],
1157 root: &Path,
1158 rule: Severity,
1159 snippets: &mut SourceSnippetCache,
1160) {
1161 for entry in deps {
1162 let level = finding_level(entry, rule);
1163 let dep = &entry.dep;
1164 for site in &dep.imported_from {
1165 let uri = relative_uri(&site.path, root);
1166 let source_snippet = snippets.line(&site.path, site.line);
1167 sarif_results.push(sarif_result_with_snippet(
1168 "fallow/unlisted-dependency",
1169 level,
1170 &format!(
1171 "Package '{}' is imported but not listed in package.json",
1172 dep.package_name
1173 ),
1174 &uri,
1175 Some((site.line, site.col + 1)),
1176 source_snippet.as_deref(),
1177 ));
1178 }
1179 }
1180}
1181
1182fn push_sarif_duplicate_exports(
1185 sarif_results: &mut Vec<serde_json::Value>,
1186 dups: &[DuplicateExportFinding],
1187 root: &Path,
1188 rule: Severity,
1189 snippets: &mut SourceSnippetCache,
1190) {
1191 for dup in dups {
1192 let level = finding_level(dup, rule);
1193 let dup = &dup.export;
1194 for loc in &dup.locations {
1195 let uri = relative_uri(&loc.path, root);
1196 let source_snippet = snippets.line(&loc.path, loc.line);
1197 sarif_results.push(sarif_result_with_snippet(
1198 "fallow/duplicate-export",
1199 level,
1200 &format!("Export '{}' appears in multiple modules", dup.export_name),
1201 &uri,
1202 Some((loc.line, loc.col + 1)),
1203 source_snippet.as_deref(),
1204 ));
1205 }
1206 }
1207}
1208
1209fn build_sarif_rules(
1211 rules: &RulesConfig,
1212 rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1213) -> Vec<serde_json::Value> {
1214 let mut sarif_rules = Vec::new();
1215 for contract in issue_output_contracts() {
1216 for rule_id in contract.sarif_rule_ids {
1217 let severity = sarif_rule_severity(rules, contract.code, &rule_id);
1218 let description = issue_sarif_rule_description(&rule_id).unwrap_or_else(|| {
1219 panic!("dead-code SARIF rule {rule_id} is missing issue metadata")
1220 });
1221 sarif_rules.push(rule_builder(
1222 &rule_id,
1223 description,
1224 configured_sarif_level(severity),
1225 ));
1226 }
1227 }
1228 sarif_rules
1229}
1230
1231fn sarif_rule_severity(rules: &RulesConfig, issue_code: &str, rule_id: &str) -> Severity {
1232 if rule_id == "fallow/missing-suppression-reason" {
1233 return rules.require_suppression_reason;
1234 }
1235 dead_code_rule_severity(rules, issue_code)
1236 .unwrap_or_else(|| panic!("dead-code SARIF rule {rule_id} has no severity mapping"))
1237}
1238
1239fn dead_code_rule_severity(rules: &RulesConfig, issue_code: &str) -> Option<Severity> {
1240 let severity = match issue_code {
1241 "unused-file" => rules.unused_files,
1242 "unused-export" => rules.unused_exports,
1243 "unused-type" => rules.unused_types,
1244 "private-type-leak" => rules.private_type_leaks,
1245 "deprecated-export-in-use" => rules.deprecated_exports_in_use,
1246 "unused-dependency" => rules.unused_dependencies,
1247 "unused-dev-dependency" => rules.unused_dev_dependencies,
1248 "unused-optional-dependency" => rules.unused_optional_dependencies,
1249 "type-only-dependency" => rules.type_only_dependencies,
1250 "test-only-dependency" => rules.test_only_dependencies,
1251 "dev-dependency-in-production" => rules.dev_dependencies_in_production,
1252 "unused-enum-member" => rules.unused_enum_members,
1253 "unused-class-member" => rules.unused_class_members,
1254 "unused-store-member" => rules.unused_store_members,
1255 "unresolved-import" => rules.unresolved_imports,
1256 "unlisted-dependency" => rules.unlisted_dependencies,
1257 "duplicate-export" => rules.duplicate_exports,
1258 "circular-dependency" => rules.circular_dependencies,
1259 "re-export-cycle" => rules.re_export_cycle,
1260 "package-cycle" => rules.package_cycle,
1261 "boundary-violation" | "boundary-coverage" | "boundary-call-violation" => {
1262 rules.boundary_violation
1263 }
1264 "policy-violation" => rules.policy_violation,
1265 "invalid-client-export" => rules.invalid_client_export,
1266 "mixed-client-server-barrel" => rules.mixed_client_server_barrel,
1267 "misplaced-directive" => rules.misplaced_directive,
1268 "unprovided-inject" => rules.unprovided_injects,
1269 "unrendered-component" => rules.unrendered_components,
1270 "unused-component-prop" => rules.unused_component_props,
1271 "absent-component-prop" => rules.absent_component_props,
1272 "unused-component-emit" => rules.unused_component_emits,
1273 "unused-component-input" => rules.unused_component_inputs,
1274 "unused-component-output" => rules.unused_component_outputs,
1275 "unused-svelte-event" => rules.unused_svelte_events,
1276 "unused-effect-layer" => rules.unused_effect_layers,
1277 "unused-tagged-error" => rules.unused_tagged_errors,
1278 "unproduced-catch-tag" => rules.unproduced_catch_tags,
1279 "unused-server-action" => rules.unused_server_actions,
1280 "unused-load-data-key" => rules.unused_load_data_keys,
1281 "prop-drilling" => rules.prop_drilling,
1282 "thin-wrapper" => non_gating_severity(rules.thin_wrapper),
1283 "duplicate-prop-shape" => non_gating_severity(rules.duplicate_prop_shape),
1284 "route-collision" => rules.route_collision,
1285 "dynamic-segment-name-conflict" => rules.dynamic_segment_name_conflict,
1286 "stale-suppression" => rules.stale_suppressions,
1287 "unused-catalog-entry" => rules.unused_catalog_entries,
1288 "empty-catalog-group" => rules.empty_catalog_groups,
1289 "unresolved-catalog-reference" => rules.unresolved_catalog_references,
1290 "unused-dependency-override" => rules.unused_dependency_overrides,
1291 "misconfigured-dependency-override" => rules.misconfigured_dependency_overrides,
1292 _ => return None,
1293 };
1294 Some(severity)
1295}
1296
1297#[must_use]
1304pub fn build_dead_code_sarif(
1305 results: &AnalysisResults,
1306 root: &Path,
1307 rules: &RulesConfig,
1308 rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1309) -> serde_json::Value {
1310 let mut sarif_results = Vec::new();
1311 let mut snippets = SourceSnippetCache::with_root(root);
1312 let ctx = SarifCtx {
1313 results,
1314 root,
1315 rules,
1316 };
1317
1318 push_primary_dead_code_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1319 push_dependency_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1320 push_member_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1321 push_sarif_results(
1322 &mut sarif_results,
1323 &results.unresolved_imports,
1324 &mut snippets,
1325 |i| {
1326 sarif_unresolved_import_fields(
1327 &i.import,
1328 root,
1329 finding_level(i, rules.unresolved_imports),
1330 )
1331 },
1332 );
1333 push_misc_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1334 push_graph_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1335 push_catalog_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1336
1337 let sarif_rules = build_sarif_rules(rules, rule_builder);
1338 let mut sarif = sarif_document(&sarif_results, &sarif_rules);
1339 annotate_cascade_hidden(&mut sarif, results.cascade_hidden);
1340 sarif
1341}
1342
1343fn annotate_cascade_hidden(sarif: &mut serde_json::Value, hidden: usize) {
1347 if hidden == 0 {
1348 return;
1349 }
1350 let Some(run) = sarif
1351 .get_mut("runs")
1352 .and_then(serde_json::Value::as_array_mut)
1353 .and_then(|runs| runs.first_mut())
1354 .and_then(serde_json::Value::as_object_mut)
1355 else {
1356 return;
1357 };
1358 let properties = run
1359 .entry("properties")
1360 .or_insert_with(|| serde_json::json!({}));
1361 if let Some(properties) = properties.as_object_mut() {
1362 properties.insert("cascadeHidden".to_owned(), serde_json::json!(hidden));
1363 }
1364}
1365
1366fn push_primary_dead_code_sarif_results(
1367 sarif_results: &mut Vec<serde_json::Value>,
1368 ctx: &SarifCtx<'_>,
1369 snippets: &mut SourceSnippetCache,
1370) {
1371 let SarifCtx {
1372 results,
1373 root,
1374 rules,
1375 } = *ctx;
1376
1377 push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1378 with_caveats(
1379 sarif_unused_file_fields(
1380 &finding.file,
1381 root,
1382 finding_level(finding, rules.unused_files),
1383 ),
1384 &finding.reachability_caveats,
1385 )
1386 });
1387 push_sarif_results(
1388 sarif_results,
1389 &results.unused_exports,
1390 snippets,
1391 |finding| {
1392 with_caveats(
1393 sarif_export_fields(
1394 &finding.export,
1395 root,
1396 "fallow/unused-export",
1397 finding_level(finding, rules.unused_exports),
1398 "Export",
1399 "Re-export",
1400 ),
1401 &finding.reachability_caveats,
1402 )
1403 },
1404 );
1405 push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1406 with_caveats(
1407 sarif_export_fields(
1408 &finding.export,
1409 root,
1410 "fallow/unused-type",
1411 finding_level(finding, rules.unused_types),
1412 "Type export",
1413 "Type re-export",
1414 ),
1415 &finding.reachability_caveats,
1416 )
1417 });
1418 push_sarif_results(
1419 sarif_results,
1420 &results.private_type_leaks,
1421 snippets,
1422 |finding| {
1423 sarif_private_type_leak_fields(
1424 &finding.leak,
1425 root,
1426 finding_level(finding, rules.private_type_leaks),
1427 )
1428 },
1429 );
1430 push_sarif_results(
1431 sarif_results,
1432 &results.deprecated_exports_in_use,
1433 snippets,
1434 |finding| {
1435 sarif_deprecated_export_fields(
1436 &finding.export,
1437 root,
1438 finding_level(finding, rules.deprecated_exports_in_use),
1439 )
1440 },
1441 );
1442}
1443
1444fn sarif_document(
1445 sarif_results: &[serde_json::Value],
1446 sarif_rules: &[serde_json::Value],
1447) -> serde_json::Value {
1448 build_sarif_document(SarifDocumentInput {
1449 results: sarif_results,
1450 rules: sarif_rules,
1451 tool_version: env!("CARGO_PKG_VERSION"),
1452 })
1453}
1454
1455fn push_dependency_sarif_results(
1456 sarif_results: &mut Vec<serde_json::Value>,
1457 ctx: &SarifCtx<'_>,
1458 snippets: &mut SourceSnippetCache,
1459) {
1460 push_unused_dependency_sarif_results(sarif_results, ctx, snippets);
1461 push_classified_dependency_sarif_results(sarif_results, ctx, snippets);
1462}
1463
1464fn push_unused_dependency_sarif_results(
1466 sarif_results: &mut Vec<serde_json::Value>,
1467 ctx: &SarifCtx<'_>,
1468 snippets: &mut SourceSnippetCache,
1469) {
1470 let SarifCtx {
1471 results,
1472 root,
1473 rules,
1474 } = *ctx;
1475
1476 let mut columns =
1477 manifest_key_columns(&results.unused_dependencies, snippets, |f| dep_key(&f.dep));
1478 push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1479 with_caveats(
1480 sarif_dep_fields(
1481 &d.dep,
1482 root,
1483 "fallow/unused-dependency",
1484 finding_level(d, rules.unused_dependencies),
1485 "dependencies",
1486 columns.next().unwrap_or(1),
1487 ),
1488 &d.reachability_caveats,
1489 )
1490 });
1491 let mut columns = manifest_key_columns(&results.unused_dev_dependencies, snippets, |f| {
1492 dep_key(&f.dep)
1493 });
1494 push_sarif_results(
1495 sarif_results,
1496 &results.unused_dev_dependencies,
1497 snippets,
1498 |d| {
1499 with_caveats(
1500 sarif_dep_fields(
1501 &d.dep,
1502 root,
1503 "fallow/unused-dev-dependency",
1504 finding_level(d, rules.unused_dev_dependencies),
1505 "devDependencies",
1506 columns.next().unwrap_or(1),
1507 ),
1508 &d.reachability_caveats,
1509 )
1510 },
1511 );
1512 let mut columns = manifest_key_columns(&results.unused_optional_dependencies, snippets, |f| {
1513 dep_key(&f.dep)
1514 });
1515 push_sarif_results(
1516 sarif_results,
1517 &results.unused_optional_dependencies,
1518 snippets,
1519 |d| {
1520 with_caveats(
1521 sarif_dep_fields(
1522 &d.dep,
1523 root,
1524 "fallow/unused-optional-dependency",
1525 finding_level(d, rules.unused_optional_dependencies),
1526 "optionalDependencies",
1527 columns.next().unwrap_or(1),
1528 ),
1529 &d.reachability_caveats,
1530 )
1531 },
1532 );
1533}
1534
1535fn push_classified_dependency_sarif_results(
1537 sarif_results: &mut Vec<serde_json::Value>,
1538 ctx: &SarifCtx<'_>,
1539 snippets: &mut SourceSnippetCache,
1540) {
1541 let SarifCtx {
1542 results,
1543 root,
1544 rules,
1545 } = *ctx;
1546
1547 let mut columns = manifest_key_columns(&results.type_only_dependencies, snippets, |f| {
1548 (
1549 f.dep.path.as_path(),
1550 f.dep.line,
1551 f.dep.package_name.as_str(),
1552 )
1553 });
1554 push_sarif_results(
1555 sarif_results,
1556 &results.type_only_dependencies,
1557 snippets,
1558 |d| {
1559 sarif_type_only_dep_fields(
1560 &d.dep,
1561 root,
1562 finding_level(d, rules.type_only_dependencies),
1563 columns.next().unwrap_or(1),
1564 )
1565 },
1566 );
1567 let mut columns = manifest_key_columns(&results.test_only_dependencies, snippets, |f| {
1568 (
1569 f.dep.path.as_path(),
1570 f.dep.line,
1571 f.dep.package_name.as_str(),
1572 )
1573 });
1574 push_sarif_results(
1575 sarif_results,
1576 &results.test_only_dependencies,
1577 snippets,
1578 |d| {
1579 sarif_test_only_dep_fields(
1580 &d.dep,
1581 root,
1582 finding_level(d, rules.test_only_dependencies),
1583 columns.next().unwrap_or(1),
1584 )
1585 },
1586 );
1587 let mut columns =
1588 manifest_key_columns(&results.dev_dependencies_in_production, snippets, |f| {
1589 (
1590 f.dep.path.as_path(),
1591 f.dep.line,
1592 f.dep.package_name.as_str(),
1593 )
1594 });
1595 push_sarif_results(
1596 sarif_results,
1597 &results.dev_dependencies_in_production,
1598 snippets,
1599 |d| {
1600 sarif_dev_dep_in_prod_fields(
1601 &d.dep,
1602 root,
1603 finding_level(d, rules.dev_dependencies_in_production),
1604 columns.next().unwrap_or(1),
1605 )
1606 },
1607 );
1608}
1609
1610fn push_member_sarif_results(
1611 sarif_results: &mut Vec<serde_json::Value>,
1612 ctx: &SarifCtx<'_>,
1613 snippets: &mut SourceSnippetCache,
1614) {
1615 let SarifCtx {
1616 results,
1617 root,
1618 rules,
1619 } = *ctx;
1620
1621 push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1622 with_caveats(
1623 sarif_member_fields(
1624 &m.member,
1625 root,
1626 "fallow/unused-enum-member",
1627 finding_level(m, rules.unused_enum_members),
1628 "Enum",
1629 ),
1630 &m.reachability_caveats,
1631 )
1632 });
1633 push_sarif_results(
1634 sarif_results,
1635 &results.unused_class_members,
1636 snippets,
1637 |m| {
1638 with_caveats(
1639 sarif_member_fields(
1640 &m.member,
1641 root,
1642 "fallow/unused-class-member",
1643 finding_level(m, rules.unused_class_members),
1644 "Class",
1645 ),
1646 &m.reachability_caveats,
1647 )
1648 },
1649 );
1650 push_sarif_results(
1651 sarif_results,
1652 &results.unused_store_members,
1653 snippets,
1654 |m| {
1655 with_caveats(
1656 sarif_member_fields(
1657 &m.member,
1658 root,
1659 "fallow/unused-store-member",
1660 finding_level(m, rules.unused_store_members),
1661 "Store",
1662 ),
1663 &m.reachability_caveats,
1664 )
1665 },
1666 );
1667}
1668
1669fn push_misc_sarif_results(
1670 sarif_results: &mut Vec<serde_json::Value>,
1671 ctx: &SarifCtx<'_>,
1672 snippets: &mut SourceSnippetCache,
1673) {
1674 let SarifCtx {
1675 results,
1676 root,
1677 rules,
1678 } = *ctx;
1679
1680 if !results.unlisted_dependencies.is_empty() {
1681 push_sarif_unlisted_deps(
1682 sarif_results,
1683 &results.unlisted_dependencies,
1684 root,
1685 rules.unlisted_dependencies,
1686 snippets,
1687 );
1688 }
1689 if !results.duplicate_exports.is_empty() {
1690 push_sarif_duplicate_exports(
1691 sarif_results,
1692 &results.duplicate_exports,
1693 root,
1694 rules.duplicate_exports,
1695 snippets,
1696 );
1697 }
1698}
1699
1700fn push_component_contract_sarif_results(
1704 sarif_results: &mut Vec<serde_json::Value>,
1705 ctx: &SarifCtx<'_>,
1706 snippets: &mut SourceSnippetCache,
1707) {
1708 push_component_member_sarif_results(sarif_results, ctx, snippets);
1709 push_component_framework_sarif_results(sarif_results, ctx, snippets);
1710 push_component_shape_sarif_results(sarif_results, ctx, snippets);
1711}
1712
1713fn candidate_sarif_region(
1715 snippets: &mut SourceSnippetCache,
1716 path: &Path,
1717 line: u32,
1718 byte_col: u32,
1719) -> serde_json::Value {
1720 let mut region = serde_json::json!({"startLine":line});
1721 if let Some(source) = snippets.line(path, line)
1722 && let Some(prefix) = source.get(..byte_col as usize)
1723 {
1724 region["startColumn"] = serde_json::json!(prefix.encode_utf16().count() + 1);
1726 }
1727 region
1728}
1729
1730fn push_absent_component_prop_sarif_results(
1731 sarif_results: &mut Vec<serde_json::Value>,
1732 ctx: &SarifCtx<'_>,
1733 snippets: &mut SourceSnippetCache,
1734) {
1735 let start = sarif_results.len();
1736 push_sarif_results(
1737 sarif_results,
1738 &ctx.results.absent_component_props,
1739 snippets,
1740 |finding| {
1741 let prop = &finding.prop;
1742 SarifFields {
1743 rule_id: "fallow/absent-component-prop",
1744 level: finding_level(finding, ctx.rules.absent_component_props),
1745 message: format!(
1746 "Optional prop '{}.{}' is absent from inspected callers. {}",
1747 prop.component_name, prop.prop_name, prop.explanation
1748 ),
1749 uri: relative_uri(&prop.path, ctx.root),
1750 region: Some((prop.line, prop.col + 1)),
1751 source_path: Some(prop.path.clone()),
1752 properties: Some(
1753 serde_json::json!({"framework":prop.framework,"hasDefault":prop.has_default}),
1754 ),
1755 }
1756 },
1757 );
1758 for (result, finding) in sarif_results[start..]
1759 .iter_mut()
1760 .zip(&ctx.results.absent_component_props)
1761 {
1762 result["locations"][0]["physicalLocation"]["region"] = candidate_sarif_region(
1763 snippets,
1764 &finding.prop.path,
1765 finding.prop.line,
1766 finding.prop.col,
1767 );
1768 result["relatedLocations"] = serde_json::Value::Array(finding.prop.inspected_call_sites.iter().enumerate().map(|(index, site)| serde_json::json!({
1769 "id": index + 1,
1770 "message": {"text":"Inspected caller does not supply this optional prop"},
1771 "physicalLocation": {"artifactLocation":{"uri":relative_uri(&site.path,ctx.root)},"region":candidate_sarif_region(snippets,&site.path,site.line,site.col)},
1772 })).collect());
1773 }
1774}
1775
1776fn push_component_member_sarif_results(
1777 sarif_results: &mut Vec<serde_json::Value>,
1778 ctx: &SarifCtx<'_>,
1779 snippets: &mut SourceSnippetCache,
1780) {
1781 let SarifCtx {
1782 results,
1783 root,
1784 rules,
1785 } = *ctx;
1786 push_absent_component_prop_sarif_results(sarif_results, ctx, snippets);
1787
1788 push_sarif_results(
1789 sarif_results,
1790 &results.unused_component_props,
1791 snippets,
1792 |p| {
1793 sarif_unused_component_prop_fields(
1794 &p.prop,
1795 root,
1796 finding_level(p, rules.unused_component_props),
1797 )
1798 },
1799 );
1800 push_sarif_results(
1801 sarif_results,
1802 &results.unused_component_emits,
1803 snippets,
1804 |e| {
1805 sarif_unused_component_emit_fields(
1806 &e.emit,
1807 root,
1808 finding_level(e, rules.unused_component_emits),
1809 )
1810 },
1811 );
1812 push_sarif_results(
1813 sarif_results,
1814 &results.unused_component_inputs,
1815 snippets,
1816 |i| {
1817 sarif_unused_component_input_fields(
1818 &i.input,
1819 root,
1820 finding_level(i, rules.unused_component_inputs),
1821 )
1822 },
1823 );
1824 push_sarif_results(
1825 sarif_results,
1826 &results.unused_component_outputs,
1827 snippets,
1828 |o| {
1829 sarif_unused_component_output_fields(
1830 &o.output,
1831 root,
1832 finding_level(o, rules.unused_component_outputs),
1833 )
1834 },
1835 );
1836}
1837
1838fn push_component_framework_sarif_results(
1840 sarif_results: &mut Vec<serde_json::Value>,
1841 ctx: &SarifCtx<'_>,
1842 snippets: &mut SourceSnippetCache,
1843) {
1844 let SarifCtx {
1845 results,
1846 root,
1847 rules,
1848 } = *ctx;
1849
1850 push_sarif_results(
1851 sarif_results,
1852 &results.unused_svelte_events,
1853 snippets,
1854 |e| {
1855 sarif_unused_svelte_event_fields(
1856 &e.event,
1857 root,
1858 finding_level(e, rules.unused_svelte_events),
1859 )
1860 },
1861 );
1862 push_sarif_results(
1863 sarif_results,
1864 &results.unused_effect_layers,
1865 snippets,
1866 |e| {
1867 sarif_unused_effect_layer_fields(
1868 &e.layer,
1869 root,
1870 finding_level(e, rules.unused_effect_layers),
1871 )
1872 },
1873 );
1874 push_sarif_results(
1875 sarif_results,
1876 &results.unused_tagged_errors,
1877 snippets,
1878 |e| {
1879 sarif_unused_tagged_error_fields(
1880 &e.error,
1881 root,
1882 finding_level(e, rules.unused_tagged_errors),
1883 )
1884 },
1885 );
1886 push_sarif_results(
1887 sarif_results,
1888 &results.unproduced_catch_tags,
1889 snippets,
1890 |e| {
1891 sarif_unproduced_catch_tag_fields(
1892 &e.catch_tag,
1893 root,
1894 finding_level(e, rules.unproduced_catch_tags),
1895 )
1896 },
1897 );
1898 push_sarif_results(
1899 sarif_results,
1900 &results.unused_server_actions,
1901 snippets,
1902 |a| {
1903 sarif_unused_server_action_fields(
1904 &a.action,
1905 root,
1906 finding_level(a, rules.unused_server_actions),
1907 )
1908 },
1909 );
1910 push_sarif_results(
1911 sarif_results,
1912 &results.unused_load_data_keys,
1913 snippets,
1914 |k| {
1915 sarif_unused_load_data_key_fields(
1916 &k.key,
1917 root,
1918 finding_level(k, rules.unused_load_data_keys),
1919 )
1920 },
1921 );
1922}
1923
1924fn push_component_shape_sarif_results(
1926 sarif_results: &mut Vec<serde_json::Value>,
1927 ctx: &SarifCtx<'_>,
1928 snippets: &mut SourceSnippetCache,
1929) {
1930 let SarifCtx {
1931 results,
1932 root,
1933 rules,
1934 } = *ctx;
1935
1936 push_sarif_results(
1937 sarif_results,
1938 &results.prop_drilling_chains,
1939 snippets,
1940 |c| sarif_prop_drilling_fields(&c.chain, root, finding_level(c, rules.prop_drilling)),
1941 );
1942 push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1943 sarif_thin_wrapper_fields(&w.wrapper, root, non_gating_level(w, rules.thin_wrapper))
1944 });
1945 push_sarif_results(
1946 sarif_results,
1947 &results.duplicate_prop_shapes,
1948 snippets,
1949 |d| {
1950 sarif_duplicate_prop_shape_fields(
1951 &d.shape,
1952 root,
1953 non_gating_level(d, rules.duplicate_prop_shape),
1954 )
1955 },
1956 );
1957}
1958
1959fn push_graph_sarif_results(
1960 sarif_results: &mut Vec<serde_json::Value>,
1961 ctx: &SarifCtx<'_>,
1962 snippets: &mut SourceSnippetCache,
1963) {
1964 push_structure_sarif_results(sarif_results, ctx, snippets);
1965 push_framework_sarif_results(sarif_results, ctx, snippets);
1966 push_route_sarif_results(sarif_results, ctx, snippets);
1967 push_suppression_sarif_results(sarif_results, ctx, snippets);
1968}
1969
1970fn push_structure_sarif_results(
1971 sarif_results: &mut Vec<serde_json::Value>,
1972 ctx: &SarifCtx<'_>,
1973 snippets: &mut SourceSnippetCache,
1974) {
1975 push_cycle_sarif_results(sarif_results, ctx, snippets);
1976 push_boundary_sarif_results(sarif_results, ctx, snippets);
1977}
1978
1979fn push_cycle_sarif_results(
1982 sarif_results: &mut Vec<serde_json::Value>,
1983 ctx: &SarifCtx<'_>,
1984 snippets: &mut SourceSnippetCache,
1985) {
1986 let SarifCtx {
1987 results,
1988 root,
1989 rules,
1990 } = *ctx;
1991
1992 push_sarif_results(
1993 sarif_results,
1994 &results.circular_dependencies,
1995 snippets,
1996 |c| {
1997 sarif_circular_dep_fields(
1998 &c.cycle,
1999 root,
2000 finding_level(c, rules.circular_dependencies),
2001 )
2002 },
2003 );
2004 push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
2005 sarif_re_export_cycle_fields(&c.cycle, root, finding_level(c, rules.re_export_cycle))
2006 });
2007 push_sarif_results(sarif_results, &results.package_cycles, snippets, |c| {
2008 sarif_package_cycle_fields(&c.cycle, root, finding_level(c, rules.package_cycle))
2009 });
2010}
2011
2012fn push_boundary_sarif_results(
2014 sarif_results: &mut Vec<serde_json::Value>,
2015 ctx: &SarifCtx<'_>,
2016 snippets: &mut SourceSnippetCache,
2017) {
2018 let SarifCtx {
2019 results,
2020 root,
2021 rules,
2022 } = *ctx;
2023
2024 push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
2025 sarif_boundary_violation_fields(
2026 &v.violation,
2027 root,
2028 finding_level(v, rules.boundary_violation),
2029 )
2030 });
2031 push_sarif_results(
2032 sarif_results,
2033 &results.boundary_coverage_violations,
2034 snippets,
2035 |v| {
2036 sarif_boundary_coverage_fields(
2037 &v.violation,
2038 root,
2039 finding_level(v, rules.boundary_violation),
2040 )
2041 },
2042 );
2043 push_sarif_results(
2044 sarif_results,
2045 &results.boundary_call_violations,
2046 snippets,
2047 |v| {
2048 sarif_boundary_call_fields(
2049 &v.violation,
2050 root,
2051 finding_level(v, rules.boundary_violation),
2052 )
2053 },
2054 );
2055 push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
2056 sarif_policy_violation_fields(&v.violation, root)
2057 });
2058}
2059
2060fn push_framework_sarif_results(
2061 sarif_results: &mut Vec<serde_json::Value>,
2062 ctx: &SarifCtx<'_>,
2063 snippets: &mut SourceSnippetCache,
2064) {
2065 push_framework_boundary_sarif_results(sarif_results, ctx, snippets);
2066 push_component_contract_sarif_results(sarif_results, ctx, snippets);
2067}
2068
2069fn push_framework_boundary_sarif_results(
2071 sarif_results: &mut Vec<serde_json::Value>,
2072 ctx: &SarifCtx<'_>,
2073 snippets: &mut SourceSnippetCache,
2074) {
2075 let SarifCtx {
2076 results,
2077 root,
2078 rules,
2079 } = *ctx;
2080
2081 push_sarif_results(
2082 sarif_results,
2083 &results.invalid_client_exports,
2084 snippets,
2085 |e| {
2086 sarif_invalid_client_export_fields(
2087 &e.export,
2088 root,
2089 finding_level(e, rules.invalid_client_export),
2090 )
2091 },
2092 );
2093 push_sarif_results(
2094 sarif_results,
2095 &results.mixed_client_server_barrels,
2096 snippets,
2097 |b| {
2098 sarif_mixed_client_server_barrel_fields(
2099 &b.barrel,
2100 root,
2101 finding_level(b, rules.mixed_client_server_barrel),
2102 )
2103 },
2104 );
2105 push_sarif_results(
2106 sarif_results,
2107 &results.misplaced_directives,
2108 snippets,
2109 |d| {
2110 sarif_misplaced_directive_fields(
2111 &d.directive_site,
2112 root,
2113 finding_level(d, rules.misplaced_directive),
2114 )
2115 },
2116 );
2117 push_framework_render_sarif_results(sarif_results, ctx, snippets);
2118}
2119
2120fn push_framework_render_sarif_results(
2121 sarif_results: &mut Vec<serde_json::Value>,
2122 ctx: &SarifCtx<'_>,
2123 snippets: &mut SourceSnippetCache,
2124) {
2125 let SarifCtx {
2126 results,
2127 root,
2128 rules,
2129 } = *ctx;
2130
2131 push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
2132 sarif_unprovided_inject_fields(&i.inject, root, finding_level(i, rules.unprovided_injects))
2133 });
2134 push_sarif_results(
2135 sarif_results,
2136 &results.unrendered_components,
2137 snippets,
2138 |c| {
2139 sarif_unrendered_component_fields(
2140 &c.component,
2141 root,
2142 finding_level(c, rules.unrendered_components),
2143 )
2144 },
2145 );
2146}
2147
2148fn push_route_sarif_results(
2149 sarif_results: &mut Vec<serde_json::Value>,
2150 ctx: &SarifCtx<'_>,
2151 snippets: &mut SourceSnippetCache,
2152) {
2153 let SarifCtx {
2154 results,
2155 root,
2156 rules,
2157 } = *ctx;
2158
2159 push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
2160 sarif_route_collision_fields(&c.collision, root, finding_level(c, rules.route_collision))
2161 });
2162 push_sarif_results(
2163 sarif_results,
2164 &results.dynamic_segment_name_conflicts,
2165 snippets,
2166 |c| {
2167 sarif_dynamic_segment_name_conflict_fields(
2168 &c.conflict,
2169 root,
2170 finding_level(c, rules.dynamic_segment_name_conflict),
2171 )
2172 },
2173 );
2174}
2175
2176fn push_suppression_sarif_results(
2177 sarif_results: &mut Vec<serde_json::Value>,
2178 ctx: &SarifCtx<'_>,
2179 snippets: &mut SourceSnippetCache,
2180) {
2181 let SarifCtx {
2182 results,
2183 root,
2184 rules,
2185 } = *ctx;
2186
2187 push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
2188 sarif_stale_suppression_fields(
2189 s,
2190 root,
2191 finding_level(s, stale_suppression_severity(s, rules)),
2192 )
2193 });
2194}
2195
2196fn push_catalog_sarif_results(
2197 sarif_results: &mut Vec<serde_json::Value>,
2198 ctx: &SarifCtx<'_>,
2199 snippets: &mut SourceSnippetCache,
2200) {
2201 push_catalog_entry_sarif_results(sarif_results, ctx, snippets);
2202 push_dependency_override_sarif_results(sarif_results, ctx, snippets);
2203}
2204
2205fn push_catalog_entry_sarif_results(
2207 sarif_results: &mut Vec<serde_json::Value>,
2208 ctx: &SarifCtx<'_>,
2209 snippets: &mut SourceSnippetCache,
2210) {
2211 let SarifCtx {
2212 results,
2213 root,
2214 rules,
2215 } = *ctx;
2216
2217 push_sarif_results(
2218 sarif_results,
2219 &results.unused_catalog_entries,
2220 snippets,
2221 |e| {
2222 sarif_unused_catalog_entry_fields(
2223 e,
2224 root,
2225 finding_level(e, rules.unused_catalog_entries),
2226 )
2227 },
2228 );
2229 push_sarif_results(
2230 sarif_results,
2231 &results.empty_catalog_groups,
2232 snippets,
2233 |g| sarif_empty_catalog_group_fields(g, root, finding_level(g, rules.empty_catalog_groups)),
2234 );
2235 push_sarif_results(
2236 sarif_results,
2237 &results.unresolved_catalog_references,
2238 snippets,
2239 |f| {
2240 sarif_unresolved_catalog_reference_fields(
2241 f,
2242 root,
2243 finding_level(f, rules.unresolved_catalog_references),
2244 )
2245 },
2246 );
2247}
2248
2249fn push_dependency_override_sarif_results(
2251 sarif_results: &mut Vec<serde_json::Value>,
2252 ctx: &SarifCtx<'_>,
2253 snippets: &mut SourceSnippetCache,
2254) {
2255 let SarifCtx {
2256 results,
2257 root,
2258 rules,
2259 } = *ctx;
2260
2261 push_sarif_results(
2262 sarif_results,
2263 &results.unused_dependency_overrides,
2264 snippets,
2265 |f| {
2266 sarif_unused_dependency_override_fields(
2267 f,
2268 root,
2269 finding_level(f, rules.unused_dependency_overrides),
2270 )
2271 },
2272 );
2273 push_sarif_results(
2274 sarif_results,
2275 &results.misconfigured_dependency_overrides,
2276 snippets,
2277 |f| {
2278 sarif_misconfigured_dependency_override_fields(
2279 f,
2280 root,
2281 finding_level(f, rules.misconfigured_dependency_overrides),
2282 )
2283 },
2284 );
2285}
2286
2287#[cfg(test)]
2288mod tests {
2289 use std::collections::BTreeSet;
2290 use std::path::Path;
2291
2292 use fallow_config::RulesConfig;
2293 use fallow_types::results::AnalysisResults;
2294
2295 use super::*;
2296
2297 fn test_rule_builder(id: &str, description: &str, level: &str) -> serde_json::Value {
2298 serde_json::json!({
2299 "id": id,
2300 "shortDescription": { "text": description },
2301 "defaultConfiguration": { "level": level }
2302 })
2303 }
2304
2305 #[test]
2309 fn sarif_messages_name_the_degraded_parse_caveat() {
2310 let mut results = AnalysisResults::default();
2311 results
2312 .unused_files
2313 .push(UnusedFileFinding::with_actions(UnusedFile {
2314 path: Path::new("/p/src/clean.ts").to_path_buf(),
2315 }));
2316 let mut flagged = UnusedFileFinding::with_actions(UnusedFile {
2317 path: Path::new("/p/src/orphan.ts").to_path_buf(),
2318 });
2319 flagged.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2320 results.unused_files.push(flagged);
2321
2322 let member = |parent: &str, name: &str, kind| UnusedMember {
2327 path: Path::new("/p/src/lib.ts").to_path_buf(),
2328 parent_name: parent.to_owned(),
2329 member_name: name.to_owned(),
2330 kind,
2331 line: 7,
2332 col: 2,
2333 };
2334 let mut enum_member = UnusedEnumMemberFinding::with_actions(member(
2335 "Mode",
2336 "Legacy",
2337 fallow_types::extract::MemberKind::EnumMember,
2338 ));
2339 enum_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2340 results.unused_enum_members.push(enum_member);
2341 let mut class_member = UnusedClassMemberFinding::with_actions(member(
2342 "Widget",
2343 "render",
2344 fallow_types::extract::MemberKind::ClassMethod,
2345 ));
2346 class_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2347 results.unused_class_members.push(class_member);
2348 let mut store_member = UnusedStoreMemberFinding::with_actions(member(
2349 "useCart",
2350 "subtotal",
2351 fallow_types::extract::MemberKind::StoreMember,
2352 ));
2353 store_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2354 results.unused_store_members.push(store_member);
2355
2356 let sarif = build_dead_code_sarif(
2357 &results,
2358 Path::new("/p"),
2359 &RulesConfig::default(),
2360 &test_rule_builder,
2361 );
2362 let messages: Vec<String> = sarif
2363 .pointer("/runs/0/results")
2364 .and_then(serde_json::Value::as_array)
2365 .expect("SARIF results")
2366 .iter()
2367 .filter_map(|entry| {
2368 entry
2369 .pointer("/message/text")
2370 .and_then(serde_json::Value::as_str)
2371 .map(str::to_owned)
2372 })
2373 .collect();
2374
2375 assert!(
2376 messages.contains(&"File is not reachable from any entry point".to_owned()),
2377 "a clean finding keeps its exact message: {messages:?}"
2378 );
2379 assert!(
2380 messages.contains(
2381 &"File is not reachable from any entry point (caveat: incomplete import graph)"
2382 .to_owned()
2383 ),
2384 "a caveated finding names it in the message: {messages:?}"
2385 );
2386 for expected in [
2387 "Enum member 'Mode.Legacy' is never referenced (caveat: incomplete import graph)",
2388 "Class member 'Widget.render' is never referenced (caveat: incomplete import graph)",
2389 "Store member 'useCart.subtotal' is never referenced (caveat: incomplete import graph)",
2390 ] {
2391 assert!(
2392 messages.contains(&expected.to_owned()),
2393 "every member kind names the caveat: {messages:?}"
2394 );
2395 }
2396 }
2397
2398 #[test]
2399 fn root_dependency_message_names_the_workspaces_that_declare_and_import_it() {
2400 let root = Path::new("/project");
2401 let mut results = AnalysisResults::default();
2402 results
2403 .unused_dependencies
2404 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2405 package_name: "left-pad".to_owned(),
2406 location: fallow_types::results::DependencyLocation::Dependencies,
2407 path: root.join("package.json"),
2408 line: 0,
2409 used_in_workspaces: Vec::new(),
2410 declared_and_imported_in: vec![root.join("packages/app")],
2411 }));
2412
2413 let sarif =
2414 build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2415 let message = sarif
2416 .pointer("/runs/0/results/0/message/text")
2417 .and_then(serde_json::Value::as_str)
2418 .expect("SARIF message");
2419 assert_eq!(
2420 message,
2421 "Package 'left-pad' is in dependencies but never imported; declared and imported in other workspaces: packages/app"
2422 );
2423 }
2424
2425 #[test]
2433 fn two_dependencies_on_one_manifest_line_are_two_alerts() {
2434 let dir = tempfile::tempdir().expect("temporary project");
2435 let root = dir.path();
2436 std::fs::write(
2437 root.join("package.json"),
2438 r#"{"name":"compact","dependencies":{"lodash":"^4.17.21","chalk":"^5.3.0"}}"#,
2439 )
2440 .expect("write manifest");
2441
2442 let mut results = AnalysisResults::default();
2443 for name in ["lodash", "chalk"] {
2444 results
2445 .unused_dependencies
2446 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2447 package_name: name.to_owned(),
2448 location: fallow_types::results::DependencyLocation::Dependencies,
2449 path: root.join("package.json"),
2450 line: 1,
2451 used_in_workspaces: Vec::new(),
2452 declared_and_imported_in: Vec::new(),
2453 }));
2454 }
2455
2456 let sarif =
2457 build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2458 let entries = sarif
2459 .pointer("/runs/0/results")
2460 .and_then(serde_json::Value::as_array)
2461 .expect("SARIF results");
2462
2463 let fingerprints = entries
2464 .iter()
2465 .map(|entry| {
2466 entry
2467 .pointer("/partialFingerprints/tools.fallow.fingerprint~1v1")
2468 .and_then(serde_json::Value::as_str)
2469 .expect("fingerprint")
2470 })
2471 .collect::<BTreeSet<_>>();
2472 assert_eq!(
2473 fingerprints.len(),
2474 entries.len(),
2475 "two dependencies declared on one line are two alerts: {entries:#?}"
2476 );
2477
2478 let columns = entries
2479 .iter()
2480 .map(|entry| {
2481 entry
2482 .pointer("/locations/0/physicalLocation/region/startColumn")
2483 .and_then(serde_json::Value::as_u64)
2484 .expect("start column")
2485 })
2486 .collect::<BTreeSet<_>>();
2487 assert_eq!(
2488 columns.len(),
2489 entries.len(),
2490 "each dependency points at its own key in the manifest line: {entries:#?}"
2491 );
2492 }
2493
2494 #[test]
2500 fn a_dependency_added_above_leaves_the_others_alert_alone() {
2501 let dir = tempfile::tempdir().expect("temporary project");
2502 let root = dir.path();
2503 let manifest = root.join("package.json");
2504
2505 let chalk_fingerprint = |manifest_text: &str, chalk_line: u32| {
2506 std::fs::write(&manifest, manifest_text).expect("write manifest");
2507 let mut results = AnalysisResults::default();
2508 results
2509 .unused_dependencies
2510 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2511 package_name: "chalk".to_owned(),
2512 location: fallow_types::results::DependencyLocation::Dependencies,
2513 path: manifest.clone(),
2514 line: chalk_line,
2515 used_in_workspaces: Vec::new(),
2516 declared_and_imported_in: Vec::new(),
2517 }));
2518 build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder)
2519 .pointer("/runs/0/results/0/partialFingerprints/tools.fallow.fingerprint~1v1")
2520 .and_then(serde_json::Value::as_str)
2521 .expect("chalk fingerprint")
2522 .to_owned()
2523 };
2524
2525 let before = "{\n \"dependencies\": {\n \"chalk\": \"^5.3.0\"\n }\n}\n";
2526 let after = "{\n \"dependencies\": {\n \"lodash\": \"^4.17.21\",\n \"chalk\": \"^5.3.0\"\n }\n}\n";
2527
2528 assert_eq!(
2529 chalk_fingerprint(before, 3),
2530 chalk_fingerprint(after, 4),
2531 "a dependency declared above it must not move chalk's alert"
2532 );
2533 }
2534
2535 #[test]
2542 fn the_caveat_does_not_move_the_sarif_fingerprint() {
2543 let build = |caveated: bool| {
2544 let mut results = AnalysisResults::default();
2545 let mut finding = UnusedFileFinding::with_actions(UnusedFile {
2546 path: Path::new("/p/src/orphan.ts").to_path_buf(),
2547 });
2548 if caveated {
2549 finding.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2550 }
2551 results.unused_files.push(finding);
2552 build_dead_code_sarif(
2553 &results,
2554 Path::new("/p"),
2555 &RulesConfig::default(),
2556 &test_rule_builder,
2557 )
2558 };
2559
2560 let read = |sarif: &serde_json::Value, pointer: &str| {
2561 sarif
2562 .pointer("/runs/0/results")
2563 .and_then(serde_json::Value::as_array)
2564 .expect("SARIF results")
2565 .iter()
2566 .map(|entry| {
2567 entry
2568 .pointer(pointer)
2569 .and_then(serde_json::Value::as_str)
2570 .expect("SARIF field")
2571 .to_owned()
2572 })
2573 .collect::<Vec<_>>()
2574 };
2575
2576 let clean = build(false);
2577 let caveated = build(true);
2578
2579 for key in [
2580 "/partialFingerprints/tools.fallow.fingerprint~1v1",
2581 "/partialFingerprints/primaryLocationLineHash~1v1",
2582 ] {
2583 assert_eq!(
2584 read(&clean, key),
2585 read(&caveated, key),
2586 "the caveat must not move {key}"
2587 );
2588 }
2589
2590 assert_ne!(
2591 read(&clean, "/message/text"),
2592 read(&caveated, "/message/text"),
2593 "the guard is only meaningful while the message actually changed"
2594 );
2595 }
2596
2597 #[test]
2598 fn sarif_rule_list_is_backed_by_issue_contracts() {
2599 let sarif = build_dead_code_sarif(
2600 &AnalysisResults::default(),
2601 Path::new("."),
2602 &RulesConfig::default(),
2603 &test_rule_builder,
2604 );
2605 let Some(rules) = sarif
2606 .pointer("/runs/0/tool/driver/rules")
2607 .and_then(serde_json::Value::as_array)
2608 else {
2609 panic!("SARIF document should contain driver rules");
2610 };
2611
2612 let actual_ids = rules
2613 .iter()
2614 .filter_map(|rule| {
2615 rule.get("id")
2616 .and_then(serde_json::Value::as_str)
2617 .map(str::to_owned)
2618 })
2619 .collect::<BTreeSet<_>>();
2620 let expected_ids = issue_output_contracts()
2621 .flat_map(|contract| contract.sarif_rule_ids)
2622 .collect::<BTreeSet<_>>();
2623
2624 assert_eq!(actual_ids, expected_ids);
2625
2626 for rule in rules {
2627 let id = rule
2628 .get("id")
2629 .and_then(serde_json::Value::as_str)
2630 .expect("SARIF rule should have id");
2631 let description = rule
2632 .pointer("/shortDescription/text")
2633 .and_then(serde_json::Value::as_str)
2634 .expect("SARIF rule should have short description");
2635 assert_eq!(
2636 description,
2637 issue_sarif_rule_description(id).expect("SARIF rule description should resolve")
2638 );
2639 }
2640 }
2641
2642 const FINDING_ID_POINTER: &str = "/partialFingerprints/fallowFinding~1v1";
2643
2644 fn identity_results(root: &Path) -> AnalysisResults {
2647 let mut results = AnalysisResults::default();
2648 results
2649 .unused_files
2650 .push(UnusedFileFinding::with_actions(UnusedFile {
2651 path: root.join("src/orphan.ts"),
2652 }));
2653 results
2654 .unused_dependencies
2655 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2656 package_name: "lodash".to_owned(),
2657 location: fallow_types::results::DependencyLocation::Dependencies,
2658 path: root.join("package.json"),
2659 line: 3,
2660 used_in_workspaces: Vec::new(),
2661 declared_and_imported_in: Vec::new(),
2662 }));
2663 results
2664 .unlisted_dependencies
2665 .push(UnlistedDependencyFinding::with_actions(
2666 fallow_types::results::UnlistedDependency {
2667 package_name: "chalk".to_owned(),
2668 imported_from: vec![
2669 fallow_types::results::ImportSite {
2670 path: root.join("src/a.ts"),
2671 line: 1,
2672 col: 0,
2673 },
2674 fallow_types::results::ImportSite {
2675 path: root.join("src/b.ts"),
2676 line: 2,
2677 col: 0,
2678 },
2679 ],
2680 },
2681 ));
2682 results
2683 .duplicate_exports
2684 .push(DuplicateExportFinding::with_actions(
2685 fallow_types::results::DuplicateExport {
2686 export_name: "Button".to_owned(),
2687 locations: vec![
2688 fallow_types::results::DuplicateLocation {
2689 path: root.join("src/a.ts"),
2690 line: 4,
2691 col: 0,
2692 },
2693 fallow_types::results::DuplicateLocation {
2694 path: root.join("src/b.ts"),
2695 line: 5,
2696 col: 0,
2697 },
2698 ],
2699 },
2700 ));
2701 results
2702 }
2703
2704 fn sarif_entries(sarif: &serde_json::Value) -> Vec<serde_json::Value> {
2705 sarif
2706 .pointer("/runs/0/results")
2707 .and_then(serde_json::Value::as_array)
2708 .expect("SARIF results")
2709 .clone()
2710 }
2711
2712 #[test]
2715 fn a_single_result_finding_carries_its_id_as_a_partial_fingerprint() {
2716 let root = Path::new("/p");
2717 let mut results = identity_results(root);
2718 fallow_types::identity::stamp_dead_code_finding_ids(&mut results, root);
2719 let sarif =
2720 build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2721 let entries = sarif_entries(&sarif);
2722
2723 let id_of_rule = |rule: &str| {
2724 entries
2725 .iter()
2726 .find(|entry| entry["ruleId"] == rule)
2727 .and_then(|entry| entry.pointer(FINDING_ID_POINTER))
2728 .and_then(serde_json::Value::as_str)
2729 .map(str::to_owned)
2730 };
2731 assert_eq!(
2732 id_of_rule("fallow/unused-file").as_deref(),
2733 results.unused_files[0].finding_id.as_deref(),
2734 );
2735 assert_eq!(
2736 id_of_rule("fallow/unused-dependency").as_deref(),
2737 results.unused_dependencies[0].finding_id.as_deref(),
2738 );
2739 assert!(
2740 id_of_rule("fallow/unused-file").is_some_and(|id| id.starts_with("dc1:unused-file:")),
2741 "the key holds the finding id: {entries:#?}"
2742 );
2743 }
2744
2745 #[test]
2749 fn a_fanned_out_finding_does_not_carry_the_id() {
2750 let root = Path::new("/p");
2751 let mut results = identity_results(root);
2752 fallow_types::identity::stamp_dead_code_finding_ids(&mut results, root);
2753 let sarif =
2754 build_dead_code_sarif(&results, root, &RulesConfig::default(), &test_rule_builder);
2755
2756 for entry in sarif_entries(&sarif) {
2757 let rule = entry["ruleId"].as_str().expect("rule id");
2758 if matches!(
2759 rule,
2760 "fallow/unlisted-dependency" | "fallow/duplicate-export"
2761 ) {
2762 assert!(
2763 entry.pointer(FINDING_ID_POINTER).is_none(),
2764 "{rule} fans out and must not carry the id: {entry:#?}"
2765 );
2766 }
2767 }
2768 }
2769
2770 #[test]
2774 fn the_finding_id_key_leaves_every_other_sarif_byte_alone() {
2775 let root = Path::new("/p");
2776 let without_ids = identity_results(root);
2777 let mut with_ids = without_ids.clone();
2778 fallow_types::identity::stamp_dead_code_finding_ids(&mut with_ids, root);
2779
2780 let before = build_dead_code_sarif(
2781 &without_ids,
2782 root,
2783 &RulesConfig::default(),
2784 &test_rule_builder,
2785 );
2786 let mut after =
2787 build_dead_code_sarif(&with_ids, root, &RulesConfig::default(), &test_rule_builder);
2788
2789 let mut removed = 0;
2790 for entry in after
2791 .pointer_mut("/runs/0/results")
2792 .and_then(serde_json::Value::as_array_mut)
2793 .expect("SARIF results")
2794 {
2795 let prints = entry["partialFingerprints"]
2796 .as_object_mut()
2797 .expect("partial fingerprints");
2798 if prints.remove("fallowFinding/v1").is_some() {
2799 removed += 1;
2800 }
2801 }
2802 assert!(
2803 removed > 0,
2804 "the guard needs at least one result with the key"
2805 );
2806 assert_eq!(
2807 serde_json::to_string(&before).expect("serialize"),
2808 serde_json::to_string(&after).expect("serialize"),
2809 );
2810 }
2811
2812 #[test]
2815 fn a_finding_without_an_id_has_no_finding_id_key() {
2816 let root = Path::new("/p");
2817 let sarif = build_dead_code_sarif(
2818 &identity_results(root),
2819 root,
2820 &RulesConfig::default(),
2821 &test_rule_builder,
2822 );
2823 for entry in sarif_entries(&sarif) {
2824 assert!(
2825 entry["partialFingerprints"]
2826 .get("fallowFinding/v1")
2827 .is_none(),
2828 "no id, no key: {entry:#?}"
2829 );
2830 }
2831 }
2832}