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    /// Every gate this run evaluated, absent when it evaluated none. The
38    /// programmatic route runs no CLI-layer gate and leaves this `None`.
39    pub gate_outcomes: Option<fallow_output::GateOutcomes>,
40
41    /// Dead-code section; `None` omits `check` from the envelope.
42    pub check: Option<CombinedCheckJsonSection<'a>>,
43    /// Duplication section; `None` omits `dupes` from the envelope.
44    pub dupes: Option<&'a DupesReportPayload>,
45    /// Health section; `None` omits `health` from the envelope.
46    pub health: Option<&'a HealthReport>,
47    /// Project root; its prefix is stripped from paths in every section.
48    pub root: &'a Path,
49    /// Total wall time across sections, emitted as the root `elapsed_ms`.
50    pub elapsed: Duration,
51    /// Emit per-section explain metadata under `meta`.
52    pub explain: bool,
53    /// Type-aware pass metadata, merged into the check section's meta even
54    /// when `explain` is off.
55    pub type_aware: Option<fallow_types::envelope::TypeAwareMeta>,
56    /// Workspace, source-discovery, and analysis-stage diagnostics for the
57    /// run: the same list the standalone envelopes carry, emitted on the
58    /// combined root and omitted when empty. The root is the only carrier, so
59    /// a run that skips a section still reports them (issue #2366).
60    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
61    /// Suggested follow-up commands for the consumer.
62    pub next_steps: Vec<NextStep>,
63    /// Whether the root envelope carries a `kind` discriminant.
64    pub envelope_mode: RootEnvelopeMode,
65    /// Analysis run id stamped into telemetry metadata when present.
66    pub telemetry_analysis_run_id: Option<&'a str>,
67}
68
69/// Build and serialize bare combined JSON through the API output boundary.
70///
71/// # Errors
72///
73/// Returns a serde error when any typed section cannot be converted to JSON.
74pub 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
150/// Build a combined duplication section without adding a nested root envelope.
151///
152/// # Errors
153///
154/// Returns a serde error when the typed duplication payload cannot be
155/// serialized.
156pub 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
169/// Build a combined health section without adding a nested root envelope.
170///
171/// # Errors
172///
173/// Returns a serde error when the typed health payload cannot be serialized.
174pub 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    /// Issue #2366: the combined root carries the diagnostics it is given,
379    /// root-relative like the standalone envelopes, and omits the array when
380    /// there are none. The `check` section never grows its own copy, so the
381    /// document has exactly one carrier.
382    #[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    /// Issue #2366: the carrier does not depend on which sections ran, so a
412    /// combined run without a `check` section (`--skip check`, `--only health`,
413    /// `--only dupes`) still reports the diagnostics.
414    #[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}