1use 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
20const STORY_FILE_MARKERS: &[&str] = &[".stories.", ".story."];
23
24const REVIEW_DESCRIPTION: &str = "Review this flag for retirement. The evidence lists the reasons.";
26
27#[derive(Debug, Clone)]
29pub struct RetirementSiteInput {
30 pub path: PathBuf,
32 pub flag_name: String,
34 pub kind: RetirementFlagKind,
36 pub sdk_name: Option<String>,
38 pub line: u32,
40 pub col: u32,
42 pub role: FlagSiteRole,
44 pub guarded_dead_exports: Vec<String>,
46 pub facts: FlagSiteFacts,
48 pub literal: Option<String>,
50 pub unread: Option<String>,
52}
53
54#[derive(Debug, Default)]
57pub struct RetirementFacts {
58 pub site_facts: FxHashMap<(PathBuf, u32, u32), FlagSiteFacts>,
60 pub constant_sites: Vec<RetirementSiteInput>,
63 pub unread_definitions: FxHashMap<(PathBuf, u32, u32), String>,
66}
67
68impl RetirementFacts {
69 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub enum RetirementSort {
123 #[default]
125 Age,
126 Sites,
128 Name,
130}
131
132#[derive(Debug, Clone, Default)]
134pub struct RetirementOptions {
135 pub sort: RetirementSort,
137 pub min_age_days: Option<u64>,
140 pub reasons: Vec<RetirementReason>,
142 pub top: Option<usize>,
144}
145
146#[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#[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#[derive(Debug, Clone, Copy, Default)]
213struct ReadCounts {
214 reads: usize,
215 production_reads: usize,
216}
217
218fn 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
437fn 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#[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
488pub 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
492pub const AGE_GATE_AGE_OFF: &str = "the flag age mode is off, so no flag age was measured";
494
495#[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 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
609fn 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 site("FEATURE_ONE", "src/changed.ts", 1),
766 site("FEATURE_ONE", "src/third.ts", 1),
767 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}