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