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