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    /// 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    /// Whether the root envelope carries a `kind` discriminant.
69    pub envelope_mode: RootEnvelopeMode,
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        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
156/// Build a combined duplication section without adding a nested root envelope.
157///
158/// # Errors
159///
160/// Returns a serde error when the typed duplication payload cannot be
161/// serialized.
162pub 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
175/// Build a combined health section without adding a nested root envelope.
176///
177/// # Errors
178///
179/// Returns a serde error when the typed health payload cannot be serialized.
180pub 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    /// Issue #2366: the combined root carries the diagnostics it is given,
388    /// root-relative like the standalone envelopes, and omits the array when
389    /// there are none. The `check` section never grows its own copy, so the
390    /// document has exactly one carrier.
391    #[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    /// Issue #2366: the carrier does not depend on which sections ran, so a
421    /// combined run without a `check` section (`--skip check`, `--only health`,
422    /// `--only dupes`) still reports the diagnostics.
423    #[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}