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