Skip to main content

fallow_api/
combined_output.rs

1//! Combined JSON output assembly shared by CLI and programmatic consumers.
2
3use 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
20/// Dead-code section inputs for a bare combined JSON report.
21pub struct CombinedCheckJsonSection<'a> {
22    /// Typed dead-code results serialized into the `check` section.
23    pub results: &'a AnalysisResults,
24    /// Project root; its prefix is stripped from paths inside the section.
25    pub root: &'a Path,
26    /// Dead-code analysis wall time, emitted as the section's `elapsed_ms`.
27    pub elapsed: Duration,
28    /// Whether duplicate-export findings can be auto-fixed through config;
29    /// propagated onto their fix actions.
30    pub config_fixable: bool,
31    /// Caller-computed baseline and regression sections.
32    pub extras: CheckJsonExtraOutputs,
33}
34
35/// Inputs for bare `fallow --format json` output assembly.
36pub struct CombinedJsonOutputInput<'a> {
37    /// Dead-code section; `None` omits `check` from the envelope.
38    pub check: Option<CombinedCheckJsonSection<'a>>,
39    /// Duplication section; `None` omits `dupes` from the envelope.
40    pub dupes: Option<&'a DupesReportPayload>,
41    /// Health section; `None` omits `health` from the envelope.
42    pub health: Option<&'a HealthReport>,
43    /// Project root; its prefix is stripped from paths in every section.
44    pub root: &'a Path,
45    /// Total wall time across sections, emitted as the root `elapsed_ms`.
46    pub elapsed: Duration,
47    /// Emit per-section explain metadata under `meta`.
48    pub explain: bool,
49    /// Type-aware pass metadata, merged into the check section's meta even
50    /// when `explain` is off.
51    pub type_aware: Option<fallow_types::envelope::TypeAwareMeta>,
52    /// Workspace, source-discovery, and analysis-stage diagnostics for the
53    /// run: the same list the standalone envelopes carry, emitted on the
54    /// combined root and omitted when empty. The root is the only carrier, so
55    /// a run that skips a section still reports them (issue #2366).
56    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
57    /// Suggested follow-up commands for the consumer.
58    pub next_steps: Vec<NextStep>,
59    /// Whether the root envelope carries a `kind` discriminant.
60    pub envelope_mode: RootEnvelopeMode,
61    /// Analysis run id stamped into telemetry metadata when present.
62    pub telemetry_analysis_run_id: Option<&'a str>,
63}
64
65/// Build and serialize bare combined JSON through the API output boundary.
66///
67/// # Errors
68///
69/// Returns a serde error when any typed section cannot be converted to JSON.
70pub 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
145/// Build a combined duplication section without adding a nested root envelope.
146///
147/// # Errors
148///
149/// Returns a serde error when the typed duplication payload cannot be
150/// serialized.
151pub 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
164/// Build a combined health section without adding a nested root envelope.
165///
166/// # Errors
167///
168/// Returns a serde error when the typed health payload cannot be serialized.
169pub 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    /// Issue #2366: the combined root carries the diagnostics it is given,
371    /// root-relative like the standalone envelopes, and omits the array when
372    /// there are none. The `check` section never grows its own copy, so the
373    /// document has exactly one carrier.
374    #[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    /// Issue #2366: the carrier does not depend on which sections ran, so a
404    /// combined run without a `check` section (`--skip check`, `--only health`,
405    /// `--only dupes`) still reports the diagnostics.
406    #[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}