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