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::{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 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#[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 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 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}