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