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