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