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