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