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 request_outcomes: Option<crate::RequestOutcomes>,
47 pub meta: Option<FeatureFlagsMeta>,
49}
50
51#[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 #[cfg_attr(feature = "schema", schemars(with = "FeatureFlagsSchemaVersion"))]
58 pub schema_version: SchemaVersion,
59 pub version: ToolVersion,
61 pub elapsed_ms: ElapsedMs,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub request_outcomes: Option<crate::RequestOutcomes>,
78 pub feature_flags: Vec<FeatureFlagFinding>,
80 pub total_flags: usize,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
99 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
101 pub meta: Option<FeatureFlagsMeta>,
102}
103
104#[derive(Debug, Clone, Serialize)]
106#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
107pub struct FeatureFlagFinding {
108 pub path: String,
110 pub flag_name: String,
112 pub kind: FeatureFlagKind,
114 pub confidence: FeatureFlagConfidence,
116 pub line: u32,
118 pub col: u32,
120 pub actions: Vec<FeatureFlagAction>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub sdk_name: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub dead_code_overlap: Option<FeatureFlagDeadCodeOverlap>,
128}
129
130#[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 EnvironmentVariable,
137 SdkCall,
139 ConfigObject,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
146#[serde(rename_all = "lowercase")]
147pub enum FeatureFlagConfidence {
148 High,
150 Medium,
152 Low,
154}
155
156#[derive(Debug, Clone, Serialize)]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159pub struct FeatureFlagAction {
160 #[serde(rename = "type")]
162 pub kind: FeatureFlagActionType,
163 pub auto_fixable: bool,
165 pub description: String,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub comment: Option<String>,
170}
171
172#[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 InvestigateFlag,
179 SuppressLine,
181}
182
183#[derive(Debug, Clone, Serialize)]
185#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
186pub struct FeatureFlagDeadCodeOverlap {
187 pub guarded_lines: u32,
189 pub dead_export_count: usize,
191 pub dead_exports: Vec<String>,
193}
194
195#[derive(Debug, Clone, Serialize)]
202#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
203pub struct FeatureFlagsMeta {
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub feature_flags: Option<FeatureFlagsMetaDetails>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub telemetry: Option<TelemetryMeta>,
210}
211
212#[derive(Debug, Clone, Serialize)]
214#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
215pub struct FeatureFlagsMetaDetails {
216 pub description: &'static str,
218 pub kinds: FeatureFlagsKindMeta,
220 pub confidence: FeatureFlagsConfidenceMeta,
222 pub docs: &'static str,
224}
225
226#[derive(Debug, Clone, Serialize)]
228#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
229pub struct FeatureFlagsKindMeta {
230 pub environment_variable: &'static str,
232 pub sdk_call: &'static str,
234 pub config_object: &'static str,
236}
237
238#[derive(Debug, Clone, Serialize)]
240#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
241pub struct FeatureFlagsConfidenceMeta {
242 pub high: &'static str,
244 pub medium: &'static str,
246 pub low: &'static str,
248}
249
250#[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 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
278pub 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#[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 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 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}