fallow_output/health.rs
1use std::time::Duration;
2
3use fallow_types::envelope::{ElapsedMs, Meta, SchemaVersion, ToolVersion};
4use fallow_types::output::NextStep;
5use serde::Serialize;
6
7use fallow_types::workspace::WorkspaceDiagnostic;
8
9use crate::root_envelopes::apply_root_kind;
10use crate::{GroupByMode, attach_telemetry_meta, strip_root_prefix};
11
12/// Current schema version for the standalone health JSON envelope.
13///
14/// Version 11 expands the required semantic omission reason-code enum embedded
15/// by type-aware metadata.
16pub const HEALTH_SCHEMA_VERSION: u32 = 11;
17
18/// Exact schema version for [`HealthOutput`].
19#[cfg(feature = "schema")]
20#[allow(dead_code, reason = "schema-only type used by the field projection")]
21#[derive(schemars::JsonSchema)]
22#[schemars(extend("const" = HEALTH_SCHEMA_VERSION))]
23struct HealthSchemaVersion(u32);
24
25/// Envelope emitted by `fallow health --format json` (plus the `health` block
26/// inside the combined and audit envelopes).
27///
28/// The body is `HealthReport` flattened into the envelope so every report
29/// field (`findings`, `summary`, `vital_signs`, `hotspots`, `actions_meta`,
30/// ...) lives at the top level. Grouped runs populate `grouped_by` +
31/// `groups` with per-bucket recomputed metrics. The `actions_meta`
32/// breadcrumb is modeled on `HealthReport` as an `Option<HealthActionsMeta>`
33/// and is set at construction time by the report builder when the active
34/// `HealthActionContext` requests suppress-line omission, so the schema
35/// documents the field and serde populates it natively.
36#[derive(Debug, Clone, Serialize)]
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38#[cfg_attr(feature = "schema", schemars(title = "fallow health --format json"))]
39pub struct HealthOutput<Report, Group> {
40 /// Health output schema version.
41 #[cfg_attr(feature = "schema", schemars(with = "HealthSchemaVersion"))]
42 pub schema_version: SchemaVersion,
43 /// Fallow CLI version that produced this output.
44 pub version: ToolVersion,
45 /// Wall-clock analysis duration in milliseconds.
46 pub elapsed_ms: ElapsedMs,
47 /// Health report body, flattened into the envelope root.
48 #[serde(flatten)]
49 pub report: Report,
50 /// Grouping mode when `--group-by` was passed.
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub grouped_by: Option<GroupByMode>,
53 /// Per-bucket recomputed metrics; present only in grouped output.
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub groups: Option<Vec<Group>>,
56 /// The `--group` selector patterns, as given, when the run kept only some
57 /// groups. A group that is not in `groups` was filtered out by this
58 /// selector. The project-level sections are not filtered.
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub group_filter: Option<Vec<String>>,
61 /// The verdict of every gate this run evaluated, keyed by name. The CLI
62 /// always emits it, with the command's default exit rule in it also when
63 /// no flag armed a gate, so a CI integration reads the verdict instead of
64 /// guessing from a process status it usually cannot see. A gate fails the
65 /// build when `status` is `fail` AND `enforced` is true. The typed
66 /// programmatic API runs no CLI gate and leaves it absent. See
67 /// [`crate::GateOutcomes`].
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub gate_outcomes: Option<crate::GateOutcomes>,
70 /// Every narrowing or shaping request this run RECEIVED, keyed by name,
71 /// absent when it was asked for nothing. An entry whose `status` is not
72 /// `applied` means the run could not do what it was asked and reported
73 /// something WIDER instead, so what follows is a valid report of a scope
74 /// nobody requested. Honoured requests are published too, with
75 /// `status: "applied"`, so an absent object means "nothing was asked for",
76 /// never "nothing failed". See [`crate::RequestOutcomes`].
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub request_outcomes: Option<crate::RequestOutcomes>,
79 /// `_meta` block with metric definitions, when `--explain` was passed.
80 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
81 pub meta: Option<Meta>,
82 /// Workspace-discovery, source-discovery, and analysis-stage diagnostics
83 /// for the run. See `CheckOutput::workspace_diagnostics` for the full
84 /// contract: the kinds each stage records, project-root-relative paths,
85 /// omitted when empty.
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
88 /// Read-only follow-up commands computed from this run's findings. See
89 /// `CheckOutput::next_steps` for the contract.
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub next_steps: Vec<NextStep>,
92}
93
94/// Inputs for constructing a [`HealthOutput`] without exposing envelope
95/// assembly details to callers.
96#[derive(Debug, Clone)]
97pub struct HealthOutputInput<Report, Group> {
98 /// Health output schema version to report.
99 pub schema_version: u32,
100 /// Fallow CLI version to report.
101 pub version: String,
102 /// Wall-clock analysis duration; serialized as whole milliseconds.
103 pub elapsed: Duration,
104 /// Health report body to flatten into the envelope root.
105 pub report: Report,
106 /// Grouping mode when `--group-by` was passed.
107 pub grouped_by: Option<GroupByMode>,
108 /// Per-bucket recomputed metrics, for grouped output.
109 pub groups: Option<Vec<Group>>,
110 /// The `--group` selector patterns, for grouped output.
111 pub group_filter: Option<Vec<String>>,
112 /// Every gate this run evaluated, absent when it evaluated none.
113 pub gate_outcomes: Option<crate::GateOutcomes>,
114 /// Every narrowing or shaping request this run received, absent when it
115 /// was asked for nothing.
116 pub request_outcomes: Option<crate::RequestOutcomes>,
117 /// `_meta` block to attach when `--explain` was passed.
118 pub meta: Option<Meta>,
119 /// Workspace-discovery, source-discovery, and analysis-stage diagnostics.
120 /// See `CheckOutput::workspace_diagnostics` for the contract.
121 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
122 /// Read-only follow-up commands computed from this run's findings.
123 pub next_steps: Vec<NextStep>,
124}
125
126/// Inputs for serializing a health report into the root JSON contract.
127#[derive(Debug, Clone)]
128pub struct HealthJsonOutputInput<'a, Report, Group> {
129 /// Envelope construction inputs.
130 pub output: HealthOutputInput<Report, Group>,
131 /// Absolute root prefix to strip from every emitted path, when set.
132 pub root_prefix: Option<&'a str>,
133 /// Root discriminator policy.
134 /// Telemetry run id to attach under `_meta.telemetry`, when available.
135 pub analysis_run_id: Option<&'a str>,
136}
137
138/// Build a health JSON envelope from caller-owned report data.
139#[must_use]
140pub fn build_health_output<Report, Group>(
141 input: HealthOutputInput<Report, Group>,
142) -> HealthOutput<Report, Group> {
143 HealthOutput {
144 schema_version: SchemaVersion(input.schema_version),
145 version: ToolVersion(input.version),
146 elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
147 report: input.report,
148 grouped_by: input.grouped_by,
149 groups: input.groups,
150 group_filter: input.group_filter,
151 gate_outcomes: input.gate_outcomes,
152 request_outcomes: input.request_outcomes,
153 meta: input.meta,
154 workspace_diagnostics: input.workspace_diagnostics,
155 next_steps: input.next_steps,
156 }
157}
158
159/// Build and serialize a health root JSON envelope.
160///
161/// This keeps the health contract serialization in `fallow-output` while
162/// callers still own report assembly, workspace diagnostics, and follow-up
163/// suggestion policy.
164///
165/// # Errors
166///
167/// Returns a serde error when the provided report or group payload cannot be
168/// converted to JSON.
169pub fn serialize_health_json_output<Report, Group>(
170 input: HealthJsonOutputInput<'_, Report, Group>,
171) -> Result<serde_json::Value, serde_json::Error>
172where
173 Report: Serialize,
174 Group: Serialize,
175{
176 let envelope = build_health_output(input.output);
177 let mut output = serde_json::to_value(envelope)?;
178 apply_root_kind(&mut output, "health");
179 if let Some(root_prefix) = input.root_prefix {
180 strip_root_prefix(&mut output, root_prefix);
181 }
182 attach_telemetry_meta(&mut output, input.analysis_run_id);
183 Ok(output)
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn serialize_health_json_output_tags_and_strips_root_paths() {
192 let output = serialize_health_json_output(HealthJsonOutputInput {
193 output: HealthOutputInput {
194 gate_outcomes: None,
195 request_outcomes: None,
196 schema_version: 7,
197 version: "test".to_string(),
198 elapsed: Duration::ZERO,
199 report: serde_json::json!({ "findings": [{ "path": "/repo/src/a.ts" }] }),
200 grouped_by: None,
201 groups: None::<Vec<serde_json::Value>>,
202 group_filter: None,
203 meta: None,
204 workspace_diagnostics: Vec::new(),
205 next_steps: Vec::new(),
206 },
207 root_prefix: Some("/repo/"),
208 analysis_run_id: Some("run-health"),
209 })
210 .expect("health output should serialize");
211
212 assert_eq!(output["kind"], "health");
213 assert_eq!(output["findings"][0]["path"], "src/a.ts");
214 assert_eq!(
215 output["_meta"]["telemetry"]["analysis_run_id"],
216 "run-health"
217 );
218 }
219}