Skip to main content

fallow_engine/
flag_retirement.rs

1//! Per-flag retirement report for `fallow flags --retirement`.
2//!
3//! The per-site flag findings group into one row per flag identity. Each
4//! detector adds a reason and its evidence to a row. The report is advisory:
5//! every action is `auto_fixable: false`, and nothing here removes code.
6
7use std::path::{Path, PathBuf};
8
9use fallow_config::WorkspaceInfo;
10use fallow_types::envelope::RegressionStatus;
11use fallow_types::extract::FlagSiteFacts;
12use fallow_types::flag_retirement::{
13    FlagAgeGate, FlagAgeGateEntry, FlagAgeMode, FlagRetirementReport, FlagSiteRole,
14    RetirementAction, RetirementActionType, RetirementEvidence, RetirementFlag, RetirementFlagKind,
15    RetirementReason, RetirementSite, RetirementSummary,
16};
17use fallow_types::results::{FeatureFlag, FlagKind};
18use rustc_hash::FxHashMap;
19
20/// File-name markers of story files. A story renders a component in
21/// isolation, so a flag that only a story reads is not live in production.
22const STORY_FILE_MARKERS: &[&str] = &[".stories.", ".story."];
23
24/// Description of the one action on a retirement candidate.
25const REVIEW_DESCRIPTION: &str = "Review this flag for retirement. The evidence lists the reasons.";
26
27/// One site of a flag, before the sites group into rows.
28#[derive(Debug, Clone)]
29pub struct RetirementSiteInput {
30    /// Absolute path of the file.
31    pub path: PathBuf,
32    /// Flag identifier.
33    pub flag_name: String,
34    /// How the flag was detected.
35    pub kind: RetirementFlagKind,
36    /// SDK provider label, if known.
37    pub sdk_name: Option<String>,
38    /// 1-based line.
39    pub line: u32,
40    /// 0-based byte column.
41    pub col: u32,
42    /// What the site does with the flag.
43    pub role: FlagSiteRole,
44    /// Unused exports inside the block that the site guards.
45    pub guarded_dead_exports: Vec<String>,
46    /// Facts about the guard of the site.
47    pub facts: FlagSiteFacts,
48    /// The literal value of a `const` flag, on its definition site.
49    pub literal: Option<String>,
50    /// Why no code reads this definition, when that is known.
51    pub unread: Option<String>,
52}
53
54/// Flag facts that only the retirement report reads. The per-site
55/// `feature_flags[]` array does not carry them.
56#[derive(Debug, Default)]
57pub struct RetirementFacts {
58    /// Guard facts of each flag read, keyed by file, line and column.
59    pub site_facts: FxHashMap<(PathBuf, u32, u32), FlagSiteFacts>,
60    /// Sites that are not per-site flag findings: literal `const` flags
61    /// (a definition and the guard reads) and unused registry members.
62    pub constant_sites: Vec<RetirementSiteInput>,
63    /// Definition sites that no code reads, keyed by file, line and column,
64    /// with the reason.
65    pub unread_definitions: FxHashMap<(PathBuf, u32, u32), String>,
66}
67
68impl RetirementFacts {
69    /// Retirement sites for per-site flag findings, with their guard facts,
70    /// followed by the sites of literal `const` flags.
71    #[must_use]
72    pub fn sites_for(&self, flags: &[FeatureFlag]) -> Vec<RetirementSiteInput> {
73        flags
74            .iter()
75            .map(|flag| {
76                let mut site = RetirementSiteInput::from_feature_flag(flag);
77                let key = (flag.path.clone(), flag.line, flag.col);
78                if let Some(facts) = self.site_facts.get(&key) {
79                    site.facts = *facts;
80                    if facts.definition() {
81                        site.role = FlagSiteRole::Definition;
82                    }
83                }
84                site.unread = self.unread_definitions.get(&key).cloned();
85                site
86            })
87            .chain(self.constant_sites.iter().cloned())
88            .collect()
89    }
90}
91
92impl RetirementSiteInput {
93    /// The retirement site of a per-site flag finding, without guard facts.
94    #[must_use]
95    pub fn from_feature_flag(flag: &FeatureFlag) -> Self {
96        Self {
97            path: flag.path.clone(),
98            flag_name: flag.flag_name.clone(),
99            kind: retirement_kind(flag.kind),
100            sdk_name: flag.sdk_name.clone(),
101            line: flag.line,
102            col: flag.col,
103            role: FlagSiteRole::Read,
104            guarded_dead_exports: flag.guarded_dead_exports.clone(),
105            facts: FlagSiteFacts::default(),
106            literal: None,
107            unread: None,
108        }
109    }
110}
111
112const fn retirement_kind(kind: FlagKind) -> RetirementFlagKind {
113    match kind {
114        FlagKind::EnvironmentVariable => RetirementFlagKind::EnvironmentVariable,
115        FlagKind::SdkCall => RetirementFlagKind::SdkCall,
116        FlagKind::ConfigObject => RetirementFlagKind::ConfigObject,
117    }
118}
119
120/// How the report orders its rows.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub enum RetirementSort {
123    /// Oldest flag first. Flags without an age come last.
124    #[default]
125    Age,
126    /// Fewest read sites first.
127    Sites,
128    /// Flag name, ascending.
129    Name,
130}
131
132/// Options that narrow and order the rows of the report.
133#[derive(Debug, Clone, Default)]
134pub struct RetirementOptions {
135    /// Row order.
136    pub sort: RetirementSort,
137    /// Keep only flags at least this many days old. A flag without an age
138    /// does not pass.
139    pub min_age_days: Option<u64>,
140    /// Keep only flags with at least one of these reasons. Empty keeps all.
141    pub reasons: Vec<RetirementReason>,
142    /// Keep only the first N rows after the sort.
143    pub top: Option<usize>,
144}
145
146/// Identity of a flag: detection kind, SDK provider, name and workspace.
147#[derive(Debug, Clone, PartialEq, Eq, Hash)]
148struct FlagKey {
149    kind: RetirementFlagKind,
150    sdk_name: Option<String>,
151    flag_name: String,
152    workspace: Option<String>,
153}
154
155/// Group flag sites into one row per flag and run the reason detectors.
156///
157/// `root` makes paths relative. `workspaces` adds the workspace root to the
158/// flag identity, so two packages that use the same flag name get two rows.
159/// Pass every site of the project: `in_scope` selects the sites the rows
160/// show, but the read reasons count the reads outside the scope too, so a
161/// `--changed-since` run does not call a widely read flag single-read.
162/// Rows come back sorted by name; [`finish_report`] applies the final order.
163#[must_use]
164pub fn aggregate_flags(
165    sites: Vec<RetirementSiteInput>,
166    root: &Path,
167    workspaces: &[WorkspaceInfo],
168    in_scope: &dyn Fn(&Path) -> bool,
169) -> Vec<RetirementFlag> {
170    let mut groups: FxHashMap<FlagKey, Vec<RetirementSiteInput>> = FxHashMap::default();
171    for site in sites {
172        let key = FlagKey {
173            kind: site.kind,
174            sdk_name: site.sdk_name.clone(),
175            flag_name: site.flag_name.clone(),
176            workspace: workspace_of(&site.path, root, workspaces),
177        };
178        groups.entry(key).or_default().push(site);
179    }
180    for inputs in groups.values_mut() {
181        inputs.sort_by(|a, b| {
182            a.path
183                .cmp(&b.path)
184                .then(a.line.cmp(&b.line))
185                .then(a.col.cmp(&b.col))
186                .then(a.role.cmp(&b.role))
187        });
188        inputs.dedup_by(|a, b| {
189            a.path == b.path && a.line == b.line && a.col == b.col && a.role == b.role
190        });
191    }
192    let reads = count_reads_across_workspaces(&groups, root);
193    let mut rows: Vec<RetirementFlag> = groups
194        .into_iter()
195        .filter_map(|(key, mut sites)| {
196            sites.retain(|site| in_scope(&site.path));
197            if sites.is_empty() {
198                return None;
199            }
200            let all = reads
201                .get(&(key.kind, key.sdk_name.clone(), key.flag_name.clone()))
202                .copied()
203                .unwrap_or_default();
204            Some(build_row(key, &sites, all, root))
205        })
206        .collect();
207    rows.sort_by(compare_identity);
208    rows
209}
210
211/// Read counts of a flag over every workspace.
212#[derive(Debug, Clone, Copy, Default)]
213struct ReadCounts {
214    reads: usize,
215    production_reads: usize,
216}
217
218/// A flag with the same kind, SDK and name in two workspaces gets two rows,
219/// but a read in one workspace still reads the flag of the other. The read
220/// reasons use these counts, so an end-to-end test package does not make a
221/// flag test-only.
222fn count_reads_across_workspaces(
223    groups: &FxHashMap<FlagKey, Vec<RetirementSiteInput>>,
224    root: &Path,
225) -> FxHashMap<(RetirementFlagKind, Option<String>, String), ReadCounts> {
226    let mut counts: FxHashMap<(RetirementFlagKind, Option<String>, String), ReadCounts> =
227        FxHashMap::default();
228    for (key, inputs) in groups {
229        let entry = counts
230            .entry((key.kind, key.sdk_name.clone(), key.flag_name.clone()))
231            .or_default();
232        for input in inputs
233            .iter()
234            .filter(|input| input.role == FlagSiteRole::Read)
235        {
236            entry.reads += 1;
237            if !is_test_or_story(root, &relative(&input.path, root)) {
238                entry.production_reads += 1;
239            }
240        }
241    }
242    counts
243}
244
245fn build_row(
246    key: FlagKey,
247    inputs: &[RetirementSiteInput],
248    all: ReadCounts,
249    root: &Path,
250) -> RetirementFlag {
251    let sites: Vec<RetirementSite> = inputs
252        .iter()
253        .map(|input| {
254            let path = relative(&input.path, root);
255            RetirementSite {
256                in_test: is_test_or_story(root, &path),
257                path,
258                line: input.line,
259                col: input.col,
260                role: input.role,
261            }
262        })
263        .collect();
264    let read_sites = sites
265        .iter()
266        .filter(|site| site.role == FlagSiteRole::Read)
267        .count();
268    let test_only = all.reads > 0 && all.production_reads == 0;
269
270    let mut row = RetirementFlag {
271        flag_name: key.flag_name,
272        kind: key.kind,
273        sdk_name: key.sdk_name,
274        workspace: key.workspace,
275        sites,
276        read_sites,
277        test_only,
278        first_seen: None,
279        oldest_surviving_site: None,
280        last_touched: None,
281        age_days: None,
282        reasons: Vec::new(),
283        evidence: Vec::new(),
284        actions: Vec::new(),
285        vendor: None,
286    };
287    detect_single_read_site(&mut row, all);
288    detect_test_only(&mut row, all);
289    detect_literal_constant(&mut row, inputs, root);
290    detect_guard_facts(&mut row, inputs, root);
291    detect_guards_dead_code(&mut row, inputs, root);
292    detect_defined_never_read(&mut row, inputs, all, root);
293    row
294}
295
296fn detect_defined_never_read(
297    row: &mut RetirementFlag,
298    inputs: &[RetirementSiteInput],
299    all: ReadCounts,
300    root: &Path,
301) {
302    if all.reads > 0 {
303        return;
304    }
305    for input in inputs {
306        let Some(detail) = &input.unread else {
307            continue;
308        };
309        add_reason(
310            row,
311            RetirementEvidence {
312                reason: RetirementReason::DefinedNeverRead,
313                path: relative(&input.path, root),
314                line: input.line,
315                detail: detail.clone(),
316            },
317        );
318    }
319}
320
321fn detect_literal_constant(row: &mut RetirementFlag, inputs: &[RetirementSiteInput], root: &Path) {
322    if row.kind != RetirementFlagKind::Constant {
323        return;
324    }
325    let definition = inputs.iter().find(|input| input.literal.is_some());
326    let Some(site) = definition.or_else(|| inputs.first()) else {
327        return;
328    };
329    let detail = site.literal.as_ref().map_or_else(
330        || "the flag is a const with a literal value".to_string(),
331        |value| format!("const {} = {value}", row.flag_name),
332    );
333    add_reason(
334        row,
335        RetirementEvidence {
336            reason: RetirementReason::LiteralConstant,
337            path: relative(&site.path, root),
338            line: site.line,
339            detail,
340        },
341    );
342}
343
344fn detect_guard_facts(row: &mut RetirementFlag, inputs: &[RetirementSiteInput], root: &Path) {
345    for input in inputs {
346        if input.facts.identical_branches() {
347            add_reason(
348                row,
349                RetirementEvidence {
350                    reason: RetirementReason::IdenticalBranches,
351                    path: relative(&input.path, root),
352                    line: input.line,
353                    detail: "both branches of the guard are the same code".to_string(),
354                },
355            );
356        }
357        if input.facts.empty_branch() {
358            add_reason(
359                row,
360                RetirementEvidence {
361                    reason: RetirementReason::EmptyBranch,
362                    path: relative(&input.path, root),
363                    line: input.line,
364                    detail: "no branch of the guard holds code".to_string(),
365                },
366            );
367        }
368    }
369}
370
371fn detect_single_read_site(row: &mut RetirementFlag, all: ReadCounts) {
372    if row.read_sites != 1 || all.reads != 1 {
373        return;
374    }
375    let Some(site) = row
376        .sites
377        .iter()
378        .find(|site| site.role == FlagSiteRole::Read)
379    else {
380        return;
381    };
382    let evidence = RetirementEvidence {
383        reason: RetirementReason::SingleReadSite,
384        path: site.path.clone(),
385        line: site.line,
386        detail: "the flag has one read site".to_string(),
387    };
388    add_reason(row, evidence);
389}
390
391fn detect_test_only(row: &mut RetirementFlag, all: ReadCounts) {
392    if !row.test_only {
393        return;
394    }
395    let Some(site) = row
396        .sites
397        .iter()
398        .find(|site| site.role == FlagSiteRole::Read)
399    else {
400        return;
401    };
402    let detail = if all.reads == 1 {
403        "the only read site is in a test, story or mock file".to_string()
404    } else {
405        format!(
406            "all {} read sites are in test, story or mock files",
407            all.reads
408        )
409    };
410    let evidence = RetirementEvidence {
411        reason: RetirementReason::TestOnly,
412        path: site.path.clone(),
413        line: site.line,
414        detail,
415    };
416    add_reason(row, evidence);
417}
418
419fn detect_guards_dead_code(row: &mut RetirementFlag, inputs: &[RetirementSiteInput], root: &Path) {
420    for input in inputs {
421        if input.guarded_dead_exports.is_empty() {
422            continue;
423        }
424        let evidence = RetirementEvidence {
425            reason: RetirementReason::GuardsDeadCode,
426            path: relative(&input.path, root),
427            line: input.line,
428            detail: format!(
429                "the guarded block holds unused exports: {}",
430                input.guarded_dead_exports.join(", ")
431            ),
432        };
433        add_reason(row, evidence);
434    }
435}
436
437/// Record a reason once and keep every piece of evidence for it.
438fn add_reason(row: &mut RetirementFlag, evidence: RetirementEvidence) {
439    if !row.reasons.contains(&evidence.reason) {
440        row.reasons.push(evidence.reason);
441    }
442    row.evidence.push(evidence);
443}
444
445/// Count, filter, order and limit the rows, and add the review action to
446/// each candidate.
447#[must_use]
448pub fn finish_report(
449    mut rows: Vec<RetirementFlag>,
450    age_mode: FlagAgeMode,
451    generated_at_clock: Option<String>,
452    options: &RetirementOptions,
453) -> FlagRetirementReport {
454    for row in &mut rows {
455        row.reasons
456            .sort_by_key(|reason| RetirementReason::ALL.iter().position(|r| r == reason));
457        row.evidence.sort_by(|a, b| {
458            reason_rank(a.reason)
459                .cmp(&reason_rank(b.reason))
460                .then(a.path.cmp(&b.path))
461                .then(a.line.cmp(&b.line))
462        });
463        if !row.reasons.is_empty() {
464            row.actions = vec![RetirementAction {
465                kind: RetirementActionType::ReviewRetirement,
466                auto_fixable: false,
467                description: REVIEW_DESCRIPTION.to_string(),
468            }];
469        }
470    }
471    let summary = summarize(&rows);
472    rows.retain(|row| passes_filters(row, options));
473    rows.sort_by(|a, b| compare_for_sort(a, b, options.sort));
474    if let Some(top) = options.top {
475        rows.truncate(top);
476    }
477    FlagRetirementReport {
478        generated_at_clock,
479        age_mode,
480        vendor_state: None,
481        summary,
482        regression: None,
483        max_flag_age: None,
484        flags: rows,
485    }
486}
487
488/// Why a `--max-flag-age` gate without git history did not run.
489pub const AGE_GATE_NO_HISTORY: &str =
490    "git history is not available (a shallow clone or no repository), so no flag age was measured";
491
492/// Why a `--max-flag-age` gate with the age mode `off` did not run.
493pub const AGE_GATE_AGE_OFF: &str = "the flag age mode is off, so no flag age was measured";
494
495/// The `--max-flag-age` verdict over every row in scope. Pass the rows
496/// before [`finish_report`] filters and limits them. `skip_reason` is set
497/// when no age was measured; the gate is then `skipped`, not passed.
498#[must_use]
499pub fn max_age_gate(
500    rows: &[RetirementFlag],
501    max_days: u64,
502    skip_reason: Option<&str>,
503) -> FlagAgeGate {
504    let unmeasured = rows
505        .iter()
506        .filter(|row| row.kind != RetirementFlagKind::VendorExport && row.age_days.is_none())
507        .count();
508    if let Some(reason) = skip_reason {
509        return FlagAgeGate {
510            status: RegressionStatus::Skipped,
511            max_days,
512            exceeded: false,
513            unmeasured,
514            reason: Some(reason.to_string()),
515            flags: Vec::new(),
516        };
517    }
518    let mut old: Vec<&RetirementFlag> = rows
519        .iter()
520        .filter(|row| row.age_days.is_some_and(|age| age > max_days))
521        .collect();
522    old.sort_by(|a, b| compare_for_sort(a, b, RetirementSort::Age));
523    let exceeded = !old.is_empty();
524    FlagAgeGate {
525        status: if exceeded {
526            RegressionStatus::Exceeded
527        } else {
528            RegressionStatus::Pass
529        },
530        max_days,
531        exceeded,
532        unmeasured,
533        reason: None,
534        flags: old
535            .into_iter()
536            .map(|row| FlagAgeGateEntry {
537                flag_name: row.flag_name.clone(),
538                kind: row.kind,
539                sdk_name: row.sdk_name.clone(),
540                workspace: row.workspace.clone(),
541                age_days: row.age_days.unwrap_or_default(),
542            })
543            .collect(),
544    }
545}
546
547fn reason_rank(reason: RetirementReason) -> usize {
548    RetirementReason::ALL
549        .iter()
550        .position(|r| *r == reason)
551        .unwrap_or(usize::MAX)
552}
553
554fn summarize(rows: &[RetirementFlag]) -> RetirementSummary {
555    let mut summary = RetirementSummary {
556        distinct_flags: rows
557            .iter()
558            .filter(|row| row.kind != RetirementFlagKind::VendorExport)
559            .count(),
560        ..RetirementSummary::default()
561    };
562    for row in rows {
563        if !row.reasons.is_empty() {
564            summary.candidates += 1;
565        }
566        for reason in &row.reasons {
567            *summary.by_reason.entry(*reason).or_default() += 1;
568        }
569    }
570    summary
571}
572
573fn passes_filters(row: &RetirementFlag, options: &RetirementOptions) -> bool {
574    if let Some(min_age) = options.min_age_days
575        && row.age_days.is_none_or(|age| age < min_age)
576    {
577        return false;
578    }
579    options.reasons.is_empty() || row.reasons.iter().any(|r| options.reasons.contains(r))
580}
581
582fn compare_for_sort(
583    a: &RetirementFlag,
584    b: &RetirementFlag,
585    sort: RetirementSort,
586) -> std::cmp::Ordering {
587    let primary = match sort {
588        // Oldest first; a flag without an age sorts after every aged flag.
589        RetirementSort::Age => match (a.age_days, b.age_days) {
590            (Some(x), Some(y)) => y.cmp(&x),
591            (Some(_), None) => std::cmp::Ordering::Less,
592            (None, Some(_)) => std::cmp::Ordering::Greater,
593            (None, None) => std::cmp::Ordering::Equal,
594        },
595        RetirementSort::Sites => a.read_sites.cmp(&b.read_sites),
596        RetirementSort::Name => std::cmp::Ordering::Equal,
597    };
598    primary.then_with(|| compare_identity(a, b))
599}
600
601fn compare_identity(a: &RetirementFlag, b: &RetirementFlag) -> std::cmp::Ordering {
602    a.flag_name
603        .cmp(&b.flag_name)
604        .then(a.kind.cmp(&b.kind))
605        .then(a.sdk_name.cmp(&b.sdk_name))
606        .then(a.workspace.cmp(&b.workspace))
607}
608
609/// Root-relative workspace path of the deepest workspace that holds `path`,
610/// or `None` for a file outside every workspace or a project without them.
611fn workspace_of(path: &Path, root: &Path, workspaces: &[WorkspaceInfo]) -> Option<String> {
612    workspaces
613        .iter()
614        .filter(|ws| path.starts_with(&ws.root) && ws.root != root)
615        .max_by_key(|ws| ws.root.components().count())
616        .map(|ws| relative(&ws.root, root))
617}
618
619fn relative(path: &Path, root: &Path) -> String {
620    path.strip_prefix(root)
621        .unwrap_or(path)
622        .to_string_lossy()
623        .replace('\\', "/")
624}
625
626fn is_test_or_story(root: &Path, relative_path: &str) -> bool {
627    if crate::test_paths::is_test_path_str(root, relative_path) {
628        return true;
629    }
630    let file_name = relative_path.rsplit('/').next().unwrap_or(relative_path);
631    let lower = file_name.to_ascii_lowercase();
632    STORY_FILE_MARKERS
633        .iter()
634        .any(|marker| lower.contains(marker))
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    const ROOT: &str = "/repo";
642
643    fn site(name: &str, path: &str, line: u32) -> RetirementSiteInput {
644        RetirementSiteInput {
645            path: PathBuf::from(ROOT).join(path),
646            flag_name: name.to_string(),
647            kind: RetirementFlagKind::EnvironmentVariable,
648            sdk_name: None,
649            line,
650            col: 4,
651            role: FlagSiteRole::Read,
652            guarded_dead_exports: Vec::new(),
653            facts: FlagSiteFacts::default(),
654            literal: None,
655            unread: None,
656        }
657    }
658
659    fn sdk_site(name: &str, sdk: &str, path: &str, line: u32) -> RetirementSiteInput {
660        RetirementSiteInput {
661            kind: RetirementFlagKind::SdkCall,
662            sdk_name: Some(sdk.to_string()),
663            ..site(name, path, line)
664        }
665    }
666
667    fn rows(sites: Vec<RetirementSiteInput>) -> Vec<RetirementFlag> {
668        aggregate_flags(sites, Path::new(ROOT), &[], &|_| true)
669    }
670
671    fn row<'r>(rows: &'r [RetirementFlag], name: &str) -> &'r RetirementFlag {
672        rows.iter()
673            .find(|row| row.flag_name == name)
674            .unwrap_or_else(|| panic!("no row for {name}"))
675    }
676
677    #[test]
678    fn one_row_per_kind_sdk_and_name() {
679        let rows = rows(vec![
680            site("FEATURE_A", "src/a.ts", 1),
681            site("FEATURE_A", "src/b.ts", 2),
682            sdk_site("checkout", "LaunchDarkly", "src/a.ts", 3),
683            sdk_site("checkout", "Statsig", "src/a.ts", 4),
684            sdk_site("checkout", "LaunchDarkly", "src/c.ts", 5),
685        ]);
686        assert_eq!(rows.len(), 3);
687        assert_eq!(row(&rows, "FEATURE_A").read_sites, 2);
688        let launchdarkly = rows
689            .iter()
690            .find(|row| row.sdk_name.as_deref() == Some("LaunchDarkly"))
691            .expect("LaunchDarkly row");
692        assert_eq!(launchdarkly.read_sites, 2);
693        assert_eq!(launchdarkly.sites[0].path, "src/a.ts");
694        assert_eq!(launchdarkly.sites[1].path, "src/c.ts");
695    }
696
697    #[test]
698    fn workspace_root_is_part_of_the_identity() {
699        let workspaces = vec![
700            WorkspaceInfo {
701                root: PathBuf::from("/repo/packages/web"),
702                name: "web".to_string(),
703                is_internal_dependency: false,
704            },
705            WorkspaceInfo {
706                root: PathBuf::from("/repo/packages/api"),
707                name: "api".to_string(),
708                is_internal_dependency: false,
709            },
710        ];
711        let rows = aggregate_flags(
712            vec![
713                site("FEATURE_A", "packages/web/src/a.ts", 1),
714                site("FEATURE_A", "packages/api/src/a.ts", 1),
715                site("FEATURE_A", "scripts/a.ts", 1),
716            ],
717            Path::new(ROOT),
718            &workspaces,
719            &|_| true,
720        );
721        let workspaces: Vec<Option<&str>> = rows.iter().map(|r| r.workspace.as_deref()).collect();
722        assert_eq!(
723            workspaces,
724            vec![None, Some("packages/api"), Some("packages/web")]
725        );
726    }
727
728    #[test]
729    fn reads_in_other_workspaces_count_for_the_read_reasons() {
730        let workspaces = vec![
731            WorkspaceInfo {
732                root: PathBuf::from("/repo/packages/web"),
733                name: "web".to_string(),
734                is_internal_dependency: false,
735            },
736            WorkspaceInfo {
737                root: PathBuf::from("/repo/packages/e2e"),
738                name: "e2e".to_string(),
739                is_internal_dependency: false,
740            },
741        ];
742        let rows = aggregate_flags(
743            vec![
744                site("FEATURE_X", "packages/web/src/a.ts", 1),
745                site("FEATURE_X", "packages/e2e/a.spec.ts", 1),
746            ],
747            Path::new(ROOT),
748            &workspaces,
749            &|_| true,
750        );
751        assert_eq!(rows.len(), 2, "one row per workspace");
752        for row in &rows {
753            assert_eq!(row.read_sites, 1);
754            assert!(!row.test_only, "production code reads the flag: {row:?}");
755            assert!(row.reasons.is_empty(), "{row:?}");
756        }
757    }
758
759    #[test]
760    fn reads_outside_the_scope_count_for_the_read_reasons() {
761        let rows = aggregate_flags(
762            vec![
763                // One read in scope: without the out-of-scope read this row
764                // gets single-read-site.
765                site("FEATURE_ONE", "src/changed.ts", 1),
766                site("FEATURE_ONE", "src/third.ts", 1),
767                // One test read in scope: without the out-of-scope production
768                // read this row gets test-only.
769                site("FEATURE_TEST", "src/other.test.ts", 1),
770                site("FEATURE_TEST", "src/third.ts", 2),
771                site("FEATURE_OUT", "src/third.ts", 3),
772            ],
773            Path::new(ROOT),
774            &[],
775            &|path| path.ends_with("changed.ts") || path.ends_with("other.test.ts"),
776        );
777        let names: Vec<&str> = rows.iter().map(|r| r.flag_name.as_str()).collect();
778        assert_eq!(
779            names,
780            vec!["FEATURE_ONE", "FEATURE_TEST"],
781            "a flag with no site in scope has no row"
782        );
783        for row in &rows {
784            assert_eq!(row.read_sites, 1, "{row:?}");
785            assert!(!row.test_only, "{row:?}");
786            assert!(row.reasons.is_empty(), "{row:?}");
787        }
788    }
789
790    #[test]
791    fn single_read_site_needs_exactly_one_read() {
792        let rows = rows(vec![
793            site("FEATURE_ONE", "src/a.ts", 7),
794            site("FEATURE_TWO", "src/a.ts", 1),
795            site("FEATURE_TWO", "src/b.ts", 1),
796        ]);
797        let one = row(&rows, "FEATURE_ONE");
798        assert_eq!(one.reasons, vec![RetirementReason::SingleReadSite]);
799        assert_eq!(one.evidence[0].path, "src/a.ts");
800        assert_eq!(one.evidence[0].line, 7);
801        assert!(row(&rows, "FEATURE_TWO").reasons.is_empty());
802    }
803
804    #[test]
805    fn test_only_needs_every_read_in_test_story_or_mock_files() {
806        let rows = rows(vec![
807            site("FEATURE_T", "src/a.test.ts", 1),
808            site("FEATURE_T", "src/Button.stories.tsx", 1),
809            site("FEATURE_T", "src/__mocks__/flags.ts", 1),
810            site("FEATURE_MIXED", "src/a.test.ts", 1),
811            site("FEATURE_MIXED", "src/a.ts", 1),
812        ]);
813        let only = row(&rows, "FEATURE_T");
814        assert!(only.test_only);
815        let evidence = only
816            .evidence
817            .iter()
818            .find(|e| e.reason == RetirementReason::TestOnly)
819            .expect("test-only evidence");
820        assert_eq!(
821            evidence.detail,
822            "all 3 read sites are in test, story or mock files"
823        );
824        assert!(only.reasons.contains(&RetirementReason::TestOnly));
825        assert!(only.sites.iter().all(|s| s.in_test));
826        let mixed = row(&rows, "FEATURE_MIXED");
827        assert!(!mixed.test_only);
828        assert!(!mixed.reasons.contains(&RetirementReason::TestOnly));
829    }
830
831    #[test]
832    fn guards_dead_code_lists_the_unused_exports() {
833        let mut guarded = site("FEATURE_G", "src/a.ts", 3);
834        guarded.guarded_dead_exports = vec!["legacy".to_string(), "old".to_string()];
835        let rows = rows(vec![guarded, site("FEATURE_G", "src/b.ts", 9)]);
836        let row = row(&rows, "FEATURE_G");
837        assert_eq!(row.reasons, vec![RetirementReason::GuardsDeadCode]);
838        assert_eq!(
839            row.evidence[0].detail,
840            "the guarded block holds unused exports: legacy, old"
841        );
842    }
843
844    #[test]
845    fn guard_facts_become_reasons_with_one_evidence_per_site() {
846        let mut identical = site("FEATURE_I", "src/a.ts", 3);
847        identical.facts = FlagSiteFacts::default().with_identical_branches(true);
848        let mut empty = site("FEATURE_I", "src/b.ts", 8);
849        empty.facts = FlagSiteFacts::default().with_empty_branch(true);
850        let rows = rows(vec![identical, empty]);
851        let row = row(&rows, "FEATURE_I");
852        assert_eq!(
853            row.reasons,
854            vec![
855                RetirementReason::IdenticalBranches,
856                RetirementReason::EmptyBranch
857            ]
858        );
859        assert_eq!(row.evidence[0].path, "src/a.ts");
860        assert_eq!(row.evidence[1].path, "src/b.ts");
861    }
862
863    #[test]
864    fn a_constant_row_is_a_literal_constant_with_the_value_as_evidence() {
865        let definition = RetirementSiteInput {
866            kind: RetirementFlagKind::Constant,
867            role: FlagSiteRole::Definition,
868            literal: Some("true".to_string()),
869            ..site("FEATURE_C", "src/a.ts", 1)
870        };
871        let read = RetirementSiteInput {
872            kind: RetirementFlagKind::Constant,
873            ..site("FEATURE_C", "src/a.ts", 4)
874        };
875        let rows = rows(vec![definition, read]);
876        let row = row(&rows, "FEATURE_C");
877        assert_eq!(row.read_sites, 1, "the definition is not a read");
878        assert_eq!(
879            row.reasons,
880            vec![
881                RetirementReason::SingleReadSite,
882                RetirementReason::LiteralConstant
883            ]
884        );
885        let evidence = row
886            .evidence
887            .iter()
888            .find(|e| e.reason == RetirementReason::LiteralConstant)
889            .expect("evidence");
890        assert_eq!(evidence.detail, "const FEATURE_C = true");
891        assert_eq!(evidence.line, 1);
892    }
893
894    #[test]
895    fn an_unread_definition_is_defined_never_read_only_without_reads() {
896        let definition = RetirementSiteInput {
897            kind: RetirementFlagKind::SdkCall,
898            role: FlagSiteRole::Definition,
899            unread: Some("export `x` is unused".to_string()),
900            ..site("show-x", "src/flags.ts", 2)
901        };
902        let alone = rows(vec![definition.clone()]);
903        assert_eq!(alone[0].read_sites, 0);
904        assert_eq!(alone[0].reasons, vec![RetirementReason::DefinedNeverRead]);
905        assert_eq!(alone[0].evidence[0].detail, "export `x` is unused");
906
907        let read = RetirementSiteInput {
908            kind: RetirementFlagKind::SdkCall,
909            ..site("show-x", "src/page.ts", 9)
910        };
911        let with_read = rows(vec![definition, read]);
912        assert!(
913            !with_read[0]
914                .reasons
915                .contains(&RetirementReason::DefinedNeverRead)
916        );
917    }
918
919    #[test]
920    fn facts_attach_to_sites_by_file_line_and_column() {
921        let flag = FeatureFlag {
922            path: PathBuf::from("/repo/src/a.ts"),
923            flag_name: "FEATURE_A".to_string(),
924            kind: FlagKind::EnvironmentVariable,
925            confidence: fallow_types::results::FlagConfidence::High,
926            line: 3,
927            col: 6,
928            guard_span_start: None,
929            guard_span_end: None,
930            sdk_name: None,
931            guard_line_start: None,
932            guard_line_end: None,
933            guarded_dead_exports: Vec::new(),
934        };
935        let mut facts = RetirementFacts::default();
936        facts.site_facts.insert(
937            (PathBuf::from("/repo/src/a.ts"), 3, 6),
938            FlagSiteFacts::default().with_empty_branch(true),
939        );
940        let sites = facts.sites_for(std::slice::from_ref(&flag));
941        assert!(sites[0].facts.empty_branch());
942        let other = FeatureFlag { col: 7, ..flag };
943        assert!(!facts.sites_for(&[other])[0].facts.empty_branch());
944    }
945
946    fn aged(name: &str, age: Option<u64>, reads: usize) -> RetirementFlag {
947        let sites = (0..reads)
948            .map(|i| site(name, "src/a.ts", u32::try_from(i).unwrap_or(0) + 1))
949            .collect();
950        let mut row = rows(sites).remove(0);
951        row.age_days = age;
952        row
953    }
954
955    fn names(report: &FlagRetirementReport) -> Vec<&str> {
956        report.flags.iter().map(|r| r.flag_name.as_str()).collect()
957    }
958
959    #[test]
960    fn max_age_gate_lists_only_flags_older_than_the_limit() {
961        let rows = vec![
962            aged("FEATURE_B", Some(90), 2),
963            aged("FEATURE_A", None, 2),
964            aged("FEATURE_C", Some(300), 2),
965            aged("FEATURE_D", Some(91), 2),
966        ];
967        let gate = max_age_gate(&rows, 90, None);
968        assert!(gate.exceeded);
969        assert_eq!(gate.status, RegressionStatus::Exceeded);
970        assert_eq!(gate.unmeasured, 1, "FEATURE_A has no age");
971        let names: Vec<&str> = gate.flags.iter().map(|f| f.flag_name.as_str()).collect();
972        assert_eq!(
973            names,
974            vec!["FEATURE_C", "FEATURE_D"],
975            "oldest first; 90 is not over 90"
976        );
977        let passed = max_age_gate(&rows, 300, None);
978        assert!(!passed.exceeded);
979        assert_eq!(passed.status, RegressionStatus::Pass);
980    }
981
982    #[test]
983    fn max_age_gate_without_history_is_skipped_not_passed() {
984        let rows = vec![aged("FEATURE_A", None, 2), aged("FEATURE_B", None, 1)];
985        let gate = max_age_gate(&rows, 1, Some(AGE_GATE_NO_HISTORY));
986        assert_eq!(gate.status, RegressionStatus::Skipped);
987        assert!(!gate.exceeded);
988        assert_eq!(gate.unmeasured, 2);
989        assert_eq!(gate.reason.as_deref(), Some(AGE_GATE_NO_HISTORY));
990        assert!(gate.flags.is_empty());
991    }
992
993    #[test]
994    fn vendor_only_rows_do_not_count_as_distinct_flags() {
995        let mut vendor_only = aged("web.new", None, 1);
996        vendor_only.sites.clear();
997        vendor_only.read_sites = 0;
998        vendor_only.kind = RetirementFlagKind::VendorExport;
999        vendor_only.reasons = vec![RetirementReason::VendorOnly];
1000        let report = finish_report(
1001            vec![aged("FEATURE_A", None, 2), vendor_only],
1002            FlagAgeMode::Off,
1003            None,
1004            &RetirementOptions::default(),
1005        );
1006        assert_eq!(report.summary.distinct_flags, 1);
1007        assert_eq!(report.summary.candidates, 1);
1008        assert_eq!(report.summary.listed_flags(), 2);
1009        assert_eq!(max_age_gate(&report.flags, 1, None).unmeasured, 1);
1010    }
1011
1012    #[test]
1013    fn sort_by_age_puts_the_oldest_first_and_unknown_ages_last() {
1014        let report = finish_report(
1015            vec![
1016                aged("FEATURE_B", Some(10), 2),
1017                aged("FEATURE_A", None, 2),
1018                aged("FEATURE_C", Some(300), 2),
1019                aged("FEATURE_D", Some(10), 2),
1020            ],
1021            FlagAgeMode::Blame,
1022            None,
1023            &RetirementOptions::default(),
1024        );
1025        assert_eq!(
1026            names(&report),
1027            vec!["FEATURE_C", "FEATURE_B", "FEATURE_D", "FEATURE_A"]
1028        );
1029    }
1030
1031    #[test]
1032    fn sort_by_sites_puts_the_fewest_reads_first() {
1033        let report = finish_report(
1034            vec![aged("FEATURE_A", None, 3), aged("FEATURE_B", None, 1)],
1035            FlagAgeMode::Off,
1036            None,
1037            &RetirementOptions {
1038                sort: RetirementSort::Sites,
1039                ..RetirementOptions::default()
1040            },
1041        );
1042        assert_eq!(names(&report), vec!["FEATURE_B", "FEATURE_A"]);
1043    }
1044
1045    #[test]
1046    fn filters_apply_after_the_summary() {
1047        let report = finish_report(
1048            vec![
1049                aged("FEATURE_OLD", Some(400), 1),
1050                aged("FEATURE_NEW", Some(3), 1),
1051                aged("FEATURE_WIDE", Some(900), 2),
1052                aged("FEATURE_UNKNOWN", None, 1),
1053            ],
1054            FlagAgeMode::Blame,
1055            None,
1056            &RetirementOptions {
1057                min_age_days: Some(30),
1058                reasons: vec![RetirementReason::SingleReadSite],
1059                ..RetirementOptions::default()
1060            },
1061        );
1062        assert_eq!(names(&report), vec!["FEATURE_OLD"]);
1063        assert_eq!(report.summary.distinct_flags, 4);
1064        assert_eq!(report.summary.candidates, 3);
1065        assert_eq!(
1066            report
1067                .summary
1068                .by_reason
1069                .get(&RetirementReason::SingleReadSite),
1070            Some(&3)
1071        );
1072    }
1073
1074    #[test]
1075    fn only_candidates_get_the_review_action_and_it_is_never_auto_fixable() {
1076        let report = finish_report(
1077            vec![aged("FEATURE_A", None, 1), aged("FEATURE_B", None, 2)],
1078            FlagAgeMode::Off,
1079            None,
1080            &RetirementOptions::default(),
1081        );
1082        let candidate = &report.flags[0];
1083        assert_eq!(candidate.actions.len(), 1);
1084        assert!(!candidate.actions[0].auto_fixable);
1085        assert!(report.flags[1].actions.is_empty());
1086    }
1087
1088    #[test]
1089    fn top_limits_rows_after_the_sort() {
1090        let report = finish_report(
1091            vec![aged("FEATURE_A", Some(1), 1), aged("FEATURE_B", Some(2), 1)],
1092            FlagAgeMode::Blame,
1093            None,
1094            &RetirementOptions {
1095                top: Some(1),
1096                ..RetirementOptions::default()
1097            },
1098        );
1099        assert_eq!(names(&report), vec!["FEATURE_B"]);
1100        assert_eq!(report.summary.distinct_flags, 2);
1101    }
1102}