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 = 9;
20
21/// Schema projection for the dead-code envelope's exact version.
22#[cfg(feature = "schema")]
23#[allow(dead_code, reason = "schema-only type used by the field projection")]
24#[derive(schemars::JsonSchema)]
25#[schemars(extend("const" = CHECK_SCHEMA_VERSION))]
26struct CheckSchemaVersion(u32);
27
28/// Envelope emitted by `fallow dead-code --format json` (plus the `check`
29/// block inside the combined and audit envelopes).
30///
31/// The body is the full `AnalysisResults` flattened into the envelope so
32/// every issue array (`unused_files`, `unused_exports`, ...) lives at the
33/// top level, matching the existing wire shape. `entry_points` lifts the
34/// otherwise `#[serde(skip)]`'d `AnalysisResults::entry_point_summary` back
35/// into the JSON output. `summary` carries the per-category counts the
36/// JSON layer always emits.
37#[derive(Debug, Clone, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39#[cfg_attr(feature = "schema", schemars(title = "fallow dead-code --format json"))]
40pub struct CheckOutput {
41    /// Dead-code output schema version; currently [`CHECK_SCHEMA_VERSION`].
42    #[cfg_attr(feature = "schema", schemars(with = "CheckSchemaVersion"))]
43    pub schema_version: SchemaVersion,
44    /// Fallow CLI version that produced this output.
45    pub version: ToolVersion,
46    /// Wall-clock analysis duration in milliseconds.
47    pub elapsed_ms: ElapsedMs,
48    /// Total findings across all issue arrays; excludes `next_steps`.
49    pub total_issues: usize,
50    /// Entry-point totals per source, when the analysis recorded them.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub entry_points: Option<EntryPoints>,
53    /// Per-category finding counts.
54    pub summary: CheckSummary,
55    /// Full analysis results, flattened so each issue array sits at the
56    /// envelope root.
57    #[serde(flatten)]
58    pub results: AnalysisResults,
59    /// Count deltas against the matched baseline, in baseline runs.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub baseline_deltas: Option<BaselineDeltas>,
62    /// Which baseline snapshot was matched, in baseline runs.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub baseline: Option<BaselineMatch>,
65    /// Regression verdict against the baseline, in `--fail-on-regression` runs.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub regression: Option<RegressionResult>,
68    /// `_meta` block with docs and rule definitions, when `--explain` was
69    /// passed.
70    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
71    pub meta: Option<Meta>,
72    /// Workspace-discovery diagnostics surfaced during config load.
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
75    /// Read-only follow-up commands computed from this run's findings, emitted
76    /// at the JSON root so an agent acting on the output is pointed at fallow's
77    /// adjacent verification capabilities (trace, complexity breakdown, audit,
78    /// workspace scoping). Each command is runnable as-is and never mutating;
79    /// see [`NextStep`] for both contracts. Omitted when empty or when
80    /// `FALLOW_SUGGESTIONS=off`; does NOT contribute to `total_issues`.
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub next_steps: Vec<NextStep>,
83}
84
85/// Envelope emitted by `fallow dead-code --group-by ... --format json`.
86///
87/// Issues are partitioned into resolver buckets (CODEOWNERS team, directory
88/// prefix, workspace package, or GitLab CODEOWNERS section) instead of flat
89/// arrays. Each bucket carries the same issue-array shape as the ungrouped
90/// `CheckOutput` body, plus per-group `key` / `owners` / `total_issues`.
91#[derive(Debug, Clone, Serialize)]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93#[cfg_attr(
94    feature = "schema",
95    schemars(
96        title = "fallow dead-code --group-by <owner|directory|package|section> --format json"
97    )
98)]
99pub struct CheckGroupedOutput {
100    /// Dead-code output schema version; currently [`CHECK_SCHEMA_VERSION`].
101    #[cfg_attr(feature = "schema", schemars(with = "CheckSchemaVersion"))]
102    pub schema_version: SchemaVersion,
103    /// Fallow CLI version that produced this output.
104    pub version: ToolVersion,
105    /// Wall-clock analysis duration in milliseconds.
106    pub elapsed_ms: ElapsedMs,
107    /// Resolver the issues were grouped by.
108    pub grouped_by: GroupByMode,
109    /// Total findings across all groups.
110    pub total_issues: usize,
111    /// One bucket per resolver key.
112    pub groups: Vec<CheckGroupedEntry>,
113    /// `_meta` block with docs and rule definitions, when `--explain` was
114    /// passed.
115    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
116    pub meta: Option<Meta>,
117    /// Diagnostics collected for the full analysis before issue grouping.
118    /// See [`CheckOutput::workspace_diagnostics`] for the contract.
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
121    /// Read-only follow-up commands computed from the full (ungrouped) findings.
122    /// See [`CheckOutput::next_steps`] for the contract.
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub next_steps: Vec<NextStep>,
125}
126
127/// Single resolver bucket inside `CheckGroupedOutput`. Carries the group's
128/// identifier, optional section owners, and a per-group flattened
129/// `AnalysisResults`.
130#[derive(Debug, Clone, Serialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132pub struct CheckGroupedEntry {
133    /// Resolver key: team name, directory prefix, package name, or section.
134    pub key: String,
135    /// Owners of a GitLab CODEOWNERS section; present for section grouping.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub owners: Option<Vec<String>>,
138    /// Findings in this group.
139    pub total_issues: usize,
140    /// Group-scoped analysis results, flattened like the ungrouped body.
141    #[serde(flatten)]
142    pub results: AnalysisResults,
143}
144
145/// Resolver mode label for grouped envelopes (dead-code, dupes, health).
146///
147/// `owner` groups by CODEOWNERS team, `directory` groups by top-level
148/// directory prefix, `package` groups by workspace package name, `section`
149/// groups by GitLab CODEOWNERS `[Section]` header name.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
151#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
152#[serde(rename_all = "lowercase")]
153pub enum GroupByMode {
154    /// Group by CODEOWNERS team.
155    Owner,
156    /// Group by top-level directory prefix.
157    Directory,
158    /// Group by workspace package name.
159    Package,
160    /// Group by GitLab CODEOWNERS `[Section]` header name.
161    Section,
162}
163
164/// Inputs for building the dead-code JSON envelope.
165pub struct CheckOutputInput {
166    /// Dead-code output schema version to report.
167    pub schema_version: u32,
168    /// Fallow CLI version to report.
169    pub version: String,
170    /// Wall-clock analysis duration; serialized as whole milliseconds.
171    pub elapsed: Duration,
172    /// Engine analysis results to embed.
173    pub results: AnalysisResults,
174    /// Whether duplicate-export findings are fixable via config edits, which
175    /// flips their `config_fixable` action flag.
176    pub config_fixable: bool,
177    /// `_meta` block to attach when `--explain` was passed.
178    pub meta: Option<Meta>,
179    /// Workspace-discovery diagnostics surfaced during config load.
180    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
181    /// Read-only follow-up commands computed from this run's findings.
182    pub next_steps: Vec<NextStep>,
183}
184
185/// Build the typed dead-code JSON envelope from engine results.
186#[must_use]
187pub fn build_check_output(input: CheckOutputInput) -> CheckOutput {
188    let mut results = input.results;
189    apply_config_fixable_to_duplicate_exports(&mut results, input.config_fixable);
190    harmonize_multi_kind_suppress_line_actions(&mut results);
191    CheckOutput {
192        schema_version: SchemaVersion(input.schema_version),
193        version: ToolVersion(input.version),
194        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
195        total_issues: results.total_issues(),
196        entry_points: results
197            .entry_point_summary
198            .as_ref()
199            .map(|entry_points| EntryPoints {
200                total: entry_points.total,
201                sources: entry_points
202                    .by_source
203                    .iter()
204                    .map(|(key, value)| (key.replace(' ', "_"), *value))
205                    .collect(),
206            }),
207        summary: build_check_summary(&results),
208        results,
209        baseline_deltas: None,
210        baseline: None,
211        regression: None,
212        meta: input.meta,
213        workspace_diagnostics: input.workspace_diagnostics,
214        next_steps: input.next_steps,
215    }
216}
217
218fn serialize_check_family_json_output<T: Serialize>(
219    output: T,
220    kind: &'static str,
221    mode: RootEnvelopeMode,
222    analysis_run_id: Option<&str>,
223) -> Result<serde_json::Value, serde_json::Error> {
224    let mut value = serialize_named_json_output(output, kind, mode)?;
225    attach_telemetry_meta(&mut value, analysis_run_id);
226    Ok(value)
227}
228
229/// Serialize `fallow dead-code --format json`.
230///
231/// # Errors
232///
233/// Returns a serde error when the dead-code output cannot be converted to JSON.
234pub fn serialize_check_json_output(
235    output: CheckOutput,
236    mode: RootEnvelopeMode,
237    analysis_run_id: Option<&str>,
238) -> Result<serde_json::Value, serde_json::Error> {
239    serialize_check_family_json_output(output, "dead-code", mode, analysis_run_id)
240}
241
242/// Serialize `fallow dead-code --group-by ... --format json`.
243///
244/// # Errors
245///
246/// Returns a serde error when the grouped dead-code output cannot be converted
247/// to JSON.
248pub fn serialize_check_grouped_json_output(
249    output: CheckGroupedOutput,
250    mode: RootEnvelopeMode,
251    analysis_run_id: Option<&str>,
252) -> Result<serde_json::Value, serde_json::Error> {
253    serialize_check_family_json_output(output, "dead-code-grouped", mode, analysis_run_id)
254}
255
256/// Mark every duplicate-export finding as fixable through a config edit when
257/// the project's config supports automated fixes.
258pub fn apply_config_fixable_to_duplicate_exports(
259    results: &mut AnalysisResults,
260    config_fixable: bool,
261) {
262    if !config_fixable {
263        return;
264    }
265    for finding in &mut results.duplicate_exports {
266        finding.set_config_fixable(true);
267    }
268}
269
270type SuppressAnchor = (String, u32);
271
272macro_rules! visit_suppress_line_findings {
273    ($results:expr, $visit:expr) => {{
274        let results = $results;
275        for finding in &results.unused_exports {
276            $visit(&finding.export.path, finding.export.line, &finding.actions);
277        }
278        for finding in &results.unused_types {
279            $visit(&finding.export.path, finding.export.line, &finding.actions);
280        }
281        for finding in &results.private_type_leaks {
282            $visit(&finding.leak.path, finding.leak.line, &finding.actions);
283        }
284        for finding in &results.unused_enum_members {
285            $visit(&finding.member.path, finding.member.line, &finding.actions);
286        }
287        for finding in &results.unused_class_members {
288            $visit(&finding.member.path, finding.member.line, &finding.actions);
289        }
290        for finding in &results.unused_store_members {
291            $visit(&finding.member.path, finding.member.line, &finding.actions);
292        }
293        for finding in &results.unresolved_imports {
294            $visit(&finding.import.path, finding.import.line, &finding.actions);
295        }
296        for finding in &results.unused_dependencies {
297            $visit(&finding.dep.path, finding.dep.line, &finding.actions);
298        }
299        for finding in &results.unused_dev_dependencies {
300            $visit(&finding.dep.path, finding.dep.line, &finding.actions);
301        }
302        for finding in &results.unused_optional_dependencies {
303            $visit(&finding.dep.path, finding.dep.line, &finding.actions);
304        }
305        for finding in &results.type_only_dependencies {
306            $visit(&finding.dep.path, finding.dep.line, &finding.actions);
307        }
308        for finding in &results.test_only_dependencies {
309            $visit(&finding.dep.path, finding.dep.line, &finding.actions);
310        }
311        for finding in &results.dev_dependencies_in_production {
312            $visit(&finding.dep.path, finding.dep.line, &finding.actions);
313        }
314        for finding in &results.circular_dependencies {
315            if let Some(path) = finding.cycle.files.first() {
316                $visit(path, finding.cycle.line, &finding.actions);
317            }
318        }
319        for finding in &results.boundary_violations {
320            $visit(
321                &finding.violation.from_path,
322                finding.violation.line,
323                &finding.actions,
324            );
325        }
326        for finding in &results.boundary_coverage_violations {
327            $visit(
328                &finding.violation.path,
329                finding.violation.line,
330                &finding.actions,
331            );
332        }
333        for finding in &results.boundary_call_violations {
334            $visit(
335                &finding.violation.path,
336                finding.violation.line,
337                &finding.actions,
338            );
339        }
340        for finding in &results.policy_violations {
341            $visit(
342                &finding.violation.path,
343                finding.violation.line,
344                &finding.actions,
345            );
346        }
347        for finding in &results.unused_catalog_entries {
348            $visit(&finding.entry.path, finding.entry.line, &finding.actions);
349        }
350        for finding in &results.empty_catalog_groups {
351            $visit(&finding.group.path, finding.group.line, &finding.actions);
352        }
353        for finding in &results.unresolved_catalog_references {
354            $visit(
355                &finding.reference.path,
356                finding.reference.line,
357                &finding.actions,
358            );
359        }
360        for finding in &results.unused_dependency_overrides {
361            $visit(&finding.entry.path, finding.entry.line, &finding.actions);
362        }
363        for finding in &results.misconfigured_dependency_overrides {
364            $visit(&finding.entry.path, finding.entry.line, &finding.actions);
365        }
366        for finding in &results.invalid_client_exports {
367            $visit(&finding.export.path, finding.export.line, &finding.actions);
368        }
369        for finding in &results.mixed_client_server_barrels {
370            $visit(&finding.barrel.path, finding.barrel.line, &finding.actions);
371        }
372        for finding in &results.misplaced_directives {
373            $visit(
374                &finding.directive_site.path,
375                finding.directive_site.line,
376                &finding.actions,
377            );
378        }
379        for finding in &results.unprovided_injects {
380            $visit(&finding.inject.path, finding.inject.line, &finding.actions);
381        }
382        for finding in &results.unrendered_components {
383            $visit(
384                &finding.component.path,
385                finding.component.line,
386                &finding.actions,
387            );
388        }
389        for finding in &results.route_collisions {
390            $visit(
391                &finding.collision.path,
392                finding.collision.line,
393                &finding.actions,
394            );
395        }
396        for finding in &results.dynamic_segment_name_conflicts {
397            $visit(
398                &finding.conflict.path,
399                finding.conflict.line,
400                &finding.actions,
401            );
402        }
403        for finding in &results.unused_component_props {
404            $visit(&finding.prop.path, finding.prop.line, &finding.actions);
405        }
406        for finding in &results.unused_component_emits {
407            $visit(&finding.emit.path, finding.emit.line, &finding.actions);
408        }
409        for finding in &results.unused_component_inputs {
410            $visit(&finding.input.path, finding.input.line, &finding.actions);
411        }
412        for finding in &results.unused_component_outputs {
413            $visit(&finding.output.path, finding.output.line, &finding.actions);
414        }
415        for finding in &results.unused_svelte_events {
416            $visit(&finding.event.path, finding.event.line, &finding.actions);
417        }
418        for finding in &results.unused_server_actions {
419            $visit(&finding.action.path, finding.action.line, &finding.actions);
420        }
421        for finding in &results.unused_load_data_keys {
422            $visit(&finding.key.path, finding.key.line, &finding.actions);
423        }
424        for finding in &results.prop_drilling_chains {
425            if let Some(hop) = finding.chain.hops.first() {
426                $visit(&hop.file, hop.line, &finding.actions);
427            }
428        }
429        for finding in &results.thin_wrappers {
430            $visit(
431                &finding.wrapper.file,
432                finding.wrapper.line,
433                &finding.actions,
434            );
435        }
436        for finding in &results.duplicate_prop_shapes {
437            $visit(&finding.shape.file, finding.shape.line, &finding.actions);
438        }
439    }};
440}
441
442macro_rules! visit_suppress_line_findings_mut {
443    ($results:expr, $visit:expr) => {{
444        let results = $results;
445        for finding in &mut results.unused_exports {
446            $visit(
447                &finding.export.path,
448                finding.export.line,
449                &mut finding.actions,
450            );
451        }
452        for finding in &mut results.unused_types {
453            $visit(
454                &finding.export.path,
455                finding.export.line,
456                &mut finding.actions,
457            );
458        }
459        for finding in &mut results.private_type_leaks {
460            $visit(&finding.leak.path, finding.leak.line, &mut finding.actions);
461        }
462        for finding in &mut results.unused_enum_members {
463            $visit(
464                &finding.member.path,
465                finding.member.line,
466                &mut finding.actions,
467            );
468        }
469        for finding in &mut results.unused_class_members {
470            $visit(
471                &finding.member.path,
472                finding.member.line,
473                &mut finding.actions,
474            );
475        }
476        for finding in &mut results.unused_store_members {
477            $visit(
478                &finding.member.path,
479                finding.member.line,
480                &mut finding.actions,
481            );
482        }
483        for finding in &mut results.unresolved_imports {
484            $visit(
485                &finding.import.path,
486                finding.import.line,
487                &mut finding.actions,
488            );
489        }
490        for finding in &mut results.unused_dependencies {
491            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
492        }
493        for finding in &mut results.unused_dev_dependencies {
494            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
495        }
496        for finding in &mut results.unused_optional_dependencies {
497            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
498        }
499        for finding in &mut results.type_only_dependencies {
500            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
501        }
502        for finding in &mut results.test_only_dependencies {
503            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
504        }
505        for finding in &mut results.dev_dependencies_in_production {
506            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
507        }
508        for finding in &mut results.circular_dependencies {
509            if let Some(path) = finding.cycle.files.first() {
510                $visit(path, finding.cycle.line, &mut finding.actions);
511            }
512        }
513        for finding in &mut results.boundary_violations {
514            $visit(
515                &finding.violation.from_path,
516                finding.violation.line,
517                &mut finding.actions,
518            );
519        }
520        for finding in &mut results.boundary_coverage_violations {
521            $visit(
522                &finding.violation.path,
523                finding.violation.line,
524                &mut finding.actions,
525            );
526        }
527        for finding in &mut results.boundary_call_violations {
528            $visit(
529                &finding.violation.path,
530                finding.violation.line,
531                &mut finding.actions,
532            );
533        }
534        for finding in &mut results.policy_violations {
535            $visit(
536                &finding.violation.path,
537                finding.violation.line,
538                &mut finding.actions,
539            );
540        }
541        for finding in &mut results.unused_catalog_entries {
542            $visit(
543                &finding.entry.path,
544                finding.entry.line,
545                &mut finding.actions,
546            );
547        }
548        for finding in &mut results.empty_catalog_groups {
549            $visit(
550                &finding.group.path,
551                finding.group.line,
552                &mut finding.actions,
553            );
554        }
555        for finding in &mut results.unresolved_catalog_references {
556            $visit(
557                &finding.reference.path,
558                finding.reference.line,
559                &mut finding.actions,
560            );
561        }
562        for finding in &mut results.unused_dependency_overrides {
563            $visit(
564                &finding.entry.path,
565                finding.entry.line,
566                &mut finding.actions,
567            );
568        }
569        for finding in &mut results.misconfigured_dependency_overrides {
570            $visit(
571                &finding.entry.path,
572                finding.entry.line,
573                &mut finding.actions,
574            );
575        }
576        for finding in &mut results.invalid_client_exports {
577            $visit(
578                &finding.export.path,
579                finding.export.line,
580                &mut finding.actions,
581            );
582        }
583        for finding in &mut results.mixed_client_server_barrels {
584            $visit(
585                &finding.barrel.path,
586                finding.barrel.line,
587                &mut finding.actions,
588            );
589        }
590        for finding in &mut results.misplaced_directives {
591            $visit(
592                &finding.directive_site.path,
593                finding.directive_site.line,
594                &mut finding.actions,
595            );
596        }
597        for finding in &mut results.unprovided_injects {
598            $visit(
599                &finding.inject.path,
600                finding.inject.line,
601                &mut finding.actions,
602            );
603        }
604        for finding in &mut results.unrendered_components {
605            $visit(
606                &finding.component.path,
607                finding.component.line,
608                &mut finding.actions,
609            );
610        }
611        for finding in &mut results.route_collisions {
612            $visit(
613                &finding.collision.path,
614                finding.collision.line,
615                &mut finding.actions,
616            );
617        }
618        for finding in &mut results.dynamic_segment_name_conflicts {
619            $visit(
620                &finding.conflict.path,
621                finding.conflict.line,
622                &mut finding.actions,
623            );
624        }
625        for finding in &mut results.unused_component_props {
626            $visit(&finding.prop.path, finding.prop.line, &mut finding.actions);
627        }
628        for finding in &mut results.unused_component_emits {
629            $visit(&finding.emit.path, finding.emit.line, &mut finding.actions);
630        }
631        for finding in &mut results.unused_component_inputs {
632            $visit(
633                &finding.input.path,
634                finding.input.line,
635                &mut finding.actions,
636            );
637        }
638        for finding in &mut results.unused_component_outputs {
639            $visit(
640                &finding.output.path,
641                finding.output.line,
642                &mut finding.actions,
643            );
644        }
645        for finding in &mut results.unused_svelte_events {
646            $visit(
647                &finding.event.path,
648                finding.event.line,
649                &mut finding.actions,
650            );
651        }
652        for finding in &mut results.unused_server_actions {
653            $visit(
654                &finding.action.path,
655                finding.action.line,
656                &mut finding.actions,
657            );
658        }
659        for finding in &mut results.unused_load_data_keys {
660            $visit(&finding.key.path, finding.key.line, &mut finding.actions);
661        }
662        for finding in &mut results.prop_drilling_chains {
663            if let Some(hop) = finding.chain.hops.first() {
664                $visit(&hop.file, hop.line, &mut finding.actions);
665            }
666        }
667        for finding in &mut results.thin_wrappers {
668            $visit(
669                &finding.wrapper.file,
670                finding.wrapper.line,
671                &mut finding.actions,
672            );
673        }
674        for finding in &mut results.duplicate_prop_shapes {
675            $visit(
676                &finding.shape.file,
677                finding.shape.line,
678                &mut finding.actions,
679            );
680        }
681    }};
682}
683
684/// Merge same-line suppress actions so multi-kind findings share one comment.
685///
686/// This runs on typed `AnalysisResults` before serialization. It replaces the
687/// older JSON-object walk for normal check output and keeps the action contract
688/// owned by the output builders.
689pub fn harmonize_multi_kind_suppress_line_actions(results: &mut AnalysisResults) {
690    let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
691    collect_dead_code_suppress_line_anchors(results, &mut anchors);
692    retain_multi_kind_anchors(&mut anchors);
693    if anchors.is_empty() {
694        return;
695    }
696    rewrite_dead_code_suppress_line_actions(results, &anchors);
697}
698
699/// Merge same-line suppress actions across dead-code and health sections.
700///
701/// Combined and audit output can surface both dead-code and complexity findings
702/// anchored to the same source line. This keeps the single-line suppress hint
703/// typed until the final JSON serialization step.
704pub fn harmonize_dead_code_health_suppress_line_actions(
705    dead_code: Option<&mut AnalysisResults>,
706    health: Option<&mut HealthReport>,
707) {
708    let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
709    if let Some(results) = dead_code.as_deref() {
710        collect_dead_code_suppress_line_anchors(results, &mut anchors);
711    }
712    if let Some(report) = health.as_deref() {
713        collect_health_suppress_line_anchors(report, &mut anchors);
714    }
715
716    retain_multi_kind_anchors(&mut anchors);
717    if anchors.is_empty() {
718        return;
719    }
720
721    if let Some(results) = dead_code {
722        rewrite_dead_code_suppress_line_actions(results, &anchors);
723    }
724    if let Some(report) = health {
725        rewrite_health_suppress_line_actions(report, &anchors);
726    }
727}
728
729fn retain_multi_kind_anchors(anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>) {
730    anchors.retain(|_, kinds| {
731        sort_suppression_kinds(kinds);
732        kinds.dedup();
733        kinds.len() > 1
734    });
735}
736
737fn collect_dead_code_suppress_line_anchors(
738    results: &AnalysisResults,
739    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
740) {
741    visit_suppress_line_findings!(results, |path: &Path, line, actions: &[IssueAction]| {
742        collect_action_kinds(path, line, actions, anchors);
743    });
744}
745
746fn rewrite_dead_code_suppress_line_actions(
747    results: &mut AnalysisResults,
748    anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
749) {
750    visit_suppress_line_findings_mut!(
751        results,
752        |path: &Path, line, actions: &mut Vec<IssueAction>| {
753            let anchor = suppress_anchor(path, line);
754            if let Some(kinds) = anchors.get(&anchor) {
755                let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
756                rewrite_action_comments(actions, &comment);
757            }
758        }
759    );
760}
761
762fn collect_health_suppress_line_anchors(
763    report: &HealthReport,
764    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
765) {
766    for finding in &report.findings {
767        collect_health_action_kinds(
768            &finding.violation.path,
769            finding.violation.line,
770            &finding.actions,
771            anchors,
772        );
773    }
774    for finding in &report.prop_drilling_chains {
775        if let Some(hop) = finding.chain.hops.first() {
776            collect_action_kinds(&hop.file, hop.line, &finding.actions, anchors);
777        }
778    }
779}
780
781fn rewrite_health_suppress_line_actions(
782    report: &mut HealthReport,
783    anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
784) {
785    for finding in &mut report.findings {
786        let anchor = suppress_anchor(&finding.violation.path, finding.violation.line);
787        if let Some(kinds) = anchors.get(&anchor) {
788            let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
789            rewrite_health_action_comments(&mut finding.actions, &comment);
790        }
791    }
792    for finding in &mut report.prop_drilling_chains {
793        if let Some(hop) = finding.chain.hops.first() {
794            let anchor = suppress_anchor(&hop.file, hop.line);
795            if let Some(kinds) = anchors.get(&anchor) {
796                let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
797                rewrite_action_comments(&mut finding.actions, &comment);
798            }
799        }
800    }
801}
802
803fn collect_action_kinds(
804    path: &Path,
805    line: u32,
806    actions: &[IssueAction],
807    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
808) {
809    for action in actions {
810        if let Some(comment) = suppress_line_comment(action) {
811            let kinds = anchors.entry(suppress_anchor(path, line)).or_default();
812            for kind in parse_suppress_line_comment(comment) {
813                if !kinds.iter().any(|existing| existing == &kind) {
814                    kinds.push(kind);
815                }
816            }
817        }
818    }
819}
820
821fn collect_health_action_kinds(
822    path: &Path,
823    line: u32,
824    actions: &[HealthFindingAction],
825    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
826) {
827    for action in actions {
828        if let Some(comment) = health_suppress_line_comment(action) {
829            let kinds = anchors.entry(suppress_anchor(path, line)).or_default();
830            for kind in parse_suppress_line_comment(comment) {
831                if !kinds.iter().any(|existing| existing == &kind) {
832                    kinds.push(kind);
833                }
834            }
835        }
836    }
837}
838
839fn rewrite_action_comments(actions: &mut [IssueAction], comment: &str) {
840    for action in actions {
841        if let IssueAction::SuppressLine(suppress) = action {
842            suppress.comment = comment.to_string();
843        }
844    }
845}
846
847fn rewrite_health_action_comments(actions: &mut [HealthFindingAction], comment: &str) {
848    for action in actions {
849        if matches!(action.kind, HealthFindingActionType::SuppressLine) {
850            action.comment = Some(comment.to_string());
851        }
852    }
853}
854
855fn suppress_anchor(path: &Path, line: u32) -> SuppressAnchor {
856    (path.display().to_string(), line)
857}
858
859fn suppress_line_comment(action: &IssueAction) -> Option<&str> {
860    match action {
861        IssueAction::SuppressLine(action) => Some(&action.comment),
862        _ => None,
863    }
864}
865
866fn health_suppress_line_comment(action: &HealthFindingAction) -> Option<&str> {
867    matches!(action.kind, HealthFindingActionType::SuppressLine)
868        .then_some(())
869        .and(action.comment.as_deref())
870}
871
872fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
873    comment
874        .strip_prefix("// fallow-ignore-next-line ")
875        .map(|rest| {
876            rest.split(|c: char| c == ',' || c.is_whitespace())
877                .filter(|token| !token.is_empty())
878                .map(str::to_string)
879                .collect()
880        })
881        .unwrap_or_default()
882}
883
884fn sort_suppression_kinds(kinds: &mut [String]) {
885    kinds.sort_by_key(|kind| suppression_kind_rank(kind));
886}
887
888fn suppression_kind_rank(kind: &str) -> usize {
889    match kind {
890        "unused-file" => 0,
891        "unused-export" => 1,
892        "unused-type" => 2,
893        "private-type-leak" => 3,
894        "unused-enum-member" => 4,
895        "unused-class-member" => 5,
896        "unused-store-member" => 6,
897        "unresolved-import" => 7,
898        "unlisted-dependency" => 8,
899        "duplicate-export" => 9,
900        "circular-dependency" => 10,
901        "re-export-cycle" => 11,
902        "boundary-violation" => 12,
903        "code-duplication" => 13,
904        "complexity" => 14,
905        "unprovided-inject" => 15,
906        "unrendered-component" => 16,
907        "unused-server-action" => 17,
908        _ => usize::MAX,
909    }
910}
911
912/// Compute the per-category `CheckSummary` from analysis results.
913#[must_use]
914pub fn build_check_summary(results: &AnalysisResults) -> CheckSummary {
915    CheckSummary {
916        total_issues: results.total_issues(),
917        unused_files: results.unused_files.len(),
918        unused_exports: results.unused_exports.len(),
919        unused_types: results.unused_types.len(),
920        private_type_leaks: results.private_type_leaks.len(),
921        unused_dependencies: results.unused_dependencies.len()
922            + results.unused_dev_dependencies.len()
923            + results.unused_optional_dependencies.len(),
924        unused_enum_members: results.unused_enum_members.len(),
925        unused_class_members: results.unused_class_members.len(),
926        unused_store_members: results.unused_store_members.len(),
927        unresolved_imports: results.unresolved_imports.len(),
928        unlisted_dependencies: results.unlisted_dependencies.len(),
929        duplicate_exports: results.duplicate_exports.len(),
930        type_only_dependencies: results.type_only_dependencies.len(),
931        test_only_dependencies: results.test_only_dependencies.len(),
932        dev_dependencies_in_production: results.dev_dependencies_in_production.len(),
933        circular_dependencies: results.circular_dependencies.len(),
934        re_export_cycles: results.re_export_cycles.len(),
935        boundary_violations: results.boundary_violations.len(),
936        boundary_coverage_violations: results.boundary_coverage_violations.len(),
937        boundary_call_violations: results.boundary_call_violations.len(),
938        policy_violations: results.policy_violations.len(),
939        stale_suppressions: results.stale_suppressions.len(),
940        unused_catalog_entries: results.unused_catalog_entries.len(),
941        empty_catalog_groups: results.empty_catalog_groups.len(),
942        unresolved_catalog_references: results.unresolved_catalog_references.len(),
943        unused_dependency_overrides: results.unused_dependency_overrides.len(),
944        misconfigured_dependency_overrides: results.misconfigured_dependency_overrides.len(),
945        invalid_client_exports: results.invalid_client_exports.len(),
946        mixed_client_server_barrels: results.mixed_client_server_barrels.len(),
947        misplaced_directives: results.misplaced_directives.len(),
948        unprovided_injects: results.unprovided_injects.len(),
949        unrendered_components: results.unrendered_components.len(),
950        unused_component_props: results.unused_component_props.len(),
951        unused_component_emits: results.unused_component_emits.len(),
952        unused_component_inputs: results.unused_component_inputs.len(),
953        unused_component_outputs: results.unused_component_outputs.len(),
954        unused_svelte_events: results.unused_svelte_events.len(),
955        unused_server_actions: results.unused_server_actions.len(),
956        unused_load_data_keys: results.unused_load_data_keys.len(),
957        route_collisions: results.route_collisions.len(),
958        dynamic_segment_name_conflicts: results.dynamic_segment_name_conflicts.len(),
959    }
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965    use crate::{ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding};
966    use fallow_types::output_dead_code::{
967        UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
968    };
969    use fallow_types::results::{UnusedExport, UnusedFile};
970    use fallow_types::workspace::WorkspaceDiagnosticKind;
971
972    #[test]
973    fn build_check_output_counts_issues_and_entry_points() {
974        let mut results = AnalysisResults::default();
975        results
976            .unused_files
977            .push(UnusedFileFinding::with_actions(UnusedFile {
978                path: "src/unused.ts".into(),
979            }));
980
981        let output = build_check_output(CheckOutputInput {
982            schema_version: 7,
983            version: "0.0.0".to_string(),
984            elapsed: Duration::from_millis(42),
985            results,
986            config_fixable: false,
987            meta: None,
988            workspace_diagnostics: Vec::new(),
989            next_steps: Vec::new(),
990        });
991
992        assert_eq!(output.schema_version.0, 7);
993        assert_eq!(output.total_issues, 1);
994        assert_eq!(output.summary.unused_files, 1);
995        assert_eq!(output.elapsed_ms.0, 42);
996    }
997
998    #[test]
999    fn build_check_output_harmonizes_multi_kind_suppress_actions_typed() {
1000        let mut results = AnalysisResults::default();
1001        let path = std::path::PathBuf::from("/project/src/shared.ts");
1002        results
1003            .unused_exports
1004            .push(UnusedExportFinding::with_actions(UnusedExport {
1005                path: path.clone(),
1006                export_name: "value".to_string(),
1007                is_type_only: false,
1008                line: 7,
1009                col: 0,
1010                span_start: 0,
1011                is_re_export: false,
1012            }));
1013        results
1014            .unused_types
1015            .push(UnusedTypeFinding::with_actions(UnusedExport {
1016                path,
1017                export_name: "TypeOnly".to_string(),
1018                is_type_only: true,
1019                line: 7,
1020                col: 0,
1021                span_start: 0,
1022                is_re_export: false,
1023            }));
1024
1025        let output = build_check_output(CheckOutputInput {
1026            schema_version: 7,
1027            version: "0.0.0".to_string(),
1028            elapsed: Duration::from_millis(42),
1029            results,
1030            config_fixable: false,
1031            meta: None,
1032            workspace_diagnostics: Vec::new(),
1033            next_steps: Vec::new(),
1034        });
1035
1036        let export_comment = suppress_comment(&output.results.unused_exports[0].actions);
1037        let type_comment = suppress_comment(&output.results.unused_types[0].actions);
1038        assert_eq!(
1039            export_comment,
1040            Some("// fallow-ignore-next-line unused-export, unused-type")
1041        );
1042        assert_eq!(type_comment, export_comment);
1043    }
1044
1045    #[test]
1046    fn harmonize_dead_code_health_suppress_actions_typed() {
1047        let mut results = AnalysisResults::default();
1048        let path = std::path::PathBuf::from("/project/src/shared.ts");
1049        results
1050            .unused_exports
1051            .push(UnusedExportFinding::with_actions(UnusedExport {
1052                path: path.clone(),
1053                export_name: "value".to_string(),
1054                is_type_only: false,
1055                line: 7,
1056                col: 0,
1057                span_start: 0,
1058                is_re_export: false,
1059            }));
1060        let mut health = HealthReport {
1061            findings: vec![HealthFinding::new(
1062                ComplexityViolation {
1063                    path,
1064                    name: "expensive".to_string(),
1065                    line: 7,
1066                    col: 0,
1067                    cyclomatic: 22,
1068                    cognitive: 18,
1069                    line_count: 40,
1070                    param_count: 1,
1071                    react_hook_count: 0,
1072                    react_jsx_max_depth: 0,
1073                    react_prop_count: 0,
1074                    react_hook_profile: None,
1075                    exceeded: ExceededThreshold::Both,
1076                    severity: FindingSeverity::High,
1077                    crap: None,
1078                    coverage_pct: None,
1079                    coverage_tier: None,
1080                    coverage_source: None,
1081                    inherited_from: None,
1082                    component_rollup: None,
1083                    contributions: Vec::new(),
1084                    effective_thresholds: None,
1085                    threshold_source: None,
1086                },
1087                vec![HealthFindingAction {
1088                    kind: HealthFindingActionType::SuppressLine,
1089                    auto_fixable: false,
1090                    description: "Suppress with an inline comment above the function declaration"
1091                        .to_string(),
1092                    note: None,
1093                    comment: Some("// fallow-ignore-next-line complexity".to_string()),
1094                    placement: Some("above-function-declaration".to_string()),
1095                    target_path: None,
1096                }],
1097                None,
1098            )],
1099            ..HealthReport::default()
1100        };
1101
1102        harmonize_dead_code_health_suppress_line_actions(Some(&mut results), Some(&mut health));
1103
1104        assert_eq!(
1105            suppress_comment(&results.unused_exports[0].actions),
1106            Some("// fallow-ignore-next-line unused-export, complexity")
1107        );
1108        assert_eq!(
1109            health.findings[0].actions[0].comment.as_deref(),
1110            Some("// fallow-ignore-next-line unused-export, complexity")
1111        );
1112    }
1113
1114    #[test]
1115    fn check_json_output_uses_output_owned_root_contract() {
1116        let output = build_check_output(CheckOutputInput {
1117            schema_version: 7,
1118            version: "0.0.0".to_string(),
1119            elapsed: Duration::from_millis(42),
1120            results: AnalysisResults::default(),
1121            config_fixable: false,
1122            meta: None,
1123            workspace_diagnostics: Vec::new(),
1124            next_steps: Vec::new(),
1125        });
1126
1127        let value =
1128            serialize_check_json_output(output, RootEnvelopeMode::Tagged, Some("run-check"))
1129                .expect("check output should serialize");
1130
1131        assert_eq!(value["kind"], "dead-code");
1132        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-check");
1133    }
1134
1135    #[test]
1136    fn grouped_check_json_output_uses_output_owned_root_contract() {
1137        let root = std::path::Path::new("/project");
1138        let output = CheckGroupedOutput {
1139            schema_version: SchemaVersion(7),
1140            version: ToolVersion("0.0.0".to_string()),
1141            elapsed_ms: ElapsedMs(1),
1142            grouped_by: GroupByMode::Directory,
1143            total_issues: 0,
1144            groups: Vec::new(),
1145            meta: None,
1146            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1147                root,
1148                root.join("src/unreadable.ts"),
1149                WorkspaceDiagnosticKind::SourceReadFailure {
1150                    error: "permission denied".to_string(),
1151                },
1152            )],
1153            next_steps: Vec::new(),
1154        };
1155
1156        let value = serialize_check_grouped_json_output(
1157            output,
1158            RootEnvelopeMode::Tagged,
1159            Some("run-group"),
1160        )
1161        .expect("grouped check output should serialize");
1162
1163        assert_eq!(value["kind"], "dead-code-grouped");
1164        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-group");
1165        assert_eq!(
1166            value["workspace_diagnostics"][0]["path"],
1167            "/project/src/unreadable.ts"
1168        );
1169        assert_eq!(
1170            value["workspace_diagnostics"][0]["kind"],
1171            "source-read-failure"
1172        );
1173    }
1174
1175    #[test]
1176    fn workspace_diagnostics_serialize_typed_kind_path_message() {
1177        let root = std::path::Path::new("/project");
1178        let output = build_check_output(CheckOutputInput {
1179            schema_version: 7,
1180            version: "0.0.0".to_string(),
1181            elapsed: Duration::from_millis(1),
1182            results: AnalysisResults::default(),
1183            config_fixable: false,
1184            meta: None,
1185            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1186                root,
1187                root.join("packages/legacy"),
1188                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1189            )],
1190            next_steps: Vec::new(),
1191        });
1192
1193        let value = serde_json::to_value(&output).expect("check output serializes");
1194        let diag = &value["workspace_diagnostics"][0];
1195        assert_eq!(diag["kind"], "undeclared-workspace");
1196        assert!(
1197            diag["path"]
1198                .as_str()
1199                .is_some_and(|path| path.contains("packages/legacy")),
1200            "path field is carried verbatim: {diag}"
1201        );
1202        assert!(
1203            diag["message"]
1204                .as_str()
1205                .is_some_and(|message| message.contains("packages/legacy")),
1206            "message is rendered from kind + path: {diag}"
1207        );
1208    }
1209
1210    #[test]
1211    fn source_read_failure_workspace_diagnostic_serializes_error_payload() {
1212        let root = std::path::Path::new("/project");
1213        let output = build_check_output(CheckOutputInput {
1214            schema_version: 7,
1215            version: "0.0.0".to_string(),
1216            elapsed: Duration::from_millis(1),
1217            results: AnalysisResults::default(),
1218            config_fixable: false,
1219            meta: None,
1220            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1221                root,
1222                root.join("src/removed.ts"),
1223                WorkspaceDiagnosticKind::SourceReadFailure {
1224                    error: "No such file or directory".to_string(),
1225                },
1226            )],
1227            next_steps: Vec::new(),
1228        });
1229
1230        let value = serde_json::to_value(&output).expect("check output serializes");
1231        let diagnostic = &value["workspace_diagnostics"][0];
1232        assert_eq!(diagnostic["kind"], "source-read-failure");
1233        assert_eq!(diagnostic["error"], "No such file or directory");
1234        assert!(
1235            diagnostic["message"]
1236                .as_str()
1237                .is_some_and(|message| message.contains("src/removed.ts"))
1238        );
1239    }
1240
1241    fn suppress_comment(actions: &[IssueAction]) -> Option<&str> {
1242        actions.iter().find_map(|action| match action {
1243            IssueAction::SuppressLine(action) => Some(action.comment.as_str()),
1244            _ => None,
1245        })
1246    }
1247}