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
28pub const ARCHITECTURE_SCHEMA_VERSION: u32 = 1;
33
34#[cfg(feature = "schema")]
39#[allow(dead_code, reason = "schema-only type used by the field projection")]
40#[derive(schemars::JsonSchema)]
41#[schemars(extend("const" = ARCHITECTURE_SCHEMA_VERSION))]
42pub struct ArchitectureSchemaVersion(u32);
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum CheckEnvelope {
52 #[default]
54 DeadCode,
55 Architecture,
57}
58
59impl CheckEnvelope {
60 #[must_use]
62 pub const fn kind(self) -> &'static str {
63 match self {
64 Self::DeadCode => "dead-code",
65 Self::Architecture => "architecture",
66 }
67 }
68
69 #[must_use]
71 pub const fn grouped_kind(self) -> &'static str {
72 match self {
73 Self::DeadCode => "dead-code-grouped",
74 Self::Architecture => "architecture-grouped",
75 }
76 }
77
78 #[must_use]
80 pub const fn schema_version(self) -> u32 {
81 match self {
82 Self::DeadCode => CHECK_SCHEMA_VERSION,
83 Self::Architecture => ARCHITECTURE_SCHEMA_VERSION,
84 }
85 }
86
87 #[must_use]
89 pub const fn command(self) -> &'static str {
90 match self {
91 Self::DeadCode => "dead-code",
92 Self::Architecture => "architecture",
93 }
94 }
95}
96
97#[expect(
98 clippy::trivially_copy_pass_by_ref,
99 reason = "serde `skip_serializing_if` passes the field by reference"
100)]
101const fn is_zero(count: &usize) -> bool {
102 *count == 0
103}
104
105#[derive(Debug, Clone, Serialize)]
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116#[cfg_attr(feature = "schema", schemars(title = "fallow dead-code --format json"))]
117pub struct CheckOutput {
118 #[cfg_attr(feature = "schema", schemars(with = "CheckSchemaVersion"))]
120 pub schema_version: SchemaVersion,
121 pub version: ToolVersion,
123 pub elapsed_ms: ElapsedMs,
125 pub total_issues: usize,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub entry_points: Option<EntryPoints>,
130 pub summary: CheckSummary,
132 #[serde(flatten)]
135 pub results: AnalysisResults,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub baseline_deltas: Option<BaselineDeltas>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub baseline: Option<BaselineMatch>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub baseline_staleness: Option<crate::BaselineStaleness>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub finding_id_query: Option<crate::FindingIdQuery>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub regression: Option<RegressionResult>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub gate_outcomes: Option<crate::GateOutcomes>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub request_outcomes: Option<crate::RequestOutcomes>,
178 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub package_baselines: Vec<crate::PackageBaselineStatus>,
182 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
185 pub meta: Option<Meta>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
237 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
238 #[serde(default, skip_serializing_if = "Vec::is_empty")]
245 pub next_steps: Vec<NextStep>,
246}
247
248#[derive(Debug, Clone, Serialize)]
255#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
256#[cfg_attr(
257 feature = "schema",
258 schemars(
259 title = "fallow dead-code --group-by <owner|directory|package|section> --format json"
260 )
261)]
262pub struct CheckGroupedOutput {
263 #[cfg_attr(feature = "schema", schemars(with = "CheckSchemaVersion"))]
265 pub schema_version: SchemaVersion,
266 pub version: ToolVersion,
268 pub elapsed_ms: ElapsedMs,
270 pub grouped_by: GroupByMode,
272 pub total_issues: usize,
274 pub groups: Vec<CheckGroupedEntry>,
276 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
282 pub unused_load_data_keys_global_abstain: bool,
283 #[serde(default, skip_serializing_if = "is_zero")]
288 pub cascade_hidden: usize,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub baseline_staleness: Option<crate::BaselineStaleness>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub finding_id_query: Option<crate::FindingIdQuery>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub gate_outcomes: Option<crate::GateOutcomes>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub request_outcomes: Option<crate::RequestOutcomes>,
322 #[serde(default, skip_serializing_if = "Vec::is_empty")]
324 pub package_baselines: Vec<crate::PackageBaselineStatus>,
325 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
328 pub meta: Option<Meta>,
329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
332 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
333 #[serde(default, skip_serializing_if = "Vec::is_empty")]
336 pub next_steps: Vec<NextStep>,
337}
338
339#[derive(Debug, Clone, Serialize)]
343#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
344pub struct CheckGroupedEntry {
345 pub key: String,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub owners: Option<Vec<String>>,
350 pub total_issues: usize,
352 #[serde(flatten)]
354 pub results: AnalysisResults,
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
363#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
364#[serde(rename_all = "lowercase")]
365pub enum GroupByMode {
366 Owner,
368 Directory,
370 Package,
372 Section,
374}
375
376pub struct CheckOutputInput {
378 pub schema_version: u32,
380 pub version: String,
382 pub elapsed: Duration,
384 pub results: AnalysisResults,
386 pub config_fixable: bool,
389 pub meta: Option<Meta>,
391 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
394 pub next_steps: Vec<NextStep>,
396}
397
398#[must_use]
400pub fn build_check_output(input: CheckOutputInput) -> CheckOutput {
401 let mut results = input.results;
402 apply_config_fixable_to_duplicate_exports(&mut results, input.config_fixable);
403 harmonize_multi_kind_suppress_line_actions(&mut results);
404 CheckOutput {
405 schema_version: SchemaVersion(input.schema_version),
406 version: ToolVersion(input.version),
407 elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
408 total_issues: results.total_issues(),
409 entry_points: results
410 .entry_point_summary
411 .as_ref()
412 .map(|entry_points| EntryPoints {
413 total: entry_points.total,
414 sources: entry_points
415 .by_source
416 .iter()
417 .map(|(key, value)| (key.replace(' ', "_"), *value))
418 .collect(),
419 }),
420 summary: build_check_summary(&results),
421 results,
422 baseline_deltas: None,
423 baseline: None,
424 baseline_staleness: None,
425 finding_id_query: None,
426 regression: None,
427 gate_outcomes: None,
428 request_outcomes: None,
429 package_baselines: Vec::new(),
430 meta: input.meta,
431 workspace_diagnostics: input.workspace_diagnostics,
432 next_steps: input.next_steps,
433 }
434}
435
436fn serialize_check_family_json_output<T: Serialize>(
437 output: T,
438 kind: &'static str,
439 analysis_run_id: Option<&str>,
440) -> Result<serde_json::Value, serde_json::Error> {
441 let mut value = serialize_named_json_output(output, kind)?;
442 attach_telemetry_meta(&mut value, analysis_run_id);
443 Ok(value)
444}
445
446pub fn serialize_check_json_output(
452 output: CheckOutput,
453 analysis_run_id: Option<&str>,
454) -> Result<serde_json::Value, serde_json::Error> {
455 serialize_check_family_json_output(output, "dead-code", analysis_run_id)
456}
457
458pub fn serialize_check_grouped_json_output(
465 output: CheckGroupedOutput,
466 analysis_run_id: Option<&str>,
467) -> Result<serde_json::Value, serde_json::Error> {
468 serialize_check_family_json_output(output, "dead-code-grouped", analysis_run_id)
469}
470
471pub fn serialize_architecture_json_output(
480 output: CheckOutput,
481 analysis_run_id: Option<&str>,
482) -> Result<serde_json::Value, serde_json::Error> {
483 serialize_check_family_json_output(output, CheckEnvelope::Architecture.kind(), analysis_run_id)
484}
485
486pub fn serialize_architecture_grouped_json_output(
492 output: CheckGroupedOutput,
493 analysis_run_id: Option<&str>,
494) -> Result<serde_json::Value, serde_json::Error> {
495 serialize_check_family_json_output(
496 output,
497 CheckEnvelope::Architecture.grouped_kind(),
498 analysis_run_id,
499 )
500}
501
502pub fn apply_config_fixable_to_duplicate_exports(
505 results: &mut AnalysisResults,
506 config_fixable: bool,
507) {
508 if !config_fixable {
509 return;
510 }
511 for finding in &mut results.duplicate_exports {
512 finding.set_config_fixable(true);
513 }
514}
515
516type SuppressAnchor = (String, u32);
517
518macro_rules! visit_suppress_line_findings {
519 ($results:expr, $visit:expr) => {{
520 let results = $results;
521 for finding in &results.unused_exports {
522 $visit(&finding.export.path, finding.export.line, &finding.actions);
523 }
524 for finding in &results.unused_types {
525 $visit(&finding.export.path, finding.export.line, &finding.actions);
526 }
527 for finding in &results.private_type_leaks {
528 $visit(&finding.leak.path, finding.leak.line, &finding.actions);
529 }
530 for finding in &results.deprecated_exports_in_use {
531 $visit(&finding.export.path, finding.export.line, &finding.actions);
532 }
533 for finding in &results.unused_enum_members {
534 $visit(&finding.member.path, finding.member.line, &finding.actions);
535 }
536 for finding in &results.unused_class_members {
537 $visit(&finding.member.path, finding.member.line, &finding.actions);
538 }
539 for finding in &results.unused_store_members {
540 $visit(&finding.member.path, finding.member.line, &finding.actions);
541 }
542 for finding in &results.unresolved_imports {
543 $visit(&finding.import.path, finding.import.line, &finding.actions);
544 }
545 for finding in &results.unused_dependencies {
546 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
547 }
548 for finding in &results.unused_dev_dependencies {
549 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
550 }
551 for finding in &results.unused_optional_dependencies {
552 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
553 }
554 for finding in &results.type_only_dependencies {
555 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
556 }
557 for finding in &results.test_only_dependencies {
558 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
559 }
560 for finding in &results.dev_dependencies_in_production {
561 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
562 }
563 for finding in &results.circular_dependencies {
564 if let Some(path) = finding.cycle.files.first() {
565 $visit(path, finding.cycle.line, &finding.actions);
566 }
567 }
568 for finding in &results.package_cycles {
569 if let Some(edge) = finding.cycle.edges.first() {
570 $visit(&edge.path, edge.line, &finding.actions);
571 }
572 }
573 for finding in &results.boundary_violations {
574 $visit(
575 &finding.violation.from_path,
576 finding.violation.line,
577 &finding.actions,
578 );
579 }
580 for finding in &results.boundary_coverage_violations {
581 $visit(
582 &finding.violation.path,
583 finding.violation.line,
584 &finding.actions,
585 );
586 }
587 for finding in &results.boundary_call_violations {
588 $visit(
589 &finding.violation.path,
590 finding.violation.line,
591 &finding.actions,
592 );
593 }
594 for finding in &results.policy_violations {
595 $visit(
596 &finding.violation.path,
597 finding.violation.line,
598 &finding.actions,
599 );
600 }
601 for finding in &results.unused_catalog_entries {
602 $visit(&finding.entry.path, finding.entry.line, &finding.actions);
603 }
604 for finding in &results.empty_catalog_groups {
605 $visit(&finding.group.path, finding.group.line, &finding.actions);
606 }
607 for finding in &results.unresolved_catalog_references {
608 $visit(
609 &finding.reference.path,
610 finding.reference.line,
611 &finding.actions,
612 );
613 }
614 for finding in &results.unused_dependency_overrides {
615 $visit(&finding.entry.path, finding.entry.line, &finding.actions);
616 }
617 for finding in &results.misconfigured_dependency_overrides {
618 $visit(&finding.entry.path, finding.entry.line, &finding.actions);
619 }
620 for finding in &results.invalid_client_exports {
621 $visit(&finding.export.path, finding.export.line, &finding.actions);
622 }
623 for finding in &results.mixed_client_server_barrels {
624 $visit(&finding.barrel.path, finding.barrel.line, &finding.actions);
625 }
626 for finding in &results.misplaced_directives {
627 $visit(
628 &finding.directive_site.path,
629 finding.directive_site.line,
630 &finding.actions,
631 );
632 }
633 for finding in &results.unprovided_injects {
634 $visit(&finding.inject.path, finding.inject.line, &finding.actions);
635 }
636 for finding in &results.unrendered_components {
637 $visit(
638 &finding.component.path,
639 finding.component.line,
640 &finding.actions,
641 );
642 }
643 for finding in &results.route_collisions {
644 $visit(
645 &finding.collision.path,
646 finding.collision.line,
647 &finding.actions,
648 );
649 }
650 for finding in &results.dynamic_segment_name_conflicts {
651 $visit(
652 &finding.conflict.path,
653 finding.conflict.line,
654 &finding.actions,
655 );
656 }
657 for finding in &results.unused_component_props {
658 $visit(&finding.prop.path, finding.prop.line, &finding.actions);
659 }
660 for finding in &results.absent_component_props {
661 $visit(&finding.prop.path, finding.prop.line, &finding.actions);
662 }
663 for finding in &results.unused_component_emits {
664 $visit(&finding.emit.path, finding.emit.line, &finding.actions);
665 }
666 for finding in &results.unused_component_inputs {
667 $visit(&finding.input.path, finding.input.line, &finding.actions);
668 }
669 for finding in &results.unused_component_outputs {
670 $visit(&finding.output.path, finding.output.line, &finding.actions);
671 }
672 for finding in &results.unused_svelte_events {
673 $visit(&finding.event.path, finding.event.line, &finding.actions);
674 }
675 for finding in &results.unused_effect_layers {
676 $visit(&finding.layer.path, finding.layer.line, &finding.actions);
677 }
678 for finding in &results.unused_tagged_errors {
679 $visit(&finding.error.path, finding.error.line, &finding.actions);
680 }
681 for finding in &results.unproduced_catch_tags {
682 $visit(
683 &finding.catch_tag.path,
684 finding.catch_tag.line,
685 &finding.actions,
686 );
687 }
688 for finding in &results.unused_server_actions {
689 $visit(&finding.action.path, finding.action.line, &finding.actions);
690 }
691 for finding in &results.unused_load_data_keys {
692 $visit(&finding.key.path, finding.key.line, &finding.actions);
693 }
694 for finding in &results.prop_drilling_chains {
695 if let Some(hop) = finding.chain.hops.first() {
696 $visit(&hop.file, hop.line, &finding.actions);
697 }
698 }
699 for finding in &results.thin_wrappers {
700 $visit(
701 &finding.wrapper.file,
702 finding.wrapper.line,
703 &finding.actions,
704 );
705 }
706 for finding in &results.duplicate_prop_shapes {
707 $visit(&finding.shape.file, finding.shape.line, &finding.actions);
708 }
709 }};
710}
711
712macro_rules! visit_suppress_line_findings_mut {
713 ($results:expr, $visit:expr) => {{
714 let results = $results;
715 for finding in &mut results.unused_exports {
716 $visit(
717 &finding.export.path,
718 finding.export.line,
719 &mut finding.actions,
720 );
721 }
722 for finding in &mut results.unused_types {
723 $visit(
724 &finding.export.path,
725 finding.export.line,
726 &mut finding.actions,
727 );
728 }
729 for finding in &mut results.private_type_leaks {
730 $visit(&finding.leak.path, finding.leak.line, &mut finding.actions);
731 }
732 for finding in &mut results.deprecated_exports_in_use {
733 $visit(
734 &finding.export.path,
735 finding.export.line,
736 &mut finding.actions,
737 );
738 }
739 for finding in &mut results.unused_enum_members {
740 $visit(
741 &finding.member.path,
742 finding.member.line,
743 &mut finding.actions,
744 );
745 }
746 for finding in &mut results.unused_class_members {
747 $visit(
748 &finding.member.path,
749 finding.member.line,
750 &mut finding.actions,
751 );
752 }
753 for finding in &mut results.unused_store_members {
754 $visit(
755 &finding.member.path,
756 finding.member.line,
757 &mut finding.actions,
758 );
759 }
760 for finding in &mut results.unresolved_imports {
761 $visit(
762 &finding.import.path,
763 finding.import.line,
764 &mut finding.actions,
765 );
766 }
767 for finding in &mut results.unused_dependencies {
768 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
769 }
770 for finding in &mut results.unused_dev_dependencies {
771 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
772 }
773 for finding in &mut results.unused_optional_dependencies {
774 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
775 }
776 for finding in &mut results.type_only_dependencies {
777 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
778 }
779 for finding in &mut results.test_only_dependencies {
780 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
781 }
782 for finding in &mut results.dev_dependencies_in_production {
783 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
784 }
785 for finding in &mut results.circular_dependencies {
786 if let Some(path) = finding.cycle.files.first() {
787 $visit(path, finding.cycle.line, &mut finding.actions);
788 }
789 }
790 for finding in &mut results.package_cycles {
791 if let Some(edge) = finding.cycle.edges.first() {
792 $visit(&edge.path, edge.line, &mut finding.actions);
793 }
794 }
795 for finding in &mut results.boundary_violations {
796 $visit(
797 &finding.violation.from_path,
798 finding.violation.line,
799 &mut finding.actions,
800 );
801 }
802 for finding in &mut results.boundary_coverage_violations {
803 $visit(
804 &finding.violation.path,
805 finding.violation.line,
806 &mut finding.actions,
807 );
808 }
809 for finding in &mut results.boundary_call_violations {
810 $visit(
811 &finding.violation.path,
812 finding.violation.line,
813 &mut finding.actions,
814 );
815 }
816 for finding in &mut results.policy_violations {
817 $visit(
818 &finding.violation.path,
819 finding.violation.line,
820 &mut finding.actions,
821 );
822 }
823 for finding in &mut results.unused_catalog_entries {
824 $visit(
825 &finding.entry.path,
826 finding.entry.line,
827 &mut finding.actions,
828 );
829 }
830 for finding in &mut results.empty_catalog_groups {
831 $visit(
832 &finding.group.path,
833 finding.group.line,
834 &mut finding.actions,
835 );
836 }
837 for finding in &mut results.unresolved_catalog_references {
838 $visit(
839 &finding.reference.path,
840 finding.reference.line,
841 &mut finding.actions,
842 );
843 }
844 for finding in &mut results.unused_dependency_overrides {
845 $visit(
846 &finding.entry.path,
847 finding.entry.line,
848 &mut finding.actions,
849 );
850 }
851 for finding in &mut results.misconfigured_dependency_overrides {
852 $visit(
853 &finding.entry.path,
854 finding.entry.line,
855 &mut finding.actions,
856 );
857 }
858 for finding in &mut results.invalid_client_exports {
859 $visit(
860 &finding.export.path,
861 finding.export.line,
862 &mut finding.actions,
863 );
864 }
865 for finding in &mut results.mixed_client_server_barrels {
866 $visit(
867 &finding.barrel.path,
868 finding.barrel.line,
869 &mut finding.actions,
870 );
871 }
872 for finding in &mut results.misplaced_directives {
873 $visit(
874 &finding.directive_site.path,
875 finding.directive_site.line,
876 &mut finding.actions,
877 );
878 }
879 for finding in &mut results.unprovided_injects {
880 $visit(
881 &finding.inject.path,
882 finding.inject.line,
883 &mut finding.actions,
884 );
885 }
886 for finding in &mut results.unrendered_components {
887 $visit(
888 &finding.component.path,
889 finding.component.line,
890 &mut finding.actions,
891 );
892 }
893 for finding in &mut results.route_collisions {
894 $visit(
895 &finding.collision.path,
896 finding.collision.line,
897 &mut finding.actions,
898 );
899 }
900 for finding in &mut results.dynamic_segment_name_conflicts {
901 $visit(
902 &finding.conflict.path,
903 finding.conflict.line,
904 &mut finding.actions,
905 );
906 }
907 for finding in &mut results.unused_component_props {
908 $visit(&finding.prop.path, finding.prop.line, &mut finding.actions);
909 }
910 for finding in &mut results.absent_component_props {
911 $visit(&finding.prop.path, finding.prop.line, &mut finding.actions);
912 }
913 for finding in &mut results.unused_component_emits {
914 $visit(&finding.emit.path, finding.emit.line, &mut finding.actions);
915 }
916 for finding in &mut results.unused_component_inputs {
917 $visit(
918 &finding.input.path,
919 finding.input.line,
920 &mut finding.actions,
921 );
922 }
923 for finding in &mut results.unused_component_outputs {
924 $visit(
925 &finding.output.path,
926 finding.output.line,
927 &mut finding.actions,
928 );
929 }
930 for finding in &mut results.unused_svelte_events {
931 $visit(
932 &finding.event.path,
933 finding.event.line,
934 &mut finding.actions,
935 );
936 }
937 for finding in &mut results.unused_effect_layers {
938 $visit(
939 &finding.layer.path,
940 finding.layer.line,
941 &mut finding.actions,
942 );
943 }
944 for finding in &mut results.unused_tagged_errors {
945 $visit(
946 &finding.error.path,
947 finding.error.line,
948 &mut finding.actions,
949 );
950 }
951 for finding in &mut results.unproduced_catch_tags {
952 $visit(
953 &finding.catch_tag.path,
954 finding.catch_tag.line,
955 &mut finding.actions,
956 );
957 }
958 for finding in &mut results.unused_server_actions {
959 $visit(
960 &finding.action.path,
961 finding.action.line,
962 &mut finding.actions,
963 );
964 }
965 for finding in &mut results.unused_load_data_keys {
966 $visit(&finding.key.path, finding.key.line, &mut finding.actions);
967 }
968 for finding in &mut results.prop_drilling_chains {
969 if let Some(hop) = finding.chain.hops.first() {
970 $visit(&hop.file, hop.line, &mut finding.actions);
971 }
972 }
973 for finding in &mut results.thin_wrappers {
974 $visit(
975 &finding.wrapper.file,
976 finding.wrapper.line,
977 &mut finding.actions,
978 );
979 }
980 for finding in &mut results.duplicate_prop_shapes {
981 $visit(
982 &finding.shape.file,
983 finding.shape.line,
984 &mut finding.actions,
985 );
986 }
987 }};
988}
989
990pub fn harmonize_multi_kind_suppress_line_actions(results: &mut AnalysisResults) {
996 let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
997 collect_dead_code_suppress_line_anchors(results, &mut anchors);
998 retain_multi_kind_anchors(&mut anchors);
999 if anchors.is_empty() {
1000 return;
1001 }
1002 rewrite_dead_code_suppress_line_actions(results, &anchors);
1003}
1004
1005pub fn harmonize_dead_code_health_suppress_line_actions(
1011 dead_code: Option<&mut AnalysisResults>,
1012 health: Option<&mut HealthReport>,
1013) {
1014 let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
1015 if let Some(results) = dead_code.as_deref() {
1016 collect_dead_code_suppress_line_anchors(results, &mut anchors);
1017 }
1018 if let Some(report) = health.as_deref() {
1019 collect_health_suppress_line_anchors(report, &mut anchors);
1020 }
1021
1022 retain_multi_kind_anchors(&mut anchors);
1023 if anchors.is_empty() {
1024 return;
1025 }
1026
1027 if let Some(results) = dead_code {
1028 rewrite_dead_code_suppress_line_actions(results, &anchors);
1029 }
1030 if let Some(report) = health {
1031 rewrite_health_suppress_line_actions(report, &anchors);
1032 }
1033}
1034
1035fn retain_multi_kind_anchors(anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>) {
1036 anchors.retain(|_, kinds| {
1037 sort_suppression_kinds(kinds);
1038 kinds.dedup();
1039 kinds.len() > 1
1040 });
1041}
1042
1043fn collect_dead_code_suppress_line_anchors(
1044 results: &AnalysisResults,
1045 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
1046) {
1047 visit_suppress_line_findings!(results, |path: &Path, line, actions: &[IssueAction]| {
1048 collect_action_kinds(path, line, actions, anchors);
1049 });
1050}
1051
1052fn rewrite_dead_code_suppress_line_actions(
1053 results: &mut AnalysisResults,
1054 anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
1055) {
1056 visit_suppress_line_findings_mut!(
1057 results,
1058 |path: &Path, line, actions: &mut Vec<IssueAction>| {
1059 let anchor = suppress_anchor(path, line);
1060 if let Some(kinds) = anchors.get(&anchor) {
1061 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
1062 rewrite_action_comments(actions, &comment);
1063 }
1064 }
1065 );
1066}
1067
1068fn collect_health_suppress_line_anchors(
1069 report: &HealthReport,
1070 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
1071) {
1072 for finding in &report.findings {
1073 collect_health_action_kinds(
1074 &finding.violation.path,
1075 finding.violation.line,
1076 &finding.actions,
1077 anchors,
1078 );
1079 }
1080 for finding in &report.prop_drilling_chains {
1081 if let Some(hop) = finding.chain.hops.first() {
1082 collect_action_kinds(&hop.file, hop.line, &finding.actions, anchors);
1083 }
1084 }
1085}
1086
1087fn rewrite_health_suppress_line_actions(
1088 report: &mut HealthReport,
1089 anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
1090) {
1091 for finding in &mut report.findings {
1092 let anchor = suppress_anchor(&finding.violation.path, finding.violation.line);
1093 if let Some(kinds) = anchors.get(&anchor) {
1094 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
1095 rewrite_health_action_comments(&mut finding.actions, &comment);
1096 }
1097 }
1098 for finding in &mut report.prop_drilling_chains {
1099 if let Some(hop) = finding.chain.hops.first() {
1100 let anchor = suppress_anchor(&hop.file, hop.line);
1101 if let Some(kinds) = anchors.get(&anchor) {
1102 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
1103 rewrite_action_comments(&mut finding.actions, &comment);
1104 }
1105 }
1106 }
1107}
1108
1109fn collect_action_kinds(
1110 path: &Path,
1111 line: u32,
1112 actions: &[IssueAction],
1113 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
1114) {
1115 for action in actions {
1116 if let Some(comment) = suppress_line_comment(action) {
1117 let kinds = anchors.entry(suppress_anchor(path, line)).or_default();
1118 for kind in parse_suppress_line_comment(comment) {
1119 if !kinds.iter().any(|existing| existing == &kind) {
1120 kinds.push(kind);
1121 }
1122 }
1123 }
1124 }
1125}
1126
1127fn collect_health_action_kinds(
1128 path: &Path,
1129 line: u32,
1130 actions: &[HealthFindingAction],
1131 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
1132) {
1133 for action in actions {
1134 if let Some(comment) = health_suppress_line_comment(action) {
1135 let kinds = anchors.entry(suppress_anchor(path, line)).or_default();
1136 for kind in parse_suppress_line_comment(comment) {
1137 if !kinds.iter().any(|existing| existing == &kind) {
1138 kinds.push(kind);
1139 }
1140 }
1141 }
1142 }
1143}
1144
1145fn rewrite_action_comments(actions: &mut [IssueAction], comment: &str) {
1146 for action in actions {
1147 if let IssueAction::SuppressLine(suppress) = action {
1148 suppress.comment = comment.to_string();
1149 }
1150 }
1151}
1152
1153fn rewrite_health_action_comments(actions: &mut [HealthFindingAction], comment: &str) {
1154 for action in actions {
1155 if matches!(action.kind, HealthFindingActionType::SuppressLine) {
1156 action.comment = Some(comment.to_string());
1157 }
1158 }
1159}
1160
1161fn suppress_anchor(path: &Path, line: u32) -> SuppressAnchor {
1162 (path.display().to_string(), line)
1163}
1164
1165fn suppress_line_comment(action: &IssueAction) -> Option<&str> {
1166 match action {
1167 IssueAction::SuppressLine(action) => Some(&action.comment),
1168 _ => None,
1169 }
1170}
1171
1172fn health_suppress_line_comment(action: &HealthFindingAction) -> Option<&str> {
1173 matches!(action.kind, HealthFindingActionType::SuppressLine)
1174 .then_some(())
1175 .and(action.comment.as_deref())
1176}
1177
1178fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
1179 comment
1180 .strip_prefix("// fallow-ignore-next-line ")
1181 .map(|rest| {
1182 rest.split(|c: char| c == ',' || c.is_whitespace())
1183 .filter(|token| !token.is_empty())
1184 .map(str::to_string)
1185 .collect()
1186 })
1187 .unwrap_or_default()
1188}
1189
1190fn sort_suppression_kinds(kinds: &mut [String]) {
1191 kinds.sort_by_key(|kind| suppression_kind_rank(kind));
1192}
1193
1194fn suppression_kind_rank(kind: &str) -> usize {
1195 match kind {
1196 "unused-file" => 0,
1197 "unused-export" => 1,
1198 "unused-type" => 2,
1199 "private-type-leak" => 3,
1200 "unused-enum-member" => 4,
1201 "unused-class-member" => 5,
1202 "unused-store-member" => 6,
1203 "unresolved-import" => 7,
1204 "unlisted-dependency" => 8,
1205 "duplicate-export" => 9,
1206 "circular-dependency" => 10,
1207 "re-export-cycle" => 11,
1208 "boundary-violation" => 12,
1209 "code-duplication" => 13,
1210 "complexity" => 14,
1211 "unprovided-inject" => 15,
1212 "unrendered-component" => 16,
1213 "unused-server-action" => 17,
1214 "deprecated-export-in-use" => 18,
1215 "package-cycle" => 19,
1216 _ => usize::MAX,
1217 }
1218}
1219
1220#[must_use]
1222pub fn build_check_summary(results: &AnalysisResults) -> CheckSummary {
1223 CheckSummary {
1224 total_issues: results.total_issues(),
1225 prop_drilling_chains: results.enforced_prop_drilling_count(),
1226 thin_wrappers: results.enforced_thin_wrapper_count(),
1227 duplicate_prop_shapes: results.enforced_duplicate_prop_shape_count(),
1228 unused_files: results.unused_files.len(),
1229 unused_exports: results.unused_exports.len(),
1230 unused_types: results.unused_types.len(),
1231 private_type_leaks: results.private_type_leaks.len(),
1232 deprecated_exports_in_use: results.deprecated_exports_in_use.len(),
1233 unused_dependencies: results.unused_dependencies.len()
1234 + results.unused_dev_dependencies.len()
1235 + results.unused_optional_dependencies.len(),
1236 unused_enum_members: results.unused_enum_members.len(),
1237 unused_class_members: results.unused_class_members.len(),
1238 unused_store_members: results.unused_store_members.len(),
1239 unresolved_imports: results.unresolved_imports.len(),
1240 unlisted_dependencies: results.unlisted_dependencies.len(),
1241 duplicate_exports: results.duplicate_exports.len(),
1242 type_only_dependencies: results.type_only_dependencies.len(),
1243 test_only_dependencies: results.test_only_dependencies.len(),
1244 dev_dependencies_in_production: results.dev_dependencies_in_production.len(),
1245 circular_dependencies: results.circular_dependencies.len(),
1246 re_export_cycles: results.re_export_cycles.len(),
1247 package_cycles: results.package_cycles.len(),
1248 boundary_violations: results.boundary_violations.len(),
1249 boundary_coverage_violations: results.boundary_coverage_violations.len(),
1250 boundary_call_violations: results.boundary_call_violations.len(),
1251 policy_violations: results.policy_violations.len(),
1252 stale_suppressions: results.stale_suppressions.len(),
1253 unused_catalog_entries: results.unused_catalog_entries.len(),
1254 empty_catalog_groups: results.empty_catalog_groups.len(),
1255 unresolved_catalog_references: results.unresolved_catalog_references.len(),
1256 unused_dependency_overrides: results.unused_dependency_overrides.len(),
1257 misconfigured_dependency_overrides: results.misconfigured_dependency_overrides.len(),
1258 invalid_client_exports: results.invalid_client_exports.len(),
1259 mixed_client_server_barrels: results.mixed_client_server_barrels.len(),
1260 misplaced_directives: results.misplaced_directives.len(),
1261 unprovided_injects: results.unprovided_injects.len(),
1262 unrendered_components: results.unrendered_components.len(),
1263 unused_component_props: results.unused_component_props.len(),
1264 absent_component_props: results.absent_component_props.len(),
1265 unused_component_emits: results.unused_component_emits.len(),
1266 unused_component_inputs: results.unused_component_inputs.len(),
1267 unused_component_outputs: results.unused_component_outputs.len(),
1268 unused_svelte_events: results.unused_svelte_events.len(),
1269 unused_effect_layers: results.unused_effect_layers.len(),
1270 unused_tagged_errors: results.unused_tagged_errors.len(),
1271 unproduced_catch_tags: results.unproduced_catch_tags.len(),
1272 unused_server_actions: results.unused_server_actions.len(),
1273 unused_load_data_keys: results.unused_load_data_keys.len(),
1274 route_collisions: results.route_collisions.len(),
1275 dynamic_segment_name_conflicts: results.dynamic_segment_name_conflicts.len(),
1276 }
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281 use super::*;
1282 use crate::{ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding};
1283 use fallow_types::output_dead_code::{
1284 ReachabilityCaveat, UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
1285 };
1286 use fallow_types::results::{UnusedExport, UnusedFile};
1287 use fallow_types::workspace::WorkspaceDiagnosticKind;
1288
1289 #[test]
1290 fn build_check_output_counts_issues_and_entry_points() {
1291 let mut results = AnalysisResults::default();
1292 results
1293 .unused_files
1294 .push(UnusedFileFinding::with_actions(UnusedFile {
1295 path: "src/unused.ts".into(),
1296 }));
1297
1298 let output = build_check_output(CheckOutputInput {
1299 schema_version: 7,
1300 version: "0.0.0".to_string(),
1301 elapsed: Duration::from_millis(42),
1302 results,
1303 config_fixable: false,
1304 meta: None,
1305 workspace_diagnostics: Vec::new(),
1306 next_steps: Vec::new(),
1307 });
1308
1309 assert_eq!(output.schema_version.0, 7);
1310 assert_eq!(output.total_issues, 1);
1311 assert_eq!(output.summary.unused_files, 1);
1312 assert_eq!(output.elapsed_ms.0, 42);
1313 }
1314
1315 #[test]
1316 fn build_check_output_harmonizes_multi_kind_suppress_actions_typed() {
1317 let mut results = AnalysisResults::default();
1318 let path = std::path::PathBuf::from("/project/src/shared.ts");
1319 results
1320 .unused_exports
1321 .push(UnusedExportFinding::with_actions(UnusedExport {
1322 path: path.clone(),
1323 export_name: "value".to_string(),
1324 is_type_only: false,
1325 line: 7,
1326 col: 0,
1327 span_start: 0,
1328 is_re_export: false,
1329 deprecated: false,
1330 deprecated_reason: None,
1331 }));
1332 results
1333 .unused_types
1334 .push(UnusedTypeFinding::with_actions(UnusedExport {
1335 path,
1336 export_name: "TypeOnly".to_string(),
1337 is_type_only: true,
1338 line: 7,
1339 col: 0,
1340 span_start: 0,
1341 is_re_export: false,
1342 deprecated: false,
1343 deprecated_reason: None,
1344 }));
1345
1346 let output = build_check_output(CheckOutputInput {
1347 schema_version: 7,
1348 version: "0.0.0".to_string(),
1349 elapsed: Duration::from_millis(42),
1350 results,
1351 config_fixable: false,
1352 meta: None,
1353 workspace_diagnostics: Vec::new(),
1354 next_steps: Vec::new(),
1355 });
1356
1357 let export_comment = suppress_comment(&output.results.unused_exports[0].actions);
1358 let type_comment = suppress_comment(&output.results.unused_types[0].actions);
1359 assert_eq!(
1360 export_comment,
1361 Some("// fallow-ignore-next-line unused-export, unused-type")
1362 );
1363 assert_eq!(type_comment, export_comment);
1364 }
1365
1366 #[test]
1367 fn harmonize_dead_code_health_suppress_actions_typed() {
1368 let mut results = AnalysisResults::default();
1369 let path = std::path::PathBuf::from("/project/src/shared.ts");
1370 results
1371 .unused_exports
1372 .push(UnusedExportFinding::with_actions(UnusedExport {
1373 path: path.clone(),
1374 export_name: "value".to_string(),
1375 is_type_only: false,
1376 line: 7,
1377 col: 0,
1378 span_start: 0,
1379 is_re_export: false,
1380 deprecated: false,
1381 deprecated_reason: None,
1382 }));
1383 let mut health = HealthReport {
1384 findings: vec![HealthFinding::new(
1385 ComplexityViolation {
1386 path,
1387 name: "expensive".to_string(),
1388 line: 7,
1389 col: 0,
1390 cyclomatic: 22,
1391 standard_cyclomatic: None,
1392 cognitive: 18,
1393 line_count: 40,
1394 param_count: 1,
1395 react_hook_count: 0,
1396 react_jsx_max_depth: 0,
1397 react_prop_count: 0,
1398 react_hook_profile: None,
1399 exceeded: ExceededThreshold::Both,
1400 severity: FindingSeverity::High,
1401 effective_severity: None,
1402 crap: None,
1403 mutation: None,
1404 coverage_pct: None,
1405 coverage_tier: None,
1406 coverage_source: None,
1407 inherited_from: None,
1408 component_rollup: None,
1409 contributions: Vec::new(),
1410 effective_thresholds: None,
1411 threshold_source: None,
1412 },
1413 vec![HealthFindingAction {
1414 kind: HealthFindingActionType::SuppressLine,
1415 auto_fixable: false,
1416 description: "Suppress with an inline comment above the function declaration"
1417 .to_string(),
1418 note: None,
1419 comment: Some("// fallow-ignore-next-line complexity".to_string()),
1420 placement: Some("above-function-declaration".to_string()),
1421 target_path: None,
1422 }],
1423 None,
1424 )],
1425 ..HealthReport::default()
1426 };
1427
1428 harmonize_dead_code_health_suppress_line_actions(Some(&mut results), Some(&mut health));
1429
1430 assert_eq!(
1431 suppress_comment(&results.unused_exports[0].actions),
1432 Some("// fallow-ignore-next-line unused-export, complexity")
1433 );
1434 assert_eq!(
1435 health.findings[0].actions[0].comment.as_deref(),
1436 Some("// fallow-ignore-next-line unused-export, complexity")
1437 );
1438 }
1439
1440 #[test]
1441 fn check_json_output_uses_output_owned_root_contract() {
1442 let output = build_check_output(CheckOutputInput {
1443 schema_version: 7,
1444 version: "0.0.0".to_string(),
1445 elapsed: Duration::from_millis(42),
1446 results: AnalysisResults::default(),
1447 config_fixable: false,
1448 meta: None,
1449 workspace_diagnostics: Vec::new(),
1450 next_steps: Vec::new(),
1451 });
1452
1453 let value = serialize_check_json_output(output, Some("run-check"))
1454 .expect("check output should serialize");
1455
1456 assert_eq!(value["kind"], "dead-code");
1457 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-check");
1458 }
1459
1460 #[test]
1461 fn architecture_json_output_has_architecture_kind_and_version() {
1462 let envelope = CheckEnvelope::Architecture;
1463 let output = build_check_output(CheckOutputInput {
1464 schema_version: envelope.schema_version(),
1465 version: "0.0.0".to_string(),
1466 elapsed: Duration::from_millis(1),
1467 results: AnalysisResults::default(),
1468 config_fixable: false,
1469 meta: None,
1470 workspace_diagnostics: Vec::new(),
1471 next_steps: Vec::new(),
1472 });
1473
1474 let value = serialize_architecture_json_output(output, Some("run-arch"))
1475 .expect("architecture output should serialize");
1476
1477 assert_eq!(value["kind"], "architecture");
1478 assert_eq!(value["schema_version"], ARCHITECTURE_SCHEMA_VERSION);
1479 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-arch");
1480 }
1481
1482 #[test]
1483 fn check_envelope_names_each_family_member() {
1484 assert_eq!(CheckEnvelope::DeadCode.kind(), "dead-code");
1485 assert_eq!(CheckEnvelope::DeadCode.grouped_kind(), "dead-code-grouped");
1486 assert_eq!(
1487 CheckEnvelope::DeadCode.schema_version(),
1488 CHECK_SCHEMA_VERSION
1489 );
1490 assert_eq!(CheckEnvelope::DeadCode.command(), "dead-code");
1491 assert_eq!(CheckEnvelope::Architecture.kind(), "architecture");
1492 assert_eq!(
1493 CheckEnvelope::Architecture.grouped_kind(),
1494 "architecture-grouped"
1495 );
1496 assert_eq!(
1497 CheckEnvelope::Architecture.schema_version(),
1498 ARCHITECTURE_SCHEMA_VERSION
1499 );
1500 assert_eq!(CheckEnvelope::Architecture.command(), "architecture");
1501 }
1502
1503 #[test]
1510 fn reachability_caveats_are_absent_when_clean_and_named_when_flagged() {
1511 let mut results = AnalysisResults::default();
1512 results
1513 .unused_files
1514 .push(UnusedFileFinding::with_actions(UnusedFile {
1515 path: "/project/src/clean.ts".into(),
1516 }));
1517 let mut flagged = UnusedFileFinding::with_actions(UnusedFile {
1518 path: "/project/src/orphan.ts".into(),
1519 });
1520 flagged.reachability_caveats = vec![
1521 ReachabilityCaveat::IncompleteFileAnalysis,
1522 ReachabilityCaveat::IncompleteImportGraph,
1523 ];
1524 results.unused_files.push(flagged);
1525
1526 let output = build_check_output(CheckOutputInput {
1527 schema_version: 7,
1528 version: "0.0.0".to_string(),
1529 elapsed: Duration::from_millis(1),
1530 results,
1531 config_fixable: false,
1532 meta: None,
1533 workspace_diagnostics: Vec::new(),
1534 next_steps: Vec::new(),
1535 });
1536 let value =
1537 serialize_check_json_output(output, None).expect("dead-code output should serialize");
1538
1539 let entries = value["unused_files"]
1540 .as_array()
1541 .expect("unused_files array")
1542 .clone();
1543 let find = |name: &str| {
1544 entries
1545 .iter()
1546 .find(|entry| {
1547 entry["path"]
1548 .as_str()
1549 .is_some_and(|path| path.ends_with(name))
1550 })
1551 .cloned()
1552 .expect("finding present")
1553 };
1554
1555 assert!(
1556 find("clean.ts").get("reachability_caveats").is_none(),
1557 "a finding with no caveat must keep the previous wire shape exactly"
1558 );
1559
1560 let flagged = find("orphan.ts");
1561 assert_eq!(
1562 flagged["reachability_caveats"],
1563 serde_json::json!(["incomplete-file-analysis", "incomplete-import-graph"]),
1564 "both caveats are named on the wire, in declaration order"
1565 );
1566 assert_eq!(
1567 flagged["actions"].as_array().map(Vec::len),
1568 Some(2),
1569 "the caveat never trims the finding's actions"
1570 );
1571 assert_eq!(
1572 flagged["actions"][0]["type"], "delete-file",
1573 "nor reorders them, so a consumer reading actions[0].type is unaffected"
1574 );
1575 assert_eq!(
1576 flagged["actions"][0]["auto_fixable"],
1577 serde_json::json!(false),
1578 "but the mutation it gates must not advertise itself as applicable"
1579 );
1580 }
1581
1582 #[test]
1583 fn grouped_check_json_output_uses_output_owned_root_contract() {
1584 let root = std::path::Path::new("/project");
1585 let output = CheckGroupedOutput {
1586 gate_outcomes: None,
1587 request_outcomes: None,
1588 package_baselines: Vec::new(),
1589 baseline_staleness: None,
1590 finding_id_query: None,
1591 schema_version: SchemaVersion(7),
1592 version: ToolVersion("0.0.0".to_string()),
1593 elapsed_ms: ElapsedMs(1),
1594 grouped_by: GroupByMode::Directory,
1595 total_issues: 0,
1596 groups: Vec::new(),
1597 unused_load_data_keys_global_abstain: false,
1598 cascade_hidden: 0,
1599 meta: None,
1600 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1601 root,
1602 root.join("src/unreadable.ts"),
1603 WorkspaceDiagnosticKind::SourceReadFailure {
1604 error: "permission denied".to_string(),
1605 },
1606 )],
1607 next_steps: Vec::new(),
1608 };
1609
1610 let value = serialize_check_grouped_json_output(output, Some("run-group"))
1611 .expect("grouped check output should serialize");
1612
1613 assert_eq!(value["kind"], "dead-code-grouped");
1614 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-group");
1615 assert_eq!(
1616 value["workspace_diagnostics"][0]["path"],
1617 "/project/src/unreadable.ts"
1618 );
1619 assert_eq!(
1620 value["workspace_diagnostics"][0]["kind"],
1621 "source-read-failure"
1622 );
1623 }
1624
1625 #[test]
1626 fn architecture_grouped_json_output_has_grouped_kind() {
1627 let output = CheckGroupedOutput {
1628 gate_outcomes: None,
1629 request_outcomes: None,
1630 package_baselines: Vec::new(),
1631 baseline_staleness: None,
1632 finding_id_query: None,
1633 schema_version: SchemaVersion(ARCHITECTURE_SCHEMA_VERSION),
1634 version: ToolVersion("0.0.0".to_string()),
1635 elapsed_ms: ElapsedMs(1),
1636 grouped_by: GroupByMode::Directory,
1637 total_issues: 0,
1638 groups: Vec::new(),
1639 unused_load_data_keys_global_abstain: false,
1640 cascade_hidden: 0,
1641 meta: None,
1642 workspace_diagnostics: Vec::new(),
1643 next_steps: Vec::new(),
1644 };
1645 let grouped = serialize_architecture_grouped_json_output(output, None)
1646 .expect("grouped architecture output should serialize");
1647 assert_eq!(grouped["kind"], "architecture-grouped");
1648 assert_eq!(grouped["schema_version"], ARCHITECTURE_SCHEMA_VERSION);
1649 }
1650
1651 #[test]
1652 fn workspace_diagnostics_serialize_typed_kind_path_message() {
1653 let root = std::path::Path::new("/project");
1654 let output = build_check_output(CheckOutputInput {
1655 schema_version: 7,
1656 version: "0.0.0".to_string(),
1657 elapsed: Duration::from_millis(1),
1658 results: AnalysisResults::default(),
1659 config_fixable: false,
1660 meta: None,
1661 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1662 root,
1663 root.join("packages/legacy"),
1664 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1665 )],
1666 next_steps: Vec::new(),
1667 });
1668
1669 let value = serde_json::to_value(&output).expect("check output serializes");
1670 let diag = &value["workspace_diagnostics"][0];
1671 assert_eq!(diag["kind"], "undeclared-workspace");
1672 assert!(
1673 diag["path"]
1674 .as_str()
1675 .is_some_and(|path| path.contains("packages/legacy")),
1676 "path field is carried verbatim: {diag}"
1677 );
1678 assert!(
1679 diag["message"]
1680 .as_str()
1681 .is_some_and(|message| message.contains("packages/legacy")),
1682 "message is rendered from kind + path: {diag}"
1683 );
1684 }
1685
1686 #[test]
1687 fn source_read_failure_workspace_diagnostic_serializes_error_payload() {
1688 let root = std::path::Path::new("/project");
1689 let output = build_check_output(CheckOutputInput {
1690 schema_version: 7,
1691 version: "0.0.0".to_string(),
1692 elapsed: Duration::from_millis(1),
1693 results: AnalysisResults::default(),
1694 config_fixable: false,
1695 meta: None,
1696 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1697 root,
1698 root.join("src/removed.ts"),
1699 WorkspaceDiagnosticKind::SourceReadFailure {
1700 error: "No such file or directory".to_string(),
1701 },
1702 )],
1703 next_steps: Vec::new(),
1704 });
1705
1706 let value = serde_json::to_value(&output).expect("check output serializes");
1707 let diagnostic = &value["workspace_diagnostics"][0];
1708 assert_eq!(diagnostic["kind"], "source-read-failure");
1709 assert_eq!(diagnostic["error"], "No such file or directory");
1710 assert!(
1711 diagnostic["message"]
1712 .as_str()
1713 .is_some_and(|message| message.contains("src/removed.ts"))
1714 );
1715 }
1716
1717 fn suppress_comment(actions: &[IssueAction]) -> Option<&str> {
1718 actions.iter().find_map(|action| match action {
1719 IssueAction::SuppressLine(action) => Some(action.comment.as_str()),
1720 _ => None,
1721 })
1722 }
1723}