1use 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
14pub const FEATURE_FLAGS_SCHEMA_VERSION: u32 = 8;
22
23#[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
30pub struct FeatureFlagsOutputInput<'a> {
32 pub schema_version: u32,
34 pub version: String,
36 pub elapsed: Duration,
38 pub flags: &'a [FeatureFlag],
40 pub root: &'a Path,
42 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
45 pub request_outcomes: Option<crate::RequestOutcomes>,
48 pub meta: Option<FeatureFlagsMeta>,
50 pub retirement: Option<FlagRetirementReport>,
52}
53
54#[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 #[cfg_attr(feature = "schema", schemars(with = "FeatureFlagsSchemaVersion"))]
61 pub schema_version: SchemaVersion,
62 pub version: ToolVersion,
64 pub elapsed_ms: ElapsedMs,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub request_outcomes: Option<crate::RequestOutcomes>,
81 pub feature_flags: Vec<FeatureFlagFinding>,
83 pub total_flags: usize,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub retirement: Option<FlagRetirementReport>,
110 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
112 pub meta: Option<FeatureFlagsMeta>,
113}
114
115#[derive(Debug, Clone, Serialize)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118pub struct FeatureFlagFinding {
119 pub path: String,
121 pub flag_name: String,
123 pub kind: FeatureFlagKind,
125 pub confidence: FeatureFlagConfidence,
127 pub line: u32,
129 pub col: u32,
131 pub actions: Vec<FeatureFlagAction>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub sdk_name: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub dead_code_overlap: Option<FeatureFlagDeadCodeOverlap>,
139}
140
141#[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 EnvironmentVariable,
148 SdkCall,
150 ConfigObject,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
156#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
157#[serde(rename_all = "lowercase")]
158pub enum FeatureFlagConfidence {
159 High,
161 Medium,
164 Low,
166}
167
168#[derive(Debug, Clone, Serialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171pub struct FeatureFlagAction {
172 #[serde(rename = "type")]
174 pub kind: FeatureFlagActionType,
175 pub auto_fixable: bool,
177 pub description: String,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub comment: Option<String>,
182}
183
184#[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 InvestigateFlag,
191 SuppressLine,
193}
194
195#[derive(Debug, Clone, Serialize)]
197#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
198pub struct FeatureFlagDeadCodeOverlap {
199 pub guarded_lines: u32,
201 pub dead_export_count: usize,
203 pub dead_exports: Vec<String>,
205}
206
207#[derive(Debug, Clone, Serialize)]
214#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
215pub struct FeatureFlagsMeta {
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub feature_flags: Option<FeatureFlagsMetaDetails>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub telemetry: Option<TelemetryMeta>,
222}
223
224#[derive(Debug, Clone, Serialize)]
226#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
227pub struct FeatureFlagsMetaDetails {
228 pub description: &'static str,
230 pub kinds: FeatureFlagsKindMeta,
232 pub confidence: FeatureFlagsConfidenceMeta,
234 pub docs: &'static str,
236}
237
238#[derive(Debug, Clone, Serialize)]
240#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
241pub struct FeatureFlagsKindMeta {
242 pub environment_variable: &'static str,
244 pub sdk_call: &'static str,
246 pub config_object: &'static str,
248}
249
250#[derive(Debug, Clone, Serialize)]
252#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
253pub struct FeatureFlagsConfidenceMeta {
254 pub high: &'static str,
256 pub medium: &'static str,
258 pub low: &'static str,
260}
261
262#[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 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
291pub 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#[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 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 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}