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_server_actions {
673            $visit(&finding.action.path, finding.action.line, &finding.actions);
674        }
675        for finding in &results.unused_load_data_keys {
676            $visit(&finding.key.path, finding.key.line, &finding.actions);
677        }
678        for finding in &results.prop_drilling_chains {
679            if let Some(hop) = finding.chain.hops.first() {
680                $visit(&hop.file, hop.line, &finding.actions);
681            }
682        }
683        for finding in &results.thin_wrappers {
684            $visit(
685                &finding.wrapper.file,
686                finding.wrapper.line,
687                &finding.actions,
688            );
689        }
690        for finding in &results.duplicate_prop_shapes {
691            $visit(&finding.shape.file, finding.shape.line, &finding.actions);
692        }
693    }};
694}
695
696macro_rules! visit_suppress_line_findings_mut {
697    ($results:expr, $visit:expr) => {{
698        let results = $results;
699        for finding in &mut results.unused_exports {
700            $visit(
701                &finding.export.path,
702                finding.export.line,
703                &mut finding.actions,
704            );
705        }
706        for finding in &mut results.unused_types {
707            $visit(
708                &finding.export.path,
709                finding.export.line,
710                &mut finding.actions,
711            );
712        }
713        for finding in &mut results.private_type_leaks {
714            $visit(&finding.leak.path, finding.leak.line, &mut finding.actions);
715        }
716        for finding in &mut results.deprecated_exports_in_use {
717            $visit(
718                &finding.export.path,
719                finding.export.line,
720                &mut finding.actions,
721            );
722        }
723        for finding in &mut results.unused_enum_members {
724            $visit(
725                &finding.member.path,
726                finding.member.line,
727                &mut finding.actions,
728            );
729        }
730        for finding in &mut results.unused_class_members {
731            $visit(
732                &finding.member.path,
733                finding.member.line,
734                &mut finding.actions,
735            );
736        }
737        for finding in &mut results.unused_store_members {
738            $visit(
739                &finding.member.path,
740                finding.member.line,
741                &mut finding.actions,
742            );
743        }
744        for finding in &mut results.unresolved_imports {
745            $visit(
746                &finding.import.path,
747                finding.import.line,
748                &mut finding.actions,
749            );
750        }
751        for finding in &mut results.unused_dependencies {
752            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
753        }
754        for finding in &mut results.unused_dev_dependencies {
755            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
756        }
757        for finding in &mut results.unused_optional_dependencies {
758            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
759        }
760        for finding in &mut results.type_only_dependencies {
761            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
762        }
763        for finding in &mut results.test_only_dependencies {
764            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
765        }
766        for finding in &mut results.dev_dependencies_in_production {
767            $visit(&finding.dep.path, finding.dep.line, &mut finding.actions);
768        }
769        for finding in &mut results.circular_dependencies {
770            if let Some(path) = finding.cycle.files.first() {
771                $visit(path, finding.cycle.line, &mut finding.actions);
772            }
773        }
774        for finding in &mut results.package_cycles {
775            if let Some(edge) = finding.cycle.edges.first() {
776                $visit(&edge.path, edge.line, &mut finding.actions);
777            }
778        }
779        for finding in &mut results.boundary_violations {
780            $visit(
781                &finding.violation.from_path,
782                finding.violation.line,
783                &mut finding.actions,
784            );
785        }
786        for finding in &mut results.boundary_coverage_violations {
787            $visit(
788                &finding.violation.path,
789                finding.violation.line,
790                &mut finding.actions,
791            );
792        }
793        for finding in &mut results.boundary_call_violations {
794            $visit(
795                &finding.violation.path,
796                finding.violation.line,
797                &mut finding.actions,
798            );
799        }
800        for finding in &mut results.policy_violations {
801            $visit(
802                &finding.violation.path,
803                finding.violation.line,
804                &mut finding.actions,
805            );
806        }
807        for finding in &mut results.unused_catalog_entries {
808            $visit(
809                &finding.entry.path,
810                finding.entry.line,
811                &mut finding.actions,
812            );
813        }
814        for finding in &mut results.empty_catalog_groups {
815            $visit(
816                &finding.group.path,
817                finding.group.line,
818                &mut finding.actions,
819            );
820        }
821        for finding in &mut results.unresolved_catalog_references {
822            $visit(
823                &finding.reference.path,
824                finding.reference.line,
825                &mut finding.actions,
826            );
827        }
828        for finding in &mut results.unused_dependency_overrides {
829            $visit(
830                &finding.entry.path,
831                finding.entry.line,
832                &mut finding.actions,
833            );
834        }
835        for finding in &mut results.misconfigured_dependency_overrides {
836            $visit(
837                &finding.entry.path,
838                finding.entry.line,
839                &mut finding.actions,
840            );
841        }
842        for finding in &mut results.invalid_client_exports {
843            $visit(
844                &finding.export.path,
845                finding.export.line,
846                &mut finding.actions,
847            );
848        }
849        for finding in &mut results.mixed_client_server_barrels {
850            $visit(
851                &finding.barrel.path,
852                finding.barrel.line,
853                &mut finding.actions,
854            );
855        }
856        for finding in &mut results.misplaced_directives {
857            $visit(
858                &finding.directive_site.path,
859                finding.directive_site.line,
860                &mut finding.actions,
861            );
862        }
863        for finding in &mut results.unprovided_injects {
864            $visit(
865                &finding.inject.path,
866                finding.inject.line,
867                &mut finding.actions,
868            );
869        }
870        for finding in &mut results.unrendered_components {
871            $visit(
872                &finding.component.path,
873                finding.component.line,
874                &mut finding.actions,
875            );
876        }
877        for finding in &mut results.route_collisions {
878            $visit(
879                &finding.collision.path,
880                finding.collision.line,
881                &mut finding.actions,
882            );
883        }
884        for finding in &mut results.dynamic_segment_name_conflicts {
885            $visit(
886                &finding.conflict.path,
887                finding.conflict.line,
888                &mut finding.actions,
889            );
890        }
891        for finding in &mut results.unused_component_props {
892            $visit(&finding.prop.path, finding.prop.line, &mut finding.actions);
893        }
894        for finding in &mut results.absent_component_props {
895            $visit(&finding.prop.path, finding.prop.line, &mut finding.actions);
896        }
897        for finding in &mut results.unused_component_emits {
898            $visit(&finding.emit.path, finding.emit.line, &mut finding.actions);
899        }
900        for finding in &mut results.unused_component_inputs {
901            $visit(
902                &finding.input.path,
903                finding.input.line,
904                &mut finding.actions,
905            );
906        }
907        for finding in &mut results.unused_component_outputs {
908            $visit(
909                &finding.output.path,
910                finding.output.line,
911                &mut finding.actions,
912            );
913        }
914        for finding in &mut results.unused_svelte_events {
915            $visit(
916                &finding.event.path,
917                finding.event.line,
918                &mut finding.actions,
919            );
920        }
921        for finding in &mut results.unused_server_actions {
922            $visit(
923                &finding.action.path,
924                finding.action.line,
925                &mut finding.actions,
926            );
927        }
928        for finding in &mut results.unused_load_data_keys {
929            $visit(&finding.key.path, finding.key.line, &mut finding.actions);
930        }
931        for finding in &mut results.prop_drilling_chains {
932            if let Some(hop) = finding.chain.hops.first() {
933                $visit(&hop.file, hop.line, &mut finding.actions);
934            }
935        }
936        for finding in &mut results.thin_wrappers {
937            $visit(
938                &finding.wrapper.file,
939                finding.wrapper.line,
940                &mut finding.actions,
941            );
942        }
943        for finding in &mut results.duplicate_prop_shapes {
944            $visit(
945                &finding.shape.file,
946                finding.shape.line,
947                &mut finding.actions,
948            );
949        }
950    }};
951}
952
953/// Merge same-line suppress actions so multi-kind findings share one comment.
954///
955/// This runs on typed `AnalysisResults` before serialization. It replaces the
956/// older JSON-object walk for normal check output and keeps the action contract
957/// owned by the output builders.
958pub fn harmonize_multi_kind_suppress_line_actions(results: &mut AnalysisResults) {
959    let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
960    collect_dead_code_suppress_line_anchors(results, &mut anchors);
961    retain_multi_kind_anchors(&mut anchors);
962    if anchors.is_empty() {
963        return;
964    }
965    rewrite_dead_code_suppress_line_actions(results, &anchors);
966}
967
968/// Merge same-line suppress actions across dead-code and health sections.
969///
970/// Combined and audit output can surface both dead-code and complexity findings
971/// anchored to the same source line. This keeps the single-line suppress hint
972/// typed until the final JSON serialization step.
973pub fn harmonize_dead_code_health_suppress_line_actions(
974    dead_code: Option<&mut AnalysisResults>,
975    health: Option<&mut HealthReport>,
976) {
977    let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
978    if let Some(results) = dead_code.as_deref() {
979        collect_dead_code_suppress_line_anchors(results, &mut anchors);
980    }
981    if let Some(report) = health.as_deref() {
982        collect_health_suppress_line_anchors(report, &mut anchors);
983    }
984
985    retain_multi_kind_anchors(&mut anchors);
986    if anchors.is_empty() {
987        return;
988    }
989
990    if let Some(results) = dead_code {
991        rewrite_dead_code_suppress_line_actions(results, &anchors);
992    }
993    if let Some(report) = health {
994        rewrite_health_suppress_line_actions(report, &anchors);
995    }
996}
997
998fn retain_multi_kind_anchors(anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>) {
999    anchors.retain(|_, kinds| {
1000        sort_suppression_kinds(kinds);
1001        kinds.dedup();
1002        kinds.len() > 1
1003    });
1004}
1005
1006fn collect_dead_code_suppress_line_anchors(
1007    results: &AnalysisResults,
1008    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
1009) {
1010    visit_suppress_line_findings!(results, |path: &Path, line, actions: &[IssueAction]| {
1011        collect_action_kinds(path, line, actions, anchors);
1012    });
1013}
1014
1015fn rewrite_dead_code_suppress_line_actions(
1016    results: &mut AnalysisResults,
1017    anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
1018) {
1019    visit_suppress_line_findings_mut!(
1020        results,
1021        |path: &Path, line, actions: &mut Vec<IssueAction>| {
1022            let anchor = suppress_anchor(path, line);
1023            if let Some(kinds) = anchors.get(&anchor) {
1024                let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
1025                rewrite_action_comments(actions, &comment);
1026            }
1027        }
1028    );
1029}
1030
1031fn collect_health_suppress_line_anchors(
1032    report: &HealthReport,
1033    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
1034) {
1035    for finding in &report.findings {
1036        collect_health_action_kinds(
1037            &finding.violation.path,
1038            finding.violation.line,
1039            &finding.actions,
1040            anchors,
1041        );
1042    }
1043    for finding in &report.prop_drilling_chains {
1044        if let Some(hop) = finding.chain.hops.first() {
1045            collect_action_kinds(&hop.file, hop.line, &finding.actions, anchors);
1046        }
1047    }
1048}
1049
1050fn rewrite_health_suppress_line_actions(
1051    report: &mut HealthReport,
1052    anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
1053) {
1054    for finding in &mut report.findings {
1055        let anchor = suppress_anchor(&finding.violation.path, finding.violation.line);
1056        if let Some(kinds) = anchors.get(&anchor) {
1057            let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
1058            rewrite_health_action_comments(&mut finding.actions, &comment);
1059        }
1060    }
1061    for finding in &mut report.prop_drilling_chains {
1062        if let Some(hop) = finding.chain.hops.first() {
1063            let anchor = suppress_anchor(&hop.file, hop.line);
1064            if let Some(kinds) = anchors.get(&anchor) {
1065                let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
1066                rewrite_action_comments(&mut finding.actions, &comment);
1067            }
1068        }
1069    }
1070}
1071
1072fn collect_action_kinds(
1073    path: &Path,
1074    line: u32,
1075    actions: &[IssueAction],
1076    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
1077) {
1078    for action in actions {
1079        if let Some(comment) = suppress_line_comment(action) {
1080            let kinds = anchors.entry(suppress_anchor(path, line)).or_default();
1081            for kind in parse_suppress_line_comment(comment) {
1082                if !kinds.iter().any(|existing| existing == &kind) {
1083                    kinds.push(kind);
1084                }
1085            }
1086        }
1087    }
1088}
1089
1090fn collect_health_action_kinds(
1091    path: &Path,
1092    line: u32,
1093    actions: &[HealthFindingAction],
1094    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
1095) {
1096    for action in actions {
1097        if let Some(comment) = health_suppress_line_comment(action) {
1098            let kinds = anchors.entry(suppress_anchor(path, line)).or_default();
1099            for kind in parse_suppress_line_comment(comment) {
1100                if !kinds.iter().any(|existing| existing == &kind) {
1101                    kinds.push(kind);
1102                }
1103            }
1104        }
1105    }
1106}
1107
1108fn rewrite_action_comments(actions: &mut [IssueAction], comment: &str) {
1109    for action in actions {
1110        if let IssueAction::SuppressLine(suppress) = action {
1111            suppress.comment = comment.to_string();
1112        }
1113    }
1114}
1115
1116fn rewrite_health_action_comments(actions: &mut [HealthFindingAction], comment: &str) {
1117    for action in actions {
1118        if matches!(action.kind, HealthFindingActionType::SuppressLine) {
1119            action.comment = Some(comment.to_string());
1120        }
1121    }
1122}
1123
1124fn suppress_anchor(path: &Path, line: u32) -> SuppressAnchor {
1125    (path.display().to_string(), line)
1126}
1127
1128fn suppress_line_comment(action: &IssueAction) -> Option<&str> {
1129    match action {
1130        IssueAction::SuppressLine(action) => Some(&action.comment),
1131        _ => None,
1132    }
1133}
1134
1135fn health_suppress_line_comment(action: &HealthFindingAction) -> Option<&str> {
1136    matches!(action.kind, HealthFindingActionType::SuppressLine)
1137        .then_some(())
1138        .and(action.comment.as_deref())
1139}
1140
1141fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
1142    comment
1143        .strip_prefix("// fallow-ignore-next-line ")
1144        .map(|rest| {
1145            rest.split(|c: char| c == ',' || c.is_whitespace())
1146                .filter(|token| !token.is_empty())
1147                .map(str::to_string)
1148                .collect()
1149        })
1150        .unwrap_or_default()
1151}
1152
1153fn sort_suppression_kinds(kinds: &mut [String]) {
1154    kinds.sort_by_key(|kind| suppression_kind_rank(kind));
1155}
1156
1157fn suppression_kind_rank(kind: &str) -> usize {
1158    match kind {
1159        "unused-file" => 0,
1160        "unused-export" => 1,
1161        "unused-type" => 2,
1162        "private-type-leak" => 3,
1163        "unused-enum-member" => 4,
1164        "unused-class-member" => 5,
1165        "unused-store-member" => 6,
1166        "unresolved-import" => 7,
1167        "unlisted-dependency" => 8,
1168        "duplicate-export" => 9,
1169        "circular-dependency" => 10,
1170        "re-export-cycle" => 11,
1171        "boundary-violation" => 12,
1172        "code-duplication" => 13,
1173        "complexity" => 14,
1174        "unprovided-inject" => 15,
1175        "unrendered-component" => 16,
1176        "unused-server-action" => 17,
1177        "deprecated-export-in-use" => 18,
1178        "package-cycle" => 19,
1179        _ => usize::MAX,
1180    }
1181}
1182
1183/// Compute the per-category `CheckSummary` from analysis results.
1184#[must_use]
1185pub fn build_check_summary(results: &AnalysisResults) -> CheckSummary {
1186    CheckSummary {
1187        total_issues: results.total_issues(),
1188        unused_files: results.unused_files.len(),
1189        unused_exports: results.unused_exports.len(),
1190        unused_types: results.unused_types.len(),
1191        private_type_leaks: results.private_type_leaks.len(),
1192        deprecated_exports_in_use: results.deprecated_exports_in_use.len(),
1193        unused_dependencies: results.unused_dependencies.len()
1194            + results.unused_dev_dependencies.len()
1195            + results.unused_optional_dependencies.len(),
1196        unused_enum_members: results.unused_enum_members.len(),
1197        unused_class_members: results.unused_class_members.len(),
1198        unused_store_members: results.unused_store_members.len(),
1199        unresolved_imports: results.unresolved_imports.len(),
1200        unlisted_dependencies: results.unlisted_dependencies.len(),
1201        duplicate_exports: results.duplicate_exports.len(),
1202        type_only_dependencies: results.type_only_dependencies.len(),
1203        test_only_dependencies: results.test_only_dependencies.len(),
1204        dev_dependencies_in_production: results.dev_dependencies_in_production.len(),
1205        circular_dependencies: results.circular_dependencies.len(),
1206        re_export_cycles: results.re_export_cycles.len(),
1207        package_cycles: results.package_cycles.len(),
1208        boundary_violations: results.boundary_violations.len(),
1209        boundary_coverage_violations: results.boundary_coverage_violations.len(),
1210        boundary_call_violations: results.boundary_call_violations.len(),
1211        policy_violations: results.policy_violations.len(),
1212        stale_suppressions: results.stale_suppressions.len(),
1213        unused_catalog_entries: results.unused_catalog_entries.len(),
1214        empty_catalog_groups: results.empty_catalog_groups.len(),
1215        unresolved_catalog_references: results.unresolved_catalog_references.len(),
1216        unused_dependency_overrides: results.unused_dependency_overrides.len(),
1217        misconfigured_dependency_overrides: results.misconfigured_dependency_overrides.len(),
1218        invalid_client_exports: results.invalid_client_exports.len(),
1219        mixed_client_server_barrels: results.mixed_client_server_barrels.len(),
1220        misplaced_directives: results.misplaced_directives.len(),
1221        unprovided_injects: results.unprovided_injects.len(),
1222        unrendered_components: results.unrendered_components.len(),
1223        unused_component_props: results.unused_component_props.len(),
1224        absent_component_props: results.absent_component_props.len(),
1225        unused_component_emits: results.unused_component_emits.len(),
1226        unused_component_inputs: results.unused_component_inputs.len(),
1227        unused_component_outputs: results.unused_component_outputs.len(),
1228        unused_svelte_events: results.unused_svelte_events.len(),
1229        unused_server_actions: results.unused_server_actions.len(),
1230        unused_load_data_keys: results.unused_load_data_keys.len(),
1231        route_collisions: results.route_collisions.len(),
1232        dynamic_segment_name_conflicts: results.dynamic_segment_name_conflicts.len(),
1233    }
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238    use super::*;
1239    use crate::{ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding};
1240    use fallow_types::output_dead_code::{
1241        ReachabilityCaveat, UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
1242    };
1243    use fallow_types::results::{UnusedExport, UnusedFile};
1244    use fallow_types::workspace::WorkspaceDiagnosticKind;
1245
1246    #[test]
1247    fn build_check_output_counts_issues_and_entry_points() {
1248        let mut results = AnalysisResults::default();
1249        results
1250            .unused_files
1251            .push(UnusedFileFinding::with_actions(UnusedFile {
1252                path: "src/unused.ts".into(),
1253            }));
1254
1255        let output = build_check_output(CheckOutputInput {
1256            schema_version: 7,
1257            version: "0.0.0".to_string(),
1258            elapsed: Duration::from_millis(42),
1259            results,
1260            config_fixable: false,
1261            meta: None,
1262            workspace_diagnostics: Vec::new(),
1263            next_steps: Vec::new(),
1264        });
1265
1266        assert_eq!(output.schema_version.0, 7);
1267        assert_eq!(output.total_issues, 1);
1268        assert_eq!(output.summary.unused_files, 1);
1269        assert_eq!(output.elapsed_ms.0, 42);
1270    }
1271
1272    #[test]
1273    fn build_check_output_harmonizes_multi_kind_suppress_actions_typed() {
1274        let mut results = AnalysisResults::default();
1275        let path = std::path::PathBuf::from("/project/src/shared.ts");
1276        results
1277            .unused_exports
1278            .push(UnusedExportFinding::with_actions(UnusedExport {
1279                path: path.clone(),
1280                export_name: "value".to_string(),
1281                is_type_only: false,
1282                line: 7,
1283                col: 0,
1284                span_start: 0,
1285                is_re_export: false,
1286                deprecated: false,
1287                deprecated_reason: None,
1288            }));
1289        results
1290            .unused_types
1291            .push(UnusedTypeFinding::with_actions(UnusedExport {
1292                path,
1293                export_name: "TypeOnly".to_string(),
1294                is_type_only: true,
1295                line: 7,
1296                col: 0,
1297                span_start: 0,
1298                is_re_export: false,
1299                deprecated: false,
1300                deprecated_reason: None,
1301            }));
1302
1303        let output = build_check_output(CheckOutputInput {
1304            schema_version: 7,
1305            version: "0.0.0".to_string(),
1306            elapsed: Duration::from_millis(42),
1307            results,
1308            config_fixable: false,
1309            meta: None,
1310            workspace_diagnostics: Vec::new(),
1311            next_steps: Vec::new(),
1312        });
1313
1314        let export_comment = suppress_comment(&output.results.unused_exports[0].actions);
1315        let type_comment = suppress_comment(&output.results.unused_types[0].actions);
1316        assert_eq!(
1317            export_comment,
1318            Some("// fallow-ignore-next-line unused-export, unused-type")
1319        );
1320        assert_eq!(type_comment, export_comment);
1321    }
1322
1323    #[test]
1324    fn harmonize_dead_code_health_suppress_actions_typed() {
1325        let mut results = AnalysisResults::default();
1326        let path = std::path::PathBuf::from("/project/src/shared.ts");
1327        results
1328            .unused_exports
1329            .push(UnusedExportFinding::with_actions(UnusedExport {
1330                path: path.clone(),
1331                export_name: "value".to_string(),
1332                is_type_only: false,
1333                line: 7,
1334                col: 0,
1335                span_start: 0,
1336                is_re_export: false,
1337                deprecated: false,
1338                deprecated_reason: None,
1339            }));
1340        let mut health = HealthReport {
1341            findings: vec![HealthFinding::new(
1342                ComplexityViolation {
1343                    path,
1344                    name: "expensive".to_string(),
1345                    line: 7,
1346                    col: 0,
1347                    cyclomatic: 22,
1348                    cognitive: 18,
1349                    line_count: 40,
1350                    param_count: 1,
1351                    react_hook_count: 0,
1352                    react_jsx_max_depth: 0,
1353                    react_prop_count: 0,
1354                    react_hook_profile: None,
1355                    exceeded: ExceededThreshold::Both,
1356                    severity: FindingSeverity::High,
1357                    effective_severity: None,
1358                    crap: None,
1359                    coverage_pct: None,
1360                    coverage_tier: None,
1361                    coverage_source: None,
1362                    inherited_from: None,
1363                    component_rollup: None,
1364                    contributions: Vec::new(),
1365                    effective_thresholds: None,
1366                    threshold_source: None,
1367                },
1368                vec![HealthFindingAction {
1369                    kind: HealthFindingActionType::SuppressLine,
1370                    auto_fixable: false,
1371                    description: "Suppress with an inline comment above the function declaration"
1372                        .to_string(),
1373                    note: None,
1374                    comment: Some("// fallow-ignore-next-line complexity".to_string()),
1375                    placement: Some("above-function-declaration".to_string()),
1376                    target_path: None,
1377                }],
1378                None,
1379            )],
1380            ..HealthReport::default()
1381        };
1382
1383        harmonize_dead_code_health_suppress_line_actions(Some(&mut results), Some(&mut health));
1384
1385        assert_eq!(
1386            suppress_comment(&results.unused_exports[0].actions),
1387            Some("// fallow-ignore-next-line unused-export, complexity")
1388        );
1389        assert_eq!(
1390            health.findings[0].actions[0].comment.as_deref(),
1391            Some("// fallow-ignore-next-line unused-export, complexity")
1392        );
1393    }
1394
1395    #[test]
1396    fn check_json_output_uses_output_owned_root_contract() {
1397        let output = build_check_output(CheckOutputInput {
1398            schema_version: 7,
1399            version: "0.0.0".to_string(),
1400            elapsed: Duration::from_millis(42),
1401            results: AnalysisResults::default(),
1402            config_fixable: false,
1403            meta: None,
1404            workspace_diagnostics: Vec::new(),
1405            next_steps: Vec::new(),
1406        });
1407
1408        let value = serialize_check_json_output(output, Some("run-check"))
1409            .expect("check output should serialize");
1410
1411        assert_eq!(value["kind"], "dead-code");
1412        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-check");
1413    }
1414
1415    #[test]
1416    fn architecture_json_output_has_architecture_kind_and_version() {
1417        let envelope = CheckEnvelope::Architecture;
1418        let output = build_check_output(CheckOutputInput {
1419            schema_version: envelope.schema_version(),
1420            version: "0.0.0".to_string(),
1421            elapsed: Duration::from_millis(1),
1422            results: AnalysisResults::default(),
1423            config_fixable: false,
1424            meta: None,
1425            workspace_diagnostics: Vec::new(),
1426            next_steps: Vec::new(),
1427        });
1428
1429        let value = serialize_architecture_json_output(output, Some("run-arch"))
1430            .expect("architecture output should serialize");
1431
1432        assert_eq!(value["kind"], "architecture");
1433        assert_eq!(value["schema_version"], ARCHITECTURE_SCHEMA_VERSION);
1434        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-arch");
1435    }
1436
1437    #[test]
1438    fn check_envelope_names_each_family_member() {
1439        assert_eq!(CheckEnvelope::DeadCode.kind(), "dead-code");
1440        assert_eq!(CheckEnvelope::DeadCode.grouped_kind(), "dead-code-grouped");
1441        assert_eq!(
1442            CheckEnvelope::DeadCode.schema_version(),
1443            CHECK_SCHEMA_VERSION
1444        );
1445        assert_eq!(CheckEnvelope::DeadCode.command(), "dead-code");
1446        assert_eq!(CheckEnvelope::Architecture.kind(), "architecture");
1447        assert_eq!(
1448            CheckEnvelope::Architecture.grouped_kind(),
1449            "architecture-grouped"
1450        );
1451        assert_eq!(
1452            CheckEnvelope::Architecture.schema_version(),
1453            ARCHITECTURE_SCHEMA_VERSION
1454        );
1455        assert_eq!(CheckEnvelope::Architecture.command(), "architecture");
1456    }
1457
1458    /// The degraded-parse caveat has to travel WITH the finding it can distort,
1459    /// because a reader looking at a `delete-file` action never sees the
1460    /// diagnostic at the other end of the envelope. It is advisory about the
1461    /// FINDING and decisive only about the MUTATION: the actions array keeps
1462    /// its shape and its order, the mutating action reports
1463    /// `auto_fixable: false`, and a clean finding stays byte-identical.
1464    #[test]
1465    fn reachability_caveats_are_absent_when_clean_and_named_when_flagged() {
1466        let mut results = AnalysisResults::default();
1467        results
1468            .unused_files
1469            .push(UnusedFileFinding::with_actions(UnusedFile {
1470                path: "/project/src/clean.ts".into(),
1471            }));
1472        let mut flagged = UnusedFileFinding::with_actions(UnusedFile {
1473            path: "/project/src/orphan.ts".into(),
1474        });
1475        flagged.reachability_caveats = vec![
1476            ReachabilityCaveat::IncompleteFileAnalysis,
1477            ReachabilityCaveat::IncompleteImportGraph,
1478        ];
1479        results.unused_files.push(flagged);
1480
1481        let output = build_check_output(CheckOutputInput {
1482            schema_version: 7,
1483            version: "0.0.0".to_string(),
1484            elapsed: Duration::from_millis(1),
1485            results,
1486            config_fixable: false,
1487            meta: None,
1488            workspace_diagnostics: Vec::new(),
1489            next_steps: Vec::new(),
1490        });
1491        let value =
1492            serialize_check_json_output(output, None).expect("dead-code output should serialize");
1493
1494        let entries = value["unused_files"]
1495            .as_array()
1496            .expect("unused_files array")
1497            .clone();
1498        let find = |name: &str| {
1499            entries
1500                .iter()
1501                .find(|entry| {
1502                    entry["path"]
1503                        .as_str()
1504                        .is_some_and(|path| path.ends_with(name))
1505                })
1506                .cloned()
1507                .expect("finding present")
1508        };
1509
1510        assert!(
1511            find("clean.ts").get("reachability_caveats").is_none(),
1512            "a finding with no caveat must keep the previous wire shape exactly"
1513        );
1514
1515        let flagged = find("orphan.ts");
1516        assert_eq!(
1517            flagged["reachability_caveats"],
1518            serde_json::json!(["incomplete-file-analysis", "incomplete-import-graph"]),
1519            "both caveats are named on the wire, in declaration order"
1520        );
1521        assert_eq!(
1522            flagged["actions"].as_array().map(Vec::len),
1523            Some(2),
1524            "the caveat never trims the finding's actions"
1525        );
1526        assert_eq!(
1527            flagged["actions"][0]["type"], "delete-file",
1528            "nor reorders them, so a consumer reading actions[0].type is unaffected"
1529        );
1530        assert_eq!(
1531            flagged["actions"][0]["auto_fixable"],
1532            serde_json::json!(false),
1533            "but the mutation it gates must not advertise itself as applicable"
1534        );
1535    }
1536
1537    #[test]
1538    fn grouped_check_json_output_uses_output_owned_root_contract() {
1539        let root = std::path::Path::new("/project");
1540        let output = CheckGroupedOutput {
1541            gate_outcomes: None,
1542            request_outcomes: None,
1543            package_baselines: Vec::new(),
1544            baseline_staleness: None,
1545            finding_id_query: None,
1546            schema_version: SchemaVersion(7),
1547            version: ToolVersion("0.0.0".to_string()),
1548            elapsed_ms: ElapsedMs(1),
1549            grouped_by: GroupByMode::Directory,
1550            total_issues: 0,
1551            groups: Vec::new(),
1552            unused_load_data_keys_global_abstain: false,
1553            cascade_hidden: 0,
1554            meta: None,
1555            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1556                root,
1557                root.join("src/unreadable.ts"),
1558                WorkspaceDiagnosticKind::SourceReadFailure {
1559                    error: "permission denied".to_string(),
1560                },
1561            )],
1562            next_steps: Vec::new(),
1563        };
1564
1565        let value = serialize_check_grouped_json_output(output, Some("run-group"))
1566            .expect("grouped check output should serialize");
1567
1568        assert_eq!(value["kind"], "dead-code-grouped");
1569        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-group");
1570        assert_eq!(
1571            value["workspace_diagnostics"][0]["path"],
1572            "/project/src/unreadable.ts"
1573        );
1574        assert_eq!(
1575            value["workspace_diagnostics"][0]["kind"],
1576            "source-read-failure"
1577        );
1578    }
1579
1580    #[test]
1581    fn architecture_grouped_json_output_has_grouped_kind() {
1582        let output = CheckGroupedOutput {
1583            gate_outcomes: None,
1584            request_outcomes: None,
1585            package_baselines: Vec::new(),
1586            baseline_staleness: None,
1587            finding_id_query: None,
1588            schema_version: SchemaVersion(ARCHITECTURE_SCHEMA_VERSION),
1589            version: ToolVersion("0.0.0".to_string()),
1590            elapsed_ms: ElapsedMs(1),
1591            grouped_by: GroupByMode::Directory,
1592            total_issues: 0,
1593            groups: Vec::new(),
1594            unused_load_data_keys_global_abstain: false,
1595            cascade_hidden: 0,
1596            meta: None,
1597            workspace_diagnostics: Vec::new(),
1598            next_steps: Vec::new(),
1599        };
1600        let grouped = serialize_architecture_grouped_json_output(output, None)
1601            .expect("grouped architecture output should serialize");
1602        assert_eq!(grouped["kind"], "architecture-grouped");
1603        assert_eq!(grouped["schema_version"], ARCHITECTURE_SCHEMA_VERSION);
1604    }
1605
1606    #[test]
1607    fn workspace_diagnostics_serialize_typed_kind_path_message() {
1608        let root = std::path::Path::new("/project");
1609        let output = build_check_output(CheckOutputInput {
1610            schema_version: 7,
1611            version: "0.0.0".to_string(),
1612            elapsed: Duration::from_millis(1),
1613            results: AnalysisResults::default(),
1614            config_fixable: false,
1615            meta: None,
1616            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1617                root,
1618                root.join("packages/legacy"),
1619                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1620            )],
1621            next_steps: Vec::new(),
1622        });
1623
1624        let value = serde_json::to_value(&output).expect("check output serializes");
1625        let diag = &value["workspace_diagnostics"][0];
1626        assert_eq!(diag["kind"], "undeclared-workspace");
1627        assert!(
1628            diag["path"]
1629                .as_str()
1630                .is_some_and(|path| path.contains("packages/legacy")),
1631            "path field is carried verbatim: {diag}"
1632        );
1633        assert!(
1634            diag["message"]
1635                .as_str()
1636                .is_some_and(|message| message.contains("packages/legacy")),
1637            "message is rendered from kind + path: {diag}"
1638        );
1639    }
1640
1641    #[test]
1642    fn source_read_failure_workspace_diagnostic_serializes_error_payload() {
1643        let root = std::path::Path::new("/project");
1644        let output = build_check_output(CheckOutputInput {
1645            schema_version: 7,
1646            version: "0.0.0".to_string(),
1647            elapsed: Duration::from_millis(1),
1648            results: AnalysisResults::default(),
1649            config_fixable: false,
1650            meta: None,
1651            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
1652                root,
1653                root.join("src/removed.ts"),
1654                WorkspaceDiagnosticKind::SourceReadFailure {
1655                    error: "No such file or directory".to_string(),
1656                },
1657            )],
1658            next_steps: Vec::new(),
1659        });
1660
1661        let value = serde_json::to_value(&output).expect("check output serializes");
1662        let diagnostic = &value["workspace_diagnostics"][0];
1663        assert_eq!(diagnostic["kind"], "source-read-failure");
1664        assert_eq!(diagnostic["error"], "No such file or directory");
1665        assert!(
1666            diagnostic["message"]
1667                .as_str()
1668                .is_some_and(|message| message.contains("src/removed.ts"))
1669        );
1670    }
1671
1672    fn suppress_comment(actions: &[IssueAction]) -> Option<&str> {
1673        actions.iter().find_map(|action| match action {
1674            IssueAction::SuppressLine(action) => Some(action.comment.as_str()),
1675            _ => None,
1676        })
1677    }
1678}