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