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