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