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::{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 = 10;
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/// Current schema version for the `fallow architecture` JSON envelope.
29///
30/// The architecture body is the dead-code body, so a change to that shared
31/// body bumps this version and [`CHECK_SCHEMA_VERSION`] together.
32pub const ARCHITECTURE_SCHEMA_VERSION: u32 = 1;
33
34/// Schema projection for the architecture envelope's exact version.
35///
36/// The schema emitter registers this type by name and points the
37/// `ArchitectureOutput` and `ArchitectureGroupedOutput` definitions at it.
38#[cfg(feature = "schema")]
39#[allow(dead_code, reason = "schema-only type used by the field projection")]
40#[derive(schemars::JsonSchema)]
41#[schemars(extend("const" = ARCHITECTURE_SCHEMA_VERSION))]
42pub struct ArchitectureSchemaVersion(u32);
43
44/// The commands that share the dead-code JSON body.
45///
46/// `fallow dead-code` and `fallow architecture` serialize the same
47/// [`CheckOutput`] and [`CheckGroupedOutput`] structs. Only the root `kind`
48/// and `schema_version` are different. A closed enum keeps an unknown kind
49/// string off the wire.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum CheckEnvelope {
52    /// `fallow dead-code`, and every embedded `check` section.
53    #[default]
54    DeadCode,
55    /// `fallow architecture`.
56    Architecture,
57}
58
59impl CheckEnvelope {
60    /// The root `kind` of the flat envelope.
61    #[must_use]
62    pub const fn kind(self) -> &'static str {
63        match self {
64            Self::DeadCode => "dead-code",
65            Self::Architecture => "architecture",
66        }
67    }
68
69    /// The root `kind` of the `--group-by` envelope.
70    #[must_use]
71    pub const fn grouped_kind(self) -> &'static str {
72        match self {
73            Self::DeadCode => "dead-code-grouped",
74            Self::Architecture => "architecture-grouped",
75        }
76    }
77
78    /// The `schema_version` that the envelope reports.
79    #[must_use]
80    pub const fn schema_version(self) -> u32 {
81        match self {
82            Self::DeadCode => CHECK_SCHEMA_VERSION,
83            Self::Architecture => ARCHITECTURE_SCHEMA_VERSION,
84        }
85    }
86
87    /// The subcommand that writes the envelope.
88    #[must_use]
89    pub const fn command(self) -> &'static str {
90        match self {
91            Self::DeadCode => "dead-code",
92            Self::Architecture => "architecture",
93        }
94    }
95}
96
97#[expect(
98    clippy::trivially_copy_pass_by_ref,
99    reason = "serde `skip_serializing_if` passes the field by reference"
100)]
101const fn is_zero(count: &usize) -> bool {
102    *count == 0
103}
104
105/// Envelope emitted by `fallow dead-code --format json` (plus the `check`
106/// block inside the combined and audit envelopes).
107///
108/// The body is the full `AnalysisResults` flattened into the envelope so
109/// every issue array (`unused_files`, `unused_exports`, ...) lives at the
110/// top level, matching the existing wire shape. `entry_points` lifts the
111/// otherwise `#[serde(skip)]`'d `AnalysisResults::entry_point_summary` back
112/// into the JSON output. `summary` carries the per-category counts the
113/// JSON layer always emits.
114#[derive(Debug, Clone, Serialize)]
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116#[cfg_attr(feature = "schema", schemars(title = "fallow dead-code --format json"))]
117pub struct CheckOutput {
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    /// Total findings across all issue arrays; excludes `next_steps`.
126    pub total_issues: usize,
127    /// Entry-point totals per source, when the analysis recorded them.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub entry_points: Option<EntryPoints>,
130    /// Per-category finding counts.
131    pub summary: CheckSummary,
132    /// Full analysis results, flattened so each issue array sits at the
133    /// envelope root.
134    #[serde(flatten)]
135    pub results: AnalysisResults,
136    /// Count deltas against the matched baseline, in baseline runs.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub baseline_deltas: Option<BaselineDeltas>,
139    /// Which baseline snapshot was matched, in baseline runs.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub baseline: Option<BaselineMatch>,
142    /// This run's view of the loaded baseline, present only in baseline runs.
143    /// Carries the staleness counts, the advisory verdict and `gate_trips`, the
144    /// same boolean `--fail-on-stale-baseline` exits on, so a CI integration
145    /// reads one field instead of restating the rule. Read `change_scoped`
146    /// before dividing `matched_entries` by `baseline_entries`: a narrowed run
147    /// can report `matched_entries: 0` on a healthy baseline.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub baseline_staleness: Option<crate::BaselineStaleness>,
150    /// The answer to `--finding-id`, present only when the run received one
151    /// or more `--finding-id` values. The report then holds only the
152    /// requested findings. Read `missing` as resolved only when `conclusive`
153    /// is true; a scope, a baseline or a filter can hide a finding that still
154    /// exists. See [`crate::FindingIdQuery`].
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub finding_id_query: Option<crate::FindingIdQuery>,
157    /// Regression verdict against the baseline, in `--fail-on-regression` runs.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub regression: Option<RegressionResult>,
160    /// The verdict of every gate this run evaluated, keyed by name. The CLI
161    /// always emits it, with the command's default exit rule in it also when
162    /// no flag armed a gate, so a CI integration reads the verdict instead of
163    /// guessing from a process status it usually cannot see. A gate fails the
164    /// build when `status` is `fail` AND `enforced` is true. The typed
165    /// programmatic API runs no CLI gate and leaves it absent. See
166    /// [`crate::GateOutcomes`].
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub gate_outcomes: Option<crate::GateOutcomes>,
169    /// Every narrowing or shaping request this run RECEIVED, keyed by name,
170    /// absent when it was asked for nothing. An entry whose `status` is not
171    /// `applied` means the run could not do what it was asked and reported
172    /// something WIDER instead, so what follows is a valid report of a scope
173    /// nobody requested. Honoured requests are published too, with
174    /// `status: "applied"`, so an absent object means "nothing was asked for",
175    /// never "nothing failed". See [`crate::RequestOutcomes`].
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub request_outcomes: Option<crate::RequestOutcomes>,
178    /// Applied Git refs for exact workspace packages. Absent when no package
179    /// baselines were selected, including runs with a global changed-since ref.
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub package_baselines: Vec<crate::PackageBaselineStatus>,
182    /// `_meta` block with docs and rule definitions, when `--explain` was
183    /// passed.
184    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
185    pub meta: Option<Meta>,
186    /// Non-fatal diagnostics about the project itself, from all three stages
187    /// that record them (issue #473):
188    ///
189    /// - workspace discovery, at config load: `undeclared-workspace`,
190    ///   `malformed-package-json`, `glob-matched-no-package-json`,
191    ///   `workspace-glob-outside-root`, `workspace-pattern-dir-missing`,
192    ///   `nested-project-root`, `malformed-tsconfig`,
193    ///   `tsconfig-reference-dir-missing`;
194    /// - source discovery, during the file walk: `skipped-large-file`,
195    ///   `skipped-minified-file`, `skipped-source-dotdir`,
196    ///   `excluded-by-default-ignore`, `source-read-failure`,
197    ///   `source-parse-degraded`, `source-nesting-too-deep`;
198    /// - dead-code analysis, from the dependency-catalog and override
199    ///   detectors: `malformed-pnpm-workspace-yaml`,
200    ///   `bun-lockb-override-resolution-skipped`;
201    /// - framework plugins, while they read their own build configs:
202    ///   `plugin-config-unreadable`, `plugin-effect-not-modeled`;
203    /// - the dead-code result, for config patterns that matched nothing:
204    ///   `ignore-dependencies-glob-unmatched`,
205    ///   `ignore-findings-pattern-unmatched`.
206    ///
207    /// Analysis-stage and plugin-stage kinds therefore reach only the envelopes
208    /// whose run includes a dead-code analyze pass, never a standalone
209    /// `fallow dupes --format json`. `path` is project-root-relative with
210    /// forward slashes; the array is omitted when empty. The same list is
211    /// repeated on each top-level command's envelope so single-command
212    /// consumers see it without having to look at a separate top-level field.
213    ///
214    /// A diagnostic here is advisory and never withholds a finding. Where an
215    /// entry reports a source file this run never fully analyzed
216    /// (`source-parse-degraded`, `source-nesting-too-deep`,
217    /// `source-read-failure`, `skipped-large-file`, `skipped-minified-file`,
218    /// `skipped-source-dotdir`) it can distort a verdict, so the affected
219    /// `unused_files[]`, `unused_exports[]`, and dependency entries
220    /// additionally carry the caveat themselves in their own
221    /// optional `reachability_caveats[]` array, and a reader who never scrolls
222    /// back up to this list still sees it. `fallow fix` reads the same array
223    /// and withholds the removal while a caveat stands.
224    ///
225    /// `excluded-by-default-ignore` is the one source-discovery kind that
226    /// reports unseen files WITHOUT raising a caveat. It names a built-in
227    /// ignore pattern (`**/dist/**`, `**/build/**`, `**/coverage/**`, or one
228    /// of the four minified-bundle globs) that removed candidate source files
229    /// from the walk, which is designed behavior on generated output rather
230    /// than a degraded run, so it is advisory only and no finding inherits it.
231    /// One entry per pattern, never per file, so the array stays bounded on a
232    /// project of any size. Gitignored trees are pruned before the walk sees
233    /// them and count zero, and `**/node_modules/**` is never reported:
234    /// installed dependencies are not the first-party source the kind is
235    /// about.
236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
237    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
238    /// Read-only follow-up commands computed from this run's findings, emitted
239    /// at the JSON root so an agent acting on the output is pointed at fallow's
240    /// adjacent verification capabilities (trace, complexity breakdown, audit,
241    /// workspace scoping). Each command is runnable as-is and never mutating;
242    /// see [`NextStep`] for both contracts. Omitted when empty or when
243    /// `FALLOW_SUGGESTIONS=off`; does NOT contribute to `total_issues`.
244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
245    pub next_steps: Vec<NextStep>,
246}
247
248/// Envelope emitted by `fallow dead-code --group-by ... --format json`.
249///
250/// Issues are partitioned into resolver buckets (CODEOWNERS team, directory
251/// prefix, workspace package, or GitLab CODEOWNERS section) instead of flat
252/// arrays. Each bucket carries the same issue-array shape as the ungrouped
253/// `CheckOutput` body, plus per-group `key` / `owners` / `total_issues`.
254#[derive(Debug, Clone, Serialize)]
255#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
256#[cfg_attr(
257    feature = "schema",
258    schemars(
259        title = "fallow dead-code --group-by <owner|directory|package|section> --format json"
260    )
261)]
262pub struct CheckGroupedOutput {
263    /// Dead-code output schema version; currently [`CHECK_SCHEMA_VERSION`].
264    #[cfg_attr(feature = "schema", schemars(with = "CheckSchemaVersion"))]
265    pub schema_version: SchemaVersion,
266    /// Fallow CLI version that produced this output.
267    pub version: ToolVersion,
268    /// Wall-clock analysis duration in milliseconds.
269    pub elapsed_ms: ElapsedMs,
270    /// Resolver the issues were grouped by.
271    pub grouped_by: GroupByMode,
272    /// Total findings across all groups.
273    pub total_issues: usize,
274    /// One bucket per resolver key.
275    pub groups: Vec<CheckGroupedEntry>,
276    /// `true` when the `unused-load-data-key` detector abstained for the whole
277    /// project. The abstain has no file, so it is on the root and not in a
278    /// group. An empty `unused_load_data_keys` with this flag set does not
279    /// mean the project is clean: the rule could not run safely. Serialized
280    /// only when `true`, like the flat `CheckOutput` field.
281    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
282    pub unused_load_data_keys_global_abstain: bool,
283    /// Number of export and member findings that the run hid because their
284    /// file is an unused file. The count covers the whole run, so it is on
285    /// the root and not in a group. Serialized only when not zero, like the
286    /// flat `CheckOutput` field.
287    #[serde(default, skip_serializing_if = "is_zero")]
288    pub cascade_hidden: usize,
289    /// This run's view of the loaded baseline, present only in baseline runs.
290    /// Carries the staleness counts, the advisory verdict and `gate_trips`, the
291    /// same boolean `--fail-on-stale-baseline` exits on, so a CI integration
292    /// reads one field instead of restating the rule. Read `change_scoped`
293    /// before dividing `matched_entries` by `baseline_entries`: a narrowed run
294    /// can report `matched_entries: 0` on a healthy baseline.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub baseline_staleness: Option<crate::BaselineStaleness>,
297    /// The answer to `--finding-id`, present only when the run received one
298    /// or more `--finding-id` values. The report then holds only the
299    /// requested findings. Read `missing` as resolved only when `conclusive`
300    /// is true; a scope, a baseline or a filter can hide a finding that still
301    /// exists. See [`crate::FindingIdQuery`].
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub finding_id_query: Option<crate::FindingIdQuery>,
304    /// The verdict of every gate this run evaluated, keyed by name. The CLI
305    /// always emits it, with the command's default exit rule in it also when
306    /// no flag armed a gate, so a CI integration reads the verdict instead of
307    /// guessing from a process status it usually cannot see. A gate fails the
308    /// build when `status` is `fail` AND `enforced` is true. The typed
309    /// programmatic API runs no CLI gate and leaves it absent. See
310    /// [`crate::GateOutcomes`].
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub gate_outcomes: Option<crate::GateOutcomes>,
313    /// Every narrowing or shaping request this run RECEIVED, keyed by name,
314    /// absent when it was asked for nothing. An entry whose `status` is not
315    /// `applied` means the run could not do what it was asked and reported
316    /// something WIDER instead, so what follows is a valid report of a scope
317    /// nobody requested. Honoured requests are published too, with
318    /// `status: "applied"`, so an absent object means "nothing was asked for",
319    /// never "nothing failed". See [`crate::RequestOutcomes`].
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub request_outcomes: Option<crate::RequestOutcomes>,
322    /// Applied package Git refs, omitted outside package-baseline runs.
323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
324    pub package_baselines: Vec<crate::PackageBaselineStatus>,
325    /// `_meta` block with docs and rule definitions, when `--explain` was
326    /// passed.
327    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
328    pub meta: Option<Meta>,
329    /// Diagnostics collected for the full analysis before issue grouping.
330    /// See [`CheckOutput::workspace_diagnostics`] for the contract.
331    #[serde(default, skip_serializing_if = "Vec::is_empty")]
332    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
333    /// Read-only follow-up commands computed from the full (ungrouped) findings.
334    /// See [`CheckOutput::next_steps`] for the contract.
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    pub next_steps: Vec<NextStep>,
337}
338
339/// Single resolver bucket inside `CheckGroupedOutput`. Carries the group's
340/// identifier, optional section owners, and a per-group flattened
341/// `AnalysisResults`.
342#[derive(Debug, Clone, Serialize)]
343#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
344pub struct CheckGroupedEntry {
345    /// Resolver key: team name, directory prefix, package name, or section.
346    pub key: String,
347    /// Owners of a GitLab CODEOWNERS section; present for section grouping.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub owners: Option<Vec<String>>,
350    /// Findings in this group.
351    pub total_issues: usize,
352    /// Group-scoped analysis results, flattened like the ungrouped body.
353    #[serde(flatten)]
354    pub results: AnalysisResults,
355}
356
357/// Resolver mode label for grouped envelopes (dead-code, dupes, health).
358///
359/// `owner` groups by CODEOWNERS team, `directory` groups by top-level
360/// directory prefix, `package` groups by workspace package name, `section`
361/// groups by GitLab CODEOWNERS `[Section]` header name.
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
363#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
364#[serde(rename_all = "lowercase")]
365pub enum GroupByMode {
366    /// Group by CODEOWNERS team.
367    Owner,
368    /// Group by top-level directory prefix.
369    Directory,
370    /// Group by workspace package name.
371    Package,
372    /// Group by GitLab CODEOWNERS `[Section]` header name.
373    Section,
374}
375
376/// Inputs for building the dead-code JSON envelope.
377pub struct CheckOutputInput {
378    /// Dead-code output schema version to report.
379    pub schema_version: u32,
380    /// Fallow CLI version to report.
381    pub version: String,
382    /// Wall-clock analysis duration; serialized as whole milliseconds.
383    pub elapsed: Duration,
384    /// Engine analysis results to embed.
385    pub results: AnalysisResults,
386    /// Whether duplicate-export findings are fixable via config edits, which
387    /// flips their `config_fixable` action flag.
388    pub config_fixable: bool,
389    /// `_meta` block to attach when `--explain` was passed.
390    pub meta: Option<Meta>,
391    /// Workspace-discovery, source-discovery, and analysis-stage diagnostics.
392    /// See [`CheckOutput::workspace_diagnostics`] for the contract.
393    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
394    /// Read-only follow-up commands computed from this run's findings.
395    pub next_steps: Vec<NextStep>,
396}
397
398/// Build the typed dead-code JSON envelope from engine results.
399#[must_use]
400pub fn build_check_output(input: CheckOutputInput) -> CheckOutput {
401    let mut results = input.results;
402    apply_config_fixable_to_duplicate_exports(&mut results, input.config_fixable);
403    harmonize_multi_kind_suppress_line_actions(&mut results);
404    CheckOutput {
405        schema_version: SchemaVersion(input.schema_version),
406        version: ToolVersion(input.version),
407        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
408        total_issues: results.total_issues(),
409        entry_points: results
410            .entry_point_summary
411            .as_ref()
412            .map(|entry_points| EntryPoints {
413                total: entry_points.total,
414                sources: entry_points
415                    .by_source
416                    .iter()
417                    .map(|(key, value)| (key.replace(' ', "_"), *value))
418                    .collect(),
419            }),
420        summary: build_check_summary(&results),
421        results,
422        baseline_deltas: None,
423        baseline: None,
424        baseline_staleness: None,
425        finding_id_query: None,
426        regression: None,
427        gate_outcomes: None,
428        request_outcomes: None,
429        package_baselines: Vec::new(),
430        meta: input.meta,
431        workspace_diagnostics: input.workspace_diagnostics,
432        next_steps: input.next_steps,
433    }
434}
435
436fn serialize_check_family_json_output<T: Serialize>(
437    output: T,
438    kind: &'static str,
439    analysis_run_id: Option<&str>,
440) -> Result<serde_json::Value, serde_json::Error> {
441    let mut value = serialize_named_json_output(output, kind)?;
442    attach_telemetry_meta(&mut value, analysis_run_id);
443    Ok(value)
444}
445
446/// Serialize `fallow dead-code --format json`.
447///
448/// # Errors
449///
450/// Returns a serde error when the dead-code output cannot be converted to JSON.
451pub fn serialize_check_json_output(
452    output: CheckOutput,
453    analysis_run_id: Option<&str>,
454) -> Result<serde_json::Value, serde_json::Error> {
455    serialize_check_family_json_output(output, "dead-code", analysis_run_id)
456}
457
458/// Serialize `fallow dead-code --group-by ... --format json`.
459///
460/// # Errors
461///
462/// Returns a serde error when the grouped dead-code output cannot be converted
463/// to JSON.
464pub fn serialize_check_grouped_json_output(
465    output: CheckGroupedOutput,
466    analysis_run_id: Option<&str>,
467) -> Result<serde_json::Value, serde_json::Error> {
468    serialize_check_family_json_output(output, "dead-code-grouped", analysis_run_id)
469}
470
471/// Serialize `fallow architecture --format json`.
472///
473/// The body is the dead-code body. The caller sets `schema_version` to
474/// [`ARCHITECTURE_SCHEMA_VERSION`].
475///
476/// # Errors
477///
478/// Returns a serde error when the output cannot be converted to JSON.
479pub fn serialize_architecture_json_output(
480    output: CheckOutput,
481    analysis_run_id: Option<&str>,
482) -> Result<serde_json::Value, serde_json::Error> {
483    serialize_check_family_json_output(output, CheckEnvelope::Architecture.kind(), analysis_run_id)
484}
485
486/// Serialize `fallow architecture --group-by ... --format json`.
487///
488/// # Errors
489///
490/// Returns a serde error when the grouped output cannot be converted to JSON.
491pub fn serialize_architecture_grouped_json_output(
492    output: CheckGroupedOutput,
493    analysis_run_id: Option<&str>,
494) -> Result<serde_json::Value, serde_json::Error> {
495    serialize_check_family_json_output(
496        output,
497        CheckEnvelope::Architecture.grouped_kind(),
498        analysis_run_id,
499    )
500}
501
502/// Mark every duplicate-export finding as fixable through a config edit when
503/// the project's config supports automated fixes.
504pub fn apply_config_fixable_to_duplicate_exports(
505    results: &mut AnalysisResults,
506    config_fixable: bool,
507) {
508    if !config_fixable {
509        return;
510    }
511    for finding in &mut results.duplicate_exports {
512        finding.set_config_fixable(true);
513    }
514}
515
516type SuppressAnchor = (String, u32);
517
518macro_rules! visit_suppress_line_findings {
519    ($results:expr, $visit:expr) => {{
520        let results = $results;
521        for finding in &results.unused_exports {
522            $visit(&finding.export.path, finding.export.line, &finding.actions);
523        }
524        for finding in &results.unused_types {
525            $visit(&finding.export.path, finding.export.line, &finding.actions);
526        }
527        for finding in &results.private_type_leaks {
528            $visit(&finding.leak.path, finding.leak.line, &finding.actions);
529        }
530        for finding in &results.deprecated_exports_in_use {
531            $visit(&finding.export.path, finding.export.line, &finding.actions);
532        }
533        for finding in &results.unused_enum_members {
534            $visit(&finding.member.path, finding.member.line, &finding.actions);
535        }
536        for finding in &results.unused_class_members {
537            $visit(&finding.member.path, finding.member.line, &finding.actions);
538        }
539        for finding in &results.unused_store_members {
540            $visit(&finding.member.path, finding.member.line, &finding.actions);
541        }
542        for finding in &results.unresolved_imports {
543            $visit(&finding.import.path, finding.import.line, &finding.actions);
544        }
545        for finding in &results.unused_dependencies {
546            $visit(&finding.dep.path, finding.dep.line, &finding.actions);
547        }
548        for finding in &results.unused_dev_dependencies {
549            $visit(&finding.dep.path, finding.dep.line, &finding.actions);
550        }
551        for finding in &results.unused_optional_dependencies {
552            $visit(&finding.dep.path, finding.dep.line, &finding.actions);
553        }
554        for finding in &results.type_only_dependencies {
555            $visit(&finding.dep.path, finding.dep.line, &finding.actions);
556        }
557        for finding in &results.test_only_dependencies {
558            $visit(&finding.dep.path, finding.dep.line, &finding.actions);
559        }
560        for finding in &results.dev_dependencies_in_production {
561            $visit(&finding.dep.path, finding.dep.line, &finding.actions);
562        }
563        for finding in &results.circular_dependencies {
564            if let Some(path) = finding.cycle.files.first() {
565                $visit(path, finding.cycle.line, &finding.actions);
566            }
567        }
568        for finding in &results.package_cycles {
569            if let Some(edge) = finding.cycle.edges.first() {
570                $visit(&edge.path, edge.line, &finding.actions);
571            }
572        }
573        for finding in &results.boundary_violations {
574            $visit(
575                &finding.violation.from_path,
576                finding.violation.line,
577                &finding.actions,
578            );
579        }
580        for finding in &results.boundary_coverage_violations {
581            $visit(
582                &finding.violation.path,
583                finding.violation.line,
584                &finding.actions,
585            );
586        }
587        for finding in &results.boundary_call_violations {
588            $visit(
589                &finding.violation.path,
590                finding.violation.line,
591                &finding.actions,
592            );
593        }
594        for finding in &results.policy_violations {
595            $visit(
596                &finding.violation.path,
597                finding.violation.line,
598                &finding.actions,
599            );
600        }
601        for finding in &results.unused_catalog_entries {
602            $visit(&finding.entry.path, finding.entry.line, &finding.actions);
603        }
604        for finding in &results.empty_catalog_groups {
605            $visit(&finding.group.path, finding.group.line, &finding.actions);
606        }
607        for finding in &results.unresolved_catalog_references {
608            $visit(
609                &finding.reference.path,
610                finding.reference.line,
611                &finding.actions,
612            );
613        }
614        for finding in &results.unused_dependency_overrides {
615            $visit(&finding.entry.path, finding.entry.line, &finding.actions);
616        }
617        for finding in &results.misconfigured_dependency_overrides {
618            $visit(&finding.entry.path, finding.entry.line, &finding.actions);
619        }
620        for finding in &results.invalid_client_exports {
621            $visit(&finding.export.path, finding.export.line, &finding.actions);
622        }
623        for finding in &results.mixed_client_server_barrels {
624            $visit(&finding.barrel.path, finding.barrel.line, &finding.actions);
625        }
626        for finding in &results.misplaced_directives {
627            $visit(
628                &finding.directive_site.path,
629                finding.directive_site.line,
630                &finding.actions,
631            );
632        }
633        for finding in &results.unprovided_injects {
634            $visit(&finding.inject.path, finding.inject.line, &finding.actions);
635        }
636        for finding in &results.unrendered_components {
637            $visit(
638                &finding.component.path,
639                finding.component.line,
640                &finding.actions,
641            );
642        }
643        for finding in &results.route_collisions {
644            $visit(
645                &finding.collision.path,
646                finding.collision.line,
647                &finding.actions,
648            );
649        }
650        for finding in &results.dynamic_segment_name_conflicts {
651            $visit(
652                &finding.conflict.path,
653                finding.conflict.line,
654                &finding.actions,
655            );
656        }
657        for finding in &results.unused_component_props {
658            $visit(&finding.prop.path, finding.prop.line, &finding.actions);
659        }
660        for finding in &results.absent_component_props {
661            $visit(&finding.prop.path, finding.prop.line, &finding.actions);
662        }
663        for finding in &results.unused_component_emits {
664            $visit(&finding.emit.path, finding.emit.line, &finding.actions);
665        }
666        for finding in &results.unused_component_inputs {
667            $visit(&finding.input.path, finding.input.line, &finding.actions);
668        }
669        for finding in &results.unused_component_outputs {
670            $visit(&finding.output.path, finding.output.line, &finding.actions);
671        }
672        for finding in &results.unused_svelte_events {
673            $visit(&finding.event.path, finding.event.line, &finding.actions);
674        }
675        for finding in &results.unused_effect_layers {
676            $visit(&finding.layer.path, finding.layer.line, &finding.actions);
677        }
678        for finding in &results.unused_tagged_errors {
679            $visit(&finding.error.path, finding.error.line, &finding.actions);
680        }
681        for finding in &results.unproduced_catch_tags {
682            $visit(
683                &finding.catch_tag.path,
684                finding.catch_tag.line,
685                &finding.actions,
686            );
687        }
688        for finding in &results.unused_server_actions {
689            $visit(&finding.action.path, finding.action.line, &finding.actions);
690        }
691        for finding in &results.unused_load_data_keys {
692            $visit(&finding.key.path, finding.key.line, &finding.actions);
693        }
694        for finding in &results.prop_drilling_chains {
695            if let Some(hop) = finding.chain.hops.first() {
696                $visit(&hop.file, hop.line, &finding.actions);
697            }
698        }
699        for finding in &results.thin_wrappers {
700            $visit(
701                &finding.wrapper.file,
702                finding.wrapper.line,
703                &finding.actions,
704            );
705        }
706        for finding in &results.duplicate_prop_shapes {
707            $visit(&finding.shape.file, finding.shape.line, &finding.actions);
708        }
709    }};
710}
711
712macro_rules! visit_suppress_line_findings_mut {
713    ($results:expr, $visit:expr) => {{
714        let results = $results;
715        for finding in &mut results.unused_exports {
716            $visit(
717                &finding.export.path,
718                finding.export.line,
719                &mut finding.actions,
720            );
721        }
722        for finding in &mut results.unused_types {
723            $visit(
724                &finding.export.path,
725                finding.export.line,
726                &mut finding.actions,
727            );
728        }
729        for finding in &mut results.private_type_leaks {
730            $visit(&finding.leak.path, finding.leak.line, &mut finding.actions);
731        }
732        for finding in &mut results.deprecated_exports_in_use {
733            $visit(
734                &finding.export.path,
735                finding.export.line,
736                &mut finding.actions,
737            );
738        }
739        for finding in &mut results.unused_enum_members {
740            $visit(
741                &finding.member.path,
742                finding.member.line,
743                &mut finding.actions,
744            );
745        }
746        for finding in &mut results.unused_class_members {
747            $visit(
748                &finding.member.path,
749                finding.member.line,
750                &mut finding.actions,
751            );
752        }
753        for finding in &mut results.unused_store_members {
754            $visit(
755                &finding.member.path,
756                finding.member.line,
757                &mut finding.actions,
758            );
759        }
760        for finding in &mut results.unresolved_imports {
761            $visit(
762                &finding.import.path,
763                finding.import.line,
764                &mut finding.actions,
765            );
766        }
767        for finding in &mut results.unused_dependencies {
768            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
769        }
770        for finding in &mut results.unused_dev_dependencies {
771            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
772        }
773        for finding in &mut results.unused_optional_dependencies {
774            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
775        }
776        for finding in &mut results.type_only_dependencies {
777            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
778        }
779        for finding in &mut results.test_only_dependencies {
780            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
781        }
782        for finding in &mut results.dev_dependencies_in_production {
783            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
784        }
785        for finding in &mut results.circular_dependencies {
786            if let Some(path) = finding.cycle.files.first() {
787                $visit(path, finding.cycle.line, &mut finding.actions);
788            }
789        }
790        for finding in &mut results.package_cycles {
791            if let Some(edge) = finding.cycle.edges.first() {
792                $visit(&edge.path, edge.line, &mut finding.actions);
793            }
794        }
795        for finding in &mut results.boundary_violations {
796            $visit(
797                &finding.violation.from_path,
798                finding.violation.line,
799                &mut finding.actions,
800            );
801        }
802        for finding in &mut results.boundary_coverage_violations {
803            $visit(
804                &finding.violation.path,
805                finding.violation.line,
806                &mut finding.actions,
807            );
808        }
809        for finding in &mut results.boundary_call_violations {
810            $visit(
811                &finding.violation.path,
812                finding.violation.line,
813                &mut finding.actions,
814            );
815        }
816        for finding in &mut results.policy_violations {
817            $visit(
818                &finding.violation.path,
819                finding.violation.line,
820                &mut finding.actions,
821            );
822        }
823        for finding in &mut results.unused_catalog_entries {
824            $visit(
825                &finding.entry.path,
826                finding.entry.line,
827                &mut finding.actions,
828            );
829        }
830        for finding in &mut results.empty_catalog_groups {
831            $visit(
832                &finding.group.path,
833                finding.group.line,
834                &mut finding.actions,
835            );
836        }
837        for finding in &mut results.unresolved_catalog_references {
838            $visit(
839                &finding.reference.path,
840                finding.reference.line,
841                &mut finding.actions,
842            );
843        }
844        for finding in &mut results.unused_dependency_overrides {
845            $visit(
846                &finding.entry.path,
847                finding.entry.line,
848                &mut finding.actions,
849            );
850        }
851        for finding in &mut results.misconfigured_dependency_overrides {
852            $visit(
853                &finding.entry.path,
854                finding.entry.line,
855                &mut finding.actions,
856            );
857        }
858        for finding in &mut results.invalid_client_exports {
859            $visit(
860                &finding.export.path,
861                finding.export.line,
862                &mut finding.actions,
863            );
864        }
865        for finding in &mut results.mixed_client_server_barrels {
866            $visit(
867                &finding.barrel.path,
868                finding.barrel.line,
869                &mut finding.actions,
870            );
871        }
872        for finding in &mut results.misplaced_directives {
873            $visit(
874                &finding.directive_site.path,
875                finding.directive_site.line,
876                &mut finding.actions,
877            );
878        }
879        for finding in &mut results.unprovided_injects {
880            $visit(
881                &finding.inject.path,
882                finding.inject.line,
883                &mut finding.actions,
884            );
885        }
886        for finding in &mut results.unrendered_components {
887            $visit(
888                &finding.component.path,
889                finding.component.line,
890                &mut finding.actions,
891            );
892        }
893        for finding in &mut results.route_collisions {
894            $visit(
895                &finding.collision.path,
896                finding.collision.line,
897                &mut finding.actions,
898            );
899        }
900        for finding in &mut results.dynamic_segment_name_conflicts {
901            $visit(
902                &finding.conflict.path,
903                finding.conflict.line,
904                &mut finding.actions,
905            );
906        }
907        for finding in &mut results.unused_component_props {
908            $visit(&finding.prop.path, finding.prop.line, &mut finding.actions);
909        }
910        for finding in &mut results.absent_component_props {
911            $visit(&finding.prop.path, finding.prop.line, &mut finding.actions);
912        }
913        for finding in &mut results.unused_component_emits {
914            $visit(&finding.emit.path, finding.emit.line, &mut finding.actions);
915        }
916        for finding in &mut results.unused_component_inputs {
917            $visit(
918                &finding.input.path,
919                finding.input.line,
920                &mut finding.actions,
921            );
922        }
923        for finding in &mut results.unused_component_outputs {
924            $visit(
925                &finding.output.path,
926                finding.output.line,
927                &mut finding.actions,
928            );
929        }
930        for finding in &mut results.unused_svelte_events {
931            $visit(
932                &finding.event.path,
933                finding.event.line,
934                &mut finding.actions,
935            );
936        }
937        for finding in &mut results.unused_effect_layers {
938            $visit(
939                &finding.layer.path,
940                finding.layer.line,
941                &mut finding.actions,
942            );
943        }
944        for finding in &mut results.unused_tagged_errors {
945            $visit(
946                &finding.error.path,
947                finding.error.line,
948                &mut finding.actions,
949            );
950        }
951        for finding in &mut results.unproduced_catch_tags {
952            $visit(
953                &finding.catch_tag.path,
954                finding.catch_tag.line,
955                &mut finding.actions,
956            );
957        }
958        for finding in &mut results.unused_server_actions {
959            $visit(
960                &finding.action.path,
961                finding.action.line,
962                &mut finding.actions,
963            );
964        }
965        for finding in &mut results.unused_load_data_keys {
966            $visit(&finding.key.path, finding.key.line, &mut finding.actions);
967        }
968        for finding in &mut results.prop_drilling_chains {
969            if let Some(hop) = finding.chain.hops.first() {
970                $visit(&hop.file, hop.line, &mut finding.actions);
971            }
972        }
973        for finding in &mut results.thin_wrappers {
974            $visit(
975                &finding.wrapper.file,
976                finding.wrapper.line,
977                &mut finding.actions,
978            );
979        }
980        for finding in &mut results.duplicate_prop_shapes {
981            $visit(
982                &finding.shape.file,
983                finding.shape.line,
984                &mut finding.actions,
985            );
986        }
987    }};
988}
989
990/// Merge same-line suppress actions so multi-kind findings share one comment.
991///
992/// This runs on typed `AnalysisResults` before serialization. It replaces the
993/// older JSON-object walk for normal check output and keeps the action contract
994/// owned by the output builders.
995pub fn harmonize_multi_kind_suppress_line_actions(results: &mut AnalysisResults) {
996    let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
997    collect_dead_code_suppress_line_anchors(results, &mut anchors);
998    retain_multi_kind_anchors(&mut anchors);
999    if anchors.is_empty() {
1000        return;
1001    }
1002    rewrite_dead_code_suppress_line_actions(results, &anchors);
1003}
1004
1005/// Merge same-line suppress actions across dead-code and health sections.
1006///
1007/// Combined and audit output can surface both dead-code and complexity findings
1008/// anchored to the same source line. This keeps the single-line suppress hint
1009/// typed until the final JSON serialization step.
1010pub fn harmonize_dead_code_health_suppress_line_actions(
1011    dead_code: Option<&mut AnalysisResults>,
1012    health: Option<&mut HealthReport>,
1013) {
1014    let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
1015    if let Some(results) = dead_code.as_deref() {
1016        collect_dead_code_suppress_line_anchors(results, &mut anchors);
1017    }
1018    if let Some(report) = health.as_deref() {
1019        collect_health_suppress_line_anchors(report, &mut anchors);
1020    }
1021
1022    retain_multi_kind_anchors(&mut anchors);
1023    if anchors.is_empty() {
1024        return;
1025    }
1026
1027    if let Some(results) = dead_code {
1028        rewrite_dead_code_suppress_line_actions(results, &anchors);
1029    }
1030    if let Some(report) = health {
1031        rewrite_health_suppress_line_actions(report, &anchors);
1032    }
1033}
1034
1035fn retain_multi_kind_anchors(anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>) {
1036    anchors.retain(|_, kinds| {
1037        sort_suppression_kinds(kinds);
1038        kinds.dedup();
1039        kinds.len() > 1
1040    });
1041}
1042
1043fn collect_dead_code_suppress_line_anchors(
1044    results: &AnalysisResults,
1045    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
1046) {
1047    visit_suppress_line_findings!(results, |path: &Path, line, actions: &[IssueAction]| {
1048        collect_action_kinds(path, line, actions, anchors);
1049    });
1050}
1051
1052fn rewrite_dead_code_suppress_line_actions(
1053    results: &mut AnalysisResults,
1054    anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
1055) {
1056    visit_suppress_line_findings_mut!(
1057        results,
1058        |path: &Path, line, actions: &mut Vec<IssueAction>| {
1059            let anchor = suppress_anchor(path, line);
1060            if let Some(kinds) = anchors.get(&anchor) {
1061                let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
1062                rewrite_action_comments(actions, &comment);
1063            }
1064        }
1065    );
1066}
1067
1068fn collect_health_suppress_line_anchors(
1069    report: &HealthReport,
1070    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
1071) {
1072    for finding in &report.findings {
1073        collect_health_action_kinds(
1074            &finding.violation.path,
1075            finding.violation.line,
1076            &finding.actions,
1077            anchors,
1078        );
1079    }
1080    for finding in &report.prop_drilling_chains {
1081        if let Some(hop) = finding.chain.hops.first() {
1082            collect_action_kinds(&hop.file, hop.line, &finding.actions, anchors);
1083        }
1084    }
1085}
1086
1087fn rewrite_health_suppress_line_actions(
1088    report: &mut HealthReport,
1089    anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
1090) {
1091    for finding in &mut report.findings {
1092        let anchor = suppress_anchor(&finding.violation.path, finding.violation.line);
1093        if let Some(kinds) = anchors.get(&anchor) {
1094            let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
1095            rewrite_health_action_comments(&mut finding.actions, &comment);
1096        }
1097    }
1098    for finding in &mut report.prop_drilling_chains {
1099        if let Some(hop) = finding.chain.hops.first() {
1100            let anchor = suppress_anchor(&hop.file, hop.line);
1101            if let Some(kinds) = anchors.get(&anchor) {
1102                let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
1103                rewrite_action_comments(&mut finding.actions, &comment);
1104            }
1105        }
1106    }
1107}
1108
1109fn collect_action_kinds(
1110    path: &Path,
1111    line: u32,
1112    actions: &[IssueAction],
1113    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
1114) {
1115    for action in actions {
1116        if let Some(comment) = suppress_line_comment(action) {
1117            let kinds = anchors.entry(suppress_anchor(path, line)).or_default();
1118            for kind in parse_suppress_line_comment(comment) {
1119                if !kinds.iter().any(|existing| existing == &kind) {
1120                    kinds.push(kind);
1121                }
1122            }
1123        }
1124    }
1125}
1126
1127fn collect_health_action_kinds(
1128    path: &Path,
1129    line: u32,
1130    actions: &[HealthFindingAction],
1131    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
1132) {
1133    for action in actions {
1134        if let Some(comment) = health_suppress_line_comment(action) {
1135            let kinds = anchors.entry(suppress_anchor(path, line)).or_default();
1136            for kind in parse_suppress_line_comment(comment) {
1137                if !kinds.iter().any(|existing| existing == &kind) {
1138                    kinds.push(kind);
1139                }
1140            }
1141        }
1142    }
1143}
1144
1145fn rewrite_action_comments(actions: &mut [IssueAction], comment: &str) {
1146    for action in actions {
1147        if let IssueAction::SuppressLine(suppress) = action {
1148            suppress.comment = comment.to_string();
1149        }
1150    }
1151}
1152
1153fn rewrite_health_action_comments(actions: &mut [HealthFindingAction], comment: &str) {
1154    for action in actions {
1155        if matches!(action.kind, HealthFindingActionType::SuppressLine) {
1156            action.comment = Some(comment.to_string());
1157        }
1158    }
1159}
1160
1161fn suppress_anchor(path: &Path, line: u32) -> SuppressAnchor {
1162    (path.display().to_string(), line)
1163}
1164
1165fn suppress_line_comment(action: &IssueAction) -> Option<&str> {
1166    match action {
1167        IssueAction::SuppressLine(action) => Some(&action.comment),
1168        _ => None,
1169    }
1170}
1171
1172fn health_suppress_line_comment(action: &HealthFindingAction) -> Option<&str> {
1173    matches!(action.kind, HealthFindingActionType::SuppressLine)
1174        .then_some(())
1175        .and(action.comment.as_deref())
1176}
1177
1178fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
1179    comment
1180        .strip_prefix("// fallow-ignore-next-line ")
1181        .map(|rest| {
1182            rest.split(|c: char| c == ',' || c.is_whitespace())
1183                .filter(|token| !token.is_empty())
1184                .map(str::to_string)
1185                .collect()
1186        })
1187        .unwrap_or_default()
1188}
1189
1190fn sort_suppression_kinds(kinds: &mut [String]) {
1191    kinds.sort_by_key(|kind| suppression_kind_rank(kind));
1192}
1193
1194fn suppression_kind_rank(kind: &str) -> usize {
1195    match kind {
1196        "unused-file" => 0,
1197        "unused-export" => 1,
1198        "unused-type" => 2,
1199        "private-type-leak" => 3,
1200        "unused-enum-member" => 4,
1201        "unused-class-member" => 5,
1202        "unused-store-member" => 6,
1203        "unresolved-import" => 7,
1204        "unlisted-dependency" => 8,
1205        "duplicate-export" => 9,
1206        "circular-dependency" => 10,
1207        "re-export-cycle" => 11,
1208        "boundary-violation" => 12,
1209        "code-duplication" => 13,
1210        "complexity" => 14,
1211        "unprovided-inject" => 15,
1212        "unrendered-component" => 16,
1213        "unused-server-action" => 17,
1214        "deprecated-export-in-use" => 18,
1215        "package-cycle" => 19,
1216        _ => usize::MAX,
1217    }
1218}
1219
1220/// Compute the per-category `CheckSummary` from analysis results.
1221#[must_use]
1222pub fn build_check_summary(results: &AnalysisResults) -> CheckSummary {
1223    CheckSummary {
1224        total_issues: results.total_issues(),
1225        prop_drilling_chains: results.enforced_prop_drilling_count(),
1226        unused_files: results.unused_files.len(),
1227        unused_exports: results.unused_exports.len(),
1228        unused_types: results.unused_types.len(),
1229        private_type_leaks: results.private_type_leaks.len(),
1230        deprecated_exports_in_use: results.deprecated_exports_in_use.len(),
1231        unused_dependencies: results.unused_dependencies.len()
1232            + results.unused_dev_dependencies.len()
1233            + results.unused_optional_dependencies.len(),
1234        unused_enum_members: results.unused_enum_members.len(),
1235        unused_class_members: results.unused_class_members.len(),
1236        unused_store_members: results.unused_store_members.len(),
1237        unresolved_imports: results.unresolved_imports.len(),
1238        unlisted_dependencies: results.unlisted_dependencies.len(),
1239        duplicate_exports: results.duplicate_exports.len(),
1240        type_only_dependencies: results.type_only_dependencies.len(),
1241        test_only_dependencies: results.test_only_dependencies.len(),
1242        dev_dependencies_in_production: results.dev_dependencies_in_production.len(),
1243        circular_dependencies: results.circular_dependencies.len(),
1244        re_export_cycles: results.re_export_cycles.len(),
1245        package_cycles: results.package_cycles.len(),
1246        boundary_violations: results.boundary_violations.len(),
1247        boundary_coverage_violations: results.boundary_coverage_violations.len(),
1248        boundary_call_violations: results.boundary_call_violations.len(),
1249        policy_violations: results.policy_violations.len(),
1250        stale_suppressions: results.stale_suppressions.len(),
1251        unused_catalog_entries: results.unused_catalog_entries.len(),
1252        empty_catalog_groups: results.empty_catalog_groups.len(),
1253        unresolved_catalog_references: results.unresolved_catalog_references.len(),
1254        unused_dependency_overrides: results.unused_dependency_overrides.len(),
1255        misconfigured_dependency_overrides: results.misconfigured_dependency_overrides.len(),
1256        invalid_client_exports: results.invalid_client_exports.len(),
1257        mixed_client_server_barrels: results.mixed_client_server_barrels.len(),
1258        misplaced_directives: results.misplaced_directives.len(),
1259        unprovided_injects: results.unprovided_injects.len(),
1260        unrendered_components: results.unrendered_components.len(),
1261        unused_component_props: results.unused_component_props.len(),
1262        absent_component_props: results.absent_component_props.len(),
1263        unused_component_emits: results.unused_component_emits.len(),
1264        unused_component_inputs: results.unused_component_inputs.len(),
1265        unused_component_outputs: results.unused_component_outputs.len(),
1266        unused_svelte_events: results.unused_svelte_events.len(),
1267        unused_effect_layers: results.unused_effect_layers.len(),
1268        unused_tagged_errors: results.unused_tagged_errors.len(),
1269        unproduced_catch_tags: results.unproduced_catch_tags.len(),
1270        unused_server_actions: results.unused_server_actions.len(),
1271        unused_load_data_keys: results.unused_load_data_keys.len(),
1272        route_collisions: results.route_collisions.len(),
1273        dynamic_segment_name_conflicts: results.dynamic_segment_name_conflicts.len(),
1274    }
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279    use super::*;
1280    use crate::{ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding};
1281    use fallow_types::output_dead_code::{
1282        ReachabilityCaveat, UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
1283    };
1284    use fallow_types::results::{UnusedExport, UnusedFile};
1285    use fallow_types::workspace::WorkspaceDiagnosticKind;
1286
1287    #[test]
1288    fn build_check_output_counts_issues_and_entry_points() {
1289        let mut results = AnalysisResults::default();
1290        results
1291            .unused_files
1292            .push(UnusedFileFinding::with_actions(UnusedFile {
1293                path: "src/unused.ts".into(),
1294            }));
1295
1296        let output = build_check_output(CheckOutputInput {
1297            schema_version: 7,
1298            version: "0.0.0".to_string(),
1299            elapsed: Duration::from_millis(42),
1300            results,
1301            config_fixable: false,
1302            meta: None,
1303            workspace_diagnostics: Vec::new(),
1304            next_steps: Vec::new(),
1305        });
1306
1307        assert_eq!(output.schema_version.0, 7);
1308        assert_eq!(output.total_issues, 1);
1309        assert_eq!(output.summary.unused_files, 1);
1310        assert_eq!(output.elapsed_ms.0, 42);
1311    }
1312
1313    #[test]
1314    fn build_check_output_harmonizes_multi_kind_suppress_actions_typed() {
1315        let mut results = AnalysisResults::default();
1316        let path = std::path::PathBuf::from("/project/src/shared.ts");
1317        results
1318            .unused_exports
1319            .push(UnusedExportFinding::with_actions(UnusedExport {
1320                path: path.clone(),
1321                export_name: "value".to_string(),
1322                is_type_only: false,
1323                line: 7,
1324                col: 0,
1325                span_start: 0,
1326                is_re_export: false,
1327                deprecated: false,
1328                deprecated_reason: None,
1329            }));
1330        results
1331            .unused_types
1332            .push(UnusedTypeFinding::with_actions(UnusedExport {
1333                path,
1334                export_name: "TypeOnly".to_string(),
1335                is_type_only: true,
1336                line: 7,
1337                col: 0,
1338                span_start: 0,
1339                is_re_export: false,
1340                deprecated: false,
1341                deprecated_reason: None,
1342            }));
1343
1344        let output = build_check_output(CheckOutputInput {
1345            schema_version: 7,
1346            version: "0.0.0".to_string(),
1347            elapsed: Duration::from_millis(42),
1348            results,
1349            config_fixable: false,
1350            meta: None,
1351            workspace_diagnostics: Vec::new(),
1352            next_steps: Vec::new(),
1353        });
1354
1355        let export_comment = suppress_comment(&output.results.unused_exports[0].actions);
1356        let type_comment = suppress_comment(&output.results.unused_types[0].actions);
1357        assert_eq!(
1358            export_comment,
1359            Some("// fallow-ignore-next-line unused-export, unused-type")
1360        );
1361        assert_eq!(type_comment, export_comment);
1362    }
1363
1364    #[test]
1365    fn harmonize_dead_code_health_suppress_actions_typed() {
1366        let mut results = AnalysisResults::default();
1367        let path = std::path::PathBuf::from("/project/src/shared.ts");
1368        results
1369            .unused_exports
1370            .push(UnusedExportFinding::with_actions(UnusedExport {
1371                path: path.clone(),
1372                export_name: "value".to_string(),
1373                is_type_only: false,
1374                line: 7,
1375                col: 0,
1376                span_start: 0,
1377                is_re_export: false,
1378                deprecated: false,
1379                deprecated_reason: None,
1380            }));
1381        let mut health = HealthReport {
1382            findings: vec![HealthFinding::new(
1383                ComplexityViolation {
1384                    path,
1385                    name: "expensive".to_string(),
1386                    line: 7,
1387                    col: 0,
1388                    cyclomatic: 22,
1389                    standard_cyclomatic: None,
1390                    cognitive: 18,
1391                    line_count: 40,
1392                    param_count: 1,
1393                    react_hook_count: 0,
1394                    react_jsx_max_depth: 0,
1395                    react_prop_count: 0,
1396                    react_hook_profile: None,
1397                    exceeded: ExceededThreshold::Both,
1398                    severity: FindingSeverity::High,
1399                    effective_severity: None,
1400                    crap: None,
1401                    mutation: None,
1402                    coverage_pct: None,
1403                    coverage_tier: None,
1404                    coverage_source: None,
1405                    inherited_from: None,
1406                    component_rollup: None,
1407                    contributions: Vec::new(),
1408                    effective_thresholds: None,
1409                    threshold_source: None,
1410                },
1411                vec![HealthFindingAction {
1412                    kind: HealthFindingActionType::SuppressLine,
1413                    auto_fixable: false,
1414                    description: "Suppress with an inline comment above the function declaration"
1415                        .to_string(),
1416                    note: None,
1417                    comment: Some("// fallow-ignore-next-line complexity".to_string()),
1418                    placement: Some("above-function-declaration".to_string()),
1419                    target_path: None,
1420                }],
1421                None,
1422            )],
1423            ..HealthReport::default()
1424        };
1425
1426        harmonize_dead_code_health_suppress_line_actions(Some(&mut results), Some(&mut health));
1427
1428        assert_eq!(
1429            suppress_comment(&results.unused_exports[0].actions),
1430            Some("// fallow-ignore-next-line unused-export, complexity")
1431        );
1432        assert_eq!(
1433            health.findings[0].actions[0].comment.as_deref(),
1434            Some("// fallow-ignore-next-line unused-export, complexity")
1435        );
1436    }
1437
1438    #[test]
1439    fn check_json_output_uses_output_owned_root_contract() {
1440        let output = build_check_output(CheckOutputInput {
1441            schema_version: 7,
1442            version: "0.0.0".to_string(),
1443            elapsed: Duration::from_millis(42),
1444            results: AnalysisResults::default(),
1445            config_fixable: false,
1446            meta: None,
1447            workspace_diagnostics: Vec::new(),
1448            next_steps: Vec::new(),
1449        });
1450
1451        let value = serialize_check_json_output(output, Some("run-check"))
1452            .expect("check output should serialize");
1453
1454        assert_eq!(value["kind"], "dead-code");
1455        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-check");
1456    }
1457
1458    #[test]
1459    fn architecture_json_output_has_architecture_kind_and_version() {
1460        let envelope = CheckEnvelope::Architecture;
1461        let output = build_check_output(CheckOutputInput {
1462            schema_version: envelope.schema_version(),
1463            version: "0.0.0".to_string(),
1464            elapsed: Duration::from_millis(1),
1465            results: AnalysisResults::default(),
1466            config_fixable: false,
1467            meta: None,
1468            workspace_diagnostics: Vec::new(),
1469            next_steps: Vec::new(),
1470        });
1471
1472        let value = serialize_architecture_json_output(output, Some("run-arch"))
1473            .expect("architecture output should serialize");
1474
1475        assert_eq!(value["kind"], "architecture");
1476        assert_eq!(value["schema_version"], ARCHITECTURE_SCHEMA_VERSION);
1477        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-arch");
1478    }
1479
1480    #[test]
1481    fn check_envelope_names_each_family_member() {
1482        assert_eq!(CheckEnvelope::DeadCode.kind(), "dead-code");
1483        assert_eq!(CheckEnvelope::DeadCode.grouped_kind(), "dead-code-grouped");
1484        assert_eq!(
1485            CheckEnvelope::DeadCode.schema_version(),
1486            CHECK_SCHEMA_VERSION
1487        );
1488        assert_eq!(CheckEnvelope::DeadCode.command(), "dead-code");
1489        assert_eq!(CheckEnvelope::Architecture.kind(), "architecture");
1490        assert_eq!(
1491            CheckEnvelope::Architecture.grouped_kind(),
1492            "architecture-grouped"
1493        );
1494        assert_eq!(
1495            CheckEnvelope::Architecture.schema_version(),
1496            ARCHITECTURE_SCHEMA_VERSION
1497        );
1498        assert_eq!(CheckEnvelope::Architecture.command(), "architecture");
1499    }
1500
1501    /// The degraded-parse caveat has to travel WITH the finding it can distort,
1502    /// because a reader looking at a `delete-file` action never sees the
1503    /// diagnostic at the other end of the envelope. It is advisory about the
1504    /// FINDING and decisive only about the MUTATION: the actions array keeps
1505    /// its shape and its order, the mutating action reports
1506    /// `auto_fixable: false`, and a clean finding stays byte-identical.
1507    #[test]
1508    fn reachability_caveats_are_absent_when_clean_and_named_when_flagged() {
1509        let mut results = AnalysisResults::default();
1510        results
1511            .unused_files
1512            .push(UnusedFileFinding::with_actions(UnusedFile {
1513                path: "/project/src/clean.ts".into(),
1514            }));
1515        let mut flagged = UnusedFileFinding::with_actions(UnusedFile {
1516            path: "/project/src/orphan.ts".into(),
1517        });
1518        flagged.reachability_caveats = vec![
1519            ReachabilityCaveat::IncompleteFileAnalysis,
1520            ReachabilityCaveat::IncompleteImportGraph,
1521        ];
1522        results.unused_files.push(flagged);
1523
1524        let output = build_check_output(CheckOutputInput {
1525            schema_version: 7,
1526            version: "0.0.0".to_string(),
1527            elapsed: Duration::from_millis(1),
1528            results,
1529            config_fixable: false,
1530            meta: None,
1531            workspace_diagnostics: Vec::new(),
1532            next_steps: Vec::new(),
1533        });
1534        let value =
1535            serialize_check_json_output(output, None).expect("dead-code output should serialize");
1536
1537        let entries = value["unused_files"]
1538            .as_array()
1539            .expect("unused_files array")
1540            .clone();
1541        let find = |name: &str| {
1542            entries
1543                .iter()
1544                .find(|entry| {
1545                    entry["path"]
1546                        .as_str()
1547                        .is_some_and(|path| path.ends_with(name))
1548                })
1549                .cloned()
1550                .expect("finding present")
1551        };
1552
1553        assert!(
1554            find("clean.ts").get("reachability_caveats").is_none(),
1555            "a finding with no caveat must keep the previous wire shape exactly"
1556        );
1557
1558        let flagged = find("orphan.ts");
1559        assert_eq!(
1560            flagged["reachability_caveats"],
1561            serde_json::json!(["incomplete-file-analysis", "incomplete-import-graph"]),
1562            "both caveats are named on the wire, in declaration order"
1563        );
1564        assert_eq!(
1565            flagged["actions"].as_array().map(Vec::len),
1566            Some(2),
1567            "the caveat never trims the finding's actions"
1568        );
1569        assert_eq!(
1570            flagged["actions"][0]["type"], "delete-file",
1571            "nor reorders them, so a consumer reading actions[0].type is unaffected"
1572        );
1573        assert_eq!(
1574            flagged["actions"][0]["auto_fixable"],
1575            serde_json::json!(false),
1576            "but the mutation it gates must not advertise itself as applicable"
1577        );
1578    }
1579
1580    #[test]
1581    fn grouped_check_json_output_uses_output_owned_root_contract() {
1582        let root = std::path::Path::new("/project");
1583        let output = CheckGroupedOutput {
1584            gate_outcomes: None,
1585            request_outcomes: None,
1586            package_baselines: Vec::new(),
1587            baseline_staleness: None,
1588            finding_id_query: None,
1589            schema_version: SchemaVersion(7),
1590            version: ToolVersion("0.0.0".to_string()),
1591            elapsed_ms: ElapsedMs(1),
1592            grouped_by: GroupByMode::Directory,
1593            total_issues: 0,
1594            groups: Vec::new(),
1595            unused_load_data_keys_global_abstain: false,
1596            cascade_hidden: 0,
1597            meta: None,
1598            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1599                root,
1600                root.join("src/unreadable.ts"),
1601                WorkspaceDiagnosticKind::SourceReadFailure {
1602                    error: "permission denied".to_string(),
1603                },
1604            )],
1605            next_steps: Vec::new(),
1606        };
1607
1608        let value = serialize_check_grouped_json_output(output, Some("run-group"))
1609            .expect("grouped check output should serialize");
1610
1611        assert_eq!(value["kind"], "dead-code-grouped");
1612        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-group");
1613        assert_eq!(
1614            value["workspace_diagnostics"][0]["path"],
1615            "/project/src/unreadable.ts"
1616        );
1617        assert_eq!(
1618            value["workspace_diagnostics"][0]["kind"],
1619            "source-read-failure"
1620        );
1621    }
1622
1623    #[test]
1624    fn architecture_grouped_json_output_has_grouped_kind() {
1625        let output = CheckGroupedOutput {
1626            gate_outcomes: None,
1627            request_outcomes: None,
1628            package_baselines: Vec::new(),
1629            baseline_staleness: None,
1630            finding_id_query: None,
1631            schema_version: SchemaVersion(ARCHITECTURE_SCHEMA_VERSION),
1632            version: ToolVersion("0.0.0".to_string()),
1633            elapsed_ms: ElapsedMs(1),
1634            grouped_by: GroupByMode::Directory,
1635            total_issues: 0,
1636            groups: Vec::new(),
1637            unused_load_data_keys_global_abstain: false,
1638            cascade_hidden: 0,
1639            meta: None,
1640            workspace_diagnostics: Vec::new(),
1641            next_steps: Vec::new(),
1642        };
1643        let grouped = serialize_architecture_grouped_json_output(output, None)
1644            .expect("grouped architecture output should serialize");
1645        assert_eq!(grouped["kind"], "architecture-grouped");
1646        assert_eq!(grouped["schema_version"], ARCHITECTURE_SCHEMA_VERSION);
1647    }
1648
1649    #[test]
1650    fn workspace_diagnostics_serialize_typed_kind_path_message() {
1651        let root = std::path::Path::new("/project");
1652        let output = build_check_output(CheckOutputInput {
1653            schema_version: 7,
1654            version: "0.0.0".to_string(),
1655            elapsed: Duration::from_millis(1),
1656            results: AnalysisResults::default(),
1657            config_fixable: false,
1658            meta: None,
1659            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1660                root,
1661                root.join("packages/legacy"),
1662                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1663            )],
1664            next_steps: Vec::new(),
1665        });
1666
1667        let value = serde_json::to_value(&output).expect("check output serializes");
1668        let diag = &value["workspace_diagnostics"][0];
1669        assert_eq!(diag["kind"], "undeclared-workspace");
1670        assert!(
1671            diag["path"]
1672                .as_str()
1673                .is_some_and(|path| path.contains("packages/legacy")),
1674            "path field is carried verbatim: {diag}"
1675        );
1676        assert!(
1677            diag["message"]
1678                .as_str()
1679                .is_some_and(|message| message.contains("packages/legacy")),
1680            "message is rendered from kind + path: {diag}"
1681        );
1682    }
1683
1684    #[test]
1685    fn source_read_failure_workspace_diagnostic_serializes_error_payload() {
1686        let root = std::path::Path::new("/project");
1687        let output = build_check_output(CheckOutputInput {
1688            schema_version: 7,
1689            version: "0.0.0".to_string(),
1690            elapsed: Duration::from_millis(1),
1691            results: AnalysisResults::default(),
1692            config_fixable: false,
1693            meta: None,
1694            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1695                root,
1696                root.join("src/removed.ts"),
1697                WorkspaceDiagnosticKind::SourceReadFailure {
1698                    error: "No such file or directory".to_string(),
1699                },
1700            )],
1701            next_steps: Vec::new(),
1702        });
1703
1704        let value = serde_json::to_value(&output).expect("check output serializes");
1705        let diagnostic = &value["workspace_diagnostics"][0];
1706        assert_eq!(diagnostic["kind"], "source-read-failure");
1707        assert_eq!(diagnostic["error"], "No such file or directory");
1708        assert!(
1709            diagnostic["message"]
1710                .as_str()
1711                .is_some_and(|message| message.contains("src/removed.ts"))
1712        );
1713    }
1714
1715    fn suppress_comment(actions: &[IssueAction]) -> Option<&str> {
1716        actions.iter().find_map(|action| match action {
1717            IssueAction::SuppressLine(action) => Some(action.comment.as_str()),
1718            _ => None,
1719        })
1720    }
1721}