Skip to main content

fallow_extract/
flags.rs

1//! Feature flag detection via lightweight Oxc AST visitor.
2//!
3//! Detects three patterns:
4//! 1. **Environment variables**: `process.env.FEATURE_X`
5//! 2. **SDK calls**: `useFlag('name')`, `variation('name', false)`,
6//!    `flag({ key: 'name' })`, etc.
7//! 3. **Config objects**: `config.features.x` (opt-in, heuristic)
8//!
9//! Always extracted during parse (lightweight pattern matching on `MemberExpression`
10//! and `CallExpression` nodes). Custom SDK patterns and config object heuristics
11//! are applied as a supplementary pass in the CLI when user config is present.
12
13#[allow(clippy::wildcard_imports, reason = "many AST types used")]
14use oxc_ast::ast::*;
15use oxc_ast_visit::Visit;
16use oxc_ast_visit::walk;
17use rustc_hash::{FxHashMap, FxHashSet};
18
19use fallow_types::extract::{FlagUse, FlagUseKind, byte_offset_to_line_col};
20
21/// Built-in SDK function patterns: (function_name, name_arg_index, provider_label).
22const BUILTIN_SDK_PATTERNS: &[(&str, usize, &str)] = &[
23    ("useFlag", 0, "LaunchDarkly"),
24    ("useLDFlag", 0, "LaunchDarkly"),
25    ("useFeatureFlag", 0, "LaunchDarkly"),
26    ("variation", 0, "LaunchDarkly"),
27    ("boolVariation", 0, "LaunchDarkly"),
28    ("stringVariation", 0, "LaunchDarkly"),
29    ("numberVariation", 0, "LaunchDarkly"),
30    ("jsonVariation", 0, "LaunchDarkly"),
31    ("useGate", 0, "Statsig"),
32    ("checkGate", 0, "Statsig"),
33    ("useExperiment", 0, "Statsig"),
34    ("useConfig", 0, "Statsig"),
35    ("isEnabled", 0, "Unleash"),
36    ("getVariant", 0, "Unleash"),
37    ("isOn", 0, "GrowthBook"),
38    ("isOff", 0, "GrowthBook"),
39    ("getFeatureValue", 0, "GrowthBook"),
40    ("getTreatment", 0, "Split"),
41    ("useFeatureFlagEnabled", 0, "PostHog"),
42    ("useFeatureFlagPayload", 0, "PostHog"),
43    ("useFeatureFlagVariantKey", 0, "PostHog"),
44    ("getFeatureFlagPayload", 0, "PostHog"),
45    ("getValueAsync", 0, "ConfigCat"),
46    ("getValueDetailsAsync", 0, "ConfigCat"),
47    ("hasFeature", 0, "Flagsmith"),
48    ("useDecision", 0, "Optimizely"),
49    ("getFeatureVariable", 0, "Optimizely"),
50    ("getFeatureVariableBoolean", 0, "Optimizely"),
51    ("getFeatureVariableString", 0, "Optimizely"),
52    ("getFeatureVariableInteger", 0, "Optimizely"),
53    ("getFeatureVariableDouble", 0, "Optimizely"),
54    ("getFeatureVariableJson", 0, "Optimizely"),
55    ("getFeatureVariableJSON", 0, "Optimizely"),
56    ("getStringAssignment", 0, "Eppo"),
57    ("getBooleanAssignment", 0, "Eppo"),
58    ("getNumericAssignment", 0, "Eppo"),
59    ("getIntegerAssignment", 0, "Eppo"),
60    ("getJSONAssignment", 0, "Eppo"),
61    ("getStringAssignmentDetails", 0, "Eppo"),
62    ("getBooleanAssignmentDetails", 0, "Eppo"),
63    ("getNumericAssignmentDetails", 0, "Eppo"),
64    ("getIntegerAssignmentDetails", 0, "Eppo"),
65    ("getJSONAssignmentDetails", 0, "Eppo"),
66    ("getValue", 0, ""),
67    ("useFeature", 0, ""),
68    ("getFeatureFlag", 0, ""),
69];
70
71const VERCEL_FLAGS_PROVIDER: &str = "Vercel Flags";
72const VERCEL_FLAGS_FUNCTIONS: &[&str] = &["flag", "evaluate"];
73
74/// Built-in environment variable prefixes that indicate feature flags.
75const BUILTIN_ENV_PREFIXES: &[&str] = &[
76    "FEATURE_",
77    "NEXT_PUBLIC_FEATURE_",
78    "NEXT_PUBLIC_ENABLE_",
79    "REACT_APP_FEATURE_",
80    "REACT_APP_ENABLE_",
81    "VITE_FEATURE_",
82    "VITE_ENABLE_",
83    "NUXT_PUBLIC_FEATURE_",
84    "ENABLE_",
85    "FF_",
86    "FLAG_",
87    "TOGGLE_",
88];
89
90/// Distinct built-in SDK provider labels, in declaration order.
91///
92/// Used by `fallow flags` to tell the user which SDKs the default detectors
93/// cover when no flags are found. Derived from `BUILTIN_SDK_PATTERNS` (empty
94/// provider labels skipped) with the import-based Vercel Flags provider appended,
95/// so the surfaced list stays in sync with what is actually detected.
96#[must_use]
97pub fn builtin_sdk_providers() -> Vec<&'static str> {
98    let mut providers: Vec<&'static str> = Vec::new();
99    for &(_, _, provider) in BUILTIN_SDK_PATTERNS {
100        if !provider.is_empty() && !providers.contains(&provider) {
101            providers.push(provider);
102        }
103    }
104    if !providers.contains(&VERCEL_FLAGS_PROVIDER) {
105        providers.push(VERCEL_FLAGS_PROVIDER);
106    }
107    providers
108}
109
110/// Built-in environment variable prefixes treated as feature flags.
111///
112/// Used by `fallow flags` to surface the default env-prefix detectors in the
113/// empty-result hint. Returns the source-of-truth `BUILTIN_ENV_PREFIXES`.
114#[must_use]
115pub fn builtin_env_prefixes() -> &'static [&'static str] {
116    BUILTIN_ENV_PREFIXES
117}
118
119/// Config object names that heuristically indicate feature flag namespaces.
120const CONFIG_OBJECT_KEYWORDS: &[&str] = &[
121    "feature",
122    "features",
123    "featureFlags",
124    "featureFlag",
125    "flag",
126    "flags",
127    "toggle",
128    "toggles",
129];
130
131/// AST visitor that detects feature flag patterns.
132struct FlagVisitor<'a> {
133    results: Vec<FlagUse>,
134    line_offsets: &'a [u32],
135    /// Extra SDK patterns from user config.
136    extra_sdk_patterns: &'a [(String, usize, String)],
137    /// Extra env prefixes from user config.
138    extra_env_prefixes: &'a [String],
139    /// Whether to detect config object patterns (opt-in).
140    config_object_heuristics: bool,
141    /// Local named imports from Vercel Flags packages: local name -> imported name.
142    vercel_flags_imports: FxHashMap<String, String>,
143    /// Namespace imports from Vercel Flags packages.
144    vercel_flags_namespaces: FxHashSet<String>,
145}
146
147impl<'a> FlagVisitor<'a> {
148    fn new(
149        line_offsets: &'a [u32],
150        extra_sdk_patterns: &'a [(String, usize, String)],
151        extra_env_prefixes: &'a [String],
152        config_object_heuristics: bool,
153    ) -> Self {
154        Self {
155            results: Vec::new(),
156            line_offsets,
157            extra_sdk_patterns,
158            extra_env_prefixes,
159            config_object_heuristics,
160            vercel_flags_imports: FxHashMap::default(),
161            vercel_flags_namespaces: FxHashSet::default(),
162        }
163    }
164
165    /// Check if a member expression matches `process.env.SOMETHING`.
166    fn check_env_var(&mut self, expr: &MemberExpression<'_>, guard: Option<(u32, u32)>) {
167        if let MemberExpression::StaticMemberExpression(static_expr) = expr
168            && let Some(env_name) = extract_process_env_name(static_expr)
169            && self.is_flag_env_name(&env_name)
170        {
171            let (line, col) = byte_offset_to_line_col(self.line_offsets, static_expr.span.start);
172            self.results.push(FlagUse {
173                flag_name: env_name,
174                kind: FlagUseKind::EnvVar,
175                line,
176                col,
177                guard_span_start: guard.map(|(s, _)| s),
178                guard_span_end: guard.map(|(_, e)| e),
179                sdk_name: None,
180            });
181        }
182    }
183
184    /// Check if a call expression matches an SDK pattern.
185    fn check_sdk_call(&mut self, call: &CallExpression<'_>, guard: Option<(u32, u32)>) {
186        let func_name = match &call.callee {
187            Expression::Identifier(id) => Some(id.name.as_str()),
188            Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
189            _ => None,
190        };
191
192        let Some(func_name) = func_name else {
193            return;
194        };
195
196        if self.check_vercel_flags_call(call, guard) {
197            return;
198        }
199
200        for &(pattern_name, name_arg_idx, provider) in BUILTIN_SDK_PATTERNS {
201            if func_name == pattern_name {
202                if let Some(flag_name) = extract_string_arg(&call.arguments, name_arg_idx) {
203                    let (line, col) = byte_offset_to_line_col(self.line_offsets, call.span.start);
204                    self.results.push(FlagUse {
205                        flag_name,
206                        kind: FlagUseKind::SdkCall,
207                        line,
208                        col,
209                        guard_span_start: guard.map(|(s, _)| s),
210                        guard_span_end: guard.map(|(_, e)| e),
211                        sdk_name: if provider.is_empty() {
212                            None
213                        } else {
214                            Some(provider.to_string())
215                        },
216                    });
217                }
218                return;
219            }
220        }
221
222        for (pattern_name, name_arg_idx, provider) in self.extra_sdk_patterns {
223            if func_name == pattern_name {
224                if let Some(flag_name) = extract_string_arg(&call.arguments, *name_arg_idx) {
225                    let (line, col) = byte_offset_to_line_col(self.line_offsets, call.span.start);
226                    self.results.push(FlagUse {
227                        flag_name,
228                        kind: FlagUseKind::SdkCall,
229                        line,
230                        col,
231                        guard_span_start: guard.map(|(s, _)| s),
232                        guard_span_end: guard.map(|(_, e)| e),
233                        sdk_name: if provider.is_empty() {
234                            None
235                        } else {
236                            Some(provider.clone())
237                        },
238                    });
239                }
240                return;
241            }
242        }
243    }
244
245    fn check_vercel_flags_call(
246        &mut self,
247        call: &CallExpression<'_>,
248        guard: Option<(u32, u32)>,
249    ) -> bool {
250        let Some(imported_name) = self.vercel_flags_imported_name(call) else {
251            return false;
252        };
253
254        let flag_name = match imported_name {
255            "flag" => extract_object_string_property_arg(&call.arguments, 0, "key"),
256            "evaluate" => extract_string_arg(&call.arguments, 0),
257            _ => None,
258        };
259
260        let Some(flag_name) = flag_name else {
261            return false;
262        };
263
264        let (line, col) = byte_offset_to_line_col(self.line_offsets, call.span.start);
265        self.results.push(FlagUse {
266            flag_name,
267            kind: FlagUseKind::SdkCall,
268            line,
269            col,
270            guard_span_start: guard.map(|(s, _)| s),
271            guard_span_end: guard.map(|(_, e)| e),
272            sdk_name: Some(VERCEL_FLAGS_PROVIDER.to_string()),
273        });
274        true
275    }
276
277    fn vercel_flags_imported_name<'b>(&'b self, call: &'b CallExpression<'_>) -> Option<&'b str> {
278        match &call.callee {
279            Expression::Identifier(id) => self
280                .vercel_flags_imports
281                .get(id.name.as_str())
282                .map(String::as_str),
283            Expression::StaticMemberExpression(member) => {
284                let Expression::Identifier(object) = &member.object else {
285                    return None;
286                };
287                self.vercel_flags_namespaces
288                    .contains(object.name.as_str())
289                    .then_some(member.property.name.as_str())
290            }
291            _ => None,
292        }
293    }
294
295    fn collect_vercel_flags_imports(&mut self, program: &Program<'_>) {
296        for stmt in &program.body {
297            if let Statement::ImportDeclaration(decl) = stmt {
298                self.collect_vercel_flags_import(decl);
299            }
300        }
301    }
302
303    fn collect_vercel_flags_import(&mut self, decl: &ImportDeclaration<'_>) {
304        if !is_vercel_flags_source(decl.source.value.as_str()) || decl.import_kind.is_type() {
305            return;
306        }
307
308        let Some(specifiers) = &decl.specifiers else {
309            return;
310        };
311
312        for spec in specifiers {
313            match spec {
314                ImportDeclarationSpecifier::ImportSpecifier(specifier) => {
315                    if specifier.import_kind.is_type() {
316                        continue;
317                    }
318                    let imported_name = specifier.imported.name();
319                    if VERCEL_FLAGS_FUNCTIONS.contains(&imported_name.as_str()) {
320                        self.vercel_flags_imports
321                            .insert(specifier.local.name.to_string(), imported_name.to_string());
322                    }
323                }
324                ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => {
325                    self.vercel_flags_namespaces
326                        .insert(specifier.local.name.to_string());
327                }
328                ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => {}
329            }
330        }
331    }
332
333    /// Check if a member expression matches a config object pattern.
334    fn check_config_object(
335        &mut self,
336        expr: &StaticMemberExpression<'_>,
337        guard: Option<(u32, u32)>,
338    ) {
339        if !self.config_object_heuristics {
340            return;
341        }
342
343        if let Some((obj_name, prop_name)) = extract_config_object_access(expr)
344            && CONFIG_OBJECT_KEYWORDS
345                .iter()
346                .any(|kw| obj_name.eq_ignore_ascii_case(kw) || prop_name.eq_ignore_ascii_case(kw))
347        {
348            let (line, col) = byte_offset_to_line_col(self.line_offsets, expr.span.start);
349            self.results.push(FlagUse {
350                flag_name: format!("{obj_name}.{prop_name}"),
351                kind: FlagUseKind::ConfigObject,
352                line,
353                col,
354                guard_span_start: guard.map(|(s, _)| s),
355                guard_span_end: guard.map(|(_, e)| e),
356                sdk_name: None,
357            });
358        }
359    }
360
361    fn is_flag_env_name(&self, name: &str) -> bool {
362        for prefix in BUILTIN_ENV_PREFIXES {
363            if name.starts_with(prefix) {
364                return true;
365            }
366        }
367        for prefix in self.extra_env_prefixes {
368            if name.starts_with(prefix.as_str()) {
369                return true;
370            }
371        }
372        false
373    }
374}
375
376impl Visit<'_> for FlagVisitor<'_> {
377    fn visit_program(&mut self, program: &Program<'_>) {
378        self.collect_vercel_flags_imports(program);
379        walk::walk_program(self, program);
380    }
381
382    fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'_>) {
383        self.collect_vercel_flags_import(decl);
384    }
385
386    fn visit_if_statement(&mut self, stmt: &IfStatement<'_>) {
387        let guard = Some((stmt.span.start, stmt.span.end));
388
389        check_expression_for_flags(self, &stmt.test, guard);
390
391        self.visit_statement(&stmt.consequent);
392        if let Some(alt) = &stmt.alternate {
393            self.visit_statement(alt);
394        }
395    }
396
397    fn visit_conditional_expression(&mut self, expr: &ConditionalExpression<'_>) {
398        let guard = Some((expr.span.start, expr.span.end));
399        check_expression_for_flags(self, &expr.test, guard);
400
401        self.visit_expression(&expr.consequent);
402        self.visit_expression(&expr.alternate);
403    }
404
405    fn visit_call_expression(&mut self, call: &CallExpression<'_>) {
406        self.check_sdk_call(call, None);
407        walk::walk_call_expression(self, call);
408    }
409
410    fn visit_member_expression(&mut self, expr: &MemberExpression<'_>) {
411        self.check_env_var(expr, None);
412        if let MemberExpression::StaticMemberExpression(static_expr) = expr {
413            self.check_config_object(static_expr, None);
414        }
415        walk::walk_member_expression(self, expr);
416    }
417}
418
419fn is_vercel_flags_source(source: &str) -> bool {
420    source == "flags"
421        || source.starts_with("flags/")
422        || source == "@vercel/flags"
423        || source.starts_with("@vercel/flags/")
424}
425
426/// Check an expression (typically an if-test) for flag patterns.
427fn check_expression_for_flags(
428    visitor: &mut FlagVisitor<'_>,
429    expr: &Expression<'_>,
430    guard: Option<(u32, u32)>,
431) {
432    match expr {
433        Expression::CallExpression(call) => {
434            visitor.check_sdk_call(call, guard);
435        }
436        Expression::StaticMemberExpression(member) => {
437            check_static_member_for_env(visitor, member, guard);
438            visitor.check_config_object(member, guard);
439        }
440        Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::LogicalNot => {
441            check_expression_for_flags(visitor, &unary.argument, guard);
442        }
443        Expression::LogicalExpression(logical) => {
444            check_expression_for_flags(visitor, &logical.left, guard);
445            check_expression_for_flags(visitor, &logical.right, guard);
446        }
447        _ => {}
448    }
449}
450
451/// Check a static member expression directly for `process.env.X` pattern.
452fn check_static_member_for_env(
453    visitor: &mut FlagVisitor<'_>,
454    expr: &StaticMemberExpression<'_>,
455    guard: Option<(u32, u32)>,
456) {
457    if let Some(env_name) = extract_process_env_name(expr)
458        && visitor.is_flag_env_name(&env_name)
459    {
460        let (line, col) = byte_offset_to_line_col(visitor.line_offsets, expr.span.start);
461        visitor.results.push(FlagUse {
462            flag_name: env_name,
463            kind: FlagUseKind::EnvVar,
464            line,
465            col,
466            guard_span_start: guard.map(|(s, _)| s),
467            guard_span_end: guard.map(|(_, e)| e),
468            sdk_name: None,
469        });
470    }
471}
472
473/// Extract the environment variable name from `process.env.X`.
474fn extract_process_env_name(expr: &StaticMemberExpression<'_>) -> Option<String> {
475    let prop_name = expr.property.name.as_str();
476
477    if let Expression::StaticMemberExpression(inner) = &expr.object
478        && inner.property.name.as_str() == "env"
479        && let Expression::Identifier(id) = &inner.object
480        && id.name.as_str() == "process"
481    {
482        return Some(prop_name.to_string());
483    }
484
485    None
486}
487
488/// Extract a string literal argument at the given index.
489fn extract_string_arg(args: &[Argument<'_>], index: usize) -> Option<String> {
490    args.get(index).and_then(|arg| {
491        if let Argument::StringLiteral(lit) = arg {
492            Some(lit.value.to_string())
493        } else {
494            None
495        }
496    })
497}
498
499/// Extract a string property from an object argument at the given index.
500fn extract_object_string_property_arg(
501    args: &[Argument<'_>],
502    index: usize,
503    property_name: &str,
504) -> Option<String> {
505    let Some(Argument::ObjectExpression(obj)) = args.get(index) else {
506        return None;
507    };
508
509    for prop in &obj.properties {
510        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
511            continue;
512        };
513        if prop
514            .key
515            .static_name()
516            .is_some_and(|key| key.as_ref() == property_name)
517            && let Expression::StringLiteral(lit) = &prop.value
518        {
519            return Some(lit.value.to_string());
520        }
521    }
522
523    None
524}
525
526/// Extract config object access pattern: `obj.prop` where either name is a flag keyword.
527fn extract_config_object_access(expr: &StaticMemberExpression<'_>) -> Option<(String, String)> {
528    let prop_name = expr.property.name.to_string();
529
530    match &expr.object {
531        Expression::Identifier(id) => Some((id.name.to_string(), prop_name)),
532        Expression::StaticMemberExpression(inner) => {
533            if matches!(&inner.object, Expression::Identifier(_)) {
534                Some((inner.property.name.to_string(), prop_name))
535            } else {
536                None
537            }
538        }
539        _ => None,
540    }
541}
542
543/// Entry point: extract feature flag use sites from a parsed program.
544///
545/// Called unconditionally from `parse_source_to_module` for all parsed files.
546pub fn extract_flags(
547    program: &Program<'_>,
548    line_offsets: &[u32],
549    extra_sdk_patterns: &[(String, usize, String)],
550    extra_env_prefixes: &[String],
551    config_object_heuristics: bool,
552) -> Vec<FlagUse> {
553    let mut visitor = FlagVisitor::new(
554        line_offsets,
555        extra_sdk_patterns,
556        extra_env_prefixes,
557        config_object_heuristics,
558    );
559    visitor.visit_program(program);
560    visitor.results
561}
562
563/// Extract feature flags from source text with custom configuration.
564///
565/// Higher-level convenience function that handles parsing internally.
566/// Used by the CLI flags command for supplementary extraction with
567/// user-configured patterns that aren't applied at parse/cache time.
568pub fn extract_flags_from_source(
569    source: &str,
570    path: &std::path::Path,
571    extra_sdk_patterns: &[(String, usize, String)],
572    extra_env_prefixes: &[String],
573    config_object_heuristics: bool,
574) -> Vec<FlagUse> {
575    let source_type = oxc_span::SourceType::from_path(path).unwrap_or_default();
576    let allocator = oxc_allocator::Allocator::default();
577    let parser_return = oxc_parser::Parser::new(&allocator, source, source_type).parse();
578    let line_offsets = fallow_types::extract::compute_line_offsets(source);
579    extract_flags(
580        &parser_return.program,
581        &line_offsets,
582        extra_sdk_patterns,
583        extra_env_prefixes,
584        config_object_heuristics,
585    )
586}
587
588#[cfg(all(test, not(miri)))]
589mod tests {
590    use super::*;
591    use oxc_allocator::Allocator;
592    use oxc_parser::Parser;
593    use oxc_span::SourceType;
594
595    fn extract_from_source(source: &str) -> Vec<FlagUse> {
596        let allocator = Allocator::default();
597        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
598        let line_offsets = fallow_types::extract::compute_line_offsets(source);
599        extract_flags(&parser_return.program, &line_offsets, &[], &[], false)
600    }
601
602    fn extract_with_config_objects(source: &str) -> Vec<FlagUse> {
603        let allocator = Allocator::default();
604        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
605        let line_offsets = fallow_types::extract::compute_line_offsets(source);
606        extract_flags(&parser_return.program, &line_offsets, &[], &[], true)
607    }
608
609    #[test]
610    fn detects_process_env_feature_flag() {
611        let flags = extract_from_source("if (process.env.FEATURE_NEW_CHECKOUT) { doStuff(); }");
612        assert_eq!(flags.len(), 1);
613        assert_eq!(flags[0].flag_name, "FEATURE_NEW_CHECKOUT");
614        assert_eq!(flags[0].kind, FlagUseKind::EnvVar);
615        assert!(flags[0].guard_span_start.is_some());
616    }
617
618    #[test]
619    fn detects_next_public_enable_prefix() {
620        let flags = extract_from_source("if (process.env.NEXT_PUBLIC_ENABLE_BETA) {}");
621        assert_eq!(flags.len(), 1);
622        assert_eq!(flags[0].flag_name, "NEXT_PUBLIC_ENABLE_BETA");
623    }
624
625    #[test]
626    fn ignores_non_flag_env_vars() {
627        let flags = extract_from_source("const url = process.env.DATABASE_URL;");
628        assert!(flags.is_empty());
629    }
630
631    #[test]
632    fn detects_negated_env_flag() {
633        let flags = extract_from_source("if (!process.env.FEATURE_X) { fallback(); }");
634        assert_eq!(flags.len(), 1);
635        assert_eq!(flags[0].flag_name, "FEATURE_X");
636    }
637
638    #[test]
639    fn detects_launchdarkly_use_flag() {
640        let flags = extract_from_source("const flag = useFlag('new-checkout');");
641        assert_eq!(flags.len(), 1);
642        assert_eq!(flags[0].flag_name, "new-checkout");
643        assert_eq!(flags[0].kind, FlagUseKind::SdkCall);
644        assert_eq!(flags[0].sdk_name.as_deref(), Some("LaunchDarkly"));
645    }
646
647    #[test]
648    fn detects_statsig_use_gate() {
649        let flags = extract_from_source("if (useGate('beta-feature')) {}");
650        assert_eq!(flags.len(), 1);
651        assert_eq!(flags[0].flag_name, "beta-feature");
652        assert_eq!(flags[0].sdk_name.as_deref(), Some("Statsig"));
653    }
654
655    #[test]
656    fn detects_unleash_is_enabled() {
657        let flags = extract_from_source("client.isEnabled('feature-x')");
658        assert_eq!(flags.len(), 1);
659        assert_eq!(flags[0].flag_name, "feature-x");
660    }
661
662    #[test]
663    fn detects_growthbook_get_feature_value() {
664        let flags = extract_from_source("const val = getFeatureValue('parser', false);");
665        assert_eq!(flags.len(), 1);
666        assert_eq!(flags[0].flag_name, "parser");
667        assert_eq!(flags[0].sdk_name.as_deref(), Some("GrowthBook"));
668    }
669
670    #[test]
671    fn detects_posthog_hooks() {
672        let flags = extract_from_source(
673            "const enabled = useFeatureFlagEnabled('new-checkout');\n\
674             const payload = useFeatureFlagPayload('checkout-copy');\n\
675             const variant = useFeatureFlagVariantKey('pricing-test');",
676        );
677
678        let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
679        assert_eq!(names, ["new-checkout", "checkout-copy", "pricing-test"]);
680        assert!(
681            flags
682                .iter()
683                .all(|flag| flag.sdk_name.as_deref() == Some("PostHog"))
684        );
685    }
686
687    #[test]
688    fn detects_vercel_flags_object_key_and_core_evaluate_from_imports() {
689        let flags = extract_from_source(
690            "import { flag, evaluate as evalFlag } from 'flags/next';\n\
691             export const showSale = flag({ key: 'summer-sale', decide: () => false });\n\
692             const value = await evalFlag('show-new-feature', false);",
693        );
694
695        let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
696        assert_eq!(names, ["summer-sale", "show-new-feature"]);
697        assert!(
698            flags
699                .iter()
700                .all(|flag| flag.sdk_name.as_deref() == Some("Vercel Flags"))
701        );
702    }
703
704    #[test]
705    fn detects_vercel_flags_namespace_imports() {
706        let flags = extract_from_source(
707            "import * as vercelFlags from '@vercel/flags';\n\
708             const value = await vercelFlags.evaluate('show-new-feature', false);\n\
709             export const showSale = vercelFlags.flag({ key: 'summer-sale', decide: () => false });",
710        );
711
712        let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
713        assert_eq!(names, ["show-new-feature", "summer-sale"]);
714        assert!(
715            flags
716                .iter()
717                .all(|flag| flag.sdk_name.as_deref() == Some("Vercel Flags"))
718        );
719    }
720
721    #[test]
722    fn detects_vercel_flags_calls_before_import_declaration() {
723        let flags = extract_from_source(
724            "export const showSale = flag({ key: 'summer-sale', decide: () => false });\n\
725             import { flag } from 'flags/next';",
726        );
727
728        assert_eq!(flags.len(), 1);
729        assert_eq!(flags[0].flag_name, "summer-sale");
730        assert_eq!(flags[0].sdk_name.as_deref(), Some("Vercel Flags"));
731    }
732
733    #[test]
734    fn ignores_unimported_vercel_like_function_names() {
735        let flags = extract_from_source(
736            "function math() { return evaluate('2 + 2'); }\n\
737             function marker() { return flag({ key: 'ui-row' }); }",
738        );
739
740        assert!(flags.is_empty());
741    }
742
743    #[test]
744    fn detects_configcat_detail_evaluation() {
745        let flags = extract_from_source(
746            "const details = await client.getValueDetailsAsync('new-checkout', false);",
747        );
748        assert_eq!(flags.len(), 1);
749        assert_eq!(flags[0].flag_name, "new-checkout");
750        assert_eq!(flags[0].sdk_name.as_deref(), Some("ConfigCat"));
751    }
752
753    #[test]
754    fn detects_optimizely_decisions_and_variables() {
755        let flags = extract_from_source(
756            "const [decision] = useDecision('checkout-flow');\n\
757             const copy = optimizelyClient.getFeatureVariableString('checkout-flow', 'copy', userId, attrs);\n\
758             const json = optimizelyClient.getFeatureVariableJson('checkout-flow', 'json', userId, attrs);",
759        );
760
761        assert_eq!(flags.len(), 3);
762        assert!(flags.iter().all(|flag| flag.flag_name == "checkout-flow"));
763        assert!(
764            flags
765                .iter()
766                .all(|flag| flag.sdk_name.as_deref() == Some("Optimizely"))
767        );
768    }
769
770    #[test]
771    fn detects_eppo_typed_assignments() {
772        let flags = extract_from_source(
773            "const value = client.getBooleanAssignment('new-onboarding', subject, {}, false);\n\
774             const details = client.getStringAssignmentDetails('copy-test', subject, {}, 'control');\n\
775             const payload = client.getJSONAssignmentDetails('payload-test', subject, {}, {});",
776        );
777
778        let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
779        assert_eq!(names, ["new-onboarding", "copy-test", "payload-test"]);
780        assert!(
781            flags
782                .iter()
783                .all(|flag| flag.sdk_name.as_deref() == Some("Eppo"))
784        );
785    }
786
787    #[test]
788    fn ignores_sdk_call_without_string_arg() {
789        let flags = extract_from_source("useFlag(dynamicKey);");
790        assert!(flags.is_empty());
791    }
792
793    #[test]
794    fn config_objects_off_by_default() {
795        let flags = extract_from_source("if (config.features.newCheckout) {}");
796        assert!(flags.is_empty());
797    }
798
799    #[test]
800    fn detects_config_features_when_enabled() {
801        let flags = extract_with_config_objects("if (config.features.newCheckout) {}");
802        assert_eq!(flags.len(), 1);
803        assert_eq!(flags[0].flag_name, "features.newCheckout");
804        assert_eq!(flags[0].kind, FlagUseKind::ConfigObject);
805    }
806
807    #[test]
808    fn detects_flags_object() {
809        let flags = extract_with_config_objects("if (flags.enableV2) {}");
810        assert_eq!(flags.len(), 1);
811        assert_eq!(flags[0].flag_name, "flags.enableV2");
812    }
813
814    #[test]
815    fn ignores_non_flag_config_object() {
816        let flags = extract_with_config_objects("const host = config.database.host;");
817        assert!(flags.is_empty());
818    }
819
820    #[test]
821    fn captures_if_guard_span() {
822        let source = "if (process.env.FEATURE_X) {\n  doStuff();\n}";
823        let flags = extract_from_source(source);
824        assert_eq!(flags.len(), 1);
825        assert!(flags[0].guard_span_start.is_some());
826        assert!(flags[0].guard_span_end.is_some());
827    }
828
829    #[test]
830    fn captures_ternary_guard_span() {
831        let source = "const x = useFlag('beta') ? newFlow() : oldFlow();";
832        let flags = extract_from_source(source);
833        assert_eq!(flags.len(), 1);
834        assert!(flags[0].guard_span_start.is_some());
835    }
836
837    #[test]
838    fn detects_custom_sdk_pattern() {
839        let allocator = Allocator::default();
840        let source = "isFeatureActive('my-flag');";
841        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
842        let line_offsets = fallow_types::extract::compute_line_offsets(source);
843        let custom = vec![("isFeatureActive".to_string(), 0, "Internal".to_string())];
844        let flags = extract_flags(&parser_return.program, &line_offsets, &custom, &[], false);
845        assert_eq!(flags.len(), 1);
846        assert_eq!(flags[0].flag_name, "my-flag");
847        assert_eq!(flags[0].sdk_name.as_deref(), Some("Internal"));
848    }
849
850    #[test]
851    fn custom_sdk_pattern_can_use_vercel_object_function_name() {
852        let allocator = Allocator::default();
853        let source = "flag('internal-flag');";
854        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
855        let line_offsets = fallow_types::extract::compute_line_offsets(source);
856        let custom = vec![("flag".to_string(), 0, "Internal".to_string())];
857        let flags = extract_flags(&parser_return.program, &line_offsets, &custom, &[], false);
858        assert_eq!(flags.len(), 1);
859        assert_eq!(flags[0].flag_name, "internal-flag");
860        assert_eq!(flags[0].sdk_name.as_deref(), Some("Internal"));
861    }
862
863    #[test]
864    fn detects_custom_env_prefix() {
865        let allocator = Allocator::default();
866        let source = "if (process.env.MYAPP_ENABLE_V2) {}";
867        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
868        let line_offsets = fallow_types::extract::compute_line_offsets(source);
869        let custom_prefixes = vec!["MYAPP_ENABLE_".to_string()];
870        let flags = extract_flags(
871            &parser_return.program,
872            &line_offsets,
873            &[],
874            &custom_prefixes,
875            false,
876        );
877        assert_eq!(flags.len(), 1);
878        assert_eq!(flags[0].flag_name, "MYAPP_ENABLE_V2");
879    }
880
881    #[test]
882    fn builtin_sdk_providers_are_distinct_and_ordered() {
883        let providers = builtin_sdk_providers();
884        assert!(!providers.is_empty());
885        let mut sorted = providers.clone();
886        sorted.sort_unstable();
887        sorted.dedup();
888        assert_eq!(
889            sorted.len(),
890            providers.len(),
891            "providers must be distinct: {providers:?}"
892        );
893        assert!(
894            !providers.contains(&""),
895            "empty provider labels must not leak into the surfaced list"
896        );
897        assert_eq!(providers.first(), Some(&"LaunchDarkly"));
898        assert_eq!(providers.last(), Some(&VERCEL_FLAGS_PROVIDER));
899    }
900
901    #[test]
902    fn builtin_env_prefixes_match_source_constant() {
903        let prefixes = builtin_env_prefixes();
904        assert_eq!(prefixes, BUILTIN_ENV_PREFIXES);
905        assert!(prefixes.contains(&"FEATURE_"));
906        assert!(prefixes.contains(&"TOGGLE_"));
907    }
908}