Skip to main content

fallow_engine/
flags.rs

1//! Feature flag analysis owned by the engine boundary.
2
3use 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/// Typed result from running feature flag analysis.
16#[derive(Debug, Clone)]
17pub struct FeatureFlagsAnalysis {
18    /// Detected feature flags with their usage sites and confidence.
19    pub flags: Vec<FeatureFlag>,
20    /// Number of files the flag scan covered.
21    pub files_scanned: usize,
22}
23
24/// Run feature flag analysis with a reusable analysis session.
25///
26/// # Errors
27///
28/// Returns [`crate::EngineError::cancelled`] when the session's caller
29/// cancelled the run. The scan spends its time in the parse loop and in the
30/// dead-code correlation behind it, and both observe the token. A session
31/// without a cancellation token can never return this error.
32pub 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
43/// Run feature flag analysis and also collect the facts that the flag
44/// retirement report reads.
45///
46/// The flags are the same as [`analyze_feature_flags_with_session`] returns.
47///
48/// # Errors
49///
50/// Returns [`crate::EngineError::cancelled`] when the session's caller
51/// cancelled the run.
52pub 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
113/// Name fragments that mark an enum as a flag registry.
114const FLAG_REGISTRY_NAME_MARKERS: &[&str] = &["flag", "feature", "toggle", "experiment", "gate"];
115
116/// Record flag definitions that no code reads:
117///
118/// - a Vercel `flag()` definition whose export is unused, and
119/// - an unused member of an exported flag registry enum.
120///
121/// A key that some flag site reads by name is read, so it is skipped.
122fn 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    // The pass reports every export of an unreachable file as unused, even
140    // when an unreachable module imports it, so that says nothing about the
141    // flag.
142    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 &registry_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 &registry_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
232/// Sites of the literal `const` flags of a module. The `feature-flag`
233/// suppressions apply to them as they apply to every flag read.
234fn 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 &registry_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/// Run feature flag analysis while reusing dead-code results from the same
282/// session.
283///
284/// Compound surfaces such as `fallow viz` use this path to avoid rebuilding
285/// the module graph solely to correlate guarded dead exports.
286#[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/// Built-in environment variable prefixes treated as feature flags.
301#[must_use]
302pub fn builtin_env_prefixes() -> &'static [&'static str] {
303    crate::feature_flags::builtin_env_prefixes()
304}
305
306/// Distinct built-in SDK provider labels, in declaration order.
307#[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
319/// Collect the flags and correlate them with the dead-code pass. Returns the
320/// dead-code results too, or `None` when the pass failed.
321fn 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        // Correlation only enriches the flags, so a broken dead-code pass
332        // leaves them uncorrelated rather than failing the scan. A cancelled
333        // one is not a failure to enrich, it is the caller asking to stop.
334        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
360/// Unused exports grouped by file and sorted by line, so the guard lookup
361/// of each flag is a binary search and not a scan of every finding.
362struct 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    /// Names of the exports in `path` on lines `start..=end`, in the order of
383    /// the findings.
384    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) = &registry_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
439/// Resolve reads such as `useFlag(FLAGS.X)`, where `FLAGS` is imported.
440fn 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        // The dead-code pass reports every export of an unreachable file as
681        // unused, even when an unreachable module imports it. That says
682        // nothing about the flag, so the definition gets no reason.
683        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    /// The loop the index replaced: every flag against every finding.
895    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}