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 = 3;
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)]
148 pub frontier: FxHashMap<String, FileFrontier>,
149 #[serde(default)]
150 pub clone_frontier: FxHashMap<String, Vec<String>>,
151 #[serde(default)]
152 pub resolved_total: usize,
153 #[serde(default)]
154 pub suppressed_total: usize,
155 #[serde(default)]
156 pub recent_resolved: Vec<ResolutionEvent>,
157 #[serde(default)]
158 pub onboarding_declined: bool,
159 #[serde(default)]
163 pub explicit_decision: bool,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub last_digest_epoch: Option<u64>,
169}
170
171fn store_path(root: &Path) -> PathBuf {
172 root.join(".fallow").join(STORE_FILE)
173}
174
175pub fn load(root: &Path) -> ImpactStore {
178 let path = store_path(root);
179 let Ok(content) = std::fs::read_to_string(&path) else {
180 return ImpactStore::default();
181 };
182 match serde_json::from_str::<ImpactStore>(&content) {
183 Ok(store) => {
184 if store.schema_version > STORE_SCHEMA_VERSION {
185 tracing::warn!(
186 "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.",
187 path.display(),
188 store.schema_version,
189 STORE_SCHEMA_VERSION,
190 );
191 }
192 store
193 }
194 Err(err) => {
195 tracing::warn!(
196 "fallow impact: ignoring unreadable store at {} ({err}); run `fallow impact enable` to reset it",
197 path.display()
198 );
199 ImpactStore::default()
200 }
201 }
202}
203
204fn save(store: &ImpactStore, root: &Path) {
206 let path = store_path(root);
207 if let Some(parent) = path.parent()
208 && std::fs::create_dir_all(parent).is_err()
209 {
210 return;
211 }
212 if let Ok(json) = serde_json::to_string_pretty(store) {
213 let _ = fallow_config::atomic_write(&path, json.as_bytes());
214 }
215}
216
217pub fn enable(root: &Path) -> bool {
219 let mut store = load(root);
220 let was_enabled = store.enabled;
221 store.enabled = true;
222 store.explicit_decision = true;
223 if store.schema_version == 0 {
224 store.schema_version = STORE_SCHEMA_VERSION;
225 }
226 save(&store, root);
227 ensure_fallow_gitignored(root);
228 !was_enabled
229}
230
231fn ensure_fallow_gitignored(root: &Path) {
233 let path = root.join(".gitignore");
234 let existing = std::fs::read_to_string(&path).unwrap_or_default();
235 let already = existing
236 .lines()
237 .any(|line| matches!(line.trim(), ".fallow" | ".fallow/"));
238 if already {
239 return;
240 }
241 let mut contents = existing;
242 if !contents.is_empty() && !contents.ends_with('\n') {
243 contents.push('\n');
244 }
245 contents.push_str(".fallow/\n");
246 let _ = fallow_config::atomic_write(&path, contents.as_bytes());
247}
248
249pub fn disable(root: &Path) -> bool {
254 let mut store = load(root);
255 let was_enabled = store.enabled;
256 store.enabled = false;
257 store.explicit_decision = true;
258 if store.schema_version == 0 {
259 store.schema_version = STORE_SCHEMA_VERSION;
260 }
261 save(&store, root);
262 was_enabled
263}
264
265#[derive(Debug, Clone, Copy)]
269pub struct ImpactDigest {
270 pub containment_count: usize,
271 pub resolved_total: usize,
272}
273
274const DIGEST_INTERVAL_SECS: u64 = 7 * 24 * 60 * 60;
276
277pub fn take_due_digest(root: &Path) -> Option<ImpactDigest> {
284 let mut store = load(root);
285 if !store.enabled {
286 return None;
287 }
288 let containment_count = store.containment.len();
289 if containment_count == 0 && store.resolved_total == 0 {
290 return None;
291 }
292 let now = std::time::SystemTime::now()
293 .duration_since(std::time::UNIX_EPOCH)
294 .ok()?
295 .as_secs();
296 if let Some(last) = store.last_digest_epoch
297 && now.saturating_sub(last) < DIGEST_INTERVAL_SECS
298 {
299 return None;
300 }
301 store.last_digest_epoch = Some(now);
302 save(&store, root);
303 Some(ImpactDigest {
304 containment_count,
305 resolved_total: store.resolved_total,
306 })
307}
308
309pub fn decline_onboarding(root: &Path) -> bool {
311 let mut store = load(root);
312 let was_declined = store.onboarding_declined;
313 store.onboarding_declined = true;
314 if store.schema_version == 0 {
315 store.schema_version = STORE_SCHEMA_VERSION;
316 }
317 save(&store, root);
318 ensure_fallow_gitignored(root);
319 !was_declined
320}
321
322pub struct AuditRunRecord<'a> {
324 pub verdict: AuditVerdict,
325 pub gate: bool,
326 pub git_sha: Option<&'a str>,
327 pub version: &'a str,
328 pub timestamp: &'a str,
329 pub attribution: Option<&'a AttributionInput<'a>>,
330}
331
332pub fn record_audit_run(root: &Path, summary: &AuditSummary, record: &AuditRunRecord<'_>) {
333 let AuditRunRecord {
334 verdict,
335 gate,
336 git_sha,
337 version,
338 timestamp,
339 attribution,
340 } = record;
341 let mut store = load(root);
342 if !store.enabled {
343 return;
344 }
345 store.schema_version = STORE_SCHEMA_VERSION;
346
347 let counts = ImpactCounts::from_summary(summary);
348 let verdict_str = verdict_label(*verdict);
349
350 if store.first_recorded.is_none() {
351 store.first_recorded = Some((*timestamp).to_owned());
352 }
353
354 apply_containment(&mut store, *verdict, *gate, *git_sha, timestamp, &counts);
355
356 store.records.push(ImpactRecord {
357 timestamp: (*timestamp).to_owned(),
358 version: (*version).to_owned(),
359 git_sha: git_sha.map(ToOwned::to_owned),
360 verdict: verdict_str.to_owned(),
361 gate: *gate,
362 counts,
363 });
364 compact(&mut store);
365
366 if let Some(attribution) = attribution {
367 apply_attribution(&mut store, attribution, *git_sha, timestamp);
368 }
369
370 save(&store, root);
371}
372
373pub fn record_combined_run(
375 root: &Path,
376 counts: ImpactCounts,
377 git_sha: Option<&str>,
378 version: &str,
379 timestamp: &str,
380 attribution: Option<&AttributionInput<'_>>,
381) {
382 let mut store = load(root);
383 if !store.enabled {
384 return;
385 }
386 store.schema_version = STORE_SCHEMA_VERSION;
387
388 if store.first_recorded.is_none() {
389 store.first_recorded = Some(timestamp.to_owned());
390 }
391
392 let verdict_str = if counts.total_issues == 0 {
393 "pass"
394 } else {
395 "warn"
396 };
397 store.project_records.push(ImpactRecord {
398 timestamp: timestamp.to_owned(),
399 version: version.to_owned(),
400 git_sha: git_sha.map(ToOwned::to_owned),
401 verdict: verdict_str.to_owned(),
402 gate: false,
403 counts,
404 });
405 if store.project_records.len() > MAX_RECORDS {
406 let overflow = store.project_records.len() - MAX_RECORDS;
407 store.project_records.drain(0..overflow);
408 }
409
410 if let Some(attribution) = attribution {
411 apply_attribution(&mut store, attribution, git_sha, timestamp);
412 }
413
414 save(&store, root);
415}
416
417fn apply_containment(
419 store: &mut ImpactStore,
420 verdict: AuditVerdict,
421 gate: bool,
422 git_sha: Option<&str>,
423 timestamp: &str,
424 counts: &ImpactCounts,
425) {
426 if !gate {
427 return;
428 }
429 if verdict == AuditVerdict::Fail {
430 if store.pending_containment.is_none() {
431 store.pending_containment = Some(PendingContainment {
432 blocked_at: timestamp.to_owned(),
433 git_sha: git_sha.map(ToOwned::to_owned),
434 blocked_counts: counts.clone(),
435 });
436 }
437 } else if let Some(pending) = store.pending_containment.take() {
438 store.containment.push(ContainmentEvent {
439 blocked_at: pending.blocked_at,
440 cleared_at: timestamp.to_owned(),
441 git_sha: pending.git_sha,
442 blocked_counts: pending.blocked_counts,
443 });
444 if store.containment.len() > MAX_CONTAINMENT {
445 let overflow = store.containment.len() - MAX_CONTAINMENT;
446 store.containment.drain(0..overflow);
447 }
448 }
449}
450
451fn compact(store: &mut ImpactStore) {
452 if store.records.len() > MAX_RECORDS {
453 let overflow = store.records.len() - MAX_RECORDS;
454 store.records.drain(0..overflow);
455 }
456}
457
458#[derive(Debug, Clone)]
459pub struct FindingInput {
460 pub path: PathBuf,
461 pub kind: &'static str,
462 pub symbol: Option<String>,
463}
464
465#[derive(Debug, Clone)]
466pub struct CloneInput {
467 pub fingerprint: String,
468 pub instance_paths: Vec<PathBuf>,
469}
470
471pub enum Scope<'a> {
472 ChangedFiles(&'a [PathBuf]),
473 WholeProject,
474}
475
476pub struct AttributionInput<'a> {
477 pub root: &'a Path,
478 pub scope: Scope<'a>,
479 pub findings: Vec<FindingInput>,
480 pub clones: Vec<CloneInput>,
481 pub suppressions: &'a [ActiveSuppression],
482}
483
484fn finding_id(kind: &str, rel_path: &str, symbol: Option<&str>) -> String {
485 fingerprint_hash(&[kind, rel_path, symbol.unwrap_or("")])
486}
487
488fn covered_by(present: &FxHashSet<String>, kind: &str) -> bool {
489 present.contains(BLANKET_SUPPRESSION) || present.contains(kind)
490}
491
492fn apply_attribution(
493 store: &mut ImpactStore,
494 input: &AttributionInput<'_>,
495 git_sha: Option<&str>,
496 timestamp: &str,
497) {
498 let root = input.root;
499 let changed: FxHashSet<String> = match input.scope {
500 Scope::ChangedFiles(files) => files.iter().map(|p| format_display_path(p, root)).collect(),
501 Scope::WholeProject => whole_project_scope(store, input, root),
502 };
503
504 let mut current_findings: FxHashMap<String, Vec<FrontierFinding>> = FxHashMap::default();
505 for f in &input.findings {
506 let rel = format_display_path(&f.path, root);
507 if !changed.contains(&rel) {
508 continue;
509 }
510 let id = finding_id(f.kind, &rel, f.symbol.as_deref());
511 current_findings
512 .entry(rel)
513 .or_default()
514 .push(FrontierFinding {
515 id,
516 kind: f.kind.to_owned(),
517 symbol: f.symbol.clone(),
518 });
519 }
520 let mut current_supps: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
521 for s in input.suppressions {
522 let rel = format_display_path(&s.path, root);
523 if !changed.contains(&rel) {
524 continue;
525 }
526 let key = s
527 .kind
528 .clone()
529 .unwrap_or_else(|| BLANKET_SUPPRESSION.to_owned());
530 current_supps.entry(rel).or_default().insert(key);
531 }
532
533 let mut appeared_move_keys: FxHashSet<String> = FxHashSet::default();
534 for (rel, findings) in ¤t_findings {
535 let prior_ids: FxHashSet<&str> = store
536 .frontier
537 .get(rel)
538 .map(|f| f.findings.iter().map(|x| x.id.as_str()).collect())
539 .unwrap_or_default();
540 for ff in findings {
541 if !prior_ids.contains(ff.id.as_str()) {
542 appeared_move_keys.insert(ff.move_key());
543 }
544 }
545 }
546
547 uncredit_cross_run_moves(store, &appeared_move_keys);
548
549 let mut disappearance_input = FileDisappearancesInput {
550 store,
551 changed: &changed,
552 current_findings: ¤t_findings,
553 current_supps: ¤t_supps,
554 appeared_move_keys: &appeared_move_keys,
555 git_sha,
556 timestamp,
557 };
558 classify_file_disappearances(&mut disappearance_input);
559 update_file_frontier(store, &changed, current_findings, current_supps);
560 classify_clone_disappearances(store, input, &changed, git_sha, timestamp);
561 prune_frontier(store, root);
562 bound_recent_resolved(store);
563}
564
565fn whole_project_scope(
566 store: &ImpactStore,
567 input: &AttributionInput<'_>,
568 root: &Path,
569) -> FxHashSet<String> {
570 let mut set: FxHashSet<String> = store.frontier.keys().cloned().collect();
571 for paths in store.clone_frontier.values() {
572 for p in paths {
573 set.insert(p.clone());
574 }
575 }
576 for f in &input.findings {
577 set.insert(format_display_path(&f.path, root));
578 }
579 for c in &input.clones {
580 for p in &c.instance_paths {
581 set.insert(format_display_path(p, root));
582 }
583 }
584 set
585}
586
587struct FileDisappearancesInput<'a> {
588 store: &'a mut ImpactStore,
589 changed: &'a FxHashSet<String>,
590 current_findings: &'a FxHashMap<String, Vec<FrontierFinding>>,
591 current_supps: &'a FxHashMap<String, FxHashSet<String>>,
592 appeared_move_keys: &'a FxHashSet<String>,
593 git_sha: Option<&'a str>,
594 timestamp: &'a str,
595}
596
597fn classify_file_disappearances(input: &mut FileDisappearancesInput<'_>) {
598 let store = &mut *input.store;
599 let changed = input.changed;
600 let current_findings = input.current_findings;
601 let current_supps = input.current_supps;
602 let appeared_move_keys = input.appeared_move_keys;
603 let git_sha = input.git_sha;
604 let timestamp = input.timestamp;
605 let empty_supps = FxHashSet::default();
606 for rel in changed {
607 let Some(prior) = store.frontier.get(rel) else {
608 continue;
609 };
610 let now_ids: FxHashSet<&str> = current_findings
611 .get(rel)
612 .map(|fs| fs.iter().map(|f| f.id.as_str()).collect())
613 .unwrap_or_default();
614 let now_supps = current_supps.get(rel).unwrap_or(&empty_supps);
615 let prior_supps: FxHashSet<&str> = prior.suppressions.iter().map(String::as_str).collect();
616 let new_supp_kinds: FxHashSet<String> = now_supps
617 .iter()
618 .filter(|k| !prior_supps.contains(k.as_str()))
619 .cloned()
620 .collect();
621
622 let mut resolved = Vec::new();
623 let mut suppressed = 0usize;
624 for pf in &prior.findings {
625 if now_ids.contains(pf.id.as_str()) {
626 continue; }
628 if appeared_move_keys.contains(&pf.move_key()) {
629 continue; }
631 if covered_by(&new_supp_kinds, &pf.kind) {
632 suppressed += 1; } else {
634 resolved.push(pf.clone());
635 }
636 }
637 store.suppressed_total += suppressed;
638 for pf in resolved {
639 store.resolved_total += 1;
640 store.recent_resolved.push(ResolutionEvent {
641 kind: pf.kind,
642 path: rel.clone(),
643 symbol: pf.symbol,
644 git_sha: git_sha.map(ToOwned::to_owned),
645 timestamp: timestamp.to_owned(),
646 });
647 }
648 }
649}
650
651fn update_file_frontier(
652 store: &mut ImpactStore,
653 changed: &FxHashSet<String>,
654 mut current_findings: FxHashMap<String, Vec<FrontierFinding>>,
655 mut current_supps: FxHashMap<String, FxHashSet<String>>,
656) {
657 for rel in changed {
658 let findings = current_findings.remove(rel).unwrap_or_default();
659 let mut suppressions: Vec<String> = current_supps
660 .remove(rel)
661 .unwrap_or_default()
662 .into_iter()
663 .collect();
664 suppressions.sort_unstable();
665 if findings.is_empty() && suppressions.is_empty() {
666 store.frontier.remove(rel);
667 } else {
668 store.frontier.insert(
669 rel.clone(),
670 FileFrontier {
671 findings,
672 suppressions,
673 },
674 );
675 }
676 }
677}
678
679fn classify_clone_disappearances(
680 store: &mut ImpactStore,
681 input: &AttributionInput<'_>,
682 changed: &FxHashSet<String>,
683 git_sha: Option<&str>,
684 timestamp: &str,
685) {
686 let root = input.root;
687 let mut current: FxHashMap<String, Vec<String>> = FxHashMap::default();
688 for c in &input.clones {
689 let mut paths: Vec<String> = c
690 .instance_paths
691 .iter()
692 .map(|p| format_display_path(p, root))
693 .collect();
694 paths.sort_unstable();
695 paths.dedup();
696 if paths.iter().any(|p| changed.contains(p)) {
697 current.insert(c.fingerprint.clone(), paths);
698 }
699 }
700
701 let dup_suppressed = |paths: &[String]| -> bool {
702 paths.iter().any(|p| {
703 changed.contains(p)
704 && store.frontier.get(p).is_some_and(|f| {
705 f.suppressions
706 .iter()
707 .any(|k| k == CODE_DUPLICATION_KIND || k == BLANKET_SUPPRESSION)
708 })
709 })
710 };
711
712 let still_duplicated: FxHashSet<&String> = current.values().flatten().collect();
713
714 let disappeared: Vec<(String, Vec<String>)> = store
715 .clone_frontier
716 .iter()
717 .filter(|(fp, paths)| {
718 paths.iter().any(|p| changed.contains(p)) && !current.contains_key(*fp)
719 })
720 .map(|(fp, paths)| (fp.clone(), paths.clone()))
721 .collect();
722
723 for (fp, paths) in disappeared {
724 store.clone_frontier.remove(&fp);
725 if paths.iter().any(|p| still_duplicated.contains(p)) {
726 continue;
727 }
728 if dup_suppressed(&paths) {
729 store.suppressed_total += 1;
730 } else {
731 store.resolved_total += 1;
732 let path = paths.first().cloned().unwrap_or_default();
733 store.recent_resolved.push(ResolutionEvent {
734 kind: CODE_DUPLICATION_KIND.to_owned(),
735 path,
736 symbol: None,
737 git_sha: git_sha.map(ToOwned::to_owned),
738 timestamp: timestamp.to_owned(),
739 });
740 }
741 }
742
743 for (fp, paths) in current {
744 store.clone_frontier.insert(fp, paths);
745 }
746}
747
748fn prune_frontier(store: &mut ImpactStore, root: &Path) {
749 store.frontier.retain(|rel, _| root.join(rel).exists());
750 store
751 .clone_frontier
752 .retain(|_, paths| paths.iter().any(|p| root.join(p).exists()));
753}
754
755fn bound_recent_resolved(store: &mut ImpactStore) {
756 if store.recent_resolved.len() > MAX_RECENT_RESOLVED {
757 let overflow = store.recent_resolved.len() - MAX_RECENT_RESOLVED;
758 store.recent_resolved.drain(0..overflow);
759 }
760}
761
762fn event_move_key(ev: &ResolutionEvent) -> Option<String> {
763 ev.symbol
764 .as_ref()
765 .map(|symbol| format!("{}{ID_SEP}{symbol}", ev.kind))
766}
767
768fn uncredit_cross_run_moves(store: &mut ImpactStore, appeared_move_keys: &FxHashSet<String>) {
769 if appeared_move_keys.is_empty() {
770 return;
771 }
772 let mut uncredited = 0usize;
773 store.recent_resolved.retain(|ev| match event_move_key(ev) {
774 Some(mk) if appeared_move_keys.contains(&mk) => {
775 uncredited += 1;
776 false
777 }
778 _ => true,
779 });
780 store.resolved_total = store.resolved_total.saturating_sub(uncredited);
781}
782
783#[must_use]
784pub fn collect_dead_code_findings(results: &AnalysisResults) -> Vec<FindingInput> {
785 let mut out = Vec::new();
786 let mut push = |path: &Path, kind: &'static str, symbol: Option<String>| {
787 out.push(FindingInput {
788 path: path.to_path_buf(),
789 kind,
790 symbol,
791 });
792 };
793 collect_unused_symbol_findings(results, &mut push);
794 collect_dependency_findings(results, &mut push);
795 collect_catalog_findings(results, &mut push);
796 out
797}
798
799fn collect_unused_symbol_findings(
800 results: &AnalysisResults,
801 push: &mut impl FnMut(&Path, &'static str, Option<String>),
802) {
803 for f in &results.unused_files {
804 push(&f.file.path, "unused-file", None);
805 }
806 for f in &results.unused_exports {
807 push(
808 &f.export.path,
809 "unused-export",
810 Some(f.export.export_name.clone()),
811 );
812 }
813 for f in &results.unused_types {
814 push(
815 &f.export.path,
816 "unused-type",
817 Some(f.export.export_name.clone()),
818 );
819 }
820 for f in &results.private_type_leaks {
821 push(
822 &f.leak.path,
823 "private-type-leak",
824 Some(format!(
825 "{}{ID_SEP}{}",
826 f.leak.export_name, f.leak.type_name
827 )),
828 );
829 }
830 for f in &results.unused_enum_members {
831 push(
832 &f.member.path,
833 "unused-enum-member",
834 Some(format!(
835 "{}{ID_SEP}{}",
836 f.member.parent_name, f.member.member_name
837 )),
838 );
839 }
840 for f in &results.unused_class_members {
841 push(
842 &f.member.path,
843 "unused-class-member",
844 Some(format!(
845 "{}{ID_SEP}{}",
846 f.member.parent_name, f.member.member_name
847 )),
848 );
849 }
850 for f in &results.unresolved_imports {
851 push(
852 &f.import.path,
853 "unresolved-import",
854 Some(f.import.specifier.clone()),
855 );
856 }
857 for f in &results.boundary_violations {
858 let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
859 push(
860 &f.violation.from_path,
861 "boundary-violation",
862 Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
863 );
864 }
865}
866
867fn collect_dependency_findings(
868 results: &AnalysisResults,
869 push: &mut impl FnMut(&Path, &'static str, Option<String>),
870) {
871 for f in &results.unused_dependencies {
872 push(
873 &f.dep.path,
874 "unused-dependency",
875 Some(f.dep.package_name.clone()),
876 );
877 }
878 for f in &results.unused_dev_dependencies {
879 push(
880 &f.dep.path,
881 "unused-dev-dependency",
882 Some(f.dep.package_name.clone()),
883 );
884 }
885 for f in &results.unused_optional_dependencies {
886 push(
887 &f.dep.path,
888 "unused-optional-dependency",
889 Some(f.dep.package_name.clone()),
890 );
891 }
892 for f in &results.type_only_dependencies {
893 push(
894 &f.dep.path,
895 "type-only-dependency",
896 Some(f.dep.package_name.clone()),
897 );
898 }
899 for f in &results.test_only_dependencies {
900 push(
901 &f.dep.path,
902 "test-only-dependency",
903 Some(f.dep.package_name.clone()),
904 );
905 }
906}
907
908fn collect_catalog_findings(
909 results: &AnalysisResults,
910 push: &mut impl FnMut(&Path, &'static str, Option<String>),
911) {
912 for f in &results.unused_catalog_entries {
913 push(
914 &f.entry.path,
915 "unused-catalog-entry",
916 Some(format!(
917 "{}{ID_SEP}{}",
918 f.entry.catalog_name, f.entry.entry_name
919 )),
920 );
921 }
922 for f in &results.empty_catalog_groups {
923 push(
924 &f.group.path,
925 "empty-catalog-group",
926 Some(f.group.catalog_name.clone()),
927 );
928 }
929 for f in &results.unresolved_catalog_references {
930 push(
931 &f.reference.path,
932 "unresolved-catalog-reference",
933 Some(format!(
934 "{}{ID_SEP}{}",
935 f.reference.catalog_name, f.reference.entry_name
936 )),
937 );
938 }
939 for f in &results.unused_dependency_overrides {
940 push(
941 &f.entry.path,
942 "unused-dependency-override",
943 Some(f.entry.raw_key.clone()),
944 );
945 }
946 for f in &results.misconfigured_dependency_overrides {
947 push(
948 &f.entry.path,
949 "misconfigured-dependency-override",
950 Some(f.entry.raw_key.clone()),
951 );
952 }
953}
954
955#[must_use]
959pub fn collect_complexity_findings(
960 report: &crate::health_types::HealthReport,
961) -> Vec<FindingInput> {
962 report
963 .findings
964 .iter()
965 .map(|f| FindingInput {
966 path: f.path.clone(),
967 kind: "complexity",
968 symbol: Some(f.name.clone()),
969 })
970 .collect()
971}
972
973#[must_use]
977pub fn collect_clone_findings(
978 report: &fallow_core::duplicates::DuplicationReport,
979) -> Vec<CloneInput> {
980 report
981 .clone_groups
982 .iter()
983 .map(|g| CloneInput {
984 fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
985 instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
986 })
987 .collect()
988}
989
990const fn verdict_label(verdict: AuditVerdict) -> &'static str {
991 match verdict {
992 AuditVerdict::Pass => "pass",
993 AuditVerdict::Warn => "warn",
994 AuditVerdict::Fail => "fail",
995 }
996}
997
998#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1000#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1001#[serde(rename_all = "snake_case")]
1002pub enum ImpactTrendDirection {
1003 Improving,
1005 Declining,
1007 Stable,
1009}
1010
1011#[derive(Debug, Clone, Serialize)]
1013#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1014pub struct TrendSummary {
1015 pub direction: ImpactTrendDirection,
1016 pub total_delta: i64,
1018 pub previous_total: usize,
1019 pub current_total: usize,
1020}
1021
1022fn direction_for(delta: i64) -> ImpactTrendDirection {
1023 if delta < -TREND_TOLERANCE {
1024 ImpactTrendDirection::Improving
1025 } else if delta > TREND_TOLERANCE {
1026 ImpactTrendDirection::Declining
1027 } else {
1028 ImpactTrendDirection::Stable
1029 }
1030}
1031
1032#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1039#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1040pub enum ImpactReportSchemaVersion {
1041 #[serde(rename = "1")]
1043 V1,
1044}
1045
1046#[derive(Debug, Clone, Serialize)]
1048#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1049#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
1050pub struct ImpactReport {
1051 pub schema_version: ImpactReportSchemaVersion,
1055 pub enabled: bool,
1056 pub record_count: usize,
1057 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
1058 pub meta: Option<Meta>,
1059 #[serde(default, skip_serializing_if = "Option::is_none")]
1060 pub first_recorded: Option<String>,
1061 #[serde(default, skip_serializing_if = "Option::is_none")]
1068 pub latest_git_sha: Option<String>,
1069 #[serde(default, skip_serializing_if = "Option::is_none")]
1074 pub surfacing: Option<ImpactCounts>,
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1077 pub trend: Option<TrendSummary>,
1078 #[serde(default, skip_serializing_if = "Option::is_none")]
1083 pub project_surfacing: Option<ImpactCounts>,
1084 #[serde(default, skip_serializing_if = "Option::is_none")]
1088 pub project_trend: Option<TrendSummary>,
1089 pub containment_count: usize,
1090 pub recent_containment: Vec<ContainmentEvent>,
1092 pub resolved_total: usize,
1095 pub suppressed_total: usize,
1098 pub recent_resolved: Vec<ResolutionEvent>,
1100 pub attribution_active: bool,
1104 pub onboarding_declined: bool,
1107 pub explicit_decision: bool,
1112}
1113
1114fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1120 if records.len() < 2 {
1121 return None;
1122 }
1123 let current = &records[records.len() - 1];
1124 let previous = &records[records.len() - 2];
1125 let current_total = current.counts.total_issues;
1126 let previous_total = previous.counts.total_issues;
1127 let total_delta = current_total as i64 - previous_total as i64;
1128 Some(TrendSummary {
1129 direction: direction_for(total_delta),
1130 total_delta,
1131 previous_total,
1132 current_total,
1133 })
1134}
1135
1136pub fn build_report(store: &ImpactStore) -> ImpactReport {
1137 let surfacing = store.records.last().map(|r| r.counts.clone());
1138 let trend = trend_for(&store.records);
1139 let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1140 let project_trend = trend_for(&store.project_records);
1141
1142 let recent_containment = store
1143 .containment
1144 .iter()
1145 .rev()
1146 .take(5)
1147 .rev()
1148 .cloned()
1149 .collect();
1150
1151 let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1152
1153 let recent_resolved = store
1154 .recent_resolved
1155 .iter()
1156 .rev()
1157 .take(5)
1158 .rev()
1159 .cloned()
1160 .collect();
1161 let attribution_active = !store.frontier.is_empty()
1162 || !store.clone_frontier.is_empty()
1163 || store.resolved_total > 0
1164 || store.suppressed_total > 0;
1165
1166 ImpactReport {
1167 schema_version: ImpactReportSchemaVersion::V1,
1168 enabled: store.enabled,
1169 record_count: store.records.len(),
1170 meta: None,
1171 first_recorded: store.first_recorded.clone(),
1172 latest_git_sha,
1173 surfacing,
1174 trend,
1175 project_surfacing,
1176 project_trend,
1177 containment_count: store.containment.len(),
1178 recent_containment,
1179 resolved_total: store.resolved_total,
1180 suppressed_total: store.suppressed_total,
1181 recent_resolved,
1182 attribution_active,
1183 onboarding_declined: store.onboarding_declined,
1184 explicit_decision: store.explicit_decision,
1185 }
1186}
1187
1188#[expect(
1194 clippy::format_push_string,
1195 reason = "small report renderer; readability over avoiding the extra allocation"
1196)]
1197fn render_project_section(out: &mut String, report: &ImpactReport) {
1198 let Some(s) = &report.project_surfacing else {
1199 return;
1200 };
1201 out.push_str(&format!(
1202 " WHOLE PROJECT (whole-repo context, not a to-do)\n {} issue{} across the whole project at your last full `fallow` run\n",
1203 s.total_issues,
1204 plural(s.total_issues),
1205 ));
1206 if let Some(t) = &report.project_trend {
1207 let arrow = trend_arrow(t.direction);
1208 out.push_str(&format!(
1209 " {} -> {} ({}) across your last two full runs (comparable over time)\n",
1210 t.previous_total, t.current_total, arrow,
1211 ));
1212 } else {
1213 out.push_str(" project trend starts after your next full `fallow` run\n");
1214 }
1215 out.push_str(" advances only on your local full `fallow` runs, not CI\n\n");
1216}
1217
1218#[expect(
1220 clippy::format_push_string,
1221 reason = "small report renderer; readability over avoiding the extra allocation"
1222)]
1223pub fn render_human(report: &ImpactReport) -> String {
1224 let mut out = String::new();
1225 out.push_str("FALLOW IMPACT\n\n");
1226
1227 if !report.enabled {
1228 out.push_str(
1229 "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
1230 let your pre-commit gate run a few times to build history.\n",
1231 );
1232 return out;
1233 }
1234
1235 if report.record_count == 0 && report.project_surfacing.is_none() {
1236 out.push_str(
1237 "Tracking enabled. No history yet: check back after your next few\n\
1238 commits (Impact records each `fallow audit` / pre-commit gate run,\n\
1239 and each full `fallow` run for the whole-project view).\n",
1240 );
1241 return out;
1242 }
1243
1244 if let Some(s) = &report.surfacing {
1245 out.push_str(&format!(
1246 " LATEST RUN (changed files, act on these now)\n {} issue{} flagged in your last `fallow audit` run\n",
1247 s.total_issues,
1248 plural(s.total_issues),
1249 ));
1250 out.push_str(&format!(
1251 " dead code {} · complexity {} · duplication {}\n\n",
1252 s.dead_code, s.complexity, s.duplication,
1253 ));
1254 }
1255
1256 if let Some(t) = &report.trend {
1257 let arrow = trend_arrow(t.direction);
1258 out.push_str(&format!(
1259 " 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",
1260 t.previous_total, t.current_total, arrow,
1261 ));
1262 }
1263
1264 render_project_section(&mut out, report);
1265
1266 out.push_str(&format!(
1267 " CONTAINED AT COMMIT\n {} time{} fallow blocked a commit until it was fixed\n",
1268 report.containment_count,
1269 plural(report.containment_count),
1270 ));
1271
1272 if report.resolved_total > 0 {
1273 out.push_str(&format!(
1274 "\n RESOLVED\n {} finding{} you cleared since fallow started tracking\n",
1275 report.resolved_total,
1276 plural(report.resolved_total),
1277 ));
1278 for ev in &report.recent_resolved {
1279 match &ev.symbol {
1280 Some(symbol) => {
1281 out.push_str(&format!(" {} {} in {}\n", ev.kind, symbol, ev.path));
1282 }
1283 None => out.push_str(&format!(" {} in {}\n", ev.kind, ev.path)),
1284 }
1285 }
1286 } else if report.attribution_active {
1287 out.push_str(
1288 "\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",
1289 );
1290 } else {
1291 out.push_str("\n RESOLVED\n resolution tracking starts from your next gate run\n");
1292 }
1293
1294 if report.suppressed_total > 0 {
1295 out.push_str(&format!(
1296 " {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
1297 report.suppressed_total,
1298 plural(report.suppressed_total),
1299 ));
1300 }
1301
1302 out.push('\n');
1303 let since = report
1304 .first_recorded
1305 .as_deref()
1306 .map_or("the first run", date_only);
1307 if report.record_count > 0 {
1308 out.push_str(&format!(
1309 "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
1310 Changed-file scope: each audit run only sees files differing from your base.\n",
1311 report.record_count,
1312 plural(report.record_count),
1313 since,
1314 ));
1315 } else {
1316 out.push_str(&format!(
1317 "Tracking since {since}. Local-only; never uploaded.\n",
1318 ));
1319 }
1320 out.push_str(
1321 "Resolution tracking is a local-developer signal: it accrues where\n\
1322 .fallow/impact.json persists across runs, not in ephemeral CI runners.\n",
1323 );
1324 out
1325}
1326
1327pub fn render_json(report: &ImpactReport) -> String {
1329 let value = crate::output_envelope::serialize_root_output(
1330 crate::output_envelope::FallowOutput::Impact(report.clone()),
1331 )
1332 .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
1333 serde_json::to_string_pretty(&value)
1334 .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
1335}
1336
1337#[expect(
1341 clippy::format_push_string,
1342 reason = "small report renderer; readability over avoiding the extra allocation"
1343)]
1344fn render_project_markdown(out: &mut String, report: &ImpactReport) {
1345 let Some(s) = &report.project_surfacing else {
1346 return;
1347 };
1348 out.push_str(&format!(
1349 "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1350 s.total_issues,
1351 plural(s.total_issues),
1352 s.dead_code,
1353 s.complexity,
1354 s.duplication,
1355 ));
1356 if let Some(t) = &report.project_trend {
1357 let arrow = trend_arrow(t.direction);
1358 out.push_str(&format!(
1359 "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
1360 t.previous_total, t.current_total, arrow,
1361 ));
1362 }
1363}
1364
1365#[expect(
1367 clippy::format_push_string,
1368 reason = "small report renderer; readability over avoiding the extra allocation"
1369)]
1370pub fn render_markdown(report: &ImpactReport) -> String {
1371 let mut out = String::new();
1372 out.push_str("## Fallow impact\n\n");
1373
1374 if !report.enabled {
1375 out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
1376 return out;
1377 }
1378 if report.record_count == 0 && report.project_surfacing.is_none() {
1379 out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
1380 return out;
1381 }
1382
1383 if let Some(s) = &report.surfacing {
1384 out.push_str(&format!(
1385 "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1386 s.total_issues,
1387 plural(s.total_issues),
1388 s.dead_code,
1389 s.complexity,
1390 s.duplication,
1391 ));
1392 }
1393 if let Some(t) = &report.trend {
1394 out.push_str(&format!(
1395 "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
1396 t.previous_total,
1397 t.current_total,
1398 trend_arrow(t.direction),
1399 ));
1400 }
1401 render_project_markdown(&mut out, report);
1402 out.push_str(&format!(
1403 "- **Contained at commit:** {} time{}\n",
1404 report.containment_count,
1405 plural(report.containment_count),
1406 ));
1407 if report.resolved_total > 0 {
1408 out.push_str(&format!(
1409 "- **Resolved:** {} finding{} cleared since tracking started\n",
1410 report.resolved_total,
1411 plural(report.resolved_total),
1412 ));
1413 } else if report.attribution_active {
1414 out.push_str("- **Resolved:** none yet; tracking active\n");
1415 } else {
1416 out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
1417 }
1418 if report.suppressed_total > 0 {
1419 out.push_str(&format!(
1420 "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
1421 report.suppressed_total,
1422 plural(report.suppressed_total),
1423 ));
1424 }
1425 let since = report
1426 .first_recorded
1427 .as_deref()
1428 .map_or("the first run", date_only);
1429 if report.record_count > 0 {
1430 out.push_str(&format!(
1431 "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
1432 report.record_count,
1433 plural(report.record_count),
1434 since,
1435 ));
1436 } else {
1437 out.push_str(&format!(
1438 "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
1439 ));
1440 }
1441 out
1442}
1443
1444const fn plural(n: usize) -> &'static str {
1445 if n == 1 { "" } else { "s" }
1446}
1447
1448fn date_only(ts: &str) -> &str {
1454 ts.split_once('T').map_or(ts, |(date, _)| date)
1455}
1456
1457const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
1461 match direction {
1462 ImpactTrendDirection::Improving => "down",
1463 ImpactTrendDirection::Declining => "up",
1464 ImpactTrendDirection::Stable => "flat",
1465 }
1466}
1467
1468#[cfg(test)]
1469mod tests {
1470 use super::*;
1471
1472 fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
1473 AuditSummary {
1474 dead_code_issues: dead,
1475 dead_code_has_errors: dead > 0,
1476 complexity_findings: complexity,
1477 max_cyclomatic: None,
1478 duplication_clone_groups: dupes,
1479 }
1480 }
1481
1482 fn record_v1(
1484 root: &Path,
1485 summary: &AuditSummary,
1486 verdict: AuditVerdict,
1487 gate: bool,
1488 git_sha: Option<&str>,
1489 version: &str,
1490 timestamp: &str,
1491 ) {
1492 record_audit_run(
1493 root,
1494 summary,
1495 &AuditRunRecord {
1496 verdict,
1497 gate,
1498 git_sha,
1499 version,
1500 timestamp,
1501 attribution: None,
1502 },
1503 );
1504 }
1505
1506 fn touch(root: &Path, rel: &str) -> PathBuf {
1509 let p = root.join(rel);
1510 if let Some(parent) = p.parent() {
1511 std::fs::create_dir_all(parent).unwrap();
1512 }
1513 std::fs::write(&p, b"x").unwrap();
1514 p
1515 }
1516
1517 fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
1518 FindingInput {
1519 path: path.to_path_buf(),
1520 kind,
1521 symbol: Some(symbol.to_owned()),
1522 }
1523 }
1524
1525 fn supp(path: &Path, kind: &str) -> ActiveSuppression {
1526 ActiveSuppression {
1527 path: path.to_path_buf(),
1528 kind: Some(kind.to_owned()),
1529 is_file_level: false,
1530 }
1531 }
1532
1533 fn run(
1535 root: &Path,
1536 changed: &[&Path],
1537 findings: Vec<FindingInput>,
1538 clones: Vec<CloneInput>,
1539 supps: &[ActiveSuppression],
1540 ts: &str,
1541 ) {
1542 let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
1543 let input = AttributionInput {
1544 root,
1545 scope: Scope::ChangedFiles(&changed_files),
1546 findings,
1547 clones,
1548 suppressions: supps,
1549 };
1550 record_audit_run(
1551 root,
1552 &summary(0, 0, 0),
1553 &AuditRunRecord {
1554 verdict: AuditVerdict::Pass,
1555 gate: true,
1556 git_sha: Some("sha"),
1557 version: "2.0.0",
1558 timestamp: ts,
1559 attribution: Some(&input),
1560 },
1561 );
1562 }
1563
1564 #[test]
1565 fn disabled_store_does_not_record() {
1566 let dir = tempfile::tempdir().unwrap();
1567 let root = dir.path();
1568 record_v1(
1569 root,
1570 &summary(3, 1, 0),
1571 AuditVerdict::Fail,
1572 true,
1573 Some("abc1234"),
1574 "2.0.0",
1575 "2026-05-29T10:00:00Z",
1576 );
1577 let store = load(root);
1578 assert!(store.records.is_empty());
1579 assert!(!store.enabled);
1580 }
1581
1582 #[test]
1583 fn enable_and_disable_record_the_explicit_decision() {
1584 let dir = tempfile::tempdir().unwrap();
1585 let root = dir.path();
1586 assert!(!load(root).explicit_decision, "fresh store: never asked");
1587
1588 disable(root);
1590 let store = load(root);
1591 assert!(!store.enabled);
1592 assert!(store.explicit_decision);
1593 assert!(build_report(&store).explicit_decision);
1594 }
1595
1596 #[test]
1597 fn due_digest_stamps_and_respects_interval_and_gates() {
1598 let dir = tempfile::tempdir().unwrap();
1599 let root = dir.path();
1600
1601 assert!(take_due_digest(root).is_none());
1603 enable(root);
1604 assert!(take_due_digest(root).is_none(), "zero counters never nag");
1605
1606 let mut store = load(root);
1607 store.resolved_total = 3;
1608 store.containment.push(ContainmentEvent {
1609 blocked_at: "2026-06-11T00:00:00Z".to_string(),
1610 cleared_at: "2026-06-11T00:05:00Z".to_string(),
1611 git_sha: None,
1612 blocked_counts: ImpactCounts::default(),
1613 });
1614 save(&store, root);
1615
1616 let digest = take_due_digest(root).expect("first digest is due");
1617 assert_eq!(digest.containment_count, 1);
1618 assert_eq!(digest.resolved_total, 3);
1619 assert!(
1620 take_due_digest(root).is_none(),
1621 "stamped: not due again within the interval"
1622 );
1623
1624 let mut store = load(root);
1626 store.last_digest_epoch = Some(0);
1627 save(&store, root);
1628 assert!(take_due_digest(root).is_some());
1629 }
1630
1631 #[test]
1632 fn decline_onboarding_persists_in_existing_store() {
1633 let dir = tempfile::tempdir().unwrap();
1634 let root = dir.path();
1635
1636 assert!(decline_onboarding(root));
1637 assert!(!decline_onboarding(root));
1638
1639 let store = load(root);
1640 assert!(store.onboarding_declined);
1641 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
1642 assert!(root.join(".gitignore").exists());
1643 let report = build_report(&store);
1644 assert!(report.onboarding_declined);
1645 }
1646
1647 #[test]
1648 fn enable_then_record_accrues_history() {
1649 let dir = tempfile::tempdir().unwrap();
1650 let root = dir.path();
1651 assert!(enable(root));
1652 assert!(!enable(root)); record_v1(
1654 root,
1655 &summary(2, 1, 0),
1656 AuditVerdict::Warn,
1657 false,
1658 None,
1659 "2.0.0",
1660 "2026-05-29T10:00:00Z",
1661 );
1662 let store = load(root);
1663 assert_eq!(store.records.len(), 1);
1664 assert_eq!(store.records[0].counts.total_issues, 3);
1665 assert_eq!(
1666 store.first_recorded.as_deref(),
1667 Some("2026-05-29T10:00:00Z")
1668 );
1669 }
1670
1671 #[test]
1672 fn enable_gitignores_the_store() {
1673 let dir = tempfile::tempdir().unwrap();
1674 let root = dir.path();
1675 enable(root);
1676 let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1677 assert!(
1678 gitignore.lines().any(|l| l.trim() == ".fallow/"),
1679 "enable must gitignore .fallow/, got: {gitignore:?}"
1680 );
1681 enable(root);
1682 let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1683 assert_eq!(
1684 gitignore.lines().filter(|l| l.trim() == ".fallow/").count(),
1685 1,
1686 "re-enabling must not duplicate the .fallow/ entry"
1687 );
1688 }
1689
1690 #[test]
1691 fn single_record_yields_no_trend_no_spike() {
1692 let mut store = ImpactStore {
1693 enabled: true,
1694 ..Default::default()
1695 };
1696 store.records.push(ImpactRecord {
1697 timestamp: "t0".into(),
1698 version: "2.0.0".into(),
1699 git_sha: None,
1700 verdict: "warn".into(),
1701 gate: false,
1702 counts: ImpactCounts {
1703 total_issues: 5,
1704 dead_code: 5,
1705 complexity: 0,
1706 duplication: 0,
1707 },
1708 });
1709 let report = build_report(&store);
1710 assert!(report.trend.is_none());
1711 assert_eq!(report.surfacing.unwrap().total_issues, 5);
1712 }
1713
1714 #[test]
1715 fn empty_store_report_is_first_run() {
1716 let store = ImpactStore::default();
1717 let report = build_report(&store);
1718 assert_eq!(report.record_count, 0);
1719 assert!(report.trend.is_none());
1720 assert!(report.surfacing.is_none());
1721 let human = render_human(&report);
1722 assert!(human.contains("off")); }
1724
1725 #[test]
1726 fn enabled_empty_store_shows_check_back() {
1727 let store = ImpactStore {
1728 enabled: true,
1729 ..Default::default()
1730 };
1731 let report = build_report(&store);
1732 let human = render_human(&report);
1733 assert!(human.contains("No history yet"));
1734 assert!(!human.contains("0 issues"));
1735 }
1736
1737 #[test]
1738 fn trend_improving_when_issues_drop() {
1739 let mut store = ImpactStore {
1740 enabled: true,
1741 ..Default::default()
1742 };
1743 for total in [8usize, 3usize] {
1744 store.records.push(ImpactRecord {
1745 timestamp: format!("t{total}"),
1746 version: "2.0.0".into(),
1747 git_sha: None,
1748 verdict: "warn".into(),
1749 gate: false,
1750 counts: ImpactCounts {
1751 total_issues: total,
1752 dead_code: total,
1753 complexity: 0,
1754 duplication: 0,
1755 },
1756 });
1757 }
1758 let report = build_report(&store);
1759 let trend = report.trend.unwrap();
1760 assert_eq!(trend.direction, ImpactTrendDirection::Improving);
1761 assert_eq!(trend.total_delta, -5);
1762 }
1763
1764 #[test]
1765 fn containment_blocked_then_cleared_records_one_event() {
1766 let dir = tempfile::tempdir().unwrap();
1767 let root = dir.path();
1768 enable(root);
1769 record_v1(
1770 root,
1771 &summary(2, 0, 0),
1772 AuditVerdict::Fail,
1773 true,
1774 Some("sha1"),
1775 "2.0.0",
1776 "t0",
1777 );
1778 let store = load(root);
1779 assert!(store.pending_containment.is_some());
1780 assert!(store.containment.is_empty());
1781
1782 record_v1(
1783 root,
1784 &summary(0, 0, 0),
1785 AuditVerdict::Pass,
1786 true,
1787 Some("sha2"),
1788 "2.0.0",
1789 "t1",
1790 );
1791 let store = load(root);
1792 assert!(store.pending_containment.is_none());
1793 assert_eq!(store.containment.len(), 1);
1794 assert_eq!(store.containment[0].blocked_at, "t0");
1795 assert_eq!(store.containment[0].cleared_at, "t1");
1796 }
1797
1798 #[test]
1799 fn non_gate_run_never_creates_containment() {
1800 let dir = tempfile::tempdir().unwrap();
1801 let root = dir.path();
1802 enable(root);
1803 record_v1(
1804 root,
1805 &summary(2, 0, 0),
1806 AuditVerdict::Fail,
1807 false,
1808 None,
1809 "2.0.0",
1810 "t0",
1811 );
1812 let store = load(root);
1813 assert!(store.pending_containment.is_none());
1814 assert!(store.containment.is_empty());
1815 }
1816
1817 #[test]
1818 fn corrupt_store_loads_as_default_no_panic() {
1819 let dir = tempfile::tempdir().unwrap();
1820 let root = dir.path();
1821 std::fs::create_dir_all(root.join(".fallow")).unwrap();
1822 std::fs::write(store_path(root), b"{ not valid json ][").unwrap();
1823 let store = load(root);
1824 assert!(!store.enabled);
1825 assert!(store.records.is_empty());
1826 record_v1(
1827 root,
1828 &summary(1, 0, 0),
1829 AuditVerdict::Fail,
1830 true,
1831 None,
1832 "2.0.0",
1833 "t0",
1834 );
1835 }
1836
1837 #[test]
1838 fn records_are_bounded() {
1839 let mut store = ImpactStore {
1840 enabled: true,
1841 ..Default::default()
1842 };
1843 for i in 0..(MAX_RECORDS + 50) {
1844 store.records.push(ImpactRecord {
1845 timestamp: format!("t{i}"),
1846 version: "2.0.0".into(),
1847 git_sha: None,
1848 verdict: "pass".into(),
1849 gate: false,
1850 counts: ImpactCounts::default(),
1851 });
1852 }
1853 compact(&mut store);
1854 assert_eq!(store.records.len(), MAX_RECORDS);
1855 assert_eq!(store.records[0].timestamp, "t50");
1856 }
1857
1858 #[test]
1859 fn report_always_carries_schema_version() {
1860 let empty = build_report(&ImpactStore::default());
1861 assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
1862 let json = render_json(&empty);
1863 assert!(
1864 json.contains("\"schema_version\": \"1\""),
1865 "schema_version must be present (as the \"1\" const) even when disabled: {json}"
1866 );
1867
1868 let mut store = ImpactStore {
1869 enabled: true,
1870 ..Default::default()
1871 };
1872 store.records.push(ImpactRecord {
1873 timestamp: "2026-05-29T10:00:00Z".into(),
1874 version: "2.0.0".into(),
1875 git_sha: None,
1876 verdict: "pass".into(),
1877 gate: false,
1878 counts: ImpactCounts::default(),
1879 });
1880 assert_eq!(
1881 build_report(&store).schema_version,
1882 ImpactReportSchemaVersion::V1
1883 );
1884 }
1885
1886 #[test]
1887 fn date_only_trims_iso_timestamp() {
1888 assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
1889 assert_eq!(date_only("2026-05-29"), "2026-05-29");
1890 assert_eq!(date_only("the first run"), "the first run");
1891 }
1892
1893 #[test]
1894 fn human_footer_shows_date_only() {
1895 let mut store = ImpactStore {
1896 enabled: true,
1897 ..Default::default()
1898 };
1899 store.first_recorded = Some("2026-05-29T18:15:23Z".into());
1900 store.records.push(ImpactRecord {
1901 timestamp: "2026-05-29T18:15:23Z".into(),
1902 version: "2.0.0".into(),
1903 git_sha: None,
1904 verdict: "pass".into(),
1905 gate: false,
1906 counts: ImpactCounts::default(),
1907 });
1908 let report = build_report(&store);
1909 let human = render_human(&report);
1910 assert!(
1911 human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
1912 "human footer must show date-only: {human}"
1913 );
1914 let md = render_markdown(&report);
1915 assert!(
1916 md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
1917 "markdown footer must show date-only: {md}"
1918 );
1919 }
1920
1921 #[test]
1922 fn future_schema_version_store_loads_without_panic_or_loss() {
1923 let dir = tempfile::tempdir().unwrap();
1924 let root = dir.path();
1925 std::fs::create_dir_all(root.join(".fallow")).unwrap();
1926 let future = format!(
1927 "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
1928 STORE_SCHEMA_VERSION + 1
1929 );
1930 std::fs::write(store_path(root), future).unwrap();
1931 let store = load(root);
1932 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
1933 assert!(
1934 store.enabled,
1935 "future-version store must not degrade to default"
1936 );
1937 }
1938
1939 #[test]
1940 fn removed_finding_is_credited_as_resolved() {
1941 let dir = tempfile::tempdir().unwrap();
1942 let root = dir.path();
1943 enable(root);
1944 let a = touch(root, "src/a.ts");
1945 run(
1946 root,
1947 &[&a],
1948 vec![fi(&a, "unused-export", "foo")],
1949 vec![],
1950 &[],
1951 "t0",
1952 );
1953 assert_eq!(
1954 load(root).resolved_total,
1955 0,
1956 "first run only establishes a baseline"
1957 );
1958 run(root, &[&a], vec![], vec![], &[], "t1");
1959 let store = load(root);
1960 assert_eq!(store.resolved_total, 1);
1961 assert_eq!(store.suppressed_total, 0);
1962 assert_eq!(store.recent_resolved.len(), 1);
1963 assert_eq!(store.recent_resolved[0].kind, "unused-export");
1964 assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
1965 assert_eq!(store.recent_resolved[0].path, "src/a.ts");
1966 }
1967
1968 #[test]
1969 fn suppressed_finding_is_not_a_win() {
1970 let dir = tempfile::tempdir().unwrap();
1971 let root = dir.path();
1972 enable(root);
1973 let a = touch(root, "src/a.ts");
1974 run(
1975 root,
1976 &[&a],
1977 vec![fi(&a, "unused-export", "foo")],
1978 vec![],
1979 &[],
1980 "t0",
1981 );
1982 run(
1983 root,
1984 &[&a],
1985 vec![],
1986 vec![],
1987 &[supp(&a, "unused-export")],
1988 "t1",
1989 );
1990 let store = load(root);
1991 assert_eq!(
1992 store.resolved_total, 0,
1993 "a suppression must never count as a win"
1994 );
1995 assert_eq!(store.suppressed_total, 1);
1996 }
1997
1998 #[test]
1999 fn fix_and_suppress_same_kind_credits_zero_resolved() {
2000 let dir = tempfile::tempdir().unwrap();
2001 let root = dir.path();
2002 enable(root);
2003 let a = touch(root, "src/a.ts");
2004 run(
2005 root,
2006 &[&a],
2007 vec![
2008 fi(&a, "unused-export", "foo"),
2009 fi(&a, "unused-export", "bar"),
2010 ],
2011 vec![],
2012 &[],
2013 "t0",
2014 );
2015 run(
2016 root,
2017 &[&a],
2018 vec![],
2019 vec![],
2020 &[supp(&a, "unused-export")],
2021 "t1",
2022 );
2023 let store = load(root);
2024 assert_eq!(store.resolved_total, 0);
2025 assert_eq!(store.suppressed_total, 2);
2026 }
2027
2028 #[test]
2029 fn within_file_move_is_not_resolved() {
2030 let dir = tempfile::tempdir().unwrap();
2031 let root = dir.path();
2032 enable(root);
2033 let a = touch(root, "src/a.ts");
2034 run(
2035 root,
2036 &[&a],
2037 vec![fi(&a, "unused-export", "foo")],
2038 vec![],
2039 &[],
2040 "t0",
2041 );
2042 run(
2043 root,
2044 &[&a],
2045 vec![fi(&a, "unused-export", "foo")],
2046 vec![],
2047 &[],
2048 "t1",
2049 );
2050 let store = load(root);
2051 assert_eq!(store.resolved_total, 0);
2052 assert_eq!(store.suppressed_total, 0);
2053 }
2054
2055 #[test]
2056 fn cross_file_move_in_same_run_is_not_resolved() {
2057 let dir = tempfile::tempdir().unwrap();
2058 let root = dir.path();
2059 enable(root);
2060 let a = touch(root, "src/a.ts");
2061 let b = touch(root, "src/b.ts");
2062 run(
2063 root,
2064 &[&a],
2065 vec![fi(&a, "unused-export", "foo")],
2066 vec![],
2067 &[],
2068 "t0",
2069 );
2070 run(
2071 root,
2072 &[&a, &b],
2073 vec![fi(&b, "unused-export", "foo")],
2074 vec![],
2075 &[],
2076 "t1",
2077 );
2078 assert_eq!(
2079 load(root).resolved_total,
2080 0,
2081 "a cross-file move is not a resolution"
2082 );
2083 }
2084
2085 #[test]
2086 fn cross_run_move_uncredits_the_prior_resolution() {
2087 let dir = tempfile::tempdir().unwrap();
2088 let root = dir.path();
2089 enable(root);
2090 let a = touch(root, "src/a.ts");
2091 let b = touch(root, "src/b.ts");
2092 run(
2093 root,
2094 &[&a],
2095 vec![fi(&a, "unused-export", "foo")],
2096 vec![],
2097 &[],
2098 "t0",
2099 );
2100 run(root, &[&a], vec![], vec![], &[], "t1");
2101 assert_eq!(
2102 load(root).resolved_total,
2103 1,
2104 "source disappearance credited in run A"
2105 );
2106 run(
2107 root,
2108 &[&b],
2109 vec![fi(&b, "unused-export", "foo")],
2110 vec![],
2111 &[],
2112 "t2",
2113 );
2114 let store = load(root);
2115 assert_eq!(
2116 store.resolved_total, 0,
2117 "cross-run move must un-credit the phantom win"
2118 );
2119 assert!(
2120 store.recent_resolved.is_empty(),
2121 "the stale resolution event is dropped"
2122 );
2123 }
2124
2125 #[test]
2126 fn resolved_complexity_finding_and_suppressed_complexity() {
2127 let dir = tempfile::tempdir().unwrap();
2128 let root = dir.path();
2129 enable(root);
2130 let a = touch(root, "src/a.ts");
2131 run(
2132 root,
2133 &[&a],
2134 vec![fi(&a, "complexity", "bigFn")],
2135 vec![],
2136 &[],
2137 "t0",
2138 );
2139 run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
2140 let store = load(root);
2141 assert_eq!(store.resolved_total, 0);
2142 assert_eq!(store.suppressed_total, 1);
2143
2144 let b = touch(root, "src/b.ts");
2145 run(
2146 root,
2147 &[&b],
2148 vec![fi(&b, "complexity", "huge")],
2149 vec![],
2150 &[],
2151 "t2",
2152 );
2153 run(root, &[&b], vec![], vec![], &[], "t3");
2154 assert_eq!(load(root).resolved_total, 1);
2155 }
2156
2157 #[test]
2158 fn resolved_duplication_clone_group() {
2159 let dir = tempfile::tempdir().unwrap();
2160 let root = dir.path();
2161 enable(root);
2162 let a = touch(root, "src/a.ts");
2163 let b = touch(root, "src/b.ts");
2164 let clone = CloneInput {
2165 fingerprint: "dup:abc12345".to_owned(),
2166 instance_paths: vec![a.clone(), b],
2167 };
2168 run(root, &[&a], vec![], vec![clone], &[], "t0");
2169 run(root, &[&a], vec![], vec![], &[], "t1");
2170 let store = load(root);
2171 assert_eq!(store.resolved_total, 1);
2172 assert_eq!(store.recent_resolved[0].kind, "code-duplication");
2173 }
2174
2175 #[test]
2176 fn blanket_suppression_covers_any_kind() {
2177 let dir = tempfile::tempdir().unwrap();
2178 let root = dir.path();
2179 enable(root);
2180 let a = touch(root, "src/a.ts");
2181 run(
2182 root,
2183 &[&a],
2184 vec![fi(&a, "unused-export", "foo")],
2185 vec![],
2186 &[],
2187 "t0",
2188 );
2189 let blanket = ActiveSuppression {
2190 path: a.clone(),
2191 kind: None,
2192 is_file_level: true,
2193 };
2194 run(root, &[&a], vec![], vec![], &[blanket], "t1");
2195 let store = load(root);
2196 assert_eq!(store.resolved_total, 0);
2197 assert_eq!(store.suppressed_total, 1);
2198 }
2199
2200 #[test]
2201 fn v1_store_loads_and_upgrades_to_v2() {
2202 let dir = tempfile::tempdir().unwrap();
2203 let root = dir.path();
2204 std::fs::create_dir_all(root.join(".fallow")).unwrap();
2205 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":[]}"#;
2206 std::fs::write(store_path(root), v1).unwrap();
2207 let store = load(root);
2208 assert_eq!(store.schema_version, 1);
2209 assert!(store.frontier.is_empty());
2210 assert_eq!(store.resolved_total, 0);
2211 let a = touch(root, "src/a.ts");
2212 run(
2213 root,
2214 &[&a],
2215 vec![fi(&a, "unused-export", "foo")],
2216 vec![],
2217 &[],
2218 "t1",
2219 );
2220 let store = load(root);
2221 assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2222 assert!(store.frontier.contains_key("src/a.ts"));
2223 }
2224
2225 #[test]
2226 fn recent_resolved_is_bounded() {
2227 let mut store = ImpactStore {
2228 enabled: true,
2229 ..Default::default()
2230 };
2231 for i in 0..(MAX_RECENT_RESOLVED + 25) {
2232 store.recent_resolved.push(ResolutionEvent {
2233 kind: "unused-export".into(),
2234 path: format!("src/f{i}.ts"),
2235 symbol: Some(format!("s{i}")),
2236 git_sha: None,
2237 timestamp: format!("t{i}"),
2238 });
2239 }
2240 bound_recent_resolved(&mut store);
2241 assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
2242 assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
2243 }
2244
2245 #[test]
2246 fn frontier_prunes_deleted_files() {
2247 let dir = tempfile::tempdir().unwrap();
2248 let root = dir.path();
2249 enable(root);
2250 let a = touch(root, "src/a.ts");
2251 run(
2252 root,
2253 &[&a],
2254 vec![fi(&a, "unused-export", "foo")],
2255 vec![],
2256 &[],
2257 "t0",
2258 );
2259 assert!(load(root).frontier.contains_key("src/a.ts"));
2260 std::fs::remove_file(&a).unwrap();
2261 let b = touch(root, "src/b.ts");
2262 run(root, &[&b], vec![], vec![], &[], "t1");
2263 assert!(!load(root).frontier.contains_key("src/a.ts"));
2264 }
2265
2266 #[test]
2267 fn honest_empty_state_before_attribution_baseline() {
2268 let store = ImpactStore {
2269 enabled: true,
2270 records: vec![ImpactRecord {
2271 timestamp: "t0".into(),
2272 version: "2.0.0".into(),
2273 git_sha: None,
2274 verdict: "warn".into(),
2275 gate: false,
2276 counts: ImpactCounts::default(),
2277 }],
2278 ..Default::default()
2279 };
2280 let report = build_report(&store);
2281 assert!(!report.attribution_active);
2282 let human = render_human(&report);
2283 assert!(human.contains("resolution tracking starts from your next gate run"));
2284 assert!(!human.contains("0 finding"));
2285 }
2286
2287 #[test]
2288 fn suppression_only_state_renders_under_a_resolved_header() {
2289 let report = ImpactReport {
2290 schema_version: ImpactReportSchemaVersion::V1,
2291 enabled: true,
2292 record_count: 2,
2293 meta: None,
2294 first_recorded: Some("2026-05-29T10:00:00Z".into()),
2295 latest_git_sha: None,
2296 surfacing: Some(ImpactCounts::default()),
2297 trend: None,
2298 project_surfacing: None,
2299 project_trend: None,
2300 containment_count: 0,
2301 recent_containment: vec![],
2302 resolved_total: 0,
2303 suppressed_total: 2,
2304 recent_resolved: vec![],
2305 attribution_active: true,
2306 onboarding_declined: false,
2307 explicit_decision: false,
2308 };
2309 let human = render_human(&report);
2310 let resolved_idx = human.find(" RESOLVED").expect("RESOLVED header present");
2311 let supp_idx = human
2312 .find("2 findings you marked intentional")
2313 .expect("suppression line present");
2314 assert!(
2315 resolved_idx < supp_idx,
2316 "suppression must render under RESOLVED"
2317 );
2318 assert!(human.contains("none yet"));
2319
2320 let md = render_markdown(&report);
2321 assert!(
2322 md.contains("- **Resolved:**"),
2323 "markdown always has a Resolved bullet"
2324 );
2325 assert!(md.contains("- **Marked intentional:** 2 finding"));
2326 }
2327
2328 fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
2330 CloneInput {
2331 fingerprint: fingerprint.to_owned(),
2332 instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
2333 }
2334 }
2335
2336 fn run_wp(
2340 root: &Path,
2341 findings: Vec<FindingInput>,
2342 clones: Vec<CloneInput>,
2343 supps: &[ActiveSuppression],
2344 ts: &str,
2345 ) {
2346 let input = AttributionInput {
2347 root,
2348 scope: Scope::WholeProject,
2349 findings,
2350 clones,
2351 suppressions: supps,
2352 };
2353 record_combined_run(
2354 root,
2355 ImpactCounts::default(),
2356 Some("sha"),
2357 "2.0.0",
2358 ts,
2359 Some(&input),
2360 );
2361 }
2362
2363 #[test]
2364 fn whole_project_run_does_not_double_credit_after_audit() {
2365 let dir = tempfile::tempdir().unwrap();
2366 let root = dir.path();
2367 enable(root);
2368 let a = touch(root, "src/a.ts");
2369 let b = touch(root, "src/b.ts");
2370 run(
2371 root,
2372 &[&a, &b],
2373 vec![],
2374 vec![clone_at("dup:abc", &[&a, &b])],
2375 &[],
2376 "t1",
2377 );
2378 assert_eq!(load(root).clone_frontier.len(), 1);
2379
2380 run(root, &[&a, &b], vec![], vec![], &[], "t2");
2381 assert_eq!(load(root).resolved_total, 1);
2382 assert!(load(root).clone_frontier.is_empty());
2383
2384 run_wp(root, vec![], vec![], &[], "t3");
2385 assert_eq!(
2386 load(root).resolved_total,
2387 1,
2388 "whole-project run re-credited a resolution"
2389 );
2390 }
2391
2392 #[test]
2393 fn whole_project_run_credits_suppressed_not_resolved() {
2394 let dir = tempfile::tempdir().unwrap();
2395 let root = dir.path();
2396 enable(root);
2397 let util = touch(root, "src/util.ts");
2398 run(
2399 root,
2400 &[&util],
2401 vec![fi(&util, "unused-export", "dead")],
2402 vec![],
2403 &[],
2404 "t1",
2405 );
2406 assert_eq!(load(root).frontier.len(), 1);
2407
2408 run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
2409 let store = load(root);
2410 assert_eq!(
2411 store.suppressed_total, 1,
2412 "suppressed finding not counted suppressed"
2413 );
2414 assert_eq!(
2415 store.resolved_total, 0,
2416 "suppressed finding wrongly counted resolved"
2417 );
2418 }
2419
2420 #[test]
2421 fn clone_reshape_three_to_two_not_credited_as_resolved() {
2422 let dir = tempfile::tempdir().unwrap();
2423 let root = dir.path();
2424 enable(root);
2425 let a = touch(root, "src/a.ts");
2426 let b = touch(root, "src/b.ts");
2427 let c = touch(root, "src/c.ts");
2428 run(
2429 root,
2430 &[&a, &b, &c],
2431 vec![],
2432 vec![clone_at("dup:aaa", &[&a, &b, &c])],
2433 &[],
2434 "t1",
2435 );
2436 assert_eq!(load(root).clone_frontier.len(), 1);
2437
2438 run_wp(
2439 root,
2440 vec![],
2441 vec![clone_at("dup:bbb", &[&a, &b])],
2442 &[],
2443 "t2",
2444 );
2445 let store = load(root);
2446 assert_eq!(
2447 store.resolved_total, 0,
2448 "clone reshape miscredited as resolved"
2449 );
2450 assert!(store.clone_frontier.contains_key("dup:bbb"));
2451 assert!(!store.clone_frontier.contains_key("dup:aaa"));
2452 }
2453
2454 fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
2455 ImpactCounts {
2456 total_issues: total,
2457 dead_code: dead,
2458 complexity,
2459 duplication: dup,
2460 }
2461 }
2462
2463 fn rtrend(prev: usize, cur: usize) -> TrendSummary {
2464 TrendSummary {
2465 direction: direction_for(cur as i64 - prev as i64),
2466 total_delta: cur as i64 - prev as i64,
2467 previous_total: prev,
2468 current_total: cur,
2469 }
2470 }
2471
2472 fn rreport(
2474 record_count: usize,
2475 first_recorded: Option<&str>,
2476 surfacing: Option<ImpactCounts>,
2477 trend: Option<TrendSummary>,
2478 project_surfacing: Option<ImpactCounts>,
2479 project_trend: Option<TrendSummary>,
2480 attribution_active: bool,
2481 ) -> ImpactReport {
2482 ImpactReport {
2483 schema_version: ImpactReportSchemaVersion::V1,
2484 enabled: true,
2485 record_count,
2486 meta: None,
2487 first_recorded: first_recorded.map(ToOwned::to_owned),
2488 latest_git_sha: None,
2489 surfacing,
2490 trend,
2491 project_surfacing,
2492 project_trend,
2493 containment_count: 0,
2494 recent_containment: vec![],
2495 resolved_total: 0,
2496 suppressed_total: 0,
2497 recent_resolved: vec![],
2498 attribution_active,
2499 onboarding_declined: false,
2500 explicit_decision: false,
2501 }
2502 }
2503
2504 #[test]
2505 fn render_human_project_only_store_shows_whole_project_not_empty_state() {
2506 let r = rreport(
2507 0,
2508 Some("2026-05-30T10:00:00Z"),
2509 None,
2510 None,
2511 Some(rcounts(1, 1, 0, 0)),
2512 None,
2513 true,
2514 );
2515 let human = render_human(&r);
2516 assert!(
2517 human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
2518 "project-only must render the labeled section"
2519 );
2520 assert!(human.contains("1 issue across the whole project"));
2521 assert!(
2522 human.contains("project trend starts after your next full `fallow` run"),
2523 "single project record => no trend line, shows the next-run hint"
2524 );
2525 assert!(human.contains("Tracking since 2026-05-30"));
2526 assert!(
2527 !human.contains("No history yet"),
2528 "must not show the empty-state copy"
2529 );
2530 assert!(
2531 !human.contains("LATEST RUN"),
2532 "no changed-file track recorded"
2533 );
2534 assert!(
2535 !human.contains("recorded audit run"),
2536 "no audit runs => no changed-file footer"
2537 );
2538 }
2539
2540 #[test]
2541 fn render_human_both_tracks_label_actionable_vs_context() {
2542 let r = rreport(
2543 3,
2544 Some("2026-05-29T10:00:00Z"),
2545 Some(rcounts(4, 4, 0, 0)),
2546 Some(rtrend(6, 4)),
2547 Some(rcounts(40, 30, 5, 5)),
2548 Some(rtrend(45, 40)),
2549 true,
2550 );
2551 let human = render_human(&r);
2552 let latest = human
2553 .find("LATEST RUN (changed files, act on these now)")
2554 .expect("LATEST RUN labeled actionable");
2555 let whole = human
2556 .find("WHOLE PROJECT (whole-repo context, not a to-do)")
2557 .expect("WHOLE PROJECT labeled context");
2558 assert!(
2559 latest < whole,
2560 "changed-file section renders before whole-project"
2561 );
2562 assert!(human.contains("45 -> 40 (down) across your last two full runs"));
2563 assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
2564 }
2565
2566 #[test]
2567 fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
2568 let r = rreport(
2569 0,
2570 Some("2026-05-30T10:00:00Z"),
2571 None,
2572 None,
2573 Some(rcounts(1, 1, 0, 0)),
2574 None,
2575 true,
2576 );
2577 let md = render_markdown(&r);
2578 assert!(
2579 md.contains(
2580 "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
2581 ),
2582 "project-only md must render the labeled whole-project line"
2583 );
2584 assert!(
2585 !md.contains("No history yet"),
2586 "project-only md must not show empty state"
2587 );
2588 assert!(md.contains("Tracking since 2026-05-30"));
2589 }
2590}