Skip to main content

fallow_cli/
impact.rs

1//! Fallow Impact: local, opt-in value reporting.
2
3use 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/// Per-category issue counts captured at a recorded run.
33#[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    /// Whether the user ever ran an explicit `impact enable` or `impact
160    /// disable`. Distinguishes "deliberately declined" from "never asked" so
161    /// the agent skill asks for the impact opt-in exactly once per project.
162    #[serde(default)]
163    pub explicit_decision: bool,
164    /// Unix epoch seconds when the periodic impact digest was last surfaced
165    /// (the `impact-report` next-step / human one-liner). Internal cadence
166    /// state, never exposed on the report.
167    #[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
175/// Load the store. Missing or unreadable files fall back to defaults; unreadable
176/// files are warned about rather than silently disabling tracking.
177pub 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
204/// Persist the store best-effort using atomic replace.
205fn 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
217/// Enable Impact tracking and ensure `.fallow/` is gitignored.
218pub 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
231/// Append `.fallow/` to `.gitignore` if needed.
232fn 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
249/// Disable Impact tracking. Retains existing history. Returns whether it was
250/// newly disabled (false if already off). Also records the explicit decision,
251/// so declining the impact opt-in on a never-enabled project (`impact
252/// disable`) persists "asked and said no" for the agent skill.
253pub 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/// A due periodic value digest: the headline counters for "what has fallow
266/// done for you here". Returned by [`take_due_digest`] at most once per
267/// `DIGEST_INTERVAL_SECS` per project.
268#[derive(Debug, Clone, Copy)]
269pub struct ImpactDigest {
270    pub containment_count: usize,
271    pub resolved_total: usize,
272}
273
274/// Minimum seconds between periodic digest surfacings (one week).
275const DIGEST_INTERVAL_SECS: u64 = 7 * 24 * 60 * 60;
276
277/// Return the periodic value digest when it is due, stamping the store so the
278/// next one is at least `DIGEST_INTERVAL_SECS` away. Due means: tracking is
279/// enabled, there is non-zero value to report (anti-nag: a zero digest never
280/// surfaces), and the previous digest is older than the interval (or never
281/// happened). Best-effort like the rest of the store: a clean run that drops
282/// the emitted step simply defers the digest to the next interval.
283pub 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
309/// Persist that the local user declined the agent onboarding prompt.
310pub 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
322/// Record an audit run into the rolling store.
323pub 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
373/// Record a whole-project combined run into the project track.
374pub 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
417/// Update pending/contained state from a gate run's verdict.
418fn 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 &current_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: &current_findings,
553        current_supps: &current_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; // still present
627            }
628            if appeared_move_keys.contains(&pf.move_key()) {
629                continue; // moved to another file this run
630            }
631            if covered_by(&new_supp_kinds, &pf.kind) {
632                suppressed += 1; // conservative: a fresh fallow-ignore, never a win
633            } 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    for f in &results.unused_files {
794        push(&f.file.path, "unused-file", None);
795    }
796    for f in &results.unused_exports {
797        push(
798            &f.export.path,
799            "unused-export",
800            Some(f.export.export_name.clone()),
801        );
802    }
803    for f in &results.unused_types {
804        push(
805            &f.export.path,
806            "unused-type",
807            Some(f.export.export_name.clone()),
808        );
809    }
810    for f in &results.private_type_leaks {
811        push(
812            &f.leak.path,
813            "private-type-leak",
814            Some(format!(
815                "{}{ID_SEP}{}",
816                f.leak.export_name, f.leak.type_name
817            )),
818        );
819    }
820    for f in &results.unused_enum_members {
821        push(
822            &f.member.path,
823            "unused-enum-member",
824            Some(format!(
825                "{}{ID_SEP}{}",
826                f.member.parent_name, f.member.member_name
827            )),
828        );
829    }
830    for f in &results.unused_class_members {
831        push(
832            &f.member.path,
833            "unused-class-member",
834            Some(format!(
835                "{}{ID_SEP}{}",
836                f.member.parent_name, f.member.member_name
837            )),
838        );
839    }
840    for f in &results.unresolved_imports {
841        push(
842            &f.import.path,
843            "unresolved-import",
844            Some(f.import.specifier.clone()),
845        );
846    }
847    for f in &results.boundary_violations {
848        let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
849        push(
850            &f.violation.from_path,
851            "boundary-violation",
852            Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
853        );
854    }
855    for f in &results.unused_dependencies {
856        push(
857            &f.dep.path,
858            "unused-dependency",
859            Some(f.dep.package_name.clone()),
860        );
861    }
862    for f in &results.unused_dev_dependencies {
863        push(
864            &f.dep.path,
865            "unused-dev-dependency",
866            Some(f.dep.package_name.clone()),
867        );
868    }
869    for f in &results.unused_optional_dependencies {
870        push(
871            &f.dep.path,
872            "unused-optional-dependency",
873            Some(f.dep.package_name.clone()),
874        );
875    }
876    for f in &results.type_only_dependencies {
877        push(
878            &f.dep.path,
879            "type-only-dependency",
880            Some(f.dep.package_name.clone()),
881        );
882    }
883    for f in &results.test_only_dependencies {
884        push(
885            &f.dep.path,
886            "test-only-dependency",
887            Some(f.dep.package_name.clone()),
888        );
889    }
890    for f in &results.unused_catalog_entries {
891        push(
892            &f.entry.path,
893            "unused-catalog-entry",
894            Some(format!(
895                "{}{ID_SEP}{}",
896                f.entry.catalog_name, f.entry.entry_name
897            )),
898        );
899    }
900    for f in &results.empty_catalog_groups {
901        push(
902            &f.group.path,
903            "empty-catalog-group",
904            Some(f.group.catalog_name.clone()),
905        );
906    }
907    for f in &results.unresolved_catalog_references {
908        push(
909            &f.reference.path,
910            "unresolved-catalog-reference",
911            Some(format!(
912                "{}{ID_SEP}{}",
913                f.reference.catalog_name, f.reference.entry_name
914            )),
915        );
916    }
917    for f in &results.unused_dependency_overrides {
918        push(
919            &f.entry.path,
920            "unused-dependency-override",
921            Some(f.entry.raw_key.clone()),
922        );
923    }
924    for f in &results.misconfigured_dependency_overrides {
925        push(
926            &f.entry.path,
927            "misconfigured-dependency-override",
928            Some(f.entry.raw_key.clone()),
929        );
930    }
931    out
932}
933
934/// Collect line-independent complexity finding identities `(path, function name)`
935/// from a health report. The function name is line-independent, so a function
936/// moving within its file keeps the same identity.
937#[must_use]
938pub fn collect_complexity_findings(
939    report: &crate::health_types::HealthReport,
940) -> Vec<FindingInput> {
941    report
942        .findings
943        .iter()
944        .map(|f| FindingInput {
945            path: f.path.clone(),
946            kind: "complexity",
947            symbol: Some(f.name.clone()),
948        })
949        .collect()
950}
951
952/// Collect clone-group identities `(fingerprint, instance paths)` from a
953/// duplication report. The fingerprint is content-derived (`dup:<hash>`), so it
954/// is stable across pure relocation.
955#[must_use]
956pub fn collect_clone_findings(
957    report: &fallow_core::duplicates::DuplicationReport,
958) -> Vec<CloneInput> {
959    report
960        .clone_groups
961        .iter()
962        .map(|g| CloneInput {
963            fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
964            instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
965        })
966        .collect()
967}
968
969const fn verdict_label(verdict: AuditVerdict) -> &'static str {
970    match verdict {
971        AuditVerdict::Pass => "pass",
972        AuditVerdict::Warn => "warn",
973        AuditVerdict::Fail => "fail",
974    }
975}
976
977/// Direction of a count trend between two recorded runs.
978#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
979#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
980#[serde(rename_all = "snake_case")]
981pub enum ImpactTrendDirection {
982    /// Issue count went down (good).
983    Improving,
984    /// Issue count went up.
985    Declining,
986    /// Within tolerance.
987    Stable,
988}
989
990/// A computed trend between the two most recent records.
991#[derive(Debug, Clone, Serialize)]
992#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
993pub struct TrendSummary {
994    pub direction: ImpactTrendDirection,
995    /// Signed delta in total issues (current minus previous).
996    pub total_delta: i64,
997    pub previous_total: usize,
998    pub current_total: usize,
999}
1000
1001fn direction_for(delta: i64) -> ImpactTrendDirection {
1002    if delta < -TREND_TOLERANCE {
1003        ImpactTrendDirection::Improving
1004    } else if delta > TREND_TOLERANCE {
1005        ImpactTrendDirection::Declining
1006    } else {
1007        ImpactTrendDirection::Stable
1008    }
1009}
1010
1011/// Wire-version discriminator for [`ImpactReport`]. Independent from the global
1012/// `SchemaVersion` (the impact report versions on its own cadence) and from the
1013/// on-disk `STORE_SCHEMA_VERSION` (the persisted store shape versions
1014/// separately). Serializes as a string `const` so JSON consumers can switch on
1015/// it, matching the other independently-versioned envelopes (e.g.
1016/// `CoverageAnalyzeSchemaVersion`).
1017#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1018#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1019pub enum ImpactReportSchemaVersion {
1020    /// First release of the `fallow impact --format json` shape.
1021    #[serde(rename = "1")]
1022    V1,
1023}
1024
1025/// The rendered impact report, derived purely from the store (no analysis run).
1026#[derive(Debug, Clone, Serialize)]
1027#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1028#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
1029pub struct ImpactReport {
1030    /// Output-shape version for this report, so JSON consumers have a
1031    /// forward-compat signal independent of the on-disk store version. Always
1032    /// present; bumped only on a breaking change to this report's wire shape.
1033    pub schema_version: ImpactReportSchemaVersion,
1034    pub enabled: bool,
1035    pub record_count: usize,
1036    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
1037    pub meta: Option<Meta>,
1038    #[serde(default, skip_serializing_if = "Option::is_none")]
1039    pub first_recorded: Option<String>,
1040    /// Git SHA of the most recent recorded run, so a consumer can tell which
1041    /// commit the `surfacing` counts belong to. This is an ABBREVIATED SHA
1042    /// (`git rev-parse --short`), so it is for display/correlation only and will
1043    /// not match a full 40-character SHA from `$GITHUB_SHA` or the git API
1044    /// without expansion. None when the latest run had no SHA (not a git repo)
1045    /// or there are no records yet.
1046    #[serde(default, skip_serializing_if = "Option::is_none")]
1047    pub latest_git_sha: Option<String>,
1048    /// Counts from the most recent recorded run. These are CHANGED-FILE scoped
1049    /// (each record comes from a `fallow audit` run, whose default `new-only`
1050    /// gate counts only findings in the changed files of that run), NOT a
1051    /// whole-project total.
1052    #[serde(default, skip_serializing_if = "Option::is_none")]
1053    pub surfacing: Option<ImpactCounts>,
1054    /// Trend between the two most recent records. None until two records exist.
1055    #[serde(default, skip_serializing_if = "Option::is_none")]
1056    pub trend: Option<TrendSummary>,
1057    /// Counts from the most recent whole-project `fallow` run. WHOLE-PROJECT
1058    /// scope (not changed-file), so this is the current issue total across the
1059    /// whole repo, context next to the actionable changed-file `surfacing`
1060    /// count. None until a full `fallow` run has been recorded. v1.6.
1061    #[serde(default, skip_serializing_if = "Option::is_none")]
1062    pub project_surfacing: Option<ImpactCounts>,
1063    /// Trend between the two most recent whole-project records. Comparable over
1064    /// time (same whole-project denominator every run), unlike the changed-file
1065    /// `trend`. None until two full `fallow` runs exist. v1.6.
1066    #[serde(default, skip_serializing_if = "Option::is_none")]
1067    pub project_trend: Option<TrendSummary>,
1068    pub containment_count: usize,
1069    /// Most recent containment events (newest last), capped for display.
1070    pub recent_containment: Vec<ContainmentEvent>,
1071    /// Lifetime count of findings fallow credits as genuinely resolved (code
1072    /// removed or refactored, never a `fallow-ignore`). v1.5.
1073    pub resolved_total: usize,
1074    /// Lifetime count of findings silenced by a newly-added `fallow-ignore`.
1075    /// Reported as honest context, never as a win. v1.5.
1076    pub suppressed_total: usize,
1077    /// Most recent resolution events (newest last), capped for display. v1.5.
1078    pub recent_resolved: Vec<ResolutionEvent>,
1079    /// Whether per-finding attribution has a baseline yet. False on a freshly
1080    /// upgraded v1 store (no frontier captured), which the renderer uses to show
1081    /// "resolution tracking starts from your next run" instead of a bare zero.
1082    pub attribution_active: bool,
1083    /// Whether the local agent onboarding prompt has been explicitly declined.
1084    /// Stored under `.fallow/` so agents can avoid cross-session nags.
1085    pub onboarding_declined: bool,
1086    /// Whether the user ever made an explicit enable/disable decision for
1087    /// Impact tracking. `enabled: false` with `explicit_decision: false` means
1088    /// "never asked"; with `true` it means "asked and declined". Agents use
1089    /// this to offer the impact opt-in exactly once per project.
1090    pub explicit_decision: bool,
1091}
1092
1093/// Build a report from the store. Defensive: a single record (or none) yields
1094/// no trend rather than a spurious spike, and an empty store yields an empty
1095/// report flagged so the renderer can show the first-run message.
1096/// Trend between the two most recent records in a series. None until two records
1097/// exist; a missing prior record is "unknown" (no trend), never a spike.
1098fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
1099    if records.len() < 2 {
1100        return None;
1101    }
1102    let current = &records[records.len() - 1];
1103    let previous = &records[records.len() - 2];
1104    let current_total = current.counts.total_issues;
1105    let previous_total = previous.counts.total_issues;
1106    let total_delta = current_total as i64 - previous_total as i64;
1107    Some(TrendSummary {
1108        direction: direction_for(total_delta),
1109        total_delta,
1110        previous_total,
1111        current_total,
1112    })
1113}
1114
1115pub fn build_report(store: &ImpactStore) -> ImpactReport {
1116    let surfacing = store.records.last().map(|r| r.counts.clone());
1117    let trend = trend_for(&store.records);
1118    let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1119    let project_trend = trend_for(&store.project_records);
1120
1121    let recent_containment = store
1122        .containment
1123        .iter()
1124        .rev()
1125        .take(5)
1126        .rev()
1127        .cloned()
1128        .collect();
1129
1130    let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1131
1132    let recent_resolved = store
1133        .recent_resolved
1134        .iter()
1135        .rev()
1136        .take(5)
1137        .rev()
1138        .cloned()
1139        .collect();
1140    let attribution_active = !store.frontier.is_empty()
1141        || !store.clone_frontier.is_empty()
1142        || store.resolved_total > 0
1143        || store.suppressed_total > 0;
1144
1145    ImpactReport {
1146        schema_version: ImpactReportSchemaVersion::V1,
1147        enabled: store.enabled,
1148        record_count: store.records.len(),
1149        meta: None,
1150        first_recorded: store.first_recorded.clone(),
1151        latest_git_sha,
1152        surfacing,
1153        trend,
1154        project_surfacing,
1155        project_trend,
1156        containment_count: store.containment.len(),
1157        recent_containment,
1158        resolved_total: store.resolved_total,
1159        suppressed_total: store.suppressed_total,
1160        recent_resolved,
1161        attribution_active,
1162        onboarding_declined: store.onboarding_declined,
1163        explicit_decision: store.explicit_decision,
1164    }
1165}
1166
1167/// Render the whole-project view for the human report. Deliberately understated
1168/// (one count line, one trend line, one caveat) rather than a co-equal header:
1169/// the project track advances only on local full `fallow` runs, not CI, so it is
1170/// context for the changed-file story above, not the headline. Renders nothing
1171/// when no full `fallow` run has been recorded yet.
1172#[expect(
1173    clippy::format_push_string,
1174    reason = "small report renderer; readability over avoiding the extra allocation"
1175)]
1176fn render_project_section(out: &mut String, report: &ImpactReport) {
1177    let Some(s) = &report.project_surfacing else {
1178        return;
1179    };
1180    out.push_str(&format!(
1181        "  WHOLE PROJECT (whole-repo context, not a to-do)\n    {} issue{} across the whole project at your last full `fallow` run\n",
1182        s.total_issues,
1183        plural(s.total_issues),
1184    ));
1185    if let Some(t) = &report.project_trend {
1186        let arrow = trend_arrow(t.direction);
1187        out.push_str(&format!(
1188            "    {} -> {} ({}) across your last two full runs (comparable over time)\n",
1189            t.previous_total, t.current_total, arrow,
1190        ));
1191    } else {
1192        out.push_str("    project trend starts after your next full `fallow` run\n");
1193    }
1194    out.push_str("      advances only on your local full `fallow` runs, not CI\n\n");
1195}
1196
1197/// Render the report as human-readable text.
1198#[expect(
1199    clippy::format_push_string,
1200    reason = "small report renderer; readability over avoiding the extra allocation"
1201)]
1202pub fn render_human(report: &ImpactReport) -> String {
1203    let mut out = String::new();
1204    out.push_str("FALLOW IMPACT\n\n");
1205
1206    if !report.enabled {
1207        out.push_str(
1208            "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
1209             let your pre-commit gate run a few times to build history.\n",
1210        );
1211        return out;
1212    }
1213
1214    if report.record_count == 0 && report.project_surfacing.is_none() {
1215        out.push_str(
1216            "Tracking enabled. No history yet: check back after your next few\n\
1217             commits (Impact records each `fallow audit` / pre-commit gate run,\n\
1218             and each full `fallow` run for the whole-project view).\n",
1219        );
1220        return out;
1221    }
1222
1223    if let Some(s) = &report.surfacing {
1224        out.push_str(&format!(
1225            "  LATEST RUN (changed files, act on these now)\n    {} issue{} flagged in your last `fallow audit` run\n",
1226            s.total_issues,
1227            plural(s.total_issues),
1228        ));
1229        out.push_str(&format!(
1230            "      dead code {}  ·  complexity {}  ·  duplication {}\n\n",
1231            s.dead_code, s.complexity, s.duplication,
1232        ));
1233    }
1234
1235    if let Some(t) = &report.trend {
1236        let arrow = trend_arrow(t.direction);
1237        out.push_str(&format!(
1238            "  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",
1239            t.previous_total, t.current_total, arrow,
1240        ));
1241    }
1242
1243    render_project_section(&mut out, report);
1244
1245    out.push_str(&format!(
1246        "  CONTAINED AT COMMIT\n    {} time{} fallow blocked a commit until it was fixed\n",
1247        report.containment_count,
1248        plural(report.containment_count),
1249    ));
1250
1251    if report.resolved_total > 0 {
1252        out.push_str(&format!(
1253            "\n  RESOLVED\n    {} finding{} you cleared since fallow started tracking\n",
1254            report.resolved_total,
1255            plural(report.resolved_total),
1256        ));
1257        for ev in &report.recent_resolved {
1258            match &ev.symbol {
1259                Some(symbol) => {
1260                    out.push_str(&format!("      {} {} in {}\n", ev.kind, symbol, ev.path));
1261                }
1262                None => out.push_str(&format!("      {} in {}\n", ev.kind, ev.path)),
1263            }
1264        }
1265    } else if report.attribution_active {
1266        out.push_str(
1267            "\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",
1268        );
1269    } else {
1270        out.push_str("\n  RESOLVED\n    resolution tracking starts from your next gate run\n");
1271    }
1272
1273    if report.suppressed_total > 0 {
1274        out.push_str(&format!(
1275            "      {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
1276            report.suppressed_total,
1277            plural(report.suppressed_total),
1278        ));
1279    }
1280
1281    out.push('\n');
1282    let since = report
1283        .first_recorded
1284        .as_deref()
1285        .map_or("the first run", date_only);
1286    if report.record_count > 0 {
1287        out.push_str(&format!(
1288            "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
1289             Changed-file scope: each audit run only sees files differing from your base.\n",
1290            report.record_count,
1291            plural(report.record_count),
1292            since,
1293        ));
1294    } else {
1295        out.push_str(&format!(
1296            "Tracking since {since}. Local-only; never uploaded.\n",
1297        ));
1298    }
1299    out.push_str(
1300        "Resolution tracking is a local-developer signal: it accrues where\n\
1301         .fallow/impact.json persists across runs, not in ephemeral CI runners.\n",
1302    );
1303    out
1304}
1305
1306/// Render the report as JSON.
1307pub fn render_json(report: &ImpactReport) -> String {
1308    let value = crate::output_envelope::serialize_root_output(
1309        crate::output_envelope::FallowOutput::Impact(report.clone()),
1310    )
1311    .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
1312    serde_json::to_string_pretty(&value)
1313        .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
1314}
1315
1316/// Render the whole-project view for the markdown report. One understated line
1317/// plus a trend line when available, matching the human renderer's framing.
1318/// Renders nothing when no full `fallow` run has been recorded yet.
1319#[expect(
1320    clippy::format_push_string,
1321    reason = "small report renderer; readability over avoiding the extra allocation"
1322)]
1323fn render_project_markdown(out: &mut String, report: &ImpactReport) {
1324    let Some(s) = &report.project_surfacing else {
1325        return;
1326    };
1327    out.push_str(&format!(
1328        "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1329        s.total_issues,
1330        plural(s.total_issues),
1331        s.dead_code,
1332        s.complexity,
1333        s.duplication,
1334    ));
1335    if let Some(t) = &report.project_trend {
1336        let arrow = trend_arrow(t.direction);
1337        out.push_str(&format!(
1338            "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
1339            t.previous_total, t.current_total, arrow,
1340        ));
1341    }
1342}
1343
1344/// Render the report as Markdown (paste-ready for a PR description or standup).
1345#[expect(
1346    clippy::format_push_string,
1347    reason = "small report renderer; readability over avoiding the extra allocation"
1348)]
1349pub fn render_markdown(report: &ImpactReport) -> String {
1350    let mut out = String::new();
1351    out.push_str("## Fallow impact\n\n");
1352
1353    if !report.enabled {
1354        out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
1355        return out;
1356    }
1357    if report.record_count == 0 && report.project_surfacing.is_none() {
1358        out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
1359        return out;
1360    }
1361
1362    if let Some(s) = &report.surfacing {
1363        out.push_str(&format!(
1364            "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1365            s.total_issues,
1366            plural(s.total_issues),
1367            s.dead_code,
1368            s.complexity,
1369            s.duplication,
1370        ));
1371    }
1372    if let Some(t) = &report.trend {
1373        out.push_str(&format!(
1374            "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
1375            t.previous_total,
1376            t.current_total,
1377            trend_arrow(t.direction),
1378        ));
1379    }
1380    render_project_markdown(&mut out, report);
1381    out.push_str(&format!(
1382        "- **Contained at commit:** {} time{}\n",
1383        report.containment_count,
1384        plural(report.containment_count),
1385    ));
1386    if report.resolved_total > 0 {
1387        out.push_str(&format!(
1388            "- **Resolved:** {} finding{} cleared since tracking started\n",
1389            report.resolved_total,
1390            plural(report.resolved_total),
1391        ));
1392    } else if report.attribution_active {
1393        out.push_str("- **Resolved:** none yet; tracking active\n");
1394    } else {
1395        out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
1396    }
1397    if report.suppressed_total > 0 {
1398        out.push_str(&format!(
1399            "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
1400            report.suppressed_total,
1401            plural(report.suppressed_total),
1402        ));
1403    }
1404    let since = report
1405        .first_recorded
1406        .as_deref()
1407        .map_or("the first run", date_only);
1408    if report.record_count > 0 {
1409        out.push_str(&format!(
1410            "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
1411            report.record_count,
1412            plural(report.record_count),
1413            since,
1414        ));
1415    } else {
1416        out.push_str(&format!(
1417            "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
1418        ));
1419    }
1420    out
1421}
1422
1423const fn plural(n: usize) -> &'static str {
1424    if n == 1 { "" } else { "s" }
1425}
1426
1427/// Trim a stored ISO-8601 timestamp (`2026-05-29T18:15:23Z`) to its date part
1428/// (`2026-05-29`) for human/markdown footers. The wall-clock time and `Z` add
1429/// noise without meaning when a reader just wants "tracking since when". JSON
1430/// keeps the full `first_recorded` timestamp. Returns the input unchanged if it
1431/// has no `T` separator.
1432fn date_only(ts: &str) -> &str {
1433    ts.split_once('T').map_or(ts, |(date, _)| date)
1434}
1435
1436/// Single human-facing trend vocabulary, shared by the text and markdown
1437/// renderers so the same concept does not read three different ways. The JSON
1438/// wire keeps the `improving`/`declining`/`stable` enum form for machines.
1439const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
1440    match direction {
1441        ImpactTrendDirection::Improving => "down",
1442        ImpactTrendDirection::Declining => "up",
1443        ImpactTrendDirection::Stable => "flat",
1444    }
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449    use super::*;
1450
1451    fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
1452        AuditSummary {
1453            dead_code_issues: dead,
1454            dead_code_has_errors: dead > 0,
1455            complexity_findings: complexity,
1456            max_cyclomatic: None,
1457            duplication_clone_groups: dupes,
1458        }
1459    }
1460
1461    /// Record a run with no per-finding attribution (v1 surfacing/trend/containment only).
1462    fn record_v1(
1463        root: &Path,
1464        summary: &AuditSummary,
1465        verdict: AuditVerdict,
1466        gate: bool,
1467        git_sha: Option<&str>,
1468        version: &str,
1469        timestamp: &str,
1470    ) {
1471        record_audit_run(
1472            root,
1473            summary,
1474            &AuditRunRecord {
1475                verdict,
1476                gate,
1477                git_sha,
1478                version,
1479                timestamp,
1480                attribution: None,
1481            },
1482        );
1483    }
1484
1485    /// Create a real file under `root` (attribution prunes frontier entries for
1486    /// files that no longer exist, so test files must exist on disk).
1487    fn touch(root: &Path, rel: &str) -> PathBuf {
1488        let p = root.join(rel);
1489        if let Some(parent) = p.parent() {
1490            std::fs::create_dir_all(parent).unwrap();
1491        }
1492        std::fs::write(&p, b"x").unwrap();
1493        p
1494    }
1495
1496    fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
1497        FindingInput {
1498            path: path.to_path_buf(),
1499            kind,
1500            symbol: Some(symbol.to_owned()),
1501        }
1502    }
1503
1504    fn supp(path: &Path, kind: &str) -> ActiveSuppression {
1505        ActiveSuppression {
1506            path: path.to_path_buf(),
1507            kind: Some(kind.to_owned()),
1508            is_file_level: false,
1509        }
1510    }
1511
1512    /// Record one attribution run against the store.
1513    fn run(
1514        root: &Path,
1515        changed: &[&Path],
1516        findings: Vec<FindingInput>,
1517        clones: Vec<CloneInput>,
1518        supps: &[ActiveSuppression],
1519        ts: &str,
1520    ) {
1521        let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
1522        let input = AttributionInput {
1523            root,
1524            scope: Scope::ChangedFiles(&changed_files),
1525            findings,
1526            clones,
1527            suppressions: supps,
1528        };
1529        record_audit_run(
1530            root,
1531            &summary(0, 0, 0),
1532            &AuditRunRecord {
1533                verdict: AuditVerdict::Pass,
1534                gate: true,
1535                git_sha: Some("sha"),
1536                version: "2.0.0",
1537                timestamp: ts,
1538                attribution: Some(&input),
1539            },
1540        );
1541    }
1542
1543    #[test]
1544    fn disabled_store_does_not_record() {
1545        let dir = tempfile::tempdir().unwrap();
1546        let root = dir.path();
1547        record_v1(
1548            root,
1549            &summary(3, 1, 0),
1550            AuditVerdict::Fail,
1551            true,
1552            Some("abc1234"),
1553            "2.0.0",
1554            "2026-05-29T10:00:00Z",
1555        );
1556        let store = load(root);
1557        assert!(store.records.is_empty());
1558        assert!(!store.enabled);
1559    }
1560
1561    #[test]
1562    fn enable_and_disable_record_the_explicit_decision() {
1563        let dir = tempfile::tempdir().unwrap();
1564        let root = dir.path();
1565        assert!(!load(root).explicit_decision, "fresh store: never asked");
1566
1567        // Declining on a never-enabled project is an explicit decision too.
1568        disable(root);
1569        let store = load(root);
1570        assert!(!store.enabled);
1571        assert!(store.explicit_decision);
1572        assert!(build_report(&store).explicit_decision);
1573    }
1574
1575    #[test]
1576    fn due_digest_stamps_and_respects_interval_and_gates() {
1577        let dir = tempfile::tempdir().unwrap();
1578        let root = dir.path();
1579
1580        // Disabled, or enabled with zero value: never due.
1581        assert!(take_due_digest(root).is_none());
1582        enable(root);
1583        assert!(take_due_digest(root).is_none(), "zero counters never nag");
1584
1585        let mut store = load(root);
1586        store.resolved_total = 3;
1587        store.containment.push(ContainmentEvent {
1588            blocked_at: "2026-06-11T00:00:00Z".to_string(),
1589            cleared_at: "2026-06-11T00:05:00Z".to_string(),
1590            git_sha: None,
1591            blocked_counts: ImpactCounts::default(),
1592        });
1593        save(&store, root);
1594
1595        let digest = take_due_digest(root).expect("first digest is due");
1596        assert_eq!(digest.containment_count, 1);
1597        assert_eq!(digest.resolved_total, 3);
1598        assert!(
1599            take_due_digest(root).is_none(),
1600            "stamped: not due again within the interval"
1601        );
1602
1603        // An expired stamp makes it due again.
1604        let mut store = load(root);
1605        store.last_digest_epoch = Some(0);
1606        save(&store, root);
1607        assert!(take_due_digest(root).is_some());
1608    }
1609
1610    #[test]
1611    fn decline_onboarding_persists_in_existing_store() {
1612        let dir = tempfile::tempdir().unwrap();
1613        let root = dir.path();
1614
1615        assert!(decline_onboarding(root));
1616        assert!(!decline_onboarding(root));
1617
1618        let store = load(root);
1619        assert!(store.onboarding_declined);
1620        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
1621        assert!(root.join(".gitignore").exists());
1622        let report = build_report(&store);
1623        assert!(report.onboarding_declined);
1624    }
1625
1626    #[test]
1627    fn enable_then_record_accrues_history() {
1628        let dir = tempfile::tempdir().unwrap();
1629        let root = dir.path();
1630        assert!(enable(root));
1631        assert!(!enable(root)); // second enable is a no-op-ish (already on)
1632        record_v1(
1633            root,
1634            &summary(2, 1, 0),
1635            AuditVerdict::Warn,
1636            false,
1637            None,
1638            "2.0.0",
1639            "2026-05-29T10:00:00Z",
1640        );
1641        let store = load(root);
1642        assert_eq!(store.records.len(), 1);
1643        assert_eq!(store.records[0].counts.total_issues, 3);
1644        assert_eq!(
1645            store.first_recorded.as_deref(),
1646            Some("2026-05-29T10:00:00Z")
1647        );
1648    }
1649
1650    #[test]
1651    fn enable_gitignores_the_store() {
1652        let dir = tempfile::tempdir().unwrap();
1653        let root = dir.path();
1654        enable(root);
1655        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1656        assert!(
1657            gitignore.lines().any(|l| l.trim() == ".fallow/"),
1658            "enable must gitignore .fallow/, got: {gitignore:?}"
1659        );
1660        enable(root);
1661        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1662        assert_eq!(
1663            gitignore.lines().filter(|l| l.trim() == ".fallow/").count(),
1664            1,
1665            "re-enabling must not duplicate the .fallow/ entry"
1666        );
1667    }
1668
1669    #[test]
1670    fn single_record_yields_no_trend_no_spike() {
1671        let mut store = ImpactStore {
1672            enabled: true,
1673            ..Default::default()
1674        };
1675        store.records.push(ImpactRecord {
1676            timestamp: "t0".into(),
1677            version: "2.0.0".into(),
1678            git_sha: None,
1679            verdict: "warn".into(),
1680            gate: false,
1681            counts: ImpactCounts {
1682                total_issues: 5,
1683                dead_code: 5,
1684                complexity: 0,
1685                duplication: 0,
1686            },
1687        });
1688        let report = build_report(&store);
1689        assert!(report.trend.is_none());
1690        assert_eq!(report.surfacing.unwrap().total_issues, 5);
1691    }
1692
1693    #[test]
1694    fn empty_store_report_is_first_run() {
1695        let store = ImpactStore::default();
1696        let report = build_report(&store);
1697        assert_eq!(report.record_count, 0);
1698        assert!(report.trend.is_none());
1699        assert!(report.surfacing.is_none());
1700        let human = render_human(&report);
1701        assert!(human.contains("off")); // default store is disabled
1702    }
1703
1704    #[test]
1705    fn enabled_empty_store_shows_check_back() {
1706        let store = ImpactStore {
1707            enabled: true,
1708            ..Default::default()
1709        };
1710        let report = build_report(&store);
1711        let human = render_human(&report);
1712        assert!(human.contains("No history yet"));
1713        assert!(!human.contains("0 issues"));
1714    }
1715
1716    #[test]
1717    fn trend_improving_when_issues_drop() {
1718        let mut store = ImpactStore {
1719            enabled: true,
1720            ..Default::default()
1721        };
1722        for total in [8usize, 3usize] {
1723            store.records.push(ImpactRecord {
1724                timestamp: format!("t{total}"),
1725                version: "2.0.0".into(),
1726                git_sha: None,
1727                verdict: "warn".into(),
1728                gate: false,
1729                counts: ImpactCounts {
1730                    total_issues: total,
1731                    dead_code: total,
1732                    complexity: 0,
1733                    duplication: 0,
1734                },
1735            });
1736        }
1737        let report = build_report(&store);
1738        let trend = report.trend.unwrap();
1739        assert_eq!(trend.direction, ImpactTrendDirection::Improving);
1740        assert_eq!(trend.total_delta, -5);
1741    }
1742
1743    #[test]
1744    fn containment_blocked_then_cleared_records_one_event() {
1745        let dir = tempfile::tempdir().unwrap();
1746        let root = dir.path();
1747        enable(root);
1748        record_v1(
1749            root,
1750            &summary(2, 0, 0),
1751            AuditVerdict::Fail,
1752            true,
1753            Some("sha1"),
1754            "2.0.0",
1755            "t0",
1756        );
1757        let store = load(root);
1758        assert!(store.pending_containment.is_some());
1759        assert!(store.containment.is_empty());
1760
1761        record_v1(
1762            root,
1763            &summary(0, 0, 0),
1764            AuditVerdict::Pass,
1765            true,
1766            Some("sha2"),
1767            "2.0.0",
1768            "t1",
1769        );
1770        let store = load(root);
1771        assert!(store.pending_containment.is_none());
1772        assert_eq!(store.containment.len(), 1);
1773        assert_eq!(store.containment[0].blocked_at, "t0");
1774        assert_eq!(store.containment[0].cleared_at, "t1");
1775    }
1776
1777    #[test]
1778    fn non_gate_run_never_creates_containment() {
1779        let dir = tempfile::tempdir().unwrap();
1780        let root = dir.path();
1781        enable(root);
1782        record_v1(
1783            root,
1784            &summary(2, 0, 0),
1785            AuditVerdict::Fail,
1786            false,
1787            None,
1788            "2.0.0",
1789            "t0",
1790        );
1791        let store = load(root);
1792        assert!(store.pending_containment.is_none());
1793        assert!(store.containment.is_empty());
1794    }
1795
1796    #[test]
1797    fn corrupt_store_loads_as_default_no_panic() {
1798        let dir = tempfile::tempdir().unwrap();
1799        let root = dir.path();
1800        std::fs::create_dir_all(root.join(".fallow")).unwrap();
1801        std::fs::write(store_path(root), b"{ not valid json ][").unwrap();
1802        let store = load(root);
1803        assert!(!store.enabled);
1804        assert!(store.records.is_empty());
1805        record_v1(
1806            root,
1807            &summary(1, 0, 0),
1808            AuditVerdict::Fail,
1809            true,
1810            None,
1811            "2.0.0",
1812            "t0",
1813        );
1814    }
1815
1816    #[test]
1817    fn records_are_bounded() {
1818        let mut store = ImpactStore {
1819            enabled: true,
1820            ..Default::default()
1821        };
1822        for i in 0..(MAX_RECORDS + 50) {
1823            store.records.push(ImpactRecord {
1824                timestamp: format!("t{i}"),
1825                version: "2.0.0".into(),
1826                git_sha: None,
1827                verdict: "pass".into(),
1828                gate: false,
1829                counts: ImpactCounts::default(),
1830            });
1831        }
1832        compact(&mut store);
1833        assert_eq!(store.records.len(), MAX_RECORDS);
1834        assert_eq!(store.records[0].timestamp, "t50");
1835    }
1836
1837    #[test]
1838    fn report_always_carries_schema_version() {
1839        let empty = build_report(&ImpactStore::default());
1840        assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
1841        let json = render_json(&empty);
1842        assert!(
1843            json.contains("\"schema_version\": \"1\""),
1844            "schema_version must be present (as the \"1\" const) even when disabled: {json}"
1845        );
1846
1847        let mut store = ImpactStore {
1848            enabled: true,
1849            ..Default::default()
1850        };
1851        store.records.push(ImpactRecord {
1852            timestamp: "2026-05-29T10:00:00Z".into(),
1853            version: "2.0.0".into(),
1854            git_sha: None,
1855            verdict: "pass".into(),
1856            gate: false,
1857            counts: ImpactCounts::default(),
1858        });
1859        assert_eq!(
1860            build_report(&store).schema_version,
1861            ImpactReportSchemaVersion::V1
1862        );
1863    }
1864
1865    #[test]
1866    fn date_only_trims_iso_timestamp() {
1867        assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
1868        assert_eq!(date_only("2026-05-29"), "2026-05-29");
1869        assert_eq!(date_only("the first run"), "the first run");
1870    }
1871
1872    #[test]
1873    fn human_footer_shows_date_only() {
1874        let mut store = ImpactStore {
1875            enabled: true,
1876            ..Default::default()
1877        };
1878        store.first_recorded = Some("2026-05-29T18:15:23Z".into());
1879        store.records.push(ImpactRecord {
1880            timestamp: "2026-05-29T18:15:23Z".into(),
1881            version: "2.0.0".into(),
1882            git_sha: None,
1883            verdict: "pass".into(),
1884            gate: false,
1885            counts: ImpactCounts::default(),
1886        });
1887        let report = build_report(&store);
1888        let human = render_human(&report);
1889        assert!(
1890            human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
1891            "human footer must show date-only: {human}"
1892        );
1893        let md = render_markdown(&report);
1894        assert!(
1895            md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
1896            "markdown footer must show date-only: {md}"
1897        );
1898    }
1899
1900    #[test]
1901    fn future_schema_version_store_loads_without_panic_or_loss() {
1902        let dir = tempfile::tempdir().unwrap();
1903        let root = dir.path();
1904        std::fs::create_dir_all(root.join(".fallow")).unwrap();
1905        let future = format!(
1906            "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
1907            STORE_SCHEMA_VERSION + 1
1908        );
1909        std::fs::write(store_path(root), future).unwrap();
1910        let store = load(root);
1911        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
1912        assert!(
1913            store.enabled,
1914            "future-version store must not degrade to default"
1915        );
1916    }
1917
1918    #[test]
1919    fn removed_finding_is_credited_as_resolved() {
1920        let dir = tempfile::tempdir().unwrap();
1921        let root = dir.path();
1922        enable(root);
1923        let a = touch(root, "src/a.ts");
1924        run(
1925            root,
1926            &[&a],
1927            vec![fi(&a, "unused-export", "foo")],
1928            vec![],
1929            &[],
1930            "t0",
1931        );
1932        assert_eq!(
1933            load(root).resolved_total,
1934            0,
1935            "first run only establishes a baseline"
1936        );
1937        run(root, &[&a], vec![], vec![], &[], "t1");
1938        let store = load(root);
1939        assert_eq!(store.resolved_total, 1);
1940        assert_eq!(store.suppressed_total, 0);
1941        assert_eq!(store.recent_resolved.len(), 1);
1942        assert_eq!(store.recent_resolved[0].kind, "unused-export");
1943        assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
1944        assert_eq!(store.recent_resolved[0].path, "src/a.ts");
1945    }
1946
1947    #[test]
1948    fn suppressed_finding_is_not_a_win() {
1949        let dir = tempfile::tempdir().unwrap();
1950        let root = dir.path();
1951        enable(root);
1952        let a = touch(root, "src/a.ts");
1953        run(
1954            root,
1955            &[&a],
1956            vec![fi(&a, "unused-export", "foo")],
1957            vec![],
1958            &[],
1959            "t0",
1960        );
1961        run(
1962            root,
1963            &[&a],
1964            vec![],
1965            vec![],
1966            &[supp(&a, "unused-export")],
1967            "t1",
1968        );
1969        let store = load(root);
1970        assert_eq!(
1971            store.resolved_total, 0,
1972            "a suppression must never count as a win"
1973        );
1974        assert_eq!(store.suppressed_total, 1);
1975    }
1976
1977    #[test]
1978    fn fix_and_suppress_same_kind_credits_zero_resolved() {
1979        let dir = tempfile::tempdir().unwrap();
1980        let root = dir.path();
1981        enable(root);
1982        let a = touch(root, "src/a.ts");
1983        run(
1984            root,
1985            &[&a],
1986            vec![
1987                fi(&a, "unused-export", "foo"),
1988                fi(&a, "unused-export", "bar"),
1989            ],
1990            vec![],
1991            &[],
1992            "t0",
1993        );
1994        run(
1995            root,
1996            &[&a],
1997            vec![],
1998            vec![],
1999            &[supp(&a, "unused-export")],
2000            "t1",
2001        );
2002        let store = load(root);
2003        assert_eq!(store.resolved_total, 0);
2004        assert_eq!(store.suppressed_total, 2);
2005    }
2006
2007    #[test]
2008    fn within_file_move_is_not_resolved() {
2009        let dir = tempfile::tempdir().unwrap();
2010        let root = dir.path();
2011        enable(root);
2012        let a = touch(root, "src/a.ts");
2013        run(
2014            root,
2015            &[&a],
2016            vec![fi(&a, "unused-export", "foo")],
2017            vec![],
2018            &[],
2019            "t0",
2020        );
2021        run(
2022            root,
2023            &[&a],
2024            vec![fi(&a, "unused-export", "foo")],
2025            vec![],
2026            &[],
2027            "t1",
2028        );
2029        let store = load(root);
2030        assert_eq!(store.resolved_total, 0);
2031        assert_eq!(store.suppressed_total, 0);
2032    }
2033
2034    #[test]
2035    fn cross_file_move_in_same_run_is_not_resolved() {
2036        let dir = tempfile::tempdir().unwrap();
2037        let root = dir.path();
2038        enable(root);
2039        let a = touch(root, "src/a.ts");
2040        let b = touch(root, "src/b.ts");
2041        run(
2042            root,
2043            &[&a],
2044            vec![fi(&a, "unused-export", "foo")],
2045            vec![],
2046            &[],
2047            "t0",
2048        );
2049        run(
2050            root,
2051            &[&a, &b],
2052            vec![fi(&b, "unused-export", "foo")],
2053            vec![],
2054            &[],
2055            "t1",
2056        );
2057        assert_eq!(
2058            load(root).resolved_total,
2059            0,
2060            "a cross-file move is not a resolution"
2061        );
2062    }
2063
2064    #[test]
2065    fn cross_run_move_uncredits_the_prior_resolution() {
2066        let dir = tempfile::tempdir().unwrap();
2067        let root = dir.path();
2068        enable(root);
2069        let a = touch(root, "src/a.ts");
2070        let b = touch(root, "src/b.ts");
2071        run(
2072            root,
2073            &[&a],
2074            vec![fi(&a, "unused-export", "foo")],
2075            vec![],
2076            &[],
2077            "t0",
2078        );
2079        run(root, &[&a], vec![], vec![], &[], "t1");
2080        assert_eq!(
2081            load(root).resolved_total,
2082            1,
2083            "source disappearance credited in run A"
2084        );
2085        run(
2086            root,
2087            &[&b],
2088            vec![fi(&b, "unused-export", "foo")],
2089            vec![],
2090            &[],
2091            "t2",
2092        );
2093        let store = load(root);
2094        assert_eq!(
2095            store.resolved_total, 0,
2096            "cross-run move must un-credit the phantom win"
2097        );
2098        assert!(
2099            store.recent_resolved.is_empty(),
2100            "the stale resolution event is dropped"
2101        );
2102    }
2103
2104    #[test]
2105    fn resolved_complexity_finding_and_suppressed_complexity() {
2106        let dir = tempfile::tempdir().unwrap();
2107        let root = dir.path();
2108        enable(root);
2109        let a = touch(root, "src/a.ts");
2110        run(
2111            root,
2112            &[&a],
2113            vec![fi(&a, "complexity", "bigFn")],
2114            vec![],
2115            &[],
2116            "t0",
2117        );
2118        run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
2119        let store = load(root);
2120        assert_eq!(store.resolved_total, 0);
2121        assert_eq!(store.suppressed_total, 1);
2122
2123        let b = touch(root, "src/b.ts");
2124        run(
2125            root,
2126            &[&b],
2127            vec![fi(&b, "complexity", "huge")],
2128            vec![],
2129            &[],
2130            "t2",
2131        );
2132        run(root, &[&b], vec![], vec![], &[], "t3");
2133        assert_eq!(load(root).resolved_total, 1);
2134    }
2135
2136    #[test]
2137    fn resolved_duplication_clone_group() {
2138        let dir = tempfile::tempdir().unwrap();
2139        let root = dir.path();
2140        enable(root);
2141        let a = touch(root, "src/a.ts");
2142        let b = touch(root, "src/b.ts");
2143        let clone = CloneInput {
2144            fingerprint: "dup:abc12345".to_owned(),
2145            instance_paths: vec![a.clone(), b],
2146        };
2147        run(root, &[&a], vec![], vec![clone], &[], "t0");
2148        run(root, &[&a], vec![], vec![], &[], "t1");
2149        let store = load(root);
2150        assert_eq!(store.resolved_total, 1);
2151        assert_eq!(store.recent_resolved[0].kind, "code-duplication");
2152    }
2153
2154    #[test]
2155    fn blanket_suppression_covers_any_kind() {
2156        let dir = tempfile::tempdir().unwrap();
2157        let root = dir.path();
2158        enable(root);
2159        let a = touch(root, "src/a.ts");
2160        run(
2161            root,
2162            &[&a],
2163            vec![fi(&a, "unused-export", "foo")],
2164            vec![],
2165            &[],
2166            "t0",
2167        );
2168        let blanket = ActiveSuppression {
2169            path: a.clone(),
2170            kind: None,
2171            is_file_level: true,
2172        };
2173        run(root, &[&a], vec![], vec![], &[blanket], "t1");
2174        let store = load(root);
2175        assert_eq!(store.resolved_total, 0);
2176        assert_eq!(store.suppressed_total, 1);
2177    }
2178
2179    #[test]
2180    fn v1_store_loads_and_upgrades_to_v2() {
2181        let dir = tempfile::tempdir().unwrap();
2182        let root = dir.path();
2183        std::fs::create_dir_all(root.join(".fallow")).unwrap();
2184        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":[]}"#;
2185        std::fs::write(store_path(root), v1).unwrap();
2186        let store = load(root);
2187        assert_eq!(store.schema_version, 1);
2188        assert!(store.frontier.is_empty());
2189        assert_eq!(store.resolved_total, 0);
2190        let a = touch(root, "src/a.ts");
2191        run(
2192            root,
2193            &[&a],
2194            vec![fi(&a, "unused-export", "foo")],
2195            vec![],
2196            &[],
2197            "t1",
2198        );
2199        let store = load(root);
2200        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2201        assert!(store.frontier.contains_key("src/a.ts"));
2202    }
2203
2204    #[test]
2205    fn recent_resolved_is_bounded() {
2206        let mut store = ImpactStore {
2207            enabled: true,
2208            ..Default::default()
2209        };
2210        for i in 0..(MAX_RECENT_RESOLVED + 25) {
2211            store.recent_resolved.push(ResolutionEvent {
2212                kind: "unused-export".into(),
2213                path: format!("src/f{i}.ts"),
2214                symbol: Some(format!("s{i}")),
2215                git_sha: None,
2216                timestamp: format!("t{i}"),
2217            });
2218        }
2219        bound_recent_resolved(&mut store);
2220        assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
2221        assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
2222    }
2223
2224    #[test]
2225    fn frontier_prunes_deleted_files() {
2226        let dir = tempfile::tempdir().unwrap();
2227        let root = dir.path();
2228        enable(root);
2229        let a = touch(root, "src/a.ts");
2230        run(
2231            root,
2232            &[&a],
2233            vec![fi(&a, "unused-export", "foo")],
2234            vec![],
2235            &[],
2236            "t0",
2237        );
2238        assert!(load(root).frontier.contains_key("src/a.ts"));
2239        std::fs::remove_file(&a).unwrap();
2240        let b = touch(root, "src/b.ts");
2241        run(root, &[&b], vec![], vec![], &[], "t1");
2242        assert!(!load(root).frontier.contains_key("src/a.ts"));
2243    }
2244
2245    #[test]
2246    fn honest_empty_state_before_attribution_baseline() {
2247        let store = ImpactStore {
2248            enabled: true,
2249            records: vec![ImpactRecord {
2250                timestamp: "t0".into(),
2251                version: "2.0.0".into(),
2252                git_sha: None,
2253                verdict: "warn".into(),
2254                gate: false,
2255                counts: ImpactCounts::default(),
2256            }],
2257            ..Default::default()
2258        };
2259        let report = build_report(&store);
2260        assert!(!report.attribution_active);
2261        let human = render_human(&report);
2262        assert!(human.contains("resolution tracking starts from your next gate run"));
2263        assert!(!human.contains("0 finding"));
2264    }
2265
2266    #[test]
2267    fn suppression_only_state_renders_under_a_resolved_header() {
2268        let report = ImpactReport {
2269            schema_version: ImpactReportSchemaVersion::V1,
2270            enabled: true,
2271            record_count: 2,
2272            meta: None,
2273            first_recorded: Some("2026-05-29T10:00:00Z".into()),
2274            latest_git_sha: None,
2275            surfacing: Some(ImpactCounts::default()),
2276            trend: None,
2277            project_surfacing: None,
2278            project_trend: None,
2279            containment_count: 0,
2280            recent_containment: vec![],
2281            resolved_total: 0,
2282            suppressed_total: 2,
2283            recent_resolved: vec![],
2284            attribution_active: true,
2285            onboarding_declined: false,
2286            explicit_decision: false,
2287        };
2288        let human = render_human(&report);
2289        let resolved_idx = human.find("  RESOLVED").expect("RESOLVED header present");
2290        let supp_idx = human
2291            .find("2 findings you marked intentional")
2292            .expect("suppression line present");
2293        assert!(
2294            resolved_idx < supp_idx,
2295            "suppression must render under RESOLVED"
2296        );
2297        assert!(human.contains("none yet"));
2298
2299        let md = render_markdown(&report);
2300        assert!(
2301            md.contains("- **Resolved:**"),
2302            "markdown always has a Resolved bullet"
2303        );
2304        assert!(md.contains("- **Marked intentional:** 2 finding"));
2305    }
2306
2307    /// Build a `CloneInput` over real absolute paths (built from `root`).
2308    fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
2309        CloneInput {
2310            fingerprint: fingerprint.to_owned(),
2311            instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
2312        }
2313    }
2314
2315    /// Record a WHOLE-PROJECT run via the real combined-track recorder
2316    /// (`record_combined_run` with `Scope::WholeProject`), exercising the same
2317    /// path `combined.rs` uses on a full `fallow` run.
2318    fn run_wp(
2319        root: &Path,
2320        findings: Vec<FindingInput>,
2321        clones: Vec<CloneInput>,
2322        supps: &[ActiveSuppression],
2323        ts: &str,
2324    ) {
2325        let input = AttributionInput {
2326            root,
2327            scope: Scope::WholeProject,
2328            findings,
2329            clones,
2330            suppressions: supps,
2331        };
2332        record_combined_run(
2333            root,
2334            ImpactCounts::default(),
2335            Some("sha"),
2336            "2.0.0",
2337            ts,
2338            Some(&input),
2339        );
2340    }
2341
2342    #[test]
2343    fn whole_project_run_does_not_double_credit_after_audit() {
2344        let dir = tempfile::tempdir().unwrap();
2345        let root = dir.path();
2346        enable(root);
2347        let a = touch(root, "src/a.ts");
2348        let b = touch(root, "src/b.ts");
2349        run(
2350            root,
2351            &[&a, &b],
2352            vec![],
2353            vec![clone_at("dup:abc", &[&a, &b])],
2354            &[],
2355            "t1",
2356        );
2357        assert_eq!(load(root).clone_frontier.len(), 1);
2358
2359        run(root, &[&a, &b], vec![], vec![], &[], "t2");
2360        assert_eq!(load(root).resolved_total, 1);
2361        assert!(load(root).clone_frontier.is_empty());
2362
2363        run_wp(root, vec![], vec![], &[], "t3");
2364        assert_eq!(
2365            load(root).resolved_total,
2366            1,
2367            "whole-project run re-credited a resolution"
2368        );
2369    }
2370
2371    #[test]
2372    fn whole_project_run_credits_suppressed_not_resolved() {
2373        let dir = tempfile::tempdir().unwrap();
2374        let root = dir.path();
2375        enable(root);
2376        let util = touch(root, "src/util.ts");
2377        run(
2378            root,
2379            &[&util],
2380            vec![fi(&util, "unused-export", "dead")],
2381            vec![],
2382            &[],
2383            "t1",
2384        );
2385        assert_eq!(load(root).frontier.len(), 1);
2386
2387        run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
2388        let store = load(root);
2389        assert_eq!(
2390            store.suppressed_total, 1,
2391            "suppressed finding not counted suppressed"
2392        );
2393        assert_eq!(
2394            store.resolved_total, 0,
2395            "suppressed finding wrongly counted resolved"
2396        );
2397    }
2398
2399    #[test]
2400    fn clone_reshape_three_to_two_not_credited_as_resolved() {
2401        let dir = tempfile::tempdir().unwrap();
2402        let root = dir.path();
2403        enable(root);
2404        let a = touch(root, "src/a.ts");
2405        let b = touch(root, "src/b.ts");
2406        let c = touch(root, "src/c.ts");
2407        run(
2408            root,
2409            &[&a, &b, &c],
2410            vec![],
2411            vec![clone_at("dup:aaa", &[&a, &b, &c])],
2412            &[],
2413            "t1",
2414        );
2415        assert_eq!(load(root).clone_frontier.len(), 1);
2416
2417        run_wp(
2418            root,
2419            vec![],
2420            vec![clone_at("dup:bbb", &[&a, &b])],
2421            &[],
2422            "t2",
2423        );
2424        let store = load(root);
2425        assert_eq!(
2426            store.resolved_total, 0,
2427            "clone reshape miscredited as resolved"
2428        );
2429        assert!(store.clone_frontier.contains_key("dup:bbb"));
2430        assert!(!store.clone_frontier.contains_key("dup:aaa"));
2431    }
2432
2433    fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
2434        ImpactCounts {
2435            total_issues: total,
2436            dead_code: dead,
2437            complexity,
2438            duplication: dup,
2439        }
2440    }
2441
2442    fn rtrend(prev: usize, cur: usize) -> TrendSummary {
2443        TrendSummary {
2444            direction: direction_for(cur as i64 - prev as i64),
2445            total_delta: cur as i64 - prev as i64,
2446            previous_total: prev,
2447            current_total: cur,
2448        }
2449    }
2450
2451    /// Build a report literal for render-state tests.
2452    fn rreport(
2453        record_count: usize,
2454        first_recorded: Option<&str>,
2455        surfacing: Option<ImpactCounts>,
2456        trend: Option<TrendSummary>,
2457        project_surfacing: Option<ImpactCounts>,
2458        project_trend: Option<TrendSummary>,
2459        attribution_active: bool,
2460    ) -> ImpactReport {
2461        ImpactReport {
2462            schema_version: ImpactReportSchemaVersion::V1,
2463            enabled: true,
2464            record_count,
2465            meta: None,
2466            first_recorded: first_recorded.map(ToOwned::to_owned),
2467            latest_git_sha: None,
2468            surfacing,
2469            trend,
2470            project_surfacing,
2471            project_trend,
2472            containment_count: 0,
2473            recent_containment: vec![],
2474            resolved_total: 0,
2475            suppressed_total: 0,
2476            recent_resolved: vec![],
2477            attribution_active,
2478            onboarding_declined: false,
2479            explicit_decision: false,
2480        }
2481    }
2482
2483    #[test]
2484    fn render_human_project_only_store_shows_whole_project_not_empty_state() {
2485        let r = rreport(
2486            0,
2487            Some("2026-05-30T10:00:00Z"),
2488            None,
2489            None,
2490            Some(rcounts(1, 1, 0, 0)),
2491            None,
2492            true,
2493        );
2494        let human = render_human(&r);
2495        assert!(
2496            human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
2497            "project-only must render the labeled section"
2498        );
2499        assert!(human.contains("1 issue across the whole project"));
2500        assert!(
2501            human.contains("project trend starts after your next full `fallow` run"),
2502            "single project record => no trend line, shows the next-run hint"
2503        );
2504        assert!(human.contains("Tracking since 2026-05-30"));
2505        assert!(
2506            !human.contains("No history yet"),
2507            "must not show the empty-state copy"
2508        );
2509        assert!(
2510            !human.contains("LATEST RUN"),
2511            "no changed-file track recorded"
2512        );
2513        assert!(
2514            !human.contains("recorded audit run"),
2515            "no audit runs => no changed-file footer"
2516        );
2517    }
2518
2519    #[test]
2520    fn render_human_both_tracks_label_actionable_vs_context() {
2521        let r = rreport(
2522            3,
2523            Some("2026-05-29T10:00:00Z"),
2524            Some(rcounts(4, 4, 0, 0)),
2525            Some(rtrend(6, 4)),
2526            Some(rcounts(40, 30, 5, 5)),
2527            Some(rtrend(45, 40)),
2528            true,
2529        );
2530        let human = render_human(&r);
2531        let latest = human
2532            .find("LATEST RUN (changed files, act on these now)")
2533            .expect("LATEST RUN labeled actionable");
2534        let whole = human
2535            .find("WHOLE PROJECT (whole-repo context, not a to-do)")
2536            .expect("WHOLE PROJECT labeled context");
2537        assert!(
2538            latest < whole,
2539            "changed-file section renders before whole-project"
2540        );
2541        assert!(human.contains("45 -> 40 (down) across your last two full runs"));
2542        assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
2543    }
2544
2545    #[test]
2546    fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
2547        let r = rreport(
2548            0,
2549            Some("2026-05-30T10:00:00Z"),
2550            None,
2551            None,
2552            Some(rcounts(1, 1, 0, 0)),
2553            None,
2554            true,
2555        );
2556        let md = render_markdown(&r);
2557        assert!(
2558            md.contains(
2559                "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
2560            ),
2561            "project-only md must render the labeled whole-project line"
2562        );
2563        assert!(
2564            !md.contains("No history yet"),
2565            "project-only md must not show empty state"
2566        );
2567        assert!(md.contains("Tracking since 2026-05-30"));
2568    }
2569}