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