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