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/// Current schema version for feature-flag JSON output.
13pub const FEATURE_FLAGS_SCHEMA_VERSION: u32 = 8;
14
15/// Schema projection for the feature-flags envelope's exact version.
16#[cfg(feature = "schema")]
17#[allow(dead_code, reason = "schema-only type used by the field projection")]
18#[derive(schemars::JsonSchema)]
19#[schemars(extend("const" = FEATURE_FLAGS_SCHEMA_VERSION))]
20struct FeatureFlagsSchemaVersion(u32);
21
22/// Inputs for building `fallow flags --format json`.
23pub struct FeatureFlagsOutputInput<'a> {
24    /// Flags output schema version to report.
25    pub schema_version: u32,
26    /// Fallow CLI version to report.
27    pub version: String,
28    /// Wall-clock analysis duration; serialized as whole milliseconds.
29    pub elapsed: Duration,
30    /// Detected flags from the engine.
31    pub flags: &'a [FeatureFlag],
32    /// Analysis root paths are relativized against.
33    pub root: &'a Path,
34    /// `_meta` block to attach when `--explain` was passed.
35    pub meta: Option<FeatureFlagsMeta>,
36}
37
38/// Envelope emitted by `fallow flags --format json`.
39#[derive(Debug, Clone, Serialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[cfg_attr(feature = "schema", schemars(title = "fallow flags --format json"))]
42pub struct FeatureFlagsOutput {
43    /// Flags output schema version.
44    #[cfg_attr(feature = "schema", schemars(with = "FeatureFlagsSchemaVersion"))]
45    pub schema_version: SchemaVersion,
46    /// Fallow CLI version that produced this output.
47    pub version: ToolVersion,
48    /// Wall-clock analysis duration in milliseconds.
49    pub elapsed_ms: ElapsedMs,
50    /// Detected feature-flag findings.
51    pub feature_flags: Vec<FeatureFlagFinding>,
52    /// Number of entries in `feature_flags`.
53    pub total_flags: usize,
54    /// `_meta` block; see [`FeatureFlagsMeta`].
55    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
56    pub meta: Option<FeatureFlagsMeta>,
57}
58
59/// One feature flag finding in JSON output.
60#[derive(Debug, Clone, Serialize)]
61#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
62pub struct FeatureFlagFinding {
63    /// File path relative to the analysed root.
64    pub path: String,
65    /// Detected flag identifier, e.g. the env var or SDK key name.
66    pub flag_name: String,
67    /// Detection pattern the flag matched.
68    pub kind: FeatureFlagKind,
69    /// How confident the detector is that this is a real feature flag.
70    pub confidence: FeatureFlagConfidence,
71    /// 1-based line of the flag usage.
72    pub line: u32,
73    /// 1-based column of the flag usage.
74    pub col: u32,
75    /// Suggested follow-up actions (investigate / suppress).
76    pub actions: Vec<FeatureFlagAction>,
77    /// Flag SDK the call belongs to, for SDK-call findings.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub sdk_name: Option<String>,
80    /// Overlap with dead-code findings when the flag guards unused exports.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub dead_code_overlap: Option<FeatureFlagDeadCodeOverlap>,
83}
84
85/// Feature flag kind values emitted in JSON.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88#[serde(rename_all = "snake_case")]
89pub enum FeatureFlagKind {
90    /// Environment-variable read used as a toggle.
91    EnvironmentVariable,
92    /// Feature-flag SDK evaluation call.
93    SdkCall,
94    /// Flag key in a configuration object literal.
95    ConfigObject,
96}
97
98/// Feature flag confidence values emitted in JSON.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101#[serde(rename_all = "lowercase")]
102pub enum FeatureFlagConfidence {
103    /// Strong flag signal, e.g. a known SDK call.
104    High,
105    /// Plausible flag signal with some ambiguity.
106    Medium,
107    /// Weak signal; likely needs human confirmation.
108    Low,
109}
110
111/// Per-finding action emitted for feature flag findings.
112#[derive(Debug, Clone, Serialize)]
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114pub struct FeatureFlagAction {
115    /// Action discriminator, serialized as `type`.
116    #[serde(rename = "type")]
117    pub kind: FeatureFlagActionType,
118    /// Whether `fallow fix` can apply the action automatically.
119    pub auto_fixable: bool,
120    /// Human-readable action description.
121    pub description: String,
122    /// Suppression comment to insert, for suppress actions.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub comment: Option<String>,
125}
126
127/// Feature flag action discriminants.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130#[serde(rename_all = "kebab-case")]
131pub enum FeatureFlagActionType {
132    /// Check whether the flag is still needed.
133    InvestigateFlag,
134    /// Suppress the finding with a `fallow-ignore` line comment.
135    SuppressLine,
136}
137
138/// Dead-code overlap block attached when a flag guards unused exports.
139#[derive(Debug, Clone, Serialize)]
140#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
141pub struct FeatureFlagDeadCodeOverlap {
142    /// Lines inside the flag-guarded region.
143    pub guarded_lines: u32,
144    /// Number of unused exports the flag guards.
145    pub dead_export_count: usize,
146    /// Names of the unused exports the flag guards.
147    pub dead_exports: Vec<String>,
148}
149
150/// Optional `_meta` block for [`FeatureFlagsOutput`]. Both fields are optional
151/// because the two contributors are independent: `feature_flags` details are
152/// present only with `--explain`, and `telemetry` is injected post-pass by
153/// [`attach_telemetry_meta`] whenever an analysis run id is available (which is
154/// the default path). Mirrors `Meta` / `CombinedMeta`, which also model
155/// `telemetry` as an optional, never-required property.
156#[derive(Debug, Clone, Serialize)]
157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
158pub struct FeatureFlagsMeta {
159    /// Feature-flag detection explanations, emitted only with `--explain`.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub feature_flags: Option<FeatureFlagsMetaDetails>,
162    /// Local telemetry correlation metadata for agent follow-up runs.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub telemetry: Option<TelemetryMeta>,
165}
166
167/// Feature flag explanatory metadata.
168#[derive(Debug, Clone, Serialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170pub struct FeatureFlagsMetaDetails {
171    /// What the flags command reports.
172    pub description: &'static str,
173    /// Explanation of each `kind` value.
174    pub kinds: FeatureFlagsKindMeta,
175    /// Explanation of each `confidence` value.
176    pub confidence: FeatureFlagsConfidenceMeta,
177    /// Public documentation URL for the flags command.
178    pub docs: &'static str,
179}
180
181/// Feature flag kind explanations.
182#[derive(Debug, Clone, Serialize)]
183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
184pub struct FeatureFlagsKindMeta {
185    /// Explanation of the `environment_variable` kind.
186    pub environment_variable: &'static str,
187    /// Explanation of the `sdk_call` kind.
188    pub sdk_call: &'static str,
189    /// Explanation of the `config_object` kind.
190    pub config_object: &'static str,
191}
192
193/// Feature flag confidence explanations.
194#[derive(Debug, Clone, Serialize)]
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196pub struct FeatureFlagsConfidenceMeta {
197    /// Explanation of the `high` confidence level.
198    pub high: &'static str,
199    /// Explanation of the `medium` confidence level.
200    pub medium: &'static str,
201    /// Explanation of the `low` confidence level.
202    pub low: &'static str,
203}
204
205/// Build the typed feature flags output envelope.
206#[must_use]
207pub fn build_feature_flags_output(input: FeatureFlagsOutputInput<'_>) -> FeatureFlagsOutput {
208    let feature_flags = input
209        .flags
210        .iter()
211        .map(|flag| feature_flag_finding(flag, input.root))
212        .collect();
213    FeatureFlagsOutput {
214        schema_version: SchemaVersion(input.schema_version),
215        version: ToolVersion(input.version),
216        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
217        feature_flags,
218        total_flags: input.flags.len(),
219        meta: input.meta,
220    }
221}
222
223/// Serialize `fallow flags --format json`.
224///
225/// # Errors
226///
227/// Returns a serde error when the feature flags output cannot be converted to
228/// JSON.
229pub fn serialize_feature_flags_json_output(
230    output: FeatureFlagsOutput,
231    mode: RootEnvelopeMode,
232    analysis_run_id: Option<&str>,
233) -> Result<serde_json::Value, serde_json::Error> {
234    let mut value = serialize_named_json_output(output, "feature-flags", mode)?;
235    attach_telemetry_meta(&mut value, analysis_run_id);
236    Ok(value)
237}
238
239/// Metadata emitted when `fallow flags --explain --format json` is requested.
240#[must_use]
241pub const fn feature_flags_meta() -> FeatureFlagsMeta {
242    FeatureFlagsMeta {
243        telemetry: None,
244        feature_flags: Some(FeatureFlagsMetaDetails {
245            description: "Feature flag patterns detected via AST analysis",
246            kinds: FeatureFlagsKindMeta {
247                environment_variable: "process.env.FEATURE_* pattern (high confidence)",
248                sdk_call: "Feature flag SDK function call (high confidence)",
249                config_object: "Config object property access matching flag keywords (low confidence, heuristic)",
250            },
251            confidence: FeatureFlagsConfidenceMeta {
252                high: "Unambiguous pattern match (env vars, direct SDK calls)",
253                medium: "Pattern match with some ambiguity",
254                low: "Heuristic match (config objects), may produce false positives",
255            },
256            docs: "https://docs.fallow.tools/cli/flags",
257        }),
258    }
259}
260
261fn feature_flag_finding(flag: &FeatureFlag, root: &Path) -> FeatureFlagFinding {
262    let path = flag
263        .path
264        .strip_prefix(root)
265        .unwrap_or(&flag.path)
266        .to_string_lossy()
267        .replace('\\', "/");
268    FeatureFlagFinding {
269        path,
270        flag_name: flag.flag_name.clone(),
271        kind: feature_flag_kind(flag.kind),
272        confidence: feature_flag_confidence(flag.confidence),
273        line: flag.line,
274        col: flag.col,
275        actions: feature_flag_actions(&flag.flag_name),
276        sdk_name: flag.sdk_name.clone(),
277        dead_code_overlap: feature_flag_dead_code_overlap(flag),
278    }
279}
280
281const fn feature_flag_kind(kind: FlagKind) -> FeatureFlagKind {
282    match kind {
283        FlagKind::EnvironmentVariable => FeatureFlagKind::EnvironmentVariable,
284        FlagKind::SdkCall => FeatureFlagKind::SdkCall,
285        FlagKind::ConfigObject => FeatureFlagKind::ConfigObject,
286    }
287}
288
289const fn feature_flag_confidence(confidence: FlagConfidence) -> FeatureFlagConfidence {
290    match confidence {
291        FlagConfidence::High => FeatureFlagConfidence::High,
292        FlagConfidence::Medium => FeatureFlagConfidence::Medium,
293        FlagConfidence::Low => FeatureFlagConfidence::Low,
294    }
295}
296
297fn feature_flag_actions(flag_name: &str) -> Vec<FeatureFlagAction> {
298    vec![
299        FeatureFlagAction {
300            kind: FeatureFlagActionType::InvestigateFlag,
301            auto_fixable: false,
302            description: format!("Verify whether feature flag '{flag_name}' is still active"),
303            comment: None,
304        },
305        FeatureFlagAction {
306            kind: FeatureFlagActionType::SuppressLine,
307            auto_fixable: false,
308            description: "Suppress with an inline comment".to_string(),
309            comment: Some("// fallow-ignore-next-line feature-flag".to_string()),
310        },
311    ]
312}
313
314fn feature_flag_dead_code_overlap(flag: &FeatureFlag) -> Option<FeatureFlagDeadCodeOverlap> {
315    if flag.guarded_dead_exports.is_empty() {
316        return None;
317    }
318    let guarded_lines = flag
319        .guard_line_start
320        .and_then(|start| flag.guard_line_end.map(|end| end.saturating_sub(start) + 1))
321        .unwrap_or(0);
322    Some(FeatureFlagDeadCodeOverlap {
323        guarded_lines,
324        dead_export_count: flag.guarded_dead_exports.len(),
325        dead_exports: flag.guarded_dead_exports.clone(),
326    })
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use std::path::PathBuf;
333
334    fn flag() -> FeatureFlag {
335        FeatureFlag {
336            path: PathBuf::from("/repo/src/app.ts"),
337            flag_name: "FEATURE_CHECKOUT".to_string(),
338            kind: FlagKind::EnvironmentVariable,
339            confidence: FlagConfidence::High,
340            line: 10,
341            col: 4,
342            guard_span_start: None,
343            guard_span_end: None,
344            sdk_name: None,
345            guard_line_start: Some(10),
346            guard_line_end: Some(12),
347            guarded_dead_exports: vec!["legacyCheckout".to_string()],
348        }
349    }
350
351    #[test]
352    fn feature_flags_json_output_uses_output_owned_root_contract() {
353        let output = build_feature_flags_output(FeatureFlagsOutputInput {
354            schema_version: 7,
355            version: "0.0.0".to_string(),
356            elapsed: Duration::from_millis(4),
357            flags: &[flag()],
358            root: Path::new("/repo"),
359            meta: Some(feature_flags_meta()),
360        });
361
362        let value = serialize_feature_flags_json_output(
363            output,
364            RootEnvelopeMode::Tagged,
365            Some("run-flags"),
366        )
367        .expect("feature flags output should serialize");
368
369        assert_eq!(value["kind"], "feature-flags");
370        assert_eq!(value["feature_flags"][0]["path"], "src/app.ts");
371        assert_eq!(
372            value["feature_flags"][0]["dead_code_overlap"]["guarded_lines"],
373            3
374        );
375        assert_eq!(
376            value["_meta"]["feature_flags"]["docs"],
377            "https://docs.fallow.tools/cli/flags"
378        );
379        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-flags");
380    }
381
382    #[test]
383    fn feature_flags_json_output_without_explain_emits_telemetry_only_meta() {
384        // The default path (no --explain) leaves `meta` as None, so the only
385        // `_meta` contributor is the post-pass telemetry injection. The typed
386        // `FeatureFlagsMeta` must model this telemetry-only shape (both fields
387        // optional) so the emitted document conforms to the published schema.
388        let output = build_feature_flags_output(FeatureFlagsOutputInput {
389            schema_version: 7,
390            version: "0.0.0".to_string(),
391            elapsed: Duration::from_millis(4),
392            flags: &[flag()],
393            root: Path::new("/repo"),
394            meta: None,
395        });
396
397        let value = serialize_feature_flags_json_output(
398            output,
399            RootEnvelopeMode::Tagged,
400            Some("run-flags"),
401        )
402        .expect("feature flags output should serialize");
403
404        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-flags");
405        assert!(
406            value["_meta"].get("feature_flags").is_none(),
407            "feature_flags details are absent without --explain"
408        );
409    }
410}