1use std::{path::Path, sync::Arc};
4
5use fallow_types::extract::{FlagSiteFacts, FlagUse, FlagUseKind, ModuleInfo};
6use fallow_types::results::{AnalysisResults, FeatureFlag, FlagConfidence, FlagKind, UnusedExport};
7use rustc_hash::{FxHashMap, FxHashSet};
8
9use crate::flag_registry::RegistryIndex;
10use crate::flag_retirement::{RetirementFacts, RetirementSiteInput};
11use crate::session::AnalysisSession;
12use crate::suppress::{IssueKind, is_file_suppressed, is_suppressed};
13use fallow_types::flag_retirement::{FlagSiteRole, RetirementFlagKind};
14
15#[derive(Debug, Clone)]
17pub struct FeatureFlagsAnalysis {
18 pub flags: Vec<FeatureFlag>,
20 pub files_scanned: usize,
22}
23
24pub fn analyze_feature_flags_with_session(
33 session: &AnalysisSession,
34) -> crate::EngineResult<FeatureFlagsAnalysis> {
35 let modules = session.shared_parsed_modules_cancellable(false, "the feature-flag scan")?;
36 let flags = collect_flags_for_modules(session, &modules)?;
37 Ok(FeatureFlagsAnalysis {
38 flags,
39 files_scanned: session.files().len(),
40 })
41}
42
43pub fn analyze_feature_flags_for_retirement(
53 session: &AnalysisSession,
54) -> crate::EngineResult<(FeatureFlagsAnalysis, RetirementFacts)> {
55 let modules = session.shared_parsed_modules_cancellable(false, "the feature-flag scan")?;
56 let (flags, dead_code) = collect_flags_and_dead_code(session, &modules)?;
57 let facts = collect_retirement_facts(session, &modules, &flags, dead_code.as_ref());
58 Ok((
59 FeatureFlagsAnalysis {
60 flags,
61 files_scanned: session.files().len(),
62 },
63 facts,
64 ))
65}
66
67fn collect_retirement_facts(
68 session: &AnalysisSession,
69 modules: &[ModuleInfo],
70 flags: &[FeatureFlag],
71 dead_code: Option<&AnalysisResults>,
72) -> RetirementFacts {
73 let file_paths: FxHashMap<_, _> = session
74 .files()
75 .iter()
76 .map(|file| (file.id, &file.path))
77 .collect();
78 let mut facts = RetirementFacts::default();
79 for module in modules {
80 let Some(path) = file_paths.get(&module.file_id) else {
81 continue;
82 };
83 let registry_reads = module
84 .flag_registry_facts
85 .iter()
86 .flat_map(|registry| registry.reads.iter().map(|read| &read.flag_use));
87 for flag_use in module.flag_uses.iter().chain(registry_reads) {
88 if flag_use.facts != FlagSiteFacts::default() {
89 facts.site_facts.insert(
90 ((*path).clone(), flag_use.line, flag_use.col),
91 flag_use.facts,
92 );
93 }
94 }
95 collect_constant_sites(&mut facts.constant_sites, module, path);
96 }
97 if let Some(results) = dead_code {
98 let read_names: FxHashSet<&str> = flags
99 .iter()
100 .filter(|flag| {
101 !facts
102 .site_facts
103 .get(&(flag.path.clone(), flag.line, flag.col))
104 .is_some_and(|site| site.definition())
105 })
106 .map(|flag| flag.flag_name.as_str())
107 .collect();
108 collect_unread_definitions(&mut facts, modules, &file_paths, results, &read_names);
109 }
110 facts
111}
112
113const FLAG_REGISTRY_NAME_MARKERS: &[&str] = &["flag", "feature", "toggle", "experiment", "gate"];
115
116fn collect_unread_definitions(
123 facts: &mut RetirementFacts,
124 modules: &[ModuleInfo],
125 file_paths: &FxHashMap<fallow_types::discover::FileId, &std::path::PathBuf>,
126 results: &AnalysisResults,
127 read_names: &FxHashSet<&str>,
128) {
129 let unused_exports: FxHashSet<(&Path, &str)> = results
130 .unused_exports
131 .iter()
132 .map(|finding| {
133 (
134 finding.export.path.as_path(),
135 finding.export.export_name.as_str(),
136 )
137 })
138 .collect();
139 let unreachable: FxHashSet<&Path> = results
143 .unused_files
144 .iter()
145 .map(|finding| finding.file.path.as_path())
146 .collect();
147 for module in modules {
148 let (Some(path), Some(registry_facts)) = (
149 file_paths.get(&module.file_id),
150 module.flag_registry_facts.as_ref(),
151 ) else {
152 continue;
153 };
154 if is_file_suppressed(&module.suppressions, IssueKind::FeatureFlag)
155 || unreachable.contains(path.as_path())
156 {
157 continue;
158 }
159 for definition in ®istry_facts.definitions {
160 if unused_exports.contains(&(path.as_path(), definition.binding.as_str())) {
161 facts.unread_definitions.insert(
162 ((*path).clone(), definition.line, definition.col),
163 format!(
164 "export `{}` holds the flag definition, and the dead-code analysis reports it as unused",
165 definition.binding
166 ),
167 );
168 }
169 }
170 for registry in ®istry_facts.registries {
171 collect_unread_registry_members(facts, module, path, registry, results, read_names);
172 }
173 }
174}
175
176fn collect_unread_registry_members(
177 facts: &mut RetirementFacts,
178 module: &ModuleInfo,
179 path: &Path,
180 registry: &fallow_types::extract::FlagKeyRegistry,
181 results: &AnalysisResults,
182 read_names: &FxHashSet<&str>,
183) {
184 let lower = registry.export_name.to_ascii_lowercase();
185 let named_as_registry = FLAG_REGISTRY_NAME_MARKERS
186 .iter()
187 .any(|marker| lower.contains(marker));
188 let read_as_registry = registry
189 .members
190 .iter()
191 .any(|(_, key)| read_names.contains(key.as_str()));
192 if !named_as_registry && !read_as_registry {
193 return;
194 }
195 for finding in &results.unused_enum_members {
196 let member = &finding.member;
197 if member.path != path
198 || member.parent_name != registry.export_name
199 || is_suppressed(&module.suppressions, member.line, IssueKind::FeatureFlag)
200 {
201 continue;
202 }
203 let Some((_, key)) = registry
204 .members
205 .iter()
206 .find(|(name, _)| *name == member.member_name)
207 else {
208 continue;
209 };
210 if read_names.contains(key.as_str()) {
211 continue;
212 }
213 facts.constant_sites.push(RetirementSiteInput {
214 path: path.to_path_buf(),
215 flag_name: key.clone(),
216 kind: RetirementFlagKind::SdkCall,
217 sdk_name: None,
218 line: member.line,
219 col: member.col,
220 role: FlagSiteRole::Definition,
221 guarded_dead_exports: Vec::new(),
222 facts: FlagSiteFacts::default(),
223 literal: None,
224 unread: Some(format!(
225 "registry member `{}.{}` holds the key, and the dead-code analysis reports it as unused",
226 registry.export_name, member.member_name
227 )),
228 });
229 }
230}
231
232fn collect_constant_sites(sites: &mut Vec<RetirementSiteInput>, module: &ModuleInfo, path: &Path) {
235 let Some(registry_facts) = module.flag_registry_facts.as_ref() else {
236 return;
237 };
238 if registry_facts.constants.is_empty()
239 || is_file_suppressed(&module.suppressions, IssueKind::FeatureFlag)
240 {
241 return;
242 }
243 let site = |name: &str, line: u32, col: u32| RetirementSiteInput {
244 path: path.to_path_buf(),
245 flag_name: name.to_string(),
246 kind: RetirementFlagKind::Constant,
247 sdk_name: None,
248 line,
249 col,
250 role: FlagSiteRole::Read,
251 guarded_dead_exports: Vec::new(),
252 facts: FlagSiteFacts::default(),
253 literal: None,
254 unread: None,
255 };
256 for constant in ®istry_facts.constants {
257 if is_suppressed(&module.suppressions, constant.line, IssueKind::FeatureFlag) {
258 continue;
259 }
260 let reads: Vec<RetirementSiteInput> = constant
261 .reads
262 .iter()
263 .filter(|read| !is_suppressed(&module.suppressions, read.line, IssueKind::FeatureFlag))
264 .map(|read| RetirementSiteInput {
265 facts: read.facts,
266 ..site(&constant.name, read.line, read.col)
267 })
268 .collect();
269 if reads.is_empty() {
270 continue;
271 }
272 sites.push(RetirementSiteInput {
273 role: FlagSiteRole::Definition,
274 literal: Some(constant.value.clone()),
275 ..site(&constant.name, constant.line, constant.col)
276 });
277 sites.extend(reads);
278 }
279}
280
281#[must_use]
287pub fn analyze_feature_flags_with_session_and_results(
288 session: &AnalysisSession,
289 results: &AnalysisResults,
290) -> FeatureFlagsAnalysis {
291 let modules = session.shared_parsed_modules(false);
292 let mut flags = collect_flags_from_modules(session, &modules);
293 correlate_with_dead_code(&mut flags, results);
294 FeatureFlagsAnalysis {
295 flags,
296 files_scanned: session.files().len(),
297 }
298}
299
300#[must_use]
302pub fn builtin_env_prefixes() -> &'static [&'static str] {
303 crate::feature_flags::builtin_env_prefixes()
304}
305
306#[must_use]
308pub fn builtin_sdk_providers() -> Vec<&'static str> {
309 crate::feature_flags::builtin_sdk_providers()
310}
311
312fn collect_flags_for_modules(
313 session: &AnalysisSession,
314 modules: &Arc<[ModuleInfo]>,
315) -> crate::EngineResult<Vec<FeatureFlag>> {
316 collect_flags_and_dead_code(session, modules).map(|(flags, _)| flags)
317}
318
319fn collect_flags_and_dead_code(
322 session: &AnalysisSession,
323 modules: &Arc<[ModuleInfo]>,
324) -> crate::EngineResult<(Vec<FeatureFlag>, Option<AnalysisResults>)> {
325 let mut flags = collect_flags_from_modules(session, modules);
326 let results = match session.analyze_dead_code_with_shared_modules(Arc::clone(modules)) {
327 Ok(analysis_output) => {
328 correlate_with_dead_code(&mut flags, &analysis_output.results);
329 Some(analysis_output.results)
330 }
331 Err(err) if err.is_cancelled() => return Err(err),
335 Err(_) => None,
336 };
337 Ok((flags, results))
338}
339
340fn correlate_with_dead_code(flags: &mut [FeatureFlag], results: &AnalysisResults) {
341 if results.unused_exports.is_empty() && results.unused_types.is_empty() {
342 return;
343 }
344
345 let exports =
346 ExportLineIndex::new(results.unused_exports.iter().map(|finding| &finding.export));
347 let types = ExportLineIndex::new(results.unused_types.iter().map(|finding| &finding.export));
348 for flag in flags.iter_mut() {
349 let (Some(guard_start), Some(guard_end)) = (flag.guard_line_start, flag.guard_line_end)
350 else {
351 continue;
352 };
353 for index in [&exports, &types] {
354 flag.guarded_dead_exports
355 .extend(index.names_in(&flag.path, guard_start, guard_end));
356 }
357 }
358}
359
360struct ExportLineIndex<'r> {
363 by_path: FxHashMap<&'r Path, Vec<(u32, usize, &'r str)>>,
364}
365
366impl<'r> ExportLineIndex<'r> {
367 fn new(exports: impl Iterator<Item = &'r UnusedExport>) -> Self {
368 let mut by_path: FxHashMap<&Path, Vec<(u32, usize, &str)>> = FxHashMap::default();
369 for (position, export) in exports.enumerate() {
370 by_path.entry(export.path.as_path()).or_default().push((
371 export.line,
372 position,
373 export.export_name.as_str(),
374 ));
375 }
376 for entries in by_path.values_mut() {
377 entries.sort_unstable_by_key(|&(line, position, _)| (line, position));
378 }
379 Self { by_path }
380 }
381
382 fn names_in(&self, path: &Path, start: u32, end: u32) -> Vec<String> {
385 let mut matches: Vec<(usize, &str)> = self
386 .by_path
387 .get(path)
388 .map(|entries| {
389 let first = entries.partition_point(|&(line, _, _)| line < start);
390 entries[first..]
391 .iter()
392 .take_while(|&&(line, _, _)| line <= end)
393 .map(|&(_, position, name)| (position, name))
394 .collect()
395 })
396 .unwrap_or_default();
397 matches.sort_unstable_by_key(|&(position, _)| position);
398 matches
399 .into_iter()
400 .map(|(_, name)| name.to_string())
401 .collect()
402 }
403}
404
405fn collect_flags_from_modules(
406 session: &AnalysisSession,
407 modules: &[ModuleInfo],
408) -> Vec<FeatureFlag> {
409 let files = session.files();
410 let file_paths: FxHashMap<_, _> = files.iter().map(|file| (file.id, &file.path)).collect();
411
412 let registry_index = RegistryIndex::build(session.root(), session.workspaces(), files, modules);
413 let mut flags = Vec::new();
414 for module in modules {
415 let Some(path) = file_paths.get(&module.file_id) else {
416 continue;
417 };
418
419 collect_builtin_flags(&mut flags, module, path);
420 if let Some(index) = ®istry_index {
421 collect_registry_flags(&mut flags, module, path, index);
422 }
423 }
424 flags
425}
426
427fn collect_builtin_flags(flags: &mut Vec<FeatureFlag>, module: &ModuleInfo, path: &Path) {
428 let file_suppressed = is_file_suppressed(&module.suppressions, IssueKind::FeatureFlag);
429 for flag_use in &module.flag_uses {
430 if file_suppressed
431 || is_suppressed(&module.suppressions, flag_use.line, IssueKind::FeatureFlag)
432 {
433 continue;
434 }
435 flags.push(flag_use_to_feature_flag(flag_use, module, path));
436 }
437}
438
439fn collect_registry_flags(
441 flags: &mut Vec<FeatureFlag>,
442 module: &ModuleInfo,
443 path: &Path,
444 index: &RegistryIndex<'_>,
445) {
446 let Some(facts) = module.flag_registry_facts.as_ref() else {
447 return;
448 };
449 if facts.reads.is_empty() || is_file_suppressed(&module.suppressions, IssueKind::FeatureFlag) {
450 return;
451 }
452 for read in &facts.reads {
453 if is_suppressed(
454 &module.suppressions,
455 read.flag_use.line,
456 IssueKind::FeatureFlag,
457 ) {
458 continue;
459 }
460 let Some(key) = index.resolve(module, path, read) else {
461 continue;
462 };
463 let mut flag = flag_use_to_feature_flag(&read.flag_use, module, path);
464 flag.flag_name = key.to_string();
465 flags.push(flag);
466 }
467}
468
469fn flag_use_to_feature_flag(flag_use: &FlagUse, module: &ModuleInfo, path: &Path) -> FeatureFlag {
470 let (kind, confidence) = match flag_use.kind {
471 FlagUseKind::EnvVar => (FlagKind::EnvironmentVariable, FlagConfidence::High),
472 FlagUseKind::SdkCall if flag_use.facts.unconfirmed_sdk() => {
473 (FlagKind::SdkCall, FlagConfidence::Medium)
474 }
475 FlagUseKind::SdkCall => (FlagKind::SdkCall, FlagConfidence::High),
476 FlagUseKind::ConfigObject => (FlagKind::ConfigObject, FlagConfidence::Low),
477 };
478
479 let (guard_line_start, guard_line_end) = if let (Some(start), Some(end)) =
480 (flag_use.guard_span_start, flag_use.guard_span_end)
481 && !module.line_offsets.is_empty()
482 {
483 let (start_line, _) =
484 fallow_types::extract::byte_offset_to_line_col(&module.line_offsets, start);
485 let (end_line, _) =
486 fallow_types::extract::byte_offset_to_line_col(&module.line_offsets, end);
487 (Some(start_line), Some(end_line))
488 } else {
489 (None, None)
490 };
491
492 FeatureFlag {
493 path: path.to_path_buf(),
494 flag_name: flag_use.flag_name.clone(),
495 kind,
496 confidence,
497 line: flag_use.line,
498 col: flag_use.col,
499 guard_span_start: flag_use.guard_span_start,
500 guard_span_end: flag_use.guard_span_end,
501 sdk_name: flag_use.sdk_name.clone(),
502 guard_line_start,
503 guard_line_end,
504 guarded_dead_exports: Vec::new(),
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn session_runner_uses_session_discovery_instead_of_rediscovering() {
514 let project = tempfile::tempdir().expect("temp dir");
515 let root = project.path();
516 std::fs::create_dir(root.join("src")).expect("src dir");
517 std::fs::write(
518 root.join("package.json"),
519 r#"{"name":"flags-session","main":"src/index.ts"}"#,
520 )
521 .expect("package json");
522 std::fs::write(
523 root.join("src/index.ts"),
524 "if (process.env.FEATURE_EXISTING) {}\n",
525 )
526 .expect("initial source");
527
528 let session = AnalysisSession::load(root, None).expect("session loads");
529
530 std::fs::write(
531 root.join("src/late.ts"),
532 "if (process.env.FEATURE_LATE) {}\n",
533 )
534 .expect("late source");
535
536 let session_flags =
537 analyze_feature_flags_with_session(&session).expect("session flag scan");
538 let session_names: Vec<_> = session_flags
539 .flags
540 .iter()
541 .map(|flag| flag.flag_name.as_str())
542 .collect();
543 assert_eq!(session_names, vec!["FEATURE_EXISTING"]);
544
545 let second_session_flags =
546 analyze_feature_flags_with_session(&session).expect("second session flag scan");
547 let second_session_names: Vec<_> = second_session_flags
548 .flags
549 .iter()
550 .map(|flag| flag.flag_name.as_str())
551 .collect();
552 assert_eq!(second_session_names, vec!["FEATURE_EXISTING"]);
553 }
554
555 fn scan(files: &[(&str, &str)]) -> Vec<FeatureFlag> {
556 let project = tempfile::tempdir().expect("temp dir");
557 let root = project.path();
558 std::fs::write(
559 root.join("package.json"),
560 r#"{"name":"flag-registries","main":"src/index.ts"}"#,
561 )
562 .expect("package json");
563 for (path, source) in files {
564 let path = root.join(path);
565 std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
566 std::fs::write(path, source).expect("source");
567 }
568 let session = AnalysisSession::load(root, None).expect("session loads");
569 let mut flags = analyze_feature_flags_with_session(&session)
570 .expect("flag scan")
571 .flags;
572 flags.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
573 flags
574 }
575
576 #[test]
577 fn retirement_facts_hold_the_guard_facts_of_each_read() {
578 let project = tempfile::tempdir().expect("temp dir");
579 let root = project.path();
580 std::fs::write(
581 root.join("package.json"),
582 r#"{"name":"flag-facts","main":"src/index.ts"}"#,
583 )
584 .expect("package json");
585 std::fs::create_dir(root.join("src")).expect("src dir");
586 std::fs::write(
587 root.join("src/index.ts"),
588 "export const a = (): number => (process.env.FEATURE_SAME ? 1 : 1);\n\
589 export const b = (): number => (process.env.FEATURE_DIFF ? 1 : 2);\n",
590 )
591 .expect("source");
592 let session = AnalysisSession::load(root, None).expect("session loads");
593 let (analysis, facts) = analyze_feature_flags_for_retirement(&session).expect("flag scan");
594 let plain = analyze_feature_flags_with_session(&session).expect("plain scan");
595 assert_eq!(
596 names(&analysis.flags),
597 names(&plain.flags),
598 "the retirement scan reports the same flags"
599 );
600 let sites = facts.sites_for(&analysis.flags);
601 let same = sites
602 .iter()
603 .find(|site| site.flag_name == "FEATURE_SAME")
604 .expect("FEATURE_SAME");
605 assert!(same.facts.identical_branches());
606 let diff = sites
607 .iter()
608 .find(|site| site.flag_name == "FEATURE_DIFF")
609 .expect("FEATURE_DIFF");
610 assert!(!diff.facts.identical_branches());
611 }
612
613 #[test]
614 fn unread_definitions_cover_unused_vercel_exports_and_registry_members() {
615 let project = tempfile::tempdir().expect("temp dir");
616 let root = project.path();
617 std::fs::write(
618 root.join("package.json"),
619 r#"{"name":"flag-definitions","main":"src/index.ts"}"#,
620 )
621 .expect("package json");
622 std::fs::create_dir(root.join("src")).expect("src dir");
623 std::fs::write(
624 root.join("src/flags.ts"),
625 "import { flag } from 'flags/next';\n\
626 export const showA = flag({ key: 'show-a', decide: () => false });\n\
627 export const showB = flag({ key: 'show-b', decide: () => false });\n\
628 export const showC = flag({ key: 'show-c', decide: () => false });\n",
629 )
630 .expect("flags");
631 std::fs::write(
632 root.join("src/registry.ts"),
633 "export enum Flags {\n NewCheckout = 'new-checkout',\n OldBanner = 'old-banner',\n}\n\
634 export enum Colors {\n Red = 'red',\n Blue = 'blue',\n}\n",
635 )
636 .expect("registry");
637 std::fs::write(
638 root.join("src/index.ts"),
639 "import { showA, showB } from './flags';\n\
640 import { Colors, Flags } from './registry';\n\
641 export const run = async () => [\n\
642 await showA(),\n\
643 await showB(),\n\
644 useFlag(Flags.NewCheckout),\n\
645 Colors.Red,\n\
646 ];\n",
647 )
648 .expect("index");
649 let session = AnalysisSession::load(root, None).expect("session loads");
650 let (analysis, facts) = analyze_feature_flags_for_retirement(&session).expect("flag scan");
651 let sites = facts.sites_for(&analysis.flags);
652 let mut unread: Vec<(&str, FlagSiteRole)> = sites
653 .iter()
654 .filter(|site| site.unread.is_some())
655 .map(|site| (site.flag_name.as_str(), site.role))
656 .collect();
657 unread.sort_unstable();
658 assert_eq!(
659 unread,
660 vec![
661 ("old-banner", FlagSiteRole::Definition),
662 ("show-c", FlagSiteRole::Definition),
663 ],
664 "Colors is not a flag registry, and the used flags are read"
665 );
666 let definitions = sites
667 .iter()
668 .filter(|site| {
669 site.role == FlagSiteRole::Definition && site.kind == RetirementFlagKind::SdkCall
670 })
671 .count();
672 assert_eq!(
673 definitions, 4,
674 "three flag() definitions and one registry member"
675 );
676 }
677
678 #[test]
679 fn a_definition_in_an_unreachable_file_is_not_unread() {
680 let project = tempfile::tempdir().expect("temp dir");
684 let root = project.path();
685 std::fs::write(
686 root.join("package.json"),
687 r#"{"name":"flag-unreachable","main":"src/index.ts"}"#,
688 )
689 .expect("package json");
690 std::fs::create_dir(root.join("src")).expect("src dir");
691 std::fs::write(root.join("src/index.ts"), "export const main = 1;\n").expect("index");
692 std::fs::write(
693 root.join("src/flags.ts"),
694 "import { flag } from 'flags/next';\n\
695 export const orphan = flag({ key: 'orphan', decide: () => false });\n",
696 )
697 .expect("flags");
698 std::fs::write(
699 root.join("src/middleware.ts"),
700 "import { orphan } from './flags';\nexport const run = () => orphan();\n",
701 )
702 .expect("middleware");
703 let session = AnalysisSession::load(root, None).expect("session loads");
704 let (analysis, facts) = analyze_feature_flags_for_retirement(&session).expect("flag scan");
705 let sites = facts.sites_for(&analysis.flags);
706 assert!(
707 sites.iter().all(|site| site.unread.is_none()),
708 "unreachable file: {sites:?}"
709 );
710 }
711
712 #[test]
713 fn literal_constants_become_retirement_sites_and_respect_suppressions() {
714 let project = tempfile::tempdir().expect("temp dir");
715 let root = project.path();
716 std::fs::write(
717 root.join("package.json"),
718 r#"{"name":"flag-constants","main":"src/index.ts"}"#,
719 )
720 .expect("package json");
721 std::fs::create_dir(root.join("src")).expect("src dir");
722 std::fs::write(
723 root.join("src/index.ts"),
724 "const FEATURE_ON = true;\n\
725 const FEATURE_HIDDEN = false;\n\
726 export const a = (): number => (FEATURE_ON ? 1 : 2);\n\
727 // fallow-ignore-next-line feature-flag\n\
728 export const b = (): number => (FEATURE_HIDDEN ? 1 : 2);\n",
729 )
730 .expect("source");
731 let session = AnalysisSession::load(root, None).expect("session loads");
732 let (analysis, facts) = analyze_feature_flags_for_retirement(&session).expect("flag scan");
733 assert!(
734 analysis.flags.is_empty(),
735 "constants are not per-site flags"
736 );
737 let summary: Vec<(&str, FlagSiteRole, u32)> = facts
738 .constant_sites
739 .iter()
740 .map(|site| (site.flag_name.as_str(), site.role, site.line))
741 .collect();
742 assert_eq!(
743 summary,
744 vec![
745 ("FEATURE_ON", FlagSiteRole::Definition, 1),
746 ("FEATURE_ON", FlagSiteRole::Read, 3),
747 ]
748 );
749 assert_eq!(facts.constant_sites[0].literal.as_deref(), Some("true"));
750 }
751
752 fn names(flags: &[FeatureFlag]) -> Vec<&str> {
753 flags.iter().map(|flag| flag.flag_name.as_str()).collect()
754 }
755
756 #[test]
757 fn resolves_keys_through_relative_registry_imports() {
758 let flags = scan(&[
759 (
760 "src/flags.ts",
761 "export const FLAGS = { NewCheckout: 'new-checkout' } as const;\n\
762 export enum Gates { Beta = 'beta-gate' }\n",
763 ),
764 (
765 "src/index.ts",
766 "import { FLAGS, Gates as G } from './flags.js';\n\
767 if (useFlag(FLAGS.NewCheckout)) { run(); }\n\
768 useGate(G.Beta);\n\
769 useFlag(FLAGS.Missing);\n",
770 ),
771 ]);
772 assert_eq!(names(&flags), ["new-checkout", "beta-gate"]);
773 assert_eq!(flags[0].line, 2);
774 assert_eq!(flags[0].sdk_name.as_deref(), Some("LaunchDarkly"));
775 assert_eq!(flags[0].guard_line_start, Some(2));
776 }
777
778 #[test]
779 fn resolves_alias_and_barrel_imports_by_the_unique_registry_name() {
780 let flags = scan(&[
781 (
782 "src/config/flags.ts",
783 "export const FLAGS = { Chat: 'chat' } as const;\n",
784 ),
785 ("src/config/index.ts", "export { FLAGS } from './flags';\n"),
786 (
787 "src/index.ts",
788 "import { FLAGS } from '@/config';\n\
789 import { FLAGS as BarrelFlags } from './config';\n\
790 useFlag(FLAGS.Chat);\n\
791 useFlag(BarrelFlags.Chat);\n",
792 ),
793 ]);
794 assert_eq!(names(&flags), ["chat", "chat"]);
795 }
796
797 #[test]
798 fn leaves_ambiguous_and_non_imported_registries_unresolved() {
799 let flags = scan(&[
800 (
801 "src/a.ts",
802 "export const FLAGS = { Chat: 'chat-a' } as const;\n",
803 ),
804 (
805 "src/b.ts",
806 "export const FLAGS = { Chat: 'chat-b' } as const;\n",
807 ),
808 (
809 "src/index.ts",
810 "import { FLAGS } from '@/flags';\n\
811 useFlag(FLAGS.Chat);\n\
812 const local = { Chat: 'local' };\n\
813 useFlag(local.Chat);\n",
814 ),
815 ]);
816 assert!(flags.is_empty(), "unexpected flags: {:?}", names(&flags));
817 }
818
819 #[test]
820 fn a_dependency_import_does_not_resolve_to_a_project_registry() {
821 let flags = scan(&[
822 (
823 "package.json",
824 r#"{"name":"flag-registries","main":"src/index.ts","dependencies":{"some-pkg":"1.0.0","@scope/flags":"1.0.0"}}"#,
825 ),
826 (
827 "src/config/flags.ts",
828 "export const FLAGS = { Chat: 'chat' } as const;\n",
829 ),
830 (
831 "src/index.ts",
832 "import { FLAGS } from 'some-pkg';\n\
833 import { FLAGS as ScopedFlags } from '@scope/flags/keys';\n\
834 import { FLAGS as AliasFlags } from '@/config/flags';\n\
835 useFlag(FLAGS.Chat);\n\
836 useFlag(ScopedFlags.Chat);\n\
837 useFlag(AliasFlags.Chat);\n",
838 ),
839 ]);
840 assert_eq!(names(&flags), ["chat"]);
841 assert_eq!(flags[0].line, 6);
842 }
843
844 #[test]
845 fn registry_reads_honor_suppressions() {
846 let flags = scan(&[
847 (
848 "src/flags.ts",
849 "export const FLAGS = { Chat: 'chat' } as const;\n",
850 ),
851 (
852 "src/index.ts",
853 "import { FLAGS } from './flags';\n\
854 // fallow-ignore-next-line feature-flag\n\
855 useFlag(FLAGS.Chat);\n\
856 useFlag(FLAGS.Chat);\n",
857 ),
858 ]);
859 assert_eq!(names(&flags), ["chat"]);
860 assert_eq!(flags[0].line, 4);
861 }
862
863 fn unused(path: &str, name: &str, line: u32) -> UnusedExport {
864 UnusedExport {
865 path: std::path::PathBuf::from(path),
866 export_name: name.to_string(),
867 is_type_only: false,
868 line,
869 col: 0,
870 span_start: 0,
871 is_re_export: false,
872 deprecated: false,
873 deprecated_reason: None,
874 }
875 }
876
877 fn guarded_flag(path: &str, lines: Option<(u32, u32)>) -> FeatureFlag {
878 FeatureFlag {
879 path: std::path::PathBuf::from(path),
880 flag_name: "flag".to_string(),
881 kind: FlagKind::EnvironmentVariable,
882 confidence: FlagConfidence::High,
883 line: 1,
884 col: 0,
885 guard_span_start: None,
886 guard_span_end: None,
887 sdk_name: None,
888 guard_line_start: lines.map(|(start, _)| start),
889 guard_line_end: lines.map(|(_, end)| end),
890 guarded_dead_exports: Vec::new(),
891 }
892 }
893
894 fn correlate_by_scan(flags: &mut [FeatureFlag], results: &AnalysisResults) {
896 for flag in flags.iter_mut() {
897 let (Some(start), Some(end)) = (flag.guard_line_start, flag.guard_line_end) else {
898 continue;
899 };
900 let exports = results.unused_exports.iter().map(|finding| &finding.export);
901 let types = results.unused_types.iter().map(|finding| &finding.export);
902 for export in exports.chain(types) {
903 if export.path == flag.path && export.line >= start && export.line <= end {
904 flag.guarded_dead_exports.push(export.export_name.clone());
905 }
906 }
907 }
908 }
909
910 #[test]
911 fn indexed_correlation_matches_the_full_scan() {
912 use fallow_types::output_dead_code::{UnusedExportFinding, UnusedTypeFinding};
913
914 let mut results = AnalysisResults::default();
915 for export in [
916 unused("src/b.ts", "late", 40),
917 unused("src/a.ts", "second", 12),
918 unused("src/a.ts", "first", 10),
919 unused("src/a.ts", "sameLineB", 12),
920 unused("src/a.ts", "edgeEnd", 20),
921 unused("src/a.ts", "outside", 21),
922 unused("src/b.ts", "early", 2),
923 ] {
924 results
925 .unused_exports
926 .push(UnusedExportFinding::with_actions(export));
927 }
928 for export in [
929 unused("src/a.ts", "Shape", 15),
930 unused("src/a.ts", "Before", 9),
931 ] {
932 results
933 .unused_types
934 .push(UnusedTypeFinding::with_actions(export));
935 }
936 let flags = || {
937 vec![
938 guarded_flag("src/a.ts", Some((10, 20))),
939 guarded_flag("src/a.ts", Some((12, 12))),
940 guarded_flag("src/b.ts", Some((1, 50))),
941 guarded_flag("src/c.ts", Some((1, 50))),
942 guarded_flag("src/a.ts", None),
943 ]
944 };
945
946 let mut indexed = flags();
947 correlate_with_dead_code(&mut indexed, &results);
948 let mut scanned = flags();
949 correlate_by_scan(&mut scanned, &results);
950
951 let names = |flags: &[FeatureFlag]| -> Vec<Vec<String>> {
952 flags
953 .iter()
954 .map(|flag| flag.guarded_dead_exports.clone())
955 .collect()
956 };
957 assert_eq!(names(&indexed), names(&scanned));
958 assert_eq!(
959 names(&indexed)[0],
960 ["second", "first", "sameLineB", "edgeEnd", "Shape"]
961 );
962 }
963
964 #[test]
965 fn custom_patterns_apply_in_the_one_parse() {
966 let flags = scan(&[
967 (
968 ".fallowrc.json",
969 r#"{"flags":{"sdkPatterns":[{"function":"isFeatureActive","provider":"Internal"}],"envPrefixes":["MYAPP_"]}}"#,
970 ),
971 (
972 "src/keys.ts",
973 "export const KEYS = { Beta: 'beta' } as const;\n",
974 ),
975 (
976 "src/index.ts",
977 "import { KEYS } from './keys';\n\
978 export const a = isFeatureActive(KEYS.Beta);\n\
979 export const b = isFeatureActive('literal');\n",
980 ),
981 (
982 "src/app.js",
983 "// A .js file with JSX parses again as JSX, and the flags come from that parse.\n\
984 export const App = () => <div>{x}</div>;\n\
985 export const key = process.env.MYAPP_BETA;\n",
986 ),
987 ]);
988 assert_eq!(names(&flags), ["MYAPP_BETA", "beta", "literal"]);
989 assert!(
990 flags[1..]
991 .iter()
992 .all(|flag| flag.sdk_name.as_deref() == Some("Internal"))
993 );
994 }
995
996 #[test]
997 fn generic_sdk_names_without_a_flag_import_have_medium_confidence() {
998 let flags = scan(&[
999 (
1000 "src/keys.ts",
1001 "export const KEYS = { Beta: 'beta' } as const;\n",
1002 ),
1003 (
1004 "src/form.ts",
1005 "import { KEYS } from './keys';\n\
1006 import { form } from './form-lib';\n\
1007 export const a = form.getValue('email');\n\
1008 export const b = isEnabled(KEYS.Beta);\n\
1009 export const c = useFlag('specific');\n",
1010 ),
1011 (
1012 "src/sdk.ts",
1013 "import { useUnleashClient } from '@unleash/proxy-client-react';\n\
1014 export const d = useUnleashClient().isEnabled('confirmed');\n",
1015 ),
1016 ]);
1017 let confidence = |name: &str| {
1018 flags
1019 .iter()
1020 .find(|flag| flag.flag_name == name)
1021 .unwrap_or_else(|| panic!("{name}"))
1022 .confidence
1023 };
1024 assert_eq!(confidence("email"), FlagConfidence::Medium);
1025 assert_eq!(confidence("beta"), FlagConfidence::Medium);
1026 assert_eq!(confidence("specific"), FlagConfidence::High);
1027 assert_eq!(confidence("confirmed"), FlagConfidence::High);
1028 }
1029}