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