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