use std::{path::Path, sync::Arc};
use fallow_types::extract::{FlagSiteFacts, FlagUse, FlagUseKind, ModuleInfo};
use fallow_types::results::{AnalysisResults, FeatureFlag, FlagConfidence, FlagKind, UnusedExport};
use rustc_hash::{FxHashMap, FxHashSet};
use crate::flag_registry::RegistryIndex;
use crate::flag_retirement::{RetirementFacts, RetirementSiteInput};
use crate::session::AnalysisSession;
use crate::suppress::{IssueKind, is_file_suppressed, is_suppressed};
use fallow_types::flag_retirement::{FlagSiteRole, RetirementFlagKind};
#[derive(Debug, Clone)]
pub struct FeatureFlagsAnalysis {
pub flags: Vec<FeatureFlag>,
pub files_scanned: usize,
}
pub fn analyze_feature_flags_with_session(
session: &AnalysisSession,
) -> crate::EngineResult<FeatureFlagsAnalysis> {
let modules = session.shared_parsed_modules_cancellable(false, "the feature-flag scan")?;
let flags = collect_flags_for_modules(session, &modules)?;
Ok(FeatureFlagsAnalysis {
flags,
files_scanned: session.files().len(),
})
}
pub fn analyze_feature_flags_for_retirement(
session: &AnalysisSession,
) -> crate::EngineResult<(FeatureFlagsAnalysis, RetirementFacts)> {
let modules = session.shared_parsed_modules_cancellable(false, "the feature-flag scan")?;
let (flags, dead_code) = collect_flags_and_dead_code(session, &modules)?;
let facts = collect_retirement_facts(session, &modules, &flags, dead_code.as_ref());
Ok((
FeatureFlagsAnalysis {
flags,
files_scanned: session.files().len(),
},
facts,
))
}
fn collect_retirement_facts(
session: &AnalysisSession,
modules: &[ModuleInfo],
flags: &[FeatureFlag],
dead_code: Option<&AnalysisResults>,
) -> RetirementFacts {
let file_paths: FxHashMap<_, _> = session
.files()
.iter()
.map(|file| (file.id, &file.path))
.collect();
let mut facts = RetirementFacts::default();
for module in modules {
let Some(path) = file_paths.get(&module.file_id) else {
continue;
};
let registry_reads = module
.flag_registry_facts
.iter()
.flat_map(|registry| registry.reads.iter().map(|read| &read.flag_use));
for flag_use in module.flag_uses.iter().chain(registry_reads) {
if flag_use.facts != FlagSiteFacts::default() {
facts.site_facts.insert(
((*path).clone(), flag_use.line, flag_use.col),
flag_use.facts,
);
}
}
collect_constant_sites(&mut facts.constant_sites, module, path);
}
if let Some(results) = dead_code {
let read_names: FxHashSet<&str> = flags
.iter()
.filter(|flag| {
!facts
.site_facts
.get(&(flag.path.clone(), flag.line, flag.col))
.is_some_and(|site| site.definition())
})
.map(|flag| flag.flag_name.as_str())
.collect();
collect_unread_definitions(&mut facts, modules, &file_paths, results, &read_names);
}
facts
}
const FLAG_REGISTRY_NAME_MARKERS: &[&str] = &["flag", "feature", "toggle", "experiment", "gate"];
fn collect_unread_definitions(
facts: &mut RetirementFacts,
modules: &[ModuleInfo],
file_paths: &FxHashMap<fallow_types::discover::FileId, &std::path::PathBuf>,
results: &AnalysisResults,
read_names: &FxHashSet<&str>,
) {
let unused_exports: FxHashSet<(&Path, &str)> = results
.unused_exports
.iter()
.map(|finding| {
(
finding.export.path.as_path(),
finding.export.export_name.as_str(),
)
})
.collect();
let unreachable: FxHashSet<&Path> = results
.unused_files
.iter()
.map(|finding| finding.file.path.as_path())
.collect();
for module in modules {
let (Some(path), Some(registry_facts)) = (
file_paths.get(&module.file_id),
module.flag_registry_facts.as_ref(),
) else {
continue;
};
if is_file_suppressed(&module.suppressions, IssueKind::FeatureFlag)
|| unreachable.contains(path.as_path())
{
continue;
}
for definition in ®istry_facts.definitions {
if unused_exports.contains(&(path.as_path(), definition.binding.as_str())) {
facts.unread_definitions.insert(
((*path).clone(), definition.line, definition.col),
format!(
"export `{}` holds the flag definition, and the dead-code analysis reports it as unused",
definition.binding
),
);
}
}
for registry in ®istry_facts.registries {
collect_unread_registry_members(facts, module, path, registry, results, read_names);
}
}
}
fn collect_unread_registry_members(
facts: &mut RetirementFacts,
module: &ModuleInfo,
path: &Path,
registry: &fallow_types::extract::FlagKeyRegistry,
results: &AnalysisResults,
read_names: &FxHashSet<&str>,
) {
let lower = registry.export_name.to_ascii_lowercase();
let named_as_registry = FLAG_REGISTRY_NAME_MARKERS
.iter()
.any(|marker| lower.contains(marker));
let read_as_registry = registry
.members
.iter()
.any(|(_, key)| read_names.contains(key.as_str()));
if !named_as_registry && !read_as_registry {
return;
}
for finding in &results.unused_enum_members {
let member = &finding.member;
if member.path != path
|| member.parent_name != registry.export_name
|| is_suppressed(&module.suppressions, member.line, IssueKind::FeatureFlag)
{
continue;
}
let Some((_, key)) = registry
.members
.iter()
.find(|(name, _)| *name == member.member_name)
else {
continue;
};
if read_names.contains(key.as_str()) {
continue;
}
facts.constant_sites.push(RetirementSiteInput {
path: path.to_path_buf(),
flag_name: key.clone(),
kind: RetirementFlagKind::SdkCall,
sdk_name: None,
line: member.line,
col: member.col,
role: FlagSiteRole::Definition,
guarded_dead_exports: Vec::new(),
facts: FlagSiteFacts::default(),
literal: None,
unread: Some(format!(
"registry member `{}.{}` holds the key, and the dead-code analysis reports it as unused",
registry.export_name, member.member_name
)),
});
}
}
fn collect_constant_sites(sites: &mut Vec<RetirementSiteInput>, module: &ModuleInfo, path: &Path) {
let Some(registry_facts) = module.flag_registry_facts.as_ref() else {
return;
};
if registry_facts.constants.is_empty()
|| is_file_suppressed(&module.suppressions, IssueKind::FeatureFlag)
{
return;
}
let site = |name: &str, line: u32, col: u32| RetirementSiteInput {
path: path.to_path_buf(),
flag_name: name.to_string(),
kind: RetirementFlagKind::Constant,
sdk_name: None,
line,
col,
role: FlagSiteRole::Read,
guarded_dead_exports: Vec::new(),
facts: FlagSiteFacts::default(),
literal: None,
unread: None,
};
for constant in ®istry_facts.constants {
if is_suppressed(&module.suppressions, constant.line, IssueKind::FeatureFlag) {
continue;
}
let reads: Vec<RetirementSiteInput> = constant
.reads
.iter()
.filter(|read| !is_suppressed(&module.suppressions, read.line, IssueKind::FeatureFlag))
.map(|read| RetirementSiteInput {
facts: read.facts,
..site(&constant.name, read.line, read.col)
})
.collect();
if reads.is_empty() {
continue;
}
sites.push(RetirementSiteInput {
role: FlagSiteRole::Definition,
literal: Some(constant.value.clone()),
..site(&constant.name, constant.line, constant.col)
});
sites.extend(reads);
}
}
#[must_use]
pub fn analyze_feature_flags_with_session_and_results(
session: &AnalysisSession,
results: &AnalysisResults,
) -> FeatureFlagsAnalysis {
let modules = session.shared_parsed_modules(false);
let mut flags = collect_flags_from_modules(session, &modules);
correlate_with_dead_code(&mut flags, results);
FeatureFlagsAnalysis {
flags,
files_scanned: session.files().len(),
}
}
#[must_use]
pub fn builtin_env_prefixes() -> &'static [&'static str] {
crate::feature_flags::builtin_env_prefixes()
}
#[must_use]
pub fn builtin_sdk_providers() -> Vec<&'static str> {
crate::feature_flags::builtin_sdk_providers()
}
fn collect_flags_for_modules(
session: &AnalysisSession,
modules: &Arc<[ModuleInfo]>,
) -> crate::EngineResult<Vec<FeatureFlag>> {
collect_flags_and_dead_code(session, modules).map(|(flags, _)| flags)
}
fn collect_flags_and_dead_code(
session: &AnalysisSession,
modules: &Arc<[ModuleInfo]>,
) -> crate::EngineResult<(Vec<FeatureFlag>, Option<AnalysisResults>)> {
let mut flags = collect_flags_from_modules(session, modules);
let results = match session.analyze_dead_code_with_shared_modules(Arc::clone(modules)) {
Ok(analysis_output) => {
correlate_with_dead_code(&mut flags, &analysis_output.results);
Some(analysis_output.results)
}
Err(err) if err.is_cancelled() => return Err(err),
Err(_) => None,
};
Ok((flags, results))
}
fn correlate_with_dead_code(flags: &mut [FeatureFlag], results: &AnalysisResults) {
if results.unused_exports.is_empty() && results.unused_types.is_empty() {
return;
}
let exports =
ExportLineIndex::new(results.unused_exports.iter().map(|finding| &finding.export));
let types = ExportLineIndex::new(results.unused_types.iter().map(|finding| &finding.export));
for flag in flags.iter_mut() {
let (Some(guard_start), Some(guard_end)) = (flag.guard_line_start, flag.guard_line_end)
else {
continue;
};
for index in [&exports, &types] {
flag.guarded_dead_exports
.extend(index.names_in(&flag.path, guard_start, guard_end));
}
}
}
struct ExportLineIndex<'r> {
by_path: FxHashMap<&'r Path, Vec<(u32, usize, &'r str)>>,
}
impl<'r> ExportLineIndex<'r> {
fn new(exports: impl Iterator<Item = &'r UnusedExport>) -> Self {
let mut by_path: FxHashMap<&Path, Vec<(u32, usize, &str)>> = FxHashMap::default();
for (position, export) in exports.enumerate() {
by_path.entry(export.path.as_path()).or_default().push((
export.line,
position,
export.export_name.as_str(),
));
}
for entries in by_path.values_mut() {
entries.sort_unstable_by_key(|&(line, position, _)| (line, position));
}
Self { by_path }
}
fn names_in(&self, path: &Path, start: u32, end: u32) -> Vec<String> {
let mut matches: Vec<(usize, &str)> = self
.by_path
.get(path)
.map(|entries| {
let first = entries.partition_point(|&(line, _, _)| line < start);
entries[first..]
.iter()
.take_while(|&&(line, _, _)| line <= end)
.map(|&(_, position, name)| (position, name))
.collect()
})
.unwrap_or_default();
matches.sort_unstable_by_key(|&(position, _)| position);
matches
.into_iter()
.map(|(_, name)| name.to_string())
.collect()
}
}
fn collect_flags_from_modules(
session: &AnalysisSession,
modules: &[ModuleInfo],
) -> Vec<FeatureFlag> {
let files = session.files();
let file_paths: FxHashMap<_, _> = files.iter().map(|file| (file.id, &file.path)).collect();
let registry_index = RegistryIndex::build(session.root(), session.workspaces(), files, modules);
let mut flags = Vec::new();
for module in modules {
let Some(path) = file_paths.get(&module.file_id) else {
continue;
};
collect_builtin_flags(&mut flags, module, path);
if let Some(index) = ®istry_index {
collect_registry_flags(&mut flags, module, path, index);
}
}
flags
}
fn collect_builtin_flags(flags: &mut Vec<FeatureFlag>, module: &ModuleInfo, path: &Path) {
let file_suppressed = is_file_suppressed(&module.suppressions, IssueKind::FeatureFlag);
for flag_use in &module.flag_uses {
if file_suppressed
|| is_suppressed(&module.suppressions, flag_use.line, IssueKind::FeatureFlag)
{
continue;
}
flags.push(flag_use_to_feature_flag(flag_use, module, path));
}
}
fn collect_registry_flags(
flags: &mut Vec<FeatureFlag>,
module: &ModuleInfo,
path: &Path,
index: &RegistryIndex<'_>,
) {
let Some(facts) = module.flag_registry_facts.as_ref() else {
return;
};
if facts.reads.is_empty() || is_file_suppressed(&module.suppressions, IssueKind::FeatureFlag) {
return;
}
for read in &facts.reads {
if is_suppressed(
&module.suppressions,
read.flag_use.line,
IssueKind::FeatureFlag,
) {
continue;
}
let Some(key) = index.resolve(module, path, read) else {
continue;
};
let mut flag = flag_use_to_feature_flag(&read.flag_use, module, path);
flag.flag_name = key.to_string();
flags.push(flag);
}
}
fn flag_use_to_feature_flag(flag_use: &FlagUse, module: &ModuleInfo, path: &Path) -> FeatureFlag {
let (kind, confidence) = match flag_use.kind {
FlagUseKind::EnvVar => (FlagKind::EnvironmentVariable, FlagConfidence::High),
FlagUseKind::SdkCall if flag_use.facts.unconfirmed_sdk() => {
(FlagKind::SdkCall, FlagConfidence::Medium)
}
FlagUseKind::SdkCall => (FlagKind::SdkCall, FlagConfidence::High),
FlagUseKind::ConfigObject => (FlagKind::ConfigObject, FlagConfidence::Low),
};
let (guard_line_start, guard_line_end) = if let (Some(start), Some(end)) =
(flag_use.guard_span_start, flag_use.guard_span_end)
&& !module.line_offsets.is_empty()
{
let (start_line, _) =
fallow_types::extract::byte_offset_to_line_col(&module.line_offsets, start);
let (end_line, _) =
fallow_types::extract::byte_offset_to_line_col(&module.line_offsets, end);
(Some(start_line), Some(end_line))
} else {
(None, None)
};
FeatureFlag {
path: path.to_path_buf(),
flag_name: flag_use.flag_name.clone(),
kind,
confidence,
line: flag_use.line,
col: flag_use.col,
guard_span_start: flag_use.guard_span_start,
guard_span_end: flag_use.guard_span_end,
sdk_name: flag_use.sdk_name.clone(),
guard_line_start,
guard_line_end,
guarded_dead_exports: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_runner_uses_session_discovery_instead_of_rediscovering() {
let project = tempfile::tempdir().expect("temp dir");
let root = project.path();
std::fs::create_dir(root.join("src")).expect("src dir");
std::fs::write(
root.join("package.json"),
r#"{"name":"flags-session","main":"src/index.ts"}"#,
)
.expect("package json");
std::fs::write(
root.join("src/index.ts"),
"if (process.env.FEATURE_EXISTING) {}\n",
)
.expect("initial source");
let session = AnalysisSession::load(root, None).expect("session loads");
std::fs::write(
root.join("src/late.ts"),
"if (process.env.FEATURE_LATE) {}\n",
)
.expect("late source");
let session_flags =
analyze_feature_flags_with_session(&session).expect("session flag scan");
let session_names: Vec<_> = session_flags
.flags
.iter()
.map(|flag| flag.flag_name.as_str())
.collect();
assert_eq!(session_names, vec!["FEATURE_EXISTING"]);
let second_session_flags =
analyze_feature_flags_with_session(&session).expect("second session flag scan");
let second_session_names: Vec<_> = second_session_flags
.flags
.iter()
.map(|flag| flag.flag_name.as_str())
.collect();
assert_eq!(second_session_names, vec!["FEATURE_EXISTING"]);
}
fn scan(files: &[(&str, &str)]) -> Vec<FeatureFlag> {
let project = tempfile::tempdir().expect("temp dir");
let root = project.path();
std::fs::write(
root.join("package.json"),
r#"{"name":"flag-registries","main":"src/index.ts"}"#,
)
.expect("package json");
for (path, source) in files {
let path = root.join(path);
std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
std::fs::write(path, source).expect("source");
}
let session = AnalysisSession::load(root, None).expect("session loads");
let mut flags = analyze_feature_flags_with_session(&session)
.expect("flag scan")
.flags;
flags.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
flags
}
#[test]
fn retirement_facts_hold_the_guard_facts_of_each_read() {
let project = tempfile::tempdir().expect("temp dir");
let root = project.path();
std::fs::write(
root.join("package.json"),
r#"{"name":"flag-facts","main":"src/index.ts"}"#,
)
.expect("package json");
std::fs::create_dir(root.join("src")).expect("src dir");
std::fs::write(
root.join("src/index.ts"),
"export const a = (): number => (process.env.FEATURE_SAME ? 1 : 1);\n\
export const b = (): number => (process.env.FEATURE_DIFF ? 1 : 2);\n",
)
.expect("source");
let session = AnalysisSession::load(root, None).expect("session loads");
let (analysis, facts) = analyze_feature_flags_for_retirement(&session).expect("flag scan");
let plain = analyze_feature_flags_with_session(&session).expect("plain scan");
assert_eq!(
names(&analysis.flags),
names(&plain.flags),
"the retirement scan reports the same flags"
);
let sites = facts.sites_for(&analysis.flags);
let same = sites
.iter()
.find(|site| site.flag_name == "FEATURE_SAME")
.expect("FEATURE_SAME");
assert!(same.facts.identical_branches());
let diff = sites
.iter()
.find(|site| site.flag_name == "FEATURE_DIFF")
.expect("FEATURE_DIFF");
assert!(!diff.facts.identical_branches());
}
#[test]
fn unread_definitions_cover_unused_vercel_exports_and_registry_members() {
let project = tempfile::tempdir().expect("temp dir");
let root = project.path();
std::fs::write(
root.join("package.json"),
r#"{"name":"flag-definitions","main":"src/index.ts"}"#,
)
.expect("package json");
std::fs::create_dir(root.join("src")).expect("src dir");
std::fs::write(
root.join("src/flags.ts"),
"import { flag } from 'flags/next';\n\
export const showA = flag({ key: 'show-a', decide: () => false });\n\
export const showB = flag({ key: 'show-b', decide: () => false });\n\
export const showC = flag({ key: 'show-c', decide: () => false });\n",
)
.expect("flags");
std::fs::write(
root.join("src/registry.ts"),
"export enum Flags {\n NewCheckout = 'new-checkout',\n OldBanner = 'old-banner',\n}\n\
export enum Colors {\n Red = 'red',\n Blue = 'blue',\n}\n",
)
.expect("registry");
std::fs::write(
root.join("src/index.ts"),
"import { showA, showB } from './flags';\n\
import { Colors, Flags } from './registry';\n\
export const run = async () => [\n\
await showA(),\n\
await showB(),\n\
useFlag(Flags.NewCheckout),\n\
Colors.Red,\n\
];\n",
)
.expect("index");
let session = AnalysisSession::load(root, None).expect("session loads");
let (analysis, facts) = analyze_feature_flags_for_retirement(&session).expect("flag scan");
let sites = facts.sites_for(&analysis.flags);
let mut unread: Vec<(&str, FlagSiteRole)> = sites
.iter()
.filter(|site| site.unread.is_some())
.map(|site| (site.flag_name.as_str(), site.role))
.collect();
unread.sort_unstable();
assert_eq!(
unread,
vec![
("old-banner", FlagSiteRole::Definition),
("show-c", FlagSiteRole::Definition),
],
"Colors is not a flag registry, and the used flags are read"
);
let definitions = sites
.iter()
.filter(|site| {
site.role == FlagSiteRole::Definition && site.kind == RetirementFlagKind::SdkCall
})
.count();
assert_eq!(
definitions, 4,
"three flag() definitions and one registry member"
);
}
#[test]
fn a_definition_in_an_unreachable_file_is_not_unread() {
let project = tempfile::tempdir().expect("temp dir");
let root = project.path();
std::fs::write(
root.join("package.json"),
r#"{"name":"flag-unreachable","main":"src/index.ts"}"#,
)
.expect("package json");
std::fs::create_dir(root.join("src")).expect("src dir");
std::fs::write(root.join("src/index.ts"), "export const main = 1;\n").expect("index");
std::fs::write(
root.join("src/flags.ts"),
"import { flag } from 'flags/next';\n\
export const orphan = flag({ key: 'orphan', decide: () => false });\n",
)
.expect("flags");
std::fs::write(
root.join("src/middleware.ts"),
"import { orphan } from './flags';\nexport const run = () => orphan();\n",
)
.expect("middleware");
let session = AnalysisSession::load(root, None).expect("session loads");
let (analysis, facts) = analyze_feature_flags_for_retirement(&session).expect("flag scan");
let sites = facts.sites_for(&analysis.flags);
assert!(
sites.iter().all(|site| site.unread.is_none()),
"unreachable file: {sites:?}"
);
}
#[test]
fn literal_constants_become_retirement_sites_and_respect_suppressions() {
let project = tempfile::tempdir().expect("temp dir");
let root = project.path();
std::fs::write(
root.join("package.json"),
r#"{"name":"flag-constants","main":"src/index.ts"}"#,
)
.expect("package json");
std::fs::create_dir(root.join("src")).expect("src dir");
std::fs::write(
root.join("src/index.ts"),
"const FEATURE_ON = true;\n\
const FEATURE_HIDDEN = false;\n\
export const a = (): number => (FEATURE_ON ? 1 : 2);\n\
// fallow-ignore-next-line feature-flag\n\
export const b = (): number => (FEATURE_HIDDEN ? 1 : 2);\n",
)
.expect("source");
let session = AnalysisSession::load(root, None).expect("session loads");
let (analysis, facts) = analyze_feature_flags_for_retirement(&session).expect("flag scan");
assert!(
analysis.flags.is_empty(),
"constants are not per-site flags"
);
let summary: Vec<(&str, FlagSiteRole, u32)> = facts
.constant_sites
.iter()
.map(|site| (site.flag_name.as_str(), site.role, site.line))
.collect();
assert_eq!(
summary,
vec![
("FEATURE_ON", FlagSiteRole::Definition, 1),
("FEATURE_ON", FlagSiteRole::Read, 3),
]
);
assert_eq!(facts.constant_sites[0].literal.as_deref(), Some("true"));
}
fn names(flags: &[FeatureFlag]) -> Vec<&str> {
flags.iter().map(|flag| flag.flag_name.as_str()).collect()
}
#[test]
fn resolves_keys_through_relative_registry_imports() {
let flags = scan(&[
(
"src/flags.ts",
"export const FLAGS = { NewCheckout: 'new-checkout' } as const;\n\
export enum Gates { Beta = 'beta-gate' }\n",
),
(
"src/index.ts",
"import { FLAGS, Gates as G } from './flags.js';\n\
if (useFlag(FLAGS.NewCheckout)) { run(); }\n\
useGate(G.Beta);\n\
useFlag(FLAGS.Missing);\n",
),
]);
assert_eq!(names(&flags), ["new-checkout", "beta-gate"]);
assert_eq!(flags[0].line, 2);
assert_eq!(flags[0].sdk_name.as_deref(), Some("LaunchDarkly"));
assert_eq!(flags[0].guard_line_start, Some(2));
}
#[test]
fn resolves_alias_and_barrel_imports_by_the_unique_registry_name() {
let flags = scan(&[
(
"src/config/flags.ts",
"export const FLAGS = { Chat: 'chat' } as const;\n",
),
("src/config/index.ts", "export { FLAGS } from './flags';\n"),
(
"src/index.ts",
"import { FLAGS } from '@/config';\n\
import { FLAGS as BarrelFlags } from './config';\n\
useFlag(FLAGS.Chat);\n\
useFlag(BarrelFlags.Chat);\n",
),
]);
assert_eq!(names(&flags), ["chat", "chat"]);
}
#[test]
fn leaves_ambiguous_and_non_imported_registries_unresolved() {
let flags = scan(&[
(
"src/a.ts",
"export const FLAGS = { Chat: 'chat-a' } as const;\n",
),
(
"src/b.ts",
"export const FLAGS = { Chat: 'chat-b' } as const;\n",
),
(
"src/index.ts",
"import { FLAGS } from '@/flags';\n\
useFlag(FLAGS.Chat);\n\
const local = { Chat: 'local' };\n\
useFlag(local.Chat);\n",
),
]);
assert!(flags.is_empty(), "unexpected flags: {:?}", names(&flags));
}
#[test]
fn a_dependency_import_does_not_resolve_to_a_project_registry() {
let flags = scan(&[
(
"package.json",
r#"{"name":"flag-registries","main":"src/index.ts","dependencies":{"some-pkg":"1.0.0","@scope/flags":"1.0.0"}}"#,
),
(
"src/config/flags.ts",
"export const FLAGS = { Chat: 'chat' } as const;\n",
),
(
"src/index.ts",
"import { FLAGS } from 'some-pkg';\n\
import { FLAGS as ScopedFlags } from '@scope/flags/keys';\n\
import { FLAGS as AliasFlags } from '@/config/flags';\n\
useFlag(FLAGS.Chat);\n\
useFlag(ScopedFlags.Chat);\n\
useFlag(AliasFlags.Chat);\n",
),
]);
assert_eq!(names(&flags), ["chat"]);
assert_eq!(flags[0].line, 6);
}
#[test]
fn registry_reads_honor_suppressions() {
let flags = scan(&[
(
"src/flags.ts",
"export const FLAGS = { Chat: 'chat' } as const;\n",
),
(
"src/index.ts",
"import { FLAGS } from './flags';\n\
// fallow-ignore-next-line feature-flag\n\
useFlag(FLAGS.Chat);\n\
useFlag(FLAGS.Chat);\n",
),
]);
assert_eq!(names(&flags), ["chat"]);
assert_eq!(flags[0].line, 4);
}
fn unused(path: &str, name: &str, line: u32) -> UnusedExport {
UnusedExport {
path: std::path::PathBuf::from(path),
export_name: name.to_string(),
is_type_only: false,
line,
col: 0,
span_start: 0,
is_re_export: false,
deprecated: false,
deprecated_reason: None,
}
}
fn guarded_flag(path: &str, lines: Option<(u32, u32)>) -> FeatureFlag {
FeatureFlag {
path: std::path::PathBuf::from(path),
flag_name: "flag".to_string(),
kind: FlagKind::EnvironmentVariable,
confidence: FlagConfidence::High,
line: 1,
col: 0,
guard_span_start: None,
guard_span_end: None,
sdk_name: None,
guard_line_start: lines.map(|(start, _)| start),
guard_line_end: lines.map(|(_, end)| end),
guarded_dead_exports: Vec::new(),
}
}
fn correlate_by_scan(flags: &mut [FeatureFlag], results: &AnalysisResults) {
for flag in flags.iter_mut() {
let (Some(start), Some(end)) = (flag.guard_line_start, flag.guard_line_end) else {
continue;
};
let exports = results.unused_exports.iter().map(|finding| &finding.export);
let types = results.unused_types.iter().map(|finding| &finding.export);
for export in exports.chain(types) {
if export.path == flag.path && export.line >= start && export.line <= end {
flag.guarded_dead_exports.push(export.export_name.clone());
}
}
}
}
#[test]
fn indexed_correlation_matches_the_full_scan() {
use fallow_types::output_dead_code::{UnusedExportFinding, UnusedTypeFinding};
let mut results = AnalysisResults::default();
for export in [
unused("src/b.ts", "late", 40),
unused("src/a.ts", "second", 12),
unused("src/a.ts", "first", 10),
unused("src/a.ts", "sameLineB", 12),
unused("src/a.ts", "edgeEnd", 20),
unused("src/a.ts", "outside", 21),
unused("src/b.ts", "early", 2),
] {
results
.unused_exports
.push(UnusedExportFinding::with_actions(export));
}
for export in [
unused("src/a.ts", "Shape", 15),
unused("src/a.ts", "Before", 9),
] {
results
.unused_types
.push(UnusedTypeFinding::with_actions(export));
}
let flags = || {
vec![
guarded_flag("src/a.ts", Some((10, 20))),
guarded_flag("src/a.ts", Some((12, 12))),
guarded_flag("src/b.ts", Some((1, 50))),
guarded_flag("src/c.ts", Some((1, 50))),
guarded_flag("src/a.ts", None),
]
};
let mut indexed = flags();
correlate_with_dead_code(&mut indexed, &results);
let mut scanned = flags();
correlate_by_scan(&mut scanned, &results);
let names = |flags: &[FeatureFlag]| -> Vec<Vec<String>> {
flags
.iter()
.map(|flag| flag.guarded_dead_exports.clone())
.collect()
};
assert_eq!(names(&indexed), names(&scanned));
assert_eq!(
names(&indexed)[0],
["second", "first", "sameLineB", "edgeEnd", "Shape"]
);
}
#[test]
fn custom_patterns_apply_in_the_one_parse() {
let flags = scan(&[
(
".fallowrc.json",
r#"{"flags":{"sdkPatterns":[{"function":"isFeatureActive","provider":"Internal"}],"envPrefixes":["MYAPP_"]}}"#,
),
(
"src/keys.ts",
"export const KEYS = { Beta: 'beta' } as const;\n",
),
(
"src/index.ts",
"import { KEYS } from './keys';\n\
export const a = isFeatureActive(KEYS.Beta);\n\
export const b = isFeatureActive('literal');\n",
),
(
"src/app.js",
"// A .js file with JSX parses again as JSX, and the flags come from that parse.\n\
export const App = () => <div>{x}</div>;\n\
export const key = process.env.MYAPP_BETA;\n",
),
]);
assert_eq!(names(&flags), ["MYAPP_BETA", "beta", "literal"]);
assert!(
flags[1..]
.iter()
.all(|flag| flag.sdk_name.as_deref() == Some("Internal"))
);
}
#[test]
fn generic_sdk_names_without_a_flag_import_have_medium_confidence() {
let flags = scan(&[
(
"src/keys.ts",
"export const KEYS = { Beta: 'beta' } as const;\n",
),
(
"src/form.ts",
"import { KEYS } from './keys';\n\
import { form } from './form-lib';\n\
export const a = form.getValue('email');\n\
export const b = isEnabled(KEYS.Beta);\n\
export const c = useFlag('specific');\n",
),
(
"src/sdk.ts",
"import { useUnleashClient } from '@unleash/proxy-client-react';\n\
export const d = useUnleashClient().isEnabled('confirmed');\n",
),
]);
let confidence = |name: &str| {
flags
.iter()
.find(|flag| flag.flag_name == name)
.unwrap_or_else(|| panic!("{name}"))
.confidence
};
assert_eq!(confidence("email"), FlagConfidence::Medium);
assert_eq!(confidence("beta"), FlagConfidence::Medium);
assert_eq!(confidence("specific"), FlagConfidence::High);
assert_eq!(confidence("confirmed"), FlagConfidence::High);
}
}