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::results::{ActiveSuppression, AnalysisResults};
6use rustc_hash::{FxHashMap, FxHashSet};
7use serde::{Deserialize, Serialize};
8
9use crate::audit::{AuditSummary, AuditVerdict};
10use crate::report::ci::fingerprint::fingerprint_hash;
11use crate::report::format_display_path;
12
13const STORE_SCHEMA_VERSION: u32 = 2;
14
15const MAX_RECORDS: usize = 200;
16
17const MAX_CONTAINMENT: usize = 200;
18
19const TREND_TOLERANCE: i64 = 0;
20
21const STORE_FILE: &str = "impact.json";
22
23const MAX_RECENT_RESOLVED: usize = 50;
24
25const ID_SEP: &str = "\u{1f}";
26
27const CODE_DUPLICATION_KIND: &str = "code-duplication";
28
29const BLANKET_SUPPRESSION: &str = "*";
30
31/// Per-category issue counts captured at a recorded run.
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
33#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
34pub struct ImpactCounts {
35    pub total_issues: usize,
36    pub dead_code: usize,
37    pub complexity: usize,
38    pub duplication: usize,
39}
40
41impl ImpactCounts {
42    fn from_summary(summary: &AuditSummary) -> Self {
43        Self {
44            total_issues: summary.dead_code_issues
45                + summary.complexity_findings
46                + summary.duplication_clone_groups,
47            dead_code: summary.dead_code_issues,
48            complexity: summary.complexity_findings,
49            duplication: summary.duplication_clone_groups,
50        }
51    }
52
53    pub(crate) fn from_combined(dead_code: usize, complexity: usize, duplication: usize) -> Self {
54        Self {
55            total_issues: dead_code + complexity + duplication,
56            dead_code,
57            complexity,
58            duplication,
59        }
60    }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ImpactRecord {
65    pub timestamp: String,
66    pub version: String,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub git_sha: Option<String>,
69    pub verdict: String,
70    #[serde(default)]
71    pub gate: bool,
72    pub counts: ImpactCounts,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct PendingContainment {
77    pub blocked_at: String,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub git_sha: Option<String>,
80    pub blocked_counts: ImpactCounts,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
85pub struct ContainmentEvent {
86    pub blocked_at: String,
87    pub cleared_at: String,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub git_sha: Option<String>,
90    pub blocked_counts: ImpactCounts,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct FrontierFinding {
95    pub id: String,
96    pub kind: String,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub symbol: Option<String>,
99}
100
101impl FrontierFinding {
102    fn move_key(&self) -> String {
103        match &self.symbol {
104            Some(symbol) => format!("{}{ID_SEP}{symbol}", self.kind),
105            None => self.id.clone(),
106        }
107    }
108}
109
110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
111pub struct FileFrontier {
112    #[serde(default)]
113    pub findings: Vec<FrontierFinding>,
114    #[serde(default)]
115    pub suppressions: Vec<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120pub struct ResolutionEvent {
121    pub kind: String,
122    pub path: String,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub symbol: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub git_sha: Option<String>,
127    pub timestamp: String,
128}
129
130#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131pub struct ImpactStore {
132    #[serde(default)]
133    pub schema_version: u32,
134    #[serde(default)]
135    pub enabled: bool,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub first_recorded: Option<String>,
138    #[serde(default)]
139    pub records: Vec<ImpactRecord>,
140    #[serde(default)]
141    pub project_records: Vec<ImpactRecord>,
142    #[serde(default)]
143    pub containment: Vec<ContainmentEvent>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub pending_containment: Option<PendingContainment>,
146    #[serde(default)]
147    pub frontier: FxHashMap<String, FileFrontier>,
148    #[serde(default)]
149    pub clone_frontier: FxHashMap<String, Vec<String>>,
150    #[serde(default)]
151    pub resolved_total: usize,
152    #[serde(default)]
153    pub suppressed_total: usize,
154    #[serde(default)]
155    pub recent_resolved: Vec<ResolutionEvent>,
156}
157
158fn store_path(root: &Path) -> PathBuf {
159    root.join(".fallow").join(STORE_FILE)
160}
161
162/// Load the store. Missing or unreadable files fall back to defaults; unreadable
163/// files are warned about rather than silently disabling tracking.
164pub fn load(root: &Path) -> ImpactStore {
165    let path = store_path(root);
166    let Ok(content) = std::fs::read_to_string(&path) else {
167        return ImpactStore::default();
168    };
169    match serde_json::from_str::<ImpactStore>(&content) {
170        Ok(store) => {
171            if store.schema_version > STORE_SCHEMA_VERSION {
172                tracing::warn!(
173                    "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.",
174                    path.display(),
175                    store.schema_version,
176                    STORE_SCHEMA_VERSION,
177                );
178            }
179            store
180        }
181        Err(err) => {
182            tracing::warn!(
183                "fallow impact: ignoring unreadable store at {} ({err}); run `fallow impact enable` to reset it",
184                path.display()
185            );
186            ImpactStore::default()
187        }
188    }
189}
190
191/// Persist the store best-effort using atomic replace.
192fn save(store: &ImpactStore, root: &Path) {
193    let path = store_path(root);
194    if let Some(parent) = path.parent()
195        && std::fs::create_dir_all(parent).is_err()
196    {
197        return;
198    }
199    if let Ok(json) = serde_json::to_string_pretty(store) {
200        let _ = fallow_config::atomic_write(&path, json.as_bytes());
201    }
202}
203
204/// Enable Impact tracking and ensure `.fallow/` is gitignored.
205pub fn enable(root: &Path) -> bool {
206    let mut store = load(root);
207    let was_enabled = store.enabled;
208    store.enabled = true;
209    if store.schema_version == 0 {
210        store.schema_version = STORE_SCHEMA_VERSION;
211    }
212    save(&store, root);
213    ensure_fallow_gitignored(root);
214    !was_enabled
215}
216
217/// Append `.fallow/` to `.gitignore` if needed.
218fn ensure_fallow_gitignored(root: &Path) {
219    let path = root.join(".gitignore");
220    let existing = std::fs::read_to_string(&path).unwrap_or_default();
221    let already = existing
222        .lines()
223        .any(|line| matches!(line.trim(), ".fallow" | ".fallow/"));
224    if already {
225        return;
226    }
227    let mut contents = existing;
228    if !contents.is_empty() && !contents.ends_with('\n') {
229        contents.push('\n');
230    }
231    contents.push_str(".fallow/\n");
232    let _ = fallow_config::atomic_write(&path, contents.as_bytes());
233}
234
235/// Disable Impact tracking. Retains existing history. Returns whether it was
236/// newly disabled (false if already off).
237pub fn disable(root: &Path) -> bool {
238    let mut store = load(root);
239    let was_enabled = store.enabled;
240    store.enabled = false;
241    save(&store, root);
242    was_enabled
243}
244
245/// Record an audit run into the rolling store.
246#[expect(
247    clippy::too_many_arguments,
248    reason = "best-effort recorder threading the v1 record fields plus the v1.5 attribution input; a params struct would not improve the single call site"
249)]
250pub fn record_audit_run(
251    root: &Path,
252    summary: &AuditSummary,
253    verdict: AuditVerdict,
254    gate: bool,
255    git_sha: Option<&str>,
256    version: &str,
257    timestamp: &str,
258    attribution: Option<&AttributionInput<'_>>,
259) {
260    let mut store = load(root);
261    if !store.enabled {
262        return;
263    }
264    store.schema_version = STORE_SCHEMA_VERSION;
265
266    let counts = ImpactCounts::from_summary(summary);
267    let verdict_str = verdict_label(verdict);
268
269    if store.first_recorded.is_none() {
270        store.first_recorded = Some(timestamp.to_owned());
271    }
272
273    apply_containment(&mut store, verdict, gate, git_sha, timestamp, &counts);
274
275    store.records.push(ImpactRecord {
276        timestamp: timestamp.to_owned(),
277        version: version.to_owned(),
278        git_sha: git_sha.map(ToOwned::to_owned),
279        verdict: verdict_str.to_owned(),
280        gate,
281        counts,
282    });
283    compact(&mut store);
284
285    if let Some(attribution) = attribution {
286        apply_attribution(&mut store, attribution, git_sha, timestamp);
287    }
288
289    save(&store, root);
290}
291
292/// Record a whole-project combined run into the project track.
293pub fn record_combined_run(
294    root: &Path,
295    counts: ImpactCounts,
296    git_sha: Option<&str>,
297    version: &str,
298    timestamp: &str,
299    attribution: Option<&AttributionInput<'_>>,
300) {
301    let mut store = load(root);
302    if !store.enabled {
303        return;
304    }
305    store.schema_version = STORE_SCHEMA_VERSION;
306
307    if store.first_recorded.is_none() {
308        store.first_recorded = Some(timestamp.to_owned());
309    }
310
311    let verdict_str = if counts.total_issues == 0 {
312        "pass"
313    } else {
314        "warn"
315    };
316    store.project_records.push(ImpactRecord {
317        timestamp: timestamp.to_owned(),
318        version: version.to_owned(),
319        git_sha: git_sha.map(ToOwned::to_owned),
320        verdict: verdict_str.to_owned(),
321        gate: false,
322        counts,
323    });
324    if store.project_records.len() > MAX_RECORDS {
325        let overflow = store.project_records.len() - MAX_RECORDS;
326        store.project_records.drain(0..overflow);
327    }
328
329    if let Some(attribution) = attribution {
330        apply_attribution(&mut store, attribution, git_sha, timestamp);
331    }
332
333    save(&store, root);
334}
335
336/// Update pending/contained state from a gate run's verdict.
337fn apply_containment(
338    store: &mut ImpactStore,
339    verdict: AuditVerdict,
340    gate: bool,
341    git_sha: Option<&str>,
342    timestamp: &str,
343    counts: &ImpactCounts,
344) {
345    if !gate {
346        return;
347    }
348    if verdict == AuditVerdict::Fail {
349        if store.pending_containment.is_none() {
350            store.pending_containment = Some(PendingContainment {
351                blocked_at: timestamp.to_owned(),
352                git_sha: git_sha.map(ToOwned::to_owned),
353                blocked_counts: counts.clone(),
354            });
355        }
356    } else if let Some(pending) = store.pending_containment.take() {
357        store.containment.push(ContainmentEvent {
358            blocked_at: pending.blocked_at,
359            cleared_at: timestamp.to_owned(),
360            git_sha: pending.git_sha,
361            blocked_counts: pending.blocked_counts,
362        });
363        if store.containment.len() > MAX_CONTAINMENT {
364            let overflow = store.containment.len() - MAX_CONTAINMENT;
365            store.containment.drain(0..overflow);
366        }
367    }
368}
369
370fn compact(store: &mut ImpactStore) {
371    if store.records.len() > MAX_RECORDS {
372        let overflow = store.records.len() - MAX_RECORDS;
373        store.records.drain(0..overflow);
374    }
375}
376
377#[derive(Debug, Clone)]
378pub struct FindingInput {
379    pub path: PathBuf,
380    pub kind: &'static str,
381    pub symbol: Option<String>,
382}
383
384#[derive(Debug, Clone)]
385pub struct CloneInput {
386    pub fingerprint: String,
387    pub instance_paths: Vec<PathBuf>,
388}
389
390pub enum Scope<'a> {
391    ChangedFiles(&'a [PathBuf]),
392    WholeProject,
393}
394
395pub struct AttributionInput<'a> {
396    pub root: &'a Path,
397    pub scope: Scope<'a>,
398    pub findings: Vec<FindingInput>,
399    pub clones: Vec<CloneInput>,
400    pub suppressions: &'a [ActiveSuppression],
401}
402
403fn finding_id(kind: &str, rel_path: &str, symbol: Option<&str>) -> String {
404    fingerprint_hash(&[kind, rel_path, symbol.unwrap_or("")])
405}
406
407fn covered_by(present: &FxHashSet<String>, kind: &str) -> bool {
408    present.contains(BLANKET_SUPPRESSION) || present.contains(kind)
409}
410
411fn apply_attribution(
412    store: &mut ImpactStore,
413    input: &AttributionInput<'_>,
414    git_sha: Option<&str>,
415    timestamp: &str,
416) {
417    let root = input.root;
418    let changed: FxHashSet<String> = match input.scope {
419        Scope::ChangedFiles(files) => files.iter().map(|p| format_display_path(p, root)).collect(),
420        Scope::WholeProject => whole_project_scope(store, input, root),
421    };
422
423    let mut current_findings: FxHashMap<String, Vec<FrontierFinding>> = FxHashMap::default();
424    for f in &input.findings {
425        let rel = format_display_path(&f.path, root);
426        if !changed.contains(&rel) {
427            continue;
428        }
429        let id = finding_id(f.kind, &rel, f.symbol.as_deref());
430        current_findings
431            .entry(rel)
432            .or_default()
433            .push(FrontierFinding {
434                id,
435                kind: f.kind.to_owned(),
436                symbol: f.symbol.clone(),
437            });
438    }
439    let mut current_supps: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
440    for s in input.suppressions {
441        let rel = format_display_path(&s.path, root);
442        if !changed.contains(&rel) {
443            continue;
444        }
445        let key = s
446            .kind
447            .clone()
448            .unwrap_or_else(|| BLANKET_SUPPRESSION.to_owned());
449        current_supps.entry(rel).or_default().insert(key);
450    }
451
452    let mut appeared_move_keys: FxHashSet<String> = FxHashSet::default();
453    for (rel, findings) in &current_findings {
454        let prior_ids: FxHashSet<&str> = store
455            .frontier
456            .get(rel)
457            .map(|f| f.findings.iter().map(|x| x.id.as_str()).collect())
458            .unwrap_or_default();
459        for ff in findings {
460            if !prior_ids.contains(ff.id.as_str()) {
461                appeared_move_keys.insert(ff.move_key());
462            }
463        }
464    }
465
466    uncredit_cross_run_moves(store, &appeared_move_keys);
467
468    classify_file_disappearances(
469        store,
470        &changed,
471        &current_findings,
472        &current_supps,
473        &appeared_move_keys,
474        git_sha,
475        timestamp,
476    );
477    update_file_frontier(store, &changed, current_findings, current_supps);
478    classify_clone_disappearances(store, input, &changed, git_sha, timestamp);
479    prune_frontier(store, root);
480    bound_recent_resolved(store);
481}
482
483fn whole_project_scope(
484    store: &ImpactStore,
485    input: &AttributionInput<'_>,
486    root: &Path,
487) -> FxHashSet<String> {
488    let mut set: FxHashSet<String> = store.frontier.keys().cloned().collect();
489    for paths in store.clone_frontier.values() {
490        for p in paths {
491            set.insert(p.clone());
492        }
493    }
494    for f in &input.findings {
495        set.insert(format_display_path(&f.path, root));
496    }
497    for c in &input.clones {
498        for p in &c.instance_paths {
499            set.insert(format_display_path(p, root));
500        }
501    }
502    set
503}
504
505fn classify_file_disappearances(
506    store: &mut ImpactStore,
507    changed: &FxHashSet<String>,
508    current_findings: &FxHashMap<String, Vec<FrontierFinding>>,
509    current_supps: &FxHashMap<String, FxHashSet<String>>,
510    appeared_move_keys: &FxHashSet<String>,
511    git_sha: Option<&str>,
512    timestamp: &str,
513) {
514    let empty_supps = FxHashSet::default();
515    for rel in changed {
516        let Some(prior) = store.frontier.get(rel) else {
517            continue;
518        };
519        let now_ids: FxHashSet<&str> = current_findings
520            .get(rel)
521            .map(|fs| fs.iter().map(|f| f.id.as_str()).collect())
522            .unwrap_or_default();
523        let now_supps = current_supps.get(rel).unwrap_or(&empty_supps);
524        let prior_supps: FxHashSet<&str> = prior.suppressions.iter().map(String::as_str).collect();
525        let new_supp_kinds: FxHashSet<String> = now_supps
526            .iter()
527            .filter(|k| !prior_supps.contains(k.as_str()))
528            .cloned()
529            .collect();
530
531        let mut resolved = Vec::new();
532        let mut suppressed = 0usize;
533        for pf in &prior.findings {
534            if now_ids.contains(pf.id.as_str()) {
535                continue; // still present
536            }
537            if appeared_move_keys.contains(&pf.move_key()) {
538                continue; // moved to another file this run
539            }
540            if covered_by(&new_supp_kinds, &pf.kind) {
541                suppressed += 1; // conservative: a fresh fallow-ignore, never a win
542            } else {
543                resolved.push(pf.clone());
544            }
545        }
546        store.suppressed_total += suppressed;
547        for pf in resolved {
548            store.resolved_total += 1;
549            store.recent_resolved.push(ResolutionEvent {
550                kind: pf.kind,
551                path: rel.clone(),
552                symbol: pf.symbol,
553                git_sha: git_sha.map(ToOwned::to_owned),
554                timestamp: timestamp.to_owned(),
555            });
556        }
557    }
558}
559
560fn update_file_frontier(
561    store: &mut ImpactStore,
562    changed: &FxHashSet<String>,
563    mut current_findings: FxHashMap<String, Vec<FrontierFinding>>,
564    mut current_supps: FxHashMap<String, FxHashSet<String>>,
565) {
566    for rel in changed {
567        let findings = current_findings.remove(rel).unwrap_or_default();
568        let mut suppressions: Vec<String> = current_supps
569            .remove(rel)
570            .unwrap_or_default()
571            .into_iter()
572            .collect();
573        suppressions.sort_unstable();
574        if findings.is_empty() && suppressions.is_empty() {
575            store.frontier.remove(rel);
576        } else {
577            store.frontier.insert(
578                rel.clone(),
579                FileFrontier {
580                    findings,
581                    suppressions,
582                },
583            );
584        }
585    }
586}
587
588fn classify_clone_disappearances(
589    store: &mut ImpactStore,
590    input: &AttributionInput<'_>,
591    changed: &FxHashSet<String>,
592    git_sha: Option<&str>,
593    timestamp: &str,
594) {
595    let root = input.root;
596    let mut current: FxHashMap<String, Vec<String>> = FxHashMap::default();
597    for c in &input.clones {
598        let mut paths: Vec<String> = c
599            .instance_paths
600            .iter()
601            .map(|p| format_display_path(p, root))
602            .collect();
603        paths.sort_unstable();
604        paths.dedup();
605        if paths.iter().any(|p| changed.contains(p)) {
606            current.insert(c.fingerprint.clone(), paths);
607        }
608    }
609
610    let dup_suppressed = |paths: &[String]| -> bool {
611        paths.iter().any(|p| {
612            changed.contains(p)
613                && store.frontier.get(p).is_some_and(|f| {
614                    f.suppressions
615                        .iter()
616                        .any(|k| k == CODE_DUPLICATION_KIND || k == BLANKET_SUPPRESSION)
617                })
618        })
619    };
620
621    let still_duplicated: FxHashSet<&String> = current.values().flatten().collect();
622
623    let disappeared: Vec<(String, Vec<String>)> = store
624        .clone_frontier
625        .iter()
626        .filter(|(fp, paths)| {
627            paths.iter().any(|p| changed.contains(p)) && !current.contains_key(*fp)
628        })
629        .map(|(fp, paths)| (fp.clone(), paths.clone()))
630        .collect();
631
632    for (fp, paths) in disappeared {
633        store.clone_frontier.remove(&fp);
634        if paths.iter().any(|p| still_duplicated.contains(p)) {
635            continue;
636        }
637        if dup_suppressed(&paths) {
638            store.suppressed_total += 1;
639        } else {
640            store.resolved_total += 1;
641            let path = paths.first().cloned().unwrap_or_default();
642            store.recent_resolved.push(ResolutionEvent {
643                kind: CODE_DUPLICATION_KIND.to_owned(),
644                path,
645                symbol: None,
646                git_sha: git_sha.map(ToOwned::to_owned),
647                timestamp: timestamp.to_owned(),
648            });
649        }
650    }
651
652    for (fp, paths) in current {
653        store.clone_frontier.insert(fp, paths);
654    }
655}
656
657fn prune_frontier(store: &mut ImpactStore, root: &Path) {
658    store.frontier.retain(|rel, _| root.join(rel).exists());
659    store
660        .clone_frontier
661        .retain(|_, paths| paths.iter().any(|p| root.join(p).exists()));
662}
663
664fn bound_recent_resolved(store: &mut ImpactStore) {
665    if store.recent_resolved.len() > MAX_RECENT_RESOLVED {
666        let overflow = store.recent_resolved.len() - MAX_RECENT_RESOLVED;
667        store.recent_resolved.drain(0..overflow);
668    }
669}
670
671fn event_move_key(ev: &ResolutionEvent) -> Option<String> {
672    ev.symbol
673        .as_ref()
674        .map(|symbol| format!("{}{ID_SEP}{symbol}", ev.kind))
675}
676
677fn uncredit_cross_run_moves(store: &mut ImpactStore, appeared_move_keys: &FxHashSet<String>) {
678    if appeared_move_keys.is_empty() {
679        return;
680    }
681    let mut uncredited = 0usize;
682    store.recent_resolved.retain(|ev| match event_move_key(ev) {
683        Some(mk) if appeared_move_keys.contains(&mk) => {
684            uncredited += 1;
685            false
686        }
687        _ => true,
688    });
689    store.resolved_total = store.resolved_total.saturating_sub(uncredited);
690}
691
692#[must_use]
693pub fn collect_dead_code_findings(results: &AnalysisResults) -> Vec<FindingInput> {
694    let mut out = Vec::new();
695    let mut push = |path: &Path, kind: &'static str, symbol: Option<String>| {
696        out.push(FindingInput {
697            path: path.to_path_buf(),
698            kind,
699            symbol,
700        });
701    };
702    for f in &results.unused_files {
703        push(&f.file.path, "unused-file", None);
704    }
705    for f in &results.unused_exports {
706        push(
707            &f.export.path,
708            "unused-export",
709            Some(f.export.export_name.clone()),
710        );
711    }
712    for f in &results.unused_types {
713        push(
714            &f.export.path,
715            "unused-type",
716            Some(f.export.export_name.clone()),
717        );
718    }
719    for f in &results.private_type_leaks {
720        push(
721            &f.leak.path,
722            "private-type-leak",
723            Some(format!(
724                "{}{ID_SEP}{}",
725                f.leak.export_name, f.leak.type_name
726            )),
727        );
728    }
729    for f in &results.unused_enum_members {
730        push(
731            &f.member.path,
732            "unused-enum-member",
733            Some(format!(
734                "{}{ID_SEP}{}",
735                f.member.parent_name, f.member.member_name
736            )),
737        );
738    }
739    for f in &results.unused_class_members {
740        push(
741            &f.member.path,
742            "unused-class-member",
743            Some(format!(
744                "{}{ID_SEP}{}",
745                f.member.parent_name, f.member.member_name
746            )),
747        );
748    }
749    for f in &results.unresolved_imports {
750        push(
751            &f.import.path,
752            "unresolved-import",
753            Some(f.import.specifier.clone()),
754        );
755    }
756    for f in &results.boundary_violations {
757        let to_path = f.violation.to_path.to_string_lossy().replace('\\', "/");
758        push(
759            &f.violation.from_path,
760            "boundary-violation",
761            Some(format!("{to_path}{ID_SEP}{}", f.violation.import_specifier)),
762        );
763    }
764    for f in &results.unused_dependencies {
765        push(
766            &f.dep.path,
767            "unused-dependency",
768            Some(f.dep.package_name.clone()),
769        );
770    }
771    for f in &results.unused_dev_dependencies {
772        push(
773            &f.dep.path,
774            "unused-dev-dependency",
775            Some(f.dep.package_name.clone()),
776        );
777    }
778    for f in &results.unused_optional_dependencies {
779        push(
780            &f.dep.path,
781            "unused-optional-dependency",
782            Some(f.dep.package_name.clone()),
783        );
784    }
785    for f in &results.type_only_dependencies {
786        push(
787            &f.dep.path,
788            "type-only-dependency",
789            Some(f.dep.package_name.clone()),
790        );
791    }
792    for f in &results.test_only_dependencies {
793        push(
794            &f.dep.path,
795            "test-only-dependency",
796            Some(f.dep.package_name.clone()),
797        );
798    }
799    for f in &results.unused_catalog_entries {
800        push(
801            &f.entry.path,
802            "unused-catalog-entry",
803            Some(format!(
804                "{}{ID_SEP}{}",
805                f.entry.catalog_name, f.entry.entry_name
806            )),
807        );
808    }
809    for f in &results.empty_catalog_groups {
810        push(
811            &f.group.path,
812            "empty-catalog-group",
813            Some(f.group.catalog_name.clone()),
814        );
815    }
816    for f in &results.unresolved_catalog_references {
817        push(
818            &f.reference.path,
819            "unresolved-catalog-reference",
820            Some(format!(
821                "{}{ID_SEP}{}",
822                f.reference.catalog_name, f.reference.entry_name
823            )),
824        );
825    }
826    for f in &results.unused_dependency_overrides {
827        push(
828            &f.entry.path,
829            "unused-dependency-override",
830            Some(f.entry.raw_key.clone()),
831        );
832    }
833    for f in &results.misconfigured_dependency_overrides {
834        push(
835            &f.entry.path,
836            "misconfigured-dependency-override",
837            Some(f.entry.raw_key.clone()),
838        );
839    }
840    out
841}
842
843/// Collect line-independent complexity finding identities `(path, function name)`
844/// from a health report. The function name is line-independent, so a function
845/// moving within its file keeps the same identity.
846#[must_use]
847pub fn collect_complexity_findings(
848    report: &crate::health_types::HealthReport,
849) -> Vec<FindingInput> {
850    report
851        .findings
852        .iter()
853        .map(|f| FindingInput {
854            path: f.path.clone(),
855            kind: "complexity",
856            symbol: Some(f.name.clone()),
857        })
858        .collect()
859}
860
861/// Collect clone-group identities `(fingerprint, instance paths)` from a
862/// duplication report. The fingerprint is content-derived (`dup:<hash>`), so it
863/// is stable across pure relocation.
864#[must_use]
865pub fn collect_clone_findings(
866    report: &fallow_core::duplicates::DuplicationReport,
867) -> Vec<CloneInput> {
868    report
869        .clone_groups
870        .iter()
871        .map(|g| CloneInput {
872            fingerprint: fallow_core::duplicates::clone_fingerprint(&g.instances),
873            instance_paths: g.instances.iter().map(|i| i.file.clone()).collect(),
874        })
875        .collect()
876}
877
878const fn verdict_label(verdict: AuditVerdict) -> &'static str {
879    match verdict {
880        AuditVerdict::Pass => "pass",
881        AuditVerdict::Warn => "warn",
882        AuditVerdict::Fail => "fail",
883    }
884}
885
886/// Direction of a count trend between two recorded runs.
887#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
888#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
889#[serde(rename_all = "snake_case")]
890pub enum ImpactTrendDirection {
891    /// Issue count went down (good).
892    Improving,
893    /// Issue count went up.
894    Declining,
895    /// Within tolerance.
896    Stable,
897}
898
899/// A computed trend between the two most recent records.
900#[derive(Debug, Clone, Serialize)]
901#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
902pub struct TrendSummary {
903    pub direction: ImpactTrendDirection,
904    /// Signed delta in total issues (current minus previous).
905    pub total_delta: i64,
906    pub previous_total: usize,
907    pub current_total: usize,
908}
909
910fn direction_for(delta: i64) -> ImpactTrendDirection {
911    if delta < -TREND_TOLERANCE {
912        ImpactTrendDirection::Improving
913    } else if delta > TREND_TOLERANCE {
914        ImpactTrendDirection::Declining
915    } else {
916        ImpactTrendDirection::Stable
917    }
918}
919
920/// Wire-version discriminator for [`ImpactReport`]. Independent from the global
921/// `SchemaVersion` (the impact report versions on its own cadence) and from the
922/// on-disk `STORE_SCHEMA_VERSION` (the persisted store shape versions
923/// separately). Serializes as a string `const` so JSON consumers can switch on
924/// it, matching the other independently-versioned envelopes (e.g.
925/// `CoverageAnalyzeSchemaVersion`).
926#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
927#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
928pub enum ImpactReportSchemaVersion {
929    /// First release of the `fallow impact --format json` shape.
930    #[serde(rename = "1")]
931    V1,
932}
933
934/// The rendered impact report, derived purely from the store (no analysis run).
935#[derive(Debug, Clone, Serialize)]
936#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
937#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
938pub struct ImpactReport {
939    /// Output-shape version for this report, so JSON consumers have a
940    /// forward-compat signal independent of the on-disk store version. Always
941    /// present; bumped only on a breaking change to this report's wire shape.
942    pub schema_version: ImpactReportSchemaVersion,
943    pub enabled: bool,
944    pub record_count: usize,
945    #[serde(default, skip_serializing_if = "Option::is_none")]
946    pub first_recorded: Option<String>,
947    /// Git SHA of the most recent recorded run, so a consumer can tell which
948    /// commit the `surfacing` counts belong to. This is an ABBREVIATED SHA
949    /// (`git rev-parse --short`), so it is for display/correlation only and will
950    /// not match a full 40-character SHA from `$GITHUB_SHA` or the git API
951    /// without expansion. None when the latest run had no SHA (not a git repo)
952    /// or there are no records yet.
953    #[serde(default, skip_serializing_if = "Option::is_none")]
954    pub latest_git_sha: Option<String>,
955    /// Counts from the most recent recorded run. These are CHANGED-FILE scoped
956    /// (each record comes from a `fallow audit` run, whose default `new-only`
957    /// gate counts only findings in the changed files of that run), NOT a
958    /// whole-project total.
959    #[serde(default, skip_serializing_if = "Option::is_none")]
960    pub surfacing: Option<ImpactCounts>,
961    /// Trend between the two most recent records. None until two records exist.
962    #[serde(default, skip_serializing_if = "Option::is_none")]
963    pub trend: Option<TrendSummary>,
964    /// Counts from the most recent whole-project `fallow` run. WHOLE-PROJECT
965    /// scope (not changed-file), so this is the current issue total across the
966    /// whole repo, context next to the actionable changed-file `surfacing`
967    /// count. None until a full `fallow` run has been recorded. v1.6.
968    #[serde(default, skip_serializing_if = "Option::is_none")]
969    pub project_surfacing: Option<ImpactCounts>,
970    /// Trend between the two most recent whole-project records. Comparable over
971    /// time (same whole-project denominator every run), unlike the changed-file
972    /// `trend`. None until two full `fallow` runs exist. v1.6.
973    #[serde(default, skip_serializing_if = "Option::is_none")]
974    pub project_trend: Option<TrendSummary>,
975    pub containment_count: usize,
976    /// Most recent containment events (newest last), capped for display.
977    pub recent_containment: Vec<ContainmentEvent>,
978    /// Lifetime count of findings fallow credits as genuinely resolved (code
979    /// removed or refactored, never a `fallow-ignore`). v1.5.
980    pub resolved_total: usize,
981    /// Lifetime count of findings silenced by a newly-added `fallow-ignore`.
982    /// Reported as honest context, never as a win. v1.5.
983    pub suppressed_total: usize,
984    /// Most recent resolution events (newest last), capped for display. v1.5.
985    pub recent_resolved: Vec<ResolutionEvent>,
986    /// Whether per-finding attribution has a baseline yet. False on a freshly
987    /// upgraded v1 store (no frontier captured), which the renderer uses to show
988    /// "resolution tracking starts from your next run" instead of a bare zero.
989    pub attribution_active: bool,
990}
991
992/// Build a report from the store. Defensive: a single record (or none) yields
993/// no trend rather than a spurious spike, and an empty store yields an empty
994/// report flagged so the renderer can show the first-run message.
995/// Trend between the two most recent records in a series. None until two records
996/// exist; a missing prior record is "unknown" (no trend), never a spike.
997fn trend_for(records: &[ImpactRecord]) -> Option<TrendSummary> {
998    if records.len() < 2 {
999        return None;
1000    }
1001    let current = &records[records.len() - 1];
1002    let previous = &records[records.len() - 2];
1003    let current_total = current.counts.total_issues;
1004    let previous_total = previous.counts.total_issues;
1005    let total_delta = current_total as i64 - previous_total as i64;
1006    Some(TrendSummary {
1007        direction: direction_for(total_delta),
1008        total_delta,
1009        previous_total,
1010        current_total,
1011    })
1012}
1013
1014pub fn build_report(store: &ImpactStore) -> ImpactReport {
1015    let surfacing = store.records.last().map(|r| r.counts.clone());
1016    let trend = trend_for(&store.records);
1017    let project_surfacing = store.project_records.last().map(|r| r.counts.clone());
1018    let project_trend = trend_for(&store.project_records);
1019
1020    let recent_containment = store
1021        .containment
1022        .iter()
1023        .rev()
1024        .take(5)
1025        .rev()
1026        .cloned()
1027        .collect();
1028
1029    let latest_git_sha = store.records.last().and_then(|r| r.git_sha.clone());
1030
1031    let recent_resolved = store
1032        .recent_resolved
1033        .iter()
1034        .rev()
1035        .take(5)
1036        .rev()
1037        .cloned()
1038        .collect();
1039    let attribution_active = !store.frontier.is_empty()
1040        || !store.clone_frontier.is_empty()
1041        || store.resolved_total > 0
1042        || store.suppressed_total > 0;
1043
1044    ImpactReport {
1045        schema_version: ImpactReportSchemaVersion::V1,
1046        enabled: store.enabled,
1047        record_count: store.records.len(),
1048        first_recorded: store.first_recorded.clone(),
1049        latest_git_sha,
1050        surfacing,
1051        trend,
1052        project_surfacing,
1053        project_trend,
1054        containment_count: store.containment.len(),
1055        recent_containment,
1056        resolved_total: store.resolved_total,
1057        suppressed_total: store.suppressed_total,
1058        recent_resolved,
1059        attribution_active,
1060    }
1061}
1062
1063/// Render the whole-project view for the human report. Deliberately understated
1064/// (one count line, one trend line, one caveat) rather than a co-equal header:
1065/// the project track advances only on local full `fallow` runs, not CI, so it is
1066/// context for the changed-file story above, not the headline. Renders nothing
1067/// when no full `fallow` run has been recorded yet.
1068#[expect(
1069    clippy::format_push_string,
1070    reason = "small report renderer; readability over avoiding the extra allocation"
1071)]
1072fn render_project_section(out: &mut String, report: &ImpactReport) {
1073    let Some(s) = &report.project_surfacing else {
1074        return;
1075    };
1076    out.push_str(&format!(
1077        "  WHOLE PROJECT (whole-repo context, not a to-do)\n    {} issue{} across the whole project at your last full `fallow` run\n",
1078        s.total_issues,
1079        plural(s.total_issues),
1080    ));
1081    if let Some(t) = &report.project_trend {
1082        let arrow = trend_arrow(t.direction);
1083        out.push_str(&format!(
1084            "    {} -> {} ({}) across your last two full runs (comparable over time)\n",
1085            t.previous_total, t.current_total, arrow,
1086        ));
1087    } else {
1088        out.push_str("    project trend starts after your next full `fallow` run\n");
1089    }
1090    out.push_str("      advances only on your local full `fallow` runs, not CI\n\n");
1091}
1092
1093/// Render the report as human-readable text.
1094#[expect(
1095    clippy::format_push_string,
1096    reason = "small report renderer; readability over avoiding the extra allocation"
1097)]
1098pub fn render_human(report: &ImpactReport) -> String {
1099    let mut out = String::new();
1100    out.push_str("FALLOW IMPACT\n\n");
1101
1102    if !report.enabled {
1103        out.push_str(
1104            "Impact tracking is off. Enable it with `fallow impact enable`, then\n\
1105             let your pre-commit gate run a few times to build history.\n",
1106        );
1107        return out;
1108    }
1109
1110    if report.record_count == 0 && report.project_surfacing.is_none() {
1111        out.push_str(
1112            "Tracking enabled. No history yet: check back after your next few\n\
1113             commits (Impact records each `fallow audit` / pre-commit gate run,\n\
1114             and each full `fallow` run for the whole-project view).\n",
1115        );
1116        return out;
1117    }
1118
1119    if let Some(s) = &report.surfacing {
1120        out.push_str(&format!(
1121            "  LATEST RUN (changed files, act on these now)\n    {} issue{} flagged in your last `fallow audit` run\n",
1122            s.total_issues,
1123            plural(s.total_issues),
1124        ));
1125        out.push_str(&format!(
1126            "      dead code {}  ·  complexity {}  ·  duplication {}\n\n",
1127            s.dead_code, s.complexity, s.duplication,
1128        ));
1129    }
1130
1131    if let Some(t) = &report.trend {
1132        let arrow = trend_arrow(t.direction);
1133        out.push_str(&format!(
1134            "  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",
1135            t.previous_total, t.current_total, arrow,
1136        ));
1137    }
1138
1139    render_project_section(&mut out, report);
1140
1141    out.push_str(&format!(
1142        "  CONTAINED AT COMMIT\n    {} time{} fallow blocked a commit until it was fixed\n",
1143        report.containment_count,
1144        plural(report.containment_count),
1145    ));
1146
1147    if report.resolved_total > 0 {
1148        out.push_str(&format!(
1149            "\n  RESOLVED\n    {} finding{} you cleared since fallow started tracking\n",
1150            report.resolved_total,
1151            plural(report.resolved_total),
1152        ));
1153        for ev in &report.recent_resolved {
1154            match &ev.symbol {
1155                Some(symbol) => {
1156                    out.push_str(&format!("      {} {} in {}\n", ev.kind, symbol, ev.path));
1157                }
1158                None => out.push_str(&format!("      {} in {}\n", ev.kind, ev.path)),
1159            }
1160        }
1161    } else if report.attribution_active {
1162        out.push_str(
1163            "\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",
1164        );
1165    } else {
1166        out.push_str("\n  RESOLVED\n    resolution tracking starts from your next gate run\n");
1167    }
1168
1169    if report.suppressed_total > 0 {
1170        out.push_str(&format!(
1171            "      {} finding{} you marked intentional (fallow-ignore), not counted as resolved\n",
1172            report.suppressed_total,
1173            plural(report.suppressed_total),
1174        ));
1175    }
1176
1177    out.push('\n');
1178    let since = report
1179        .first_recorded
1180        .as_deref()
1181        .map_or("the first run", date_only);
1182    if report.record_count > 0 {
1183        out.push_str(&format!(
1184            "Based on {} recorded audit run{} since {}. Local-only; never uploaded.\n\
1185             Changed-file scope: each audit run only sees files differing from your base.\n",
1186            report.record_count,
1187            plural(report.record_count),
1188            since,
1189        ));
1190    } else {
1191        out.push_str(&format!(
1192            "Tracking since {since}. Local-only; never uploaded.\n",
1193        ));
1194    }
1195    out.push_str(
1196        "Resolution tracking is a local-developer signal: it accrues where\n\
1197         .fallow/impact.json persists across runs, not in ephemeral CI runners.\n",
1198    );
1199    out
1200}
1201
1202/// Render the report as JSON.
1203pub fn render_json(report: &ImpactReport) -> String {
1204    let value = crate::output_envelope::serialize_root_output(
1205        crate::output_envelope::FallowOutput::Impact(report.clone()),
1206    )
1207    .unwrap_or_else(|_| serde_json::json!({"error":"failed to serialize impact report"}));
1208    serde_json::to_string_pretty(&value)
1209        .unwrap_or_else(|_| "{\"error\":\"failed to serialize impact report\"}".to_owned())
1210}
1211
1212/// Render the whole-project view for the markdown report. One understated line
1213/// plus a trend line when available, matching the human renderer's framing.
1214/// Renders nothing when no full `fallow` run has been recorded yet.
1215#[expect(
1216    clippy::format_push_string,
1217    reason = "small report renderer; readability over avoiding the extra allocation"
1218)]
1219fn render_project_markdown(out: &mut String, report: &ImpactReport) {
1220    let Some(s) = &report.project_surfacing else {
1221        return;
1222    };
1223    out.push_str(&format!(
1224        "- **Whole project (whole-repo context, last full `fallow` run):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1225        s.total_issues,
1226        plural(s.total_issues),
1227        s.dead_code,
1228        s.complexity,
1229        s.duplication,
1230    ));
1231    if let Some(t) = &report.project_trend {
1232        let arrow = trend_arrow(t.direction);
1233        out.push_str(&format!(
1234            "- **Project trend (whole project, last two full runs):** {} -> {} ({})\n",
1235            t.previous_total, t.current_total, arrow,
1236        ));
1237    }
1238}
1239
1240/// Render the report as Markdown (paste-ready for a PR description or standup).
1241#[expect(
1242    clippy::format_push_string,
1243    reason = "small report renderer; readability over avoiding the extra allocation"
1244)]
1245pub fn render_markdown(report: &ImpactReport) -> String {
1246    let mut out = String::new();
1247    out.push_str("## Fallow impact\n\n");
1248
1249    if !report.enabled {
1250        out.push_str("Impact tracking is off. Run `fallow impact enable` to start.\n");
1251        return out;
1252    }
1253    if report.record_count == 0 && report.project_surfacing.is_none() {
1254        out.push_str("Tracking enabled. No history yet; check back after a few commits.\n");
1255        return out;
1256    }
1257
1258    if let Some(s) = &report.surfacing {
1259        out.push_str(&format!(
1260            "- **Latest run (changed files):** {} issue{} (dead code {}, complexity {}, duplication {})\n",
1261            s.total_issues,
1262            plural(s.total_issues),
1263            s.dead_code,
1264            s.complexity,
1265            s.duplication,
1266        ));
1267    }
1268    if let Some(t) = &report.trend {
1269        out.push_str(&format!(
1270            "- **Trend (changed-file scope, last two runs):** {} -> {} ({})\n",
1271            t.previous_total,
1272            t.current_total,
1273            trend_arrow(t.direction),
1274        ));
1275    }
1276    render_project_markdown(&mut out, report);
1277    out.push_str(&format!(
1278        "- **Contained at commit:** {} time{}\n",
1279        report.containment_count,
1280        plural(report.containment_count),
1281    ));
1282    if report.resolved_total > 0 {
1283        out.push_str(&format!(
1284            "- **Resolved:** {} finding{} cleared since tracking started\n",
1285            report.resolved_total,
1286            plural(report.resolved_total),
1287        ));
1288    } else if report.attribution_active {
1289        out.push_str("- **Resolved:** none yet; tracking active\n");
1290    } else {
1291        out.push_str("- **Resolved:** resolution tracking starts from your next gate run\n");
1292    }
1293    if report.suppressed_total > 0 {
1294        out.push_str(&format!(
1295            "- **Marked intentional:** {} finding{} (`fallow-ignore`), not counted as resolved\n",
1296            report.suppressed_total,
1297            plural(report.suppressed_total),
1298        ));
1299    }
1300    let since = report
1301        .first_recorded
1302        .as_deref()
1303        .map_or("the first run", date_only);
1304    if report.record_count > 0 {
1305        out.push_str(&format!(
1306            "\n_Based on {} recorded audit run{} since {}. Local-only; resolution is a local-developer signal._\n",
1307            report.record_count,
1308            plural(report.record_count),
1309            since,
1310        ));
1311    } else {
1312        out.push_str(&format!(
1313            "\n_Tracking since {since}. Local-only; resolution is a local-developer signal._\n",
1314        ));
1315    }
1316    out
1317}
1318
1319const fn plural(n: usize) -> &'static str {
1320    if n == 1 { "" } else { "s" }
1321}
1322
1323/// Trim a stored ISO-8601 timestamp (`2026-05-29T18:15:23Z`) to its date part
1324/// (`2026-05-29`) for human/markdown footers. The wall-clock time and `Z` add
1325/// noise without meaning when a reader just wants "tracking since when". JSON
1326/// keeps the full `first_recorded` timestamp. Returns the input unchanged if it
1327/// has no `T` separator.
1328fn date_only(ts: &str) -> &str {
1329    ts.split_once('T').map_or(ts, |(date, _)| date)
1330}
1331
1332/// Single human-facing trend vocabulary, shared by the text and markdown
1333/// renderers so the same concept does not read three different ways. The JSON
1334/// wire keeps the `improving`/`declining`/`stable` enum form for machines.
1335const fn trend_arrow(direction: ImpactTrendDirection) -> &'static str {
1336    match direction {
1337        ImpactTrendDirection::Improving => "down",
1338        ImpactTrendDirection::Declining => "up",
1339        ImpactTrendDirection::Stable => "flat",
1340    }
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345    use super::*;
1346
1347    fn summary(dead: usize, complexity: usize, dupes: usize) -> AuditSummary {
1348        AuditSummary {
1349            dead_code_issues: dead,
1350            dead_code_has_errors: dead > 0,
1351            complexity_findings: complexity,
1352            max_cyclomatic: None,
1353            duplication_clone_groups: dupes,
1354        }
1355    }
1356
1357    /// Record a run with no per-finding attribution (v1 surfacing/trend/containment only).
1358    fn record_v1(
1359        root: &Path,
1360        summary: &AuditSummary,
1361        verdict: AuditVerdict,
1362        gate: bool,
1363        git_sha: Option<&str>,
1364        version: &str,
1365        timestamp: &str,
1366    ) {
1367        record_audit_run(
1368            root, summary, verdict, gate, git_sha, version, timestamp, None,
1369        );
1370    }
1371
1372    /// Create a real file under `root` (attribution prunes frontier entries for
1373    /// files that no longer exist, so test files must exist on disk).
1374    fn touch(root: &Path, rel: &str) -> PathBuf {
1375        let p = root.join(rel);
1376        if let Some(parent) = p.parent() {
1377            std::fs::create_dir_all(parent).unwrap();
1378        }
1379        std::fs::write(&p, b"x").unwrap();
1380        p
1381    }
1382
1383    fn fi(path: &Path, kind: &'static str, symbol: &str) -> FindingInput {
1384        FindingInput {
1385            path: path.to_path_buf(),
1386            kind,
1387            symbol: Some(symbol.to_owned()),
1388        }
1389    }
1390
1391    fn supp(path: &Path, kind: &str) -> ActiveSuppression {
1392        ActiveSuppression {
1393            path: path.to_path_buf(),
1394            kind: Some(kind.to_owned()),
1395            is_file_level: false,
1396        }
1397    }
1398
1399    /// Record one attribution run against the store.
1400    fn run(
1401        root: &Path,
1402        changed: &[&Path],
1403        findings: Vec<FindingInput>,
1404        clones: Vec<CloneInput>,
1405        supps: &[ActiveSuppression],
1406        ts: &str,
1407    ) {
1408        let changed_files: Vec<PathBuf> = changed.iter().map(|p| p.to_path_buf()).collect();
1409        let input = AttributionInput {
1410            root,
1411            scope: Scope::ChangedFiles(&changed_files),
1412            findings,
1413            clones,
1414            suppressions: supps,
1415        };
1416        record_audit_run(
1417            root,
1418            &summary(0, 0, 0),
1419            AuditVerdict::Pass,
1420            true,
1421            Some("sha"),
1422            "2.0.0",
1423            ts,
1424            Some(&input),
1425        );
1426    }
1427
1428    #[test]
1429    fn disabled_store_does_not_record() {
1430        let dir = tempfile::tempdir().unwrap();
1431        let root = dir.path();
1432        record_v1(
1433            root,
1434            &summary(3, 1, 0),
1435            AuditVerdict::Fail,
1436            true,
1437            Some("abc1234"),
1438            "2.0.0",
1439            "2026-05-29T10:00:00Z",
1440        );
1441        let store = load(root);
1442        assert!(store.records.is_empty());
1443        assert!(!store.enabled);
1444    }
1445
1446    #[test]
1447    fn enable_then_record_accrues_history() {
1448        let dir = tempfile::tempdir().unwrap();
1449        let root = dir.path();
1450        assert!(enable(root));
1451        assert!(!enable(root)); // second enable is a no-op-ish (already on)
1452        record_v1(
1453            root,
1454            &summary(2, 1, 0),
1455            AuditVerdict::Warn,
1456            false,
1457            None,
1458            "2.0.0",
1459            "2026-05-29T10:00:00Z",
1460        );
1461        let store = load(root);
1462        assert_eq!(store.records.len(), 1);
1463        assert_eq!(store.records[0].counts.total_issues, 3);
1464        assert_eq!(
1465            store.first_recorded.as_deref(),
1466            Some("2026-05-29T10:00:00Z")
1467        );
1468    }
1469
1470    #[test]
1471    fn enable_gitignores_the_store() {
1472        let dir = tempfile::tempdir().unwrap();
1473        let root = dir.path();
1474        enable(root);
1475        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1476        assert!(
1477            gitignore.lines().any(|l| l.trim() == ".fallow/"),
1478            "enable must gitignore .fallow/, got: {gitignore:?}"
1479        );
1480        enable(root);
1481        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1482        assert_eq!(
1483            gitignore.lines().filter(|l| l.trim() == ".fallow/").count(),
1484            1,
1485            "re-enabling must not duplicate the .fallow/ entry"
1486        );
1487    }
1488
1489    #[test]
1490    fn single_record_yields_no_trend_no_spike() {
1491        let mut store = ImpactStore {
1492            enabled: true,
1493            ..Default::default()
1494        };
1495        store.records.push(ImpactRecord {
1496            timestamp: "t0".into(),
1497            version: "2.0.0".into(),
1498            git_sha: None,
1499            verdict: "warn".into(),
1500            gate: false,
1501            counts: ImpactCounts {
1502                total_issues: 5,
1503                dead_code: 5,
1504                complexity: 0,
1505                duplication: 0,
1506            },
1507        });
1508        let report = build_report(&store);
1509        assert!(report.trend.is_none());
1510        assert_eq!(report.surfacing.unwrap().total_issues, 5);
1511    }
1512
1513    #[test]
1514    fn empty_store_report_is_first_run() {
1515        let store = ImpactStore::default();
1516        let report = build_report(&store);
1517        assert_eq!(report.record_count, 0);
1518        assert!(report.trend.is_none());
1519        assert!(report.surfacing.is_none());
1520        let human = render_human(&report);
1521        assert!(human.contains("off")); // default store is disabled
1522    }
1523
1524    #[test]
1525    fn enabled_empty_store_shows_check_back() {
1526        let store = ImpactStore {
1527            enabled: true,
1528            ..Default::default()
1529        };
1530        let report = build_report(&store);
1531        let human = render_human(&report);
1532        assert!(human.contains("No history yet"));
1533        assert!(!human.contains("0 issues"));
1534    }
1535
1536    #[test]
1537    fn trend_improving_when_issues_drop() {
1538        let mut store = ImpactStore {
1539            enabled: true,
1540            ..Default::default()
1541        };
1542        for total in [8usize, 3usize] {
1543            store.records.push(ImpactRecord {
1544                timestamp: format!("t{total}"),
1545                version: "2.0.0".into(),
1546                git_sha: None,
1547                verdict: "warn".into(),
1548                gate: false,
1549                counts: ImpactCounts {
1550                    total_issues: total,
1551                    dead_code: total,
1552                    complexity: 0,
1553                    duplication: 0,
1554                },
1555            });
1556        }
1557        let report = build_report(&store);
1558        let trend = report.trend.unwrap();
1559        assert_eq!(trend.direction, ImpactTrendDirection::Improving);
1560        assert_eq!(trend.total_delta, -5);
1561    }
1562
1563    #[test]
1564    fn containment_blocked_then_cleared_records_one_event() {
1565        let dir = tempfile::tempdir().unwrap();
1566        let root = dir.path();
1567        enable(root);
1568        record_v1(
1569            root,
1570            &summary(2, 0, 0),
1571            AuditVerdict::Fail,
1572            true,
1573            Some("sha1"),
1574            "2.0.0",
1575            "t0",
1576        );
1577        let store = load(root);
1578        assert!(store.pending_containment.is_some());
1579        assert!(store.containment.is_empty());
1580
1581        record_v1(
1582            root,
1583            &summary(0, 0, 0),
1584            AuditVerdict::Pass,
1585            true,
1586            Some("sha2"),
1587            "2.0.0",
1588            "t1",
1589        );
1590        let store = load(root);
1591        assert!(store.pending_containment.is_none());
1592        assert_eq!(store.containment.len(), 1);
1593        assert_eq!(store.containment[0].blocked_at, "t0");
1594        assert_eq!(store.containment[0].cleared_at, "t1");
1595    }
1596
1597    #[test]
1598    fn non_gate_run_never_creates_containment() {
1599        let dir = tempfile::tempdir().unwrap();
1600        let root = dir.path();
1601        enable(root);
1602        record_v1(
1603            root,
1604            &summary(2, 0, 0),
1605            AuditVerdict::Fail,
1606            false,
1607            None,
1608            "2.0.0",
1609            "t0",
1610        );
1611        let store = load(root);
1612        assert!(store.pending_containment.is_none());
1613        assert!(store.containment.is_empty());
1614    }
1615
1616    #[test]
1617    fn corrupt_store_loads_as_default_no_panic() {
1618        let dir = tempfile::tempdir().unwrap();
1619        let root = dir.path();
1620        std::fs::create_dir_all(root.join(".fallow")).unwrap();
1621        std::fs::write(store_path(root), b"{ not valid json ][").unwrap();
1622        let store = load(root);
1623        assert!(!store.enabled);
1624        assert!(store.records.is_empty());
1625        record_v1(
1626            root,
1627            &summary(1, 0, 0),
1628            AuditVerdict::Fail,
1629            true,
1630            None,
1631            "2.0.0",
1632            "t0",
1633        );
1634    }
1635
1636    #[test]
1637    fn records_are_bounded() {
1638        let mut store = ImpactStore {
1639            enabled: true,
1640            ..Default::default()
1641        };
1642        for i in 0..(MAX_RECORDS + 50) {
1643            store.records.push(ImpactRecord {
1644                timestamp: format!("t{i}"),
1645                version: "2.0.0".into(),
1646                git_sha: None,
1647                verdict: "pass".into(),
1648                gate: false,
1649                counts: ImpactCounts::default(),
1650            });
1651        }
1652        compact(&mut store);
1653        assert_eq!(store.records.len(), MAX_RECORDS);
1654        assert_eq!(store.records[0].timestamp, "t50");
1655    }
1656
1657    #[test]
1658    fn report_always_carries_schema_version() {
1659        let empty = build_report(&ImpactStore::default());
1660        assert_eq!(empty.schema_version, ImpactReportSchemaVersion::V1);
1661        let json = render_json(&empty);
1662        assert!(
1663            json.contains("\"schema_version\": \"1\""),
1664            "schema_version must be present (as the \"1\" const) even when disabled: {json}"
1665        );
1666
1667        let mut store = ImpactStore {
1668            enabled: true,
1669            ..Default::default()
1670        };
1671        store.records.push(ImpactRecord {
1672            timestamp: "2026-05-29T10:00:00Z".into(),
1673            version: "2.0.0".into(),
1674            git_sha: None,
1675            verdict: "pass".into(),
1676            gate: false,
1677            counts: ImpactCounts::default(),
1678        });
1679        assert_eq!(
1680            build_report(&store).schema_version,
1681            ImpactReportSchemaVersion::V1
1682        );
1683    }
1684
1685    #[test]
1686    fn date_only_trims_iso_timestamp() {
1687        assert_eq!(date_only("2026-05-29T18:15:23Z"), "2026-05-29");
1688        assert_eq!(date_only("2026-05-29"), "2026-05-29");
1689        assert_eq!(date_only("the first run"), "the first run");
1690    }
1691
1692    #[test]
1693    fn human_footer_shows_date_only() {
1694        let mut store = ImpactStore {
1695            enabled: true,
1696            ..Default::default()
1697        };
1698        store.first_recorded = Some("2026-05-29T18:15:23Z".into());
1699        store.records.push(ImpactRecord {
1700            timestamp: "2026-05-29T18:15:23Z".into(),
1701            version: "2.0.0".into(),
1702            git_sha: None,
1703            verdict: "pass".into(),
1704            gate: false,
1705            counts: ImpactCounts::default(),
1706        });
1707        let report = build_report(&store);
1708        let human = render_human(&report);
1709        assert!(
1710            human.contains("since 2026-05-29.") && !human.contains("18:15:23"),
1711            "human footer must show date-only: {human}"
1712        );
1713        let md = render_markdown(&report);
1714        assert!(
1715            md.contains("since 2026-05-29.") && !md.contains("18:15:23"),
1716            "markdown footer must show date-only: {md}"
1717        );
1718    }
1719
1720    #[test]
1721    fn future_schema_version_store_loads_without_panic_or_loss() {
1722        let dir = tempfile::tempdir().unwrap();
1723        let root = dir.path();
1724        std::fs::create_dir_all(root.join(".fallow")).unwrap();
1725        let future = format!(
1726            "{{\"schema_version\":{},\"enabled\":true,\"records\":[],\"containment\":[]}}",
1727            STORE_SCHEMA_VERSION + 1
1728        );
1729        std::fs::write(store_path(root), future).unwrap();
1730        let store = load(root);
1731        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION + 1);
1732        assert!(
1733            store.enabled,
1734            "future-version store must not degrade to default"
1735        );
1736    }
1737
1738    #[test]
1739    fn removed_finding_is_credited_as_resolved() {
1740        let dir = tempfile::tempdir().unwrap();
1741        let root = dir.path();
1742        enable(root);
1743        let a = touch(root, "src/a.ts");
1744        run(
1745            root,
1746            &[&a],
1747            vec![fi(&a, "unused-export", "foo")],
1748            vec![],
1749            &[],
1750            "t0",
1751        );
1752        assert_eq!(
1753            load(root).resolved_total,
1754            0,
1755            "first run only establishes a baseline"
1756        );
1757        run(root, &[&a], vec![], vec![], &[], "t1");
1758        let store = load(root);
1759        assert_eq!(store.resolved_total, 1);
1760        assert_eq!(store.suppressed_total, 0);
1761        assert_eq!(store.recent_resolved.len(), 1);
1762        assert_eq!(store.recent_resolved[0].kind, "unused-export");
1763        assert_eq!(store.recent_resolved[0].symbol.as_deref(), Some("foo"));
1764        assert_eq!(store.recent_resolved[0].path, "src/a.ts");
1765    }
1766
1767    #[test]
1768    fn suppressed_finding_is_not_a_win() {
1769        let dir = tempfile::tempdir().unwrap();
1770        let root = dir.path();
1771        enable(root);
1772        let a = touch(root, "src/a.ts");
1773        run(
1774            root,
1775            &[&a],
1776            vec![fi(&a, "unused-export", "foo")],
1777            vec![],
1778            &[],
1779            "t0",
1780        );
1781        run(
1782            root,
1783            &[&a],
1784            vec![],
1785            vec![],
1786            &[supp(&a, "unused-export")],
1787            "t1",
1788        );
1789        let store = load(root);
1790        assert_eq!(
1791            store.resolved_total, 0,
1792            "a suppression must never count as a win"
1793        );
1794        assert_eq!(store.suppressed_total, 1);
1795    }
1796
1797    #[test]
1798    fn fix_and_suppress_same_kind_credits_zero_resolved() {
1799        let dir = tempfile::tempdir().unwrap();
1800        let root = dir.path();
1801        enable(root);
1802        let a = touch(root, "src/a.ts");
1803        run(
1804            root,
1805            &[&a],
1806            vec![
1807                fi(&a, "unused-export", "foo"),
1808                fi(&a, "unused-export", "bar"),
1809            ],
1810            vec![],
1811            &[],
1812            "t0",
1813        );
1814        run(
1815            root,
1816            &[&a],
1817            vec![],
1818            vec![],
1819            &[supp(&a, "unused-export")],
1820            "t1",
1821        );
1822        let store = load(root);
1823        assert_eq!(store.resolved_total, 0);
1824        assert_eq!(store.suppressed_total, 2);
1825    }
1826
1827    #[test]
1828    fn within_file_move_is_not_resolved() {
1829        let dir = tempfile::tempdir().unwrap();
1830        let root = dir.path();
1831        enable(root);
1832        let a = touch(root, "src/a.ts");
1833        run(
1834            root,
1835            &[&a],
1836            vec![fi(&a, "unused-export", "foo")],
1837            vec![],
1838            &[],
1839            "t0",
1840        );
1841        run(
1842            root,
1843            &[&a],
1844            vec![fi(&a, "unused-export", "foo")],
1845            vec![],
1846            &[],
1847            "t1",
1848        );
1849        let store = load(root);
1850        assert_eq!(store.resolved_total, 0);
1851        assert_eq!(store.suppressed_total, 0);
1852    }
1853
1854    #[test]
1855    fn cross_file_move_in_same_run_is_not_resolved() {
1856        let dir = tempfile::tempdir().unwrap();
1857        let root = dir.path();
1858        enable(root);
1859        let a = touch(root, "src/a.ts");
1860        let b = touch(root, "src/b.ts");
1861        run(
1862            root,
1863            &[&a],
1864            vec![fi(&a, "unused-export", "foo")],
1865            vec![],
1866            &[],
1867            "t0",
1868        );
1869        run(
1870            root,
1871            &[&a, &b],
1872            vec![fi(&b, "unused-export", "foo")],
1873            vec![],
1874            &[],
1875            "t1",
1876        );
1877        assert_eq!(
1878            load(root).resolved_total,
1879            0,
1880            "a cross-file move is not a resolution"
1881        );
1882    }
1883
1884    #[test]
1885    fn cross_run_move_uncredits_the_prior_resolution() {
1886        let dir = tempfile::tempdir().unwrap();
1887        let root = dir.path();
1888        enable(root);
1889        let a = touch(root, "src/a.ts");
1890        let b = touch(root, "src/b.ts");
1891        run(
1892            root,
1893            &[&a],
1894            vec![fi(&a, "unused-export", "foo")],
1895            vec![],
1896            &[],
1897            "t0",
1898        );
1899        run(root, &[&a], vec![], vec![], &[], "t1");
1900        assert_eq!(
1901            load(root).resolved_total,
1902            1,
1903            "source disappearance credited in run A"
1904        );
1905        run(
1906            root,
1907            &[&b],
1908            vec![fi(&b, "unused-export", "foo")],
1909            vec![],
1910            &[],
1911            "t2",
1912        );
1913        let store = load(root);
1914        assert_eq!(
1915            store.resolved_total, 0,
1916            "cross-run move must un-credit the phantom win"
1917        );
1918        assert!(
1919            store.recent_resolved.is_empty(),
1920            "the stale resolution event is dropped"
1921        );
1922    }
1923
1924    #[test]
1925    fn resolved_complexity_finding_and_suppressed_complexity() {
1926        let dir = tempfile::tempdir().unwrap();
1927        let root = dir.path();
1928        enable(root);
1929        let a = touch(root, "src/a.ts");
1930        run(
1931            root,
1932            &[&a],
1933            vec![fi(&a, "complexity", "bigFn")],
1934            vec![],
1935            &[],
1936            "t0",
1937        );
1938        run(root, &[&a], vec![], vec![], &[supp(&a, "complexity")], "t1");
1939        let store = load(root);
1940        assert_eq!(store.resolved_total, 0);
1941        assert_eq!(store.suppressed_total, 1);
1942
1943        let b = touch(root, "src/b.ts");
1944        run(
1945            root,
1946            &[&b],
1947            vec![fi(&b, "complexity", "huge")],
1948            vec![],
1949            &[],
1950            "t2",
1951        );
1952        run(root, &[&b], vec![], vec![], &[], "t3");
1953        assert_eq!(load(root).resolved_total, 1);
1954    }
1955
1956    #[test]
1957    fn resolved_duplication_clone_group() {
1958        let dir = tempfile::tempdir().unwrap();
1959        let root = dir.path();
1960        enable(root);
1961        let a = touch(root, "src/a.ts");
1962        let b = touch(root, "src/b.ts");
1963        let clone = CloneInput {
1964            fingerprint: "dup:abc12345".to_owned(),
1965            instance_paths: vec![a.clone(), b],
1966        };
1967        run(root, &[&a], vec![], vec![clone], &[], "t0");
1968        run(root, &[&a], vec![], vec![], &[], "t1");
1969        let store = load(root);
1970        assert_eq!(store.resolved_total, 1);
1971        assert_eq!(store.recent_resolved[0].kind, "code-duplication");
1972    }
1973
1974    #[test]
1975    fn blanket_suppression_covers_any_kind() {
1976        let dir = tempfile::tempdir().unwrap();
1977        let root = dir.path();
1978        enable(root);
1979        let a = touch(root, "src/a.ts");
1980        run(
1981            root,
1982            &[&a],
1983            vec![fi(&a, "unused-export", "foo")],
1984            vec![],
1985            &[],
1986            "t0",
1987        );
1988        let blanket = ActiveSuppression {
1989            path: a.clone(),
1990            kind: None,
1991            is_file_level: true,
1992        };
1993        run(root, &[&a], vec![], vec![], &[blanket], "t1");
1994        let store = load(root);
1995        assert_eq!(store.resolved_total, 0);
1996        assert_eq!(store.suppressed_total, 1);
1997    }
1998
1999    #[test]
2000    fn v1_store_loads_and_upgrades_to_v2() {
2001        let dir = tempfile::tempdir().unwrap();
2002        let root = dir.path();
2003        std::fs::create_dir_all(root.join(".fallow")).unwrap();
2004        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":[]}"#;
2005        std::fs::write(store_path(root), v1).unwrap();
2006        let store = load(root);
2007        assert_eq!(store.schema_version, 1);
2008        assert!(store.frontier.is_empty());
2009        assert_eq!(store.resolved_total, 0);
2010        let a = touch(root, "src/a.ts");
2011        run(
2012            root,
2013            &[&a],
2014            vec![fi(&a, "unused-export", "foo")],
2015            vec![],
2016            &[],
2017            "t1",
2018        );
2019        let store = load(root);
2020        assert_eq!(store.schema_version, STORE_SCHEMA_VERSION);
2021        assert!(store.frontier.contains_key("src/a.ts"));
2022    }
2023
2024    #[test]
2025    fn recent_resolved_is_bounded() {
2026        let mut store = ImpactStore {
2027            enabled: true,
2028            ..Default::default()
2029        };
2030        for i in 0..(MAX_RECENT_RESOLVED + 25) {
2031            store.recent_resolved.push(ResolutionEvent {
2032                kind: "unused-export".into(),
2033                path: format!("src/f{i}.ts"),
2034                symbol: Some(format!("s{i}")),
2035                git_sha: None,
2036                timestamp: format!("t{i}"),
2037            });
2038        }
2039        bound_recent_resolved(&mut store);
2040        assert_eq!(store.recent_resolved.len(), MAX_RECENT_RESOLVED);
2041        assert_eq!(store.recent_resolved[0].path, "src/f25.ts");
2042    }
2043
2044    #[test]
2045    fn frontier_prunes_deleted_files() {
2046        let dir = tempfile::tempdir().unwrap();
2047        let root = dir.path();
2048        enable(root);
2049        let a = touch(root, "src/a.ts");
2050        run(
2051            root,
2052            &[&a],
2053            vec![fi(&a, "unused-export", "foo")],
2054            vec![],
2055            &[],
2056            "t0",
2057        );
2058        assert!(load(root).frontier.contains_key("src/a.ts"));
2059        std::fs::remove_file(&a).unwrap();
2060        let b = touch(root, "src/b.ts");
2061        run(root, &[&b], vec![], vec![], &[], "t1");
2062        assert!(!load(root).frontier.contains_key("src/a.ts"));
2063    }
2064
2065    #[test]
2066    fn honest_empty_state_before_attribution_baseline() {
2067        let store = ImpactStore {
2068            enabled: true,
2069            records: vec![ImpactRecord {
2070                timestamp: "t0".into(),
2071                version: "2.0.0".into(),
2072                git_sha: None,
2073                verdict: "warn".into(),
2074                gate: false,
2075                counts: ImpactCounts::default(),
2076            }],
2077            ..Default::default()
2078        };
2079        let report = build_report(&store);
2080        assert!(!report.attribution_active);
2081        let human = render_human(&report);
2082        assert!(human.contains("resolution tracking starts from your next gate run"));
2083        assert!(!human.contains("0 finding"));
2084    }
2085
2086    #[test]
2087    fn suppression_only_state_renders_under_a_resolved_header() {
2088        let report = ImpactReport {
2089            schema_version: ImpactReportSchemaVersion::V1,
2090            enabled: true,
2091            record_count: 2,
2092            first_recorded: Some("2026-05-29T10:00:00Z".into()),
2093            latest_git_sha: None,
2094            surfacing: Some(ImpactCounts::default()),
2095            trend: None,
2096            project_surfacing: None,
2097            project_trend: None,
2098            containment_count: 0,
2099            recent_containment: vec![],
2100            resolved_total: 0,
2101            suppressed_total: 2,
2102            recent_resolved: vec![],
2103            attribution_active: true,
2104        };
2105        let human = render_human(&report);
2106        let resolved_idx = human.find("  RESOLVED").expect("RESOLVED header present");
2107        let supp_idx = human
2108            .find("2 findings you marked intentional")
2109            .expect("suppression line present");
2110        assert!(
2111            resolved_idx < supp_idx,
2112            "suppression must render under RESOLVED"
2113        );
2114        assert!(human.contains("none yet"));
2115
2116        let md = render_markdown(&report);
2117        assert!(
2118            md.contains("- **Resolved:**"),
2119            "markdown always has a Resolved bullet"
2120        );
2121        assert!(md.contains("- **Marked intentional:** 2 finding"));
2122    }
2123
2124    /// Build a `CloneInput` over real absolute paths (built from `root`).
2125    fn clone_at(fingerprint: &str, paths: &[&Path]) -> CloneInput {
2126        CloneInput {
2127            fingerprint: fingerprint.to_owned(),
2128            instance_paths: paths.iter().map(|p| p.to_path_buf()).collect(),
2129        }
2130    }
2131
2132    /// Record a WHOLE-PROJECT run via the real combined-track recorder
2133    /// (`record_combined_run` with `Scope::WholeProject`), exercising the same
2134    /// path `combined.rs` uses on a full `fallow` run.
2135    fn run_wp(
2136        root: &Path,
2137        findings: Vec<FindingInput>,
2138        clones: Vec<CloneInput>,
2139        supps: &[ActiveSuppression],
2140        ts: &str,
2141    ) {
2142        let input = AttributionInput {
2143            root,
2144            scope: Scope::WholeProject,
2145            findings,
2146            clones,
2147            suppressions: supps,
2148        };
2149        record_combined_run(
2150            root,
2151            ImpactCounts::default(),
2152            Some("sha"),
2153            "2.0.0",
2154            ts,
2155            Some(&input),
2156        );
2157    }
2158
2159    #[test]
2160    fn whole_project_run_does_not_double_credit_after_audit() {
2161        let dir = tempfile::tempdir().unwrap();
2162        let root = dir.path();
2163        enable(root);
2164        let a = touch(root, "src/a.ts");
2165        let b = touch(root, "src/b.ts");
2166        run(
2167            root,
2168            &[&a, &b],
2169            vec![],
2170            vec![clone_at("dup:abc", &[&a, &b])],
2171            &[],
2172            "t1",
2173        );
2174        assert_eq!(load(root).clone_frontier.len(), 1);
2175
2176        run(root, &[&a, &b], vec![], vec![], &[], "t2");
2177        assert_eq!(load(root).resolved_total, 1);
2178        assert!(load(root).clone_frontier.is_empty());
2179
2180        run_wp(root, vec![], vec![], &[], "t3");
2181        assert_eq!(
2182            load(root).resolved_total,
2183            1,
2184            "whole-project run re-credited a resolution"
2185        );
2186    }
2187
2188    #[test]
2189    fn whole_project_run_credits_suppressed_not_resolved() {
2190        let dir = tempfile::tempdir().unwrap();
2191        let root = dir.path();
2192        enable(root);
2193        let util = touch(root, "src/util.ts");
2194        run(
2195            root,
2196            &[&util],
2197            vec![fi(&util, "unused-export", "dead")],
2198            vec![],
2199            &[],
2200            "t1",
2201        );
2202        assert_eq!(load(root).frontier.len(), 1);
2203
2204        run_wp(root, vec![], vec![], &[supp(&util, "unused-export")], "t2");
2205        let store = load(root);
2206        assert_eq!(
2207            store.suppressed_total, 1,
2208            "suppressed finding not counted suppressed"
2209        );
2210        assert_eq!(
2211            store.resolved_total, 0,
2212            "suppressed finding wrongly counted resolved"
2213        );
2214    }
2215
2216    #[test]
2217    fn clone_reshape_three_to_two_not_credited_as_resolved() {
2218        let dir = tempfile::tempdir().unwrap();
2219        let root = dir.path();
2220        enable(root);
2221        let a = touch(root, "src/a.ts");
2222        let b = touch(root, "src/b.ts");
2223        let c = touch(root, "src/c.ts");
2224        run(
2225            root,
2226            &[&a, &b, &c],
2227            vec![],
2228            vec![clone_at("dup:aaa", &[&a, &b, &c])],
2229            &[],
2230            "t1",
2231        );
2232        assert_eq!(load(root).clone_frontier.len(), 1);
2233
2234        run_wp(
2235            root,
2236            vec![],
2237            vec![clone_at("dup:bbb", &[&a, &b])],
2238            &[],
2239            "t2",
2240        );
2241        let store = load(root);
2242        assert_eq!(
2243            store.resolved_total, 0,
2244            "clone reshape miscredited as resolved"
2245        );
2246        assert!(store.clone_frontier.contains_key("dup:bbb"));
2247        assert!(!store.clone_frontier.contains_key("dup:aaa"));
2248    }
2249
2250    fn rcounts(total: usize, dead: usize, complexity: usize, dup: usize) -> ImpactCounts {
2251        ImpactCounts {
2252            total_issues: total,
2253            dead_code: dead,
2254            complexity,
2255            duplication: dup,
2256        }
2257    }
2258
2259    fn rtrend(prev: usize, cur: usize) -> TrendSummary {
2260        TrendSummary {
2261            direction: direction_for(cur as i64 - prev as i64),
2262            total_delta: cur as i64 - prev as i64,
2263            previous_total: prev,
2264            current_total: cur,
2265        }
2266    }
2267
2268    /// Build a report literal for render-state tests.
2269    fn rreport(
2270        record_count: usize,
2271        first_recorded: Option<&str>,
2272        surfacing: Option<ImpactCounts>,
2273        trend: Option<TrendSummary>,
2274        project_surfacing: Option<ImpactCounts>,
2275        project_trend: Option<TrendSummary>,
2276        attribution_active: bool,
2277    ) -> ImpactReport {
2278        ImpactReport {
2279            schema_version: ImpactReportSchemaVersion::V1,
2280            enabled: true,
2281            record_count,
2282            first_recorded: first_recorded.map(ToOwned::to_owned),
2283            latest_git_sha: None,
2284            surfacing,
2285            trend,
2286            project_surfacing,
2287            project_trend,
2288            containment_count: 0,
2289            recent_containment: vec![],
2290            resolved_total: 0,
2291            suppressed_total: 0,
2292            recent_resolved: vec![],
2293            attribution_active,
2294        }
2295    }
2296
2297    #[test]
2298    fn render_human_project_only_store_shows_whole_project_not_empty_state() {
2299        let r = rreport(
2300            0,
2301            Some("2026-05-30T10:00:00Z"),
2302            None,
2303            None,
2304            Some(rcounts(1, 1, 0, 0)),
2305            None,
2306            true,
2307        );
2308        let human = render_human(&r);
2309        assert!(
2310            human.contains("WHOLE PROJECT (whole-repo context, not a to-do)"),
2311            "project-only must render the labeled section"
2312        );
2313        assert!(human.contains("1 issue across the whole project"));
2314        assert!(
2315            human.contains("project trend starts after your next full `fallow` run"),
2316            "single project record => no trend line, shows the next-run hint"
2317        );
2318        assert!(human.contains("Tracking since 2026-05-30"));
2319        assert!(
2320            !human.contains("No history yet"),
2321            "must not show the empty-state copy"
2322        );
2323        assert!(
2324            !human.contains("LATEST RUN"),
2325            "no changed-file track recorded"
2326        );
2327        assert!(
2328            !human.contains("recorded audit run"),
2329            "no audit runs => no changed-file footer"
2330        );
2331    }
2332
2333    #[test]
2334    fn render_human_both_tracks_label_actionable_vs_context() {
2335        let r = rreport(
2336            3,
2337            Some("2026-05-29T10:00:00Z"),
2338            Some(rcounts(4, 4, 0, 0)),
2339            Some(rtrend(6, 4)),
2340            Some(rcounts(40, 30, 5, 5)),
2341            Some(rtrend(45, 40)),
2342            true,
2343        );
2344        let human = render_human(&r);
2345        let latest = human
2346            .find("LATEST RUN (changed files, act on these now)")
2347            .expect("LATEST RUN labeled actionable");
2348        let whole = human
2349            .find("WHOLE PROJECT (whole-repo context, not a to-do)")
2350            .expect("WHOLE PROJECT labeled context");
2351        assert!(
2352            latest < whole,
2353            "changed-file section renders before whole-project"
2354        );
2355        assert!(human.contains("45 -> 40 (down) across your last two full runs"));
2356        assert!(human.contains("advances only on your local full `fallow` runs, not CI"));
2357    }
2358
2359    #[test]
2360    fn render_markdown_project_only_store_shows_whole_project_not_empty_state() {
2361        let r = rreport(
2362            0,
2363            Some("2026-05-30T10:00:00Z"),
2364            None,
2365            None,
2366            Some(rcounts(1, 1, 0, 0)),
2367            None,
2368            true,
2369        );
2370        let md = render_markdown(&r);
2371        assert!(
2372            md.contains(
2373                "- **Whole project (whole-repo context, last full `fallow` run):** 1 issue"
2374            ),
2375            "project-only md must render the labeled whole-project line"
2376        );
2377        assert!(
2378            !md.contains("No history yet"),
2379            "project-only md must not show empty state"
2380        );
2381        assert!(md.contains("Tracking since 2026-05-30"));
2382    }
2383}