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