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