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