1use std::path::Path;
4use std::time::Duration;
5
6use fallow_output::{
7 COMBINED_SCHEMA_VERSION, CombinedMeta, CombinedOutput, HealthReport, RootEnvelopeMode,
8 check_meta, dupes_meta, harmonize_dead_code_health_suppress_line_actions, health_meta,
9 serialize_combined_json_output, strip_root_prefix,
10};
11use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
12use fallow_types::output::NextStep;
13use fallow_types::results::AnalysisResults;
14use fallow_types::workspace::WorkspaceDiagnostic;
15
16use crate::{
17 CheckJsonExtraOutputs, CheckJsonPayloadInput, DupesReportPayload, serialize_check_json_payload,
18};
19
20pub struct CombinedCheckJsonSection<'a> {
22 pub results: &'a AnalysisResults,
24 pub root: &'a Path,
26 pub elapsed: Duration,
28 pub config_fixable: bool,
31 pub extras: CheckJsonExtraOutputs,
33}
34
35pub struct CombinedJsonOutputInput<'a> {
37 pub check: Option<CombinedCheckJsonSection<'a>>,
39 pub dupes: Option<&'a DupesReportPayload>,
41 pub health: Option<&'a HealthReport>,
43 pub root: &'a Path,
45 pub elapsed: Duration,
47 pub explain: bool,
49 pub type_aware: Option<fallow_types::envelope::TypeAwareMeta>,
52 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
57 pub next_steps: Vec<NextStep>,
59 pub envelope_mode: RootEnvelopeMode,
61 pub telemetry_analysis_run_id: Option<&'a str>,
63}
64
65pub fn serialize_combined_json(
71 input: CombinedJsonOutputInput<'_>,
72) -> Result<serde_json::Value, serde_json::Error> {
73 let mut check_results = input.check.as_ref().map(|section| section.results.clone());
74 let mut health_report = input.health.cloned();
75 harmonize_dead_code_health_suppress_line_actions(
76 check_results.as_mut(),
77 health_report.as_mut(),
78 );
79
80 let check = if let Some(section) = input.check {
81 if let Some(results) = check_results.as_ref() {
82 Some(serialize_combined_check_json(section, results)?)
83 } else {
84 None
85 }
86 } else {
87 None
88 };
89 let dupes = serialize_combined_dupes_json(input.dupes, input.root)?;
90 let health = serialize_combined_health_json(health_report.as_ref(), input.root)?;
91
92 let mut meta = input
93 .explain
94 .then(|| combined_meta_for_output(check.is_some(), dupes.is_some(), health.is_some()));
95 if let Some(type_aware) = input.type_aware {
96 let combined_meta = meta.get_or_insert(CombinedMeta {
97 check: None,
98 dupes: None,
99 health: None,
100 telemetry: None,
101 });
102 let check_meta = combined_meta
103 .check
104 .get_or_insert_with(fallow_types::envelope::Meta::default);
105 check_meta.type_aware = Some(type_aware);
106 }
107
108 let output = CombinedOutput {
109 schema_version: SchemaVersion(COMBINED_SCHEMA_VERSION),
110 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
111 elapsed_ms: ElapsedMs(elapsed_ms_for_output(input.elapsed)),
112 meta,
113 check,
114 dupes,
115 health,
116 workspace_diagnostics: input.workspace_diagnostics,
117 next_steps: input.next_steps,
118 };
119
120 let mut value = serialize_combined_json_output(
121 output,
122 input.envelope_mode,
123 input.telemetry_analysis_run_id,
124 )?;
125 if let Some(diagnostics) = value.get_mut("workspace_diagnostics") {
126 strip_root_prefix(diagnostics, &format!("{}/", input.root.display()));
127 }
128 Ok(value)
129}
130
131fn serialize_combined_check_json(
132 section: CombinedCheckJsonSection<'_>,
133 results: &AnalysisResults,
134) -> Result<serde_json::Value, serde_json::Error> {
135 serialize_check_json_payload(CheckJsonPayloadInput {
136 results,
137 root: section.root,
138 elapsed: section.elapsed,
139 config_fixable: section.config_fixable,
140 extras: section.extras,
141 workspace_diagnostics: Vec::new(),
142 })
143}
144
145pub fn serialize_combined_dupes_json(
152 dupes: Option<&DupesReportPayload>,
153 root: &Path,
154) -> Result<Option<serde_json::Value>, serde_json::Error> {
155 let Some(payload) = dupes else {
156 return Ok(None);
157 };
158 let mut json = serde_json::to_value(payload)?;
159 let root_prefix = format!("{}/", root.display());
160 strip_root_prefix(&mut json, &root_prefix);
161 Ok(Some(json))
162}
163
164pub fn serialize_combined_health_json(
170 health: Option<&HealthReport>,
171 root: &Path,
172) -> Result<Option<serde_json::Value>, serde_json::Error> {
173 let Some(report) = health else {
174 return Ok(None);
175 };
176 let mut json = serde_json::to_value(report)?;
177 let root_prefix = format!("{}/", root.display());
178 strip_root_prefix(&mut json, &root_prefix);
179 Ok(Some(json))
180}
181
182fn elapsed_ms_for_output(elapsed: Duration) -> u64 {
183 u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
184}
185
186fn combined_meta_for_output(
187 include_check: bool,
188 include_dupes: bool,
189 include_health: bool,
190) -> CombinedMeta {
191 CombinedMeta {
192 check: include_check.then(check_meta),
193 dupes: include_dupes.then(dupes_meta),
194 health: include_health.then(health_meta),
195 telemetry: None,
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use std::time::Duration;
202
203 use fallow_output::{
204 ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
205 RootEnvelopeMode,
206 };
207 use fallow_types::output_dead_code::UnusedExportFinding;
208 use fallow_types::output_health::{HealthFindingAction, HealthFindingActionType};
209 use fallow_types::results::{AnalysisResults, UnusedExport};
210 use fallow_types::workspace::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
211
212 use super::{CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_json};
213
214 #[test]
215 fn combined_json_root_contains_stable_envelope_fields() {
216 let root = serialize_combined_json(CombinedJsonOutputInput {
217 check: None,
218 dupes: None,
219 health: None,
220 root: std::path::Path::new("."),
221 elapsed: Duration::from_millis(42),
222 explain: false,
223 type_aware: None,
224 workspace_diagnostics: Vec::new(),
225 next_steps: Vec::new(),
226 envelope_mode: RootEnvelopeMode::Tagged,
227 telemetry_analysis_run_id: None,
228 })
229 .expect("combined JSON root");
230
231 assert_eq!(
232 root.get("kind").and_then(serde_json::Value::as_str),
233 Some("combined")
234 );
235 assert_eq!(
236 root.get("elapsed_ms").and_then(serde_json::Value::as_u64),
237 Some(42)
238 );
239 assert!(root.get("schema_version").is_some());
240 assert!(root.get("version").is_some());
241 }
242
243 #[test]
244 fn combined_json_harmonizes_dead_code_and_health_suppress_actions_before_serialization() {
245 let root = std::path::Path::new("/project");
246 let path = root.join("src/shared.ts");
247 let mut results = AnalysisResults::default();
248 results
249 .unused_exports
250 .push(UnusedExportFinding::with_actions(UnusedExport {
251 path: path.clone(),
252 export_name: "value".to_string(),
253 is_type_only: false,
254 line: 7,
255 col: 0,
256 span_start: 0,
257 is_re_export: false,
258 }));
259 let health = HealthReport {
260 findings: vec![HealthFinding::new(
261 ComplexityViolation {
262 path,
263 name: "expensive".to_string(),
264 line: 7,
265 col: 0,
266 cyclomatic: 22,
267 cognitive: 18,
268 line_count: 40,
269 param_count: 1,
270 react_hook_count: 0,
271 react_jsx_max_depth: 0,
272 react_prop_count: 0,
273 react_hook_profile: None,
274 exceeded: ExceededThreshold::Both,
275 severity: FindingSeverity::High,
276 crap: None,
277 coverage_pct: None,
278 coverage_tier: None,
279 coverage_source: None,
280 inherited_from: None,
281 component_rollup: None,
282 contributions: Vec::new(),
283 effective_thresholds: None,
284 threshold_source: None,
285 },
286 vec![HealthFindingAction {
287 kind: HealthFindingActionType::SuppressLine,
288 auto_fixable: false,
289 description: "Suppress with an inline comment above the function declaration"
290 .to_string(),
291 note: None,
292 comment: Some("// fallow-ignore-next-line complexity".to_string()),
293 placement: Some("above-function-declaration".to_string()),
294 target_path: None,
295 }],
296 None,
297 )],
298 ..HealthReport::default()
299 };
300
301 let output = serialize_combined_json(CombinedJsonOutputInput {
302 check: Some(CombinedCheckJsonSection {
303 results: &results,
304 root,
305 elapsed: Duration::ZERO,
306 config_fixable: false,
307 extras: crate::CheckJsonExtraOutputs::default(),
308 }),
309 dupes: None,
310 health: Some(&health),
311 root,
312 elapsed: Duration::ZERO,
313 explain: false,
314 type_aware: None,
315 workspace_diagnostics: Vec::new(),
316 next_steps: Vec::new(),
317 envelope_mode: RootEnvelopeMode::Tagged,
318 telemetry_analysis_run_id: None,
319 })
320 .expect("combined JSON");
321
322 assert_eq!(
323 output["check"]["unused_exports"][0]["actions"][1]["comment"],
324 "// fallow-ignore-next-line unused-export, complexity"
325 );
326 assert_eq!(
327 output["health"]["findings"][0]["actions"][0]["comment"],
328 "// fallow-ignore-next-line unused-export, complexity"
329 );
330 }
331
332 fn combined_json_with_diagnostics(
333 root: &std::path::Path,
334 include_check: bool,
335 workspace_diagnostics: Vec<WorkspaceDiagnostic>,
336 ) -> serde_json::Value {
337 let results = AnalysisResults::default();
338 serialize_combined_json(CombinedJsonOutputInput {
339 check: include_check.then(|| CombinedCheckJsonSection {
340 results: &results,
341 root,
342 elapsed: Duration::ZERO,
343 config_fixable: false,
344 extras: crate::CheckJsonExtraOutputs::default(),
345 }),
346 dupes: None,
347 health: None,
348 root,
349 elapsed: Duration::ZERO,
350 explain: false,
351 type_aware: None,
352 workspace_diagnostics,
353 next_steps: Vec::new(),
354 envelope_mode: RootEnvelopeMode::Tagged,
355 telemetry_analysis_run_id: None,
356 })
357 .expect("combined JSON")
358 }
359
360 fn malformed_yaml_diagnostic(root: &std::path::Path) -> WorkspaceDiagnostic {
361 WorkspaceDiagnostic::new(
362 root,
363 root.join("pnpm-workspace.yaml"),
364 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
365 error: "could not find expected ':'".to_owned(),
366 },
367 )
368 }
369
370 #[test]
375 fn combined_root_carries_workspace_diagnostics_root_relative_or_omits_them() {
376 let root = std::path::Path::new("/project");
377 let output =
378 combined_json_with_diagnostics(root, true, vec![malformed_yaml_diagnostic(root)]);
379 assert_eq!(
380 output["workspace_diagnostics"][0]["kind"],
381 "malformed-pnpm-workspace-yaml"
382 );
383 assert_eq!(
384 output["workspace_diagnostics"][0]["path"],
385 "pnpm-workspace.yaml"
386 );
387 assert!(
388 output["check"].is_object(),
389 "the check section is present, so the absence check below is not vacuous: {output}"
390 );
391 assert!(
392 output["check"].get("workspace_diagnostics").is_none(),
393 "the check section is not a second carrier: {output}"
394 );
395
396 let empty = combined_json_with_diagnostics(root, true, Vec::new());
397 assert!(
398 empty.get("workspace_diagnostics").is_none(),
399 "an empty list is omitted from the combined root: {empty}"
400 );
401 }
402
403 #[test]
407 fn combined_root_carries_workspace_diagnostics_without_a_check_section() {
408 let root = std::path::Path::new("/project");
409 let output =
410 combined_json_with_diagnostics(root, false, vec![malformed_yaml_diagnostic(root)]);
411 assert!(
412 output.get("check").is_none(),
413 "this run has no check section: {output}"
414 );
415 assert_eq!(
416 output["workspace_diagnostics"][0]["kind"],
417 "malformed-pnpm-workspace-yaml"
418 );
419 assert_eq!(
420 output["workspace_diagnostics"][0]["path"],
421 "pnpm-workspace.yaml"
422 );
423 }
424}