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