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    /// Read-only follow-up commands computed from the full (ungrouped) findings.
87    /// See [`CheckOutput::next_steps`] for the contract.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub next_steps: Vec<NextStep>,
90}
91
92/// Single resolver bucket inside `CheckGroupedOutput`. Carries the group's
93/// identifier, optional section owners, and a per-group flattened
94/// `AnalysisResults`.
95#[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/// Resolver mode label for grouped envelopes (dead-code, dupes, health).
107///
108/// `owner` groups by CODEOWNERS team, `directory` groups by top-level
109/// directory prefix, `package` groups by workspace package name, `section`
110/// groups by GitLab CODEOWNERS `[Section]` header name.
111#[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
121/// Inputs for building the dead-code JSON envelope.
122pub 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/// Build the typed dead-code JSON envelope from engine results.
134#[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
177/// Serialize `fallow dead-code --format json`.
178///
179/// # Errors
180///
181/// Returns a serde error when the dead-code output cannot be converted to JSON.
182pub 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
190/// Serialize `fallow dead-code --group-by ... --format json`.
191///
192/// # Errors
193///
194/// Returns a serde error when the grouped dead-code output cannot be converted
195/// to JSON.
196pub 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
630/// Merge same-line suppress actions so multi-kind findings share one comment.
631///
632/// This runs on typed `AnalysisResults` before serialization. It replaces the
633/// older JSON-object walk for normal check output and keeps the action contract
634/// owned by the output builders.
635pub 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
645/// Merge same-line suppress actions across dead-code and health sections.
646///
647/// Combined and audit output can surface both dead-code and complexity findings
648/// anchored to the same source line. This keeps the single-line suppress hint
649/// typed until the final JSON serialization step.
650pub 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/// Compute the per-category `CheckSummary` from analysis results.
859#[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}