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 gate_outcomes: Option<fallow_output::GateOutcomes>,
40 pub request_outcomes: Option<fallow_output::RequestOutcomes>,
45
46 pub check: Option<CombinedCheckJsonSection<'a>>,
48 pub dupes: Option<&'a DupesReportPayload>,
50 pub health: Option<&'a HealthReport>,
52 pub root: &'a Path,
54 pub elapsed: Duration,
56 pub explain: bool,
58 pub type_aware: Option<fallow_types::envelope::TypeAwareMeta>,
61 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
66 pub next_steps: Vec<NextStep>,
68 pub telemetry_analysis_run_id: Option<&'a str>,
70}
71
72pub fn serialize_combined_json(
78 input: CombinedJsonOutputInput<'_>,
79) -> Result<serde_json::Value, serde_json::Error> {
80 let mut check_results = input.check.as_ref().map(|section| section.results.clone());
81 let mut health_report = input.health.cloned();
82 harmonize_dead_code_health_suppress_line_actions(
83 check_results.as_mut(),
84 health_report.as_mut(),
85 );
86
87 let check = if let Some(section) = input.check {
88 if let Some(results) = check_results.as_ref() {
89 Some(serialize_combined_check_json(section, results)?)
90 } else {
91 None
92 }
93 } else {
94 None
95 };
96 let dupes = serialize_combined_dupes_json(input.dupes, input.root)?;
97 let health = serialize_combined_health_json(health_report.as_ref(), input.root)?;
98
99 let mut meta = input
100 .explain
101 .then(|| combined_meta_for_output(check.is_some(), dupes.is_some(), health.is_some()));
102 if let Some(type_aware) = input.type_aware {
103 let combined_meta = meta.get_or_insert(CombinedMeta {
104 check: None,
105 dupes: None,
106 health: None,
107 telemetry: None,
108 });
109 let check_meta = combined_meta
110 .check
111 .get_or_insert_with(fallow_types::envelope::Meta::default);
112 check_meta.type_aware = Some(type_aware);
113 }
114
115 let output = CombinedOutput {
116 schema_version: SchemaVersion(COMBINED_SCHEMA_VERSION),
117 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
118 elapsed_ms: ElapsedMs(elapsed_ms_for_output(input.elapsed)),
119 gate_outcomes: input.gate_outcomes,
120 request_outcomes: input.request_outcomes,
121 meta,
122 check,
123 dupes,
124 health,
125 workspace_diagnostics: input.workspace_diagnostics,
126 next_steps: input.next_steps,
127 };
128
129 let mut value = serialize_combined_json_output(output, input.telemetry_analysis_run_id)?;
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 };
211 use fallow_types::output_dead_code::UnusedExportFinding;
212 use fallow_types::output_health::{HealthFindingAction, HealthFindingActionType};
213 use fallow_types::results::{AnalysisResults, UnusedExport};
214 use fallow_types::workspace::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
215
216 use super::{CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_json};
217
218 #[test]
219 fn combined_json_root_contains_stable_envelope_fields() {
220 let root = serialize_combined_json(CombinedJsonOutputInput {
221 gate_outcomes: None,
222 request_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 telemetry_analysis_run_id: None,
233 })
234 .expect("combined JSON root");
235
236 assert_eq!(
237 root.get("kind").and_then(serde_json::Value::as_str),
238 Some("combined")
239 );
240 assert_eq!(
241 root.get("elapsed_ms").and_then(serde_json::Value::as_u64),
242 Some(42)
243 );
244 assert!(root.get("schema_version").is_some());
245 assert!(root.get("version").is_some());
246 }
247
248 #[test]
249 fn combined_json_harmonizes_dead_code_and_health_suppress_actions_before_serialization() {
250 let root = std::path::Path::new("/project");
251 let path = root.join("src/shared.ts");
252 let mut results = AnalysisResults::default();
253 results
254 .unused_exports
255 .push(UnusedExportFinding::with_actions(UnusedExport {
256 path: path.clone(),
257 export_name: "value".to_string(),
258 is_type_only: false,
259 line: 7,
260 col: 0,
261 span_start: 0,
262 is_re_export: false,
263 deprecated: false,
264 deprecated_reason: None,
265 }));
266 let health = HealthReport {
267 findings: vec![HealthFinding::new(
268 ComplexityViolation {
269 path,
270 name: "expensive".to_string(),
271 line: 7,
272 col: 0,
273 cyclomatic: 22,
274 cognitive: 18,
275 line_count: 40,
276 param_count: 1,
277 react_hook_count: 0,
278 react_jsx_max_depth: 0,
279 react_prop_count: 0,
280 react_hook_profile: None,
281 exceeded: ExceededThreshold::Both,
282 effective_severity: None,
283 severity: FindingSeverity::High,
284 crap: None,
285 coverage_pct: None,
286 coverage_tier: None,
287 coverage_source: None,
288 inherited_from: None,
289 component_rollup: None,
290 contributions: Vec::new(),
291 effective_thresholds: None,
292 threshold_source: None,
293 },
294 vec![HealthFindingAction {
295 kind: HealthFindingActionType::SuppressLine,
296 auto_fixable: false,
297 description: "Suppress with an inline comment above the function declaration"
298 .to_string(),
299 note: None,
300 comment: Some("// fallow-ignore-next-line complexity".to_string()),
301 placement: Some("above-function-declaration".to_string()),
302 target_path: None,
303 }],
304 None,
305 )],
306 ..HealthReport::default()
307 };
308
309 let output = serialize_combined_json(CombinedJsonOutputInput {
310 gate_outcomes: None,
311 request_outcomes: None,
312 check: Some(CombinedCheckJsonSection {
313 results: &results,
314 root,
315 elapsed: Duration::ZERO,
316 config_fixable: false,
317 extras: crate::CheckJsonExtraOutputs::default(),
318 }),
319 dupes: None,
320 health: Some(&health),
321 root,
322 elapsed: Duration::ZERO,
323 explain: false,
324 type_aware: None,
325 workspace_diagnostics: Vec::new(),
326 next_steps: Vec::new(),
327 telemetry_analysis_run_id: None,
328 })
329 .expect("combined JSON");
330
331 assert_eq!(
332 output["check"]["unused_exports"][0]["actions"][1]["comment"],
333 "// fallow-ignore-next-line unused-export, complexity"
334 );
335 assert_eq!(
336 output["health"]["findings"][0]["actions"][0]["comment"],
337 "// fallow-ignore-next-line unused-export, complexity"
338 );
339 }
340
341 fn combined_json_with_diagnostics(
342 root: &std::path::Path,
343 include_check: bool,
344 workspace_diagnostics: Vec<WorkspaceDiagnostic>,
345 ) -> serde_json::Value {
346 let results = AnalysisResults::default();
347 serialize_combined_json(CombinedJsonOutputInput {
348 gate_outcomes: None,
349 request_outcomes: None,
350 check: include_check.then(|| CombinedCheckJsonSection {
351 results: &results,
352 root,
353 elapsed: Duration::ZERO,
354 config_fixable: false,
355 extras: crate::CheckJsonExtraOutputs::default(),
356 }),
357 dupes: None,
358 health: None,
359 root,
360 elapsed: Duration::ZERO,
361 explain: false,
362 type_aware: None,
363 workspace_diagnostics,
364 next_steps: Vec::new(),
365 telemetry_analysis_run_id: None,
366 })
367 .expect("combined JSON")
368 }
369
370 fn malformed_yaml_diagnostic(root: &std::path::Path) -> WorkspaceDiagnostic {
371 WorkspaceDiagnostic::new(
372 root,
373 root.join("pnpm-workspace.yaml"),
374 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
375 error: "could not find expected ':'".to_owned(),
376 },
377 )
378 }
379
380 #[test]
385 fn combined_root_carries_workspace_diagnostics_root_relative_or_omits_them() {
386 let root = std::path::Path::new("/project");
387 let output =
388 combined_json_with_diagnostics(root, true, vec![malformed_yaml_diagnostic(root)]);
389 assert_eq!(
390 output["workspace_diagnostics"][0]["kind"],
391 "malformed-pnpm-workspace-yaml"
392 );
393 assert_eq!(
394 output["workspace_diagnostics"][0]["path"],
395 "pnpm-workspace.yaml"
396 );
397 assert!(
398 output["check"].is_object(),
399 "the check section is present, so the absence check below is not vacuous: {output}"
400 );
401 assert!(
402 output["check"].get("workspace_diagnostics").is_none(),
403 "the check section is not a second carrier: {output}"
404 );
405
406 let empty = combined_json_with_diagnostics(root, true, Vec::new());
407 assert!(
408 empty.get("workspace_diagnostics").is_none(),
409 "an empty list is omitted from the combined root: {empty}"
410 );
411 }
412
413 #[test]
417 fn combined_root_carries_workspace_diagnostics_without_a_check_section() {
418 let root = std::path::Path::new("/project");
419 let output =
420 combined_json_with_diagnostics(root, false, vec![malformed_yaml_diagnostic(root)]);
421 assert!(
422 output.get("check").is_none(),
423 "this run has no check section: {output}"
424 );
425 assert_eq!(
426 output["workspace_diagnostics"][0]["kind"],
427 "malformed-pnpm-workspace-yaml"
428 );
429 assert_eq!(
430 output["workspace_diagnostics"][0]["path"],
431 "pnpm-workspace.yaml"
432 );
433 }
434}