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    /// Applied refs for exact workspace package roots.
38    pub package_baselines: Vec<fallow_output::PackageBaselineStatus>,
39    /// Every gate this run evaluated, absent when it evaluated none. The
40    /// programmatic route runs no CLI-layer gate and leaves this `None`.
41    pub gate_outcomes: Option<fallow_output::GateOutcomes>,
42    /// Every narrowing or shaping request this run received, absent when it
43    /// was asked for nothing. The programmatic route resolves no CLI flag and
44    /// leaves this `None`; an entry whose `status` is not `applied` means the
45    /// report is wider than what was asked for.
46    pub request_outcomes: Option<fallow_output::RequestOutcomes>,
47
48    /// Dead-code section; `None` omits `check` from the envelope.
49    pub check: Option<CombinedCheckJsonSection<'a>>,
50    /// Duplication section; `None` omits `dupes` from the envelope.
51    pub dupes: Option<&'a DupesReportPayload>,
52    /// Health section; `None` omits `health` from the envelope.
53    pub health: Option<&'a HealthReport>,
54    /// Project root; its prefix is stripped from paths in every section.
55    pub root: &'a Path,
56    /// Total wall time across sections, emitted as the root `elapsed_ms`.
57    pub elapsed: Duration,
58    /// Emit per-section explain metadata under `meta`.
59    pub explain: bool,
60    /// Type-aware pass metadata, merged into the check section's meta even
61    /// when `explain` is off.
62    pub type_aware: Option<fallow_types::envelope::TypeAwareMeta>,
63    /// Workspace, source-discovery, and analysis-stage diagnostics for the
64    /// run: the same list the standalone envelopes carry, emitted on the
65    /// combined root and omitted when empty. The root is the only carrier, so
66    /// a run that skips a section still reports them (issue #2366).
67    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
68    /// Suggested follow-up commands for the consumer.
69    pub next_steps: Vec<NextStep>,
70    /// Analysis run id stamped into telemetry metadata when present.
71    pub telemetry_analysis_run_id: Option<&'a str>,
72}
73
74/// Build and serialize bare combined JSON through the API output boundary.
75///
76/// # Errors
77///
78/// Returns a serde error when any typed section cannot be converted to JSON.
79pub 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        package_baselines: input.package_baselines,
119        schema_version: SchemaVersion(COMBINED_SCHEMA_VERSION),
120        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
121        elapsed_ms: ElapsedMs(elapsed_ms_for_output(input.elapsed)),
122        gate_outcomes: input.gate_outcomes,
123        request_outcomes: input.request_outcomes,
124        meta,
125        check,
126        dupes,
127        health,
128        workspace_diagnostics: input.workspace_diagnostics,
129        next_steps: input.next_steps,
130    };
131
132    let mut value = serialize_combined_json_output(output, input.telemetry_analysis_run_id)?;
133    if let Some(diagnostics) = value.get_mut("workspace_diagnostics") {
134        strip_root_prefix(diagnostics, &format!("{}/", input.root.display()));
135    }
136    Ok(value)
137}
138
139fn serialize_combined_check_json(
140    section: CombinedCheckJsonSection<'_>,
141    results: &AnalysisResults,
142) -> Result<serde_json::Value, serde_json::Error> {
143    serialize_check_json_payload(CheckJsonPayloadInput {
144        results,
145        root: section.root,
146        elapsed: section.elapsed,
147        config_fixable: section.config_fixable,
148        extras: section.extras,
149        workspace_diagnostics: Vec::new(),
150    })
151}
152
153/// Build a combined duplication section without adding a nested root envelope.
154///
155/// # Errors
156///
157/// Returns a serde error when the typed duplication payload cannot be
158/// serialized.
159pub fn serialize_combined_dupes_json(
160    dupes: Option<&DupesReportPayload>,
161    root: &Path,
162) -> Result<Option<serde_json::Value>, serde_json::Error> {
163    let Some(payload) = dupes else {
164        return Ok(None);
165    };
166    let mut json = serde_json::to_value(payload)?;
167    let root_prefix = format!("{}/", root.display());
168    strip_root_prefix(&mut json, &root_prefix);
169    Ok(Some(json))
170}
171
172/// Build a combined health section without adding a nested root envelope.
173///
174/// # Errors
175///
176/// Returns a serde error when the typed health payload cannot be serialized.
177pub fn serialize_combined_health_json(
178    health: Option<&HealthReport>,
179    root: &Path,
180) -> Result<Option<serde_json::Value>, serde_json::Error> {
181    let Some(report) = health else {
182        return Ok(None);
183    };
184    let mut json = serde_json::to_value(report)?;
185    let root_prefix = format!("{}/", root.display());
186    strip_root_prefix(&mut json, &root_prefix);
187    Ok(Some(json))
188}
189
190fn elapsed_ms_for_output(elapsed: Duration) -> u64 {
191    u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
192}
193
194fn combined_meta_for_output(
195    include_check: bool,
196    include_dupes: bool,
197    include_health: bool,
198) -> CombinedMeta {
199    CombinedMeta {
200        check: include_check.then(check_meta),
201        dupes: include_dupes.then(dupes_meta),
202        health: include_health.then(health_meta),
203        telemetry: None,
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use std::time::Duration;
210
211    use fallow_output::{
212        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
213    };
214    use fallow_types::output_dead_code::UnusedExportFinding;
215    use fallow_types::output_health::{HealthFindingAction, HealthFindingActionType};
216    use fallow_types::results::{AnalysisResults, UnusedExport};
217    use fallow_types::workspace::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
218
219    use super::{CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_json};
220
221    #[test]
222    fn combined_json_root_contains_stable_envelope_fields() {
223        let root = serialize_combined_json(CombinedJsonOutputInput {
224            package_baselines: Vec::new(),
225            gate_outcomes: None,
226            request_outcomes: None,
227            check: None,
228            dupes: None,
229            health: None,
230            root: std::path::Path::new("."),
231            elapsed: Duration::from_millis(42),
232            explain: false,
233            type_aware: None,
234            workspace_diagnostics: Vec::new(),
235            next_steps: Vec::new(),
236            telemetry_analysis_run_id: None,
237        })
238        .expect("combined JSON root");
239
240        assert_eq!(
241            root.get("kind").and_then(serde_json::Value::as_str),
242            Some("combined")
243        );
244        assert_eq!(
245            root.get("elapsed_ms").and_then(serde_json::Value::as_u64),
246            Some(42)
247        );
248        assert!(root.get("schema_version").is_some());
249        assert!(root.get("version").is_some());
250    }
251
252    #[test]
253    fn combined_json_harmonizes_dead_code_and_health_suppress_actions_before_serialization() {
254        let root = std::path::Path::new("/project");
255        let path = root.join("src/shared.ts");
256        let mut results = AnalysisResults::default();
257        results
258            .unused_exports
259            .push(UnusedExportFinding::with_actions(UnusedExport {
260                path: path.clone(),
261                export_name: "value".to_string(),
262                is_type_only: false,
263                line: 7,
264                col: 0,
265                span_start: 0,
266                is_re_export: false,
267                deprecated: false,
268                deprecated_reason: None,
269            }));
270        let health = HealthReport {
271            findings: vec![HealthFinding::new(
272                ComplexityViolation {
273                    path,
274                    name: "expensive".to_string(),
275                    line: 7,
276                    col: 0,
277                    cyclomatic: 22,
278                    cognitive: 18,
279                    line_count: 40,
280                    param_count: 1,
281                    react_hook_count: 0,
282                    react_jsx_max_depth: 0,
283                    react_prop_count: 0,
284                    react_hook_profile: None,
285                    exceeded: ExceededThreshold::Both,
286                    effective_severity: None,
287                    severity: FindingSeverity::High,
288                    crap: None,
289                    coverage_pct: None,
290                    coverage_tier: None,
291                    coverage_source: None,
292                    inherited_from: None,
293                    component_rollup: None,
294                    contributions: Vec::new(),
295                    effective_thresholds: None,
296                    threshold_source: None,
297                },
298                vec![HealthFindingAction {
299                    kind: HealthFindingActionType::SuppressLine,
300                    auto_fixable: false,
301                    description: "Suppress with an inline comment above the function declaration"
302                        .to_string(),
303                    note: None,
304                    comment: Some("// fallow-ignore-next-line complexity".to_string()),
305                    placement: Some("above-function-declaration".to_string()),
306                    target_path: None,
307                }],
308                None,
309            )],
310            ..HealthReport::default()
311        };
312
313        let output = serialize_combined_json(CombinedJsonOutputInput {
314            package_baselines: Vec::new(),
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            telemetry_analysis_run_id: None,
333        })
334        .expect("combined JSON");
335
336        assert_eq!(
337            output["check"]["unused_exports"][0]["actions"][1]["comment"],
338            "// fallow-ignore-next-line unused-export, complexity"
339        );
340        assert_eq!(
341            output["health"]["findings"][0]["actions"][0]["comment"],
342            "// fallow-ignore-next-line unused-export, complexity"
343        );
344    }
345
346    fn combined_json_with_diagnostics(
347        root: &std::path::Path,
348        include_check: bool,
349        workspace_diagnostics: Vec<WorkspaceDiagnostic>,
350    ) -> serde_json::Value {
351        let results = AnalysisResults::default();
352        serialize_combined_json(CombinedJsonOutputInput {
353            package_baselines: Vec::new(),
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            telemetry_analysis_run_id: None,
372        })
373        .expect("combined JSON")
374    }
375
376    fn malformed_yaml_diagnostic(root: &std::path::Path) -> WorkspaceDiagnostic {
377        WorkspaceDiagnostic::new(
378            root,
379            root.join("pnpm-workspace.yaml"),
380            WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
381                error: "could not find expected ':'".to_owned(),
382            },
383        )
384    }
385
386    /// Issue #2366: the combined root carries the diagnostics it is given,
387    /// root-relative like the standalone envelopes, and omits the array when
388    /// there are none. The `check` section never grows its own copy, so the
389    /// document has exactly one carrier.
390    #[test]
391    fn combined_root_carries_workspace_diagnostics_root_relative_or_omits_them() {
392        let root = std::path::Path::new("/project");
393        let output =
394            combined_json_with_diagnostics(root, true, vec![malformed_yaml_diagnostic(root)]);
395        assert_eq!(
396            output["workspace_diagnostics"][0]["kind"],
397            "malformed-pnpm-workspace-yaml"
398        );
399        assert_eq!(
400            output["workspace_diagnostics"][0]["path"],
401            "pnpm-workspace.yaml"
402        );
403        assert!(
404            output["check"].is_object(),
405            "the check section is present, so the absence check below is not vacuous: {output}"
406        );
407        assert!(
408            output["check"].get("workspace_diagnostics").is_none(),
409            "the check section is not a second carrier: {output}"
410        );
411
412        let empty = combined_json_with_diagnostics(root, true, Vec::new());
413        assert!(
414            empty.get("workspace_diagnostics").is_none(),
415            "an empty list is omitted from the combined root: {empty}"
416        );
417    }
418
419    /// Issue #2366: the carrier does not depend on which sections ran, so a
420    /// combined run without a `check` section (`--skip check`, `--only health`,
421    /// `--only dupes`) still reports the diagnostics.
422    #[test]
423    fn combined_root_carries_workspace_diagnostics_without_a_check_section() {
424        let root = std::path::Path::new("/project");
425        let output =
426            combined_json_with_diagnostics(root, false, vec![malformed_yaml_diagnostic(root)]);
427        assert!(
428            output.get("check").is_none(),
429            "this run has no check section: {output}"
430        );
431        assert_eq!(
432            output["workspace_diagnostics"][0]["kind"],
433            "malformed-pnpm-workspace-yaml"
434        );
435        assert_eq!(
436            output["workspace_diagnostics"][0]["path"],
437            "pnpm-workspace.yaml"
438        );
439    }
440}