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