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