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 unused_files: results.unused_files.len(),
1227 unused_exports: results.unused_exports.len(),
1228 unused_types: results.unused_types.len(),
1229 private_type_leaks: results.private_type_leaks.len(),
1230 deprecated_exports_in_use: results.deprecated_exports_in_use.len(),
1231 unused_dependencies: results.unused_dependencies.len()
1232 + results.unused_dev_dependencies.len()
1233 + results.unused_optional_dependencies.len(),
1234 unused_enum_members: results.unused_enum_members.len(),
1235 unused_class_members: results.unused_class_members.len(),
1236 unused_store_members: results.unused_store_members.len(),
1237 unresolved_imports: results.unresolved_imports.len(),
1238 unlisted_dependencies: results.unlisted_dependencies.len(),
1239 duplicate_exports: results.duplicate_exports.len(),
1240 type_only_dependencies: results.type_only_dependencies.len(),
1241 test_only_dependencies: results.test_only_dependencies.len(),
1242 dev_dependencies_in_production: results.dev_dependencies_in_production.len(),
1243 circular_dependencies: results.circular_dependencies.len(),
1244 re_export_cycles: results.re_export_cycles.len(),
1245 package_cycles: results.package_cycles.len(),
1246 boundary_violations: results.boundary_violations.len(),
1247 boundary_coverage_violations: results.boundary_coverage_violations.len(),
1248 boundary_call_violations: results.boundary_call_violations.len(),
1249 policy_violations: results.policy_violations.len(),
1250 stale_suppressions: results.stale_suppressions.len(),
1251 unused_catalog_entries: results.unused_catalog_entries.len(),
1252 empty_catalog_groups: results.empty_catalog_groups.len(),
1253 unresolved_catalog_references: results.unresolved_catalog_references.len(),
1254 unused_dependency_overrides: results.unused_dependency_overrides.len(),
1255 misconfigured_dependency_overrides: results.misconfigured_dependency_overrides.len(),
1256 invalid_client_exports: results.invalid_client_exports.len(),
1257 mixed_client_server_barrels: results.mixed_client_server_barrels.len(),
1258 misplaced_directives: results.misplaced_directives.len(),
1259 unprovided_injects: results.unprovided_injects.len(),
1260 unrendered_components: results.unrendered_components.len(),
1261 unused_component_props: results.unused_component_props.len(),
1262 absent_component_props: results.absent_component_props.len(),
1263 unused_component_emits: results.unused_component_emits.len(),
1264 unused_component_inputs: results.unused_component_inputs.len(),
1265 unused_component_outputs: results.unused_component_outputs.len(),
1266 unused_svelte_events: results.unused_svelte_events.len(),
1267 unused_effect_layers: results.unused_effect_layers.len(),
1268 unused_tagged_errors: results.unused_tagged_errors.len(),
1269 unproduced_catch_tags: results.unproduced_catch_tags.len(),
1270 unused_server_actions: results.unused_server_actions.len(),
1271 unused_load_data_keys: results.unused_load_data_keys.len(),
1272 route_collisions: results.route_collisions.len(),
1273 dynamic_segment_name_conflicts: results.dynamic_segment_name_conflicts.len(),
1274 }
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279 use super::*;
1280 use crate::{ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding};
1281 use fallow_types::output_dead_code::{
1282 ReachabilityCaveat, UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
1283 };
1284 use fallow_types::results::{UnusedExport, UnusedFile};
1285 use fallow_types::workspace::WorkspaceDiagnosticKind;
1286
1287 #[test]
1288 fn build_check_output_counts_issues_and_entry_points() {
1289 let mut results = AnalysisResults::default();
1290 results
1291 .unused_files
1292 .push(UnusedFileFinding::with_actions(UnusedFile {
1293 path: "src/unused.ts".into(),
1294 }));
1295
1296 let output = build_check_output(CheckOutputInput {
1297 schema_version: 7,
1298 version: "0.0.0".to_string(),
1299 elapsed: Duration::from_millis(42),
1300 results,
1301 config_fixable: false,
1302 meta: None,
1303 workspace_diagnostics: Vec::new(),
1304 next_steps: Vec::new(),
1305 });
1306
1307 assert_eq!(output.schema_version.0, 7);
1308 assert_eq!(output.total_issues, 1);
1309 assert_eq!(output.summary.unused_files, 1);
1310 assert_eq!(output.elapsed_ms.0, 42);
1311 }
1312
1313 #[test]
1314 fn build_check_output_harmonizes_multi_kind_suppress_actions_typed() {
1315 let mut results = AnalysisResults::default();
1316 let path = std::path::PathBuf::from("/project/src/shared.ts");
1317 results
1318 .unused_exports
1319 .push(UnusedExportFinding::with_actions(UnusedExport {
1320 path: path.clone(),
1321 export_name: "value".to_string(),
1322 is_type_only: false,
1323 line: 7,
1324 col: 0,
1325 span_start: 0,
1326 is_re_export: false,
1327 deprecated: false,
1328 deprecated_reason: None,
1329 }));
1330 results
1331 .unused_types
1332 .push(UnusedTypeFinding::with_actions(UnusedExport {
1333 path,
1334 export_name: "TypeOnly".to_string(),
1335 is_type_only: true,
1336 line: 7,
1337 col: 0,
1338 span_start: 0,
1339 is_re_export: false,
1340 deprecated: false,
1341 deprecated_reason: None,
1342 }));
1343
1344 let output = build_check_output(CheckOutputInput {
1345 schema_version: 7,
1346 version: "0.0.0".to_string(),
1347 elapsed: Duration::from_millis(42),
1348 results,
1349 config_fixable: false,
1350 meta: None,
1351 workspace_diagnostics: Vec::new(),
1352 next_steps: Vec::new(),
1353 });
1354
1355 let export_comment = suppress_comment(&output.results.unused_exports[0].actions);
1356 let type_comment = suppress_comment(&output.results.unused_types[0].actions);
1357 assert_eq!(
1358 export_comment,
1359 Some("// fallow-ignore-next-line unused-export, unused-type")
1360 );
1361 assert_eq!(type_comment, export_comment);
1362 }
1363
1364 #[test]
1365 fn harmonize_dead_code_health_suppress_actions_typed() {
1366 let mut results = AnalysisResults::default();
1367 let path = std::path::PathBuf::from("/project/src/shared.ts");
1368 results
1369 .unused_exports
1370 .push(UnusedExportFinding::with_actions(UnusedExport {
1371 path: path.clone(),
1372 export_name: "value".to_string(),
1373 is_type_only: false,
1374 line: 7,
1375 col: 0,
1376 span_start: 0,
1377 is_re_export: false,
1378 deprecated: false,
1379 deprecated_reason: None,
1380 }));
1381 let mut health = HealthReport {
1382 findings: vec![HealthFinding::new(
1383 ComplexityViolation {
1384 path,
1385 name: "expensive".to_string(),
1386 line: 7,
1387 col: 0,
1388 cyclomatic: 22,
1389 standard_cyclomatic: None,
1390 cognitive: 18,
1391 line_count: 40,
1392 param_count: 1,
1393 react_hook_count: 0,
1394 react_jsx_max_depth: 0,
1395 react_prop_count: 0,
1396 react_hook_profile: None,
1397 exceeded: ExceededThreshold::Both,
1398 severity: FindingSeverity::High,
1399 effective_severity: None,
1400 crap: None,
1401 mutation: None,
1402 coverage_pct: None,
1403 coverage_tier: None,
1404 coverage_source: None,
1405 inherited_from: None,
1406 component_rollup: None,
1407 contributions: Vec::new(),
1408 effective_thresholds: None,
1409 threshold_source: None,
1410 },
1411 vec![HealthFindingAction {
1412 kind: HealthFindingActionType::SuppressLine,
1413 auto_fixable: false,
1414 description: "Suppress with an inline comment above the function declaration"
1415 .to_string(),
1416 note: None,
1417 comment: Some("// fallow-ignore-next-line complexity".to_string()),
1418 placement: Some("above-function-declaration".to_string()),
1419 target_path: None,
1420 }],
1421 None,
1422 )],
1423 ..HealthReport::default()
1424 };
1425
1426 harmonize_dead_code_health_suppress_line_actions(Some(&mut results), Some(&mut health));
1427
1428 assert_eq!(
1429 suppress_comment(&results.unused_exports[0].actions),
1430 Some("// fallow-ignore-next-line unused-export, complexity")
1431 );
1432 assert_eq!(
1433 health.findings[0].actions[0].comment.as_deref(),
1434 Some("// fallow-ignore-next-line unused-export, complexity")
1435 );
1436 }
1437
1438 #[test]
1439 fn check_json_output_uses_output_owned_root_contract() {
1440 let output = build_check_output(CheckOutputInput {
1441 schema_version: 7,
1442 version: "0.0.0".to_string(),
1443 elapsed: Duration::from_millis(42),
1444 results: AnalysisResults::default(),
1445 config_fixable: false,
1446 meta: None,
1447 workspace_diagnostics: Vec::new(),
1448 next_steps: Vec::new(),
1449 });
1450
1451 let value = serialize_check_json_output(output, Some("run-check"))
1452 .expect("check output should serialize");
1453
1454 assert_eq!(value["kind"], "dead-code");
1455 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-check");
1456 }
1457
1458 #[test]
1459 fn architecture_json_output_has_architecture_kind_and_version() {
1460 let envelope = CheckEnvelope::Architecture;
1461 let output = build_check_output(CheckOutputInput {
1462 schema_version: envelope.schema_version(),
1463 version: "0.0.0".to_string(),
1464 elapsed: Duration::from_millis(1),
1465 results: AnalysisResults::default(),
1466 config_fixable: false,
1467 meta: None,
1468 workspace_diagnostics: Vec::new(),
1469 next_steps: Vec::new(),
1470 });
1471
1472 let value = serialize_architecture_json_output(output, Some("run-arch"))
1473 .expect("architecture output should serialize");
1474
1475 assert_eq!(value["kind"], "architecture");
1476 assert_eq!(value["schema_version"], ARCHITECTURE_SCHEMA_VERSION);
1477 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-arch");
1478 }
1479
1480 #[test]
1481 fn check_envelope_names_each_family_member() {
1482 assert_eq!(CheckEnvelope::DeadCode.kind(), "dead-code");
1483 assert_eq!(CheckEnvelope::DeadCode.grouped_kind(), "dead-code-grouped");
1484 assert_eq!(
1485 CheckEnvelope::DeadCode.schema_version(),
1486 CHECK_SCHEMA_VERSION
1487 );
1488 assert_eq!(CheckEnvelope::DeadCode.command(), "dead-code");
1489 assert_eq!(CheckEnvelope::Architecture.kind(), "architecture");
1490 assert_eq!(
1491 CheckEnvelope::Architecture.grouped_kind(),
1492 "architecture-grouped"
1493 );
1494 assert_eq!(
1495 CheckEnvelope::Architecture.schema_version(),
1496 ARCHITECTURE_SCHEMA_VERSION
1497 );
1498 assert_eq!(CheckEnvelope::Architecture.command(), "architecture");
1499 }
1500
1501 #[test]
1508 fn reachability_caveats_are_absent_when_clean_and_named_when_flagged() {
1509 let mut results = AnalysisResults::default();
1510 results
1511 .unused_files
1512 .push(UnusedFileFinding::with_actions(UnusedFile {
1513 path: "/project/src/clean.ts".into(),
1514 }));
1515 let mut flagged = UnusedFileFinding::with_actions(UnusedFile {
1516 path: "/project/src/orphan.ts".into(),
1517 });
1518 flagged.reachability_caveats = vec![
1519 ReachabilityCaveat::IncompleteFileAnalysis,
1520 ReachabilityCaveat::IncompleteImportGraph,
1521 ];
1522 results.unused_files.push(flagged);
1523
1524 let output = build_check_output(CheckOutputInput {
1525 schema_version: 7,
1526 version: "0.0.0".to_string(),
1527 elapsed: Duration::from_millis(1),
1528 results,
1529 config_fixable: false,
1530 meta: None,
1531 workspace_diagnostics: Vec::new(),
1532 next_steps: Vec::new(),
1533 });
1534 let value =
1535 serialize_check_json_output(output, None).expect("dead-code output should serialize");
1536
1537 let entries = value["unused_files"]
1538 .as_array()
1539 .expect("unused_files array")
1540 .clone();
1541 let find = |name: &str| {
1542 entries
1543 .iter()
1544 .find(|entry| {
1545 entry["path"]
1546 .as_str()
1547 .is_some_and(|path| path.ends_with(name))
1548 })
1549 .cloned()
1550 .expect("finding present")
1551 };
1552
1553 assert!(
1554 find("clean.ts").get("reachability_caveats").is_none(),
1555 "a finding with no caveat must keep the previous wire shape exactly"
1556 );
1557
1558 let flagged = find("orphan.ts");
1559 assert_eq!(
1560 flagged["reachability_caveats"],
1561 serde_json::json!(["incomplete-file-analysis", "incomplete-import-graph"]),
1562 "both caveats are named on the wire, in declaration order"
1563 );
1564 assert_eq!(
1565 flagged["actions"].as_array().map(Vec::len),
1566 Some(2),
1567 "the caveat never trims the finding's actions"
1568 );
1569 assert_eq!(
1570 flagged["actions"][0]["type"], "delete-file",
1571 "nor reorders them, so a consumer reading actions[0].type is unaffected"
1572 );
1573 assert_eq!(
1574 flagged["actions"][0]["auto_fixable"],
1575 serde_json::json!(false),
1576 "but the mutation it gates must not advertise itself as applicable"
1577 );
1578 }
1579
1580 #[test]
1581 fn grouped_check_json_output_uses_output_owned_root_contract() {
1582 let root = std::path::Path::new("/project");
1583 let output = CheckGroupedOutput {
1584 gate_outcomes: None,
1585 request_outcomes: None,
1586 package_baselines: Vec::new(),
1587 baseline_staleness: None,
1588 finding_id_query: None,
1589 schema_version: SchemaVersion(7),
1590 version: ToolVersion("0.0.0".to_string()),
1591 elapsed_ms: ElapsedMs(1),
1592 grouped_by: GroupByMode::Directory,
1593 total_issues: 0,
1594 groups: Vec::new(),
1595 unused_load_data_keys_global_abstain: false,
1596 cascade_hidden: 0,
1597 meta: None,
1598 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1599 root,
1600 root.join("src/unreadable.ts"),
1601 WorkspaceDiagnosticKind::SourceReadFailure {
1602 error: "permission denied".to_string(),
1603 },
1604 )],
1605 next_steps: Vec::new(),
1606 };
1607
1608 let value = serialize_check_grouped_json_output(output, Some("run-group"))
1609 .expect("grouped check output should serialize");
1610
1611 assert_eq!(value["kind"], "dead-code-grouped");
1612 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-group");
1613 assert_eq!(
1614 value["workspace_diagnostics"][0]["path"],
1615 "/project/src/unreadable.ts"
1616 );
1617 assert_eq!(
1618 value["workspace_diagnostics"][0]["kind"],
1619 "source-read-failure"
1620 );
1621 }
1622
1623 #[test]
1624 fn architecture_grouped_json_output_has_grouped_kind() {
1625 let output = CheckGroupedOutput {
1626 gate_outcomes: None,
1627 request_outcomes: None,
1628 package_baselines: Vec::new(),
1629 baseline_staleness: None,
1630 finding_id_query: None,
1631 schema_version: SchemaVersion(ARCHITECTURE_SCHEMA_VERSION),
1632 version: ToolVersion("0.0.0".to_string()),
1633 elapsed_ms: ElapsedMs(1),
1634 grouped_by: GroupByMode::Directory,
1635 total_issues: 0,
1636 groups: Vec::new(),
1637 unused_load_data_keys_global_abstain: false,
1638 cascade_hidden: 0,
1639 meta: None,
1640 workspace_diagnostics: Vec::new(),
1641 next_steps: Vec::new(),
1642 };
1643 let grouped = serialize_architecture_grouped_json_output(output, None)
1644 .expect("grouped architecture output should serialize");
1645 assert_eq!(grouped["kind"], "architecture-grouped");
1646 assert_eq!(grouped["schema_version"], ARCHITECTURE_SCHEMA_VERSION);
1647 }
1648
1649 #[test]
1650 fn workspace_diagnostics_serialize_typed_kind_path_message() {
1651 let root = std::path::Path::new("/project");
1652 let output = build_check_output(CheckOutputInput {
1653 schema_version: 7,
1654 version: "0.0.0".to_string(),
1655 elapsed: Duration::from_millis(1),
1656 results: AnalysisResults::default(),
1657 config_fixable: false,
1658 meta: None,
1659 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1660 root,
1661 root.join("packages/legacy"),
1662 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1663 )],
1664 next_steps: Vec::new(),
1665 });
1666
1667 let value = serde_json::to_value(&output).expect("check output serializes");
1668 let diag = &value["workspace_diagnostics"][0];
1669 assert_eq!(diag["kind"], "undeclared-workspace");
1670 assert!(
1671 diag["path"]
1672 .as_str()
1673 .is_some_and(|path| path.contains("packages/legacy")),
1674 "path field is carried verbatim: {diag}"
1675 );
1676 assert!(
1677 diag["message"]
1678 .as_str()
1679 .is_some_and(|message| message.contains("packages/legacy")),
1680 "message is rendered from kind + path: {diag}"
1681 );
1682 }
1683
1684 #[test]
1685 fn source_read_failure_workspace_diagnostic_serializes_error_payload() {
1686 let root = std::path::Path::new("/project");
1687 let output = build_check_output(CheckOutputInput {
1688 schema_version: 7,
1689 version: "0.0.0".to_string(),
1690 elapsed: Duration::from_millis(1),
1691 results: AnalysisResults::default(),
1692 config_fixable: false,
1693 meta: None,
1694 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1695 root,
1696 root.join("src/removed.ts"),
1697 WorkspaceDiagnosticKind::SourceReadFailure {
1698 error: "No such file or directory".to_string(),
1699 },
1700 )],
1701 next_steps: Vec::new(),
1702 });
1703
1704 let value = serde_json::to_value(&output).expect("check output serializes");
1705 let diagnostic = &value["workspace_diagnostics"][0];
1706 assert_eq!(diagnostic["kind"], "source-read-failure");
1707 assert_eq!(diagnostic["error"], "No such file or directory");
1708 assert!(
1709 diagnostic["message"]
1710 .as_str()
1711 .is_some_and(|message| message.contains("src/removed.ts"))
1712 );
1713 }
1714
1715 fn suppress_comment(actions: &[IssueAction]) -> Option<&str> {
1716 actions.iter().find_map(|action| match action {
1717 IssueAction::SuppressLine(action) => Some(action.comment.as_str()),
1718 _ => None,
1719 })
1720 }
1721}