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