1use std::collections::BTreeMap;
2use std::path::Path;
3use std::time::Duration;
4
5use fallow_types::envelope::{
6 BaselineDeltas, BaselineMatch, CheckSummary, ElapsedMs, EntryPoints, Meta, RegressionResult,
7 SchemaVersion, ToolVersion,
8};
9use fallow_types::output::{IssueAction, NextStep};
10use fallow_types::output_health::{HealthFindingAction, HealthFindingActionType};
11use fallow_types::results::AnalysisResults;
12use fallow_types::workspace::WorkspaceDiagnostic;
13use serde::Serialize;
14
15use crate::HealthReport;
16use crate::root_envelopes::{attach_telemetry_meta, serialize_named_json_output};
17
18pub const CHECK_SCHEMA_VERSION: u32 = 10;
20
21#[cfg(feature = "schema")]
23#[allow(dead_code, reason = "schema-only type used by the field projection")]
24#[derive(schemars::JsonSchema)]
25#[schemars(extend("const" = CHECK_SCHEMA_VERSION))]
26struct CheckSchemaVersion(u32);
27
28#[derive(Debug, Clone, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39#[cfg_attr(feature = "schema", schemars(title = "fallow dead-code --format json"))]
40pub struct CheckOutput {
41 #[cfg_attr(feature = "schema", schemars(with = "CheckSchemaVersion"))]
43 pub schema_version: SchemaVersion,
44 pub version: ToolVersion,
46 pub elapsed_ms: ElapsedMs,
48 pub total_issues: usize,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub entry_points: Option<EntryPoints>,
53 pub summary: CheckSummary,
55 #[serde(flatten)]
58 pub results: AnalysisResults,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub baseline_deltas: Option<BaselineDeltas>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub baseline: Option<BaselineMatch>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub baseline_staleness: Option<crate::BaselineStaleness>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub finding_id_query: Option<crate::FindingIdQuery>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub regression: Option<RegressionResult>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub gate_outcomes: Option<crate::GateOutcomes>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub request_outcomes: Option<crate::RequestOutcomes>,
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub package_baselines: Vec<crate::PackageBaselineStatus>,
105 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
108 pub meta: Option<Meta>,
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub next_steps: Vec<NextStep>,
166}
167
168#[derive(Debug, Clone, Serialize)]
175#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
176#[cfg_attr(
177 feature = "schema",
178 schemars(
179 title = "fallow dead-code --group-by <owner|directory|package|section> --format json"
180 )
181)]
182pub struct CheckGroupedOutput {
183 #[cfg_attr(feature = "schema", schemars(with = "CheckSchemaVersion"))]
185 pub schema_version: SchemaVersion,
186 pub version: ToolVersion,
188 pub elapsed_ms: ElapsedMs,
190 pub grouped_by: GroupByMode,
192 pub total_issues: usize,
194 pub groups: Vec<CheckGroupedEntry>,
196 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
202 pub unused_load_data_keys_global_abstain: bool,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub baseline_staleness: Option<crate::BaselineStaleness>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub finding_id_query: Option<crate::FindingIdQuery>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub gate_outcomes: Option<crate::GateOutcomes>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub request_outcomes: Option<crate::RequestOutcomes>,
236 #[serde(default, skip_serializing_if = "Vec::is_empty")]
238 pub package_baselines: Vec<crate::PackageBaselineStatus>,
239 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
242 pub meta: Option<Meta>,
243 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
250 pub next_steps: Vec<NextStep>,
251}
252
253#[derive(Debug, Clone, Serialize)]
257#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
258pub struct CheckGroupedEntry {
259 pub key: String,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub owners: Option<Vec<String>>,
264 pub total_issues: usize,
266 #[serde(flatten)]
268 pub results: AnalysisResults,
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
277#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
278#[serde(rename_all = "lowercase")]
279pub enum GroupByMode {
280 Owner,
282 Directory,
284 Package,
286 Section,
288}
289
290pub struct CheckOutputInput {
292 pub schema_version: u32,
294 pub version: String,
296 pub elapsed: Duration,
298 pub results: AnalysisResults,
300 pub config_fixable: bool,
303 pub meta: Option<Meta>,
305 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
308 pub next_steps: Vec<NextStep>,
310}
311
312#[must_use]
314pub fn build_check_output(input: CheckOutputInput) -> CheckOutput {
315 let mut results = input.results;
316 apply_config_fixable_to_duplicate_exports(&mut results, input.config_fixable);
317 harmonize_multi_kind_suppress_line_actions(&mut results);
318 CheckOutput {
319 schema_version: SchemaVersion(input.schema_version),
320 version: ToolVersion(input.version),
321 elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
322 total_issues: results.total_issues(),
323 entry_points: results
324 .entry_point_summary
325 .as_ref()
326 .map(|entry_points| EntryPoints {
327 total: entry_points.total,
328 sources: entry_points
329 .by_source
330 .iter()
331 .map(|(key, value)| (key.replace(' ', "_"), *value))
332 .collect(),
333 }),
334 summary: build_check_summary(&results),
335 results,
336 baseline_deltas: None,
337 baseline: None,
338 baseline_staleness: None,
339 finding_id_query: None,
340 regression: None,
341 gate_outcomes: None,
342 request_outcomes: None,
343 package_baselines: Vec::new(),
344 meta: input.meta,
345 workspace_diagnostics: input.workspace_diagnostics,
346 next_steps: input.next_steps,
347 }
348}
349
350fn serialize_check_family_json_output<T: Serialize>(
351 output: T,
352 kind: &'static str,
353 analysis_run_id: Option<&str>,
354) -> Result<serde_json::Value, serde_json::Error> {
355 let mut value = serialize_named_json_output(output, kind)?;
356 attach_telemetry_meta(&mut value, analysis_run_id);
357 Ok(value)
358}
359
360pub fn serialize_check_json_output(
366 output: CheckOutput,
367 analysis_run_id: Option<&str>,
368) -> Result<serde_json::Value, serde_json::Error> {
369 serialize_check_family_json_output(output, "dead-code", analysis_run_id)
370}
371
372pub fn serialize_check_grouped_json_output(
379 output: CheckGroupedOutput,
380 analysis_run_id: Option<&str>,
381) -> Result<serde_json::Value, serde_json::Error> {
382 serialize_check_family_json_output(output, "dead-code-grouped", analysis_run_id)
383}
384
385pub fn apply_config_fixable_to_duplicate_exports(
388 results: &mut AnalysisResults,
389 config_fixable: bool,
390) {
391 if !config_fixable {
392 return;
393 }
394 for finding in &mut results.duplicate_exports {
395 finding.set_config_fixable(true);
396 }
397}
398
399type SuppressAnchor = (String, u32);
400
401macro_rules! visit_suppress_line_findings {
402 ($results:expr, $visit:expr) => {{
403 let results = $results;
404 for finding in &results.unused_exports {
405 $visit(&finding.export.path, finding.export.line, &finding.actions);
406 }
407 for finding in &results.unused_types {
408 $visit(&finding.export.path, finding.export.line, &finding.actions);
409 }
410 for finding in &results.private_type_leaks {
411 $visit(&finding.leak.path, finding.leak.line, &finding.actions);
412 }
413 for finding in &results.deprecated_exports_in_use {
414 $visit(&finding.export.path, finding.export.line, &finding.actions);
415 }
416 for finding in &results.unused_enum_members {
417 $visit(&finding.member.path, finding.member.line, &finding.actions);
418 }
419 for finding in &results.unused_class_members {
420 $visit(&finding.member.path, finding.member.line, &finding.actions);
421 }
422 for finding in &results.unused_store_members {
423 $visit(&finding.member.path, finding.member.line, &finding.actions);
424 }
425 for finding in &results.unresolved_imports {
426 $visit(&finding.import.path, finding.import.line, &finding.actions);
427 }
428 for finding in &results.unused_dependencies {
429 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
430 }
431 for finding in &results.unused_dev_dependencies {
432 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
433 }
434 for finding in &results.unused_optional_dependencies {
435 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
436 }
437 for finding in &results.type_only_dependencies {
438 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
439 }
440 for finding in &results.test_only_dependencies {
441 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
442 }
443 for finding in &results.dev_dependencies_in_production {
444 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
445 }
446 for finding in &results.circular_dependencies {
447 if let Some(path) = finding.cycle.files.first() {
448 $visit(path, finding.cycle.line, &finding.actions);
449 }
450 }
451 for finding in &results.package_cycles {
452 if let Some(edge) = finding.cycle.edges.first() {
453 $visit(&edge.path, edge.line, &finding.actions);
454 }
455 }
456 for finding in &results.boundary_violations {
457 $visit(
458 &finding.violation.from_path,
459 finding.violation.line,
460 &finding.actions,
461 );
462 }
463 for finding in &results.boundary_coverage_violations {
464 $visit(
465 &finding.violation.path,
466 finding.violation.line,
467 &finding.actions,
468 );
469 }
470 for finding in &results.boundary_call_violations {
471 $visit(
472 &finding.violation.path,
473 finding.violation.line,
474 &finding.actions,
475 );
476 }
477 for finding in &results.policy_violations {
478 $visit(
479 &finding.violation.path,
480 finding.violation.line,
481 &finding.actions,
482 );
483 }
484 for finding in &results.unused_catalog_entries {
485 $visit(&finding.entry.path, finding.entry.line, &finding.actions);
486 }
487 for finding in &results.empty_catalog_groups {
488 $visit(&finding.group.path, finding.group.line, &finding.actions);
489 }
490 for finding in &results.unresolved_catalog_references {
491 $visit(
492 &finding.reference.path,
493 finding.reference.line,
494 &finding.actions,
495 );
496 }
497 for finding in &results.unused_dependency_overrides {
498 $visit(&finding.entry.path, finding.entry.line, &finding.actions);
499 }
500 for finding in &results.misconfigured_dependency_overrides {
501 $visit(&finding.entry.path, finding.entry.line, &finding.actions);
502 }
503 for finding in &results.invalid_client_exports {
504 $visit(&finding.export.path, finding.export.line, &finding.actions);
505 }
506 for finding in &results.mixed_client_server_barrels {
507 $visit(&finding.barrel.path, finding.barrel.line, &finding.actions);
508 }
509 for finding in &results.misplaced_directives {
510 $visit(
511 &finding.directive_site.path,
512 finding.directive_site.line,
513 &finding.actions,
514 );
515 }
516 for finding in &results.unprovided_injects {
517 $visit(&finding.inject.path, finding.inject.line, &finding.actions);
518 }
519 for finding in &results.unrendered_components {
520 $visit(
521 &finding.component.path,
522 finding.component.line,
523 &finding.actions,
524 );
525 }
526 for finding in &results.route_collisions {
527 $visit(
528 &finding.collision.path,
529 finding.collision.line,
530 &finding.actions,
531 );
532 }
533 for finding in &results.dynamic_segment_name_conflicts {
534 $visit(
535 &finding.conflict.path,
536 finding.conflict.line,
537 &finding.actions,
538 );
539 }
540 for finding in &results.unused_component_props {
541 $visit(&finding.prop.path, finding.prop.line, &finding.actions);
542 }
543 for finding in &results.absent_component_props {
544 $visit(&finding.prop.path, finding.prop.line, &finding.actions);
545 }
546 for finding in &results.unused_component_emits {
547 $visit(&finding.emit.path, finding.emit.line, &finding.actions);
548 }
549 for finding in &results.unused_component_inputs {
550 $visit(&finding.input.path, finding.input.line, &finding.actions);
551 }
552 for finding in &results.unused_component_outputs {
553 $visit(&finding.output.path, finding.output.line, &finding.actions);
554 }
555 for finding in &results.unused_svelte_events {
556 $visit(&finding.event.path, finding.event.line, &finding.actions);
557 }
558 for finding in &results.unused_server_actions {
559 $visit(&finding.action.path, finding.action.line, &finding.actions);
560 }
561 for finding in &results.unused_load_data_keys {
562 $visit(&finding.key.path, finding.key.line, &finding.actions);
563 }
564 for finding in &results.prop_drilling_chains {
565 if let Some(hop) = finding.chain.hops.first() {
566 $visit(&hop.file, hop.line, &finding.actions);
567 }
568 }
569 for finding in &results.thin_wrappers {
570 $visit(
571 &finding.wrapper.file,
572 finding.wrapper.line,
573 &finding.actions,
574 );
575 }
576 for finding in &results.duplicate_prop_shapes {
577 $visit(&finding.shape.file, finding.shape.line, &finding.actions);
578 }
579 }};
580}
581
582macro_rules! visit_suppress_line_findings_mut {
583 ($results:expr, $visit:expr) => {{
584 let results = $results;
585 for finding in &mut results.unused_exports {
586 $visit(
587 &finding.export.path,
588 finding.export.line,
589 &mut finding.actions,
590 );
591 }
592 for finding in &mut results.unused_types {
593 $visit(
594 &finding.export.path,
595 finding.export.line,
596 &mut finding.actions,
597 );
598 }
599 for finding in &mut results.private_type_leaks {
600 $visit(&finding.leak.path, finding.leak.line, &mut finding.actions);
601 }
602 for finding in &mut results.deprecated_exports_in_use {
603 $visit(
604 &finding.export.path,
605 finding.export.line,
606 &mut finding.actions,
607 );
608 }
609 for finding in &mut results.unused_enum_members {
610 $visit(
611 &finding.member.path,
612 finding.member.line,
613 &mut finding.actions,
614 );
615 }
616 for finding in &mut results.unused_class_members {
617 $visit(
618 &finding.member.path,
619 finding.member.line,
620 &mut finding.actions,
621 );
622 }
623 for finding in &mut results.unused_store_members {
624 $visit(
625 &finding.member.path,
626 finding.member.line,
627 &mut finding.actions,
628 );
629 }
630 for finding in &mut results.unresolved_imports {
631 $visit(
632 &finding.import.path,
633 finding.import.line,
634 &mut finding.actions,
635 );
636 }
637 for finding in &mut results.unused_dependencies {
638 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
639 }
640 for finding in &mut results.unused_dev_dependencies {
641 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
642 }
643 for finding in &mut results.unused_optional_dependencies {
644 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
645 }
646 for finding in &mut results.type_only_dependencies {
647 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
648 }
649 for finding in &mut results.test_only_dependencies {
650 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
651 }
652 for finding in &mut results.dev_dependencies_in_production {
653 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
654 }
655 for finding in &mut results.circular_dependencies {
656 if let Some(path) = finding.cycle.files.first() {
657 $visit(path, finding.cycle.line, &mut finding.actions);
658 }
659 }
660 for finding in &mut results.package_cycles {
661 if let Some(edge) = finding.cycle.edges.first() {
662 $visit(&edge.path, edge.line, &mut finding.actions);
663 }
664 }
665 for finding in &mut results.boundary_violations {
666 $visit(
667 &finding.violation.from_path,
668 finding.violation.line,
669 &mut finding.actions,
670 );
671 }
672 for finding in &mut results.boundary_coverage_violations {
673 $visit(
674 &finding.violation.path,
675 finding.violation.line,
676 &mut finding.actions,
677 );
678 }
679 for finding in &mut results.boundary_call_violations {
680 $visit(
681 &finding.violation.path,
682 finding.violation.line,
683 &mut finding.actions,
684 );
685 }
686 for finding in &mut results.policy_violations {
687 $visit(
688 &finding.violation.path,
689 finding.violation.line,
690 &mut finding.actions,
691 );
692 }
693 for finding in &mut results.unused_catalog_entries {
694 $visit(
695 &finding.entry.path,
696 finding.entry.line,
697 &mut finding.actions,
698 );
699 }
700 for finding in &mut results.empty_catalog_groups {
701 $visit(
702 &finding.group.path,
703 finding.group.line,
704 &mut finding.actions,
705 );
706 }
707 for finding in &mut results.unresolved_catalog_references {
708 $visit(
709 &finding.reference.path,
710 finding.reference.line,
711 &mut finding.actions,
712 );
713 }
714 for finding in &mut results.unused_dependency_overrides {
715 $visit(
716 &finding.entry.path,
717 finding.entry.line,
718 &mut finding.actions,
719 );
720 }
721 for finding in &mut results.misconfigured_dependency_overrides {
722 $visit(
723 &finding.entry.path,
724 finding.entry.line,
725 &mut finding.actions,
726 );
727 }
728 for finding in &mut results.invalid_client_exports {
729 $visit(
730 &finding.export.path,
731 finding.export.line,
732 &mut finding.actions,
733 );
734 }
735 for finding in &mut results.mixed_client_server_barrels {
736 $visit(
737 &finding.barrel.path,
738 finding.barrel.line,
739 &mut finding.actions,
740 );
741 }
742 for finding in &mut results.misplaced_directives {
743 $visit(
744 &finding.directive_site.path,
745 finding.directive_site.line,
746 &mut finding.actions,
747 );
748 }
749 for finding in &mut results.unprovided_injects {
750 $visit(
751 &finding.inject.path,
752 finding.inject.line,
753 &mut finding.actions,
754 );
755 }
756 for finding in &mut results.unrendered_components {
757 $visit(
758 &finding.component.path,
759 finding.component.line,
760 &mut finding.actions,
761 );
762 }
763 for finding in &mut results.route_collisions {
764 $visit(
765 &finding.collision.path,
766 finding.collision.line,
767 &mut finding.actions,
768 );
769 }
770 for finding in &mut results.dynamic_segment_name_conflicts {
771 $visit(
772 &finding.conflict.path,
773 finding.conflict.line,
774 &mut finding.actions,
775 );
776 }
777 for finding in &mut results.unused_component_props {
778 $visit(&finding.prop.path, finding.prop.line, &mut finding.actions);
779 }
780 for finding in &mut results.absent_component_props {
781 $visit(&finding.prop.path, finding.prop.line, &mut finding.actions);
782 }
783 for finding in &mut results.unused_component_emits {
784 $visit(&finding.emit.path, finding.emit.line, &mut finding.actions);
785 }
786 for finding in &mut results.unused_component_inputs {
787 $visit(
788 &finding.input.path,
789 finding.input.line,
790 &mut finding.actions,
791 );
792 }
793 for finding in &mut results.unused_component_outputs {
794 $visit(
795 &finding.output.path,
796 finding.output.line,
797 &mut finding.actions,
798 );
799 }
800 for finding in &mut results.unused_svelte_events {
801 $visit(
802 &finding.event.path,
803 finding.event.line,
804 &mut finding.actions,
805 );
806 }
807 for finding in &mut results.unused_server_actions {
808 $visit(
809 &finding.action.path,
810 finding.action.line,
811 &mut finding.actions,
812 );
813 }
814 for finding in &mut results.unused_load_data_keys {
815 $visit(&finding.key.path, finding.key.line, &mut finding.actions);
816 }
817 for finding in &mut results.prop_drilling_chains {
818 if let Some(hop) = finding.chain.hops.first() {
819 $visit(&hop.file, hop.line, &mut finding.actions);
820 }
821 }
822 for finding in &mut results.thin_wrappers {
823 $visit(
824 &finding.wrapper.file,
825 finding.wrapper.line,
826 &mut finding.actions,
827 );
828 }
829 for finding in &mut results.duplicate_prop_shapes {
830 $visit(
831 &finding.shape.file,
832 finding.shape.line,
833 &mut finding.actions,
834 );
835 }
836 }};
837}
838
839pub fn harmonize_multi_kind_suppress_line_actions(results: &mut AnalysisResults) {
845 let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
846 collect_dead_code_suppress_line_anchors(results, &mut anchors);
847 retain_multi_kind_anchors(&mut anchors);
848 if anchors.is_empty() {
849 return;
850 }
851 rewrite_dead_code_suppress_line_actions(results, &anchors);
852}
853
854pub fn harmonize_dead_code_health_suppress_line_actions(
860 dead_code: Option<&mut AnalysisResults>,
861 health: Option<&mut HealthReport>,
862) {
863 let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
864 if let Some(results) = dead_code.as_deref() {
865 collect_dead_code_suppress_line_anchors(results, &mut anchors);
866 }
867 if let Some(report) = health.as_deref() {
868 collect_health_suppress_line_anchors(report, &mut anchors);
869 }
870
871 retain_multi_kind_anchors(&mut anchors);
872 if anchors.is_empty() {
873 return;
874 }
875
876 if let Some(results) = dead_code {
877 rewrite_dead_code_suppress_line_actions(results, &anchors);
878 }
879 if let Some(report) = health {
880 rewrite_health_suppress_line_actions(report, &anchors);
881 }
882}
883
884fn retain_multi_kind_anchors(anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>) {
885 anchors.retain(|_, kinds| {
886 sort_suppression_kinds(kinds);
887 kinds.dedup();
888 kinds.len() > 1
889 });
890}
891
892fn collect_dead_code_suppress_line_anchors(
893 results: &AnalysisResults,
894 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
895) {
896 visit_suppress_line_findings!(results, |path: &Path, line, actions: &[IssueAction]| {
897 collect_action_kinds(path, line, actions, anchors);
898 });
899}
900
901fn rewrite_dead_code_suppress_line_actions(
902 results: &mut AnalysisResults,
903 anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
904) {
905 visit_suppress_line_findings_mut!(
906 results,
907 |path: &Path, line, actions: &mut Vec<IssueAction>| {
908 let anchor = suppress_anchor(path, line);
909 if let Some(kinds) = anchors.get(&anchor) {
910 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
911 rewrite_action_comments(actions, &comment);
912 }
913 }
914 );
915}
916
917fn collect_health_suppress_line_anchors(
918 report: &HealthReport,
919 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
920) {
921 for finding in &report.findings {
922 collect_health_action_kinds(
923 &finding.violation.path,
924 finding.violation.line,
925 &finding.actions,
926 anchors,
927 );
928 }
929 for finding in &report.prop_drilling_chains {
930 if let Some(hop) = finding.chain.hops.first() {
931 collect_action_kinds(&hop.file, hop.line, &finding.actions, anchors);
932 }
933 }
934}
935
936fn rewrite_health_suppress_line_actions(
937 report: &mut HealthReport,
938 anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
939) {
940 for finding in &mut report.findings {
941 let anchor = suppress_anchor(&finding.violation.path, finding.violation.line);
942 if let Some(kinds) = anchors.get(&anchor) {
943 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
944 rewrite_health_action_comments(&mut finding.actions, &comment);
945 }
946 }
947 for finding in &mut report.prop_drilling_chains {
948 if let Some(hop) = finding.chain.hops.first() {
949 let anchor = suppress_anchor(&hop.file, hop.line);
950 if let Some(kinds) = anchors.get(&anchor) {
951 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
952 rewrite_action_comments(&mut finding.actions, &comment);
953 }
954 }
955 }
956}
957
958fn collect_action_kinds(
959 path: &Path,
960 line: u32,
961 actions: &[IssueAction],
962 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
963) {
964 for action in actions {
965 if let Some(comment) = suppress_line_comment(action) {
966 let kinds = anchors.entry(suppress_anchor(path, line)).or_default();
967 for kind in parse_suppress_line_comment(comment) {
968 if !kinds.iter().any(|existing| existing == &kind) {
969 kinds.push(kind);
970 }
971 }
972 }
973 }
974}
975
976fn collect_health_action_kinds(
977 path: &Path,
978 line: u32,
979 actions: &[HealthFindingAction],
980 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
981) {
982 for action in actions {
983 if let Some(comment) = health_suppress_line_comment(action) {
984 let kinds = anchors.entry(suppress_anchor(path, line)).or_default();
985 for kind in parse_suppress_line_comment(comment) {
986 if !kinds.iter().any(|existing| existing == &kind) {
987 kinds.push(kind);
988 }
989 }
990 }
991 }
992}
993
994fn rewrite_action_comments(actions: &mut [IssueAction], comment: &str) {
995 for action in actions {
996 if let IssueAction::SuppressLine(suppress) = action {
997 suppress.comment = comment.to_string();
998 }
999 }
1000}
1001
1002fn rewrite_health_action_comments(actions: &mut [HealthFindingAction], comment: &str) {
1003 for action in actions {
1004 if matches!(action.kind, HealthFindingActionType::SuppressLine) {
1005 action.comment = Some(comment.to_string());
1006 }
1007 }
1008}
1009
1010fn suppress_anchor(path: &Path, line: u32) -> SuppressAnchor {
1011 (path.display().to_string(), line)
1012}
1013
1014fn suppress_line_comment(action: &IssueAction) -> Option<&str> {
1015 match action {
1016 IssueAction::SuppressLine(action) => Some(&action.comment),
1017 _ => None,
1018 }
1019}
1020
1021fn health_suppress_line_comment(action: &HealthFindingAction) -> Option<&str> {
1022 matches!(action.kind, HealthFindingActionType::SuppressLine)
1023 .then_some(())
1024 .and(action.comment.as_deref())
1025}
1026
1027fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
1028 comment
1029 .strip_prefix("// fallow-ignore-next-line ")
1030 .map(|rest| {
1031 rest.split(|c: char| c == ',' || c.is_whitespace())
1032 .filter(|token| !token.is_empty())
1033 .map(str::to_string)
1034 .collect()
1035 })
1036 .unwrap_or_default()
1037}
1038
1039fn sort_suppression_kinds(kinds: &mut [String]) {
1040 kinds.sort_by_key(|kind| suppression_kind_rank(kind));
1041}
1042
1043fn suppression_kind_rank(kind: &str) -> usize {
1044 match kind {
1045 "unused-file" => 0,
1046 "unused-export" => 1,
1047 "unused-type" => 2,
1048 "private-type-leak" => 3,
1049 "unused-enum-member" => 4,
1050 "unused-class-member" => 5,
1051 "unused-store-member" => 6,
1052 "unresolved-import" => 7,
1053 "unlisted-dependency" => 8,
1054 "duplicate-export" => 9,
1055 "circular-dependency" => 10,
1056 "re-export-cycle" => 11,
1057 "boundary-violation" => 12,
1058 "code-duplication" => 13,
1059 "complexity" => 14,
1060 "unprovided-inject" => 15,
1061 "unrendered-component" => 16,
1062 "unused-server-action" => 17,
1063 "deprecated-export-in-use" => 18,
1064 "package-cycle" => 19,
1065 _ => usize::MAX,
1066 }
1067}
1068
1069#[must_use]
1071pub fn build_check_summary(results: &AnalysisResults) -> CheckSummary {
1072 CheckSummary {
1073 total_issues: results.total_issues(),
1074 unused_files: results.unused_files.len(),
1075 unused_exports: results.unused_exports.len(),
1076 unused_types: results.unused_types.len(),
1077 private_type_leaks: results.private_type_leaks.len(),
1078 deprecated_exports_in_use: results.deprecated_exports_in_use.len(),
1079 unused_dependencies: results.unused_dependencies.len()
1080 + results.unused_dev_dependencies.len()
1081 + results.unused_optional_dependencies.len(),
1082 unused_enum_members: results.unused_enum_members.len(),
1083 unused_class_members: results.unused_class_members.len(),
1084 unused_store_members: results.unused_store_members.len(),
1085 unresolved_imports: results.unresolved_imports.len(),
1086 unlisted_dependencies: results.unlisted_dependencies.len(),
1087 duplicate_exports: results.duplicate_exports.len(),
1088 type_only_dependencies: results.type_only_dependencies.len(),
1089 test_only_dependencies: results.test_only_dependencies.len(),
1090 dev_dependencies_in_production: results.dev_dependencies_in_production.len(),
1091 circular_dependencies: results.circular_dependencies.len(),
1092 re_export_cycles: results.re_export_cycles.len(),
1093 package_cycles: results.package_cycles.len(),
1094 boundary_violations: results.boundary_violations.len(),
1095 boundary_coverage_violations: results.boundary_coverage_violations.len(),
1096 boundary_call_violations: results.boundary_call_violations.len(),
1097 policy_violations: results.policy_violations.len(),
1098 stale_suppressions: results.stale_suppressions.len(),
1099 unused_catalog_entries: results.unused_catalog_entries.len(),
1100 empty_catalog_groups: results.empty_catalog_groups.len(),
1101 unresolved_catalog_references: results.unresolved_catalog_references.len(),
1102 unused_dependency_overrides: results.unused_dependency_overrides.len(),
1103 misconfigured_dependency_overrides: results.misconfigured_dependency_overrides.len(),
1104 invalid_client_exports: results.invalid_client_exports.len(),
1105 mixed_client_server_barrels: results.mixed_client_server_barrels.len(),
1106 misplaced_directives: results.misplaced_directives.len(),
1107 unprovided_injects: results.unprovided_injects.len(),
1108 unrendered_components: results.unrendered_components.len(),
1109 unused_component_props: results.unused_component_props.len(),
1110 absent_component_props: results.absent_component_props.len(),
1111 unused_component_emits: results.unused_component_emits.len(),
1112 unused_component_inputs: results.unused_component_inputs.len(),
1113 unused_component_outputs: results.unused_component_outputs.len(),
1114 unused_svelte_events: results.unused_svelte_events.len(),
1115 unused_server_actions: results.unused_server_actions.len(),
1116 unused_load_data_keys: results.unused_load_data_keys.len(),
1117 route_collisions: results.route_collisions.len(),
1118 dynamic_segment_name_conflicts: results.dynamic_segment_name_conflicts.len(),
1119 }
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125 use crate::{ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding};
1126 use fallow_types::output_dead_code::{
1127 ReachabilityCaveat, UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
1128 };
1129 use fallow_types::results::{UnusedExport, UnusedFile};
1130 use fallow_types::workspace::WorkspaceDiagnosticKind;
1131
1132 #[test]
1133 fn build_check_output_counts_issues_and_entry_points() {
1134 let mut results = AnalysisResults::default();
1135 results
1136 .unused_files
1137 .push(UnusedFileFinding::with_actions(UnusedFile {
1138 path: "src/unused.ts".into(),
1139 }));
1140
1141 let output = build_check_output(CheckOutputInput {
1142 schema_version: 7,
1143 version: "0.0.0".to_string(),
1144 elapsed: Duration::from_millis(42),
1145 results,
1146 config_fixable: false,
1147 meta: None,
1148 workspace_diagnostics: Vec::new(),
1149 next_steps: Vec::new(),
1150 });
1151
1152 assert_eq!(output.schema_version.0, 7);
1153 assert_eq!(output.total_issues, 1);
1154 assert_eq!(output.summary.unused_files, 1);
1155 assert_eq!(output.elapsed_ms.0, 42);
1156 }
1157
1158 #[test]
1159 fn build_check_output_harmonizes_multi_kind_suppress_actions_typed() {
1160 let mut results = AnalysisResults::default();
1161 let path = std::path::PathBuf::from("/project/src/shared.ts");
1162 results
1163 .unused_exports
1164 .push(UnusedExportFinding::with_actions(UnusedExport {
1165 path: path.clone(),
1166 export_name: "value".to_string(),
1167 is_type_only: false,
1168 line: 7,
1169 col: 0,
1170 span_start: 0,
1171 is_re_export: false,
1172 deprecated: false,
1173 deprecated_reason: None,
1174 }));
1175 results
1176 .unused_types
1177 .push(UnusedTypeFinding::with_actions(UnusedExport {
1178 path,
1179 export_name: "TypeOnly".to_string(),
1180 is_type_only: true,
1181 line: 7,
1182 col: 0,
1183 span_start: 0,
1184 is_re_export: false,
1185 deprecated: false,
1186 deprecated_reason: None,
1187 }));
1188
1189 let output = build_check_output(CheckOutputInput {
1190 schema_version: 7,
1191 version: "0.0.0".to_string(),
1192 elapsed: Duration::from_millis(42),
1193 results,
1194 config_fixable: false,
1195 meta: None,
1196 workspace_diagnostics: Vec::new(),
1197 next_steps: Vec::new(),
1198 });
1199
1200 let export_comment = suppress_comment(&output.results.unused_exports[0].actions);
1201 let type_comment = suppress_comment(&output.results.unused_types[0].actions);
1202 assert_eq!(
1203 export_comment,
1204 Some("// fallow-ignore-next-line unused-export, unused-type")
1205 );
1206 assert_eq!(type_comment, export_comment);
1207 }
1208
1209 #[test]
1210 fn harmonize_dead_code_health_suppress_actions_typed() {
1211 let mut results = AnalysisResults::default();
1212 let path = std::path::PathBuf::from("/project/src/shared.ts");
1213 results
1214 .unused_exports
1215 .push(UnusedExportFinding::with_actions(UnusedExport {
1216 path: path.clone(),
1217 export_name: "value".to_string(),
1218 is_type_only: false,
1219 line: 7,
1220 col: 0,
1221 span_start: 0,
1222 is_re_export: false,
1223 deprecated: false,
1224 deprecated_reason: None,
1225 }));
1226 let mut health = HealthReport {
1227 findings: vec![HealthFinding::new(
1228 ComplexityViolation {
1229 path,
1230 name: "expensive".to_string(),
1231 line: 7,
1232 col: 0,
1233 cyclomatic: 22,
1234 cognitive: 18,
1235 line_count: 40,
1236 param_count: 1,
1237 react_hook_count: 0,
1238 react_jsx_max_depth: 0,
1239 react_prop_count: 0,
1240 react_hook_profile: None,
1241 exceeded: ExceededThreshold::Both,
1242 severity: FindingSeverity::High,
1243 effective_severity: None,
1244 crap: None,
1245 coverage_pct: None,
1246 coverage_tier: None,
1247 coverage_source: None,
1248 inherited_from: None,
1249 component_rollup: None,
1250 contributions: Vec::new(),
1251 effective_thresholds: None,
1252 threshold_source: None,
1253 },
1254 vec![HealthFindingAction {
1255 kind: HealthFindingActionType::SuppressLine,
1256 auto_fixable: false,
1257 description: "Suppress with an inline comment above the function declaration"
1258 .to_string(),
1259 note: None,
1260 comment: Some("// fallow-ignore-next-line complexity".to_string()),
1261 placement: Some("above-function-declaration".to_string()),
1262 target_path: None,
1263 }],
1264 None,
1265 )],
1266 ..HealthReport::default()
1267 };
1268
1269 harmonize_dead_code_health_suppress_line_actions(Some(&mut results), Some(&mut health));
1270
1271 assert_eq!(
1272 suppress_comment(&results.unused_exports[0].actions),
1273 Some("// fallow-ignore-next-line unused-export, complexity")
1274 );
1275 assert_eq!(
1276 health.findings[0].actions[0].comment.as_deref(),
1277 Some("// fallow-ignore-next-line unused-export, complexity")
1278 );
1279 }
1280
1281 #[test]
1282 fn check_json_output_uses_output_owned_root_contract() {
1283 let output = build_check_output(CheckOutputInput {
1284 schema_version: 7,
1285 version: "0.0.0".to_string(),
1286 elapsed: Duration::from_millis(42),
1287 results: AnalysisResults::default(),
1288 config_fixable: false,
1289 meta: None,
1290 workspace_diagnostics: Vec::new(),
1291 next_steps: Vec::new(),
1292 });
1293
1294 let value = serialize_check_json_output(output, Some("run-check"))
1295 .expect("check output should serialize");
1296
1297 assert_eq!(value["kind"], "dead-code");
1298 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-check");
1299 }
1300
1301 #[test]
1308 fn reachability_caveats_are_absent_when_clean_and_named_when_flagged() {
1309 let mut results = AnalysisResults::default();
1310 results
1311 .unused_files
1312 .push(UnusedFileFinding::with_actions(UnusedFile {
1313 path: "/project/src/clean.ts".into(),
1314 }));
1315 let mut flagged = UnusedFileFinding::with_actions(UnusedFile {
1316 path: "/project/src/orphan.ts".into(),
1317 });
1318 flagged.reachability_caveats = vec![
1319 ReachabilityCaveat::IncompleteFileAnalysis,
1320 ReachabilityCaveat::IncompleteImportGraph,
1321 ];
1322 results.unused_files.push(flagged);
1323
1324 let output = build_check_output(CheckOutputInput {
1325 schema_version: 7,
1326 version: "0.0.0".to_string(),
1327 elapsed: Duration::from_millis(1),
1328 results,
1329 config_fixable: false,
1330 meta: None,
1331 workspace_diagnostics: Vec::new(),
1332 next_steps: Vec::new(),
1333 });
1334 let value =
1335 serialize_check_json_output(output, None).expect("dead-code output should serialize");
1336
1337 let entries = value["unused_files"]
1338 .as_array()
1339 .expect("unused_files array")
1340 .clone();
1341 let find = |name: &str| {
1342 entries
1343 .iter()
1344 .find(|entry| {
1345 entry["path"]
1346 .as_str()
1347 .is_some_and(|path| path.ends_with(name))
1348 })
1349 .cloned()
1350 .expect("finding present")
1351 };
1352
1353 assert!(
1354 find("clean.ts").get("reachability_caveats").is_none(),
1355 "a finding with no caveat must keep the previous wire shape exactly"
1356 );
1357
1358 let flagged = find("orphan.ts");
1359 assert_eq!(
1360 flagged["reachability_caveats"],
1361 serde_json::json!(["incomplete-file-analysis", "incomplete-import-graph"]),
1362 "both caveats are named on the wire, in declaration order"
1363 );
1364 assert_eq!(
1365 flagged["actions"].as_array().map(Vec::len),
1366 Some(2),
1367 "the caveat never trims the finding's actions"
1368 );
1369 assert_eq!(
1370 flagged["actions"][0]["type"], "delete-file",
1371 "nor reorders them, so a consumer reading actions[0].type is unaffected"
1372 );
1373 assert_eq!(
1374 flagged["actions"][0]["auto_fixable"],
1375 serde_json::json!(false),
1376 "but the mutation it gates must not advertise itself as applicable"
1377 );
1378 }
1379
1380 #[test]
1381 fn grouped_check_json_output_uses_output_owned_root_contract() {
1382 let root = std::path::Path::new("/project");
1383 let output = CheckGroupedOutput {
1384 gate_outcomes: None,
1385 request_outcomes: None,
1386 package_baselines: Vec::new(),
1387 baseline_staleness: None,
1388 finding_id_query: None,
1389 schema_version: SchemaVersion(7),
1390 version: ToolVersion("0.0.0".to_string()),
1391 elapsed_ms: ElapsedMs(1),
1392 grouped_by: GroupByMode::Directory,
1393 total_issues: 0,
1394 groups: Vec::new(),
1395 unused_load_data_keys_global_abstain: false,
1396 meta: None,
1397 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1398 root,
1399 root.join("src/unreadable.ts"),
1400 WorkspaceDiagnosticKind::SourceReadFailure {
1401 error: "permission denied".to_string(),
1402 },
1403 )],
1404 next_steps: Vec::new(),
1405 };
1406
1407 let value = serialize_check_grouped_json_output(output, Some("run-group"))
1408 .expect("grouped check output should serialize");
1409
1410 assert_eq!(value["kind"], "dead-code-grouped");
1411 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-group");
1412 assert_eq!(
1413 value["workspace_diagnostics"][0]["path"],
1414 "/project/src/unreadable.ts"
1415 );
1416 assert_eq!(
1417 value["workspace_diagnostics"][0]["kind"],
1418 "source-read-failure"
1419 );
1420 }
1421
1422 #[test]
1423 fn workspace_diagnostics_serialize_typed_kind_path_message() {
1424 let root = std::path::Path::new("/project");
1425 let output = build_check_output(CheckOutputInput {
1426 schema_version: 7,
1427 version: "0.0.0".to_string(),
1428 elapsed: Duration::from_millis(1),
1429 results: AnalysisResults::default(),
1430 config_fixable: false,
1431 meta: None,
1432 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1433 root,
1434 root.join("packages/legacy"),
1435 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1436 )],
1437 next_steps: Vec::new(),
1438 });
1439
1440 let value = serde_json::to_value(&output).expect("check output serializes");
1441 let diag = &value["workspace_diagnostics"][0];
1442 assert_eq!(diag["kind"], "undeclared-workspace");
1443 assert!(
1444 diag["path"]
1445 .as_str()
1446 .is_some_and(|path| path.contains("packages/legacy")),
1447 "path field is carried verbatim: {diag}"
1448 );
1449 assert!(
1450 diag["message"]
1451 .as_str()
1452 .is_some_and(|message| message.contains("packages/legacy")),
1453 "message is rendered from kind + path: {diag}"
1454 );
1455 }
1456
1457 #[test]
1458 fn source_read_failure_workspace_diagnostic_serializes_error_payload() {
1459 let root = std::path::Path::new("/project");
1460 let output = build_check_output(CheckOutputInput {
1461 schema_version: 7,
1462 version: "0.0.0".to_string(),
1463 elapsed: Duration::from_millis(1),
1464 results: AnalysisResults::default(),
1465 config_fixable: false,
1466 meta: None,
1467 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1468 root,
1469 root.join("src/removed.ts"),
1470 WorkspaceDiagnosticKind::SourceReadFailure {
1471 error: "No such file or directory".to_string(),
1472 },
1473 )],
1474 next_steps: Vec::new(),
1475 });
1476
1477 let value = serde_json::to_value(&output).expect("check output serializes");
1478 let diagnostic = &value["workspace_diagnostics"][0];
1479 assert_eq!(diagnostic["kind"], "source-read-failure");
1480 assert_eq!(diagnostic["error"], "No such file or directory");
1481 assert!(
1482 diagnostic["message"]
1483 .as_str()
1484 .is_some_and(|message| message.contains("src/removed.ts"))
1485 );
1486 }
1487
1488 fn suppress_comment(actions: &[IssueAction]) -> Option<&str> {
1489 actions.iter().find_map(|action| match action {
1490 IssueAction::SuppressLine(action) => Some(action.comment.as_str()),
1491 _ => None,
1492 })
1493 }
1494}