Skip to main content

fallow_output/
check.rs

1use std::time::Duration;
2
3use fallow_types::envelope::{
4    BaselineDeltas, BaselineMatch, CheckSummary, ElapsedMs, EntryPoints, Meta, RegressionResult,
5    SchemaVersion, ToolVersion,
6};
7use fallow_types::output::NextStep;
8use fallow_types::results::AnalysisResults;
9use fallow_types::workspace::WorkspaceDiagnostic;
10use serde::Serialize;
11
12use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
13
14/// Current schema version for the dead-code/check JSON envelope.
15pub const CHECK_SCHEMA_VERSION: u32 = 7;
16
17/// Envelope emitted by `fallow dead-code --format json` (plus the `check`
18/// block inside the combined and audit envelopes).
19///
20/// The body is the full `AnalysisResults` flattened into the envelope so
21/// every issue array (`unused_files`, `unused_exports`, ...) lives at the
22/// top level, matching the existing wire shape. `entry_points` lifts the
23/// otherwise `#[serde(skip)]`'d `AnalysisResults::entry_point_summary` back
24/// into the JSON output. `summary` carries the per-category counts the
25/// JSON layer always emits.
26#[derive(Debug, Clone, Serialize)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[cfg_attr(feature = "schema", schemars(title = "fallow dead-code --format json"))]
29pub struct CheckOutput {
30    pub schema_version: SchemaVersion,
31    pub version: ToolVersion,
32    pub elapsed_ms: ElapsedMs,
33    pub total_issues: usize,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub entry_points: Option<EntryPoints>,
36    pub summary: CheckSummary,
37    #[serde(flatten)]
38    pub results: AnalysisResults,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub baseline_deltas: Option<BaselineDeltas>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub baseline: Option<BaselineMatch>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub regression: Option<RegressionResult>,
45    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
46    pub meta: Option<Meta>,
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
49    /// Read-only follow-up commands computed from this run's findings, emitted
50    /// at the JSON root so an agent acting on the output is pointed at fallow's
51    /// adjacent verification capabilities (trace, complexity breakdown, audit,
52    /// workspace scoping). Each command is runnable as-is and never mutating;
53    /// see [`NextStep`] for both contracts. Omitted when empty or when
54    /// `FALLOW_SUGGESTIONS=off`; does NOT contribute to `total_issues`.
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub next_steps: Vec<NextStep>,
57}
58
59/// Envelope emitted by `fallow dead-code --group-by ... --format json`.
60///
61/// Issues are partitioned into resolver buckets (CODEOWNERS team, directory
62/// prefix, workspace package, or GitLab CODEOWNERS section) instead of flat
63/// arrays. Each bucket carries the same issue-array shape as the ungrouped
64/// `CheckOutput` body, plus per-group `key` / `owners` / `total_issues`.
65#[derive(Debug, Clone, Serialize)]
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67#[cfg_attr(
68    feature = "schema",
69    schemars(
70        title = "fallow dead-code --group-by <owner|directory|package|section> --format json"
71    )
72)]
73pub struct CheckGroupedOutput {
74    pub schema_version: SchemaVersion,
75    pub version: ToolVersion,
76    pub elapsed_ms: ElapsedMs,
77    pub grouped_by: GroupByMode,
78    pub total_issues: usize,
79    pub groups: Vec<CheckGroupedEntry>,
80    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
81    pub meta: Option<Meta>,
82    /// Read-only follow-up commands computed from the full (ungrouped) findings.
83    /// See [`CheckOutput::next_steps`] for the contract.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub next_steps: Vec<NextStep>,
86}
87
88/// Single resolver bucket inside `CheckGroupedOutput`. Carries the group's
89/// identifier, optional section owners, and a per-group flattened
90/// `AnalysisResults`.
91#[derive(Debug, Clone, Serialize)]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93pub struct CheckGroupedEntry {
94    pub key: String,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub owners: Option<Vec<String>>,
97    pub total_issues: usize,
98    #[serde(flatten)]
99    pub results: AnalysisResults,
100}
101
102/// Resolver mode label for grouped envelopes (dead-code, dupes, health).
103///
104/// `owner` groups by CODEOWNERS team, `directory` groups by top-level
105/// directory prefix, `package` groups by workspace package name, `section`
106/// groups by GitLab CODEOWNERS `[Section]` header name.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
108#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
109#[serde(rename_all = "lowercase")]
110pub enum GroupByMode {
111    Owner,
112    Directory,
113    Package,
114    Section,
115}
116
117/// Inputs for building the dead-code JSON envelope.
118pub struct CheckOutputInput {
119    pub schema_version: u32,
120    pub version: String,
121    pub elapsed: Duration,
122    pub results: AnalysisResults,
123    pub config_fixable: bool,
124    pub meta: Option<Meta>,
125    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
126    pub next_steps: Vec<NextStep>,
127}
128
129/// Build the typed dead-code JSON envelope from engine results.
130#[must_use]
131pub fn build_check_output(input: CheckOutputInput) -> CheckOutput {
132    let mut results = input.results;
133    apply_config_fixable_to_duplicate_exports(&mut results, input.config_fixable);
134    CheckOutput {
135        schema_version: SchemaVersion(input.schema_version),
136        version: ToolVersion(input.version),
137        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
138        total_issues: results.total_issues(),
139        entry_points: results
140            .entry_point_summary
141            .as_ref()
142            .map(|entry_points| EntryPoints {
143                total: entry_points.total,
144                sources: entry_points
145                    .by_source
146                    .iter()
147                    .map(|(key, value)| (key.replace(' ', "_"), *value))
148                    .collect(),
149            }),
150        summary: build_check_summary(&results),
151        results,
152        baseline_deltas: None,
153        baseline: None,
154        regression: None,
155        meta: input.meta,
156        workspace_diagnostics: input.workspace_diagnostics,
157        next_steps: input.next_steps,
158    }
159}
160
161fn serialize_check_family_json_output<T: Serialize>(
162    output: T,
163    kind: &'static str,
164    mode: RootEnvelopeMode,
165    analysis_run_id: Option<&str>,
166) -> Result<serde_json::Value, serde_json::Error> {
167    let mut value = serialize_named_json_output(output, kind, mode)?;
168    attach_telemetry_meta(&mut value, analysis_run_id);
169    Ok(value)
170}
171
172/// Serialize `fallow dead-code --format json`.
173///
174/// # Errors
175///
176/// Returns a serde error when the dead-code output cannot be converted to JSON.
177pub fn serialize_check_json_output(
178    output: CheckOutput,
179    mode: RootEnvelopeMode,
180    analysis_run_id: Option<&str>,
181) -> Result<serde_json::Value, serde_json::Error> {
182    serialize_check_family_json_output(output, "dead-code", mode, analysis_run_id)
183}
184
185/// Serialize `fallow dead-code --group-by ... --format json`.
186///
187/// # Errors
188///
189/// Returns a serde error when the grouped dead-code output cannot be converted
190/// to JSON.
191pub fn serialize_check_grouped_json_output(
192    output: CheckGroupedOutput,
193    mode: RootEnvelopeMode,
194    analysis_run_id: Option<&str>,
195) -> Result<serde_json::Value, serde_json::Error> {
196    serialize_check_family_json_output(output, "dead-code-grouped", mode, analysis_run_id)
197}
198
199pub fn apply_config_fixable_to_duplicate_exports(
200    results: &mut AnalysisResults,
201    config_fixable: bool,
202) {
203    if !config_fixable {
204        return;
205    }
206    for finding in &mut results.duplicate_exports {
207        finding.set_config_fixable(true);
208    }
209}
210
211/// Compute the per-category `CheckSummary` from analysis results.
212#[must_use]
213pub fn build_check_summary(results: &AnalysisResults) -> CheckSummary {
214    CheckSummary {
215        total_issues: results.total_issues(),
216        unused_files: results.unused_files.len(),
217        unused_exports: results.unused_exports.len(),
218        unused_types: results.unused_types.len(),
219        private_type_leaks: results.private_type_leaks.len(),
220        unused_dependencies: results.unused_dependencies.len()
221            + results.unused_dev_dependencies.len()
222            + results.unused_optional_dependencies.len(),
223        unused_enum_members: results.unused_enum_members.len(),
224        unused_class_members: results.unused_class_members.len(),
225        unused_store_members: results.unused_store_members.len(),
226        unresolved_imports: results.unresolved_imports.len(),
227        unlisted_dependencies: results.unlisted_dependencies.len(),
228        duplicate_exports: results.duplicate_exports.len(),
229        type_only_dependencies: results.type_only_dependencies.len(),
230        test_only_dependencies: results.test_only_dependencies.len(),
231        circular_dependencies: results.circular_dependencies.len(),
232        re_export_cycles: results.re_export_cycles.len(),
233        boundary_violations: results.boundary_violations.len(),
234        boundary_coverage_violations: results.boundary_coverage_violations.len(),
235        boundary_call_violations: results.boundary_call_violations.len(),
236        policy_violations: results.policy_violations.len(),
237        stale_suppressions: results.stale_suppressions.len(),
238        unused_catalog_entries: results.unused_catalog_entries.len(),
239        empty_catalog_groups: results.empty_catalog_groups.len(),
240        unresolved_catalog_references: results.unresolved_catalog_references.len(),
241        unused_dependency_overrides: results.unused_dependency_overrides.len(),
242        misconfigured_dependency_overrides: results.misconfigured_dependency_overrides.len(),
243        invalid_client_exports: results.invalid_client_exports.len(),
244        mixed_client_server_barrels: results.mixed_client_server_barrels.len(),
245        misplaced_directives: results.misplaced_directives.len(),
246        unprovided_injects: results.unprovided_injects.len(),
247        unrendered_components: results.unrendered_components.len(),
248        unused_component_props: results.unused_component_props.len(),
249        unused_component_emits: results.unused_component_emits.len(),
250        unused_component_inputs: results.unused_component_inputs.len(),
251        unused_component_outputs: results.unused_component_outputs.len(),
252        unused_svelte_events: results.unused_svelte_events.len(),
253        unused_server_actions: results.unused_server_actions.len(),
254        unused_load_data_keys: results.unused_load_data_keys.len(),
255        route_collisions: results.route_collisions.len(),
256        dynamic_segment_name_conflicts: results.dynamic_segment_name_conflicts.len(),
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use fallow_types::output_dead_code::UnusedFileFinding;
264    use fallow_types::results::UnusedFile;
265    use fallow_types::workspace::WorkspaceDiagnosticKind;
266
267    #[test]
268    fn build_check_output_counts_issues_and_entry_points() {
269        let mut results = AnalysisResults::default();
270        results
271            .unused_files
272            .push(UnusedFileFinding::with_actions(UnusedFile {
273                path: "src/unused.ts".into(),
274            }));
275
276        let output = build_check_output(CheckOutputInput {
277            schema_version: 7,
278            version: "0.0.0".to_string(),
279            elapsed: Duration::from_millis(42),
280            results,
281            config_fixable: false,
282            meta: None,
283            workspace_diagnostics: Vec::new(),
284            next_steps: Vec::new(),
285        });
286
287        assert_eq!(output.schema_version.0, 7);
288        assert_eq!(output.total_issues, 1);
289        assert_eq!(output.summary.unused_files, 1);
290        assert_eq!(output.elapsed_ms.0, 42);
291    }
292
293    #[test]
294    fn check_json_output_uses_output_owned_root_contract() {
295        let output = build_check_output(CheckOutputInput {
296            schema_version: 7,
297            version: "0.0.0".to_string(),
298            elapsed: Duration::from_millis(42),
299            results: AnalysisResults::default(),
300            config_fixable: false,
301            meta: None,
302            workspace_diagnostics: Vec::new(),
303            next_steps: Vec::new(),
304        });
305
306        let value =
307            serialize_check_json_output(output, RootEnvelopeMode::Tagged, Some("run-check"))
308                .expect("check output should serialize");
309
310        assert_eq!(value["kind"], "dead-code");
311        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-check");
312    }
313
314    #[test]
315    fn grouped_check_json_output_uses_output_owned_root_contract() {
316        let output = CheckGroupedOutput {
317            schema_version: SchemaVersion(7),
318            version: ToolVersion("0.0.0".to_string()),
319            elapsed_ms: ElapsedMs(1),
320            grouped_by: GroupByMode::Directory,
321            total_issues: 0,
322            groups: Vec::new(),
323            meta: None,
324            next_steps: Vec::new(),
325        };
326
327        let value = serialize_check_grouped_json_output(
328            output,
329            RootEnvelopeMode::Tagged,
330            Some("run-group"),
331        )
332        .expect("grouped check output should serialize");
333
334        assert_eq!(value["kind"], "dead-code-grouped");
335        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-group");
336    }
337
338    #[test]
339    fn workspace_diagnostics_serialize_typed_kind_path_message() {
340        let root = std::path::Path::new("/project");
341        let output = build_check_output(CheckOutputInput {
342            schema_version: 7,
343            version: "0.0.0".to_string(),
344            elapsed: Duration::from_millis(1),
345            results: AnalysisResults::default(),
346            config_fixable: false,
347            meta: None,
348            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
349                root,
350                root.join("packages/legacy"),
351                WorkspaceDiagnosticKind::UndeclaredWorkspace,
352            )],
353            next_steps: Vec::new(),
354        });
355
356        let value = serde_json::to_value(&output).expect("check output serializes");
357        let diag = &value["workspace_diagnostics"][0];
358        assert_eq!(diag["kind"], "undeclared-workspace");
359        assert!(
360            diag["path"]
361                .as_str()
362                .is_some_and(|path| path.contains("packages/legacy")),
363            "path field is carried verbatim: {diag}"
364        );
365        assert!(
366            diag["message"]
367                .as_str()
368                .is_some_and(|message| message.contains("packages/legacy")),
369            "message is rendered from kind + path: {diag}"
370        );
371    }
372}