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 fallow_types::workspace::WorkspaceDiagnostic;
9use serde::Serialize;
10
11use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
12
13pub const FEATURE_FLAGS_SCHEMA_VERSION: u32 = 8;
21
22#[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
29pub struct FeatureFlagsOutputInput<'a> {
31 pub schema_version: u32,
33 pub version: String,
35 pub elapsed: Duration,
37 pub flags: &'a [FeatureFlag],
39 pub root: &'a Path,
41 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
44 pub meta: Option<FeatureFlagsMeta>,
46}
47
48#[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 #[cfg_attr(feature = "schema", schemars(with = "FeatureFlagsSchemaVersion"))]
55 pub schema_version: SchemaVersion,
56 pub version: ToolVersion,
58 pub elapsed_ms: ElapsedMs,
60 pub feature_flags: Vec<FeatureFlagFinding>,
62 pub total_flags: usize,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
81 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
83 pub meta: Option<FeatureFlagsMeta>,
84}
85
86#[derive(Debug, Clone, Serialize)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89pub struct FeatureFlagFinding {
90 pub path: String,
92 pub flag_name: String,
94 pub kind: FeatureFlagKind,
96 pub confidence: FeatureFlagConfidence,
98 pub line: u32,
100 pub col: u32,
102 pub actions: Vec<FeatureFlagAction>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub sdk_name: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub dead_code_overlap: Option<FeatureFlagDeadCodeOverlap>,
110}
111
112#[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 EnvironmentVariable,
119 SdkCall,
121 ConfigObject,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
128#[serde(rename_all = "lowercase")]
129pub enum FeatureFlagConfidence {
130 High,
132 Medium,
134 Low,
136}
137
138#[derive(Debug, Clone, Serialize)]
140#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
141pub struct FeatureFlagAction {
142 #[serde(rename = "type")]
144 pub kind: FeatureFlagActionType,
145 pub auto_fixable: bool,
147 pub description: String,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub comment: Option<String>,
152}
153
154#[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 InvestigateFlag,
161 SuppressLine,
163}
164
165#[derive(Debug, Clone, Serialize)]
167#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
168pub struct FeatureFlagDeadCodeOverlap {
169 pub guarded_lines: u32,
171 pub dead_export_count: usize,
173 pub dead_exports: Vec<String>,
175}
176
177#[derive(Debug, Clone, Serialize)]
184#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185pub struct FeatureFlagsMeta {
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub feature_flags: Option<FeatureFlagsMetaDetails>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub telemetry: Option<TelemetryMeta>,
192}
193
194#[derive(Debug, Clone, Serialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197pub struct FeatureFlagsMetaDetails {
198 pub description: &'static str,
200 pub kinds: FeatureFlagsKindMeta,
202 pub confidence: FeatureFlagsConfidenceMeta,
204 pub docs: &'static str,
206}
207
208#[derive(Debug, Clone, Serialize)]
210#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
211pub struct FeatureFlagsKindMeta {
212 pub environment_variable: &'static str,
214 pub sdk_call: &'static str,
216 pub config_object: &'static str,
218}
219
220#[derive(Debug, Clone, Serialize)]
222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223pub struct FeatureFlagsConfidenceMeta {
224 pub high: &'static str,
226 pub medium: &'static str,
228 pub low: &'static str,
230}
231
232#[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 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
259pub 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#[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 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 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}