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