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