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