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::{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    analysis_run_id: Option<&str>,
287) -> Result<serde_json::Value, serde_json::Error> {
288    let mut value = serialize_named_json_output(output, "feature-flags")?;
289    attach_telemetry_meta(&mut value, analysis_run_id);
290    Ok(value)
291}
292
293/// Metadata emitted when `fallow flags --explain --format json` is requested.
294#[must_use]
295pub const fn feature_flags_meta() -> FeatureFlagsMeta {
296    FeatureFlagsMeta {
297        telemetry: None,
298        feature_flags: Some(FeatureFlagsMetaDetails {
299            description: "Feature flag patterns detected via AST analysis",
300            kinds: FeatureFlagsKindMeta {
301                environment_variable: "process.env.FEATURE_* pattern (high confidence)",
302                sdk_call: "Feature flag SDK function call (high confidence)",
303                config_object: "Config object property access matching flag keywords (low confidence, heuristic)",
304            },
305            confidence: FeatureFlagsConfidenceMeta {
306                high: "Unambiguous pattern match (env vars, direct SDK calls)",
307                medium: "Pattern match with some ambiguity",
308                low: "Heuristic match (config objects), may produce false positives",
309            },
310            docs: "https://docs.fallow.tools/cli/flags",
311        }),
312    }
313}
314
315fn feature_flag_finding(flag: &FeatureFlag, root: &Path) -> FeatureFlagFinding {
316    let path = flag
317        .path
318        .strip_prefix(root)
319        .unwrap_or(&flag.path)
320        .to_string_lossy()
321        .replace('\\', "/");
322    FeatureFlagFinding {
323        path,
324        flag_name: flag.flag_name.clone(),
325        kind: feature_flag_kind(flag.kind),
326        confidence: feature_flag_confidence(flag.confidence),
327        line: flag.line,
328        col: flag.col,
329        actions: feature_flag_actions(&flag.flag_name),
330        sdk_name: flag.sdk_name.clone(),
331        dead_code_overlap: feature_flag_dead_code_overlap(flag),
332    }
333}
334
335const fn feature_flag_kind(kind: FlagKind) -> FeatureFlagKind {
336    match kind {
337        FlagKind::EnvironmentVariable => FeatureFlagKind::EnvironmentVariable,
338        FlagKind::SdkCall => FeatureFlagKind::SdkCall,
339        FlagKind::ConfigObject => FeatureFlagKind::ConfigObject,
340    }
341}
342
343const fn feature_flag_confidence(confidence: FlagConfidence) -> FeatureFlagConfidence {
344    match confidence {
345        FlagConfidence::High => FeatureFlagConfidence::High,
346        FlagConfidence::Medium => FeatureFlagConfidence::Medium,
347        FlagConfidence::Low => FeatureFlagConfidence::Low,
348    }
349}
350
351fn feature_flag_actions(flag_name: &str) -> Vec<FeatureFlagAction> {
352    vec![
353        FeatureFlagAction {
354            kind: FeatureFlagActionType::InvestigateFlag,
355            auto_fixable: false,
356            description: format!("Verify whether feature flag '{flag_name}' is still active"),
357            comment: None,
358        },
359        FeatureFlagAction {
360            kind: FeatureFlagActionType::SuppressLine,
361            auto_fixable: false,
362            description: "Suppress with an inline comment".to_string(),
363            comment: Some("// fallow-ignore-next-line feature-flag".to_string()),
364        },
365    ]
366}
367
368fn feature_flag_dead_code_overlap(flag: &FeatureFlag) -> Option<FeatureFlagDeadCodeOverlap> {
369    if flag.guarded_dead_exports.is_empty() {
370        return None;
371    }
372    let guarded_lines = flag
373        .guard_line_start
374        .and_then(|start| flag.guard_line_end.map(|end| end.saturating_sub(start) + 1))
375        .unwrap_or(0);
376    Some(FeatureFlagDeadCodeOverlap {
377        guarded_lines,
378        dead_export_count: flag.guarded_dead_exports.len(),
379        dead_exports: flag.guarded_dead_exports.clone(),
380    })
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use std::path::PathBuf;
387
388    fn flag() -> FeatureFlag {
389        FeatureFlag {
390            path: PathBuf::from("/repo/src/app.ts"),
391            flag_name: "FEATURE_CHECKOUT".to_string(),
392            kind: FlagKind::EnvironmentVariable,
393            confidence: FlagConfidence::High,
394            line: 10,
395            col: 4,
396            guard_span_start: None,
397            guard_span_end: None,
398            sdk_name: None,
399            guard_line_start: Some(10),
400            guard_line_end: Some(12),
401            guarded_dead_exports: vec!["legacyCheckout".to_string()],
402        }
403    }
404
405    #[test]
406    fn feature_flags_json_output_uses_output_owned_root_contract() {
407        let output = build_feature_flags_output(FeatureFlagsOutputInput {
408            schema_version: 7,
409            version: "0.0.0".to_string(),
410            elapsed: Duration::from_millis(4),
411            flags: &[flag()],
412            root: Path::new("/repo"),
413            workspace_diagnostics: Vec::new(),
414            request_outcomes: None,
415            meta: Some(feature_flags_meta()),
416        });
417
418        let value = serialize_feature_flags_json_output(output, Some("run-flags"))
419            .expect("feature flags output should serialize");
420
421        assert_eq!(value["kind"], "feature-flags");
422        assert_eq!(value["feature_flags"][0]["path"], "src/app.ts");
423        assert_eq!(
424            value["feature_flags"][0]["dead_code_overlap"]["guarded_lines"],
425            3
426        );
427        assert_eq!(
428            value["_meta"]["feature_flags"]["docs"],
429            "https://docs.fallow.tools/cli/flags"
430        );
431        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-flags");
432    }
433
434    #[test]
435    fn feature_flags_json_output_without_explain_emits_telemetry_only_meta() {
436        // The default path (no --explain) leaves `meta` as None, so the only
437        // `_meta` contributor is the post-pass telemetry injection. The typed
438        // `FeatureFlagsMeta` must model this telemetry-only shape (both fields
439        // optional) so the emitted document conforms to the published schema.
440        let output = build_feature_flags_output(FeatureFlagsOutputInput {
441            schema_version: 7,
442            version: "0.0.0".to_string(),
443            elapsed: Duration::from_millis(4),
444            flags: &[flag()],
445            root: Path::new("/repo"),
446            workspace_diagnostics: Vec::new(),
447            request_outcomes: None,
448            meta: None,
449        });
450
451        let value = serialize_feature_flags_json_output(output, Some("run-flags"))
452            .expect("feature flags output should serialize");
453
454        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-flags");
455        assert!(
456            value["_meta"].get("feature_flags").is_none(),
457            "feature_flags details are absent without --explain"
458        );
459    }
460
461    #[test]
462    fn recorded_diagnostics_reach_the_flags_envelope_root_relative() {
463        // A flags run that skipped a file scanned it for nothing. Without this
464        // array the skip was unreachable from both channels on this command:
465        // stderr no longer carries every kind, and the envelope had no key.
466        let output = build_feature_flags_output(FeatureFlagsOutputInput {
467            schema_version: FEATURE_FLAGS_SCHEMA_VERSION,
468            version: "0.0.0".to_string(),
469            elapsed: Duration::from_millis(4),
470            flags: &[flag()],
471            root: Path::new("/repo"),
472            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
473                Path::new("/repo"),
474                PathBuf::from("/repo/src/generated.ts"),
475                fallow_types::workspace::WorkspaceDiagnosticKind::SkippedLargeFile {
476                    size_bytes: 9_000_000,
477                },
478            )],
479            request_outcomes: None,
480            meta: None,
481        });
482
483        let value = serialize_feature_flags_json_output(output, None)
484            .expect("feature flags output should serialize");
485
486        assert_eq!(
487            value["workspace_diagnostics"][0]["path"], "src/generated.ts",
488            "the envelope has no post-serialisation strip, so the builder must relativize"
489        );
490        assert_eq!(
491            value["workspace_diagnostics"][0]["kind"], "skipped-large-file",
492            "the typed kind reaches the wire, envelope was {value}"
493        );
494    }
495
496    #[test]
497    fn a_clean_run_omits_the_diagnostics_key_entirely() {
498        let output = build_feature_flags_output(FeatureFlagsOutputInput {
499            schema_version: FEATURE_FLAGS_SCHEMA_VERSION,
500            version: "0.0.0".to_string(),
501            elapsed: Duration::from_millis(4),
502            flags: &[flag()],
503            root: Path::new("/repo"),
504            workspace_diagnostics: Vec::new(),
505            request_outcomes: None,
506            meta: None,
507        });
508
509        let value = serialize_feature_flags_json_output(output, None)
510            .expect("feature flags output should serialize");
511
512        assert!(
513            value.get("workspace_diagnostics").is_none(),
514            "an empty array is omitted so a quiet project sees no wire change"
515        );
516    }
517}