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, 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/// `_meta.feature_flags` details emitted with `--explain`.
104#[derive(Debug, Clone, Serialize)]
105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
106pub struct FeatureFlagsMeta {
107    pub feature_flags: FeatureFlagsMetaDetails,
108}
109
110/// Feature flag explanatory metadata.
111#[derive(Debug, Clone, Serialize)]
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113pub struct FeatureFlagsMetaDetails {
114    pub description: &'static str,
115    pub kinds: FeatureFlagsKindMeta,
116    pub confidence: FeatureFlagsConfidenceMeta,
117    pub docs: &'static str,
118}
119
120/// Feature flag kind explanations.
121#[derive(Debug, Clone, Serialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct FeatureFlagsKindMeta {
124    pub environment_variable: &'static str,
125    pub sdk_call: &'static str,
126    pub config_object: &'static str,
127}
128
129/// Feature flag confidence explanations.
130#[derive(Debug, Clone, Serialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132pub struct FeatureFlagsConfidenceMeta {
133    pub high: &'static str,
134    pub medium: &'static str,
135    pub low: &'static str,
136}
137
138/// Build the typed feature flags output envelope.
139#[must_use]
140pub fn build_feature_flags_output(input: FeatureFlagsOutputInput<'_>) -> FeatureFlagsOutput {
141    let feature_flags = input
142        .flags
143        .iter()
144        .map(|flag| feature_flag_finding(flag, input.root))
145        .collect();
146    FeatureFlagsOutput {
147        schema_version: SchemaVersion(input.schema_version),
148        version: ToolVersion(input.version),
149        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
150        feature_flags,
151        total_flags: input.flags.len(),
152        meta: input.meta,
153    }
154}
155
156/// Serialize `fallow flags --format json`.
157///
158/// # Errors
159///
160/// Returns a serde error when the feature flags output cannot be converted to
161/// JSON.
162pub fn serialize_feature_flags_json_output(
163    output: FeatureFlagsOutput,
164    mode: RootEnvelopeMode,
165    analysis_run_id: Option<&str>,
166) -> Result<serde_json::Value, serde_json::Error> {
167    let mut value = serialize_named_json_output(output, "feature-flags", mode)?;
168    attach_telemetry_meta(&mut value, analysis_run_id);
169    Ok(value)
170}
171
172/// Metadata emitted when `fallow flags --explain --format json` is requested.
173#[must_use]
174pub const fn feature_flags_meta() -> FeatureFlagsMeta {
175    FeatureFlagsMeta {
176        feature_flags: FeatureFlagsMetaDetails {
177            description: "Feature flag patterns detected via AST analysis",
178            kinds: FeatureFlagsKindMeta {
179                environment_variable: "process.env.FEATURE_* pattern (high confidence)",
180                sdk_call: "Feature flag SDK function call (high confidence)",
181                config_object: "Config object property access matching flag keywords (low confidence, heuristic)",
182            },
183            confidence: FeatureFlagsConfidenceMeta {
184                high: "Unambiguous pattern match (env vars, direct SDK calls)",
185                medium: "Pattern match with some ambiguity",
186                low: "Heuristic match (config objects), may produce false positives",
187            },
188            docs: "https://docs.fallow.tools/cli/flags",
189        },
190    }
191}
192
193fn feature_flag_finding(flag: &FeatureFlag, root: &Path) -> FeatureFlagFinding {
194    let path = flag
195        .path
196        .strip_prefix(root)
197        .unwrap_or(&flag.path)
198        .to_string_lossy()
199        .replace('\\', "/");
200    FeatureFlagFinding {
201        path,
202        flag_name: flag.flag_name.clone(),
203        kind: feature_flag_kind(flag.kind),
204        confidence: feature_flag_confidence(flag.confidence),
205        line: flag.line,
206        col: flag.col,
207        actions: feature_flag_actions(&flag.flag_name),
208        sdk_name: flag.sdk_name.clone(),
209        dead_code_overlap: feature_flag_dead_code_overlap(flag),
210    }
211}
212
213const fn feature_flag_kind(kind: FlagKind) -> FeatureFlagKind {
214    match kind {
215        FlagKind::EnvironmentVariable => FeatureFlagKind::EnvironmentVariable,
216        FlagKind::SdkCall => FeatureFlagKind::SdkCall,
217        FlagKind::ConfigObject => FeatureFlagKind::ConfigObject,
218    }
219}
220
221const fn feature_flag_confidence(confidence: FlagConfidence) -> FeatureFlagConfidence {
222    match confidence {
223        FlagConfidence::High => FeatureFlagConfidence::High,
224        FlagConfidence::Medium => FeatureFlagConfidence::Medium,
225        FlagConfidence::Low => FeatureFlagConfidence::Low,
226    }
227}
228
229fn feature_flag_actions(flag_name: &str) -> Vec<FeatureFlagAction> {
230    vec![
231        FeatureFlagAction {
232            kind: FeatureFlagActionType::InvestigateFlag,
233            auto_fixable: false,
234            description: format!("Verify whether feature flag '{flag_name}' is still active"),
235            comment: None,
236        },
237        FeatureFlagAction {
238            kind: FeatureFlagActionType::SuppressLine,
239            auto_fixable: false,
240            description: "Suppress with an inline comment".to_string(),
241            comment: Some("// fallow-ignore-next-line feature-flag".to_string()),
242        },
243    ]
244}
245
246fn feature_flag_dead_code_overlap(flag: &FeatureFlag) -> Option<FeatureFlagDeadCodeOverlap> {
247    if flag.guarded_dead_exports.is_empty() {
248        return None;
249    }
250    let guarded_lines = flag
251        .guard_line_start
252        .and_then(|start| flag.guard_line_end.map(|end| end.saturating_sub(start) + 1))
253        .unwrap_or(0);
254    Some(FeatureFlagDeadCodeOverlap {
255        guarded_lines,
256        dead_export_count: flag.guarded_dead_exports.len(),
257        dead_exports: flag.guarded_dead_exports.clone(),
258    })
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::path::PathBuf;
265
266    fn flag() -> FeatureFlag {
267        FeatureFlag {
268            path: PathBuf::from("/repo/src/app.ts"),
269            flag_name: "FEATURE_CHECKOUT".to_string(),
270            kind: FlagKind::EnvironmentVariable,
271            confidence: FlagConfidence::High,
272            line: 10,
273            col: 4,
274            guard_span_start: None,
275            guard_span_end: None,
276            sdk_name: None,
277            guard_line_start: Some(10),
278            guard_line_end: Some(12),
279            guarded_dead_exports: vec!["legacyCheckout".to_string()],
280        }
281    }
282
283    #[test]
284    fn feature_flags_json_output_uses_output_owned_root_contract() {
285        let output = build_feature_flags_output(FeatureFlagsOutputInput {
286            schema_version: 7,
287            version: "0.0.0".to_string(),
288            elapsed: Duration::from_millis(4),
289            flags: &[flag()],
290            root: Path::new("/repo"),
291            meta: Some(feature_flags_meta()),
292        });
293
294        let value = serialize_feature_flags_json_output(
295            output,
296            RootEnvelopeMode::Tagged,
297            Some("run-flags"),
298        )
299        .expect("feature flags output should serialize");
300
301        assert_eq!(value["kind"], "feature-flags");
302        assert_eq!(value["feature_flags"][0]["path"], "src/app.ts");
303        assert_eq!(
304            value["feature_flags"][0]["dead_code_overlap"]["guarded_lines"],
305            3
306        );
307        assert_eq!(
308            value["_meta"]["feature_flags"]["docs"],
309            "https://docs.fallow.tools/cli/flags"
310        );
311        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-flags");
312    }
313}