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