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`, `import.meta.env.VITE_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, env prefixes, and config
11//! object heuristics from the `flags` config section apply in the same pass.
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 oxc_span::ContentEq;
18use rustc_hash::{FxHashMap, FxHashSet};
19
20use fallow_types::extract::{
21    FlagConstant, FlagConstantRead, FlagDefinition, FlagKeyRegistry, FlagPatterns,
22    FlagRegistryFacts, FlagRegistryRead, FlagSiteFacts, FlagUse, FlagUseKind,
23    byte_offset_to_line_col,
24};
25use oxc_semantic::ScopeFlags;
26
27/// Built-in SDK function patterns: (function_name, name_arg_index, provider_label).
28const BUILTIN_SDK_PATTERNS: &[(&str, usize, &str)] = &[
29    ("useFlag", 0, "LaunchDarkly"),
30    ("useLDFlag", 0, "LaunchDarkly"),
31    ("useFeatureFlag", 0, "LaunchDarkly"),
32    ("variation", 0, "LaunchDarkly"),
33    ("boolVariation", 0, "LaunchDarkly"),
34    ("stringVariation", 0, "LaunchDarkly"),
35    ("numberVariation", 0, "LaunchDarkly"),
36    ("jsonVariation", 0, "LaunchDarkly"),
37    ("useGate", 0, "Statsig"),
38    ("checkGate", 0, "Statsig"),
39    ("useExperiment", 0, "Statsig"),
40    ("useConfig", 0, "Statsig"),
41    ("isEnabled", 0, "Unleash"),
42    ("getVariant", 0, "Unleash"),
43    ("isOn", 0, "GrowthBook"),
44    ("isOff", 0, "GrowthBook"),
45    ("getFeatureValue", 0, "GrowthBook"),
46    ("getTreatment", 0, "Split"),
47    ("useFeatureFlagEnabled", 0, "PostHog"),
48    ("useFeatureFlagPayload", 0, "PostHog"),
49    ("useFeatureFlagVariantKey", 0, "PostHog"),
50    ("getFeatureFlagPayload", 0, "PostHog"),
51    ("getValueAsync", 0, "ConfigCat"),
52    ("getValueDetailsAsync", 0, "ConfigCat"),
53    ("hasFeature", 0, "Flagsmith"),
54    ("useDecision", 0, "Optimizely"),
55    ("getFeatureVariable", 0, "Optimizely"),
56    ("getFeatureVariableBoolean", 0, "Optimizely"),
57    ("getFeatureVariableString", 0, "Optimizely"),
58    ("getFeatureVariableInteger", 0, "Optimizely"),
59    ("getFeatureVariableDouble", 0, "Optimizely"),
60    ("getFeatureVariableJson", 0, "Optimizely"),
61    ("getFeatureVariableJSON", 0, "Optimizely"),
62    ("getStringAssignment", 0, "Eppo"),
63    ("getBooleanAssignment", 0, "Eppo"),
64    ("getNumericAssignment", 0, "Eppo"),
65    ("getIntegerAssignment", 0, "Eppo"),
66    ("getJSONAssignment", 0, "Eppo"),
67    ("getStringAssignmentDetails", 0, "Eppo"),
68    ("getBooleanAssignmentDetails", 0, "Eppo"),
69    ("getNumericAssignmentDetails", 0, "Eppo"),
70    ("getIntegerAssignmentDetails", 0, "Eppo"),
71    ("getJSONAssignmentDetails", 0, "Eppo"),
72    ("getValue", 0, ""),
73    ("useFeature", 0, ""),
74    ("getFeatureFlag", 0, ""),
75];
76
77/// Built-in SDK names that other libraries also use, such as a form
78/// library `getValue`. A call to one of these names is a certain flag site
79/// only when the file imports a flag SDK or a flag module.
80const GENERIC_SDK_NAMES: &[&str] = &["getValue", "isEnabled", "useFeature"];
81
82/// Case-insensitive parts of an import source that show a flag SDK or a
83/// flag module, such as `@unleash/proxy-client-react` or `./featureFlags`.
84const FLAG_SOURCE_MARKERS: &[&str] = &[
85    "flag",
86    "feature",
87    "toggle",
88    "launchdarkly",
89    "statsig",
90    "unleash",
91    "growthbook",
92    "splitio",
93    "posthog",
94    "configcat",
95    "optimizely",
96    "@eppo/",
97];
98
99const VERCEL_FLAGS_PROVIDER: &str = "Vercel Flags";
100const VERCEL_FLAGS_FUNCTIONS: &[&str] = &["flag", "evaluate"];
101
102/// Built-in environment variable prefixes that indicate feature flags.
103const BUILTIN_ENV_PREFIXES: &[&str] = &[
104    "FEATURE_",
105    "NEXT_PUBLIC_FEATURE_",
106    "NEXT_PUBLIC_ENABLE_",
107    "REACT_APP_FEATURE_",
108    "REACT_APP_ENABLE_",
109    "VITE_FEATURE_",
110    "VITE_ENABLE_",
111    "NUXT_PUBLIC_FEATURE_",
112    "ENABLE_",
113    "FF_",
114    "FLAG_",
115    "TOGGLE_",
116];
117
118/// Distinct built-in SDK provider labels, in declaration order.
119///
120/// Used by `fallow flags` to tell the user which SDKs the default detectors
121/// cover when no flags are found. Derived from `BUILTIN_SDK_PATTERNS` (empty
122/// provider labels skipped) with the import-based Vercel Flags provider appended,
123/// so the surfaced list stays in sync with what is actually detected.
124#[must_use]
125pub fn builtin_sdk_providers() -> Vec<&'static str> {
126    let mut providers: Vec<&'static str> = Vec::new();
127    for &(_, _, provider) in BUILTIN_SDK_PATTERNS {
128        if !provider.is_empty() && !providers.contains(&provider) {
129            providers.push(provider);
130        }
131    }
132    if !providers.contains(&VERCEL_FLAGS_PROVIDER) {
133        providers.push(VERCEL_FLAGS_PROVIDER);
134    }
135    providers
136}
137
138/// Built-in environment variable prefixes treated as feature flags.
139///
140/// Used by `fallow flags` to surface the default env-prefix detectors in the
141/// empty-result hint. Returns the source-of-truth `BUILTIN_ENV_PREFIXES`.
142#[must_use]
143pub fn builtin_env_prefixes() -> &'static [&'static str] {
144    BUILTIN_ENV_PREFIXES
145}
146
147/// Config object names that heuristically indicate feature flag namespaces.
148const CONFIG_OBJECT_KEYWORDS: &[&str] = &[
149    "feature",
150    "features",
151    "featureFlags",
152    "featureFlag",
153    "flag",
154    "flags",
155    "toggle",
156    "toggles",
157];
158
159/// A flag read that a later guard can attach to.
160#[derive(Debug, Clone, Copy)]
161enum FlagRef {
162    /// Index into `FlagVisitor::results`.
163    Resolved(usize),
164    /// Index into `FlagVisitor::registry_reads`.
165    Registry(usize),
166}
167
168/// The flag-name argument of an SDK call.
169enum FlagNameArg {
170    /// A string literal: `useFlag('new-checkout')`.
171    Literal(String),
172    /// A registry member: `useFlag(FLAGS.NewCheckout)`.
173    RegistryMember { registry: String, member: String },
174}
175
176/// A guarded byte range and the facts about its branches.
177#[derive(Debug, Clone, Copy)]
178struct Guard {
179    start: u32,
180    end: u32,
181    facts: FlagSiteFacts,
182}
183
184/// AST visitor that detects feature flag patterns.
185struct FlagVisitor<'a> {
186    results: Vec<FlagUse>,
187    /// Reads whose key is a member of an imported registry.
188    registry_reads: Vec<FlagRegistryRead>,
189    line_offsets: &'a [u32],
190    /// Extra SDK patterns from user config.
191    extra_sdk_patterns: &'a [(String, usize, String)],
192    /// Extra env prefixes from user config.
193    extra_env_prefixes: &'a [String],
194    /// Whether to detect config object patterns (opt-in).
195    config_object_heuristics: bool,
196    /// Local named imports from Vercel Flags packages: local name -> imported name.
197    vercel_flags_imports: FxHashMap<String, String>,
198    /// Namespace imports from Vercel Flags packages.
199    vercel_flags_namespaces: FxHashSet<String>,
200    /// Module-level registries: binding name -> (member, flag key) pairs.
201    local_registries: FxHashMap<String, Vec<(String, String)>>,
202    /// Local names of the value bindings that named imports create.
203    named_imports: FxHashSet<String>,
204    /// Registries the module exports.
205    exported_registries: Vec<FlagKeyRegistry>,
206    /// Module-level literal `const` bindings with a flag-style name.
207    constants: Vec<FlagConstant>,
208    /// Binding name -> index into `constants`.
209    literal_consts: FxHashMap<String, usize>,
210    /// Start offsets of the identifiers that are direct operands of the
211    /// current guard test, such as `X` in `if (!X)` or `if (X === 'on')`.
212    guard_operands: Vec<u32>,
213    /// Start offset of each Vercel `flag()` call -> index into `results`.
214    definition_calls: FxHashMap<u32, usize>,
215    /// Vercel `flag()` calls bound to a `const`.
216    definitions: Vec<FlagDefinition>,
217    /// Guard of the test expression the visitor is in, if any.
218    current_guard: Option<Guard>,
219    /// End offsets of the enclosing blocks, innermost last.
220    block_ends: Vec<u32>,
221    /// `const` bindings per function scope, innermost last. `None` marks a
222    /// binding that shadows an outer flag binding.
223    binding_scopes: Vec<FxHashMap<String, Option<FlagRef>>>,
224    /// Number of flag bindings recorded. Zero skips the binding bookkeeping.
225    flag_binding_count: usize,
226    /// Registry names that a parameter or a local binding shadows, per
227    /// function scope and block, innermost last. Empty at module level.
228    shadowed_registries: Vec<FxHashSet<String>>,
229    /// The most recent read the visitor recorded.
230    last_ref: Option<FlagRef>,
231    /// Start offset of the most recent read.
232    last_read_start: Option<u32>,
233    /// The declarators under visit belong to a `const` declaration.
234    in_const_declaration: bool,
235    /// Whether the file imports a flag SDK or a flag module.
236    has_flag_import: bool,
237    /// Reads of a generic SDK name that no flag import confirms.
238    unconfirmed_reads: Vec<FlagRef>,
239}
240
241impl<'a> FlagVisitor<'a> {
242    fn new(
243        line_offsets: &'a [u32],
244        extra_sdk_patterns: &'a [(String, usize, String)],
245        extra_env_prefixes: &'a [String],
246        config_object_heuristics: bool,
247    ) -> Self {
248        Self {
249            results: Vec::new(),
250            registry_reads: Vec::new(),
251            line_offsets,
252            extra_sdk_patterns,
253            extra_env_prefixes,
254            config_object_heuristics,
255            vercel_flags_imports: FxHashMap::default(),
256            vercel_flags_namespaces: FxHashSet::default(),
257            local_registries: FxHashMap::default(),
258            named_imports: FxHashSet::default(),
259            exported_registries: Vec::new(),
260            constants: Vec::new(),
261            literal_consts: FxHashMap::default(),
262            guard_operands: Vec::new(),
263            definition_calls: FxHashMap::default(),
264            definitions: Vec::new(),
265            current_guard: None,
266            block_ends: Vec::new(),
267            binding_scopes: vec![FxHashMap::default()],
268            flag_binding_count: 0,
269            shadowed_registries: Vec::new(),
270            last_ref: None,
271            last_read_start: None,
272            in_const_declaration: false,
273            has_flag_import: false,
274            unconfirmed_reads: Vec::new(),
275        }
276    }
277
278    fn read_count(&self) -> usize {
279        self.results.len() + self.registry_reads.len()
280    }
281
282    fn new_flag_use(
283        &self,
284        flag_name: String,
285        kind: FlagUseKind,
286        offset: u32,
287        sdk_name: Option<String>,
288    ) -> FlagUse {
289        let (line, col) = byte_offset_to_line_col(self.line_offsets, offset);
290        FlagUse {
291            flag_name,
292            kind,
293            line,
294            col,
295            guard_span_start: self.current_guard.map(|guard| guard.start),
296            guard_span_end: self.current_guard.map(|guard| guard.end),
297            sdk_name,
298            facts: self
299                .current_guard
300                .map_or_else(FlagSiteFacts::default, |guard| guard.facts),
301        }
302    }
303
304    fn push_flag_use(
305        &mut self,
306        flag_name: String,
307        kind: FlagUseKind,
308        offset: u32,
309        sdk_name: Option<String>,
310    ) {
311        let flag_use = self.new_flag_use(flag_name, kind, offset, sdk_name);
312        self.last_ref = Some(FlagRef::Resolved(self.results.len()));
313        self.last_read_start = Some(offset);
314        self.results.push(flag_use);
315    }
316
317    /// Check if a member expression reads `process.env.X` or `import.meta.env.X`.
318    fn check_env_var(&mut self, expr: &StaticMemberExpression<'_>) {
319        if let Some(env_name) = extract_env_name(expr)
320            && self.is_flag_env_name(env_name)
321        {
322            self.push_flag_use(
323                env_name.to_string(),
324                FlagUseKind::EnvVar,
325                expr.span.start,
326                None,
327            );
328        }
329    }
330
331    /// Check if a call expression matches an SDK pattern.
332    fn check_sdk_call(&mut self, call: &CallExpression<'_>) {
333        let func_name = match &call.callee {
334            Expression::Identifier(id) => id.name.as_str(),
335            Expression::StaticMemberExpression(member) => member.property.name.as_str(),
336            _ => return,
337        };
338
339        if self.check_vercel_flags_call(call) {
340            return;
341        }
342
343        let extra_sdk_patterns: &'a [(String, usize, String)] = self.extra_sdk_patterns;
344        let pattern = BUILTIN_SDK_PATTERNS
345            .iter()
346            .find(|(name, _, _)| *name == func_name)
347            .map(|&(_, name_arg_idx, provider)| (name_arg_idx, provider))
348            .or_else(|| {
349                extra_sdk_patterns
350                    .iter()
351                    .find(|(name, _, _)| name == func_name)
352                    .map(|(_, name_arg_idx, provider)| (*name_arg_idx, provider.as_str()))
353            });
354        let Some((name_arg_idx, provider)) = pattern else {
355            return;
356        };
357        let sdk_name = (!provider.is_empty()).then(|| provider.to_string());
358        let reads_before = self.read_count();
359
360        match extract_flag_name_arg(&call.arguments, name_arg_idx) {
361            Some(FlagNameArg::Literal(flag_name)) => {
362                self.push_flag_use(flag_name, FlagUseKind::SdkCall, call.span.start, sdk_name);
363            }
364            Some(FlagNameArg::RegistryMember { registry, member }) => {
365                self.record_registry_read(registry, member, call.span.start, sdk_name);
366            }
367            None => {}
368        }
369
370        if self.read_count() > reads_before
371            && !self.has_flag_import
372            && self.is_generic_sdk_name(func_name)
373            && let Some(flag_ref) = self.last_ref
374        {
375            self.unconfirmed_reads.push(flag_ref);
376        }
377    }
378
379    /// Whether `name` is a generic built-in SDK name that the user did not
380    /// list in `sdkPatterns`. A listed name is the user's own SDK.
381    fn is_generic_sdk_name(&self, name: &str) -> bool {
382        GENERIC_SDK_NAMES.contains(&name)
383            && !self
384                .extra_sdk_patterns
385                .iter()
386                .any(|(pattern, _, _)| pattern == name)
387    }
388
389    /// Mark the reads of a generic SDK name that no flag import confirms.
390    /// This runs after the walk, because a guard that attaches to a bound
391    /// read replaces the facts of the read.
392    fn mark_unconfirmed_reads(&mut self) {
393        for flag_ref in std::mem::take(&mut self.unconfirmed_reads) {
394            let flag_use = match flag_ref {
395                FlagRef::Resolved(index) => self.results.get_mut(index),
396                FlagRef::Registry(index) => self
397                    .registry_reads
398                    .get_mut(index)
399                    .map(|read| &mut read.flag_use),
400            };
401            if let Some(flag_use) = flag_use {
402                flag_use.facts = flag_use.facts.with_unconfirmed_sdk(true);
403            }
404        }
405    }
406
407    /// Record a read whose key is `registry.member`. A module-level registry
408    /// resolves at once. A named import waits for project analysis, which
409    /// resolves it through the import. Any other name is not a registry.
410    fn record_registry_read(
411        &mut self,
412        registry: String,
413        member: String,
414        offset: u32,
415        sdk_name: Option<String>,
416    ) {
417        if self
418            .shadowed_registries
419            .iter()
420            .any(|names| names.contains(&registry))
421        {
422            return;
423        }
424        if let Some(members) = self.local_registries.get(&registry) {
425            let key = members
426                .iter()
427                .find(|(name, _)| *name == member)
428                .map(|(_, key)| key.clone());
429            if let Some(key) = key {
430                self.push_flag_use(key, FlagUseKind::SdkCall, offset, sdk_name);
431            }
432            return;
433        }
434        if !self.named_imports.contains(&registry) {
435            return;
436        }
437        let flag_use = self.new_flag_use(String::new(), FlagUseKind::SdkCall, offset, sdk_name);
438        self.last_ref = Some(FlagRef::Registry(self.registry_reads.len()));
439        self.last_read_start = Some(offset);
440        self.registry_reads.push(FlagRegistryRead {
441            registry,
442            member,
443            flag_use,
444        });
445    }
446
447    fn check_vercel_flags_call(&mut self, call: &CallExpression<'_>) -> bool {
448        let Some(imported_name) = self.vercel_flags_imported_name(call) else {
449            return false;
450        };
451
452        let flag_name = match imported_name {
453            "flag" => extract_object_string_property_arg(&call.arguments, 0, "key"),
454            "evaluate" => extract_string_arg(&call.arguments, 0),
455            _ => None,
456        };
457
458        let Some(flag_name) = flag_name else {
459            return false;
460        };
461
462        let defines = imported_name == "flag";
463        self.push_flag_use(
464            flag_name,
465            FlagUseKind::SdkCall,
466            call.span.start,
467            Some(VERCEL_FLAGS_PROVIDER.to_string()),
468        );
469        if defines {
470            self.definition_calls
471                .insert(call.span.start, self.results.len() - 1);
472        }
473        true
474    }
475
476    /// Mark a Vercel `flag()` call that initializes a `const` as the flag's
477    /// definition, and record the binding that holds it.
478    fn record_definition(&mut self, decl: &VariableDeclarator<'_>, binding: &str) {
479        if self.definition_calls.is_empty() || !self.in_const_declaration {
480            return;
481        }
482        let Some(Expression::CallExpression(call)) = decl.init.as_ref().map(unwrap_value) else {
483            return;
484        };
485        let Some(&index) = self.definition_calls.get(&call.span.start) else {
486            return;
487        };
488        let Some(flag_use) = self.results.get_mut(index) else {
489            return;
490        };
491        flag_use.facts = flag_use.facts.with_definition(true);
492        self.definitions.push(FlagDefinition {
493            binding: binding.to_string(),
494            line: flag_use.line,
495            col: flag_use.col,
496        });
497    }
498
499    fn vercel_flags_imported_name<'b>(&'b self, call: &'b CallExpression<'_>) -> Option<&'b str> {
500        match &call.callee {
501            Expression::Identifier(id) => self
502                .vercel_flags_imports
503                .get(id.name.as_str())
504                .map(String::as_str),
505            Expression::StaticMemberExpression(member) => {
506                let Expression::Identifier(object) = &member.object else {
507                    return None;
508                };
509                self.vercel_flags_namespaces
510                    .contains(object.name.as_str())
511                    .then_some(member.property.name.as_str())
512            }
513            _ => None,
514        }
515    }
516
517    fn collect_imports(&mut self, program: &Program<'_>) {
518        for stmt in &program.body {
519            match stmt {
520                Statement::ImportDeclaration(decl) => {
521                    self.collect_vercel_flags_import(decl);
522                    self.collect_named_imports(decl);
523                    if imports_values(decl) && is_flag_source(decl.source.value.as_str()) {
524                        self.has_flag_import = true;
525                    }
526                }
527                Statement::VariableDeclaration(decl) if requires_flag_source(decl) => {
528                    self.has_flag_import = true;
529                }
530                Statement::TSImportEqualsDeclaration(decl) if import_equals_flag_source(decl) => {
531                    self.has_flag_import = true;
532                }
533                _ => {}
534            }
535        }
536    }
537
538    fn collect_named_imports(&mut self, decl: &ImportDeclaration<'_>) {
539        if decl.import_kind.is_type() {
540            return;
541        }
542        for spec in decl.specifiers.iter().flatten() {
543            if let ImportDeclarationSpecifier::ImportSpecifier(specifier) = spec
544                && !specifier.import_kind.is_type()
545            {
546                self.named_imports.insert(specifier.local.name.to_string());
547            }
548        }
549    }
550
551    fn collect_vercel_flags_import(&mut self, decl: &ImportDeclaration<'_>) {
552        if !is_vercel_flags_source(decl.source.value.as_str()) || decl.import_kind.is_type() {
553            return;
554        }
555
556        let Some(specifiers) = &decl.specifiers else {
557            return;
558        };
559
560        for spec in specifiers {
561            match spec {
562                ImportDeclarationSpecifier::ImportSpecifier(specifier) => {
563                    if specifier.import_kind.is_type() {
564                        continue;
565                    }
566                    let imported_name = specifier.imported.name();
567                    if VERCEL_FLAGS_FUNCTIONS.contains(&imported_name.as_str()) {
568                        self.vercel_flags_imports
569                            .insert(specifier.local.name.to_string(), imported_name.to_string());
570                    }
571                }
572                ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => {
573                    self.vercel_flags_namespaces
574                        .insert(specifier.local.name.to_string());
575                }
576                ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => {}
577            }
578        }
579    }
580
581    /// Collect module-level `as const` objects and enums with string members,
582    /// and note which of them the module exports.
583    fn collect_flag_registries(&mut self, program: &Program<'_>) {
584        let mut exports: Vec<(String, String)> = Vec::new();
585        for stmt in &program.body {
586            match stmt {
587                Statement::VariableDeclaration(decl) => {
588                    self.collect_const_object_registries(decl);
589                    self.collect_literal_constants(decl);
590                }
591                Statement::TSEnumDeclaration(enumd) => {
592                    self.collect_enum_registry(enumd);
593                }
594                Statement::ExportDeclaration(export) => {
595                    if let Declaration::VariableDeclaration(decl) = &export.declaration {
596                        self.collect_literal_constants(decl);
597                    }
598                    let declared = self.collect_declared_registries(&export.declaration);
599                    exports.extend(declared.into_iter().map(|name| (name.clone(), name)));
600                }
601                Statement::ExportNamedDeclaration(export) => {
602                    collect_exported_names(export, &mut exports);
603                }
604                _ => {}
605            }
606        }
607        for (local, exported) in exports {
608            if let Some(members) = self.local_registries.get(&local) {
609                self.exported_registries.push(FlagKeyRegistry {
610                    export_name: exported,
611                    members: members.clone(),
612                });
613            }
614        }
615    }
616
617    /// Record the registries that an `export <declaration>` declares, and
618    /// return their names.
619    fn collect_declared_registries(&mut self, declaration: &Declaration<'_>) -> Vec<String> {
620        match declaration {
621            Declaration::VariableDeclaration(decl) => self.collect_const_object_registries(decl),
622            Declaration::TSEnumDeclaration(enumd) => {
623                self.collect_enum_registry(enumd).into_iter().collect()
624            }
625            _ => Vec::new(),
626        }
627    }
628
629    /// Record each `const NAME = { ... } as const` registry in `decl` and
630    /// return the registry names.
631    fn collect_const_object_registries(&mut self, decl: &VariableDeclaration<'_>) -> Vec<String> {
632        let mut names = Vec::new();
633        if !decl.kind.is_const() {
634            return names;
635        }
636        for declarator in &decl.declarations {
637            let BindingPattern::BindingIdentifier(id) = &declarator.id else {
638                continue;
639            };
640            let Some(members) = declarator.init.as_ref().and_then(as_const_object_members) else {
641                continue;
642            };
643            let name = id.name.to_string();
644            self.local_registries.insert(name.clone(), members);
645            names.push(name);
646        }
647        names
648    }
649
650    /// Record each `const NAME = <literal>` in `decl` whose name has a flag
651    /// prefix. A bundler `define` replaces free identifiers only, so a
652    /// declared binding keeps the value in the source.
653    fn collect_literal_constants(&mut self, decl: &VariableDeclaration<'_>) {
654        if !decl.kind.is_const() {
655            return;
656        }
657        for declarator in &decl.declarations {
658            let BindingPattern::BindingIdentifier(id) = &declarator.id else {
659                continue;
660            };
661            if !self.is_flag_env_name(id.name.as_str()) {
662                continue;
663            }
664            let Some(value) = declarator.init.as_ref().and_then(literal_source) else {
665                continue;
666            };
667            let (line, col) = byte_offset_to_line_col(self.line_offsets, id.span.start);
668            self.literal_consts
669                .insert(id.name.to_string(), self.constants.len());
670            self.constants.push(FlagConstant {
671                name: id.name.to_string(),
672                value,
673                line,
674                col,
675                reads: Vec::new(),
676            });
677        }
678    }
679
680    /// Record a guard test that reads a literal constant.
681    fn record_constant_read(&mut self, ident: &IdentifierReference<'_>, guard: Guard) {
682        let name = ident.name.as_str();
683        let Some(&index) = self.literal_consts.get(name) else {
684            return;
685        };
686        if !self.guard_operands.contains(&ident.span.start) {
687            return;
688        }
689        if self
690            .shadowed_registries
691            .iter()
692            .any(|names| names.contains(name))
693        {
694            return;
695        }
696        let (line, col) = byte_offset_to_line_col(self.line_offsets, ident.span.start);
697        if let Some(constant) = self.constants.get_mut(index) {
698            constant.reads.push(FlagConstantRead {
699                line,
700                col,
701                facts: guard.facts,
702            });
703        }
704    }
705
706    /// Record an enum with string members as a registry and return its name.
707    fn collect_enum_registry(&mut self, enumd: &TSEnumDeclaration<'_>) -> Option<String> {
708        let members: Vec<(String, String)> = enumd
709            .body
710            .members
711            .iter()
712            .filter_map(|member| {
713                let value = string_value(member.initializer.as_ref()?)?;
714                let name = match &member.id {
715                    TSEnumMemberName::Identifier(id) => id.name.to_string(),
716                    TSEnumMemberName::String(name) | TSEnumMemberName::ComputedString(name) => {
717                        name.value.to_string()
718                    }
719                    TSEnumMemberName::ComputedTemplateString(_) => return None,
720                };
721                Some((name, value))
722            })
723            .collect();
724        if members.is_empty() {
725            return None;
726        }
727        let name = enumd.id.name.to_string();
728        self.local_registries.insert(name.clone(), members);
729        Some(name)
730    }
731
732    /// Check if a member expression matches a config object pattern.
733    fn check_config_object(&mut self, expr: &StaticMemberExpression<'_>) -> bool {
734        if !self.config_object_heuristics {
735            return false;
736        }
737
738        let Some((obj_name, prop_name)) = extract_config_object_access(expr) else {
739            return false;
740        };
741        if !CONFIG_OBJECT_KEYWORDS
742            .iter()
743            .any(|kw| obj_name.eq_ignore_ascii_case(kw) || prop_name.eq_ignore_ascii_case(kw))
744        {
745            return false;
746        }
747        self.push_flag_use(
748            format!("{obj_name}.{prop_name}"),
749            FlagUseKind::ConfigObject,
750            expr.span.start,
751            None,
752        );
753        true
754    }
755
756    fn is_flag_env_name(&self, name: &str) -> bool {
757        BUILTIN_ENV_PREFIXES
758            .iter()
759            .any(|prefix| name.starts_with(prefix))
760            || self
761                .extra_env_prefixes
762                .iter()
763                .any(|prefix| name.starts_with(prefix.as_str()))
764    }
765
766    /// Walk a test expression with `guard` as the guard of every read in it.
767    fn visit_guard_test<'b>(&mut self, test: &Expression<'b>, guard: Guard)
768    where
769        Self: Visit<'b>,
770    {
771        let outer = self.current_guard.replace(guard);
772        let outer_operands = if self.literal_consts.is_empty() {
773            None
774        } else {
775            let mut operands = Vec::new();
776            collect_direct_operands(test, &mut operands);
777            Some(std::mem::replace(&mut self.guard_operands, operands))
778        };
779        self.visit_expression(test);
780        if let Some(outer_operands) = outer_operands {
781            self.guard_operands = outer_operands;
782        }
783        self.current_guard = outer;
784    }
785
786    fn lookup_binding(&self, name: &str) -> Option<FlagRef> {
787        if self.flag_binding_count == 0 {
788            return None;
789        }
790        self.binding_scopes
791            .iter()
792            .rev()
793            .find_map(|scope| scope.get(name))
794            .copied()
795            .flatten()
796    }
797
798    fn bind(&mut self, name: &str, flag_ref: Option<FlagRef>) {
799        if flag_ref.is_none() && self.lookup_binding(name).is_none() {
800            return;
801        }
802        if flag_ref.is_some() {
803            self.flag_binding_count += 1;
804        }
805        if let Some(scope) = self.binding_scopes.last_mut() {
806            scope.insert(name.to_string(), flag_ref);
807        }
808    }
809
810    /// Give a bound read the first guard that tests its binding.
811    fn attach_guard(&mut self, flag_ref: FlagRef, guard: Guard) {
812        let flag_use = match flag_ref {
813            FlagRef::Resolved(index) => self.results.get_mut(index),
814            FlagRef::Registry(index) => self
815                .registry_reads
816                .get_mut(index)
817                .map(|read| &mut read.flag_use),
818        };
819        if let Some(flag_use) = flag_use
820            && flag_use.guard_span_start.is_none()
821        {
822            flag_use.guard_span_start = Some(guard.start);
823            flag_use.guard_span_end = Some(guard.end);
824            flag_use.facts = guard.facts;
825        }
826    }
827
828    fn visit_function_scope(&mut self, walk_scope: impl FnOnce(&mut Self)) {
829        self.binding_scopes.push(FxHashMap::default());
830        self.shadowed_registries.push(FxHashSet::default());
831        walk_scope(self);
832        self.shadowed_registries.pop();
833        self.binding_scopes.pop();
834    }
835
836    /// Note a parameter or a local binding that has the name of a registry.
837    /// Module-level bindings declare the registries, so they do not count.
838    fn note_binding(&mut self, name: &str) {
839        let is_registry_name = self.local_registries.contains_key(name)
840            || self.named_imports.contains(name)
841            || self.literal_consts.contains_key(name);
842        if !is_registry_name {
843            return;
844        }
845        if let Some(names) = self.shadowed_registries.last_mut() {
846            names.insert(name.to_string());
847        }
848    }
849
850    fn visit_block(&mut self, end: u32, walk_block: impl FnOnce(&mut Self)) {
851        self.block_ends.push(end);
852        walk_block(self);
853        self.block_ends.pop();
854    }
855}
856
857impl<'a> Visit<'a> for FlagVisitor<'_> {
858    fn visit_program(&mut self, program: &Program<'a>) {
859        self.collect_imports(program);
860        self.collect_flag_registries(program);
861        self.visit_block(program.span.end, |visitor| {
862            walk::walk_program(visitor, program);
863        });
864    }
865
866    fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
867        self.collect_vercel_flags_import(decl);
868    }
869
870    fn visit_if_statement(&mut self, stmt: &IfStatement<'a>) {
871        // `if (!flag) return;` guards the rest of the enclosing block, not
872        // only the `if` statement.
873        let guard_end = if stmt.alternate.is_none()
874            && is_negated_test(&stmt.test)
875            && exits_block(&stmt.consequent)
876        {
877            self.block_ends
878                .last()
879                .map_or(stmt.span.end, |&end| end.max(stmt.span.end))
880        } else {
881            stmt.span.end
882        };
883        // The flag does nothing only when no branch holds code. Code in one
884        // branch runs or stops when the flag changes, whatever the polarity.
885        let facts = FlagSiteFacts::default()
886            .with_empty_branch(
887                is_empty_statement(&stmt.consequent)
888                    && stmt.alternate.as_ref().is_none_or(is_empty_statement),
889            )
890            .with_identical_branches(
891                stmt.alternate
892                    .as_ref()
893                    .is_some_and(|alternate| stmt.consequent.content_eq(alternate)),
894            );
895        self.visit_guard_test(
896            &stmt.test,
897            Guard {
898                start: stmt.span.start,
899                end: guard_end,
900                facts,
901            },
902        );
903
904        self.visit_statement(&stmt.consequent);
905        if let Some(alt) = &stmt.alternate {
906            self.visit_statement(alt);
907        }
908    }
909
910    fn visit_conditional_expression(&mut self, expr: &ConditionalExpression<'a>) {
911        let facts = FlagSiteFacts::default()
912            .with_empty_branch(
913                is_empty_value(&expr.consequent, &expr.alternate)
914                    && is_empty_value(&expr.alternate, &expr.consequent),
915            )
916            .with_identical_branches(expr.consequent.content_eq(&expr.alternate));
917        self.visit_guard_test(
918            &expr.test,
919            Guard {
920                start: expr.span.start,
921                end: expr.span.end,
922                facts,
923            },
924        );
925
926        self.visit_expression(&expr.consequent);
927        self.visit_expression(&expr.alternate);
928    }
929
930    fn visit_logical_expression(&mut self, expr: &LogicalExpression<'a>) {
931        if expr.operator == LogicalOperator::And && is_jsx(&expr.right) {
932            let facts = FlagSiteFacts::default().with_empty_branch(is_empty_fragment(&expr.right));
933            self.visit_guard_test(
934                &expr.left,
935                Guard {
936                    start: expr.span.start,
937                    end: expr.span.end,
938                    facts,
939                },
940            );
941            self.visit_expression(&expr.right);
942            return;
943        }
944        walk::walk_logical_expression(self, expr);
945    }
946
947    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
948        self.check_sdk_call(call);
949        walk::walk_call_expression(self, call);
950    }
951
952    fn visit_member_expression(&mut self, expr: &MemberExpression<'a>) {
953        if let MemberExpression::StaticMemberExpression(static_expr) = expr {
954            self.check_env_var(static_expr);
955            // The object of a matched config access (`config.features` in
956            // `config.features.x`) is part of the same read.
957            if self.check_config_object(static_expr) {
958                return;
959            }
960        }
961        walk::walk_member_expression(self, expr);
962    }
963
964    fn visit_identifier_reference(&mut self, ident: &IdentifierReference<'a>) {
965        let Some(guard) = self.current_guard else {
966            return;
967        };
968        if let Some(flag_ref) = self.lookup_binding(ident.name.as_str()) {
969            self.attach_guard(flag_ref, guard);
970        }
971        if !self.literal_consts.is_empty() {
972            self.record_constant_read(ident, guard);
973        }
974    }
975
976    fn visit_variable_declaration(&mut self, decl: &VariableDeclaration<'a>) {
977        let outer = std::mem::replace(&mut self.in_const_declaration, decl.kind.is_const());
978        walk::walk_variable_declaration(self, decl);
979        self.in_const_declaration = outer;
980    }
981
982    fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'a>) {
983        let before = self.read_count();
984        walk::walk_variable_declarator(self, decl);
985        let BindingPattern::BindingIdentifier(id) = &decl.id else {
986            return;
987        };
988        self.record_definition(decl, id.name.as_str());
989        let flag_ref = (self.in_const_declaration
990            && self.read_count() == before + 1
991            && decl.init.as_ref().and_then(flag_value_read_start) == self.last_read_start)
992            .then_some(self.last_ref)
993            .flatten();
994        self.bind(id.name.as_str(), flag_ref);
995    }
996
997    fn visit_function(&mut self, func: &Function<'a>, flags: ScopeFlags) {
998        self.visit_function_scope(|visitor| walk::walk_function(visitor, func, flags));
999    }
1000
1001    fn visit_arrow_function_expression(&mut self, func: &ArrowFunctionExpression<'a>) {
1002        self.visit_function_scope(|visitor| walk::walk_arrow_function_expression(visitor, func));
1003    }
1004
1005    fn visit_function_body(&mut self, body: &FunctionBody<'a>) {
1006        self.visit_block(body.span.end, |visitor| {
1007            walk::walk_function_body(visitor, body);
1008        });
1009    }
1010
1011    fn visit_block_statement(&mut self, block: &BlockStatement<'a>) {
1012        self.shadowed_registries.push(FxHashSet::default());
1013        self.visit_block(block.span.end, |visitor| {
1014            walk::walk_block_statement(visitor, block);
1015        });
1016        self.shadowed_registries.pop();
1017    }
1018
1019    fn visit_binding_identifier(&mut self, ident: &BindingIdentifier<'a>) {
1020        self.note_binding(ident.name.as_str());
1021    }
1022}
1023
1024/// Whether an import source names a flag SDK or a flag module.
1025fn is_flag_source(source: &str) -> bool {
1026    let source = source.to_ascii_lowercase();
1027    FLAG_SOURCE_MARKERS
1028        .iter()
1029        .any(|marker| source.contains(marker))
1030}
1031
1032/// Whether an import declaration brings in a value at runtime. A
1033/// declaration of only inline type specifiers, as in
1034/// `import { type Flags } from './flags'`, is erased like `import type`.
1035fn imports_values(decl: &ImportDeclaration<'_>) -> bool {
1036    if decl.import_kind.is_type() {
1037        return false;
1038    }
1039    let Some(specifiers) = &decl.specifiers else {
1040        return true;
1041    };
1042    specifiers.is_empty()
1043        || specifiers.iter().any(|spec| match spec {
1044            ImportDeclarationSpecifier::ImportSpecifier(specifier) => {
1045                !specifier.import_kind.is_type()
1046            }
1047            _ => true,
1048        })
1049}
1050
1051/// Whether a TypeScript `import x = require('...')` names a flag SDK or a
1052/// flag module.
1053fn import_equals_flag_source(decl: &TSImportEqualsDeclaration<'_>) -> bool {
1054    if decl.import_kind.is_type() {
1055        return false;
1056    }
1057    let TSModuleReference::ExternalModuleReference(reference) = &decl.module_reference else {
1058        return false;
1059    };
1060    is_flag_source(reference.expression.value.as_str())
1061}
1062
1063/// Whether a declaration requires a flag SDK or a flag module, as in
1064/// `const { initialize } = require('unleash-client')`.
1065fn requires_flag_source(decl: &VariableDeclaration<'_>) -> bool {
1066    decl.declarations
1067        .iter()
1068        .filter_map(|declarator| declarator.init.as_ref())
1069        .filter_map(required_source)
1070        .any(is_flag_source)
1071}
1072
1073/// The source of `require('x')`, also through a member access such as
1074/// `require('x').init`.
1075fn required_source<'b>(expr: &'b Expression<'_>) -> Option<&'b str> {
1076    let call = match unwrap_value(expr) {
1077        Expression::CallExpression(call) => call,
1078        Expression::StaticMemberExpression(member) => match unwrap_value(&member.object) {
1079            Expression::CallExpression(call) => call,
1080            _ => return None,
1081        },
1082        _ => return None,
1083    };
1084    let Expression::Identifier(callee) = &call.callee else {
1085        return None;
1086    };
1087    if callee.name != "require" {
1088        return None;
1089    }
1090    match call.arguments.first() {
1091        Some(Argument::StringLiteral(source)) => Some(source.value.as_str()),
1092        _ => None,
1093    }
1094}
1095
1096fn is_vercel_flags_source(source: &str) -> bool {
1097    source == "flags"
1098        || source.starts_with("flags/")
1099        || source == "@vercel/flags"
1100        || source.starts_with("@vercel/flags/")
1101}
1102
1103/// Strip parentheses and TypeScript-only wrappers that do not change a value.
1104fn unwrap_value<'b, 'a>(mut expr: &'b Expression<'a>) -> &'b Expression<'a> {
1105    loop {
1106        expr = match expr {
1107            Expression::ParenthesizedExpression(inner) => &inner.expression,
1108            Expression::TSAsExpression(inner) => &inner.expression,
1109            Expression::TSSatisfiesExpression(inner) => &inner.expression,
1110            Expression::TSNonNullExpression(inner) => &inner.expression,
1111            _ => return expr,
1112        };
1113    }
1114}
1115
1116fn is_jsx(expr: &Expression<'_>) -> bool {
1117    matches!(
1118        unwrap_value(expr),
1119        Expression::JSXElement(_) | Expression::JSXFragment(_)
1120    )
1121}
1122
1123/// Collect the identifiers that a guard test reads as its value: the test
1124/// itself, a negation, an operand of `&&` or `||`, or the side of an
1125/// equality test whose other side is a literal. An identifier inside a call
1126/// or a member access is not a direct operand.
1127fn collect_direct_operands(test: &Expression<'_>, operands: &mut Vec<u32>) {
1128    match unwrap_value(test) {
1129        Expression::Identifier(ident) => operands.push(ident.span.start),
1130        Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::LogicalNot => {
1131            collect_direct_operands(&unary.argument, operands);
1132        }
1133        Expression::LogicalExpression(logical) => {
1134            collect_direct_operands(&logical.left, operands);
1135            collect_direct_operands(&logical.right, operands);
1136        }
1137        Expression::BinaryExpression(binary) if binary.operator.is_equality() => {
1138            if is_literal(&binary.right) {
1139                collect_direct_operands(&binary.left, operands);
1140            } else if is_literal(&binary.left) {
1141                collect_direct_operands(&binary.right, operands);
1142            }
1143        }
1144        _ => {}
1145    }
1146}
1147
1148/// Whether a branch statement does nothing: `;` or `{}`.
1149fn is_empty_statement(stmt: &Statement<'_>) -> bool {
1150    match stmt {
1151        Statement::EmptyStatement(_) => true,
1152        Statement::BlockStatement(block) => block.body.is_empty(),
1153        _ => false,
1154    }
1155}
1156
1157/// Whether a ternary arm renders or yields nothing: `null`, `undefined`,
1158/// `void 0`, `<></>`, or `false` when the other arm is JSX.
1159fn is_empty_value(arm: &Expression<'_>, other: &Expression<'_>) -> bool {
1160    match unwrap_value(arm) {
1161        Expression::NullLiteral(_) => true,
1162        Expression::Identifier(id) => id.name == "undefined",
1163        Expression::UnaryExpression(unary) => unary.operator == UnaryOperator::Void,
1164        Expression::BooleanLiteral(boolean) => !boolean.value && is_jsx(other),
1165        _ => is_empty_fragment(arm),
1166    }
1167}
1168
1169/// Whether an expression is a JSX fragment without children.
1170fn is_empty_fragment(expr: &Expression<'_>) -> bool {
1171    matches!(unwrap_value(expr), Expression::JSXFragment(fragment) if fragment.children.is_empty())
1172}
1173
1174fn is_negated_test(expr: &Expression<'_>) -> bool {
1175    matches!(
1176        unwrap_value(expr),
1177        Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::LogicalNot
1178    )
1179}
1180
1181/// Whether a statement always leaves the enclosing block.
1182fn exits_block(stmt: &Statement<'_>) -> bool {
1183    match stmt {
1184        Statement::ReturnStatement(_) | Statement::ThrowStatement(_) => true,
1185        Statement::BlockStatement(block) => block.body.last().is_some_and(exits_block),
1186        _ => false,
1187    }
1188}
1189
1190/// Start offset of the flag read whose value a `const` initializer is, so
1191/// that its binding stands for the flag. Accepts the read itself, `await`,
1192/// negation, and an equality test against a literal.
1193fn flag_value_read_start(expr: &Expression<'_>) -> Option<u32> {
1194    match unwrap_value(expr) {
1195        Expression::AwaitExpression(inner) => flag_value_read_start(&inner.argument),
1196        Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::LogicalNot => {
1197            flag_value_read_start(&unary.argument)
1198        }
1199        Expression::BinaryExpression(binary) if binary.operator.is_equality() => {
1200            if is_literal(&binary.right) {
1201                flag_value_read_start(&binary.left)
1202            } else if is_literal(&binary.left) {
1203                flag_value_read_start(&binary.right)
1204            } else {
1205                None
1206            }
1207        }
1208        Expression::CallExpression(call) => Some(call.span.start),
1209        Expression::StaticMemberExpression(member) => Some(member.span.start),
1210        _ => None,
1211    }
1212}
1213
1214/// The source form of a boolean, number or string literal.
1215fn literal_source(expr: &Expression<'_>) -> Option<String> {
1216    match unwrap_value(expr) {
1217        Expression::BooleanLiteral(boolean) => Some(boolean.value.to_string()),
1218        Expression::NumericLiteral(number) => Some(
1219            number
1220                .raw
1221                .as_ref()
1222                .map_or_else(|| number.value.to_string(), ToString::to_string),
1223        ),
1224        other => string_value(other).map(|value| format!("'{value}'")),
1225    }
1226}
1227
1228fn is_literal(expr: &Expression<'_>) -> bool {
1229    matches!(
1230        unwrap_value(expr),
1231        Expression::StringLiteral(_)
1232            | Expression::BooleanLiteral(_)
1233            | Expression::NumericLiteral(_)
1234            | Expression::NullLiteral(_)
1235    )
1236}
1237
1238/// Members of an `{ ... } as const` object with string values, in order.
1239fn as_const_object_members(init: &Expression<'_>) -> Option<Vec<(String, String)>> {
1240    let mut expr = init;
1241    loop {
1242        expr = match expr {
1243            Expression::ParenthesizedExpression(inner) => &inner.expression,
1244            Expression::TSSatisfiesExpression(inner) => &inner.expression,
1245            _ => break,
1246        };
1247    }
1248    let Expression::TSAsExpression(as_expr) = expr else {
1249        return None;
1250    };
1251    if !as_expr.type_annotation.is_const_type_reference() {
1252        return None;
1253    }
1254    let Expression::ObjectExpression(object) = unwrap_value(&as_expr.expression) else {
1255        return None;
1256    };
1257    let members: Vec<(String, String)> = object
1258        .properties
1259        .iter()
1260        .filter_map(|property| {
1261            let ObjectPropertyKind::ObjectProperty(property) = property else {
1262                return None;
1263            };
1264            if property.computed {
1265                return None;
1266            }
1267            let name = property.key.static_name()?.to_string();
1268            Some((name, string_value(&property.value)?))
1269        })
1270        .collect();
1271    (!members.is_empty()).then_some(members)
1272}
1273
1274/// The value of a string literal or of a template literal with no
1275/// substitutions.
1276fn string_value(expr: &Expression<'_>) -> Option<String> {
1277    match unwrap_value(expr) {
1278        Expression::StringLiteral(lit) => Some(lit.value.to_string()),
1279        Expression::TemplateLiteral(template) if template.expressions.is_empty() => template
1280            .quasis
1281            .first()
1282            .and_then(|quasi| quasi.value.cooked.as_ref())
1283            .map(ToString::to_string),
1284        _ => None,
1285    }
1286}
1287
1288/// Record the local `export { local as exported }` specifiers of a module.
1289/// A type-only export names no runtime registry.
1290fn collect_exported_names(
1291    export: &ExportNamedDeclaration<'_>,
1292    exports: &mut Vec<(String, String)>,
1293) {
1294    if export.export_kind.is_type() {
1295        return;
1296    }
1297    for spec in &export.specifiers {
1298        if !spec.export_kind.is_type() {
1299            exports.push((
1300                spec.local.name().to_string(),
1301                spec.exported.name().to_string(),
1302            ));
1303        }
1304    }
1305}
1306
1307/// Extract the environment variable name from `process.env.X` or
1308/// `import.meta.env.X`.
1309fn extract_env_name<'b>(expr: &'b StaticMemberExpression<'_>) -> Option<&'b str> {
1310    let Expression::StaticMemberExpression(inner) = &expr.object else {
1311        return None;
1312    };
1313    if inner.property.name.as_str() != "env" {
1314        return None;
1315    }
1316    let is_env_object = match &inner.object {
1317        Expression::Identifier(id) => id.name.as_str() == "process",
1318        Expression::ImportMeta(_) => true,
1319        _ => false,
1320    };
1321    is_env_object.then(|| expr.property.name.as_str())
1322}
1323
1324/// Extract a string literal argument at the given index.
1325fn extract_string_arg(args: &[Argument<'_>], index: usize) -> Option<String> {
1326    args.get(index).and_then(|arg| {
1327        if let Argument::StringLiteral(lit) = arg {
1328            Some(lit.value.to_string())
1329        } else {
1330            None
1331        }
1332    })
1333}
1334
1335/// Extract the flag-name argument at `index`: a string literal, or a member
1336/// of a registry (`FLAGS.X` or `FLAGS['X']`).
1337fn extract_flag_name_arg(args: &[Argument<'_>], index: usize) -> Option<FlagNameArg> {
1338    match args.get(index)? {
1339        Argument::StringLiteral(lit) => Some(FlagNameArg::Literal(lit.value.to_string())),
1340        Argument::StaticMemberExpression(member) => {
1341            let Expression::Identifier(object) = &member.object else {
1342                return None;
1343            };
1344            Some(FlagNameArg::RegistryMember {
1345                registry: object.name.to_string(),
1346                member: member.property.name.to_string(),
1347            })
1348        }
1349        Argument::ComputedMemberExpression(member) => {
1350            let (Expression::Identifier(object), Expression::StringLiteral(key)) =
1351                (&member.object, &member.expression)
1352            else {
1353                return None;
1354            };
1355            Some(FlagNameArg::RegistryMember {
1356                registry: object.name.to_string(),
1357                member: key.value.to_string(),
1358            })
1359        }
1360        _ => None,
1361    }
1362}
1363
1364/// Extract a string property from an object argument at the given index.
1365fn extract_object_string_property_arg(
1366    args: &[Argument<'_>],
1367    index: usize,
1368    property_name: &str,
1369) -> Option<String> {
1370    let Some(Argument::ObjectExpression(obj)) = args.get(index) else {
1371        return None;
1372    };
1373
1374    for prop in &obj.properties {
1375        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
1376            continue;
1377        };
1378        if prop
1379            .key
1380            .static_name()
1381            .is_some_and(|key| key.as_ref() == property_name)
1382            && let Expression::StringLiteral(lit) = &prop.value
1383        {
1384            return Some(lit.value.to_string());
1385        }
1386    }
1387
1388    None
1389}
1390
1391/// Extract config object access pattern: `obj.prop` where either name is a flag keyword.
1392fn extract_config_object_access(expr: &StaticMemberExpression<'_>) -> Option<(String, String)> {
1393    let prop_name = expr.property.name.to_string();
1394
1395    match &expr.object {
1396        Expression::Identifier(id) => Some((id.name.to_string(), prop_name)),
1397        Expression::StaticMemberExpression(inner) => {
1398            if matches!(&inner.object, Expression::Identifier(_)) {
1399                Some((inner.property.name.to_string(), prop_name))
1400            } else {
1401                None
1402            }
1403        }
1404        _ => None,
1405    }
1406}
1407
1408/// Feature flag facts extracted from one parsed program.
1409#[derive(Debug, Default)]
1410pub(crate) struct ExtractedFlags {
1411    /// Reads with a known flag key.
1412    pub flag_uses: Vec<FlagUse>,
1413    /// Registries the module exports and reads through imported registries.
1414    /// `None` when the module has neither.
1415    pub registry_facts: Option<Box<FlagRegistryFacts>>,
1416}
1417
1418/// Entry point: extract feature flag use sites from a parsed program.
1419///
1420/// Called unconditionally from `parse_source_to_module` for all parsed files,
1421/// with the user patterns of the `flags` config section. The parse cache keys
1422/// on those patterns, so one parse gives every flag.
1423pub(crate) fn extract_flags(
1424    program: &Program<'_>,
1425    line_offsets: &[u32],
1426    patterns: &FlagPatterns,
1427) -> ExtractedFlags {
1428    let mut visitor = FlagVisitor::new(
1429        line_offsets,
1430        &patterns.sdk_patterns,
1431        &patterns.env_prefixes,
1432        patterns.config_object_heuristics,
1433    );
1434    visitor.visit_program(program);
1435    visitor.mark_unconfirmed_reads();
1436    let registry_facts = FlagRegistryFacts {
1437        registries: visitor.exported_registries,
1438        reads: visitor.registry_reads,
1439        constants: visitor
1440            .constants
1441            .into_iter()
1442            .filter(|constant| !constant.reads.is_empty())
1443            .collect(),
1444        definitions: visitor.definitions,
1445    };
1446    ExtractedFlags {
1447        flag_uses: visitor.results,
1448        registry_facts: (!registry_facts.is_empty()).then(|| Box::new(registry_facts)),
1449    }
1450}
1451
1452#[cfg(all(test, not(miri)))]
1453mod tests {
1454    use super::*;
1455    use oxc_allocator::Allocator;
1456    use oxc_parser::Parser;
1457    use oxc_span::SourceType;
1458
1459    fn extract_from_source(source: &str) -> Vec<FlagUse> {
1460        let allocator = Allocator::default();
1461        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
1462        let line_offsets = fallow_types::extract::compute_line_offsets(source);
1463        extract_flags(
1464            &parser_return.program,
1465            &line_offsets,
1466            &FlagPatterns::default(),
1467        )
1468        .flag_uses
1469    }
1470
1471    fn extract_with_config_objects(source: &str) -> Vec<FlagUse> {
1472        let allocator = Allocator::default();
1473        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
1474        let line_offsets = fallow_types::extract::compute_line_offsets(source);
1475        extract_flags(
1476            &parser_return.program,
1477            &line_offsets,
1478            &FlagPatterns {
1479                config_object_heuristics: true,
1480                ..FlagPatterns::default()
1481            },
1482        )
1483        .flag_uses
1484    }
1485
1486    #[test]
1487    fn detects_process_env_feature_flag() {
1488        let flags = extract_from_source("if (process.env.FEATURE_NEW_CHECKOUT) { doStuff(); }");
1489        assert_eq!(flags.len(), 1);
1490        assert_eq!(flags[0].flag_name, "FEATURE_NEW_CHECKOUT");
1491        assert_eq!(flags[0].kind, FlagUseKind::EnvVar);
1492        assert!(flags[0].guard_span_start.is_some());
1493    }
1494
1495    #[test]
1496    fn detects_next_public_enable_prefix() {
1497        let flags = extract_from_source("if (process.env.NEXT_PUBLIC_ENABLE_BETA) {}");
1498        assert_eq!(flags.len(), 1);
1499        assert_eq!(flags[0].flag_name, "NEXT_PUBLIC_ENABLE_BETA");
1500    }
1501
1502    #[test]
1503    fn ignores_non_flag_env_vars() {
1504        let flags = extract_from_source("const url = process.env.DATABASE_URL;");
1505        assert!(flags.is_empty());
1506    }
1507
1508    #[test]
1509    fn detects_negated_env_flag() {
1510        let flags = extract_from_source("if (!process.env.FEATURE_X) { fallback(); }");
1511        assert_eq!(flags.len(), 1);
1512        assert_eq!(flags[0].flag_name, "FEATURE_X");
1513    }
1514
1515    #[test]
1516    fn detects_launchdarkly_use_flag() {
1517        let flags = extract_from_source("const flag = useFlag('new-checkout');");
1518        assert_eq!(flags.len(), 1);
1519        assert_eq!(flags[0].flag_name, "new-checkout");
1520        assert_eq!(flags[0].kind, FlagUseKind::SdkCall);
1521        assert_eq!(flags[0].sdk_name.as_deref(), Some("LaunchDarkly"));
1522    }
1523
1524    #[test]
1525    fn detects_statsig_use_gate() {
1526        let flags = extract_from_source("if (useGate('beta-feature')) {}");
1527        assert_eq!(flags.len(), 1);
1528        assert_eq!(flags[0].flag_name, "beta-feature");
1529        assert_eq!(flags[0].sdk_name.as_deref(), Some("Statsig"));
1530    }
1531
1532    #[test]
1533    fn detects_unleash_is_enabled() {
1534        let flags = extract_from_source("client.isEnabled('feature-x')");
1535        assert_eq!(flags.len(), 1);
1536        assert_eq!(flags[0].flag_name, "feature-x");
1537    }
1538
1539    #[test]
1540    fn detects_growthbook_get_feature_value() {
1541        let flags = extract_from_source("const val = getFeatureValue('parser', false);");
1542        assert_eq!(flags.len(), 1);
1543        assert_eq!(flags[0].flag_name, "parser");
1544        assert_eq!(flags[0].sdk_name.as_deref(), Some("GrowthBook"));
1545    }
1546
1547    #[test]
1548    fn detects_posthog_hooks() {
1549        let flags = extract_from_source(
1550            "const enabled = useFeatureFlagEnabled('new-checkout');\n\
1551             const payload = useFeatureFlagPayload('checkout-copy');\n\
1552             const variant = useFeatureFlagVariantKey('pricing-test');",
1553        );
1554
1555        let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
1556        assert_eq!(names, ["new-checkout", "checkout-copy", "pricing-test"]);
1557        assert!(
1558            flags
1559                .iter()
1560                .all(|flag| flag.sdk_name.as_deref() == Some("PostHog"))
1561        );
1562    }
1563
1564    #[test]
1565    fn detects_vercel_flags_object_key_and_core_evaluate_from_imports() {
1566        let flags = extract_from_source(
1567            "import { flag, evaluate as evalFlag } from 'flags/next';\n\
1568             export const showSale = flag({ key: 'summer-sale', decide: () => false });\n\
1569             const value = await evalFlag('show-new-feature', false);",
1570        );
1571
1572        let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
1573        assert_eq!(names, ["summer-sale", "show-new-feature"]);
1574        assert!(
1575            flags
1576                .iter()
1577                .all(|flag| flag.sdk_name.as_deref() == Some("Vercel Flags"))
1578        );
1579    }
1580
1581    #[test]
1582    fn detects_vercel_flags_namespace_imports() {
1583        let flags = extract_from_source(
1584            "import * as vercelFlags from '@vercel/flags';\n\
1585             const value = await vercelFlags.evaluate('show-new-feature', false);\n\
1586             export const showSale = vercelFlags.flag({ key: 'summer-sale', decide: () => false });",
1587        );
1588
1589        let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
1590        assert_eq!(names, ["show-new-feature", "summer-sale"]);
1591        assert!(
1592            flags
1593                .iter()
1594                .all(|flag| flag.sdk_name.as_deref() == Some("Vercel Flags"))
1595        );
1596    }
1597
1598    #[test]
1599    fn detects_vercel_flags_calls_before_import_declaration() {
1600        let flags = extract_from_source(
1601            "export const showSale = flag({ key: 'summer-sale', decide: () => false });\n\
1602             import { flag } from 'flags/next';",
1603        );
1604
1605        assert_eq!(flags.len(), 1);
1606        assert_eq!(flags[0].flag_name, "summer-sale");
1607        assert_eq!(flags[0].sdk_name.as_deref(), Some("Vercel Flags"));
1608    }
1609
1610    #[test]
1611    fn ignores_unimported_vercel_like_function_names() {
1612        let flags = extract_from_source(
1613            "function math() { return evaluate('2 + 2'); }\n\
1614             function marker() { return flag({ key: 'ui-row' }); }",
1615        );
1616
1617        assert!(flags.is_empty());
1618    }
1619
1620    #[test]
1621    fn detects_configcat_detail_evaluation() {
1622        let flags = extract_from_source(
1623            "const details = await client.getValueDetailsAsync('new-checkout', false);",
1624        );
1625        assert_eq!(flags.len(), 1);
1626        assert_eq!(flags[0].flag_name, "new-checkout");
1627        assert_eq!(flags[0].sdk_name.as_deref(), Some("ConfigCat"));
1628    }
1629
1630    #[test]
1631    fn detects_optimizely_decisions_and_variables() {
1632        let flags = extract_from_source(
1633            "const [decision] = useDecision('checkout-flow');\n\
1634             const copy = optimizelyClient.getFeatureVariableString('checkout-flow', 'copy', userId, attrs);\n\
1635             const json = optimizelyClient.getFeatureVariableJson('checkout-flow', 'json', userId, attrs);",
1636        );
1637
1638        assert_eq!(flags.len(), 3);
1639        assert!(flags.iter().all(|flag| flag.flag_name == "checkout-flow"));
1640        assert!(
1641            flags
1642                .iter()
1643                .all(|flag| flag.sdk_name.as_deref() == Some("Optimizely"))
1644        );
1645    }
1646
1647    #[test]
1648    fn detects_eppo_typed_assignments() {
1649        let flags = extract_from_source(
1650            "const value = client.getBooleanAssignment('new-onboarding', subject, {}, false);\n\
1651             const details = client.getStringAssignmentDetails('copy-test', subject, {}, 'control');\n\
1652             const payload = client.getJSONAssignmentDetails('payload-test', subject, {}, {});",
1653        );
1654
1655        let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
1656        assert_eq!(names, ["new-onboarding", "copy-test", "payload-test"]);
1657        assert!(
1658            flags
1659                .iter()
1660                .all(|flag| flag.sdk_name.as_deref() == Some("Eppo"))
1661        );
1662    }
1663
1664    #[test]
1665    fn ignores_sdk_call_without_string_arg() {
1666        let flags = extract_from_source("useFlag(dynamicKey);");
1667        assert!(flags.is_empty());
1668    }
1669
1670    #[test]
1671    fn config_objects_off_by_default() {
1672        let flags = extract_from_source("if (config.features.newCheckout) {}");
1673        assert!(flags.is_empty());
1674    }
1675
1676    #[test]
1677    fn detects_config_features_when_enabled() {
1678        let flags = extract_with_config_objects("if (config.features.newCheckout) {}");
1679        assert_eq!(flags.len(), 1);
1680        assert_eq!(flags[0].flag_name, "features.newCheckout");
1681        assert_eq!(flags[0].kind, FlagUseKind::ConfigObject);
1682    }
1683
1684    #[test]
1685    fn detects_flags_object() {
1686        let flags = extract_with_config_objects("if (flags.enableV2) {}");
1687        assert_eq!(flags.len(), 1);
1688        assert_eq!(flags[0].flag_name, "flags.enableV2");
1689    }
1690
1691    #[test]
1692    fn ignores_non_flag_config_object() {
1693        let flags = extract_with_config_objects("const host = config.database.host;");
1694        assert!(flags.is_empty());
1695    }
1696
1697    #[test]
1698    fn captures_if_guard_span() {
1699        let source = "if (process.env.FEATURE_X) {\n  doStuff();\n}";
1700        let flags = extract_from_source(source);
1701        assert_eq!(flags.len(), 1);
1702        assert!(flags[0].guard_span_start.is_some());
1703        assert!(flags[0].guard_span_end.is_some());
1704    }
1705
1706    #[test]
1707    fn captures_ternary_guard_span() {
1708        let source = "const x = useFlag('beta') ? newFlow() : oldFlow();";
1709        let flags = extract_from_source(source);
1710        assert_eq!(flags.len(), 1);
1711        assert!(flags[0].guard_span_start.is_some());
1712    }
1713
1714    #[test]
1715    fn detects_custom_sdk_pattern() {
1716        let allocator = Allocator::default();
1717        let source = "isFeatureActive('my-flag');";
1718        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
1719        let line_offsets = fallow_types::extract::compute_line_offsets(source);
1720        let custom = FlagPatterns {
1721            sdk_patterns: vec![("isFeatureActive".to_string(), 0, "Internal".to_string())],
1722            ..FlagPatterns::default()
1723        };
1724        let flags = extract_flags(&parser_return.program, &line_offsets, &custom).flag_uses;
1725        assert_eq!(flags.len(), 1);
1726        assert_eq!(flags[0].flag_name, "my-flag");
1727        assert_eq!(flags[0].sdk_name.as_deref(), Some("Internal"));
1728    }
1729
1730    #[test]
1731    fn custom_sdk_pattern_can_use_vercel_object_function_name() {
1732        let allocator = Allocator::default();
1733        let source = "flag('internal-flag');";
1734        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
1735        let line_offsets = fallow_types::extract::compute_line_offsets(source);
1736        let custom = FlagPatterns {
1737            sdk_patterns: vec![("flag".to_string(), 0, "Internal".to_string())],
1738            ..FlagPatterns::default()
1739        };
1740        let flags = extract_flags(&parser_return.program, &line_offsets, &custom).flag_uses;
1741        assert_eq!(flags.len(), 1);
1742        assert_eq!(flags[0].flag_name, "internal-flag");
1743        assert_eq!(flags[0].sdk_name.as_deref(), Some("Internal"));
1744    }
1745
1746    #[test]
1747    fn detects_custom_env_prefix() {
1748        let allocator = Allocator::default();
1749        let source = "if (process.env.MYAPP_ENABLE_V2) {}";
1750        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
1751        let line_offsets = fallow_types::extract::compute_line_offsets(source);
1752        let custom = FlagPatterns {
1753            env_prefixes: vec!["MYAPP_ENABLE_".to_string()],
1754            ..FlagPatterns::default()
1755        };
1756        let flags = extract_flags(&parser_return.program, &line_offsets, &custom).flag_uses;
1757        assert_eq!(flags.len(), 1);
1758        assert_eq!(flags[0].flag_name, "MYAPP_ENABLE_V2");
1759    }
1760
1761    fn extract_facts(source: &str) -> ExtractedFlags {
1762        let allocator = Allocator::default();
1763        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
1764        let line_offsets = fallow_types::extract::compute_line_offsets(source);
1765        extract_flags(
1766            &parser_return.program,
1767            &line_offsets,
1768            &FlagPatterns::default(),
1769        )
1770    }
1771
1772    fn extract_with_sdk_patterns(source: &str, names: &[&str]) -> Vec<FlagUse> {
1773        let allocator = Allocator::default();
1774        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
1775        let line_offsets = fallow_types::extract::compute_line_offsets(source);
1776        let patterns = FlagPatterns {
1777            sdk_patterns: names
1778                .iter()
1779                .map(|name| ((*name).to_string(), 0, "InHouse".to_string()))
1780                .collect(),
1781            ..FlagPatterns::default()
1782        };
1783        extract_flags(&parser_return.program, &line_offsets, &patterns).flag_uses
1784    }
1785
1786    fn unconfirmed(flags: &[FlagUse]) -> Vec<bool> {
1787        flags
1788            .iter()
1789            .map(|flag| flag.facts.unconfirmed_sdk())
1790            .collect()
1791    }
1792
1793    #[test]
1794    fn generic_sdk_names_without_an_sdk_import_are_unconfirmed() {
1795        let flags = extract_from_source(
1796            "client.isEnabled('a');\n\
1797             getValue('b');\n\
1798             useFeature('c');",
1799        );
1800        assert_eq!(unconfirmed(&flags), vec![true, true, true]);
1801    }
1802
1803    #[test]
1804    fn specific_sdk_names_without_an_import_stay_confirmed() {
1805        let flags = extract_from_source(
1806            "useFlag('a');\n\
1807             checkGate('b');\n\
1808             getFeatureValue('c');",
1809        );
1810        assert_eq!(unconfirmed(&flags), vec![false, false, false]);
1811    }
1812
1813    #[test]
1814    fn a_vendor_sdk_import_confirms_generic_sdk_names() {
1815        let flags = extract_from_source(
1816            "import { useUnleashClient } from '@unleash/proxy-client-react';\n\
1817             const client = useUnleashClient();\n\
1818             client.isEnabled('a');",
1819        );
1820        assert_eq!(unconfirmed(&flags), vec![false]);
1821    }
1822
1823    #[test]
1824    fn a_flag_module_import_confirms_generic_sdk_names() {
1825        for source in [
1826            "import { getValue } from './featureFlags';\ngetValue('a');",
1827            "import flags from '@/lib/flags';\nflags.getValue('a');",
1828            "import { useFeature } from '../toggles/client';\nuseFeature('a');",
1829        ] {
1830            let flags = extract_from_source(source);
1831            assert_eq!(unconfirmed(&flags), vec![false], "{source}");
1832        }
1833    }
1834
1835    #[test]
1836    fn a_top_level_require_of_an_sdk_confirms_generic_sdk_names() {
1837        let flags = extract_from_source(
1838            "const { initialize } = require('unleash-client');\n\
1839             const client = initialize({});\n\
1840             client.isEnabled('a');",
1841        );
1842        assert_eq!(unconfirmed(&flags), vec![false]);
1843    }
1844
1845    #[test]
1846    fn an_unrelated_import_does_not_confirm_generic_sdk_names() {
1847        let flags = extract_from_source(
1848            "import { form } from './form';\n\
1849             import type { Flags } from './flags';\n\
1850             form.getValue('email');",
1851        );
1852        assert_eq!(unconfirmed(&flags), vec![true]);
1853    }
1854
1855    #[test]
1856    fn an_import_equals_require_of_an_sdk_confirms_generic_sdk_names() {
1857        let flags = extract_from_source(
1858            "import unleash = require('unleash-client');\n\
1859             const client = unleash.initialize({});\n\
1860             client.isEnabled('a');",
1861        );
1862        assert_eq!(unconfirmed(&flags), vec![false]);
1863    }
1864
1865    #[test]
1866    fn an_import_of_only_inline_types_does_not_confirm_generic_sdk_names() {
1867        let flags = extract_from_source(
1868            "import { type Flags, type Keys } from './flags';\n\
1869             form.getValue('email');",
1870        );
1871        assert_eq!(unconfirmed(&flags), vec![true]);
1872    }
1873
1874    #[test]
1875    fn a_side_effect_or_mixed_import_of_a_flag_module_confirms_generic_sdk_names() {
1876        for source in [
1877            "import './flags';\ngetValue('a');",
1878            "import { type Flags, getValue } from './flags';\ngetValue('a');",
1879        ] {
1880            let flags = extract_from_source(source);
1881            assert_eq!(unconfirmed(&flags), vec![false], "{source}");
1882        }
1883    }
1884
1885    #[test]
1886    fn guarded_and_bound_generic_sites_stay_unconfirmed() {
1887        let flags = extract_from_source(
1888            "if (client.isEnabled('a')) { run(); }\n\
1889             function view() {\n\
1890               const on = getValue('b');\n\
1891               if (on) { run(); }\n\
1892             }",
1893        );
1894        assert_eq!(flags.len(), 2);
1895        assert!(flags.iter().all(|flag| flag.guard_span_start.is_some()));
1896        assert_eq!(unconfirmed(&flags), vec![true, true]);
1897    }
1898
1899    #[test]
1900    fn a_generic_name_in_sdk_patterns_is_confirmed() {
1901        let flags = extract_with_sdk_patterns("client.isEnabled('a');", &["isEnabled"]);
1902        assert_eq!(unconfirmed(&flags), vec![false]);
1903    }
1904
1905    #[test]
1906    fn generic_registry_reads_without_an_sdk_import_are_unconfirmed() {
1907        let facts = extract_facts(
1908            "import { FLAGS } from './keys';\n\
1909             const LOCAL = { A: 'a' } as const;\n\
1910             isEnabled(LOCAL.A);\n\
1911             isEnabled(FLAGS.B);",
1912        );
1913        assert_eq!(unconfirmed(&facts.flag_uses), vec![true]);
1914        let reads = &facts.registry_facts.expect("registry facts").reads;
1915        assert_eq!(reads.len(), 1);
1916        assert!(reads[0].flag_use.facts.unconfirmed_sdk());
1917    }
1918
1919    fn guard_text<'s>(source: &'s str, flag: &FlagUse) -> &'s str {
1920        let start = flag.guard_span_start.expect("guard start") as usize;
1921        let end = flag.guard_span_end.expect("guard end") as usize;
1922        &source[start..end]
1923    }
1924
1925    #[test]
1926    fn detects_import_meta_env_flag() {
1927        let flags = extract_from_source("if (import.meta.env.VITE_FEATURE_CHAT) { chat(); }");
1928        assert_eq!(flags.len(), 1);
1929        assert_eq!(flags[0].flag_name, "VITE_FEATURE_CHAT");
1930        assert_eq!(flags[0].kind, FlagUseKind::EnvVar);
1931        assert!(flags[0].guard_span_start.is_some());
1932    }
1933
1934    #[test]
1935    fn ignores_non_flag_import_meta_env() {
1936        let flags = extract_from_source(
1937            "const url = import.meta.env.VITE_API_URL;\nconst other = import.meta.url;",
1938        );
1939        assert!(flags.is_empty());
1940    }
1941
1942    #[test]
1943    fn resolves_local_as_const_registry_member() {
1944        let flags = extract_from_source(
1945            "const FLAGS = { NewCheckout: 'new-checkout', Beta: `beta` } as const;\n\
1946             const a = useFlag(FLAGS.NewCheckout);\n\
1947             const b = useFlag(FLAGS['Beta']);",
1948        );
1949        let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
1950        assert_eq!(names, ["new-checkout", "beta"]);
1951        assert!(
1952            flags
1953                .iter()
1954                .all(|flag| flag.sdk_name.as_deref() == Some("LaunchDarkly"))
1955        );
1956    }
1957
1958    #[test]
1959    fn resolves_local_enum_registry_member() {
1960        let flags = extract_from_source(
1961            "enum Gates { Beta = 'beta-gate', Count = 3 }\n\
1962             if (useGate(Gates.Beta)) {}\n\
1963             useGate(Gates.Count);",
1964        );
1965        assert_eq!(flags.len(), 1);
1966        assert_eq!(flags[0].flag_name, "beta-gate");
1967        assert!(flags[0].guard_span_start.is_some());
1968    }
1969
1970    #[test]
1971    fn mutable_object_is_not_a_local_registry() {
1972        let facts = extract_facts(
1973            "const FLAGS = { NewCheckout: 'new-checkout' };\nuseFlag(FLAGS.NewCheckout);",
1974        );
1975        assert!(facts.flag_uses.is_empty());
1976        assert!(facts.registry_facts.is_none());
1977    }
1978
1979    #[test]
1980    fn keeps_imported_registry_read_for_project_analysis() {
1981        let source = "import { FLAGS } from './flags';\n\
1982                      if (useFlag(FLAGS.NewCheckout)) { render(); }";
1983        let facts = extract_facts(source);
1984        assert!(facts.flag_uses.is_empty());
1985        let reads = &facts.registry_facts.expect("registry facts").reads;
1986        assert_eq!(reads.len(), 1);
1987        assert_eq!(reads[0].registry, "FLAGS");
1988        assert_eq!(reads[0].member, "NewCheckout");
1989        assert_eq!(reads[0].flag_use.sdk_name.as_deref(), Some("LaunchDarkly"));
1990        assert_eq!(reads[0].flag_use.line, 2);
1991        assert!(reads[0].flag_use.guard_span_start.is_some());
1992    }
1993
1994    #[test]
1995    fn keeps_registry_reads_only_for_named_value_imports() {
1996        let facts = extract_facts(
1997            "import type { TypeFlags } from './types';\n\
1998             import * as all from './flags';\n\
1999             function View(props) {\n\
2000               useFlag(props.flagKey);\n\
2001               useFlag(TypeFlags.A);\n\
2002               useFlag(all.B);\n\
2003             }",
2004        );
2005        assert!(facts.flag_uses.is_empty());
2006        assert!(facts.registry_facts.is_none());
2007    }
2008
2009    #[test]
2010    fn records_exported_registries_only() {
2011        let facts = extract_facts(
2012            "export const FLAGS = { A: 'a' } as const satisfies Record<string, string>;\n\
2013             export enum Gates { B = 'b' }\n\
2014             const Local = { C: 'c' } as const;\n\
2015             const Hidden = { D: 'd' } as const;\n\
2016             export { Local as Renamed };\n\
2017             export const Plain = { E: 'e' };",
2018        );
2019        let registries = facts.registry_facts.expect("registry facts").registries;
2020        let names: Vec<_> = registries
2021            .iter()
2022            .map(|registry| registry.export_name.as_str())
2023            .collect();
2024        assert_eq!(names, ["FLAGS", "Gates", "Renamed"]);
2025        assert_eq!(registries[2].members, [("C".to_string(), "c".to_string())]);
2026    }
2027
2028    #[test]
2029    fn a_binding_that_shadows_a_registry_is_not_a_registry() {
2030        let facts = extract_facts(
2031            "import { FLAGS } from './flags';\n\
2032             const LOCAL = { A: 'local-a' } as const;\n\
2033             export function f(LOCAL) { return useFlag(LOCAL.A); }\n\
2034             function g(FLAGS) { return useFlag(FLAGS.Chat); }\n\
2035             const h = () => { const LOCAL = pick(); return useFlag(LOCAL.A); };\n\
2036             function k() { try { run(); } catch (FLAGS) { useFlag(FLAGS.Chat); } }\n\
2037             export const outer = useFlag(LOCAL.A);\n\
2038             export const imported = () => useFlag(FLAGS.Chat);",
2039        );
2040        let names: Vec<_> = facts
2041            .flag_uses
2042            .iter()
2043            .map(|flag| flag.flag_name.as_str())
2044            .collect();
2045        assert_eq!(names, ["local-a"]);
2046        assert_eq!(facts.flag_uses[0].line, 7);
2047        let reads = &facts.registry_facts.expect("registry facts").reads;
2048        assert_eq!(reads.len(), 1);
2049        assert_eq!(reads[0].flag_use.line, 8);
2050    }
2051
2052    #[test]
2053    fn module_without_registries_has_no_registry_facts() {
2054        let facts = extract_facts("const FLAGS = { A: 1 } as const;\nuseFlag('a');");
2055        assert!(facts.registry_facts.is_none());
2056        assert_eq!(facts.flag_uses.len(), 1);
2057    }
2058
2059    #[test]
2060    fn jsx_logical_and_is_a_guard() {
2061        let source = "const View = () => <div>{useFlag('beta') && <Beta />}</div>;";
2062        let flags = extract_from_source(source);
2063        assert_eq!(flags.len(), 1);
2064        assert_eq!(guard_text(source, &flags[0]), "useFlag('beta') && <Beta />");
2065    }
2066
2067    #[test]
2068    fn logical_and_without_jsx_is_not_a_guard() {
2069        let flags = extract_from_source("const run = useFlag('beta') && start();");
2070        assert_eq!(flags.len(), 1);
2071        assert!(flags[0].guard_span_start.is_none());
2072    }
2073
2074    #[test]
2075    fn const_binding_takes_the_guard_of_a_jsx_ternary() {
2076        let source = "function View() {\n\
2077                        const enabled = useFlag('beta');\n\
2078                        return <div>{enabled ? <New /> : <Old />}</div>;\n\
2079                      }";
2080        let flags = extract_from_source(source);
2081        assert_eq!(flags.len(), 1);
2082        assert_eq!(flags[0].line, 2);
2083        assert_eq!(guard_text(source, &flags[0]), "enabled ? <New /> : <Old />");
2084    }
2085
2086    #[test]
2087    fn const_binding_takes_the_guard_of_a_jsx_logical_and() {
2088        let source = "function View() {\n\
2089                        const enabled = await getFeatureValue('beta', false) === true;\n\
2090                        return <div>{!enabled && <Old />}</div>;\n\
2091                      }";
2092        let flags = extract_from_source(source);
2093        assert_eq!(flags.len(), 1);
2094        assert_eq!(guard_text(source, &flags[0]), "!enabled && <Old />");
2095    }
2096
2097    #[test]
2098    fn negated_early_return_guards_the_rest_of_the_block() {
2099        let source = "function View() {\n\
2100                        const enabled = useFlag('beta');\n\
2101                        if (!enabled) return null;\n\
2102                        return <New />;\n\
2103                      }\n\
2104                      after();";
2105        let flags = extract_from_source(source);
2106        assert_eq!(flags.len(), 1);
2107        assert_eq!(
2108            guard_text(source, &flags[0]),
2109            "if (!enabled) return null;\nreturn <New />;\n}"
2110        );
2111    }
2112
2113    #[test]
2114    fn direct_negated_early_return_guards_the_rest_of_the_block() {
2115        let source = "function run() {\n\
2116                        if (!process.env.FEATURE_JOBS) { log(); return; }\n\
2117                        jobs();\n\
2118                      }";
2119        let flags = extract_from_source(source);
2120        assert_eq!(flags.len(), 1);
2121        assert!(guard_text(source, &flags[0]).ends_with("jobs();\n}"));
2122    }
2123
2124    #[test]
2125    fn early_return_without_negation_keeps_the_if_span() {
2126        let source = "function run() {\n\
2127                        if (process.env.FEATURE_JOBS) return;\n\
2128                        jobs();\n\
2129                      }";
2130        let flags = extract_from_source(source);
2131        assert_eq!(flags.len(), 1);
2132        assert_eq!(
2133            guard_text(source, &flags[0]),
2134            "if (process.env.FEATURE_JOBS) return;"
2135        );
2136    }
2137
2138    #[test]
2139    fn let_binding_does_not_take_a_guard() {
2140        let flags = extract_from_source("let enabled = useFlag('beta');\nif (enabled) { run(); }");
2141        assert_eq!(flags.len(), 1);
2142        assert!(flags[0].guard_span_start.is_none());
2143    }
2144
2145    #[test]
2146    fn shadowed_binding_does_not_take_a_guard() {
2147        let flags = extract_from_source(
2148            "const enabled = useFlag('beta');\n\
2149             function inner() { const enabled = compute(); if (enabled) { run(); } }",
2150        );
2151        assert_eq!(flags.len(), 1);
2152        assert!(flags[0].guard_span_start.is_none());
2153    }
2154
2155    #[test]
2156    fn binding_from_a_wrapped_call_does_not_take_a_guard() {
2157        let flags =
2158            extract_from_source("const enabled = wrap(useFlag('beta'));\nif (enabled) { run(); }");
2159        assert_eq!(flags.len(), 1);
2160        assert!(flags[0].guard_span_start.is_none());
2161    }
2162
2163    #[test]
2164    fn detects_sdk_call_nested_in_an_if_test() {
2165        let flags = extract_from_source("if (variation('beta', false) === true) { run(); }");
2166        assert_eq!(flags.len(), 1);
2167        assert_eq!(flags[0].flag_name, "beta");
2168        assert!(flags[0].guard_span_start.is_some());
2169    }
2170
2171    #[test]
2172    fn config_object_access_is_one_read() {
2173        let flags = extract_with_config_objects("const on = config.features.newCheckout;");
2174        let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
2175        assert_eq!(names, ["features.newCheckout"]);
2176    }
2177
2178    fn only_flag(source: &str) -> FlagUse {
2179        let mut flags = extract_from_source(source);
2180        assert_eq!(flags.len(), 1, "one flag read in {source}");
2181        flags.remove(0)
2182    }
2183
2184    #[test]
2185    fn identical_if_branches_ignore_whitespace_and_comments() {
2186        let flag =
2187            only_flag("if (process.env.FEATURE_X) {\n  run(1);\n} else {\n  run( 1 ) ; // same\n}");
2188        assert!(flag.facts.identical_branches());
2189        assert!(!flag.facts.empty_branch());
2190    }
2191
2192    #[test]
2193    fn a_one_token_difference_is_not_identical() {
2194        let flag = only_flag("if (process.env.FEATURE_X) { run(1); } else { run(2); }");
2195        assert!(!flag.facts.identical_branches());
2196    }
2197
2198    #[test]
2199    fn identical_ternary_arms_are_identical_branches() {
2200        let flag = only_flag("const v = process.env.FEATURE_X ? pick('a') : pick('a');");
2201        assert!(flag.facts.identical_branches());
2202    }
2203
2204    #[test]
2205    fn a_guard_without_code_in_any_branch_is_an_empty_branch() {
2206        for source in [
2207            "if (process.env.FEATURE_X) {}",
2208            "if (process.env.FEATURE_X) ;",
2209            "if (!process.env.FEATURE_X) {}",
2210            "if (process.env.FEATURE_X) {} else {}",
2211            "if (process.env.FEATURE_X === false) ; else {}",
2212        ] {
2213            assert!(only_flag(source).facts.empty_branch(), "{source}");
2214        }
2215    }
2216
2217    #[test]
2218    fn a_guard_with_code_in_one_branch_is_not_an_empty_branch() {
2219        // Turning the flag on or off runs or skips that code, so the flag
2220        // does something, also when the code runs only while the flag is off.
2221        for source in [
2222            "if (process.env.FEATURE_X) { run(); }",
2223            "if (process.env.FEATURE_X) { run(); } else {}",
2224            "if (process.env.FEATURE_X) {} else { run(); }",
2225            "if (!process.env.FEATURE_X) { run(); } else {}",
2226            "if (process.env.FEATURE_X === false) { run(); } else {}",
2227            "if (!process.env.FEATURE_X) return;",
2228        ] {
2229            assert!(!only_flag(source).facts.empty_branch(), "{source}");
2230        }
2231    }
2232
2233    #[test]
2234    fn ternary_and_jsx_guards_are_empty_only_when_no_arm_renders() {
2235        for source in [
2236            "const v = useFlag('beta') ? null : undefined;",
2237            "const v = useFlag('beta') ? <></> : null;",
2238            "const v = useFlag('beta') ? void 0 : null;",
2239            "const v = useFlag('beta') ? false : <></>;",
2240            "const v = <div>{useFlag('beta') && <></>}</div>;",
2241        ] {
2242            assert!(only_flag(source).facts.empty_branch(), "{source}");
2243        }
2244        for source in [
2245            "const v = useFlag('beta') ? null : <Old />;",
2246            "const v = useFlag('beta') ? undefined : <Old />;",
2247            "const v = useFlag('beta') ? false : <Old />;",
2248            "const v = useFlag('beta') ? <></> : <Old />;",
2249            "const v = !useFlag('beta') ? <Beta /> : null;",
2250            "const v = useFlag('beta') ? <Beta /> : null;",
2251            "const v = useFlag('beta') ? <Beta /> : false;",
2252            "const v = useFlag('beta') ? false : 1;",
2253            "const v = <div>{useFlag('beta') && <Beta />}</div>;",
2254        ] {
2255            assert!(!only_flag(source).facts.empty_branch(), "{source}");
2256        }
2257    }
2258
2259    #[test]
2260    fn a_bound_read_takes_the_facts_of_its_guard() {
2261        let flag = only_flag("const on = useFlag('beta');\nif (on) { run(); } else { run(); }");
2262        assert!(flag.facts.identical_branches());
2263    }
2264
2265    #[test]
2266    fn a_read_without_a_guard_has_no_facts() {
2267        let flag = only_flag("track(useFlag('beta'));");
2268        assert_eq!(flag.facts, FlagSiteFacts::default());
2269    }
2270
2271    fn constants(source: &str) -> Vec<FlagConstant> {
2272        extract_facts(source)
2273            .registry_facts
2274            .map(|facts| facts.constants)
2275            .unwrap_or_default()
2276    }
2277
2278    #[test]
2279    fn literal_const_flags_tested_by_a_guard_are_constants() {
2280        let source = "const FEATURE_NEW_UI = true;\n\
2281                      if (FEATURE_NEW_UI) { run(); }\n\
2282                      export const ENABLE_BETA = false;\n\
2283                      export const pick = (): number => (ENABLE_BETA ? 1 : 2);\n\
2284                      const FF_MODE = 'on';\n\
2285                      if (ready && FF_MODE === 'on') { run(); }\n\
2286                      const FEATURE_BANNER = 1;\n\
2287                      export const View = () => <div>{FEATURE_BANNER && <Banner />}</div>;\n";
2288        let found = constants(source);
2289        let summary: Vec<(&str, &str, u32, usize)> = found
2290            .iter()
2291            .map(|c| (c.name.as_str(), c.value.as_str(), c.line, c.reads.len()))
2292            .collect();
2293        assert_eq!(
2294            summary,
2295            vec![
2296                ("FEATURE_NEW_UI", "true", 1, 1),
2297                ("ENABLE_BETA", "false", 3, 1),
2298                ("FF_MODE", "'on'", 5, 1),
2299                ("FEATURE_BANNER", "1", 7, 1),
2300            ]
2301        );
2302        assert_eq!(found[0].reads[0].line, 2);
2303        assert!(
2304            extract_from_source(source).is_empty(),
2305            "constants are not per-site flag reads"
2306        );
2307    }
2308
2309    #[test]
2310    fn let_bindings_calls_shadows_and_plain_names_are_not_constants() {
2311        for source in [
2312            "let FEATURE_LET = true;\nif (FEATURE_LET) { run(); }",
2313            "let FEATURE_R = true;\nFEATURE_R = false;\nif (FEATURE_R) { run(); }",
2314            "const FEATURE_CALL = readFlag();\nif (FEATURE_CALL) { run(); }",
2315            "const FEATURE_S = true;\nfunction f(FEATURE_S: boolean) { if (FEATURE_S) { run(); } }",
2316            "const FEATURE_UNUSED = true;\nlog(FEATURE_UNUSED);",
2317            "const DEBUG = true;\nif (DEBUG) { run(); }",
2318            "const ENABLE_FEATURES = '--enable-features=';\n\
2319             const on = (arg: string) => (arg.startsWith(ENABLE_FEATURES) ? 1 : 0);",
2320        ] {
2321            assert!(constants(source).is_empty(), "{source}");
2322        }
2323    }
2324
2325    #[test]
2326    fn a_constant_read_takes_the_facts_of_its_guard() {
2327        let found = constants("const FEATURE_X = true;\nconst v = FEATURE_X ? null : <></>;");
2328        assert!(found[0].reads[0].facts.empty_branch());
2329    }
2330
2331    #[test]
2332    fn a_bound_vercel_flag_call_is_a_definition() {
2333        let source = "import { flag, evaluate } from 'flags/next';\n\
2334                      export const showBanner = flag({\n\
2335                        key: 'show-banner',\n\
2336                        decide: () => Boolean(process.env.FEATURE_BANNER),\n\
2337                      });\n\
2338                      const value = await evaluate('show-banner');\n";
2339        let facts = extract_facts(source);
2340        let definition = facts
2341            .flag_uses
2342            .iter()
2343            .find(|flag| flag.flag_name == "show-banner" && flag.line == 2)
2344            .expect("definition site");
2345        assert!(definition.facts.definition());
2346        let evaluate = facts
2347            .flag_uses
2348            .iter()
2349            .find(|flag| flag.line == 6)
2350            .expect("evaluate site");
2351        assert!(!evaluate.facts.definition(), "evaluate reads the flag");
2352        let definitions = facts
2353            .registry_facts
2354            .map(|registry| registry.definitions)
2355            .unwrap_or_default();
2356        assert_eq!(
2357            definitions,
2358            vec![FlagDefinition {
2359                binding: "showBanner".to_string(),
2360                line: definition.line,
2361                col: definition.col,
2362            }]
2363        );
2364    }
2365
2366    #[test]
2367    fn an_unbound_vercel_flag_call_is_not_a_definition() {
2368        let facts = extract_facts(
2369            "import { flag } from 'flags/next';\nregister(flag({ key: 'loose', decide: () => false }));",
2370        );
2371        assert!(!facts.flag_uses[0].facts.definition());
2372        assert!(facts.registry_facts.is_none());
2373    }
2374
2375    #[test]
2376    fn builtin_sdk_providers_are_distinct_and_ordered() {
2377        let providers = builtin_sdk_providers();
2378        assert!(!providers.is_empty());
2379        let mut sorted = providers.clone();
2380        sorted.sort_unstable();
2381        sorted.dedup();
2382        assert_eq!(
2383            sorted.len(),
2384            providers.len(),
2385            "providers must be distinct: {providers:?}"
2386        );
2387        assert!(
2388            !providers.contains(&""),
2389            "empty provider labels must not leak into the surfaced list"
2390        );
2391        assert_eq!(providers.first(), Some(&"LaunchDarkly"));
2392        assert_eq!(providers.last(), Some(&VERCEL_FLAGS_PROVIDER));
2393    }
2394
2395    #[test]
2396    fn builtin_env_prefixes_match_source_constant() {
2397        let prefixes = builtin_env_prefixes();
2398        assert_eq!(prefixes, BUILTIN_ENV_PREFIXES);
2399        assert!(prefixes.contains(&"FEATURE_"));
2400        assert!(prefixes.contains(&"TOGGLE_"));
2401    }
2402}