Skip to main content

fallow_output/
feature_flags.rs

1//! Feature flag output contracts.
2
3use std::path::Path;
4use std::time::Duration;
5
6use fallow_types::envelope::{ElapsedMs, SchemaVersion, TelemetryMeta, ToolVersion};
7use fallow_types::results::{FeatureFlag, FlagConfidence, FlagKind};
8use serde::Serialize;
9
10use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
11
12/// Inputs for building `fallow flags --format json`.
13pub struct FeatureFlagsOutputInput<'a> {
14    pub schema_version: u32,
15    pub version: String,
16    pub elapsed: Duration,
17    pub flags: &'a [FeatureFlag],
18    pub root: &'a Path,
19    pub meta: Option<FeatureFlagsMeta>,
20}
21
22/// Envelope emitted by `fallow flags --format json`.
23#[derive(Debug, Clone, Serialize)]
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25#[cfg_attr(feature = "schema", schemars(title = "fallow flags --format json"))]
26pub struct FeatureFlagsOutput {
27    pub schema_version: SchemaVersion,
28    pub version: ToolVersion,
29    pub elapsed_ms: ElapsedMs,
30    pub feature_flags: Vec<FeatureFlagFinding>,
31    pub total_flags: usize,
32    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
33    pub meta: Option<FeatureFlagsMeta>,
34}
35
36/// One feature flag finding in JSON output.
37#[derive(Debug, Clone, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39pub struct FeatureFlagFinding {
40    pub path: String,
41    pub flag_name: String,
42    pub kind: FeatureFlagKind,
43    pub confidence: FeatureFlagConfidence,
44    pub line: u32,
45    pub col: u32,
46    pub actions: Vec<FeatureFlagAction>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub sdk_name: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub dead_code_overlap: Option<FeatureFlagDeadCodeOverlap>,
51}
52
53/// Feature flag kind values emitted in JSON.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[serde(rename_all = "snake_case")]
57pub enum FeatureFlagKind {
58    EnvironmentVariable,
59    SdkCall,
60    ConfigObject,
61}
62
63/// Feature flag confidence values emitted in JSON.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[serde(rename_all = "lowercase")]
67pub enum FeatureFlagConfidence {
68    High,
69    Medium,
70    Low,
71}
72
73/// Per-finding action emitted for feature flag findings.
74#[derive(Debug, Clone, Serialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76pub struct FeatureFlagAction {
77    #[serde(rename = "type")]
78    pub kind: FeatureFlagActionType,
79    pub auto_fixable: bool,
80    pub description: String,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub comment: Option<String>,
83}
84
85/// Feature flag action discriminants.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88#[serde(rename_all = "kebab-case")]
89pub enum FeatureFlagActionType {
90    InvestigateFlag,
91    SuppressLine,
92}
93
94/// Dead-code overlap block attached when a flag guards unused exports.
95#[derive(Debug, Clone, Serialize)]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97pub struct FeatureFlagDeadCodeOverlap {
98    pub guarded_lines: u32,
99    pub dead_export_count: usize,
100    pub dead_exports: Vec<String>,
101}
102
103/// Optional `_meta` block for [`FeatureFlagsOutput`]. Both fields are optional
104/// because the two contributors are independent: `feature_flags` details are
105/// present only with `--explain`, and `telemetry` is injected post-pass by
106/// [`attach_telemetry_meta`] whenever an analysis run id is available (which is
107/// the default path). Mirrors `Meta` / `CombinedMeta`, which also model
108/// `telemetry` as an optional, never-required property.
109#[derive(Debug, Clone, Serialize)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111pub struct FeatureFlagsMeta {
112    /// Feature-flag detection explanations, emitted only with `--explain`.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub feature_flags: Option<FeatureFlagsMetaDetails>,
115    /// Local telemetry correlation metadata for agent follow-up runs.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub telemetry: Option<TelemetryMeta>,
118}
119
120/// Feature flag explanatory metadata.
121#[derive(Debug, Clone, Serialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct FeatureFlagsMetaDetails {
124    pub description: &'static str,
125    pub kinds: FeatureFlagsKindMeta,
126    pub confidence: FeatureFlagsConfidenceMeta,
127    pub docs: &'static str,
128}
129
130/// Feature flag kind explanations.
131#[derive(Debug, Clone, Serialize)]
132#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
133pub struct FeatureFlagsKindMeta {
134    pub environment_variable: &'static str,
135    pub sdk_call: &'static str,
136    pub config_object: &'static str,
137}
138
139/// Feature flag confidence explanations.
140#[derive(Debug, Clone, Serialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142pub struct FeatureFlagsConfidenceMeta {
143    pub high: &'static str,
144    pub medium: &'static str,
145    pub low: &'static str,
146}
147
148/// Build the typed feature flags output envelope.
149#[must_use]
150pub fn build_feature_flags_output(input: FeatureFlagsOutputInput<'_>) -> FeatureFlagsOutput {
151    let feature_flags = input
152        .flags
153        .iter()
154        .map(|flag| feature_flag_finding(flag, input.root))
155        .collect();
156    FeatureFlagsOutput {
157        schema_version: SchemaVersion(input.schema_version),
158        version: ToolVersion(input.version),
159        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
160        feature_flags,
161        total_flags: input.flags.len(),
162        meta: input.meta,
163    }
164}
165
166/// Serialize `fallow flags --format json`.
167///
168/// # Errors
169///
170/// Returns a serde error when the feature flags output cannot be converted to
171/// JSON.
172pub fn serialize_feature_flags_json_output(
173    output: FeatureFlagsOutput,
174    mode: RootEnvelopeMode,
175    analysis_run_id: Option<&str>,
176) -> Result<serde_json::Value, serde_json::Error> {
177    let mut value = serialize_named_json_output(output, "feature-flags", mode)?;
178    attach_telemetry_meta(&mut value, analysis_run_id);
179    Ok(value)
180}
181
182/// Metadata emitted when `fallow flags --explain --format json` is requested.
183#[must_use]
184pub const fn feature_flags_meta() -> FeatureFlagsMeta {
185    FeatureFlagsMeta {
186        telemetry: None,
187        feature_flags: Some(FeatureFlagsMetaDetails {
188            description: "Feature flag patterns detected via AST analysis",
189            kinds: FeatureFlagsKindMeta {
190                environment_variable: "process.env.FEATURE_* pattern (high confidence)",
191                sdk_call: "Feature flag SDK function call (high confidence)",
192                config_object: "Config object property access matching flag keywords (low confidence, heuristic)",
193            },
194            confidence: FeatureFlagsConfidenceMeta {
195                high: "Unambiguous pattern match (env vars, direct SDK calls)",
196                medium: "Pattern match with some ambiguity",
197                low: "Heuristic match (config objects), may produce false positives",
198            },
199            docs: "https://docs.fallow.tools/cli/flags",
200        }),
201    }
202}
203
204fn feature_flag_finding(flag: &FeatureFlag, root: &Path) -> FeatureFlagFinding {
205    let path = flag
206        .path
207        .strip_prefix(root)
208        .unwrap_or(&flag.path)
209        .to_string_lossy()
210        .replace('\\', "/");
211    FeatureFlagFinding {
212        path,
213        flag_name: flag.flag_name.clone(),
214        kind: feature_flag_kind(flag.kind),
215        confidence: feature_flag_confidence(flag.confidence),
216        line: flag.line,
217        col: flag.col,
218        actions: feature_flag_actions(&flag.flag_name),
219        sdk_name: flag.sdk_name.clone(),
220        dead_code_overlap: feature_flag_dead_code_overlap(flag),
221    }
222}
223
224const fn feature_flag_kind(kind: FlagKind) -> FeatureFlagKind {
225    match kind {
226        FlagKind::EnvironmentVariable => FeatureFlagKind::EnvironmentVariable,
227        FlagKind::SdkCall => FeatureFlagKind::SdkCall,
228        FlagKind::ConfigObject => FeatureFlagKind::ConfigObject,
229    }
230}
231
232const fn feature_flag_confidence(confidence: FlagConfidence) -> FeatureFlagConfidence {
233    match confidence {
234        FlagConfidence::High => FeatureFlagConfidence::High,
235        FlagConfidence::Medium => FeatureFlagConfidence::Medium,
236        FlagConfidence::Low => FeatureFlagConfidence::Low,
237    }
238}
239
240fn feature_flag_actions(flag_name: &str) -> Vec<FeatureFlagAction> {
241    vec![
242        FeatureFlagAction {
243            kind: FeatureFlagActionType::InvestigateFlag,
244            auto_fixable: false,
245            description: format!("Verify whether feature flag '{flag_name}' is still active"),
246            comment: None,
247        },
248        FeatureFlagAction {
249            kind: FeatureFlagActionType::SuppressLine,
250            auto_fixable: false,
251            description: "Suppress with an inline comment".to_string(),
252            comment: Some("// fallow-ignore-next-line feature-flag".to_string()),
253        },
254    ]
255}
256
257fn feature_flag_dead_code_overlap(flag: &FeatureFlag) -> Option<FeatureFlagDeadCodeOverlap> {
258    if flag.guarded_dead_exports.is_empty() {
259        return None;
260    }
261    let guarded_lines = flag
262        .guard_line_start
263        .and_then(|start| flag.guard_line_end.map(|end| end.saturating_sub(start) + 1))
264        .unwrap_or(0);
265    Some(FeatureFlagDeadCodeOverlap {
266        guarded_lines,
267        dead_export_count: flag.guarded_dead_exports.len(),
268        dead_exports: flag.guarded_dead_exports.clone(),
269    })
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use std::path::PathBuf;
276
277    fn flag() -> FeatureFlag {
278        FeatureFlag {
279            path: PathBuf::from("/repo/src/app.ts"),
280            flag_name: "FEATURE_CHECKOUT".to_string(),
281            kind: FlagKind::EnvironmentVariable,
282            confidence: FlagConfidence::High,
283            line: 10,
284            col: 4,
285            guard_span_start: None,
286            guard_span_end: None,
287            sdk_name: None,
288            guard_line_start: Some(10),
289            guard_line_end: Some(12),
290            guarded_dead_exports: vec!["legacyCheckout".to_string()],
291        }
292    }
293
294    #[test]
295    fn feature_flags_json_output_uses_output_owned_root_contract() {
296        let output = build_feature_flags_output(FeatureFlagsOutputInput {
297            schema_version: 7,
298            version: "0.0.0".to_string(),
299            elapsed: Duration::from_millis(4),
300            flags: &[flag()],
301            root: Path::new("/repo"),
302            meta: Some(feature_flags_meta()),
303        });
304
305        let value = serialize_feature_flags_json_output(
306            output,
307            RootEnvelopeMode::Tagged,
308            Some("run-flags"),
309        )
310        .expect("feature flags output should serialize");
311
312        assert_eq!(value["kind"], "feature-flags");
313        assert_eq!(value["feature_flags"][0]["path"], "src/app.ts");
314        assert_eq!(
315            value["feature_flags"][0]["dead_code_overlap"]["guarded_lines"],
316            3
317        );
318        assert_eq!(
319            value["_meta"]["feature_flags"]["docs"],
320            "https://docs.fallow.tools/cli/flags"
321        );
322        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-flags");
323    }
324
325    #[test]
326    fn feature_flags_json_output_without_explain_emits_telemetry_only_meta() {
327        // The default path (no --explain) leaves `meta` as None, so the only
328        // `_meta` contributor is the post-pass telemetry injection. The typed
329        // `FeatureFlagsMeta` must model this telemetry-only shape (both fields
330        // optional) so the emitted document conforms to the published schema.
331        let output = build_feature_flags_output(FeatureFlagsOutputInput {
332            schema_version: 7,
333            version: "0.0.0".to_string(),
334            elapsed: Duration::from_millis(4),
335            flags: &[flag()],
336            root: Path::new("/repo"),
337            meta: None,
338        });
339
340        let value = serialize_feature_flags_json_output(
341            output,
342            RootEnvelopeMode::Tagged,
343            Some("run-flags"),
344        )
345        .expect("feature flags output should serialize");
346
347        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-flags");
348        assert!(
349            value["_meta"].get("feature_flags").is_none(),
350            "feature_flags details are absent without --explain"
351        );
352    }
353}