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
103const CONFIG_OBJECT_KEYWORDS: &[&str] = &[
105 "feature",
106 "features",
107 "featureFlags",
108 "featureFlag",
109 "flag",
110 "flags",
111 "toggle",
112 "toggles",
113];
114
115struct FlagVisitor<'a> {
117 results: Vec<FlagUse>,
118 line_offsets: &'a [u32],
119 extra_sdk_patterns: &'a [(String, usize, String)],
121 extra_env_prefixes: &'a [String],
123 config_object_heuristics: bool,
125 vercel_flags_imports: FxHashMap<String, String>,
127 vercel_flags_namespaces: FxHashSet<String>,
129}
130
131impl<'a> FlagVisitor<'a> {
132 fn new(
133 line_offsets: &'a [u32],
134 extra_sdk_patterns: &'a [(String, usize, String)],
135 extra_env_prefixes: &'a [String],
136 config_object_heuristics: bool,
137 ) -> Self {
138 Self {
139 results: Vec::new(),
140 line_offsets,
141 extra_sdk_patterns,
142 extra_env_prefixes,
143 config_object_heuristics,
144 vercel_flags_imports: FxHashMap::default(),
145 vercel_flags_namespaces: FxHashSet::default(),
146 }
147 }
148
149 fn check_env_var(&mut self, expr: &MemberExpression<'_>, guard: Option<(u32, u32)>) {
151 if let MemberExpression::StaticMemberExpression(static_expr) = expr
153 && let Some(env_name) = extract_process_env_name(static_expr)
154 && self.is_flag_env_name(&env_name)
155 {
156 let (line, col) = byte_offset_to_line_col(self.line_offsets, static_expr.span.start);
157 self.results.push(FlagUse {
158 flag_name: env_name,
159 kind: FlagUseKind::EnvVar,
160 line,
161 col,
162 guard_span_start: guard.map(|(s, _)| s),
163 guard_span_end: guard.map(|(_, e)| e),
164 sdk_name: None,
165 });
166 }
167 }
168
169 fn check_sdk_call(&mut self, call: &CallExpression<'_>, guard: Option<(u32, u32)>) {
171 let func_name = match &call.callee {
172 Expression::Identifier(id) => Some(id.name.as_str()),
173 Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
174 _ => None,
175 };
176
177 let Some(func_name) = func_name else {
178 return;
179 };
180
181 if self.check_vercel_flags_call(call, guard) {
182 return;
183 }
184
185 for &(pattern_name, name_arg_idx, provider) in BUILTIN_SDK_PATTERNS {
187 if func_name == pattern_name {
188 if let Some(flag_name) = extract_string_arg(&call.arguments, name_arg_idx) {
189 let (line, col) = byte_offset_to_line_col(self.line_offsets, call.span.start);
190 self.results.push(FlagUse {
191 flag_name,
192 kind: FlagUseKind::SdkCall,
193 line,
194 col,
195 guard_span_start: guard.map(|(s, _)| s),
196 guard_span_end: guard.map(|(_, e)| e),
197 sdk_name: if provider.is_empty() {
198 None
199 } else {
200 Some(provider.to_string())
201 },
202 });
203 }
204 return;
205 }
206 }
207
208 for (pattern_name, name_arg_idx, provider) in self.extra_sdk_patterns {
210 if func_name == pattern_name {
211 if let Some(flag_name) = extract_string_arg(&call.arguments, *name_arg_idx) {
212 let (line, col) = byte_offset_to_line_col(self.line_offsets, call.span.start);
213 self.results.push(FlagUse {
214 flag_name,
215 kind: FlagUseKind::SdkCall,
216 line,
217 col,
218 guard_span_start: guard.map(|(s, _)| s),
219 guard_span_end: guard.map(|(_, e)| e),
220 sdk_name: if provider.is_empty() {
221 None
222 } else {
223 Some(provider.clone())
224 },
225 });
226 }
227 return;
228 }
229 }
230 }
231
232 fn check_vercel_flags_call(
233 &mut self,
234 call: &CallExpression<'_>,
235 guard: Option<(u32, u32)>,
236 ) -> bool {
237 let Some(imported_name) = self.vercel_flags_imported_name(call) else {
238 return false;
239 };
240
241 let flag_name = match imported_name {
242 "flag" => extract_object_string_property_arg(&call.arguments, 0, "key"),
243 "evaluate" => extract_string_arg(&call.arguments, 0),
244 _ => None,
245 };
246
247 let Some(flag_name) = flag_name else {
248 return false;
249 };
250
251 let (line, col) = byte_offset_to_line_col(self.line_offsets, call.span.start);
252 self.results.push(FlagUse {
253 flag_name,
254 kind: FlagUseKind::SdkCall,
255 line,
256 col,
257 guard_span_start: guard.map(|(s, _)| s),
258 guard_span_end: guard.map(|(_, e)| e),
259 sdk_name: Some(VERCEL_FLAGS_PROVIDER.to_string()),
260 });
261 true
262 }
263
264 fn vercel_flags_imported_name<'b>(&'b self, call: &'b CallExpression<'_>) -> Option<&'b str> {
265 match &call.callee {
266 Expression::Identifier(id) => self
267 .vercel_flags_imports
268 .get(id.name.as_str())
269 .map(String::as_str),
270 Expression::StaticMemberExpression(member) => {
271 let Expression::Identifier(object) = &member.object else {
272 return None;
273 };
274 self.vercel_flags_namespaces
275 .contains(object.name.as_str())
276 .then_some(member.property.name.as_str())
277 }
278 _ => None,
279 }
280 }
281
282 fn collect_vercel_flags_imports(&mut self, program: &Program<'_>) {
283 for stmt in &program.body {
284 if let Statement::ImportDeclaration(decl) = stmt {
285 self.collect_vercel_flags_import(decl);
286 }
287 }
288 }
289
290 fn collect_vercel_flags_import(&mut self, decl: &ImportDeclaration<'_>) {
291 if !is_vercel_flags_source(decl.source.value.as_str()) || decl.import_kind.is_type() {
292 return;
293 }
294
295 let Some(specifiers) = &decl.specifiers else {
296 return;
297 };
298
299 for spec in specifiers {
300 match spec {
301 ImportDeclarationSpecifier::ImportSpecifier(specifier) => {
302 if specifier.import_kind.is_type() {
303 continue;
304 }
305 let imported_name = specifier.imported.name();
306 if VERCEL_FLAGS_FUNCTIONS.contains(&imported_name.as_str()) {
307 self.vercel_flags_imports
308 .insert(specifier.local.name.to_string(), imported_name.to_string());
309 }
310 }
311 ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => {
312 self.vercel_flags_namespaces
313 .insert(specifier.local.name.to_string());
314 }
315 ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => {}
316 }
317 }
318 }
319
320 fn check_config_object(
322 &mut self,
323 expr: &StaticMemberExpression<'_>,
324 guard: Option<(u32, u32)>,
325 ) {
326 if !self.config_object_heuristics {
327 return;
328 }
329
330 if let Some((obj_name, prop_name)) = extract_config_object_access(expr)
333 && CONFIG_OBJECT_KEYWORDS
334 .iter()
335 .any(|kw| obj_name.eq_ignore_ascii_case(kw) || prop_name.eq_ignore_ascii_case(kw))
336 {
337 let (line, col) = byte_offset_to_line_col(self.line_offsets, expr.span.start);
338 self.results.push(FlagUse {
339 flag_name: format!("{obj_name}.{prop_name}"),
340 kind: FlagUseKind::ConfigObject,
341 line,
342 col,
343 guard_span_start: guard.map(|(s, _)| s),
344 guard_span_end: guard.map(|(_, e)| e),
345 sdk_name: None,
346 });
347 }
348 }
349
350 fn is_flag_env_name(&self, name: &str) -> bool {
351 for prefix in BUILTIN_ENV_PREFIXES {
352 if name.starts_with(prefix) {
353 return true;
354 }
355 }
356 for prefix in self.extra_env_prefixes {
357 if name.starts_with(prefix.as_str()) {
358 return true;
359 }
360 }
361 false
362 }
363}
364
365impl Visit<'_> for FlagVisitor<'_> {
366 fn visit_program(&mut self, program: &Program<'_>) {
367 self.collect_vercel_flags_imports(program);
368 walk::walk_program(self, program);
369 }
370
371 fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'_>) {
372 self.collect_vercel_flags_import(decl);
373 }
374
375 fn visit_if_statement(&mut self, stmt: &IfStatement<'_>) {
376 let guard = Some((stmt.span.start, stmt.span.end));
377
378 check_expression_for_flags(self, &stmt.test, guard);
380
381 self.visit_statement(&stmt.consequent);
383 if let Some(alt) = &stmt.alternate {
384 self.visit_statement(alt);
385 }
386 }
387
388 fn visit_conditional_expression(&mut self, expr: &ConditionalExpression<'_>) {
389 let guard = Some((expr.span.start, expr.span.end));
390 check_expression_for_flags(self, &expr.test, guard);
391
392 self.visit_expression(&expr.consequent);
394 self.visit_expression(&expr.alternate);
395 }
396
397 fn visit_call_expression(&mut self, call: &CallExpression<'_>) {
398 self.check_sdk_call(call, None);
399 walk::walk_call_expression(self, call);
400 }
401
402 fn visit_member_expression(&mut self, expr: &MemberExpression<'_>) {
403 self.check_env_var(expr, None);
404 if let MemberExpression::StaticMemberExpression(static_expr) = expr {
405 self.check_config_object(static_expr, None);
406 }
407 walk::walk_member_expression(self, expr);
408 }
409}
410
411fn is_vercel_flags_source(source: &str) -> bool {
412 source == "flags"
413 || source.starts_with("flags/")
414 || source == "@vercel/flags"
415 || source.starts_with("@vercel/flags/")
416}
417
418fn check_expression_for_flags(
420 visitor: &mut FlagVisitor<'_>,
421 expr: &Expression<'_>,
422 guard: Option<(u32, u32)>,
423) {
424 match expr {
425 Expression::CallExpression(call) => {
426 visitor.check_sdk_call(call, guard);
427 }
428 Expression::StaticMemberExpression(member) => {
429 check_static_member_for_env(visitor, member, guard);
430 visitor.check_config_object(member, guard);
431 }
432 Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::LogicalNot => {
433 check_expression_for_flags(visitor, &unary.argument, guard);
434 }
435 Expression::LogicalExpression(logical) => {
436 check_expression_for_flags(visitor, &logical.left, guard);
437 check_expression_for_flags(visitor, &logical.right, guard);
438 }
439 _ => {}
440 }
441}
442
443fn check_static_member_for_env(
445 visitor: &mut FlagVisitor<'_>,
446 expr: &StaticMemberExpression<'_>,
447 guard: Option<(u32, u32)>,
448) {
449 if let Some(env_name) = extract_process_env_name(expr)
450 && visitor.is_flag_env_name(&env_name)
451 {
452 let (line, col) = byte_offset_to_line_col(visitor.line_offsets, expr.span.start);
453 visitor.results.push(FlagUse {
454 flag_name: env_name,
455 kind: FlagUseKind::EnvVar,
456 line,
457 col,
458 guard_span_start: guard.map(|(s, _)| s),
459 guard_span_end: guard.map(|(_, e)| e),
460 sdk_name: None,
461 });
462 }
463}
464
465fn extract_process_env_name(expr: &StaticMemberExpression<'_>) -> Option<String> {
467 let prop_name = expr.property.name.as_str();
469
470 if let Expression::StaticMemberExpression(inner) = &expr.object
471 && inner.property.name.as_str() == "env"
472 && let Expression::Identifier(id) = &inner.object
473 && id.name.as_str() == "process"
474 {
475 return Some(prop_name.to_string());
476 }
477
478 None
479}
480
481fn extract_string_arg(args: &[Argument<'_>], index: usize) -> Option<String> {
483 args.get(index).and_then(|arg| {
484 if let Argument::StringLiteral(lit) = arg {
485 Some(lit.value.to_string())
486 } else {
487 None
488 }
489 })
490}
491
492fn extract_object_string_property_arg(
494 args: &[Argument<'_>],
495 index: usize,
496 property_name: &str,
497) -> Option<String> {
498 let Some(Argument::ObjectExpression(obj)) = args.get(index) else {
499 return None;
500 };
501
502 for prop in &obj.properties {
503 let ObjectPropertyKind::ObjectProperty(prop) = prop else {
504 continue;
505 };
506 if prop
507 .key
508 .static_name()
509 .is_some_and(|key| key.as_ref() == property_name)
510 && let Expression::StringLiteral(lit) = &prop.value
511 {
512 return Some(lit.value.to_string());
513 }
514 }
515
516 None
517}
518
519fn extract_config_object_access(expr: &StaticMemberExpression<'_>) -> Option<(String, String)> {
521 let prop_name = expr.property.name.to_string();
522
523 match &expr.object {
524 Expression::Identifier(id) => Some((id.name.to_string(), prop_name)),
525 Expression::StaticMemberExpression(inner) => {
526 if matches!(&inner.object, Expression::Identifier(_)) {
527 Some((inner.property.name.to_string(), prop_name))
529 } else {
530 None
531 }
532 }
533 _ => None,
534 }
535}
536
537pub fn extract_flags(
541 program: &Program<'_>,
542 line_offsets: &[u32],
543 extra_sdk_patterns: &[(String, usize, String)],
544 extra_env_prefixes: &[String],
545 config_object_heuristics: bool,
546) -> Vec<FlagUse> {
547 let mut visitor = FlagVisitor::new(
548 line_offsets,
549 extra_sdk_patterns,
550 extra_env_prefixes,
551 config_object_heuristics,
552 );
553 visitor.visit_program(program);
554 visitor.results
555}
556
557pub fn extract_flags_from_source(
563 source: &str,
564 path: &std::path::Path,
565 extra_sdk_patterns: &[(String, usize, String)],
566 extra_env_prefixes: &[String],
567 config_object_heuristics: bool,
568) -> Vec<FlagUse> {
569 let source_type = oxc_span::SourceType::from_path(path).unwrap_or_default();
570 let allocator = oxc_allocator::Allocator::default();
571 let parser_return = oxc_parser::Parser::new(&allocator, source, source_type).parse();
572 let line_offsets = fallow_types::extract::compute_line_offsets(source);
573 extract_flags(
574 &parser_return.program,
575 &line_offsets,
576 extra_sdk_patterns,
577 extra_env_prefixes,
578 config_object_heuristics,
579 )
580}
581
582#[cfg(all(test, not(miri)))]
583mod tests {
584 use super::*;
585 use oxc_allocator::Allocator;
586 use oxc_parser::Parser;
587 use oxc_span::SourceType;
588
589 fn extract_from_source(source: &str) -> Vec<FlagUse> {
590 let allocator = Allocator::default();
591 let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
592 let line_offsets = fallow_types::extract::compute_line_offsets(source);
593 extract_flags(&parser_return.program, &line_offsets, &[], &[], false)
594 }
595
596 fn extract_with_config_objects(source: &str) -> Vec<FlagUse> {
597 let allocator = Allocator::default();
598 let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
599 let line_offsets = fallow_types::extract::compute_line_offsets(source);
600 extract_flags(&parser_return.program, &line_offsets, &[], &[], true)
601 }
602
603 #[test]
606 fn detects_process_env_feature_flag() {
607 let flags = extract_from_source("if (process.env.FEATURE_NEW_CHECKOUT) { doStuff(); }");
608 assert_eq!(flags.len(), 1);
609 assert_eq!(flags[0].flag_name, "FEATURE_NEW_CHECKOUT");
610 assert_eq!(flags[0].kind, FlagUseKind::EnvVar);
611 assert!(flags[0].guard_span_start.is_some());
612 }
613
614 #[test]
615 fn detects_next_public_enable_prefix() {
616 let flags = extract_from_source("if (process.env.NEXT_PUBLIC_ENABLE_BETA) {}");
617 assert_eq!(flags.len(), 1);
618 assert_eq!(flags[0].flag_name, "NEXT_PUBLIC_ENABLE_BETA");
619 }
620
621 #[test]
622 fn ignores_non_flag_env_vars() {
623 let flags = extract_from_source("const url = process.env.DATABASE_URL;");
624 assert!(flags.is_empty());
625 }
626
627 #[test]
628 fn detects_negated_env_flag() {
629 let flags = extract_from_source("if (!process.env.FEATURE_X) { fallback(); }");
630 assert_eq!(flags.len(), 1);
631 assert_eq!(flags[0].flag_name, "FEATURE_X");
632 }
633
634 #[test]
637 fn detects_launchdarkly_use_flag() {
638 let flags = extract_from_source("const flag = useFlag('new-checkout');");
639 assert_eq!(flags.len(), 1);
640 assert_eq!(flags[0].flag_name, "new-checkout");
641 assert_eq!(flags[0].kind, FlagUseKind::SdkCall);
642 assert_eq!(flags[0].sdk_name.as_deref(), Some("LaunchDarkly"));
643 }
644
645 #[test]
646 fn detects_statsig_use_gate() {
647 let flags = extract_from_source("if (useGate('beta-feature')) {}");
648 assert_eq!(flags.len(), 1);
649 assert_eq!(flags[0].flag_name, "beta-feature");
650 assert_eq!(flags[0].sdk_name.as_deref(), Some("Statsig"));
651 }
652
653 #[test]
654 fn detects_unleash_is_enabled() {
655 let flags = extract_from_source("client.isEnabled('feature-x')");
656 assert_eq!(flags.len(), 1);
657 assert_eq!(flags[0].flag_name, "feature-x");
658 }
659
660 #[test]
661 fn detects_growthbook_get_feature_value() {
662 let flags = extract_from_source("const val = getFeatureValue('parser', false);");
663 assert_eq!(flags.len(), 1);
664 assert_eq!(flags[0].flag_name, "parser");
665 assert_eq!(flags[0].sdk_name.as_deref(), Some("GrowthBook"));
666 }
667
668 #[test]
669 fn detects_posthog_hooks() {
670 let flags = extract_from_source(
671 "const enabled = useFeatureFlagEnabled('new-checkout');\n\
672 const payload = useFeatureFlagPayload('checkout-copy');\n\
673 const variant = useFeatureFlagVariantKey('pricing-test');",
674 );
675
676 let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
677 assert_eq!(names, ["new-checkout", "checkout-copy", "pricing-test"]);
678 assert!(
679 flags
680 .iter()
681 .all(|flag| flag.sdk_name.as_deref() == Some("PostHog"))
682 );
683 }
684
685 #[test]
686 fn detects_vercel_flags_object_key_and_core_evaluate_from_imports() {
687 let flags = extract_from_source(
688 "import { flag, evaluate as evalFlag } from 'flags/next';\n\
689 export const showSale = flag({ key: 'summer-sale', decide: () => false });\n\
690 const value = await evalFlag('show-new-feature', false);",
691 );
692
693 let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
694 assert_eq!(names, ["summer-sale", "show-new-feature"]);
695 assert!(
696 flags
697 .iter()
698 .all(|flag| flag.sdk_name.as_deref() == Some("Vercel Flags"))
699 );
700 }
701
702 #[test]
703 fn detects_vercel_flags_namespace_imports() {
704 let flags = extract_from_source(
705 "import * as vercelFlags from '@vercel/flags';\n\
706 const value = await vercelFlags.evaluate('show-new-feature', false);\n\
707 export const showSale = vercelFlags.flag({ key: 'summer-sale', decide: () => false });",
708 );
709
710 let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
711 assert_eq!(names, ["show-new-feature", "summer-sale"]);
712 assert!(
713 flags
714 .iter()
715 .all(|flag| flag.sdk_name.as_deref() == Some("Vercel Flags"))
716 );
717 }
718
719 #[test]
720 fn detects_vercel_flags_calls_before_import_declaration() {
721 let flags = extract_from_source(
722 "export const showSale = flag({ key: 'summer-sale', decide: () => false });\n\
723 import { flag } from 'flags/next';",
724 );
725
726 assert_eq!(flags.len(), 1);
727 assert_eq!(flags[0].flag_name, "summer-sale");
728 assert_eq!(flags[0].sdk_name.as_deref(), Some("Vercel Flags"));
729 }
730
731 #[test]
732 fn ignores_unimported_vercel_like_function_names() {
733 let flags = extract_from_source(
734 "function math() { return evaluate('2 + 2'); }\n\
735 function marker() { return flag({ key: 'ui-row' }); }",
736 );
737
738 assert!(flags.is_empty());
739 }
740
741 #[test]
742 fn detects_configcat_detail_evaluation() {
743 let flags = extract_from_source(
744 "const details = await client.getValueDetailsAsync('new-checkout', false);",
745 );
746 assert_eq!(flags.len(), 1);
747 assert_eq!(flags[0].flag_name, "new-checkout");
748 assert_eq!(flags[0].sdk_name.as_deref(), Some("ConfigCat"));
749 }
750
751 #[test]
752 fn detects_optimizely_decisions_and_variables() {
753 let flags = extract_from_source(
754 "const [decision] = useDecision('checkout-flow');\n\
755 const copy = optimizelyClient.getFeatureVariableString('checkout-flow', 'copy', userId, attrs);\n\
756 const json = optimizelyClient.getFeatureVariableJson('checkout-flow', 'json', userId, attrs);",
757 );
758
759 assert_eq!(flags.len(), 3);
760 assert!(flags.iter().all(|flag| flag.flag_name == "checkout-flow"));
761 assert!(
762 flags
763 .iter()
764 .all(|flag| flag.sdk_name.as_deref() == Some("Optimizely"))
765 );
766 }
767
768 #[test]
769 fn detects_eppo_typed_assignments() {
770 let flags = extract_from_source(
771 "const value = client.getBooleanAssignment('new-onboarding', subject, {}, false);\n\
772 const details = client.getStringAssignmentDetails('copy-test', subject, {}, 'control');\n\
773 const payload = client.getJSONAssignmentDetails('payload-test', subject, {}, {});",
774 );
775
776 let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
777 assert_eq!(names, ["new-onboarding", "copy-test", "payload-test"]);
778 assert!(
779 flags
780 .iter()
781 .all(|flag| flag.sdk_name.as_deref() == Some("Eppo"))
782 );
783 }
784
785 #[test]
786 fn ignores_sdk_call_without_string_arg() {
787 let flags = extract_from_source("useFlag(dynamicKey);");
788 assert!(flags.is_empty());
789 }
790
791 #[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]
823 fn captures_if_guard_span() {
824 let source = "if (process.env.FEATURE_X) {\n doStuff();\n}";
825 let flags = extract_from_source(source);
826 assert_eq!(flags.len(), 1);
827 assert!(flags[0].guard_span_start.is_some());
828 assert!(flags[0].guard_span_end.is_some());
829 }
830
831 #[test]
832 fn captures_ternary_guard_span() {
833 let source = "const x = useFlag('beta') ? newFlow() : oldFlow();";
834 let flags = extract_from_source(source);
835 assert_eq!(flags.len(), 1);
836 assert!(flags[0].guard_span_start.is_some());
837 }
838
839 #[test]
842 fn detects_custom_sdk_pattern() {
843 let allocator = Allocator::default();
844 let source = "isFeatureActive('my-flag');";
845 let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
846 let line_offsets = fallow_types::extract::compute_line_offsets(source);
847 let custom = vec![("isFeatureActive".to_string(), 0, "Internal".to_string())];
848 let flags = extract_flags(&parser_return.program, &line_offsets, &custom, &[], false);
849 assert_eq!(flags.len(), 1);
850 assert_eq!(flags[0].flag_name, "my-flag");
851 assert_eq!(flags[0].sdk_name.as_deref(), Some("Internal"));
852 }
853
854 #[test]
855 fn custom_sdk_pattern_can_use_vercel_object_function_name() {
856 let allocator = Allocator::default();
857 let source = "flag('internal-flag');";
858 let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
859 let line_offsets = fallow_types::extract::compute_line_offsets(source);
860 let custom = vec![("flag".to_string(), 0, "Internal".to_string())];
861 let flags = extract_flags(&parser_return.program, &line_offsets, &custom, &[], false);
862 assert_eq!(flags.len(), 1);
863 assert_eq!(flags[0].flag_name, "internal-flag");
864 assert_eq!(flags[0].sdk_name.as_deref(), Some("Internal"));
865 }
866
867 #[test]
870 fn detects_custom_env_prefix() {
871 let allocator = Allocator::default();
872 let source = "if (process.env.MYAPP_ENABLE_V2) {}";
873 let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
874 let line_offsets = fallow_types::extract::compute_line_offsets(source);
875 let custom_prefixes = vec!["MYAPP_ENABLE_".to_string()];
876 let flags = extract_flags(
877 &parser_return.program,
878 &line_offsets,
879 &[],
880 &custom_prefixes,
881 false,
882 );
883 assert_eq!(flags.len(), 1);
884 assert_eq!(flags[0].flag_name, "MYAPP_ENABLE_V2");
885 }
886}