Skip to main content

fallow_output/
check.rs

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
18/// Current schema version for the dead-code/check JSON envelope.
19pub const CHECK_SCHEMA_VERSION: u32 = 7;
20
21/// Envelope emitted by `fallow dead-code --format json` (plus the `check`
22/// block inside the combined and audit envelopes).
23///
24/// The body is the full `AnalysisResults` flattened into the envelope so
25/// every issue array (`unused_files`, `unused_exports`, ...) lives at the
26/// top level, matching the existing wire shape. `entry_points` lifts the
27/// otherwise `#[serde(skip)]`'d `AnalysisResults::entry_point_summary` back
28/// into the JSON output. `summary` carries the per-category counts the
29/// JSON layer always emits.
30#[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    /// Read-only follow-up commands computed from this run's findings, emitted
54    /// at the JSON root so an agent acting on the output is pointed at fallow's
55    /// adjacent verification capabilities (trace, complexity breakdown, audit,
56    /// workspace scoping). Each command is runnable as-is and never mutating;
57    /// see [`NextStep`] for both contracts. Omitted when empty or when
58    /// `FALLOW_SUGGESTIONS=off`; does NOT contribute to `total_issues`.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub next_steps: Vec<NextStep>,
61}
62
63/// Envelope emitted by `fallow dead-code --group-by ... --format json`.
64///
65/// Issues are partitioned into resolver buckets (CODEOWNERS team, directory
66/// prefix, workspace package, or GitLab CODEOWNERS section) instead of flat
67/// arrays. Each bucket carries the same issue-array shape as the ungrouped
68/// `CheckOutput` body, plus per-group `key` / `owners` / `total_issues`.
69#[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    /// Diagnostics collected for the full analysis before issue grouping.
87    /// See [`CheckOutput::workspace_diagnostics`] for the contract.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
90    /// Read-only follow-up commands computed from the full (ungrouped) findings.
91    /// See [`CheckOutput::next_steps`] for the contract.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub next_steps: Vec<NextStep>,
94}
95
96/// Single resolver bucket inside `CheckGroupedOutput`. Carries the group's
97/// identifier, optional section owners, and a per-group flattened
98/// `AnalysisResults`.
99#[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/// Resolver mode label for grouped envelopes (dead-code, dupes, health).
111///
112/// `owner` groups by CODEOWNERS team, `directory` groups by top-level
113/// directory prefix, `package` groups by workspace package name, `section`
114/// groups by GitLab CODEOWNERS `[Section]` header name.
115#[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
125/// Inputs for building the dead-code JSON envelope.
126pub 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/// Build the typed dead-code JSON envelope from engine results.
138#[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
181/// Serialize `fallow dead-code --format json`.
182///
183/// # Errors
184///
185/// Returns a serde error when the dead-code output cannot be converted to JSON.
186pub 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
194/// Serialize `fallow dead-code --group-by ... --format json`.
195///
196/// # Errors
197///
198/// Returns a serde error when the grouped dead-code output cannot be converted
199/// to JSON.
200pub 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
634/// Merge same-line suppress actions so multi-kind findings share one comment.
635///
636/// This runs on typed `AnalysisResults` before serialization. It replaces the
637/// older JSON-object walk for normal check output and keeps the action contract
638/// owned by the output builders.
639pub 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
649/// Merge same-line suppress actions across dead-code and health sections.
650///
651/// Combined and audit output can surface both dead-code and complexity findings
652/// anchored to the same source line. This keeps the single-line suppress hint
653/// typed until the final JSON serialization step.
654pub 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/// Compute the per-category `CheckSummary` from analysis results.
863#[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}