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
14pub const CHECK_SCHEMA_VERSION: u32 = 7;
16
17#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub next_steps: Vec<NextStep>,
57}
58
59#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub next_steps: Vec<NextStep>,
86}
87
88#[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#[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
117pub 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#[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
172pub 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
185pub 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#[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}