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::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
17
18pub const CHECK_SCHEMA_VERSION: u32 = 9;
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
28#[derive(Debug, Clone, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39#[cfg_attr(feature = "schema", schemars(title = "fallow dead-code --format json"))]
40pub struct CheckOutput {
41 #[cfg_attr(feature = "schema", schemars(with = "CheckSchemaVersion"))]
43 pub schema_version: SchemaVersion,
44 pub version: ToolVersion,
46 pub elapsed_ms: ElapsedMs,
48 pub total_issues: usize,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub entry_points: Option<EntryPoints>,
53 pub summary: CheckSummary,
55 #[serde(flatten)]
58 pub results: AnalysisResults,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub baseline_deltas: Option<BaselineDeltas>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub baseline: Option<BaselineMatch>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub regression: Option<RegressionResult>,
68 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
71 pub meta: Option<Meta>,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub next_steps: Vec<NextStep>,
111}
112
113#[derive(Debug, Clone, Serialize)]
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121#[cfg_attr(
122 feature = "schema",
123 schemars(
124 title = "fallow dead-code --group-by <owner|directory|package|section> --format json"
125 )
126)]
127pub struct CheckGroupedOutput {
128 #[cfg_attr(feature = "schema", schemars(with = "CheckSchemaVersion"))]
130 pub schema_version: SchemaVersion,
131 pub version: ToolVersion,
133 pub elapsed_ms: ElapsedMs,
135 pub grouped_by: GroupByMode,
137 pub total_issues: usize,
139 pub groups: Vec<CheckGroupedEntry>,
141 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
144 pub meta: Option<Meta>,
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 pub next_steps: Vec<NextStep>,
153}
154
155#[derive(Debug, Clone, Serialize)]
159#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
160pub struct CheckGroupedEntry {
161 pub key: String,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub owners: Option<Vec<String>>,
166 pub total_issues: usize,
168 #[serde(flatten)]
170 pub results: AnalysisResults,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
179#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
180#[serde(rename_all = "lowercase")]
181pub enum GroupByMode {
182 Owner,
184 Directory,
186 Package,
188 Section,
190}
191
192pub struct CheckOutputInput {
194 pub schema_version: u32,
196 pub version: String,
198 pub elapsed: Duration,
200 pub results: AnalysisResults,
202 pub config_fixable: bool,
205 pub meta: Option<Meta>,
207 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
210 pub next_steps: Vec<NextStep>,
212}
213
214#[must_use]
216pub fn build_check_output(input: CheckOutputInput) -> CheckOutput {
217 let mut results = input.results;
218 apply_config_fixable_to_duplicate_exports(&mut results, input.config_fixable);
219 harmonize_multi_kind_suppress_line_actions(&mut results);
220 CheckOutput {
221 schema_version: SchemaVersion(input.schema_version),
222 version: ToolVersion(input.version),
223 elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
224 total_issues: results.total_issues(),
225 entry_points: results
226 .entry_point_summary
227 .as_ref()
228 .map(|entry_points| EntryPoints {
229 total: entry_points.total,
230 sources: entry_points
231 .by_source
232 .iter()
233 .map(|(key, value)| (key.replace(' ', "_"), *value))
234 .collect(),
235 }),
236 summary: build_check_summary(&results),
237 results,
238 baseline_deltas: None,
239 baseline: None,
240 regression: None,
241 meta: input.meta,
242 workspace_diagnostics: input.workspace_diagnostics,
243 next_steps: input.next_steps,
244 }
245}
246
247fn serialize_check_family_json_output<T: Serialize>(
248 output: T,
249 kind: &'static str,
250 mode: RootEnvelopeMode,
251 analysis_run_id: Option<&str>,
252) -> Result<serde_json::Value, serde_json::Error> {
253 let mut value = serialize_named_json_output(output, kind, mode)?;
254 attach_telemetry_meta(&mut value, analysis_run_id);
255 Ok(value)
256}
257
258pub fn serialize_check_json_output(
264 output: CheckOutput,
265 mode: RootEnvelopeMode,
266 analysis_run_id: Option<&str>,
267) -> Result<serde_json::Value, serde_json::Error> {
268 serialize_check_family_json_output(output, "dead-code", mode, analysis_run_id)
269}
270
271pub fn serialize_check_grouped_json_output(
278 output: CheckGroupedOutput,
279 mode: RootEnvelopeMode,
280 analysis_run_id: Option<&str>,
281) -> Result<serde_json::Value, serde_json::Error> {
282 serialize_check_family_json_output(output, "dead-code-grouped", mode, analysis_run_id)
283}
284
285pub fn apply_config_fixable_to_duplicate_exports(
288 results: &mut AnalysisResults,
289 config_fixable: bool,
290) {
291 if !config_fixable {
292 return;
293 }
294 for finding in &mut results.duplicate_exports {
295 finding.set_config_fixable(true);
296 }
297}
298
299type SuppressAnchor = (String, u32);
300
301macro_rules! visit_suppress_line_findings {
302 ($results:expr, $visit:expr) => {{
303 let results = $results;
304 for finding in &results.unused_exports {
305 $visit(&finding.export.path, finding.export.line, &finding.actions);
306 }
307 for finding in &results.unused_types {
308 $visit(&finding.export.path, finding.export.line, &finding.actions);
309 }
310 for finding in &results.private_type_leaks {
311 $visit(&finding.leak.path, finding.leak.line, &finding.actions);
312 }
313 for finding in &results.unused_enum_members {
314 $visit(&finding.member.path, finding.member.line, &finding.actions);
315 }
316 for finding in &results.unused_class_members {
317 $visit(&finding.member.path, finding.member.line, &finding.actions);
318 }
319 for finding in &results.unused_store_members {
320 $visit(&finding.member.path, finding.member.line, &finding.actions);
321 }
322 for finding in &results.unresolved_imports {
323 $visit(&finding.import.path, finding.import.line, &finding.actions);
324 }
325 for finding in &results.unused_dependencies {
326 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
327 }
328 for finding in &results.unused_dev_dependencies {
329 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
330 }
331 for finding in &results.unused_optional_dependencies {
332 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
333 }
334 for finding in &results.type_only_dependencies {
335 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
336 }
337 for finding in &results.test_only_dependencies {
338 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
339 }
340 for finding in &results.dev_dependencies_in_production {
341 $visit(&finding.dep.path, finding.dep.line, &finding.actions);
342 }
343 for finding in &results.circular_dependencies {
344 if let Some(path) = finding.cycle.files.first() {
345 $visit(path, finding.cycle.line, &finding.actions);
346 }
347 }
348 for finding in &results.boundary_violations {
349 $visit(
350 &finding.violation.from_path,
351 finding.violation.line,
352 &finding.actions,
353 );
354 }
355 for finding in &results.boundary_coverage_violations {
356 $visit(
357 &finding.violation.path,
358 finding.violation.line,
359 &finding.actions,
360 );
361 }
362 for finding in &results.boundary_call_violations {
363 $visit(
364 &finding.violation.path,
365 finding.violation.line,
366 &finding.actions,
367 );
368 }
369 for finding in &results.policy_violations {
370 $visit(
371 &finding.violation.path,
372 finding.violation.line,
373 &finding.actions,
374 );
375 }
376 for finding in &results.unused_catalog_entries {
377 $visit(&finding.entry.path, finding.entry.line, &finding.actions);
378 }
379 for finding in &results.empty_catalog_groups {
380 $visit(&finding.group.path, finding.group.line, &finding.actions);
381 }
382 for finding in &results.unresolved_catalog_references {
383 $visit(
384 &finding.reference.path,
385 finding.reference.line,
386 &finding.actions,
387 );
388 }
389 for finding in &results.unused_dependency_overrides {
390 $visit(&finding.entry.path, finding.entry.line, &finding.actions);
391 }
392 for finding in &results.misconfigured_dependency_overrides {
393 $visit(&finding.entry.path, finding.entry.line, &finding.actions);
394 }
395 for finding in &results.invalid_client_exports {
396 $visit(&finding.export.path, finding.export.line, &finding.actions);
397 }
398 for finding in &results.mixed_client_server_barrels {
399 $visit(&finding.barrel.path, finding.barrel.line, &finding.actions);
400 }
401 for finding in &results.misplaced_directives {
402 $visit(
403 &finding.directive_site.path,
404 finding.directive_site.line,
405 &finding.actions,
406 );
407 }
408 for finding in &results.unprovided_injects {
409 $visit(&finding.inject.path, finding.inject.line, &finding.actions);
410 }
411 for finding in &results.unrendered_components {
412 $visit(
413 &finding.component.path,
414 finding.component.line,
415 &finding.actions,
416 );
417 }
418 for finding in &results.route_collisions {
419 $visit(
420 &finding.collision.path,
421 finding.collision.line,
422 &finding.actions,
423 );
424 }
425 for finding in &results.dynamic_segment_name_conflicts {
426 $visit(
427 &finding.conflict.path,
428 finding.conflict.line,
429 &finding.actions,
430 );
431 }
432 for finding in &results.unused_component_props {
433 $visit(&finding.prop.path, finding.prop.line, &finding.actions);
434 }
435 for finding in &results.unused_component_emits {
436 $visit(&finding.emit.path, finding.emit.line, &finding.actions);
437 }
438 for finding in &results.unused_component_inputs {
439 $visit(&finding.input.path, finding.input.line, &finding.actions);
440 }
441 for finding in &results.unused_component_outputs {
442 $visit(&finding.output.path, finding.output.line, &finding.actions);
443 }
444 for finding in &results.unused_svelte_events {
445 $visit(&finding.event.path, finding.event.line, &finding.actions);
446 }
447 for finding in &results.unused_server_actions {
448 $visit(&finding.action.path, finding.action.line, &finding.actions);
449 }
450 for finding in &results.unused_load_data_keys {
451 $visit(&finding.key.path, finding.key.line, &finding.actions);
452 }
453 for finding in &results.prop_drilling_chains {
454 if let Some(hop) = finding.chain.hops.first() {
455 $visit(&hop.file, hop.line, &finding.actions);
456 }
457 }
458 for finding in &results.thin_wrappers {
459 $visit(
460 &finding.wrapper.file,
461 finding.wrapper.line,
462 &finding.actions,
463 );
464 }
465 for finding in &results.duplicate_prop_shapes {
466 $visit(&finding.shape.file, finding.shape.line, &finding.actions);
467 }
468 }};
469}
470
471macro_rules! visit_suppress_line_findings_mut {
472 ($results:expr, $visit:expr) => {{
473 let results = $results;
474 for finding in &mut results.unused_exports {
475 $visit(
476 &finding.export.path,
477 finding.export.line,
478 &mut finding.actions,
479 );
480 }
481 for finding in &mut results.unused_types {
482 $visit(
483 &finding.export.path,
484 finding.export.line,
485 &mut finding.actions,
486 );
487 }
488 for finding in &mut results.private_type_leaks {
489 $visit(&finding.leak.path, finding.leak.line, &mut finding.actions);
490 }
491 for finding in &mut results.unused_enum_members {
492 $visit(
493 &finding.member.path,
494 finding.member.line,
495 &mut finding.actions,
496 );
497 }
498 for finding in &mut results.unused_class_members {
499 $visit(
500 &finding.member.path,
501 finding.member.line,
502 &mut finding.actions,
503 );
504 }
505 for finding in &mut results.unused_store_members {
506 $visit(
507 &finding.member.path,
508 finding.member.line,
509 &mut finding.actions,
510 );
511 }
512 for finding in &mut results.unresolved_imports {
513 $visit(
514 &finding.import.path,
515 finding.import.line,
516 &mut finding.actions,
517 );
518 }
519 for finding in &mut results.unused_dependencies {
520 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
521 }
522 for finding in &mut results.unused_dev_dependencies {
523 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
524 }
525 for finding in &mut results.unused_optional_dependencies {
526 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
527 }
528 for finding in &mut results.type_only_dependencies {
529 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
530 }
531 for finding in &mut results.test_only_dependencies {
532 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
533 }
534 for finding in &mut results.dev_dependencies_in_production {
535 $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
536 }
537 for finding in &mut results.circular_dependencies {
538 if let Some(path) = finding.cycle.files.first() {
539 $visit(path, finding.cycle.line, &mut finding.actions);
540 }
541 }
542 for finding in &mut results.boundary_violations {
543 $visit(
544 &finding.violation.from_path,
545 finding.violation.line,
546 &mut finding.actions,
547 );
548 }
549 for finding in &mut results.boundary_coverage_violations {
550 $visit(
551 &finding.violation.path,
552 finding.violation.line,
553 &mut finding.actions,
554 );
555 }
556 for finding in &mut results.boundary_call_violations {
557 $visit(
558 &finding.violation.path,
559 finding.violation.line,
560 &mut finding.actions,
561 );
562 }
563 for finding in &mut results.policy_violations {
564 $visit(
565 &finding.violation.path,
566 finding.violation.line,
567 &mut finding.actions,
568 );
569 }
570 for finding in &mut results.unused_catalog_entries {
571 $visit(
572 &finding.entry.path,
573 finding.entry.line,
574 &mut finding.actions,
575 );
576 }
577 for finding in &mut results.empty_catalog_groups {
578 $visit(
579 &finding.group.path,
580 finding.group.line,
581 &mut finding.actions,
582 );
583 }
584 for finding in &mut results.unresolved_catalog_references {
585 $visit(
586 &finding.reference.path,
587 finding.reference.line,
588 &mut finding.actions,
589 );
590 }
591 for finding in &mut results.unused_dependency_overrides {
592 $visit(
593 &finding.entry.path,
594 finding.entry.line,
595 &mut finding.actions,
596 );
597 }
598 for finding in &mut results.misconfigured_dependency_overrides {
599 $visit(
600 &finding.entry.path,
601 finding.entry.line,
602 &mut finding.actions,
603 );
604 }
605 for finding in &mut results.invalid_client_exports {
606 $visit(
607 &finding.export.path,
608 finding.export.line,
609 &mut finding.actions,
610 );
611 }
612 for finding in &mut results.mixed_client_server_barrels {
613 $visit(
614 &finding.barrel.path,
615 finding.barrel.line,
616 &mut finding.actions,
617 );
618 }
619 for finding in &mut results.misplaced_directives {
620 $visit(
621 &finding.directive_site.path,
622 finding.directive_site.line,
623 &mut finding.actions,
624 );
625 }
626 for finding in &mut results.unprovided_injects {
627 $visit(
628 &finding.inject.path,
629 finding.inject.line,
630 &mut finding.actions,
631 );
632 }
633 for finding in &mut results.unrendered_components {
634 $visit(
635 &finding.component.path,
636 finding.component.line,
637 &mut finding.actions,
638 );
639 }
640 for finding in &mut results.route_collisions {
641 $visit(
642 &finding.collision.path,
643 finding.collision.line,
644 &mut finding.actions,
645 );
646 }
647 for finding in &mut results.dynamic_segment_name_conflicts {
648 $visit(
649 &finding.conflict.path,
650 finding.conflict.line,
651 &mut finding.actions,
652 );
653 }
654 for finding in &mut results.unused_component_props {
655 $visit(&finding.prop.path, finding.prop.line, &mut finding.actions);
656 }
657 for finding in &mut results.unused_component_emits {
658 $visit(&finding.emit.path, finding.emit.line, &mut finding.actions);
659 }
660 for finding in &mut results.unused_component_inputs {
661 $visit(
662 &finding.input.path,
663 finding.input.line,
664 &mut finding.actions,
665 );
666 }
667 for finding in &mut results.unused_component_outputs {
668 $visit(
669 &finding.output.path,
670 finding.output.line,
671 &mut finding.actions,
672 );
673 }
674 for finding in &mut results.unused_svelte_events {
675 $visit(
676 &finding.event.path,
677 finding.event.line,
678 &mut finding.actions,
679 );
680 }
681 for finding in &mut results.unused_server_actions {
682 $visit(
683 &finding.action.path,
684 finding.action.line,
685 &mut finding.actions,
686 );
687 }
688 for finding in &mut results.unused_load_data_keys {
689 $visit(&finding.key.path, finding.key.line, &mut finding.actions);
690 }
691 for finding in &mut results.prop_drilling_chains {
692 if let Some(hop) = finding.chain.hops.first() {
693 $visit(&hop.file, hop.line, &mut finding.actions);
694 }
695 }
696 for finding in &mut results.thin_wrappers {
697 $visit(
698 &finding.wrapper.file,
699 finding.wrapper.line,
700 &mut finding.actions,
701 );
702 }
703 for finding in &mut results.duplicate_prop_shapes {
704 $visit(
705 &finding.shape.file,
706 finding.shape.line,
707 &mut finding.actions,
708 );
709 }
710 }};
711}
712
713pub fn harmonize_multi_kind_suppress_line_actions(results: &mut AnalysisResults) {
719 let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
720 collect_dead_code_suppress_line_anchors(results, &mut anchors);
721 retain_multi_kind_anchors(&mut anchors);
722 if anchors.is_empty() {
723 return;
724 }
725 rewrite_dead_code_suppress_line_actions(results, &anchors);
726}
727
728pub fn harmonize_dead_code_health_suppress_line_actions(
734 dead_code: Option<&mut AnalysisResults>,
735 health: Option<&mut HealthReport>,
736) {
737 let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
738 if let Some(results) = dead_code.as_deref() {
739 collect_dead_code_suppress_line_anchors(results, &mut anchors);
740 }
741 if let Some(report) = health.as_deref() {
742 collect_health_suppress_line_anchors(report, &mut anchors);
743 }
744
745 retain_multi_kind_anchors(&mut anchors);
746 if anchors.is_empty() {
747 return;
748 }
749
750 if let Some(results) = dead_code {
751 rewrite_dead_code_suppress_line_actions(results, &anchors);
752 }
753 if let Some(report) = health {
754 rewrite_health_suppress_line_actions(report, &anchors);
755 }
756}
757
758fn retain_multi_kind_anchors(anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>) {
759 anchors.retain(|_, kinds| {
760 sort_suppression_kinds(kinds);
761 kinds.dedup();
762 kinds.len() > 1
763 });
764}
765
766fn collect_dead_code_suppress_line_anchors(
767 results: &AnalysisResults,
768 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
769) {
770 visit_suppress_line_findings!(results, |path: &Path, line, actions: &[IssueAction]| {
771 collect_action_kinds(path, line, actions, anchors);
772 });
773}
774
775fn rewrite_dead_code_suppress_line_actions(
776 results: &mut AnalysisResults,
777 anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
778) {
779 visit_suppress_line_findings_mut!(
780 results,
781 |path: &Path, line, actions: &mut Vec<IssueAction>| {
782 let anchor = suppress_anchor(path, line);
783 if let Some(kinds) = anchors.get(&anchor) {
784 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
785 rewrite_action_comments(actions, &comment);
786 }
787 }
788 );
789}
790
791fn collect_health_suppress_line_anchors(
792 report: &HealthReport,
793 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
794) {
795 for finding in &report.findings {
796 collect_health_action_kinds(
797 &finding.violation.path,
798 finding.violation.line,
799 &finding.actions,
800 anchors,
801 );
802 }
803 for finding in &report.prop_drilling_chains {
804 if let Some(hop) = finding.chain.hops.first() {
805 collect_action_kinds(&hop.file, hop.line, &finding.actions, anchors);
806 }
807 }
808}
809
810fn rewrite_health_suppress_line_actions(
811 report: &mut HealthReport,
812 anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
813) {
814 for finding in &mut report.findings {
815 let anchor = suppress_anchor(&finding.violation.path, finding.violation.line);
816 if let Some(kinds) = anchors.get(&anchor) {
817 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
818 rewrite_health_action_comments(&mut finding.actions, &comment);
819 }
820 }
821 for finding in &mut report.prop_drilling_chains {
822 if let Some(hop) = finding.chain.hops.first() {
823 let anchor = suppress_anchor(&hop.file, hop.line);
824 if let Some(kinds) = anchors.get(&anchor) {
825 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
826 rewrite_action_comments(&mut finding.actions, &comment);
827 }
828 }
829 }
830}
831
832fn collect_action_kinds(
833 path: &Path,
834 line: u32,
835 actions: &[IssueAction],
836 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
837) {
838 for action in actions {
839 if let Some(comment) = suppress_line_comment(action) {
840 let kinds = anchors.entry(suppress_anchor(path, line)).or_default();
841 for kind in parse_suppress_line_comment(comment) {
842 if !kinds.iter().any(|existing| existing == &kind) {
843 kinds.push(kind);
844 }
845 }
846 }
847 }
848}
849
850fn collect_health_action_kinds(
851 path: &Path,
852 line: u32,
853 actions: &[HealthFindingAction],
854 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
855) {
856 for action in actions {
857 if let Some(comment) = health_suppress_line_comment(action) {
858 let kinds = anchors.entry(suppress_anchor(path, line)).or_default();
859 for kind in parse_suppress_line_comment(comment) {
860 if !kinds.iter().any(|existing| existing == &kind) {
861 kinds.push(kind);
862 }
863 }
864 }
865 }
866}
867
868fn rewrite_action_comments(actions: &mut [IssueAction], comment: &str) {
869 for action in actions {
870 if let IssueAction::SuppressLine(suppress) = action {
871 suppress.comment = comment.to_string();
872 }
873 }
874}
875
876fn rewrite_health_action_comments(actions: &mut [HealthFindingAction], comment: &str) {
877 for action in actions {
878 if matches!(action.kind, HealthFindingActionType::SuppressLine) {
879 action.comment = Some(comment.to_string());
880 }
881 }
882}
883
884fn suppress_anchor(path: &Path, line: u32) -> SuppressAnchor {
885 (path.display().to_string(), line)
886}
887
888fn suppress_line_comment(action: &IssueAction) -> Option<&str> {
889 match action {
890 IssueAction::SuppressLine(action) => Some(&action.comment),
891 _ => None,
892 }
893}
894
895fn health_suppress_line_comment(action: &HealthFindingAction) -> Option<&str> {
896 matches!(action.kind, HealthFindingActionType::SuppressLine)
897 .then_some(())
898 .and(action.comment.as_deref())
899}
900
901fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
902 comment
903 .strip_prefix("// fallow-ignore-next-line ")
904 .map(|rest| {
905 rest.split(|c: char| c == ',' || c.is_whitespace())
906 .filter(|token| !token.is_empty())
907 .map(str::to_string)
908 .collect()
909 })
910 .unwrap_or_default()
911}
912
913fn sort_suppression_kinds(kinds: &mut [String]) {
914 kinds.sort_by_key(|kind| suppression_kind_rank(kind));
915}
916
917fn suppression_kind_rank(kind: &str) -> usize {
918 match kind {
919 "unused-file" => 0,
920 "unused-export" => 1,
921 "unused-type" => 2,
922 "private-type-leak" => 3,
923 "unused-enum-member" => 4,
924 "unused-class-member" => 5,
925 "unused-store-member" => 6,
926 "unresolved-import" => 7,
927 "unlisted-dependency" => 8,
928 "duplicate-export" => 9,
929 "circular-dependency" => 10,
930 "re-export-cycle" => 11,
931 "boundary-violation" => 12,
932 "code-duplication" => 13,
933 "complexity" => 14,
934 "unprovided-inject" => 15,
935 "unrendered-component" => 16,
936 "unused-server-action" => 17,
937 _ => usize::MAX,
938 }
939}
940
941#[must_use]
943pub fn build_check_summary(results: &AnalysisResults) -> CheckSummary {
944 CheckSummary {
945 total_issues: results.total_issues(),
946 unused_files: results.unused_files.len(),
947 unused_exports: results.unused_exports.len(),
948 unused_types: results.unused_types.len(),
949 private_type_leaks: results.private_type_leaks.len(),
950 unused_dependencies: results.unused_dependencies.len()
951 + results.unused_dev_dependencies.len()
952 + results.unused_optional_dependencies.len(),
953 unused_enum_members: results.unused_enum_members.len(),
954 unused_class_members: results.unused_class_members.len(),
955 unused_store_members: results.unused_store_members.len(),
956 unresolved_imports: results.unresolved_imports.len(),
957 unlisted_dependencies: results.unlisted_dependencies.len(),
958 duplicate_exports: results.duplicate_exports.len(),
959 type_only_dependencies: results.type_only_dependencies.len(),
960 test_only_dependencies: results.test_only_dependencies.len(),
961 dev_dependencies_in_production: results.dev_dependencies_in_production.len(),
962 circular_dependencies: results.circular_dependencies.len(),
963 re_export_cycles: results.re_export_cycles.len(),
964 boundary_violations: results.boundary_violations.len(),
965 boundary_coverage_violations: results.boundary_coverage_violations.len(),
966 boundary_call_violations: results.boundary_call_violations.len(),
967 policy_violations: results.policy_violations.len(),
968 stale_suppressions: results.stale_suppressions.len(),
969 unused_catalog_entries: results.unused_catalog_entries.len(),
970 empty_catalog_groups: results.empty_catalog_groups.len(),
971 unresolved_catalog_references: results.unresolved_catalog_references.len(),
972 unused_dependency_overrides: results.unused_dependency_overrides.len(),
973 misconfigured_dependency_overrides: results.misconfigured_dependency_overrides.len(),
974 invalid_client_exports: results.invalid_client_exports.len(),
975 mixed_client_server_barrels: results.mixed_client_server_barrels.len(),
976 misplaced_directives: results.misplaced_directives.len(),
977 unprovided_injects: results.unprovided_injects.len(),
978 unrendered_components: results.unrendered_components.len(),
979 unused_component_props: results.unused_component_props.len(),
980 unused_component_emits: results.unused_component_emits.len(),
981 unused_component_inputs: results.unused_component_inputs.len(),
982 unused_component_outputs: results.unused_component_outputs.len(),
983 unused_svelte_events: results.unused_svelte_events.len(),
984 unused_server_actions: results.unused_server_actions.len(),
985 unused_load_data_keys: results.unused_load_data_keys.len(),
986 route_collisions: results.route_collisions.len(),
987 dynamic_segment_name_conflicts: results.dynamic_segment_name_conflicts.len(),
988 }
989}
990
991#[cfg(test)]
992mod tests {
993 use super::*;
994 use crate::{ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding};
995 use fallow_types::output_dead_code::{
996 ReachabilityCaveat, UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
997 };
998 use fallow_types::results::{UnusedExport, UnusedFile};
999 use fallow_types::workspace::WorkspaceDiagnosticKind;
1000
1001 #[test]
1002 fn build_check_output_counts_issues_and_entry_points() {
1003 let mut results = AnalysisResults::default();
1004 results
1005 .unused_files
1006 .push(UnusedFileFinding::with_actions(UnusedFile {
1007 path: "src/unused.ts".into(),
1008 }));
1009
1010 let output = build_check_output(CheckOutputInput {
1011 schema_version: 7,
1012 version: "0.0.0".to_string(),
1013 elapsed: Duration::from_millis(42),
1014 results,
1015 config_fixable: false,
1016 meta: None,
1017 workspace_diagnostics: Vec::new(),
1018 next_steps: Vec::new(),
1019 });
1020
1021 assert_eq!(output.schema_version.0, 7);
1022 assert_eq!(output.total_issues, 1);
1023 assert_eq!(output.summary.unused_files, 1);
1024 assert_eq!(output.elapsed_ms.0, 42);
1025 }
1026
1027 #[test]
1028 fn build_check_output_harmonizes_multi_kind_suppress_actions_typed() {
1029 let mut results = AnalysisResults::default();
1030 let path = std::path::PathBuf::from("/project/src/shared.ts");
1031 results
1032 .unused_exports
1033 .push(UnusedExportFinding::with_actions(UnusedExport {
1034 path: path.clone(),
1035 export_name: "value".to_string(),
1036 is_type_only: false,
1037 line: 7,
1038 col: 0,
1039 span_start: 0,
1040 is_re_export: false,
1041 }));
1042 results
1043 .unused_types
1044 .push(UnusedTypeFinding::with_actions(UnusedExport {
1045 path,
1046 export_name: "TypeOnly".to_string(),
1047 is_type_only: true,
1048 line: 7,
1049 col: 0,
1050 span_start: 0,
1051 is_re_export: false,
1052 }));
1053
1054 let output = build_check_output(CheckOutputInput {
1055 schema_version: 7,
1056 version: "0.0.0".to_string(),
1057 elapsed: Duration::from_millis(42),
1058 results,
1059 config_fixable: false,
1060 meta: None,
1061 workspace_diagnostics: Vec::new(),
1062 next_steps: Vec::new(),
1063 });
1064
1065 let export_comment = suppress_comment(&output.results.unused_exports[0].actions);
1066 let type_comment = suppress_comment(&output.results.unused_types[0].actions);
1067 assert_eq!(
1068 export_comment,
1069 Some("// fallow-ignore-next-line unused-export, unused-type")
1070 );
1071 assert_eq!(type_comment, export_comment);
1072 }
1073
1074 #[test]
1075 fn harmonize_dead_code_health_suppress_actions_typed() {
1076 let mut results = AnalysisResults::default();
1077 let path = std::path::PathBuf::from("/project/src/shared.ts");
1078 results
1079 .unused_exports
1080 .push(UnusedExportFinding::with_actions(UnusedExport {
1081 path: path.clone(),
1082 export_name: "value".to_string(),
1083 is_type_only: false,
1084 line: 7,
1085 col: 0,
1086 span_start: 0,
1087 is_re_export: false,
1088 }));
1089 let mut health = HealthReport {
1090 findings: vec![HealthFinding::new(
1091 ComplexityViolation {
1092 path,
1093 name: "expensive".to_string(),
1094 line: 7,
1095 col: 0,
1096 cyclomatic: 22,
1097 cognitive: 18,
1098 line_count: 40,
1099 param_count: 1,
1100 react_hook_count: 0,
1101 react_jsx_max_depth: 0,
1102 react_prop_count: 0,
1103 react_hook_profile: None,
1104 exceeded: ExceededThreshold::Both,
1105 severity: FindingSeverity::High,
1106 crap: None,
1107 coverage_pct: None,
1108 coverage_tier: None,
1109 coverage_source: None,
1110 inherited_from: None,
1111 component_rollup: None,
1112 contributions: Vec::new(),
1113 effective_thresholds: None,
1114 threshold_source: None,
1115 },
1116 vec![HealthFindingAction {
1117 kind: HealthFindingActionType::SuppressLine,
1118 auto_fixable: false,
1119 description: "Suppress with an inline comment above the function declaration"
1120 .to_string(),
1121 note: None,
1122 comment: Some("// fallow-ignore-next-line complexity".to_string()),
1123 placement: Some("above-function-declaration".to_string()),
1124 target_path: None,
1125 }],
1126 None,
1127 )],
1128 ..HealthReport::default()
1129 };
1130
1131 harmonize_dead_code_health_suppress_line_actions(Some(&mut results), Some(&mut health));
1132
1133 assert_eq!(
1134 suppress_comment(&results.unused_exports[0].actions),
1135 Some("// fallow-ignore-next-line unused-export, complexity")
1136 );
1137 assert_eq!(
1138 health.findings[0].actions[0].comment.as_deref(),
1139 Some("// fallow-ignore-next-line unused-export, complexity")
1140 );
1141 }
1142
1143 #[test]
1144 fn check_json_output_uses_output_owned_root_contract() {
1145 let output = build_check_output(CheckOutputInput {
1146 schema_version: 7,
1147 version: "0.0.0".to_string(),
1148 elapsed: Duration::from_millis(42),
1149 results: AnalysisResults::default(),
1150 config_fixable: false,
1151 meta: None,
1152 workspace_diagnostics: Vec::new(),
1153 next_steps: Vec::new(),
1154 });
1155
1156 let value =
1157 serialize_check_json_output(output, RootEnvelopeMode::Tagged, Some("run-check"))
1158 .expect("check output should serialize");
1159
1160 assert_eq!(value["kind"], "dead-code");
1161 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-check");
1162 }
1163
1164 #[test]
1171 fn reachability_caveats_are_absent_when_clean_and_named_when_flagged() {
1172 let mut results = AnalysisResults::default();
1173 results
1174 .unused_files
1175 .push(UnusedFileFinding::with_actions(UnusedFile {
1176 path: "/project/src/clean.ts".into(),
1177 }));
1178 let mut flagged = UnusedFileFinding::with_actions(UnusedFile {
1179 path: "/project/src/orphan.ts".into(),
1180 });
1181 flagged.reachability_caveats = vec![
1182 ReachabilityCaveat::IncompleteFileAnalysis,
1183 ReachabilityCaveat::IncompleteImportGraph,
1184 ];
1185 results.unused_files.push(flagged);
1186
1187 let output = build_check_output(CheckOutputInput {
1188 schema_version: 7,
1189 version: "0.0.0".to_string(),
1190 elapsed: Duration::from_millis(1),
1191 results,
1192 config_fixable: false,
1193 meta: None,
1194 workspace_diagnostics: Vec::new(),
1195 next_steps: Vec::new(),
1196 });
1197 let value = serialize_check_json_output(output, RootEnvelopeMode::Tagged, None)
1198 .expect("dead-code output should serialize");
1199
1200 let entries = value["unused_files"]
1201 .as_array()
1202 .expect("unused_files array")
1203 .clone();
1204 let find = |name: &str| {
1205 entries
1206 .iter()
1207 .find(|entry| {
1208 entry["path"]
1209 .as_str()
1210 .is_some_and(|path| path.ends_with(name))
1211 })
1212 .cloned()
1213 .expect("finding present")
1214 };
1215
1216 assert!(
1217 find("clean.ts").get("reachability_caveats").is_none(),
1218 "a finding with no caveat must keep the previous wire shape exactly"
1219 );
1220
1221 let flagged = find("orphan.ts");
1222 assert_eq!(
1223 flagged["reachability_caveats"],
1224 serde_json::json!(["incomplete-file-analysis", "incomplete-import-graph"]),
1225 "both caveats are named on the wire, in declaration order"
1226 );
1227 assert_eq!(
1228 flagged["actions"].as_array().map(Vec::len),
1229 Some(2),
1230 "the caveat never trims the finding's actions"
1231 );
1232 assert_eq!(
1233 flagged["actions"][0]["type"], "delete-file",
1234 "nor reorders them, so a consumer reading actions[0].type is unaffected"
1235 );
1236 assert_eq!(
1237 flagged["actions"][0]["auto_fixable"],
1238 serde_json::json!(false),
1239 "but the mutation it gates must not advertise itself as applicable"
1240 );
1241 }
1242
1243 #[test]
1244 fn grouped_check_json_output_uses_output_owned_root_contract() {
1245 let root = std::path::Path::new("/project");
1246 let output = CheckGroupedOutput {
1247 schema_version: SchemaVersion(7),
1248 version: ToolVersion("0.0.0".to_string()),
1249 elapsed_ms: ElapsedMs(1),
1250 grouped_by: GroupByMode::Directory,
1251 total_issues: 0,
1252 groups: Vec::new(),
1253 meta: None,
1254 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1255 root,
1256 root.join("src/unreadable.ts"),
1257 WorkspaceDiagnosticKind::SourceReadFailure {
1258 error: "permission denied".to_string(),
1259 },
1260 )],
1261 next_steps: Vec::new(),
1262 };
1263
1264 let value = serialize_check_grouped_json_output(
1265 output,
1266 RootEnvelopeMode::Tagged,
1267 Some("run-group"),
1268 )
1269 .expect("grouped check output should serialize");
1270
1271 assert_eq!(value["kind"], "dead-code-grouped");
1272 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-group");
1273 assert_eq!(
1274 value["workspace_diagnostics"][0]["path"],
1275 "/project/src/unreadable.ts"
1276 );
1277 assert_eq!(
1278 value["workspace_diagnostics"][0]["kind"],
1279 "source-read-failure"
1280 );
1281 }
1282
1283 #[test]
1284 fn workspace_diagnostics_serialize_typed_kind_path_message() {
1285 let root = std::path::Path::new("/project");
1286 let output = build_check_output(CheckOutputInput {
1287 schema_version: 7,
1288 version: "0.0.0".to_string(),
1289 elapsed: Duration::from_millis(1),
1290 results: AnalysisResults::default(),
1291 config_fixable: false,
1292 meta: None,
1293 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1294 root,
1295 root.join("packages/legacy"),
1296 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1297 )],
1298 next_steps: Vec::new(),
1299 });
1300
1301 let value = serde_json::to_value(&output).expect("check output serializes");
1302 let diag = &value["workspace_diagnostics"][0];
1303 assert_eq!(diag["kind"], "undeclared-workspace");
1304 assert!(
1305 diag["path"]
1306 .as_str()
1307 .is_some_and(|path| path.contains("packages/legacy")),
1308 "path field is carried verbatim: {diag}"
1309 );
1310 assert!(
1311 diag["message"]
1312 .as_str()
1313 .is_some_and(|message| message.contains("packages/legacy")),
1314 "message is rendered from kind + path: {diag}"
1315 );
1316 }
1317
1318 #[test]
1319 fn source_read_failure_workspace_diagnostic_serializes_error_payload() {
1320 let root = std::path::Path::new("/project");
1321 let output = build_check_output(CheckOutputInput {
1322 schema_version: 7,
1323 version: "0.0.0".to_string(),
1324 elapsed: Duration::from_millis(1),
1325 results: AnalysisResults::default(),
1326 config_fixable: false,
1327 meta: None,
1328 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1329 root,
1330 root.join("src/removed.ts"),
1331 WorkspaceDiagnosticKind::SourceReadFailure {
1332 error: "No such file or directory".to_string(),
1333 },
1334 )],
1335 next_steps: Vec::new(),
1336 });
1337
1338 let value = serde_json::to_value(&output).expect("check output serializes");
1339 let diagnostic = &value["workspace_diagnostics"][0];
1340 assert_eq!(diagnostic["kind"], "source-read-failure");
1341 assert_eq!(diagnostic["error"], "No such file or directory");
1342 assert!(
1343 diagnostic["message"]
1344 .as_str()
1345 .is_some_and(|message| message.contains("src/removed.ts"))
1346 );
1347 }
1348
1349 fn suppress_comment(actions: &[IssueAction]) -> Option<&str> {
1350 actions.iter().find_map(|action| match action {
1351 IssueAction::SuppressLine(action) => Some(action.comment.as_str()),
1352 _ => None,
1353 })
1354 }
1355}