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, 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
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    /// Every narrowing or shaping request this run received, absent when it
41    /// was asked for nothing. The programmatic route resolves no CLI flag and
42    /// leaves this `None`; an entry whose `status` is not `applied` means the
43    /// report is wider than what was asked for.
44    pub request_outcomes: Option<fallow_output::RequestOutcomes>,
45
46    /// Dead-code section; `None` omits `check` from the envelope.
47    pub check: Option<CombinedCheckJsonSection<'a>>,
48    /// Duplication section; `None` omits `dupes` from the envelope.
49    pub dupes: Option<&'a DupesReportPayload>,
50    /// Health section; `None` omits `health` from the envelope.
51    pub health: Option<&'a HealthReport>,
52    /// Project root; its prefix is stripped from paths in every section.
53    pub root: &'a Path,
54    /// Total wall time across sections, emitted as the root `elapsed_ms`.
55    pub elapsed: Duration,
56    /// Emit per-section explain metadata under `meta`.
57    pub explain: bool,
58    /// Type-aware pass metadata, merged into the check section's meta even
59    /// when `explain` is off.
60    pub type_aware: Option<fallow_types::envelope::TypeAwareMeta>,
61    /// Workspace, source-discovery, and analysis-stage diagnostics for the
62    /// run: the same list the standalone envelopes carry, emitted on the
63    /// combined root and omitted when empty. The root is the only carrier, so
64    /// a run that skips a section still reports them (issue #2366).
65    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
66    /// Suggested follow-up commands for the consumer.
67    pub next_steps: Vec<NextStep>,
68    /// Analysis run id stamped into telemetry metadata when present.
69    pub telemetry_analysis_run_id: Option<&'a str>,
70}
71
72/// Build and serialize bare combined JSON through the API output boundary.
73///
74/// # Errors
75///
76/// Returns a serde error when any typed section cannot be converted to JSON.
77pub 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
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    };
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    /// Issue #2366: the combined root carries the diagnostics it is given,
381    /// root-relative like the standalone envelopes, and omits the array when
382    /// there are none. The `check` section never grows its own copy, so the
383    /// document has exactly one carrier.
384    #[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    /// Issue #2366: the carrier does not depend on which sections ran, so a
414    /// combined run without a `check` section (`--skip check`, `--only health`,
415    /// `--only dupes`) still reports the diagnostics.
416    #[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}