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