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