1#[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
27const 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
77const GENERIC_SDK_NAMES: &[&str] = &["getValue", "isEnabled", "useFeature"];
81
82const 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
102const 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#[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#[must_use]
143pub fn builtin_env_prefixes() -> &'static [&'static str] {
144 BUILTIN_ENV_PREFIXES
145}
146
147const CONFIG_OBJECT_KEYWORDS: &[&str] = &[
149 "feature",
150 "features",
151 "featureFlags",
152 "featureFlag",
153 "flag",
154 "flags",
155 "toggle",
156 "toggles",
157];
158
159#[derive(Debug, Clone, Copy)]
161enum FlagRef {
162 Resolved(usize),
164 Registry(usize),
166}
167
168enum FlagNameArg {
170 Literal(String),
172 RegistryMember { registry: String, member: String },
174}
175
176#[derive(Debug, Clone, Copy)]
178struct Guard {
179 start: u32,
180 end: u32,
181 facts: FlagSiteFacts,
182}
183
184struct FlagVisitor<'a> {
186 results: Vec<FlagUse>,
187 registry_reads: Vec<FlagRegistryRead>,
189 line_offsets: &'a [u32],
190 extra_sdk_patterns: &'a [(String, usize, String)],
192 extra_env_prefixes: &'a [String],
194 config_object_heuristics: bool,
196 vercel_flags_imports: FxHashMap<String, String>,
198 vercel_flags_namespaces: FxHashSet<String>,
200 local_registries: FxHashMap<String, Vec<(String, String)>>,
202 named_imports: FxHashSet<String>,
204 exported_registries: Vec<FlagKeyRegistry>,
206 constants: Vec<FlagConstant>,
208 literal_consts: FxHashMap<String, usize>,
210 guard_operands: Vec<u32>,
213 definition_calls: FxHashMap<u32, usize>,
215 definitions: Vec<FlagDefinition>,
217 current_guard: Option<Guard>,
219 block_ends: Vec<u32>,
221 binding_scopes: Vec<FxHashMap<String, Option<FlagRef>>>,
224 flag_binding_count: usize,
226 shadowed_registries: Vec<FxHashSet<String>>,
229 last_ref: Option<FlagRef>,
231 last_read_start: Option<u32>,
233 in_const_declaration: bool,
235 has_flag_import: bool,
237 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 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 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 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 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 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(®istry))
421 {
422 return;
423 }
424 if let Some(members) = self.local_registries.get(®istry) {
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(®istry) {
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1024fn 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
1032fn 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
1051fn 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
1063fn 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
1073fn 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
1103fn 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
1123fn 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
1148fn 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
1157fn 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
1169fn 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
1181fn 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
1190fn 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
1214fn 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
1238fn 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
1274fn 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
1288fn 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
1307fn 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
1324fn 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
1335fn 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
1364fn 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
1391fn 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#[derive(Debug, Default)]
1410pub(crate) struct ExtractedFlags {
1411 pub flag_uses: Vec<FlagUse>,
1413 pub registry_facts: Option<Box<FlagRegistryFacts>>,
1416}
1417
1418pub(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 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}