1use 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
12pub struct FeatureFlagsOutputInput<'a> {
14 pub schema_version: u32,
15 pub version: String,
16 pub elapsed: Duration,
17 pub flags: &'a [FeatureFlag],
18 pub root: &'a Path,
19 pub meta: Option<FeatureFlagsMeta>,
20}
21
22#[derive(Debug, Clone, Serialize)]
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25#[cfg_attr(feature = "schema", schemars(title = "fallow flags --format json"))]
26pub struct FeatureFlagsOutput {
27 pub schema_version: SchemaVersion,
28 pub version: ToolVersion,
29 pub elapsed_ms: ElapsedMs,
30 pub feature_flags: Vec<FeatureFlagFinding>,
31 pub total_flags: usize,
32 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
33 pub meta: Option<FeatureFlagsMeta>,
34}
35
36#[derive(Debug, Clone, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39pub struct FeatureFlagFinding {
40 pub path: String,
41 pub flag_name: String,
42 pub kind: FeatureFlagKind,
43 pub confidence: FeatureFlagConfidence,
44 pub line: u32,
45 pub col: u32,
46 pub actions: Vec<FeatureFlagAction>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub sdk_name: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub dead_code_overlap: Option<FeatureFlagDeadCodeOverlap>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[serde(rename_all = "snake_case")]
57pub enum FeatureFlagKind {
58 EnvironmentVariable,
59 SdkCall,
60 ConfigObject,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[serde(rename_all = "lowercase")]
67pub enum FeatureFlagConfidence {
68 High,
69 Medium,
70 Low,
71}
72
73#[derive(Debug, Clone, Serialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76pub struct FeatureFlagAction {
77 #[serde(rename = "type")]
78 pub kind: FeatureFlagActionType,
79 pub auto_fixable: bool,
80 pub description: String,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub comment: Option<String>,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88#[serde(rename_all = "kebab-case")]
89pub enum FeatureFlagActionType {
90 InvestigateFlag,
91 SuppressLine,
92}
93
94#[derive(Debug, Clone, Serialize)]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97pub struct FeatureFlagDeadCodeOverlap {
98 pub guarded_lines: u32,
99 pub dead_export_count: usize,
100 pub dead_exports: Vec<String>,
101}
102
103#[derive(Debug, Clone, Serialize)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111pub struct FeatureFlagsMeta {
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub feature_flags: Option<FeatureFlagsMetaDetails>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub telemetry: Option<TelemetryMeta>,
118}
119
120#[derive(Debug, Clone, Serialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct FeatureFlagsMetaDetails {
124 pub description: &'static str,
125 pub kinds: FeatureFlagsKindMeta,
126 pub confidence: FeatureFlagsConfidenceMeta,
127 pub docs: &'static str,
128}
129
130#[derive(Debug, Clone, Serialize)]
132#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
133pub struct FeatureFlagsKindMeta {
134 pub environment_variable: &'static str,
135 pub sdk_call: &'static str,
136 pub config_object: &'static str,
137}
138
139#[derive(Debug, Clone, Serialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142pub struct FeatureFlagsConfidenceMeta {
143 pub high: &'static str,
144 pub medium: &'static str,
145 pub low: &'static str,
146}
147
148#[must_use]
150pub fn build_feature_flags_output(input: FeatureFlagsOutputInput<'_>) -> FeatureFlagsOutput {
151 let feature_flags = input
152 .flags
153 .iter()
154 .map(|flag| feature_flag_finding(flag, input.root))
155 .collect();
156 FeatureFlagsOutput {
157 schema_version: SchemaVersion(input.schema_version),
158 version: ToolVersion(input.version),
159 elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
160 feature_flags,
161 total_flags: input.flags.len(),
162 meta: input.meta,
163 }
164}
165
166pub fn serialize_feature_flags_json_output(
173 output: FeatureFlagsOutput,
174 mode: RootEnvelopeMode,
175 analysis_run_id: Option<&str>,
176) -> Result<serde_json::Value, serde_json::Error> {
177 let mut value = serialize_named_json_output(output, "feature-flags", mode)?;
178 attach_telemetry_meta(&mut value, analysis_run_id);
179 Ok(value)
180}
181
182#[must_use]
184pub const fn feature_flags_meta() -> FeatureFlagsMeta {
185 FeatureFlagsMeta {
186 telemetry: None,
187 feature_flags: Some(FeatureFlagsMetaDetails {
188 description: "Feature flag patterns detected via AST analysis",
189 kinds: FeatureFlagsKindMeta {
190 environment_variable: "process.env.FEATURE_* pattern (high confidence)",
191 sdk_call: "Feature flag SDK function call (high confidence)",
192 config_object: "Config object property access matching flag keywords (low confidence, heuristic)",
193 },
194 confidence: FeatureFlagsConfidenceMeta {
195 high: "Unambiguous pattern match (env vars, direct SDK calls)",
196 medium: "Pattern match with some ambiguity",
197 low: "Heuristic match (config objects), may produce false positives",
198 },
199 docs: "https://docs.fallow.tools/cli/flags",
200 }),
201 }
202}
203
204fn feature_flag_finding(flag: &FeatureFlag, root: &Path) -> FeatureFlagFinding {
205 let path = flag
206 .path
207 .strip_prefix(root)
208 .unwrap_or(&flag.path)
209 .to_string_lossy()
210 .replace('\\', "/");
211 FeatureFlagFinding {
212 path,
213 flag_name: flag.flag_name.clone(),
214 kind: feature_flag_kind(flag.kind),
215 confidence: feature_flag_confidence(flag.confidence),
216 line: flag.line,
217 col: flag.col,
218 actions: feature_flag_actions(&flag.flag_name),
219 sdk_name: flag.sdk_name.clone(),
220 dead_code_overlap: feature_flag_dead_code_overlap(flag),
221 }
222}
223
224const fn feature_flag_kind(kind: FlagKind) -> FeatureFlagKind {
225 match kind {
226 FlagKind::EnvironmentVariable => FeatureFlagKind::EnvironmentVariable,
227 FlagKind::SdkCall => FeatureFlagKind::SdkCall,
228 FlagKind::ConfigObject => FeatureFlagKind::ConfigObject,
229 }
230}
231
232const fn feature_flag_confidence(confidence: FlagConfidence) -> FeatureFlagConfidence {
233 match confidence {
234 FlagConfidence::High => FeatureFlagConfidence::High,
235 FlagConfidence::Medium => FeatureFlagConfidence::Medium,
236 FlagConfidence::Low => FeatureFlagConfidence::Low,
237 }
238}
239
240fn feature_flag_actions(flag_name: &str) -> Vec<FeatureFlagAction> {
241 vec![
242 FeatureFlagAction {
243 kind: FeatureFlagActionType::InvestigateFlag,
244 auto_fixable: false,
245 description: format!("Verify whether feature flag '{flag_name}' is still active"),
246 comment: None,
247 },
248 FeatureFlagAction {
249 kind: FeatureFlagActionType::SuppressLine,
250 auto_fixable: false,
251 description: "Suppress with an inline comment".to_string(),
252 comment: Some("// fallow-ignore-next-line feature-flag".to_string()),
253 },
254 ]
255}
256
257fn feature_flag_dead_code_overlap(flag: &FeatureFlag) -> Option<FeatureFlagDeadCodeOverlap> {
258 if flag.guarded_dead_exports.is_empty() {
259 return None;
260 }
261 let guarded_lines = flag
262 .guard_line_start
263 .and_then(|start| flag.guard_line_end.map(|end| end.saturating_sub(start) + 1))
264 .unwrap_or(0);
265 Some(FeatureFlagDeadCodeOverlap {
266 guarded_lines,
267 dead_export_count: flag.guarded_dead_exports.len(),
268 dead_exports: flag.guarded_dead_exports.clone(),
269 })
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use std::path::PathBuf;
276
277 fn flag() -> FeatureFlag {
278 FeatureFlag {
279 path: PathBuf::from("/repo/src/app.ts"),
280 flag_name: "FEATURE_CHECKOUT".to_string(),
281 kind: FlagKind::EnvironmentVariable,
282 confidence: FlagConfidence::High,
283 line: 10,
284 col: 4,
285 guard_span_start: None,
286 guard_span_end: None,
287 sdk_name: None,
288 guard_line_start: Some(10),
289 guard_line_end: Some(12),
290 guarded_dead_exports: vec!["legacyCheckout".to_string()],
291 }
292 }
293
294 #[test]
295 fn feature_flags_json_output_uses_output_owned_root_contract() {
296 let output = build_feature_flags_output(FeatureFlagsOutputInput {
297 schema_version: 7,
298 version: "0.0.0".to_string(),
299 elapsed: Duration::from_millis(4),
300 flags: &[flag()],
301 root: Path::new("/repo"),
302 meta: Some(feature_flags_meta()),
303 });
304
305 let value = serialize_feature_flags_json_output(
306 output,
307 RootEnvelopeMode::Tagged,
308 Some("run-flags"),
309 )
310 .expect("feature flags output should serialize");
311
312 assert_eq!(value["kind"], "feature-flags");
313 assert_eq!(value["feature_flags"][0]["path"], "src/app.ts");
314 assert_eq!(
315 value["feature_flags"][0]["dead_code_overlap"]["guarded_lines"],
316 3
317 );
318 assert_eq!(
319 value["_meta"]["feature_flags"]["docs"],
320 "https://docs.fallow.tools/cli/flags"
321 );
322 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-flags");
323 }
324
325 #[test]
326 fn feature_flags_json_output_without_explain_emits_telemetry_only_meta() {
327 let output = build_feature_flags_output(FeatureFlagsOutputInput {
332 schema_version: 7,
333 version: "0.0.0".to_string(),
334 elapsed: Duration::from_millis(4),
335 flags: &[flag()],
336 root: Path::new("/repo"),
337 meta: None,
338 });
339
340 let value = serialize_feature_flags_json_output(
341 output,
342 RootEnvelopeMode::Tagged,
343 Some("run-flags"),
344 )
345 .expect("feature flags output should serialize");
346
347 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-flags");
348 assert!(
349 value["_meta"].get("feature_flags").is_none(),
350 "feature_flags details are absent without --explain"
351 );
352 }
353}