1use std::path::{Path, PathBuf};
4
5use fallow_types::envelope::Meta;
6use fallow_types::results::{ActiveSuppression, AnalysisResults};
7use rustc_hash::{FxHashMap, FxHashSet};
8use serde::{Deserialize, Serialize};
9
10use crate::audit::{AuditSummary, AuditVerdict};
11use crate::report::ci::fingerprint::fingerprint_hash;
12use crate::report::format_display_path;
13
14const STORE_SCHEMA_VERSION: u32 = 4;
15
16const MAX_RECORDS: usize = 200;
17
18const MAX_CONTAINMENT: usize = 200;
19
20const TREND_TOLERANCE: i64 = 0;
21
22const STORE_FILE: &str = "impact.json";
23
24const MAX_RECENT_RESOLVED: usize = 50;
25
26const ID_SEP: &str = "\u{1f}";
27
28const CODE_DUPLICATION_KIND: &str = "code-duplication";
29
30const BLANKET_SUPPRESSION: &str = "*";
31
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub struct ImpactCounts {
36 pub total_issues: usize,
37 pub dead_code: usize,
38 pub complexity: usize,
39 pub duplication: usize,
40}
41
42impl ImpactCounts {
43 fn from_summary(summary: &AuditSummary) -> Self {
44 Self {
45 total_issues: summary.dead_code_issues
46 + summary.complexity_findings
47 + summary.duplication_clone_groups,
48 dead_code: summary.dead_code_issues,
49 complexity: summary.complexity_findings,
50 duplication: summary.duplication_clone_groups,
51 }
52 }
53
54 pub(crate) fn from_combined(dead_code: usize, complexity: usize, duplication: usize) -> Self {
55 Self {
56 total_issues: dead_code + complexity + duplication,
57 dead_code,
58 complexity,
59 duplication,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ImpactRecord {
66 pub timestamp: String,
67 pub version: String,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub git_sha: Option<String>,
70 pub verdict: String,
71 #[serde(default)]
72 pub gate: bool,
73 pub counts: ImpactCounts,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct PendingContainment {
78 pub blocked_at: String,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub git_sha: Option<String>,
81 pub blocked_counts: ImpactCounts,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
86pub struct ContainmentEvent {
87 pub blocked_at: String,
88 pub cleared_at: String,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub git_sha: Option<String>,
91 pub blocked_counts: ImpactCounts,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct FrontierFinding {
96 pub id: String,
97 pub kind: String,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub symbol: Option<String>,
100}
101
102impl FrontierFinding {
103 fn move_key(&self) -> String {
104 match &self.symbol {
105 Some(symbol) => format!("{}{ID_SEP}{symbol}", self.kind),
106 None => self.id.clone(),
107 }
108 }
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct FileFrontier {
113 #[serde(default)]
114 pub findings: Vec<FrontierFinding>,
115 #[serde(default)]
116 pub suppressions: Vec<String>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121pub struct ResolutionEvent {
122 pub kind: String,
123 pub path: String,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub symbol: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub git_sha: Option<String>,
128 pub timestamp: String,
129}
130
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct ImpactStore {
133 #[serde(default)]
134 pub schema_version: u32,
135 #[serde(default)]
136 pub enabled: bool,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub first_recorded: Option<String>,
139 #[serde(default)]
140 pub records: Vec<ImpactRecord>,
141 #[serde(default)]
142 pub project_records: Vec<ImpactRecord>,
143 #[serde(default)]
144 pub containment: Vec<ContainmentEvent>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub pending_containment: Option<PendingContainment>,
147 #[serde(default)]
151 pub frontier: FxHashMap<String, FxHashMap<String, FileFrontier>>,
152 #[serde(default)]
155 pub clone_frontier: FxHashMap<String, FxHashMap<String, Vec<String>>>,
156 #[serde(default)]
157 pub resolved_total: usize,
158 #[serde(default)]
159 pub suppressed_total: usize,
160 #[serde(default)]
161 pub recent_resolved: Vec<ResolutionEvent>,
162 #[serde(default)]
163 pub onboarding_declined: bool,
164 #[serde(default)]
168 pub explicit_decision: bool,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub last_digest_epoch: Option<u64>,
174}
175
176#[derive(Debug, Default, Deserialize)]
181struct LegacyFlatStore {
182 #[serde(default)]
183 enabled: bool,
184 #[serde(default)]
185 first_recorded: Option<String>,
186 #[serde(default)]
187 records: Vec<ImpactRecord>,
188 #[serde(default)]
189 project_records: Vec<ImpactRecord>,
190 #[serde(default)]
191 containment: Vec<ContainmentEvent>,
192 #[serde(default)]
193 pending_containment: Option<PendingContainment>,
194 #[serde(default)]
195 frontier: FlatFrontier,
196 #[serde(default)]
197 clone_frontier: FlatCloneFrontier,
198 #[serde(default)]
199 resolved_total: usize,
200 #[serde(default)]
201 suppressed_total: usize,
202 #[serde(default)]
203 recent_resolved: Vec<ResolutionEvent>,
204 #[serde(default)]
205 onboarding_declined: bool,
206 #[serde(default)]
207 explicit_decision: bool,
208 #[serde(default)]
209 last_digest_epoch: Option<u64>,
210}
211
212impl LegacyFlatStore {
213 fn into_store(self, worktree_key: &str) -> ImpactStore {
216 let mut frontier: FxHashMap<String, FlatFrontier> = FxHashMap::default();
217 if !self.frontier.is_empty() {
218 frontier.insert(worktree_key.to_owned(), self.frontier);
219 }
220 let mut clone_frontier: FxHashMap<String, FlatCloneFrontier> = FxHashMap::default();
221 if !self.clone_frontier.is_empty() {
222 clone_frontier.insert(worktree_key.to_owned(), self.clone_frontier);
223 }
224 ImpactStore {
225 schema_version: STORE_SCHEMA_VERSION,
226 enabled: self.enabled,
227 first_recorded: self.first_recorded,
228 records: self.records,
229 project_records: self.project_records,
230 containment: self.containment,
231 pending_containment: self.pending_containment,
232 frontier,
233 clone_frontier,
234 resolved_total: self.resolved_total,
235 suppressed_total: self.suppressed_total,
236 recent_resolved: self.recent_resolved,
237 onboarding_declined: self.onboarding_declined,
238 explicit_decision: self.explicit_decision,
239 last_digest_epoch: self.last_digest_epoch,
240 }
241 }
242}
243
244static IDENTITY_CACHE: std::sync::OnceLock<std::sync::Mutex<FxHashMap<PathBuf, (String, String)>>> =
249 std::sync::OnceLock::new();
250
251fn hash_path_identity(path: &Path) -> String {
256 let raw = path.to_string_lossy();
257 let normalized = if cfg!(any(target_os = "macos", target_os = "windows")) {
258 raw.to_lowercase()
259 } else {
260 raw.into_owned()
261 };
262 fingerprint_hash(&[normalized.as_str()])
263}
264
265fn impact_project_key(root: &Path) -> String {
271 let identity = fallow_core::changed_files::resolve_git_common_dir(root)
272 .ok()
273 .or_else(|| dunce::canonicalize(root).ok())
274 .unwrap_or_else(|| root.to_path_buf());
275 hash_path_identity(&identity)
276}
277
278fn worktree_key(root: &Path) -> String {
283 let identity = fallow_core::changed_files::resolve_git_toplevel(root)
284 .ok()
285 .or_else(|| dunce::canonicalize(root).ok())
286 .unwrap_or_else(|| root.to_path_buf());
287 hash_path_identity(&identity)
288}
289
290fn project_identity(root: &Path) -> (String, String) {
292 let cache = IDENTITY_CACHE.get_or_init(|| std::sync::Mutex::new(FxHashMap::default()));
293 if let Ok(map) = cache.lock()
294 && let Some(found) = map.get(root)
295 {
296 return found.clone();
297 }
298 let identity = (impact_project_key(root), worktree_key(root));
299 if let Ok(mut map) = cache.lock() {
300 map.insert(root.to_path_buf(), identity.clone());
301 }
302 identity
303}
304
305#[cfg(test)]
306thread_local! {
307 static TEST_CONFIG_DIR: std::cell::RefCell<Option<PathBuf>> =
311 const { std::cell::RefCell::new(None) };
312
313 static TEST_FORCE_CI: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
321}
322
323fn impact_config_dir() -> Option<PathBuf> {
327 #[cfg(test)]
328 {
329 TEST_CONFIG_DIR.with(|c| c.borrow().clone())
330 }
331 #[cfg(not(test))]
332 {
333 crate::telemetry::config_dir()
334 }
335}
336
337fn record_gate_is_ci() -> bool {
344 #[cfg(test)]
345 {
346 TEST_FORCE_CI.with(std::cell::Cell::get)
347 }
348 #[cfg(not(test))]
349 {
350 crate::telemetry::is_ci()
351 }
352}
353
354fn store_path(root: &Path) -> Option<PathBuf> {
358 let (project_key, _) = project_identity(root);
359 Some(
360 impact_config_dir()?
361 .join("impact")
362 .join(format!("{project_key}.json")),
363 )
364}
365
366fn legacy_store_path(root: &Path) -> PathBuf {
369 root.join(".fallow").join(STORE_FILE)
370}
371
372pub fn load(root: &Path) -> ImpactStore {
375 let Some(path) = store_path(root) else {
376 return ImpactStore::default();
377 };
378 match std::fs::read_to_string(&path) {
379 Ok(content) => parse_store(&content, &path),
380 Err(_) => migrate_legacy_store(root),
383 }
384}
385
386fn parse_store(content: &str, path: &Path) -> ImpactStore {
387 match serde_json::from_str::<ImpactStore>(content) {
388 Ok(store) => {
389 if store.schema_version > STORE_SCHEMA_VERSION {
390 tracing::warn!(
391 "fallow impact: store at {} has schema_version {} but this build understands up to {}; reading it as best-effort, fields this build does not know are dropped on the next write. Upgrade fallow to read it fully.",
392 path.display(),
393 store.schema_version,
394 STORE_SCHEMA_VERSION,
395 );
396 }
397 store
398 }
399 Err(err) => {
400 tracing::warn!(
401 "fallow impact: ignoring unreadable store at {} ({err}); run `fallow impact enable` to reset it",
402 path.display()
403 );
404 ImpactStore::default()
405 }
406 }
407}
408
409fn save(store: &ImpactStore, root: &Path) {
412 let Some(path) = store_path(root) else {
413 return;
414 };
415 if let Some(parent) = path.parent()
416 && std::fs::create_dir_all(parent).is_err()
417 {
418 return;
419 }
420 if let Ok(json) = serde_json::to_string_pretty(store) {
421 let _ = fallow_config::atomic_write(&path, json.as_bytes());
422 }
423}
424
425fn migrate_legacy_store(root: &Path) -> ImpactStore {
433 let legacy_path = legacy_store_path(root);
434 let Ok(content) = std::fs::read_to_string(&legacy_path) else {
435 return ImpactStore::default();
436 };
437 let Ok(legacy) = serde_json::from_str::<LegacyFlatStore>(&content) else {
438 return ImpactStore::default();
439 };
440 let (_, worktree) = project_identity(root);
441 let store = legacy.into_store(&worktree);
442 save(&store, root);
443 store
444}
445
446pub fn enable(root: &Path) -> bool {
450 let mut store = load(root);
451 let was_enabled = store.enabled;
452 store.enabled = true;
453 store.explicit_decision = true;
454 if store.schema_version == 0 {
455 store.schema_version = STORE_SCHEMA_VERSION;
456 }
457 save(&store, root);
458 !was_enabled
459}
460
461pub fn disable(root: &Path) -> bool {
466 let mut store = load(root);
467 let was_enabled = store.enabled;
468 store.enabled = false;
469 store.explicit_decision = true;
470 if store.schema_version == 0 {
471 store.schema_version = STORE_SCHEMA_VERSION;
472 }
473 save(&store, root);
474 was_enabled
475}
476
477#[derive(Debug, Clone, Copy)]
481pub struct ImpactDigest {
482 pub containment_count: usize,
483 pub resolved_total: usize,
484}
485
486const DIGEST_INTERVAL_SECS: u64 = 7 * 24 * 60 * 60;
488
489pub fn take_due_digest(root: &Path) -> Option<ImpactDigest> {
496 let mut store = load(root);
497 if !resolve_enabled(&store).0 {
498 return None;
499 }
500 let containment_count = store.containment.len();
501 if containment_count == 0 && store.resolved_total == 0 {
502 return None;
503 }
504 let now = std::time::SystemTime::now()
505 .duration_since(std::time::UNIX_EPOCH)
506 .ok()?
507 .as_secs();
508 if let Some(last) = store.last_digest_epoch
509 && now.saturating_sub(last) < DIGEST_INTERVAL_SECS
510 {
511 return None;
512 }
513 store.last_digest_epoch = Some(now);
514 save(&store, root);
515 Some(ImpactDigest {
516 containment_count,
517 resolved_total: store.resolved_total,
518 })
519}
520
521pub fn decline_onboarding(root: &Path) -> bool {
524 let mut store = load(root);
525 let was_declined = store.onboarding_declined;
526 store.onboarding_declined = true;
527 if store.schema_version == 0 {
528 store.schema_version = STORE_SCHEMA_VERSION;
529 }
530 save(&store, root);
531 !was_declined
532}
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
538#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
539#[serde(rename_all = "lowercase")]
540pub enum EnabledSource {
541 Project,
542 User,
543 Default,
544}
545
546#[derive(Debug, Default, Serialize, Deserialize)]
550struct GlobalImpactConfig {
551 #[serde(default)]
552 default_enabled: bool,
553}
554
555fn global_config_path() -> Option<PathBuf> {
556 Some(impact_config_dir()?.join(STORE_FILE))
557}
558
559fn load_global_default() -> bool {
561 let Some(path) = global_config_path() else {
562 return false;
563 };
564 std::fs::read_to_string(&path)
565 .ok()
566 .and_then(|c| serde_json::from_str::<GlobalImpactConfig>(&c).ok())
567 .is_some_and(|c| c.default_enabled)
568}
569
570pub fn set_global_default(on: bool) -> bool {
572 let was = load_global_default();
573 if let Some(path) = global_config_path() {
574 if let Some(parent) = path.parent()
575 && std::fs::create_dir_all(parent).is_err()
576 {
577 return false;
578 }
579 let config = GlobalImpactConfig {
580 default_enabled: on,
581 };
582 if let Ok(json) = serde_json::to_string_pretty(&config) {
583 let _ = fallow_config::atomic_write(&path, json.as_bytes());
584 }
585 }
586 was != on
587}
588
589fn resolve_enabled(store: &ImpactStore) -> (bool, EnabledSource) {
600 if store.enabled {
601 return (true, EnabledSource::Project);
602 }
603 if store.explicit_decision {
604 return (false, EnabledSource::Project);
605 }
606 if load_global_default() {
607 return (true, EnabledSource::User);
608 }
609 (false, EnabledSource::Default)
610}
611
612#[must_use]
615pub fn resolved_store_path(root: &Path) -> Option<PathBuf> {
616 store_path(root)
617}
618
619#[must_use]
621pub fn resolved_project_key(root: &Path) -> String {
622 project_identity(root).0
623}
624
625pub fn reset(root: &Path) -> bool {
627 store_path(root).is_some_and(|p| std::fs::remove_file(&p).is_ok())
628}
629
630pub fn reset_all() -> bool {
635 let Some(dir) = impact_config_dir().map(|d| d.join("impact")) else {
636 return false;
637 };
638 dir.is_dir() && std::fs::remove_dir_all(&dir).is_ok()
639}
640
641pub struct AuditRunRecord<'a> {
643 pub verdict: AuditVerdict,
644 pub gate: bool,
645 pub git_sha: Option<&'a str>,
646 pub version: &'a str,
647 pub timestamp: &'a str,
648 pub attribution: Option<&'a AttributionInput<'a>>,
649}
650
651pub fn record_audit_run(root: &Path, summary: &AuditSummary, record: &AuditRunRecord<'_>) {
652 let AuditRunRecord {
653 verdict,
654 gate,
655 git_sha,
656 version,
657 timestamp,
658 attribution,
659 } = record;
660 if record_gate_is_ci() {
665 return;
666 }
667 let mut store = load(root);
668 if !resolve_enabled(&store).0 {
669 return;
670 }
671 store.schema_version = STORE_SCHEMA_VERSION;
672
673 let counts = ImpactCounts::from_summary(summary);
674 let verdict_str = verdict_label(*verdict);
675
676 if store.first_recorded.is_none() {
677 store.first_recorded = Some((*timestamp).to_owned());
678 }
679
680 apply_containment(&mut store, *verdict, *gate, *git_sha, timestamp, &counts);
681
682 store.records.push(ImpactRecord {
683 timestamp: (*timestamp).to_owned(),
684 version: (*version).to_owned(),
685 git_sha: git_sha.map(ToOwned::to_owned),
686 verdict: verdict_str.to_owned(),
687 gate: *gate,
688 counts,
689 });
690 compact(&mut store);
691
692 if let Some(attribution) = attribution {
693 let (_, worktree) = project_identity(root);
694 apply_attribution(&mut store, attribution, &worktree, *git_sha, timestamp);
695 }
696
697 save(&store, root);
698}
699
700pub fn record_combined_run(
702 root: &Path,
703 counts: ImpactCounts,
704 git_sha: Option<&str>,
705 version: &str,
706 timestamp: &str,
707 attribution: Option<&AttributionInput<'_>>,
708) {
709 if record_gate_is_ci() {
710 return;
711 }
712 let mut store = load(root);
713 if !resolve_enabled(&store).0 {
714 return;
715 }
716 store.schema_version = STORE_SCHEMA_VERSION;
717
718 if store.first_recorded.is_none() {
719 store.first_recorded = Some(timestamp.to_owned());
720 }
721
722 let verdict_str = if counts.total_issues == 0 {
723 "pass"
724 } else {
725 "warn"
726 };
727 store.project_records.push(ImpactRecord {
728 timestamp: timestamp.to_owned(),
729 version: version.to_owned(),
730 git_sha: git_sha.map(ToOwned::to_owned),
731 verdict: verdict_str.to_owned(),
732 gate: false,
733 counts,
734 });
735 if store.project_records.len() > MAX_RECORDS {
736 let overflow = store.project_records.len() - MAX_RECORDS;
737 store.project_records.drain(0..overflow);
738 }
739
740 if let Some(attribution) = attribution {
741 let (_, worktree) = project_identity(root);
742 apply_attribution(&mut store, attribution, &worktree, git_sha, timestamp);
743 }
744
745 save(&store, root);
746}
747
748fn apply_containment(
750 store: &mut ImpactStore,
751 verdict: AuditVerdict,
752 gate: bool,
753 git_sha: Option<&str>,
754 timestamp: &str,
755 counts: &ImpactCounts,
756) {
757 if !gate {
758 return;
759 }
760 if verdict == AuditVerdict::Fail {
761 if store.pending_containment.is_none() {
762 store.pending_containment = Some(PendingContainment {
763 blocked_at: timestamp.to_owned(),
764 git_sha: git_sha.map(ToOwned::to_owned),
765 blocked_counts: counts.clone(),
766 });
767 }
768 } else if let Some(pending) = store.pending_containment.take() {
769 store.containment.push(ContainmentEvent {
770 blocked_at: pending.blocked_at,
771 cleared_at: timestamp.to_owned(),
772 git_sha: pending.git_sha,
773 blocked_counts: pending.blocked_counts,
774 });
775 if store.containment.len() > MAX_CONTAINMENT {
776 let overflow = store.containment.len() - MAX_CONTAINMENT;
777 store.containment.drain(0..overflow);
778 }
779 }
780}
781
782fn compact(store: &mut ImpactStore) {
783 if store.records.len() > MAX_RECORDS {
784 let overflow = store.records.len() - MAX_RECORDS;
785 store.records.drain(0..overflow);
786 }
787}
788
789#[derive(Debug, Clone)]
790pub struct FindingInput {
791 pub path: PathBuf,
792 pub kind: &'static str,
793 pub symbol: Option<String>,
794}
795
796#[derive(Debug, Clone)]
797pub struct CloneInput {
798 pub fingerprint: String,
799 pub instance_paths: Vec<PathBuf>,
800}
801
802pub enum Scope<'a> {
803 ChangedFiles(&'a [PathBuf]),
804 WholeProject,
805}
806
807pub struct AttributionInput<'a> {
808 pub root: &'a Path,
809 pub scope: Scope<'a>,
810 pub findings: Vec<FindingInput>,
811 pub clones: Vec<CloneInput>,
812 pub suppressions: &'a [ActiveSuppression],
813}
814
815fn finding_id(kind: &str, rel_path: &str, symbol: Option<&str>) -> String {
816 fingerprint_hash(&[kind, rel_path, symbol.unwrap_or("")])
817}
818
819fn covered_by(present: &FxHashSet<String>, kind: &str) -> bool {
820 present.contains(BLANKET_SUPPRESSION) || present.contains(kind)
821}
822
823type FlatFrontier = FxHashMap<String, FileFrontier>;
825type FlatCloneFrontier = FxHashMap<String, Vec<String>>;
827
828fn apply_attribution(
829 store: &mut ImpactStore,
830 input: &AttributionInput<'_>,
831 worktree_key: &str,
832 git_sha: Option<&str>,
833 timestamp: &str,
834) {
835 let root = input.root;
836 let mut frontier: FlatFrontier = store.frontier.remove(worktree_key).unwrap_or_default();
841 let mut clone_frontier: FlatCloneFrontier = store
842 .clone_frontier
843 .remove(worktree_key)
844 .unwrap_or_default();
845
846 let changed: FxHashSet<String> = match input.scope {
847 Scope::ChangedFiles(files) => files.iter().map(|p| format_display_path(p, root)).collect(),
848 Scope::WholeProject => whole_project_scope(&frontier, &clone_frontier, input, root),
849 };
850
851 let mut current_findings: FxHashMap<String, Vec<FrontierFinding>> = FxHashMap::default();
852 for f in &input.findings {
853 let rel = format_display_path(&f.path, root);
854 if !changed.contains(&rel) {
855 continue;
856 }
857 let id = finding_id(f.kind, &rel, f.symbol.as_deref());
858 current_findings
859 .entry(rel)
860 .or_default()
861 .push(FrontierFinding {
862 id,
863 kind: f.kind.to_owned(),
864 symbol: f.symbol.clone(),
865 });
866 }
867 let mut current_supps: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
868 for s in input.suppressions {
869 let rel = format_display_path(&s.path, root);
870 if !changed.contains(&rel) {
871 continue;
872 }
873 let key = s
874 .kind
875 .clone()
876 .unwrap_or_else(|| BLANKET_SUPPRESSION.to_owned());
877 current_supps.entry(rel).or_default().insert(key);
878 }
879
880 let mut appeared_move_keys: FxHashSet<String> = FxHashSet::default();
881 for (rel, findings) in ¤t_findings {
882 let prior_ids: FxHashSet<&str> = frontier
883 .get(rel)
884 .map(|f| f.findings.iter().map(|x| x.id.as_str()).collect())
885 .unwrap_or_default();
886 for ff in findings {
887 if !prior_ids.contains(ff.id.as_str()) {
888 appeared_move_keys.insert(ff.move_key());
889 }
890 }
891 }
892
893 uncredit_cross_run_moves(store, &appeared_move_keys);
894
895 let mut disappearance_input = FileDisappearancesInput {
896 store,
897 frontier: &frontier,
898 changed: &changed,
899 current_findings: ¤t_findings,
900 current_supps: ¤t_supps,
901 appeared_move_keys: &appeared_move_keys,
902 git_sha,
903 timestamp,
904 };
905 classify_file_disappearances(&mut disappearance_input);
906 update_file_frontier(&mut frontier, &changed, current_findings, current_supps);
907 classify_clone_disappearances(
908 store,
909 &frontier,
910 &mut clone_frontier,
911 input,
912 &changed,
913 git_sha,
914 timestamp,
915 );
916 prune_frontier(&mut frontier, &mut clone_frontier, root);
917 bound_recent_resolved(store);
918
919 if frontier.is_empty() {
922 store.frontier.remove(worktree_key);
923 } else {
924 store.frontier.insert(worktree_key.to_owned(), frontier);
925 }
926 if clone_frontier.is_empty() {
927 store.clone_frontier.remove(worktree_key);
928 } else {
929 store
930 .clone_frontier
931 .insert(worktree_key.to_owned(), clone_frontier);
932 }
933}
934
935fn whole_project_scope(
936 frontier: &FlatFrontier,
937 clone_frontier: &FlatCloneFrontier,
938 input: &AttributionInput<'_>,
939 root: &Path,
940) -> FxHashSet<String> {
941 let mut set: FxHashSet<String> = frontier.keys().cloned().collect();
942 for paths in clone_frontier.values() {
943 for p in paths {
944 set.insert(p.clone());
945 }
946 }
947 for f in &input.findings {
948 set.insert(format_display_path(&f.path, root));
949 }
950 for c in &input.clones {
951 for p in &c.instance_paths {
952 set.insert(format_display_path(p, root));
953 }
954 }
955 set
956}
957
958struct FileDisappearancesInput<'a> {
959 store: &'a mut ImpactStore,
960 frontier: &'a FlatFrontier,
961 changed: &'a FxHashSet<String>,
962 current_findings: &'a FxHashMap<String, Vec<FrontierFinding>>,
963 current_supps: &'a FxHashMap<String, FxHashSet<String>>,
964 appeared_move_keys: &'a FxHashSet<String>,
965 git_sha: Option<&'a str>,
966 timestamp: &'a str,
967}
968
969fn classify_file_disappearances(input: &mut FileDisappearancesInput<'_>) {
970 let store = &mut *input.store;
971 let frontier = input.frontier;
972 let changed = input.changed;
973 let current_findings = input.current_findings;
974 let current_supps = input.current_supps;
975 let appeared_move_keys = input.appeared_move_keys;
976 let git_sha = input.git_sha;
977 let timestamp = input.timestamp;
978 let empty_supps = FxHashSet::default();
979 for rel in changed {
980 let Some(prior) = frontier.get(rel) else {
981 continue;
982 };
983 let now_ids: FxHashSet<&str> = current_findings
984 .get(rel)
985 .map(|fs| fs.iter().map(|f| f.id.as_str()).collect())
986 .unwrap_or_default();
987 let now_supps = current_supps.get(rel).unwrap_or(&empty_supps);
988 let prior_supps: FxHashSet<&str> = prior.suppressions.iter().map(String::as_str).collect();
989 let new_supp_kinds: FxHashSet<String> = now_supps
990 .iter()
991 .filter(|k| !prior_supps.contains(k.as_str()))
992 .cloned()
993 .collect();
994
995 let mut resolved = Vec::new();
996 let mut suppressed = 0usize;
997 for pf in &prior.findings {
998 if now_ids.contains(pf.id.as_str()) {
999 continue; }
1001 if appeared_move_keys.contains(&pf.move_key()) {
1002 continue; }
1004 if covered_by(&new_supp_kinds, &pf.kind) {
1005 suppressed += 1; } else {
1007 resolved.push(pf.clone());
1008 }
1009 }
1010 store.suppressed_total += suppressed;
1011 for pf in resolved {
1012 store.resolved_total += 1;
1013 store.recent_resolved.push(ResolutionEvent {
1014 kind: pf.kind,
1015 path: rel.clone(),
1016 symbol: pf.symbol,
1017 git_sha: git_sha.map(ToOwned::to_owned),
1018 timestamp: timestamp.to_owned(),
1019 });
1020 }
1021 }
1022}
1023
1024fn update_file_frontier(
1025 frontier: &mut FlatFrontier,
1026 changed: &FxHashSet<String>,
1027 mut current_findings: FxHashMap<String, Vec<FrontierFinding>>,
1028 mut current_supps: FxHashMap<String, FxHashSet<String>>,
1029) {
1030 for rel in changed {
1031 let findings = current_findings.remove(rel).unwrap_or_default();
1032 let mut suppressions: Vec<String> = current_supps
1033 .remove(rel)
1034 .unwrap_or_default()
1035 .into_iter()
1036 .collect();
1037 suppressions.sort_unstable();
1038 if findings.is_empty() && suppressions.is_empty() {
1039 frontier.remove(rel);
1040 } else {
1041 frontier.insert(
1042 rel.clone(),
1043 FileFrontier {
1044 findings,
1045 suppressions,
1046 },
1047 );
1048 }
1049 }
1050}
1051
1052fn classify_clone_disappearances(
1053 store: &mut ImpactStore,
1054 frontier: &FlatFrontier,
1055 clone_frontier: &mut FlatCloneFrontier,
1056 input: &AttributionInput<'_>,
1057 changed: &FxHashSet<String>,
1058 git_sha: Option<&str>,
1059 timestamp: &str,
1060) {
1061 let root = input.root;
1062 let mut current: FxHashMap<String, Vec<String>> = FxHashMap::default();
1063 for c in &input.clones {
1064 let mut paths: Vec<String> = c
1065 .instance_paths
1066 .iter()
1067 .map(|p| format_display_path(p, root))
1068 .collect();
1069 paths.sort_unstable();
1070 paths.dedup();
1071 if paths.iter().any(|p| changed.contains(p)) {
1072 current.insert(c.fingerprint.clone(), paths);
1073 }
1074 }
1075
1076 let dup_suppressed = |paths: &[String]| -> bool {
1077 paths.iter().any(|p| {
1078 changed.contains(p)
1079 && frontier.get(p).is_some_and(|f| {
1080 f.suppressions
1081 .iter()
1082 .any(|k| k == CODE_DUPLICATION_KIND || k == BLANKET_SUPPRESSION)
1083 })
1084 })
1085 };
1086
1087 let still_duplicated: FxHashSet<&String> = current.values().flatten().collect();
1088
1089 let disappeared: Vec<(String, Vec<String>)> = clone_frontier
1090 .iter()
1091 .filter(|(fp, paths)| {
1092 paths.iter().any(|p| changed.contains(p)) && !current.contains_key(*fp)
1093 })
1094 .map(|(fp, paths)| (fp.clone(), paths.clone()))
1095 .collect();
1096
1097 for (fp, paths) in disappeared {
1098 clone_frontier.remove(&fp);
1099 if paths.iter().any(|p| still_duplicated.contains(p)) {
1100 continue;
1101 }
1102 if dup_suppressed(&paths) {
1103 store.suppressed_total += 1;
1104 } else {
1105 store.resolved_total += 1;
1106 let path = paths.first().cloned().unwrap_or_default();
1107 store.recent_resolved.push(ResolutionEvent {
1108 kind: CODE_DUPLICATION_KIND.to_owned(),
1109 path,
1110 symbol: None,
1111 git_sha: git_sha.map(ToOwned::to_owned),
1112 timestamp: timestamp.to_owned(),
1113 });
1114 }
1115 }
1116
1117 for (fp, paths) in current {
1118 clone_frontier.insert(fp, paths);
1119 }
1120}
1121
1122fn prune_frontier(
1123 frontier: &mut FlatFrontier,
1124 clone_frontier: &mut FlatCloneFrontier,
1125 root: &Path,
1126) {
1127 frontier.retain(|rel, _| root.join(rel).exists());
1128 clone_frontier.retain(|_, paths| paths.iter().any(|p| root.join(p).exists()));
1129}
1130
1131fn bound_recent_resolved(store: &mut ImpactStore) {
1132 if store.recent_resolved.len() > MAX_RECENT_RESOLVED {
1133 let overflow = store.recent_resolved.len() - MAX_RECENT_RESOLVED;
1134 store.recent_resolved.drain(0..overflow);
1135 }
1136}
1137
1138fn event_move_key(ev: &ResolutionEvent) -> Option<String> {
1139 ev.symbol
1140 .as_ref()
1141 .map(|symbol| format!("{}{ID_SEP}{symbol}", ev.kind))
1142}
1143
1144fn uncredit_cross_run_moves(store: &mut ImpactStore, appeared_move_keys: &FxHashSet<String>) {
1145 if appeared_move_keys.is_empty() {
1146 return;
1147 }
1148 let mut uncredited = 0usize;
1149 store.recent_resolved.retain(|ev| match event_move_key(ev) {
1150 Some(mk) if appeared_move_keys.contains(&mk) => {
1151 uncredited += 1;
1152 false
1153 }
1154 _ => true,
1155 });
1156 store.resolved_total = store.resolved_total.saturating_sub(uncredited);
1157}
1158
1159#[must_use]
1160pub fn collect_dead_code_findings(results: &AnalysisResults) -> Vec<FindingInput> {
1161 let mut out = Vec::new();
1162 let mut push = |path: &Path, kind: &'static str, symbol: Option<String>| {
1163 out.push(FindingInput {
1164 path: path.to_path_buf(),
1165 kind,
1166 symbol,
1167 });
1168 };
1169 collect_unused_symbol_findings(results, &mut push);
1170 collect_dependency_findings(results, &mut push);
1171 collect_catalog_findings(results, &mut push);
1172 out
1173}
1174
1175fn collect_unused_symbol_findings(
1176 results: &AnalysisResults,
1177 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1178) {
1179 for f in &results.unused_files {
1180 push(&f.file.path, "unused-file", None);
1181 }
1182 for f in &results.unused_exports {
1183 push(
1184 &f.export.path,
1185 "unused-export",
1186 Some(f.export.export_name.clone()),
1187 );
1188 }
1189 for f in &results.unused_types {
1190 push(
1191 &f.export.path,
1192 "unused-type",
1193 Some(f.export.export_name.clone()),
1194 );
1195 }
1196 for f in &results.private_type_leaks {
1197 push(
1198 &f.leak.path,
1199 "private-type-leak",
1200 Some(format!(
1201 "{}{ID_SEP}{}",
1202 f.leak.export_name, f.leak.type_name
1203 )),
1204 );
1205 }
1206 for f in &results.unused_enum_members {
1207 push(
1208 &f.member.path,
1209 "unused-enum-member",
1210 Some(format!(
1211 "{}{ID_SEP}{}",
1212 f.member.parent_name, f.member.member_name
1213 )),
1214 );
1215 }
1216 for f in &results.unused_class_members {
1217 push(
1218 &f.member.path,
1219 "unused-class-member",
1220 Some(format!(
1221 "{}{ID_SEP}{}",
1222 f.member.parent_name, f.member.member_name
1223 )),
1224 );
1225 }
1226 for f in &results.unresolved_imports {
1227 push(
1228 &f.import.path,
1229 "unresolved-import",
1230 Some(f.import.specifier.clone()),
1231 );
1232 }
1233 for f in &results.boundary_violations {
1234 let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
1235 push(
1236 &f.violation.from_path,
1237 "boundary-violation",
1238 Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
1239 );
1240 }
1241}
1242
1243fn collect_dependency_findings(
1244 results: &AnalysisResults,
1245 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1246) {
1247 for f in &results.unused_dependencies {
1248 push(
1249 &f.dep.path,
1250 "unused-dependency",
1251 Some(f.dep.package_name.clone()),
1252 );
1253 }
1254 for f in &results.unused_dev_dependencies {
1255 push(
1256 &f.dep.path,
1257 "unused-dev-dependency",
1258 Some(f.dep.package_name.clone()),
1259 );
1260 }
1261 for f in &results.unused_optional_dependencies {
1262 push(
1263 &f.dep.path,
1264 "unused-optional-dependency",
1265 Some(f.dep.package_name.clone()),
1266 );
1267 }
1268 for f in &results.type_only_dependencies {
1269 push(
1270 &f.dep.path,
1271 "type-only-dependency",
1272 Some(f.dep.package_name.clone()),
1273 );
1274 }
1275 for f in &results.test_only_dependencies {
1276 push(
1277 &f.dep.path,
1278 "test-only-dependency",
1279 Some(f.dep.package_name.clone()),
1280 );
1281 }
1282}
1283
1284fn collect_catalog_findings(
1285 results: &AnalysisResults,
1286 push: &mut impl FnMut(&Path, &'static str, Option<String>),
1287) {
1288 for f in &results.unused_catalog_entries {
1289 push(
1290 &f.entry.path,
1291 "unused-catalog-entry",
1292 Some(format!(
1293 "{}{ID_SEP}{}",
1294 f.entry.catalog_name, f.entry.entry_name
1295 )),
1296 );
1297 }
1298 for f in &results.empty_catalog_groups {
1299 push(
1300 &f.group.path,
1301 "empty-catalog-group",
1302 Some(f.group.catalog_name.clone()),
1303 );
1304 }
1305 for f in &results.unresolved_catalog_references {
1306 push(
1307 &f.reference.path,
1308 "unresolved-catalog-reference",
1309 Some(format!(
1310 "{}{ID_SEP}{}",
1311 f.reference.catalog_name, f.reference.entry_name
1312 )),
1313 );
1314 }
1315 for f in &results.unused_dependency_overrides {
1316 push(
1317 &f.entry.path,
1318 "unused-dependency-override",
1319 Some(f.entry.raw_key.clone()),
1320 );
1321 }
1322 for f in &results.misconfigured_dependency_overrides {
1323 push(
1324 &f.entry.path,
1325 "misconfigured-dependency-override",
1326 Some(f.entry.raw_key.clone()),
1327 );
1328 }
1329}
1330
1331#[must_use]
1335pub fn collect_complexity_findings(
1336 report: &crate::health_types::HealthReport,
1337) -> Vec<FindingInput> {
1338 report
1339 .findings
1340 .iter()
1341 .map(|f| FindingInput {
1342 path: f.path.clone(),
1343 kind: "complexity",
1344 symbol: Some(f.name.clone()),
1345 })
1346 .collect()
1347}
1348
1349#[must_use]
1353pub fn collect_clone_findings(
1354 report: &fallow_core::duplicates::DuplicationReport,
1355) -> Vec<CloneInput> {
1356 report
1357 .clone_groups
1358 .iter()
1359 .map(|g| CloneInput {
1360 fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
1361 instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
1362 })
1363 .collect()
1364}
1365
1366const fn verdict_label(verdict: AuditVerdict) -> &'static str {
1367 match verdict {
1368 AuditVerdict::Pass => "pass",
1369 AuditVerdict::Warn => "warn",
1370 AuditVerdict::Fail => "fail",
1371 }
1372}
1373
1374#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1376#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1377#[serde(rename_all = "snake_case")]
1378pub enum ImpactTrendDirection {
1379 Improving,
1381 Declining,
1383 Stable,
1385}
1386
1387#[derive(Debug, Clone, Serialize)]
1389#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1390pub struct TrendSummary {
1391 pub direction: ImpactTrendDirection,
1392 pub total_delta: i64,
1394 pub previous_total: usize,
1395 pub current_total: usize,
1396}
1397
1398fn direction_for(delta: i64) -> ImpactTrendDirection {
1399 if delta < -TREND_TOLERANCE {
1400 ImpactTrendDirection::Improving
1401 } else if delta > TREND_TOLERANCE {
1402 ImpactTrendDirection::Declining
1403 } else {
1404 ImpactTrendDirection::Stable
1405 }
1406}
1407
1408#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1415#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1416pub enum ImpactReportSchemaVersion {
1417 #[serde(rename = "1")]
1419 V1,
1420}
1421
1422#[derive(Debug, Clone, Serialize)]
1424#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1425#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
1426pub struct ImpactReport {
1427 pub schema_version: ImpactReportSchemaVersion,
1431 pub enabled: bool,
1432 pub enabled_source: EnabledSource,
1439 pub record_count: usize,
1440 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
1441 pub meta: Option<Meta>,
1442 #[serde(default, skip_serializing_if = "Option::is_none")]
1443 pub first_recorded: Option<String>,
1444 #[serde(default, skip_serializing_if = "Option::is_none")]
1451 pub latest_git_sha: Option<String>,
1452 #[serde(default, skip_serializing_if = "Option::is_none")]
1457 pub surfacing: Option<ImpactCounts>,
1458 #[serde(default, skip_serializing_if = "Option::is_none")]
1460 pub trend: Option<TrendSummary>,
1461 #[serde(default, skip_serializing_if = "Option::is_none")]
1466 pub project_surfacing: Option<ImpactCounts>,
1467 #[serde(default, skip_serializing_if = "Option::is_none")]
1471 pub project_trend: Option<TrendSummary>,
1472 pub containment_count: usize,
1473 pub recent_containment: Vec<ContainmentEvent>,
1475 pub resolved_total: usize,
1478 pub suppressed_total: usize,
1481 pub recent_resolved: Vec<ResolutionEvent>,
1483 pub attribution_active: bool,
1487 pub onboarding_declined: bool,
1491 pub explicit_decision: bool,
1496}
1497
1498fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1504 if records.len() < 2 {
1505 return None;
1506 }
1507 let current = &records[records.len() - 1];
1508 let previous = &records[records.len() - 2];
1509 let current_total = current.counts.total_issues;
1510 let previous_total = previous.counts.total_issues;
1511 let total_delta = current_total as i64 - previous_total as i64;
1512 Some(TrendSummary {
1513 direction: direction_for(total_delta),
1514 total_delta,
1515 previous_total,
1516 current_total,
1517 })
1518}
1519
1520pub fn build_report(store: &ImpactStore) -> ImpactReport {
1521 let surfacing = store.records.last().map(|r| r.counts.clone());
1522 let trend = trend_for(&store.records);
1523 let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1524 let project_trend = trend_for(&store.project_records);
1525
1526 let recent_containment = store
1527 .containment
1528 .iter()
1529 .rev()
1530 .take(5)
1531 .rev()
1532 .cloned()
1533 .collect();
1534
1535 let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1536
1537 let recent_resolved = store
1538 .recent_resolved
1539 .iter()
1540 .rev()
1541 .take(5)
1542 .rev()
1543 .cloned()
1544 .collect();
1545 let attribution_active = !store.frontier.is_empty()
1546 || !store.clone_frontier.is_empty()
1547 || store.resolved_total > 0
1548 || store.suppressed_total > 0;
1549
1550 let (enabled, enabled_source) = resolve_enabled(store);
1551 ImpactReport {
1552 schema_version: ImpactReportSchemaVersion::V1,
1553 enabled,
1554 enabled_source,
1555 record_count: store.records.len(),
1556 meta: None,
1557 first_recorded: store.first_recorded.clone(),
1558 latest_git_sha,
1559 surfacing,
1560 trend,
1561 project_surfacing,
1562 project_trend,
1563 containment_count: store.containment.len(),
1564 recent_containment,
1565 resolved_total: store.resolved_total,
1566 suppressed_total: store.suppressed_total,
1567 recent_resolved,
1568 attribution_active,
1569 onboarding_declined: store.onboarding_declined,
1570 explicit_decision: store.explicit_decision,
1571 }
1572}
1573
1574#[expect(
1580 clippy::format_push_string,
1581 reason = "small report renderer; readability over avoiding the extra allocation"
1582)]
1583fn render_project_section(out: &mut String, report: &ImpactReport) {
1584 let Some(s) = &report.project_surfacing else {
1585 return;
1586 };
1587 out.push_str(&format!(
1588 " WHOLE PROJECT (whole-repo context, not a to-do)\n {} issue{} across the whole project at your last full `fallow` run\n",
1589 s.total_issues,
1590 plural(s.total_issues),
1591 ));
1592 if let Some(t) = &report.project_trend {
1593 let arrow = trend_arrow(t.direction);
1594 out.push_str(&format!(
1595 " {} -> {} ({}) across your last two full runs (comparable over time)\n",
1596 t.previous_total, t.current_total, arrow,
1597 ));
1598 } else {
1599 out.push_str(" project trend starts after your next full `fallow` run\n");
1600 }
1601 out.push_str(" advances only on your local full `fallow` runs, not CI\n\n");
1602}
1603
1604#[expect(
1606 clippy::format_push_string,
1607 reason = "small report renderer; readability over avoiding the extra allocation"
1608)]
1609pub fn render_human(report: &ImpactReport) -> String {
1610 let mut out = String::new();
1611 out.push_str("FALLOW IMPACT\n\n");
1612
1613 if !report.enabled {
1614 out.push_str(
1615 "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
1616 let your pre-commit gate run a few times to build history.\n",
1617 );
1618 return out;
1619 }
1620
1621 if report.enabled_source == EnabledSource::User {
1622 out.push_str(
1623 "Enabled by your user-global default (`fallow impact default on`). Run\n\
1624 `fallow impact disable` to opt this project out.\n\n",
1625 );
1626 }
1627
1628 if report.record_count == 0 && report.project_surfacing.is_none() {
1629 out.push_str(
1630 "Tracking enabled. No history yet: check back after your next few\n\
1631 commits (Impact records each `fallow audit` / pre-commit gate run,\n\
1632 and each full `fallow` run for the whole-project view).\n",
1633 );
1634 return out;
1635 }
1636
1637 if let Some(s) = &report.surfacing {
1638 out.push_str(&format!(
1639 " LATEST RUN (changed files, act on these now)\n {} issue{} flagged in your last `fallow audit` run\n",
1640 s.total_issues,
1641 plural(s.total_issues),
1642 ));
1643 out.push_str(&format!(
1644 " dead code {} · complexity {} · duplication {}\n\n",
1645 s.dead_code, s.complexity, s.duplication,
1646 ));
1647 }
1648
1649 if let Some(t) = &report.trend {
1650 let arrow = trend_arrow(t.direction);
1651 out.push_str(&format!(
1652 " TREND\n {} -> {} issues ({}) across your last two recorded runs\n each run is changed-file scope, so consecutive runs may cover different changes\n\n",
1653 t.previous_total, t.current_total, arrow,
1654 ));
1655 }
1656
1657 render_project_section(&mut out, report);
1658
1659 out.push_str(&format!(
1660 " CONTAINED AT COMMIT\n {} time{} fallow blocked a commit until it was fixed\n",
1661 report.containment_count,
1662 plural(report.containment_count),
1663 ));
1664
1665 if report.resolved_total > 0 {
1666 out.push_str(&format!(
1667 "\n RESOLVED\n {} finding{} you cleared since fallow started tracking\n",
1668 report.resolved_total,
1669 plural(report.resolved_total),
1670 ));
1671 for ev in &report.recent_resolved {
1672 match &ev.symbol {
1673 Some(symbol) => {
1674 out.push_str(&format!(" {} {} in {}\n", ev.kind, symbol, ev.path));
1675 }
1676 None => out.push_str(&format!(" {} in {}\n", ev.kind, ev.path)),
1677 }
1678 }
1679 } else if report.attribution_active {
1680 out.push_str(
1681 "\n RESOLVED\n none yet; a finding is credited when fallow re-analyzes the\n file it left (a fix that reverts a file to its base state\n may not be individually credited)\n",
1682 );
1683 } else {
1684 out.push_str("\n RESOLVED\n resolution tracking starts from your next gate run\n");
1685 }
1686
1687 if report.suppressed_total > 0 {
1688 out.push_str(&format!(
1689 " {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
1690 report.suppressed_total,
1691 plural(report.suppressed_total),
1692 ));
1693 }
1694
1695 out.push('\n');
1696 let since = report
1697 .first_recorded
1698 .as_deref()
1699 .map_or("the first run", date_only);
1700 if report.record_count > 0 {
1701 out.push_str(&format!(
1702 "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
1703 Changed-file scope: each audit run only sees files differing from your base.\n",
1704 report.record_count,
1705 plural(report.record_count),
1706 since,
1707 ));
1708 } else {
1709 out.push_str(&format!(
1710 "Tracking since {since}. Local-only; never uploaded.\n",
1711 ));
1712 }
1713 out.push_str(
1714 "Resolution tracking is a local-developer signal: it accrues on your\n\
1715 machine across runs, not in CI (fallow never records there).\n",
1716 );
1717 out
1718}
1719
1720pub fn render_json(report: &ImpactReport) -> String {
1722 let value = crate::output_envelope::serialize_root_output(
1723 crate::output_envelope::FallowOutput::Impact(report.clone()),
1724 )
1725 .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
1726 serde_json::to_string_pretty(&value)
1727 .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
1728}
1729
1730#[expect(
1734 clippy::format_push_string,
1735 reason = "small report renderer; readability over avoiding the extra allocation"
1736)]
1737fn render_project_markdown(out: &mut String, report: &ImpactReport) {
1738 let Some(s) = &report.project_surfacing else {
1739 return;
1740 };
1741 out.push_str(&format!(
1742 "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1743 s.total_issues,
1744 plural(s.total_issues),
1745 s.dead_code,
1746 s.complexity,
1747 s.duplication,
1748 ));
1749 if let Some(t) = &report.project_trend {
1750 let arrow = trend_arrow(t.direction);
1751 out.push_str(&format!(
1752 "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
1753 t.previous_total, t.current_total, arrow,
1754 ));
1755 }
1756}
1757
1758#[expect(
1760 clippy::format_push_string,
1761 reason = "small report renderer; readability over avoiding the extra allocation"
1762)]
1763pub fn render_markdown(report: &ImpactReport) -> String {
1764 let mut out = String::new();
1765 out.push_str("## Fallow impact\n\n");
1766
1767 if !report.enabled {
1768 out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
1769 return out;
1770 }
1771 if report.record_count == 0 && report.project_surfacing.is_none() {
1772 out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
1773 return out;
1774 }
1775
1776 if let Some(s) = &report.surfacing {
1777 out.push_str(&format!(
1778 "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1779 s.total_issues,
1780 plural(s.total_issues),
1781 s.dead_code,
1782 s.complexity,
1783 s.duplication,
1784 ));
1785 }
1786 if let Some(t) = &report.trend {
1787 out.push_str(&format!(
1788 "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
1789 t.previous_total,
1790 t.current_total,
1791 trend_arrow(t.direction),
1792 ));
1793 }
1794 render_project_markdown(&mut out, report);
1795 out.push_str(&format!(
1796 "- **Contained at commit:** {} time{}\n",
1797 report.containment_count,
1798 plural(report.containment_count),
1799 ));
1800 if report.resolved_total > 0 {
1801 out.push_str(&format!(
1802 "- **Resolved:** {} finding{} cleared since tracking started\n",
1803 report.resolved_total,
1804 plural(report.resolved_total),
1805 ));
1806 } else if report.attribution_active {
1807 out.push_str("- **Resolved:** none yet; tracking active\n");
1808 } else {
1809 out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
1810 }
1811 if report.suppressed_total > 0 {
1812 out.push_str(&format!(
1813 "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
1814 report.suppressed_total,
1815 plural(report.suppressed_total),
1816 ));
1817 }
1818 let since = report
1819 .first_recorded
1820 .as_deref()
1821 .map_or("the first run", date_only);
1822 if report.record_count > 0 {
1823 out.push_str(&format!(
1824 "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
1825 report.record_count,
1826 plural(report.record_count),
1827 since,
1828 ));
1829 } else {
1830 out.push_str(&format!(
1831 "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
1832 ));
1833 }
1834 out
1835}
1836
1837const fn plural(n: usize) -> &'static str {
1838 if n == 1 { "" } else { "s" }
1839}
1840
1841fn date_only(ts: &str) -> &str {
1847 ts.split_once('T').map_or(ts, |(date, _)| date)
1848}
1849
1850const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
1854 match direction {
1855 ImpactTrendDirection::Improving => "down",
1856 ImpactTrendDirection::Declining => "up",
1857 ImpactTrendDirection::Stable => "flat",
1858 }
1859}
1860
1861#[cfg(test)]
1862mod tests {
1863 use super::*;
1864
1865 fn test_env() -> (tempfile::TempDir, tempfile::TempDir) {
1871 let config = tempfile::tempdir().unwrap();
1872 TEST_CONFIG_DIR.with(|c| *c.borrow_mut() = Some(config.path().to_path_buf()));
1873 let root = tempfile::tempdir().unwrap();
1874 (config, root)
1875 }
1876
1877 fn frontier_paths(store: &ImpactStore) -> FxHashSet<String> {
1880 store
1881 .frontier
1882 .values()
1883 .flat_map(|m| m.keys().cloned())
1884 .collect()
1885 }
1886
1887 fn clone_fingerprints(store: &ImpactStore) -> FxHashSet<String> {
1889 store
1890 .clone_frontier
1891 .values()
1892 .flat_map(|m| m.keys().cloned())
1893 .collect()
1894 }
1895
1896 fn seed_store_raw(root: &Path, bytes: &[u8]) {
1899 let path = store_path(root).expect("test config dir set");
1900 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1901 std::fs::write(&path, bytes).unwrap();
1902 }
1903
1904 fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
1905 AuditSummary {
1906 dead_code_issues: dead,
1907 dead_code_has_errors: dead > 0,
1908 complexity_findings: complexity,
1909 max_cyclomatic: None,
1910 duplication_clone_groups: dupes,
1911 }
1912 }
1913
1914 fn record_v1(
1916 root: &Path,
1917 summary: &AuditSummary,
1918 verdict: AuditVerdict,
1919 gate: bool,
1920 git_sha: Option<&str>,
1921 version: &str,
1922 timestamp: &str,
1923 ) {
1924 record_audit_run(
1925 root,
1926 summary,
1927 &AuditRunRecord {
1928 verdict,
1929 gate,
1930 git_sha,
1931 version,
1932 timestamp,
1933 attribution: None,
1934 },
1935 );
1936 }
1937
1938 fn touch(root: &Path, rel: &str) -> PathBuf {
1941 let p = root.join(rel);
1942 if let Some(parent) = p.parent() {
1943 std::fs::create_dir_all(parent).unwrap();
1944 }
1945 std::fs::write(&p, b"x").unwrap();
1946 p
1947 }
1948
1949 fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
1950 FindingInput {
1951 path: path.to_path_buf(),
1952 kind,
1953 symbol: Some(symbol.to_owned()),
1954 }
1955 }
1956
1957 fn supp(path: &Path, kind: &str) -> ActiveSuppression {
1958 ActiveSuppression {
1959 path: path.to_path_buf(),
1960 kind: Some(kind.to_owned()),
1961 is_file_level: false,
1962 }
1963 }
1964
1965 fn run(
1967 root: &Path,
1968 changed: &[&Path],
1969 findings: Vec<FindingInput>,
1970 clones: Vec<CloneInput>,
1971 supps: &[ActiveSuppression],
1972 ts: &str,
1973 ) {
1974 let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
1975 let input = AttributionInput {
1976 root,
1977 scope: Scope::ChangedFiles(&changed_files),
1978 findings,
1979 clones,
1980 suppressions: supps,
1981 };
1982 record_audit_run(
1983 root,
1984 &summary(0, 0, 0),
1985 &AuditRunRecord {
1986 verdict: AuditVerdict::Pass,
1987 gate: true,
1988 git_sha: Some("sha"),
1989 version: "2.0.0",
1990 timestamp: ts,
1991 attribution: Some(&input),
1992 },
1993 );
1994 }
1995
1996 #[test]
1997 fn disabled_store_does_not_record() {
1998 let (_config, dir) = test_env();
1999 let root = dir.path();
2000 record_v1(
2001 root,
2002 &summary(3, 1, 0),
2003 AuditVerdict::Fail,
2004 true,
2005 Some("abc1234"),
2006 "2.0.0",
2007 "2026-05-29T10:00:00Z",
2008 );
2009 let store = load(root);
2010 assert!(store.records.is_empty());
2011 assert!(!store.enabled);
2012 }
2013
2014 #[test]
2015 fn enable_and_disable_record_the_explicit_decision() {
2016 let (_config, dir) = test_env();
2017 let root = dir.path();
2018 assert!(!load(root).explicit_decision, "fresh store: never asked");
2019
2020 disable(root);
2022 let store = load(root);
2023 assert!(!store.enabled);
2024 assert!(store.explicit_decision);
2025 assert!(build_report(&store).explicit_decision);
2026 }
2027
2028 #[test]
2029 fn due_digest_stamps_and_respects_interval_and_gates() {
2030 let (_config, dir) = test_env();
2031 let root = dir.path();
2032
2033 assert!(take_due_digest(root).is_none());
2035 enable(root);
2036 assert!(take_due_digest(root).is_none(), "zero counters never nag");
2037
2038 let mut store = load(root);
2039 store.resolved_total = 3;
2040 store.containment.push(ContainmentEvent {
2041 blocked_at: "2026-06-11T00:00:00Z".to_string(),
2042 cleared_at: "2026-06-11T00:05:00Z".to_string(),
2043 git_sha: None,
2044 blocked_counts: ImpactCounts::default(),
2045 });
2046 save(&store, root);
2047
2048 let digest = take_due_digest(root).expect("first digest is due");
2049 assert_eq!(digest.containment_count, 1);
2050 assert_eq!(digest.resolved_total, 3);
2051 assert!(
2052 take_due_digest(root).is_none(),
2053 "stamped: not due again within the interval"
2054 );
2055
2056 let mut store = load(root);
2058 store.last_digest_epoch = Some(0);
2059 save(&store, root);
2060 assert!(take_due_digest(root).is_some());
2061 }
2062
2063 #[test]
2064 fn decline_onboarding_persists_in_existing_store() {
2065 let (_config, dir) = test_env();
2066 let root = dir.path();
2067
2068 assert!(decline_onboarding(root));
2069 assert!(!decline_onboarding(root));
2070
2071 let store = load(root);
2072 assert!(store.onboarding_declined);
2073 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2074 assert!(!root.join(".gitignore").exists());
2076 let report = build_report(&store);
2077 assert!(report.onboarding_declined);
2078 }
2079
2080 #[test]
2081 fn enable_then_record_accrues_history() {
2082 let (_config, dir) = test_env();
2083 let root = dir.path();
2084 assert!(enable(root));
2085 assert!(!enable(root)); record_v1(
2087 root,
2088 &summary(2, 1, 0),
2089 AuditVerdict::Warn,
2090 false,
2091 None,
2092 "2.0.0",
2093 "2026-05-29T10:00:00Z",
2094 );
2095 let store = load(root);
2096 assert_eq!(store.records.len(), 1);
2097 assert_eq!(store.records[0].counts.total_issues, 3);
2098 assert_eq!(
2099 store.first_recorded.as_deref(),
2100 Some("2026-05-29T10:00:00Z")
2101 );
2102 }
2103
2104 #[test]
2105 fn record_is_a_noop_in_ci() {
2106 let (_config, dir) = test_env();
2112 let root = dir.path();
2113 assert!(enable(root));
2114 TEST_FORCE_CI.with(|c| c.set(true));
2115 record_v1(
2116 root,
2117 &summary(2, 1, 0),
2118 AuditVerdict::Warn,
2119 false,
2120 None,
2121 "2.0.0",
2122 "2026-05-29T10:00:00Z",
2123 );
2124 TEST_FORCE_CI.with(|c| c.set(false));
2125 let store = load(root);
2126 assert_eq!(store.records.len(), 0, "impact must not record while in CI");
2127 }
2128
2129 #[test]
2130 fn enable_writes_nothing_into_the_repo() {
2131 let (_config, dir) = test_env();
2132 let root = dir.path();
2133 enable(root);
2134 assert!(
2137 !root.join(".gitignore").exists(),
2138 "enable must not create or modify the repo's .gitignore"
2139 );
2140 assert!(
2141 !root.join(".fallow").exists(),
2142 "enable must not create an in-repo .fallow/ dir"
2143 );
2144 let store = load(root);
2146 assert!(store.enabled);
2147 assert!(store.explicit_decision);
2148 assert!(resolve_enabled(&store).0);
2149 }
2150
2151 #[test]
2152 fn single_record_yields_no_trend_no_spike() {
2153 let mut store = ImpactStore {
2154 enabled: true,
2155 ..Default::default()
2156 };
2157 store.records.push(ImpactRecord {
2158 timestamp: "t0".into(),
2159 version: "2.0.0".into(),
2160 git_sha: None,
2161 verdict: "warn".into(),
2162 gate: false,
2163 counts: ImpactCounts {
2164 total_issues: 5,
2165 dead_code: 5,
2166 complexity: 0,
2167 duplication: 0,
2168 },
2169 });
2170 let report = build_report(&store);
2171 assert!(report.trend.is_none());
2172 assert_eq!(report.surfacing.unwrap().total_issues, 5);
2173 }
2174
2175 #[test]
2176 fn empty_store_report_is_first_run() {
2177 let store = ImpactStore::default();
2178 let report = build_report(&store);
2179 assert_eq!(report.record_count, 0);
2180 assert!(report.trend.is_none());
2181 assert!(report.surfacing.is_none());
2182 let human = render_human(&report);
2183 assert!(human.contains("off")); }
2185
2186 #[test]
2187 fn enabled_empty_store_shows_check_back() {
2188 let store = ImpactStore {
2189 enabled: true,
2190 ..Default::default()
2191 };
2192 let report = build_report(&store);
2193 let human = render_human(&report);
2194 assert!(human.contains("No history yet"));
2195 assert!(!human.contains("0 issues"));
2196 }
2197
2198 #[test]
2199 fn trend_improving_when_issues_drop() {
2200 let mut store = ImpactStore {
2201 enabled: true,
2202 ..Default::default()
2203 };
2204 for total in [8usize, 3usize] {
2205 store.records.push(ImpactRecord {
2206 timestamp: format!("t{total}"),
2207 version: "2.0.0".into(),
2208 git_sha: None,
2209 verdict: "warn".into(),
2210 gate: false,
2211 counts: ImpactCounts {
2212 total_issues: total,
2213 dead_code: total,
2214 complexity: 0,
2215 duplication: 0,
2216 },
2217 });
2218 }
2219 let report = build_report(&store);
2220 let trend = report.trend.unwrap();
2221 assert_eq!(trend.direction, ImpactTrendDirection::Improving);
2222 assert_eq!(trend.total_delta, -5);
2223 }
2224
2225 #[test]
2226 fn containment_blocked_then_cleared_records_one_event() {
2227 let (_config, dir) = test_env();
2228 let root = dir.path();
2229 enable(root);
2230 record_v1(
2231 root,
2232 &summary(2, 0, 0),
2233 AuditVerdict::Fail,
2234 true,
2235 Some("sha1"),
2236 "2.0.0",
2237 "t0",
2238 );
2239 let store = load(root);
2240 assert!(store.pending_containment.is_some());
2241 assert!(store.containment.is_empty());
2242
2243 record_v1(
2244 root,
2245 &summary(0, 0, 0),
2246 AuditVerdict::Pass,
2247 true,
2248 Some("sha2"),
2249 "2.0.0",
2250 "t1",
2251 );
2252 let store = load(root);
2253 assert!(store.pending_containment.is_none());
2254 assert_eq!(store.containment.len(), 1);
2255 assert_eq!(store.containment[0].blocked_at, "t0");
2256 assert_eq!(store.containment[0].cleared_at, "t1");
2257 }
2258
2259 #[test]
2260 fn non_gate_run_never_creates_containment() {
2261 let (_config, dir) = test_env();
2262 let root = dir.path();
2263 enable(root);
2264 record_v1(
2265 root,
2266 &summary(2, 0, 0),
2267 AuditVerdict::Fail,
2268 false,
2269 None,
2270 "2.0.0",
2271 "t0",
2272 );
2273 let store = load(root);
2274 assert!(store.pending_containment.is_none());
2275 assert!(store.containment.is_empty());
2276 }
2277
2278 #[test]
2279 fn corrupt_store_loads_as_default_no_panic() {
2280 let (_config, dir) = test_env();
2281 let root = dir.path();
2282 seed_store_raw(root, b"{ not valid json ][");
2283 let store = load(root);
2284 assert!(!store.enabled);
2285 assert!(store.records.is_empty());
2286 record_v1(
2287 root,
2288 &summary(1, 0, 0),
2289 AuditVerdict::Fail,
2290 true,
2291 None,
2292 "2.0.0",
2293 "t0",
2294 );
2295 }
2296
2297 #[test]
2298 fn records_are_bounded() {
2299 let mut store = ImpactStore {
2300 enabled: true,
2301 ..Default::default()
2302 };
2303 for i in 0..(MAX_RECORDS + 50) {
2304 store.records.push(ImpactRecord {
2305 timestamp: format!("t{i}"),
2306 version: "2.0.0".into(),
2307 git_sha: None,
2308 verdict: "pass".into(),
2309 gate: false,
2310 counts: ImpactCounts::default(),
2311 });
2312 }
2313 compact(&mut store);
2314 assert_eq!(store.records.len(), MAX_RECORDS);
2315 assert_eq!(store.records[0].timestamp, "t50");
2316 }
2317
2318 #[test]
2319 fn report_always_carries_schema_version() {
2320 let empty = build_report(&ImpactStore::default());
2321 assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
2322 let json = render_json(&empty);
2323 assert!(
2324 json.contains("\"schema_version\": \"1\""),
2325 "schema_version must be present (as the \"1\" const) even when disabled: {json}"
2326 );
2327
2328 let mut store = ImpactStore {
2329 enabled: true,
2330 ..Default::default()
2331 };
2332 store.records.push(ImpactRecord {
2333 timestamp: "2026-05-29T10:00:00Z".into(),
2334 version: "2.0.0".into(),
2335 git_sha: None,
2336 verdict: "pass".into(),
2337 gate: false,
2338 counts: ImpactCounts::default(),
2339 });
2340 assert_eq!(
2341 build_report(&store).schema_version,
2342 ImpactReportSchemaVersion::V1
2343 );
2344 }
2345
2346 #[test]
2347 fn date_only_trims_iso_timestamp() {
2348 assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
2349 assert_eq!(date_only("2026-05-29"), "2026-05-29");
2350 assert_eq!(date_only("the first run"), "the first run");
2351 }
2352
2353 #[test]
2354 fn human_footer_shows_date_only() {
2355 let mut store = ImpactStore {
2356 enabled: true,
2357 ..Default::default()
2358 };
2359 store.first_recorded = Some("2026-05-29T18:15:23Z".into());
2360 store.records.push(ImpactRecord {
2361 timestamp: "2026-05-29T18:15:23Z".into(),
2362 version: "2.0.0".into(),
2363 git_sha: None,
2364 verdict: "pass".into(),
2365 gate: false,
2366 counts: ImpactCounts::default(),
2367 });
2368 let report = build_report(&store);
2369 let human = render_human(&report);
2370 assert!(
2371 human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
2372 "human footer must show date-only: {human}"
2373 );
2374 let md = render_markdown(&report);
2375 assert!(
2376 md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
2377 "markdown footer must show date-only: {md}"
2378 );
2379 }
2380
2381 #[test]
2382 fn future_schema_version_store_loads_without_panic_or_loss() {
2383 let (_config, dir) = test_env();
2384 let root = dir.path();
2385 let future = format!(
2386 "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
2387 STORE_SCHEMA_VERSION + 1
2388 );
2389 seed_store_raw(root, future.as_bytes());
2390 let store = load(root);
2391 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
2392 assert!(
2393 store.enabled,
2394 "future-version store must not degrade to default"
2395 );
2396 }
2397
2398 #[test]
2399 fn removed_finding_is_credited_as_resolved() {
2400 let (_config, dir) = test_env();
2401 let root = dir.path();
2402 enable(root);
2403 let a = touch(root, "src/a.ts");
2404 run(
2405 root,
2406 &[&a],
2407 vec![fi(&a, "unused-export", "foo")],
2408 vec![],
2409 &[],
2410 "t0",
2411 );
2412 assert_eq!(
2413 load(root).resolved_total,
2414 0,
2415 "first run only establishes a baseline"
2416 );
2417 run(root, &[&a], vec![], vec![], &[], "t1");
2418 let store = load(root);
2419 assert_eq!(store.resolved_total, 1);
2420 assert_eq!(store.suppressed_total, 0);
2421 assert_eq!(store.recent_resolved.len(), 1);
2422 assert_eq!(store.recent_resolved[0].kind, "unused-export");
2423 assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
2424 assert_eq!(store.recent_resolved[0].path, "src/a.ts");
2425 }
2426
2427 #[test]
2428 fn suppressed_finding_is_not_a_win() {
2429 let (_config, dir) = test_env();
2430 let root = dir.path();
2431 enable(root);
2432 let a = touch(root, "src/a.ts");
2433 run(
2434 root,
2435 &[&a],
2436 vec![fi(&a, "unused-export", "foo")],
2437 vec![],
2438 &[],
2439 "t0",
2440 );
2441 run(
2442 root,
2443 &[&a],
2444 vec![],
2445 vec![],
2446 &[supp(&a, "unused-export")],
2447 "t1",
2448 );
2449 let store = load(root);
2450 assert_eq!(
2451 store.resolved_total, 0,
2452 "a suppression must never count as a win"
2453 );
2454 assert_eq!(store.suppressed_total, 1);
2455 }
2456
2457 #[test]
2458 fn fix_and_suppress_same_kind_credits_zero_resolved() {
2459 let (_config, dir) = test_env();
2460 let root = dir.path();
2461 enable(root);
2462 let a = touch(root, "src/a.ts");
2463 run(
2464 root,
2465 &[&a],
2466 vec![
2467 fi(&a, "unused-export", "foo"),
2468 fi(&a, "unused-export", "bar"),
2469 ],
2470 vec![],
2471 &[],
2472 "t0",
2473 );
2474 run(
2475 root,
2476 &[&a],
2477 vec![],
2478 vec![],
2479 &[supp(&a, "unused-export")],
2480 "t1",
2481 );
2482 let store = load(root);
2483 assert_eq!(store.resolved_total, 0);
2484 assert_eq!(store.suppressed_total, 2);
2485 }
2486
2487 #[test]
2488 fn within_file_move_is_not_resolved() {
2489 let (_config, dir) = test_env();
2490 let root = dir.path();
2491 enable(root);
2492 let a = touch(root, "src/a.ts");
2493 run(
2494 root,
2495 &[&a],
2496 vec![fi(&a, "unused-export", "foo")],
2497 vec![],
2498 &[],
2499 "t0",
2500 );
2501 run(
2502 root,
2503 &[&a],
2504 vec![fi(&a, "unused-export", "foo")],
2505 vec![],
2506 &[],
2507 "t1",
2508 );
2509 let store = load(root);
2510 assert_eq!(store.resolved_total, 0);
2511 assert_eq!(store.suppressed_total, 0);
2512 }
2513
2514 #[test]
2515 fn cross_file_move_in_same_run_is_not_resolved() {
2516 let (_config, dir) = test_env();
2517 let root = dir.path();
2518 enable(root);
2519 let a = touch(root, "src/a.ts");
2520 let b = touch(root, "src/b.ts");
2521 run(
2522 root,
2523 &[&a],
2524 vec![fi(&a, "unused-export", "foo")],
2525 vec![],
2526 &[],
2527 "t0",
2528 );
2529 run(
2530 root,
2531 &[&a, &b],
2532 vec![fi(&b, "unused-export", "foo")],
2533 vec![],
2534 &[],
2535 "t1",
2536 );
2537 assert_eq!(
2538 load(root).resolved_total,
2539 0,
2540 "a cross-file move is not a resolution"
2541 );
2542 }
2543
2544 #[test]
2545 fn cross_run_move_uncredits_the_prior_resolution() {
2546 let (_config, dir) = test_env();
2547 let root = dir.path();
2548 enable(root);
2549 let a = touch(root, "src/a.ts");
2550 let b = touch(root, "src/b.ts");
2551 run(
2552 root,
2553 &[&a],
2554 vec![fi(&a, "unused-export", "foo")],
2555 vec![],
2556 &[],
2557 "t0",
2558 );
2559 run(root, &[&a], vec![], vec![], &[], "t1");
2560 assert_eq!(
2561 load(root).resolved_total,
2562 1,
2563 "source disappearance credited in run A"
2564 );
2565 run(
2566 root,
2567 &[&b],
2568 vec![fi(&b, "unused-export", "foo")],
2569 vec![],
2570 &[],
2571 "t2",
2572 );
2573 let store = load(root);
2574 assert_eq!(
2575 store.resolved_total, 0,
2576 "cross-run move must un-credit the phantom win"
2577 );
2578 assert!(
2579 store.recent_resolved.is_empty(),
2580 "the stale resolution event is dropped"
2581 );
2582 }
2583
2584 #[test]
2585 fn resolved_complexity_finding_and_suppressed_complexity() {
2586 let (_config, dir) = test_env();
2587 let root = dir.path();
2588 enable(root);
2589 let a = touch(root, "src/a.ts");
2590 run(
2591 root,
2592 &[&a],
2593 vec![fi(&a, "complexity", "bigFn")],
2594 vec![],
2595 &[],
2596 "t0",
2597 );
2598 run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
2599 let store = load(root);
2600 assert_eq!(store.resolved_total, 0);
2601 assert_eq!(store.suppressed_total, 1);
2602
2603 let b = touch(root, "src/b.ts");
2604 run(
2605 root,
2606 &[&b],
2607 vec![fi(&b, "complexity", "huge")],
2608 vec![],
2609 &[],
2610 "t2",
2611 );
2612 run(root, &[&b], vec![], vec![], &[], "t3");
2613 assert_eq!(load(root).resolved_total, 1);
2614 }
2615
2616 #[test]
2617 fn resolved_duplication_clone_group() {
2618 let (_config, dir) = test_env();
2619 let root = dir.path();
2620 enable(root);
2621 let a = touch(root, "src/a.ts");
2622 let b = touch(root, "src/b.ts");
2623 let clone = CloneInput {
2624 fingerprint: "dup:abc12345".to_owned(),
2625 instance_paths: vec![a.clone(), b],
2626 };
2627 run(root, &[&a], vec![], vec![clone], &[], "t0");
2628 run(root, &[&a], vec![], vec![], &[], "t1");
2629 let store = load(root);
2630 assert_eq!(store.resolved_total, 1);
2631 assert_eq!(store.recent_resolved[0].kind, "code-duplication");
2632 }
2633
2634 #[test]
2635 fn blanket_suppression_covers_any_kind() {
2636 let (_config, dir) = test_env();
2637 let root = dir.path();
2638 enable(root);
2639 let a = touch(root, "src/a.ts");
2640 run(
2641 root,
2642 &[&a],
2643 vec![fi(&a, "unused-export", "foo")],
2644 vec![],
2645 &[],
2646 "t0",
2647 );
2648 let blanket = ActiveSuppression {
2649 path: a.clone(),
2650 kind: None,
2651 is_file_level: true,
2652 };
2653 run(root, &[&a], vec![], vec![], &[blanket], "t1");
2654 let store = load(root);
2655 assert_eq!(store.resolved_total, 0);
2656 assert_eq!(store.suppressed_total, 1);
2657 }
2658
2659 #[test]
2660 fn v1_store_loads_and_upgrades_to_v2() {
2661 let (_config, dir) = test_env();
2662 let root = dir.path();
2663 let v1 = r#"{"schema_version":1,"enabled":true,"first_recorded":"t0","records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,"counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],"containment":[]}"#;
2664 seed_store_raw(root, v1.as_bytes());
2665 let store = load(root);
2666 assert_eq!(store.schema_version, 1);
2667 assert!(store.frontier.is_empty());
2668 assert_eq!(store.resolved_total, 0);
2669 let a = touch(root, "src/a.ts");
2670 run(
2671 root,
2672 &[&a],
2673 vec![fi(&a, "unused-export", "foo")],
2674 vec![],
2675 &[],
2676 "t1",
2677 );
2678 let store = load(root);
2679 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2680 assert!(frontier_paths(&store).contains("src/a.ts"));
2681 }
2682
2683 #[test]
2684 fn recent_resolved_is_bounded() {
2685 let mut store = ImpactStore {
2686 enabled: true,
2687 ..Default::default()
2688 };
2689 for i in 0..(MAX_RECENT_RESOLVED + 25) {
2690 store.recent_resolved.push(ResolutionEvent {
2691 kind: "unused-export".into(),
2692 path: format!("src/f{i}.ts"),
2693 symbol: Some(format!("s{i}")),
2694 git_sha: None,
2695 timestamp: format!("t{i}"),
2696 });
2697 }
2698 bound_recent_resolved(&mut store);
2699 assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
2700 assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
2701 }
2702
2703 #[test]
2704 fn frontier_prunes_deleted_files() {
2705 let (_config, dir) = test_env();
2706 let root = dir.path();
2707 enable(root);
2708 let a = touch(root, "src/a.ts");
2709 run(
2710 root,
2711 &[&a],
2712 vec![fi(&a, "unused-export", "foo")],
2713 vec![],
2714 &[],
2715 "t0",
2716 );
2717 assert!(frontier_paths(&load(root)).contains("src/a.ts"));
2718 std::fs::remove_file(&a).unwrap();
2719 let b = touch(root, "src/b.ts");
2720 run(root, &[&b], vec![], vec![], &[], "t1");
2721 assert!(!frontier_paths(&load(root)).contains("src/a.ts"));
2722 }
2723
2724 #[test]
2725 fn honest_empty_state_before_attribution_baseline() {
2726 let store = ImpactStore {
2727 enabled: true,
2728 records: vec![ImpactRecord {
2729 timestamp: "t0".into(),
2730 version: "2.0.0".into(),
2731 git_sha: None,
2732 verdict: "warn".into(),
2733 gate: false,
2734 counts: ImpactCounts::default(),
2735 }],
2736 ..Default::default()
2737 };
2738 let report = build_report(&store);
2739 assert!(!report.attribution_active);
2740 let human = render_human(&report);
2741 assert!(human.contains("resolution tracking starts from your next gate run"));
2742 assert!(!human.contains("0 finding"));
2743 }
2744
2745 #[test]
2746 fn suppression_only_state_renders_under_a_resolved_header() {
2747 let report = ImpactReport {
2748 schema_version: ImpactReportSchemaVersion::V1,
2749 enabled: true,
2750 enabled_source: EnabledSource::Project,
2751 record_count: 2,
2752 meta: None,
2753 first_recorded: Some("2026-05-29T10:00:00Z".into()),
2754 latest_git_sha: None,
2755 surfacing: Some(ImpactCounts::default()),
2756 trend: None,
2757 project_surfacing: None,
2758 project_trend: None,
2759 containment_count: 0,
2760 recent_containment: vec![],
2761 resolved_total: 0,
2762 suppressed_total: 2,
2763 recent_resolved: vec![],
2764 attribution_active: true,
2765 onboarding_declined: false,
2766 explicit_decision: false,
2767 };
2768 let human = render_human(&report);
2769 let resolved_idx = human.find(" RESOLVED").expect("RESOLVED header present");
2770 let supp_idx = human
2771 .find("2 findings you marked intentional")
2772 .expect("suppression line present");
2773 assert!(
2774 resolved_idx < supp_idx,
2775 "suppression must render under RESOLVED"
2776 );
2777 assert!(human.contains("none yet"));
2778
2779 let md = render_markdown(&report);
2780 assert!(
2781 md.contains("- **Resolved:**"),
2782 "markdown always has a Resolved bullet"
2783 );
2784 assert!(md.contains("- **Marked intentional:** 2 finding"));
2785 }
2786
2787 fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
2789 CloneInput {
2790 fingerprint: fingerprint.to_owned(),
2791 instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
2792 }
2793 }
2794
2795 fn run_wp(
2799 root: &Path,
2800 findings: Vec<FindingInput>,
2801 clones: Vec<CloneInput>,
2802 supps: &[ActiveSuppression],
2803 ts: &str,
2804 ) {
2805 let input = AttributionInput {
2806 root,
2807 scope: Scope::WholeProject,
2808 findings,
2809 clones,
2810 suppressions: supps,
2811 };
2812 record_combined_run(
2813 root,
2814 ImpactCounts::default(),
2815 Some("sha"),
2816 "2.0.0",
2817 ts,
2818 Some(&input),
2819 );
2820 }
2821
2822 #[test]
2823 fn whole_project_run_does_not_double_credit_after_audit() {
2824 let (_config, dir) = test_env();
2825 let root = dir.path();
2826 enable(root);
2827 let a = touch(root, "src/a.ts");
2828 let b = touch(root, "src/b.ts");
2829 run(
2830 root,
2831 &[&a, &b],
2832 vec![],
2833 vec![clone_at("dup:abc", &[&a, &b])],
2834 &[],
2835 "t1",
2836 );
2837 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
2838
2839 run(root, &[&a, &b], vec![], vec![], &[], "t2");
2840 assert_eq!(load(root).resolved_total, 1);
2841 assert!(load(root).clone_frontier.is_empty());
2842
2843 run_wp(root, vec![], vec![], &[], "t3");
2844 assert_eq!(
2845 load(root).resolved_total,
2846 1,
2847 "whole-project run re-credited a resolution"
2848 );
2849 }
2850
2851 #[test]
2852 fn whole_project_run_credits_suppressed_not_resolved() {
2853 let (_config, dir) = test_env();
2854 let root = dir.path();
2855 enable(root);
2856 let util = touch(root, "src/util.ts");
2857 run(
2858 root,
2859 &[&util],
2860 vec![fi(&util, "unused-export", "dead")],
2861 vec![],
2862 &[],
2863 "t1",
2864 );
2865 assert_eq!(frontier_paths(&load(root)).len(), 1);
2866
2867 run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
2868 let store = load(root);
2869 assert_eq!(
2870 store.suppressed_total, 1,
2871 "suppressed finding not counted suppressed"
2872 );
2873 assert_eq!(
2874 store.resolved_total, 0,
2875 "suppressed finding wrongly counted resolved"
2876 );
2877 }
2878
2879 #[test]
2880 fn clone_reshape_three_to_two_not_credited_as_resolved() {
2881 let (_config, dir) = test_env();
2882 let root = dir.path();
2883 enable(root);
2884 let a = touch(root, "src/a.ts");
2885 let b = touch(root, "src/b.ts");
2886 let c = touch(root, "src/c.ts");
2887 run(
2888 root,
2889 &[&a, &b, &c],
2890 vec![],
2891 vec![clone_at("dup:aaa", &[&a, &b, &c])],
2892 &[],
2893 "t1",
2894 );
2895 assert_eq!(clone_fingerprints(&load(root)).len(), 1);
2896
2897 run_wp(
2898 root,
2899 vec![],
2900 vec![clone_at("dup:bbb", &[&a, &b])],
2901 &[],
2902 "t2",
2903 );
2904 let store = load(root);
2905 assert_eq!(
2906 store.resolved_total, 0,
2907 "clone reshape miscredited as resolved"
2908 );
2909 assert!(clone_fingerprints(&store).contains("dup:bbb"));
2910 assert!(!clone_fingerprints(&store).contains("dup:aaa"));
2911 }
2912
2913 fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
2914 ImpactCounts {
2915 total_issues: total,
2916 dead_code: dead,
2917 complexity,
2918 duplication: dup,
2919 }
2920 }
2921
2922 fn rtrend(prev: usize, cur: usize) -> TrendSummary {
2923 TrendSummary {
2924 direction: direction_for(cur as i64 - prev as i64),
2925 total_delta: cur as i64 - prev as i64,
2926 previous_total: prev,
2927 current_total: cur,
2928 }
2929 }
2930
2931 fn rreport(
2933 record_count: usize,
2934 first_recorded: Option<&str>,
2935 surfacing: Option<ImpactCounts>,
2936 trend: Option<TrendSummary>,
2937 project_surfacing: Option<ImpactCounts>,
2938 project_trend: Option<TrendSummary>,
2939 attribution_active: bool,
2940 ) -> ImpactReport {
2941 ImpactReport {
2942 schema_version: ImpactReportSchemaVersion::V1,
2943 enabled: true,
2944 enabled_source: EnabledSource::Project,
2945 record_count,
2946 meta: None,
2947 first_recorded: first_recorded.map(ToOwned::to_owned),
2948 latest_git_sha: None,
2949 surfacing,
2950 trend,
2951 project_surfacing,
2952 project_trend,
2953 containment_count: 0,
2954 recent_containment: vec![],
2955 resolved_total: 0,
2956 suppressed_total: 0,
2957 recent_resolved: vec![],
2958 attribution_active,
2959 onboarding_declined: false,
2960 explicit_decision: false,
2961 }
2962 }
2963
2964 #[test]
2965 fn render_human_project_only_store_shows_whole_project_not_empty_state() {
2966 let r = rreport(
2967 0,
2968 Some("2026-05-30T10:00:00Z"),
2969 None,
2970 None,
2971 Some(rcounts(1, 1, 0, 0)),
2972 None,
2973 true,
2974 );
2975 let human = render_human(&r);
2976 assert!(
2977 human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
2978 "project-only must render the labeled section"
2979 );
2980 assert!(human.contains("1 issue across the whole project"));
2981 assert!(
2982 human.contains("project trend starts after your next full `fallow` run"),
2983 "single project record => no trend line, shows the next-run hint"
2984 );
2985 assert!(human.contains("Tracking since 2026-05-30"));
2986 assert!(
2987 !human.contains("No history yet"),
2988 "must not show the empty-state copy"
2989 );
2990 assert!(
2991 !human.contains("LATEST RUN"),
2992 "no changed-file track recorded"
2993 );
2994 assert!(
2995 !human.contains("recorded audit run"),
2996 "no audit runs => no changed-file footer"
2997 );
2998 }
2999
3000 #[test]
3001 fn render_human_both_tracks_label_actionable_vs_context() {
3002 let r = rreport(
3003 3,
3004 Some("2026-05-29T10:00:00Z"),
3005 Some(rcounts(4, 4, 0, 0)),
3006 Some(rtrend(6, 4)),
3007 Some(rcounts(40, 30, 5, 5)),
3008 Some(rtrend(45, 40)),
3009 true,
3010 );
3011 let human = render_human(&r);
3012 let latest = human
3013 .find("LATEST RUN (changed files, act on these now)")
3014 .expect("LATEST RUN labeled actionable");
3015 let whole = human
3016 .find("WHOLE PROJECT (whole-repo context, not a to-do)")
3017 .expect("WHOLE PROJECT labeled context");
3018 assert!(
3019 latest < whole,
3020 "changed-file section renders before whole-project"
3021 );
3022 assert!(human.contains("45 -> 40 (down) across your last two full runs"));
3023 assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
3024 }
3025
3026 #[test]
3027 fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
3028 let r = rreport(
3029 0,
3030 Some("2026-05-30T10:00:00Z"),
3031 None,
3032 None,
3033 Some(rcounts(1, 1, 0, 0)),
3034 None,
3035 true,
3036 );
3037 let md = render_markdown(&r);
3038 assert!(
3039 md.contains(
3040 "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
3041 ),
3042 "project-only md must render the labeled whole-project line"
3043 );
3044 assert!(
3045 !md.contains("No history yet"),
3046 "project-only md must not show empty state"
3047 );
3048 assert!(md.contains("Tracking since 2026-05-30"));
3049 }
3050
3051 #[test]
3052 fn resolve_enabled_precedence_table() {
3053 let (_config, _dir) = test_env();
3054 let on = ImpactStore {
3056 enabled: true,
3057 ..Default::default()
3058 };
3059 assert_eq!(resolve_enabled(&on), (true, EnabledSource::Project));
3060
3061 let off_explicit = ImpactStore {
3063 enabled: false,
3064 explicit_decision: true,
3065 ..Default::default()
3066 };
3067 assert_eq!(
3068 resolve_enabled(&off_explicit),
3069 (false, EnabledSource::Project)
3070 );
3071
3072 let never = ImpactStore::default();
3074 assert_eq!(resolve_enabled(&never), (false, EnabledSource::Default));
3075
3076 assert!(set_global_default(true));
3078 assert_eq!(resolve_enabled(&never), (true, EnabledSource::User));
3079 assert_eq!(
3081 resolve_enabled(&off_explicit),
3082 (false, EnabledSource::Project)
3083 );
3084 }
3085
3086 #[test]
3087 fn human_report_explains_user_global_default() {
3088 let (_config, _dir) = test_env();
3089 set_global_default(true);
3090 let report = build_report(&ImpactStore::default());
3092 assert_eq!(report.enabled_source, EnabledSource::User);
3093 let human = render_human(&report);
3094 assert!(
3095 human.contains("Enabled by your user-global default"),
3096 "human report must explain a global-default enable: {human}"
3097 );
3098 let project = build_report(&ImpactStore {
3100 enabled: true,
3101 explicit_decision: true,
3102 ..Default::default()
3103 });
3104 assert_eq!(project.enabled_source, EnabledSource::Project);
3105 assert!(!render_human(&project).contains("user-global default"));
3106 }
3107
3108 #[test]
3109 fn global_default_round_trips() {
3110 let (_config, _dir) = test_env();
3111 assert!(!load_global_default());
3112 assert!(set_global_default(true));
3113 assert!(load_global_default());
3114 assert!(!set_global_default(true)); assert!(set_global_default(false));
3116 assert!(!load_global_default());
3117 }
3118
3119 #[test]
3120 fn global_default_records_without_per_repo_enable() {
3121 let (_config, dir) = test_env();
3122 let root = dir.path();
3123 set_global_default(true);
3124 record_v1(
3126 root,
3127 &summary(2, 0, 0),
3128 AuditVerdict::Warn,
3129 false,
3130 None,
3131 "2.0.0",
3132 "t0",
3133 );
3134 let report = build_report(&load(root));
3135 assert!(report.enabled);
3136 assert_eq!(report.enabled_source, EnabledSource::User);
3137 assert_eq!(report.record_count, 1);
3138 }
3139
3140 #[test]
3141 fn legacy_in_repo_store_is_migrated_on_first_load() {
3142 let (_config, dir) = test_env();
3143 let root = dir.path();
3144 let legacy = r#"{"schema_version":3,"enabled":true,"explicit_decision":true,
3146 "records":[{"timestamp":"t0","version":"2.0.0","verdict":"warn","gate":false,
3147 "counts":{"total_issues":1,"dead_code":1,"complexity":0,"duplication":0}}],
3148 "resolved_total":2,
3149 "frontier":{"src/a.ts":{"findings":[{"id":"x","kind":"unused-export","symbol":"foo"}],"suppressions":[]}},
3150 "containment":[]}"#;
3151 std::fs::create_dir_all(root.join(".fallow")).unwrap();
3152 std::fs::write(legacy_store_path(root), legacy).unwrap();
3153
3154 let store = load(root);
3155 assert!(store.enabled);
3156 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
3157 assert_eq!(store.records.len(), 1);
3158 assert_eq!(store.resolved_total, 2);
3159 assert!(frontier_paths(&store).contains("src/a.ts"));
3161 assert!(store_path(root).is_some_and(|p| p.exists()));
3164 let again = load(root);
3165 assert_eq!(again.records.len(), 1);
3166 }
3167
3168 #[test]
3169 fn reset_removes_only_this_project() {
3170 let (_config, dir) = test_env();
3171 let root = dir.path();
3172 enable(root);
3173 record_v1(
3174 root,
3175 &summary(1, 0, 0),
3176 AuditVerdict::Warn,
3177 false,
3178 None,
3179 "2.0.0",
3180 "t0",
3181 );
3182 assert_eq!(load(root).records.len(), 1);
3183 assert!(reset(root));
3184 assert!(load(root).records.is_empty());
3185 assert!(!reset(root)); }
3187
3188 #[test]
3189 fn reset_all_clears_dir_but_keeps_global_default() {
3190 let (_config, dir) = test_env();
3191 let root = dir.path();
3192 set_global_default(true);
3193 enable(root);
3194 assert!(load(root).enabled);
3195 assert!(reset_all());
3196 assert!(load_global_default());
3198 }
3199}