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