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