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