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