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