Skip to main content

mati_core/health/
staleness.rs

1//! Incremental staleness from reparse diffs (M-12-C) and full staleness
2//! analysis (M-13-A).
3//!
4//! When `mati reparse` detects structural changes in a file, this module
5//! updates the staleness score on the file record and cascades staleness
6//! to linked gotcha records.
7//!
8//! The [`StalenessAnalyzer`] performs a complete 5-factor staleness computation
9//! across all knowledge records, using time, git, semantic, dependency, and
10//! cascade signals.
11
12use std::collections::HashMap;
13use std::path::Path;
14use std::time::{Instant, SystemTime, UNIX_EPOCH};
15
16use anyhow::{Context, Result};
17
18use crate::store::record::{
19    Category, FileRecord, GotchaRecord, Record, RecordLifecycle, StalenessScore, StalenessSignal,
20    StalenessTier,
21};
22use crate::store::Store;
23
24/// Maximum staleness increment from a single reparse pass.
25const MAX_REPARSE_INCREMENT: f32 = 0.4;
26
27/// Staleness increment per entry point change.
28const ENTRY_POINT_WEIGHT: f32 = 0.15;
29
30/// Staleness increment per import change.
31const IMPORT_WEIGHT: f32 = 0.10;
32
33/// Staleness increment when TODOs change.
34const TODOS_WEIGHT: f32 = 0.05;
35
36/// Staleness increment per unsafe block change.
37const UNSAFE_WEIGHT: f32 = 0.10;
38
39/// Staleness increment per unwrap change.
40const UNWRAP_WEIGHT: f32 = 0.05;
41
42/// Staleness increment cascaded to linked gotchas.
43const CASCADE_WEIGHT: f32 = 0.10;
44
45/// Diff between old and new file analysis — drives staleness signals.
46#[derive(Debug, Clone)]
47pub struct ReparseDiff {
48    pub entry_points_added: Vec<String>,
49    pub entry_points_removed: Vec<String>,
50    pub imports_added: Vec<String>,
51    pub imports_removed: Vec<String>,
52    pub todos_changed: bool,
53    pub unsafe_delta: i32,
54    pub unwrap_delta: i32,
55}
56
57impl ReparseDiff {
58    /// True when no structural changes were detected.
59    pub fn is_empty(&self) -> bool {
60        self.entry_points_added.is_empty()
61            && self.entry_points_removed.is_empty()
62            && self.imports_added.is_empty()
63            && self.imports_removed.is_empty()
64            && !self.todos_changed
65            && self.unsafe_delta == 0
66            && self.unwrap_delta == 0
67    }
68}
69
70/// Apply reparse-derived staleness signals to a record's `StalenessScore`.
71///
72/// Returns the new signals added (empty if diff is empty). The record's
73/// staleness value/tier/signals/computed_at are updated in place.
74pub fn apply_reparse_staleness(record: &mut Record, diff: &ReparseDiff) -> Vec<StalenessSignal> {
75    if diff.is_empty() {
76        return vec![];
77    }
78
79    let now = SystemTime::now()
80        .duration_since(UNIX_EPOCH)
81        .unwrap_or_default()
82        .as_secs();
83
84    let mut new_signals = Vec::new();
85    let mut increment: f32 = 0.0;
86
87    let ep_changes = (diff.entry_points_added.len() + diff.entry_points_removed.len()) as u32;
88    if ep_changes > 0 {
89        let signal = StalenessSignal::EntryPointsChanged(ep_changes);
90        new_signals.push(signal);
91        increment += ep_changes as f32 * ENTRY_POINT_WEIGHT;
92    }
93
94    let import_changes = (diff.imports_added.len() + diff.imports_removed.len()) as u32;
95    if import_changes > 0 {
96        let signal = StalenessSignal::ImportsChanged(import_changes);
97        new_signals.push(signal);
98        increment += import_changes as f32 * IMPORT_WEIGHT;
99    }
100
101    if diff.todos_changed {
102        new_signals.push(StalenessSignal::TodosChanged);
103        increment += TODOS_WEIGHT;
104    }
105
106    if diff.unsafe_delta != 0 {
107        new_signals.push(StalenessSignal::UnsafeCountChanged(diff.unsafe_delta));
108        increment += diff.unsafe_delta.unsigned_abs() as f32 * UNSAFE_WEIGHT;
109    }
110
111    if diff.unwrap_delta != 0 {
112        new_signals.push(StalenessSignal::UnwrapCountChanged(diff.unwrap_delta));
113        increment += diff.unwrap_delta.unsigned_abs() as f32 * UNWRAP_WEIGHT;
114    }
115
116    // Cap increment
117    increment = increment.min(MAX_REPARSE_INCREMENT);
118
119    // Update score
120    let new_value = (record.staleness.value + increment).min(1.0);
121    record.staleness.value = new_value;
122    record.staleness.tier = StalenessScore::tier_from_value(new_value);
123    record.staleness.computed_at = now;
124    record.staleness.signals.extend(new_signals.clone());
125
126    // Cap signal history to prevent unbounded growth
127    const MAX_SIGNALS: usize = 20;
128    if record.staleness.signals.len() > MAX_SIGNALS {
129        let drain_count = record.staleness.signals.len() - MAX_SIGNALS;
130        record.staleness.signals.drain(..drain_count);
131    }
132
133    new_signals
134}
135
136/// Cascade staleness to gotcha records linked from this file record.
137///
138/// For each `gotcha_keys` entry: add `LinkedFileChanged`, bump staleness by 0.10.
139pub async fn cascade_staleness_to_gotchas(store: &Store, file_record: &FileRecord) -> Result<u32> {
140    if file_record.gotcha_keys.is_empty() {
141        return Ok(0);
142    }
143
144    let now = SystemTime::now()
145        .duration_since(UNIX_EPOCH)
146        .unwrap_or_default()
147        .as_secs();
148
149    let mut cascaded = 0u32;
150
151    for gotcha_key in &file_record.gotcha_keys {
152        if let Some(mut gotcha_record) = store.get(gotcha_key).await? {
153            let signal = StalenessSignal::LinkedFileChanged {
154                path: file_record.path.clone(),
155            };
156
157            let new_value = (gotcha_record.staleness.value + CASCADE_WEIGHT).min(1.0);
158            gotcha_record.staleness.value = new_value;
159            gotcha_record.staleness.tier = StalenessScore::tier_from_value(new_value);
160            gotcha_record.staleness.computed_at = now;
161            gotcha_record.staleness.signals.push(signal);
162
163            const MAX_SIGNALS: usize = 20;
164            if gotcha_record.staleness.signals.len() > MAX_SIGNALS {
165                let drain_count = gotcha_record.staleness.signals.len() - MAX_SIGNALS;
166                gotcha_record.staleness.signals.drain(..drain_count);
167            }
168
169            gotcha_record.updated_at = now;
170            gotcha_record.version.logical_clock += 1;
171            gotcha_record.version.wall_clock = now;
172
173            store.put(gotcha_key, &gotcha_record).await?;
174            cascaded += 1;
175        }
176    }
177
178    Ok(cascaded)
179}
180
181// ── M-13-A: StalenessAnalyzer — full 5-factor staleness computation ──────────
182
183/// Seconds in one day.
184const SECS_PER_DAY: f64 = 86_400.0;
185
186/// Number of days after which the time factor reaches 1.0.
187const TIME_STALE_DAYS: f64 = 90.0;
188
189/// Weight for the time-based staleness factor.
190const TIME_WEIGHT: f32 = 0.20;
191
192/// Weight for the git-based staleness factor.
193const GIT_WEIGHT: f32 = 0.35;
194
195/// Weight for the semantic staleness factor (v0.1: always 0.0).
196#[allow(dead_code)]
197const SEMANTIC_WEIGHT: f32 = 0.25;
198
199/// Weight for the dependency staleness factor.
200const DEP_WEIGHT: f32 = 0.10;
201
202/// Weight multiplier for the cascade staleness factor.
203const CASCADE_WEIGHT_FACTOR: f32 = 0.10;
204
205/// Maximum commits examined during a revwalk before bailing out.
206const GIT_REVWALK_LIMIT: usize = 2000;
207
208/// When revwalk hits the cap without finding the since-SHA, assume this many
209/// commits have occurred (conservative staleness signal).
210const GIT_CAP_HIT_COMMITS: u32 = 3;
211
212/// Maximum number of recompute signals to preserve from reparse (M-12).
213const MAX_RECOMPUTE_SIGNALS: usize = 10;
214
215/// Time budget for `analyze_all` in milliseconds. After this, stop processing
216/// new records and write out whatever was computed.
217const ANALYZE_TIME_BUDGET_MS: u64 = 2000;
218
219/// Record key prefixes that the analyzer scans.
220const STALENESS_PREFIXES: &[&str] = &["file:", "gotcha:", "decision:", "dep:", "dev_note:"];
221
222/// 24 hours in seconds — window for reparse signal preservation.
223const REPARSE_WINDOW_SECS: u64 = 86_400;
224
225// ── StalenessReport ─────────────────────────────────────────────────────────
226
227/// Summary of a full `analyze_all` pass.
228#[derive(Debug, Clone)]
229pub struct StalenessReport {
230    /// Total records scanned.
231    pub scanned: u32,
232    /// Records whose staleness was updated.
233    pub updated: u32,
234    /// Records moved to Tombstone.
235    pub tombstoned: u32,
236    /// Records moved to Liability.
237    pub liability: u32,
238    /// Records above Stale tier threshold.
239    pub stale: u32,
240}
241
242// ── StalenessAnalyzer ───────────────────────────────────────────────────────
243
244/// Full staleness analyzer using the 5-factor formula from ARCHITECTURE.md §17.
245///
246/// Opened once per CLI invocation, reuses the git2 repo handle and cached HEAD.
247pub struct StalenessAnalyzer {
248    repo: Option<git2::Repository>,
249    now: u64,
250    head_commit: Option<String>,
251}
252
253impl StalenessAnalyzer {
254    /// Open the analyzer. `repo_path` should be the project root containing `.git`.
255    ///
256    /// If the git repo cannot be opened, the analyzer proceeds with git_factor = 0.0.
257    pub fn new(repo_path: &Path) -> Self {
258        let now = SystemTime::now()
259            .duration_since(UNIX_EPOCH)
260            .unwrap_or_default()
261            .as_secs();
262
263        let repo = git2::Repository::open(repo_path).ok();
264        let head_commit = repo.as_ref().and_then(head_commit_sha);
265
266        Self {
267            repo,
268            now,
269            head_commit,
270        }
271    }
272
273    /// Test-only constructor that allows injecting a fixed `now` timestamp.
274    #[cfg(test)]
275    fn new_with_now(repo_path: &Path, now: u64) -> Self {
276        let repo = git2::Repository::open(repo_path).ok();
277        let head_commit = repo.as_ref().and_then(head_commit_sha);
278
279        Self {
280            repo,
281            now,
282            head_commit,
283        }
284    }
285
286    /// Scan all staleness-eligible prefixes, recompute scores, and batch-write
287    /// updated records. Respects a 2-second time budget — partial results are
288    /// written if the budget is exceeded.
289    pub async fn analyze_all(&self, store: &Store) -> Result<StalenessReport> {
290        let deadline = Instant::now() + std::time::Duration::from_millis(ANALYZE_TIME_BUDGET_MS);
291
292        let mut report = StalenessReport {
293            scanned: 0,
294            updated: 0,
295            tombstoned: 0,
296            liability: 0,
297            stale: 0,
298        };
299
300        // Pre-load dep records for dep_factor lookups.
301        let dep_records = store.scan_prefix("dep:").await.unwrap_or_default();
302        let dep_cache: HashMap<String, Record> = dep_records
303            .into_iter()
304            .map(|r| (r.key.clone(), r))
305            .collect();
306
307        let mut updates: Vec<(String, Record)> = Vec::new();
308
309        for prefix in STALENESS_PREFIXES {
310            if Instant::now() >= deadline {
311                tracing::warn!(
312                    "staleness analyze_all: time budget exceeded after {} records",
313                    report.scanned
314                );
315                break;
316            }
317
318            let records = match store.scan_prefix(prefix).await {
319                Ok(r) => r,
320                Err(e) => {
321                    tracing::warn!("staleness scan_prefix({prefix}) failed: {e}");
322                    continue;
323                }
324            };
325
326            for record in records {
327                if Instant::now() >= deadline {
328                    tracing::warn!(
329                        "staleness analyze_all: time budget exceeded mid-prefix at {} records",
330                        report.scanned
331                    );
332                    break;
333                }
334
335                report.scanned += 1;
336
337                // Skip non-active records.
338                if !matches!(record.lifecycle, RecordLifecycle::Active) {
339                    continue;
340                }
341
342                let mut updated = record.clone();
343                match self
344                    .compute_staleness(&mut updated, store, &dep_cache)
345                    .await
346                {
347                    Ok(()) => {}
348                    Err(e) => {
349                        tracing::warn!("staleness compute for {} failed: {e}", record.key);
350                        continue;
351                    }
352                }
353
354                if staleness_changed(&record, &updated) {
355                    // Track tier counts.
356                    match updated.staleness.tier {
357                        StalenessTier::Tombstone => report.tombstoned += 1,
358                        StalenessTier::Liability => report.liability += 1,
359                        StalenessTier::Stale => report.stale += 1,
360                        _ => {}
361                    }
362
363                    updated.updated_at = self.now;
364                    updated.version.logical_clock += 1;
365                    updated.version.wall_clock = self.now;
366
367                    updates.push((updated.key.clone(), updated));
368                    report.updated += 1;
369                }
370            }
371        }
372
373        // Batch write all updates.
374        // Reporting must reflect persisted reality, not attempted computation.
375        // If the batch write fails, surface that failure so callers do not log a
376        // successful analysis based on in-memory-only updates.
377        if !updates.is_empty() {
378            let batch: Vec<(&str, &Record)> =
379                updates.iter().map(|(k, r)| (k.as_str(), r)).collect();
380            store.put_batch(&batch).await.with_context(|| {
381                format!("staleness batch write failed for {} records", batch.len())
382            })?;
383        }
384
385        Ok(report)
386    }
387
388    /// Compute the 5-factor staleness score for a single record.
389    ///
390    /// Modifies the record in-place. Returns `Ok(())` on success.
391    async fn compute_staleness(
392        &self,
393        record: &mut Record,
394        store: &Store,
395        dep_cache: &HashMap<String, Record>,
396    ) -> Result<()> {
397        // Parse FileRecord once if this is a file: record.
398        let file_record: Option<FileRecord> = if record.key.starts_with("file:") {
399            record.payload_as::<FileRecord>()
400        } else {
401            None
402        };
403
404        // ── Hard override: FileDeleted ──────────────────────────────────────
405        // Check if there's already a FileDeleted signal. If so, verify the file
406        // is still deleted on disk. If the file was restored, clear the override.
407        if record
408            .staleness
409            .signals
410            .iter()
411            .any(|s| matches!(s, StalenessSignal::FileDeleted))
412        {
413            let path = record.key.strip_prefix("file:").unwrap_or(&record.key);
414            if Path::new(path).exists() {
415                // File was restored — clear the FileDeleted signal.
416                record
417                    .staleness
418                    .signals
419                    .retain(|s| !matches!(s, StalenessSignal::FileDeleted));
420            } else {
421                // Still deleted — tombstone.
422                record.staleness.value = 1.0;
423                record.staleness.tier = StalenessTier::Tombstone;
424                record.staleness.computed_at = self.now;
425                return Ok(());
426            }
427        }
428
429        // Check if file: record's file no longer exists on disk (new detection).
430        if record.key.starts_with("file:") {
431            let path = record.key.strip_prefix("file:").unwrap_or(&record.key);
432            if !path.is_empty() && !Path::new(path).exists() {
433                record.staleness.signals.push(StalenessSignal::FileDeleted);
434                record.staleness.value = 1.0;
435                record.staleness.tier = StalenessTier::Tombstone;
436                record.staleness.computed_at = self.now;
437                return Ok(());
438            }
439        }
440
441        // ── Hard override: FileRenamed ──────────────────────────────────────
442        let has_rename = record
443            .staleness
444            .signals
445            .iter()
446            .any(|s| matches!(s, StalenessSignal::FileRenamed { .. }));
447        if has_rename {
448            // Find the new_path from the signal.
449            let new_path_exists = record.staleness.signals.iter().any(|s| {
450                if let StalenessSignal::FileRenamed { new_path } = s {
451                    Path::new(new_path).exists()
452                } else {
453                    false
454                }
455            });
456            if new_path_exists {
457                // Rename still unresolved — liability.
458                record.staleness.value = 0.85;
459                record.staleness.tier = StalenessTier::Liability;
460                record.staleness.computed_at = self.now;
461                return Ok(());
462            }
463            // new_path no longer exists either — fall through to normal computation.
464            // The rename signal will be retained as historical context.
465        }
466
467        // ── Snapshot reparse signals for preservation check ─────────────────
468        let reparse_signals: Vec<StalenessSignal> = record
469            .staleness
470            .signals
471            .iter()
472            .filter(|s| is_reparse_signal(s))
473            .cloned()
474            .collect();
475        let had_recent_reparse = record.staleness.computed_at > 0
476            && self.now.saturating_sub(record.staleness.computed_at) < REPARSE_WINDOW_SECS
477            && !reparse_signals.is_empty();
478        let old_value = record.staleness.value;
479
480        // ── 5-factor computation ────────────────────────────────────────────
481        let time_f = time_factor(record, self.now);
482
483        let (git_f, new_sha) = if let Some(ref repo) = self.repo {
484            let path_str = record.key.strip_prefix("file:").unwrap_or(&record.key);
485            self.git_factor(repo, path_str, &record.staleness.last_record_sha)
486        } else {
487            (0.0_f32, None)
488        };
489
490        let semantic_f = semantic_factor();
491
492        let dep_f = dep_factor(file_record.as_ref(), dep_cache);
493
494        let cascade_f = cascade_factor(record, file_record.as_ref(), store).await;
495
496        let raw_value = time_f * TIME_WEIGHT
497            + git_f * GIT_WEIGHT
498            + semantic_f * SEMANTIC_WEIGHT
499            + dep_f * DEP_WEIGHT
500            + cascade_f * CASCADE_WEIGHT_FACTOR;
501
502        let clamped = raw_value.clamp(0.0, 1.0);
503
504        // ── Reparse signal preservation ─────────────────────────────────────
505        // If recent reparse signals exist (within 24h) and the analyzer would
506        // reduce the score, preserve the higher value.
507        let final_value = if had_recent_reparse && clamped < old_value {
508            old_value
509        } else {
510            clamped
511        };
512
513        // ── Build new signals list ──────────────────────────────────────────
514        let mut new_signals = Vec::new();
515
516        // Preserve reparse signals (capped).
517        if had_recent_reparse {
518            for sig in reparse_signals.iter().take(MAX_RECOMPUTE_SIGNALS) {
519                new_signals.push(sig.clone());
520            }
521        }
522
523        // Add git signal if commits were found.
524        if git_f > 0.0 {
525            // Use LinesChangedPct as a proxy for git factor until GitCommitsSince
526            // is added to StalenessSignal in record.rs by a separate agent.
527            new_signals.push(StalenessSignal::LinesChangedPct(git_f));
528        }
529
530        // Cap total signals.
531        const MAX_SIGNALS: usize = 20;
532        if new_signals.len() > MAX_SIGNALS {
533            let drain_count = new_signals.len() - MAX_SIGNALS;
534            new_signals.drain(..drain_count);
535        }
536
537        // ── Apply ───────────────────────────────────────────────────────────
538        record.staleness.value = final_value;
539        record.staleness.tier = StalenessScore::tier_from_value(final_value);
540        record.staleness.computed_at = self.now;
541        record.staleness.signals = new_signals;
542
543        if let Some(sha) = new_sha {
544            record.staleness.last_record_sha = sha;
545        }
546
547        Ok(())
548    }
549
550    // ── Git factor ──────────────────────────────────────────────────────────
551
552    /// Two-phase git factor:
553    /// 1. O(1) blob comparison: compare blob SHA at HEAD vs blob SHA at stored commit.
554    /// 2. If changed, revwalk to count commits since stored SHA.
555    ///
556    /// When `last_record_sha` is empty, set baseline (return 0.0 + HEAD SHA).
557    fn git_factor(
558        &self,
559        repo: &git2::Repository,
560        path: &str,
561        last_record_sha: &str,
562    ) -> (f32, Option<String>) {
563        let head_sha = match &self.head_commit {
564            Some(sha) => sha.clone(),
565            None => return (0.0, None),
566        };
567
568        // No baseline established — set it now, no staleness.
569        if last_record_sha.is_empty() {
570            return (0.0, Some(head_sha));
571        }
572
573        // Already at HEAD — no change.
574        if last_record_sha == head_sha {
575            return (0.0, None);
576        }
577
578        // Phase 1: O(1) blob comparison.
579        let blob_at_head = blob_sha_at_head(repo, path);
580        let blob_at_record = blob_sha_at_commit(repo, path, last_record_sha);
581
582        match (blob_at_head, blob_at_record) {
583            (Some(ref h), Some(ref r)) if h == r => {
584                // File content unchanged — update SHA to HEAD but no staleness.
585                return (0.0, Some(head_sha));
586            }
587            (None, _) => {
588                // File not in HEAD tree — might be deleted. Return small signal.
589                return (0.0, Some(head_sha));
590            }
591            _ => {
592                // File changed — phase 2: count commits.
593            }
594        }
595
596        // Phase 2: revwalk to count commits since stored SHA.
597        let count = self.count_commits_since(repo, path, last_record_sha);
598        let factor = commits_to_factor(count);
599
600        (factor, Some(head_sha))
601    }
602
603    /// Count commits touching `path` since `since_sha`, respecting GIT_REVWALK_LIMIT.
604    ///
605    /// Uses `total_iterations` (not walked-commit counter) for consistent
606    /// merge-commit handling. Returns GIT_CAP_HIT_COMMITS if the revwalk cap
607    /// is hit without finding `since_sha` and no commits were counted.
608    fn count_commits_since(&self, repo: &git2::Repository, path: &str, since_sha: &str) -> u32 {
609        let head_oid = match repo.head().ok().and_then(|h| h.target()) {
610            Some(oid) => oid,
611            None => return 0,
612        };
613
614        let mut revwalk = match repo.revwalk() {
615            Ok(rw) => rw,
616            Err(_) => return 0,
617        };
618
619        if revwalk.push(head_oid).is_err() {
620            return 0;
621        }
622
623        // Sort topologically for consistent traversal.
624        revwalk.set_sorting(git2::Sort::TOPOLOGICAL).ok();
625
626        let mut count: u32 = 0;
627        let mut total_iterations: usize = 0;
628        let mut found_since = false;
629
630        for oid_result in revwalk {
631            total_iterations += 1;
632            if total_iterations > GIT_REVWALK_LIMIT {
633                break;
634            }
635
636            let oid = match oid_result {
637                Ok(o) => o,
638                Err(_) => continue,
639            };
640
641            // Check if we've reached the since-SHA.
642            let oid_str = oid.to_string();
643            if oid_str == since_sha {
644                found_since = true;
645                break;
646            }
647
648            if commit_touches_file(repo, oid, path) {
649                count += 1;
650            }
651        }
652
653        // Cap hit without finding since_sha and no commits counted.
654        if !found_since && count == 0 && total_iterations >= GIT_REVWALK_LIMIT {
655            return GIT_CAP_HIT_COMMITS;
656        }
657
658        count
659    }
660}
661
662// ── Factor functions ────────────────────────────────────────────────────────
663
664/// Time factor: linear ramp from 0.0 to 1.0 over TIME_STALE_DAYS.
665///
666/// Uses `max(updated_at, last_accessed)` as the reference timestamp, NOT
667/// `computed_at` (which is when staleness was last recalculated, not when the
668/// record was last meaningfully touched).
669fn time_factor(record: &Record, now: u64) -> f32 {
670    let last_touch = record.updated_at.max(record.last_accessed);
671    if last_touch == 0 || last_touch >= now {
672        return 0.0;
673    }
674
675    let elapsed_secs = (now - last_touch) as f64;
676    let elapsed_days = elapsed_secs / SECS_PER_DAY;
677    let factor = (elapsed_days / TIME_STALE_DAYS).min(1.0);
678
679    factor as f32
680}
681
682/// Semantic factor: placeholder for v0.1 — always returns 0.0.
683///
684/// In v0.2+ this will use candle embeddings to measure semantic drift between
685/// the record's content and the current file content.
686fn semantic_factor() -> f32 {
687    0.0
688}
689
690/// Dependency factor: checks if any imports in the file record reference
691/// dependencies whose versions have been bumped since the record was last
692/// updated.
693///
694/// LIMITATION (v0.1): Only parses Rust `use` statements from `FileRecord.imports`.
695/// Other languages (TypeScript, Python, Go) will be supported when their import
696/// parsers emit normalized dependency names.
697fn dep_factor(file_record: Option<&FileRecord>, dep_cache: &HashMap<String, Record>) -> f32 {
698    let fr = match file_record {
699        Some(fr) => fr,
700        None => return 0.0,
701    };
702
703    if fr.imports.is_empty() || dep_cache.is_empty() {
704        return 0.0;
705    }
706
707    let mut bumped_count = 0u32;
708    let mut checked_count = 0u32;
709
710    for import in &fr.imports {
711        let dep_record = dep_lookup_keys(import)
712            .into_iter()
713            .find_map(|key| dep_cache.get(&key));
714
715        if let Some(dep_record) = dep_record {
716            checked_count += 1;
717
718            // Check if the dep was updated more recently than the file record.
719            if dep_record.updated_at > fr.last_modified_session {
720                // Check for DependencyBumped signal on the dep record.
721                let has_bump_signal = dep_record
722                    .staleness
723                    .signals
724                    .iter()
725                    .any(|s| matches!(s, StalenessSignal::DependencyBumped { .. }));
726                if has_bump_signal || dep_record.updated_at > fr.last_modified_session + 1 {
727                    bumped_count += 1;
728                }
729            }
730        }
731    }
732
733    if checked_count == 0 {
734        return 0.0;
735    }
736
737    // More bumped deps -> higher staleness. Cap at 1.0.
738    let ratio = bumped_count as f32 / checked_count as f32;
739    ratio.min(1.0)
740}
741
742fn dep_lookup_keys(import: &str) -> Vec<String> {
743    let mut keys = Vec::new();
744
745    if let Some(crate_name) = import
746        .split("::")
747        .next()
748        .filter(|name| *name != import || import.contains("::"))
749    {
750        push_dep_key(&mut keys, "cargo", crate_name);
751        keys.push(format!("dep:{crate_name}"));
752    }
753
754    if let Some(package_name) = npm_package_name(import) {
755        push_dep_key(&mut keys, "npm", package_name);
756        keys.push(format!("dep:{package_name}"));
757    }
758
759    for module_name in go_module_candidates(import) {
760        push_dep_key(&mut keys, "go", &module_name);
761        keys.push(format!("dep:{module_name}"));
762    }
763
764    keys
765}
766
767fn push_dep_key(keys: &mut Vec<String>, ecosystem: &str, name: &str) {
768    let key = format!("dep:{ecosystem}:{name}");
769    if !keys.contains(&key) {
770        keys.push(key);
771    }
772}
773
774fn npm_package_name(import: &str) -> Option<&str> {
775    if import.is_empty()
776        || import.starts_with('.')
777        || import.starts_with('/')
778        || import.contains("::")
779        || first_path_segment(import).is_some_and(|seg| seg.contains('.'))
780    {
781        return None;
782    }
783
784    if import.starts_with('@') {
785        let mut segments = import.split('/');
786        let scope = segments.next()?;
787        let package = segments.next()?;
788        let len = scope.len() + 1 + package.len();
789        Some(&import[..len])
790    } else {
791        first_path_segment(import)
792    }
793}
794
795fn go_module_candidates(import: &str) -> Vec<String> {
796    if import.is_empty()
797        || import.starts_with('.')
798        || import.starts_with('/')
799        || import.contains("::")
800        || !first_path_segment(import).is_some_and(|seg| seg.contains('.'))
801    {
802        return Vec::new();
803    }
804
805    let segments: Vec<&str> = import.split('/').collect();
806    let mut modules = Vec::new();
807    for len in (2..=segments.len()).rev() {
808        let module = segments[..len].join("/");
809        if !modules.contains(&module) {
810            modules.push(module);
811        }
812    }
813    modules
814}
815
816fn first_path_segment(import: &str) -> Option<&str> {
817    import
818        .split('/')
819        .next()
820        .filter(|segment| !segment.is_empty())
821}
822
823/// Cascade factor: for file records, check if linked gotchas/decisions are stale.
824/// For gotcha records, check if affected_files have stale records.
825async fn cascade_factor(record: &Record, file_record: Option<&FileRecord>, store: &Store) -> f32 {
826    match record.category {
827        Category::File => {
828            let fr = match file_record {
829                Some(fr) => fr,
830                None => return 0.0,
831            };
832
833            let linked_keys: Vec<&str> = fr
834                .gotcha_keys
835                .iter()
836                .chain(fr.decision_keys.iter())
837                .map(|s| s.as_str())
838                .collect();
839
840            if linked_keys.is_empty() {
841                return 0.0;
842            }
843
844            let mut stale_count = 0u32;
845            for key in &linked_keys {
846                if let Ok(Some(linked)) = store.get(key).await {
847                    if linked.staleness.value >= 0.4 {
848                        stale_count += 1;
849                    }
850                }
851            }
852
853            if stale_count == 0 {
854                return 0.0;
855            }
856
857            let ratio = stale_count as f32 / linked_keys.len() as f32;
858            ratio.min(1.0)
859        }
860        Category::Gotcha => {
861            // Parse the gotcha to find affected_files.
862            let gotcha: Option<GotchaRecord> = record.payload_as::<GotchaRecord>();
863            let gotcha = match gotcha {
864                Some(g) => g,
865                None => return 0.0,
866            };
867
868            if gotcha.affected_files.is_empty() {
869                return 0.0;
870            }
871
872            let mut stale_count = 0u32;
873            for path in &gotcha.affected_files {
874                let file_key = format!("file:{path}");
875                if let Ok(Some(file_rec)) = store.get(&file_key).await {
876                    if file_rec.staleness.value >= 0.4 {
877                        stale_count += 1;
878                    }
879                }
880            }
881
882            if stale_count == 0 {
883                return 0.0;
884            }
885
886            let ratio = stale_count as f32 / gotcha.affected_files.len() as f32;
887            ratio.min(1.0)
888        }
889        _ => 0.0,
890    }
891}
892
893// ── Git helper functions ────────────────────────────────────────────────────
894
895/// Get the SHA string of HEAD commit, if available.
896fn head_commit_sha(repo: &git2::Repository) -> Option<String> {
897    repo.head().ok()?.target().map(|oid| oid.to_string())
898}
899
900/// Get the blob SHA for a file at HEAD.
901fn blob_sha_at_head(repo: &git2::Repository, path: &str) -> Option<String> {
902    let head_ref = repo.head().ok()?;
903    let commit = head_ref.peel_to_commit().ok()?;
904    let tree = commit.tree().ok()?;
905    let entry = tree.get_path(Path::new(path)).ok()?;
906    Some(entry.id().to_string())
907}
908
909/// Get the blob SHA for a file at a specific commit.
910fn blob_sha_at_commit(repo: &git2::Repository, path: &str, commit_sha: &str) -> Option<String> {
911    let oid = git2::Oid::from_str(commit_sha).ok()?;
912    let commit = repo.find_commit(oid).ok()?;
913    let tree = commit.tree().ok()?;
914    let entry = tree.get_path(Path::new(path)).ok()?;
915    Some(entry.id().to_string())
916}
917
918/// Count recent commits touching a file path, with a total iteration limit
919/// for consistent merge-commit handling.
920#[allow(dead_code)]
921fn count_recent_commits(repo: &git2::Repository, path: &str, limit: usize) -> u32 {
922    let head_oid = match repo.head().ok().and_then(|h| h.target()) {
923        Some(oid) => oid,
924        None => return 0,
925    };
926
927    let mut revwalk = match repo.revwalk() {
928        Ok(rw) => rw,
929        Err(_) => return 0,
930    };
931
932    if revwalk.push(head_oid).is_err() {
933        return 0;
934    }
935
936    revwalk.set_sorting(git2::Sort::TOPOLOGICAL).ok();
937
938    let mut count: u32 = 0;
939    let mut total_iterations: usize = 0;
940
941    for oid_result in revwalk {
942        total_iterations += 1;
943        if total_iterations > limit {
944            break;
945        }
946
947        let oid = match oid_result {
948            Ok(o) => o,
949            Err(_) => continue,
950        };
951
952        if commit_touches_file(repo, oid, path) {
953            count += 1;
954        }
955    }
956
957    count
958}
959
960/// Map a commit count to a staleness factor.
961///
962/// ```text
963/// 0 -> 0.00
964/// 1 -> 0.15
965/// 2 -> 0.30
966/// 3 -> 0.50
967/// 4 -> 0.70
968/// 5+ -> 1.00
969/// ```
970fn commits_to_factor(commits: u32) -> f32 {
971    match commits {
972        0 => 0.0,
973        1 => 0.15,
974        2 => 0.30,
975        3 => 0.50,
976        4 => 0.70,
977        _ => 1.0,
978    }
979}
980
981/// Check whether a commit touches a specific file by comparing tree entries
982/// with the parent commit's tree.
983fn commit_touches_file(repo: &git2::Repository, commit_oid: git2::Oid, path: &str) -> bool {
984    let commit = match repo.find_commit(commit_oid) {
985        Ok(c) => c,
986        Err(_) => return false,
987    };
988
989    let tree = match commit.tree() {
990        Ok(t) => t,
991        Err(_) => return false,
992    };
993
994    let file_entry = tree.get_path(Path::new(path)).ok();
995
996    // If no parents, this is the initial commit — file is touched if it exists.
997    if commit.parent_count() == 0 {
998        return file_entry.is_some();
999    }
1000
1001    // Compare with first parent.
1002    let parent = match commit.parent(0) {
1003        Ok(p) => p,
1004        Err(_) => return file_entry.is_some(),
1005    };
1006
1007    let parent_tree = match parent.tree() {
1008        Ok(t) => t,
1009        Err(_) => return file_entry.is_some(),
1010    };
1011
1012    let parent_entry = parent_tree.get_path(Path::new(path)).ok();
1013
1014    match (file_entry, parent_entry) {
1015        (Some(cur), Some(par)) => cur.id() != par.id(),
1016        (Some(_), None) => true, // File added.
1017        (None, Some(_)) => true, // File deleted.
1018        (None, None) => false,
1019    }
1020}
1021
1022/// Determine whether an old and new staleness state differ enough to warrant
1023/// writing the updated record.
1024///
1025/// Checks: value delta > 0.01, tier change, signal count change, AND
1026/// last_record_sha change.
1027fn staleness_changed(old: &Record, new: &Record) -> bool {
1028    let value_delta = (old.staleness.value - new.staleness.value).abs();
1029    if value_delta > 0.01 {
1030        return true;
1031    }
1032
1033    if old.staleness.tier != new.staleness.tier {
1034        return true;
1035    }
1036
1037    if old.staleness.signals.len() != new.staleness.signals.len() {
1038        return true;
1039    }
1040
1041    if old.staleness.last_record_sha != new.staleness.last_record_sha {
1042        return true;
1043    }
1044
1045    false
1046}
1047
1048/// Returns true if a signal is one generated by the reparse (M-12) pipeline.
1049fn is_reparse_signal(signal: &StalenessSignal) -> bool {
1050    matches!(
1051        signal,
1052        StalenessSignal::EntryPointsChanged(_)
1053            | StalenessSignal::ImportsChanged(_)
1054            | StalenessSignal::TodosChanged
1055            | StalenessSignal::UnsafeCountChanged(_)
1056            | StalenessSignal::UnwrapCountChanged(_)
1057    )
1058}
1059
1060// ── Tests ────────────────────────────────────────────────────────────────────
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065    use crate::store::record::*;
1066    use tempfile::TempDir;
1067
1068    fn make_file_record_with_staleness(value: f32) -> Record {
1069        Record {
1070            key: "file:src/main.rs".to_string(),
1071            value: String::new(),
1072            category: Category::File,
1073            priority: Priority::Normal,
1074            tags: vec![],
1075            created_at: 1_000_000,
1076            updated_at: 1_000_000,
1077            ref_url: None,
1078            staleness: StalenessScore {
1079                value,
1080                tier: StalenessScore::tier_from_value(value),
1081                signals: vec![],
1082                computed_at: 0,
1083                last_record_sha: String::new(),
1084            },
1085            lifecycle: RecordLifecycle::Active,
1086            version: RecordVersion {
1087                device_id: uuid::Uuid::new_v4(),
1088                logical_clock: 1,
1089                wall_clock: 1_000_000,
1090            },
1091            quality: QualityScore::layer0_default(),
1092            access_count: 0,
1093            last_accessed: 0,
1094            source: RecordSource::StaticAnalysis,
1095            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
1096            gap_analysis_score: 0.0,
1097            payload: None,
1098        }
1099    }
1100
1101    fn make_gotcha_record(key: &str) -> Record {
1102        let gotcha = GotchaRecord {
1103            rule: "test rule".into(),
1104            reason: "test reason".into(),
1105            severity: Priority::High,
1106            affected_files: vec!["src/main.rs".into()],
1107            ref_url: None,
1108            discovered_session: 0,
1109            confirmed: true,
1110        };
1111        Record {
1112            key: key.to_string(),
1113            value: gotcha.rule.clone(),
1114            payload: serde_json::to_value(&gotcha).ok(),
1115            category: Category::Gotcha,
1116            priority: Priority::High,
1117            tags: vec![],
1118            created_at: 1_000_000,
1119            updated_at: 1_000_000,
1120            ref_url: None,
1121            staleness: StalenessScore::fresh(),
1122            lifecycle: RecordLifecycle::Active,
1123            version: RecordVersion {
1124                device_id: uuid::Uuid::new_v4(),
1125                logical_clock: 1,
1126                wall_clock: 1_000_000,
1127            },
1128            quality: QualityScore::layer0_default(),
1129            access_count: 0,
1130            last_accessed: 0,
1131            source: RecordSource::DeveloperManual,
1132            confidence: ConfidenceScore::for_new_record(&RecordSource::DeveloperManual),
1133            gap_analysis_score: 0.0,
1134        }
1135    }
1136
1137    fn empty_diff() -> ReparseDiff {
1138        ReparseDiff {
1139            entry_points_added: vec![],
1140            entry_points_removed: vec![],
1141            imports_added: vec![],
1142            imports_removed: vec![],
1143            todos_changed: false,
1144            unsafe_delta: 0,
1145            unwrap_delta: 0,
1146        }
1147    }
1148
1149    #[test]
1150    fn empty_diff_produces_no_signals() {
1151        let mut record = make_file_record_with_staleness(0.0);
1152        let signals = apply_reparse_staleness(&mut record, &empty_diff());
1153        assert!(signals.is_empty());
1154        assert!(record.staleness.value < 0.01);
1155    }
1156
1157    #[test]
1158    fn entry_point_changes_bump_staleness() {
1159        let mut record = make_file_record_with_staleness(0.0);
1160        let diff = ReparseDiff {
1161            entry_points_added: vec!["new_fn".into()],
1162            entry_points_removed: vec!["old_fn".into()],
1163            ..empty_diff()
1164        };
1165        let signals = apply_reparse_staleness(&mut record, &diff);
1166        assert_eq!(signals.len(), 1);
1167        assert!((record.staleness.value - 0.30).abs() < 0.01);
1168        assert_eq!(record.staleness.tier, StalenessTier::Aging);
1169    }
1170
1171    #[test]
1172    fn import_changes_bump_staleness() {
1173        let mut record = make_file_record_with_staleness(0.0);
1174        let diff = ReparseDiff {
1175            imports_added: vec!["new_dep".into()],
1176            ..empty_diff()
1177        };
1178        let signals = apply_reparse_staleness(&mut record, &diff);
1179        assert_eq!(signals.len(), 1);
1180        assert!((record.staleness.value - 0.10).abs() < 0.01);
1181    }
1182
1183    #[test]
1184    fn increment_capped_at_max() {
1185        let mut record = make_file_record_with_staleness(0.0);
1186        let diff = ReparseDiff {
1187            entry_points_added: vec!["a".into(), "b".into(), "c".into(), "d".into()],
1188            imports_added: vec!["x".into(), "y".into(), "z".into()],
1189            ..empty_diff()
1190        };
1191        let _signals = apply_reparse_staleness(&mut record, &diff);
1192        // 4*0.15 + 3*0.10 = 0.90, capped at 0.40
1193        assert!((record.staleness.value - 0.40).abs() < 0.01);
1194    }
1195
1196    #[test]
1197    fn staleness_does_not_exceed_one() {
1198        let mut record = make_file_record_with_staleness(0.85);
1199        let diff = ReparseDiff {
1200            entry_points_added: vec!["a".into(), "b".into()],
1201            ..empty_diff()
1202        };
1203        let _signals = apply_reparse_staleness(&mut record, &diff);
1204        assert!(record.staleness.value <= 1.0);
1205    }
1206
1207    #[test]
1208    fn tier_updates_correctly_after_increment() {
1209        let mut record = make_file_record_with_staleness(0.35);
1210        let diff = ReparseDiff {
1211            entry_points_removed: vec!["removed_fn".into()],
1212            ..empty_diff()
1213        };
1214        let _signals = apply_reparse_staleness(&mut record, &diff);
1215        // 0.35 + 0.15 = 0.50 → Stale
1216        assert_eq!(record.staleness.tier, StalenessTier::Stale);
1217    }
1218
1219    #[tokio::test]
1220    async fn cascade_staleness_bumps_linked_gotchas() {
1221        let dir = TempDir::new().unwrap();
1222        let store = Store::open(dir.path()).await.unwrap();
1223
1224        let gotcha = make_gotcha_record("gotcha:test-rule");
1225        store.put("gotcha:test-rule", &gotcha).await.unwrap();
1226
1227        let file_record = FileRecord {
1228            path: "src/main.rs".into(),
1229            purpose: String::new(),
1230            entry_points: vec![],
1231            imports: vec![],
1232            gotcha_keys: vec!["gotcha:test-rule".into()],
1233            decision_keys: vec![],
1234            todos: vec![],
1235            unsafe_count: 0,
1236            unwrap_count: 0,
1237            change_frequency: 0,
1238            last_author: None,
1239            is_hotspot: false,
1240            token_cost_estimate: 0,
1241            last_modified_session: 0,
1242            content_hash: None,
1243            line_count: 0,
1244            blast_radius: None,
1245            propagated_staleness: None,
1246        };
1247
1248        let cascaded = cascade_staleness_to_gotchas(&store, &file_record)
1249            .await
1250            .unwrap();
1251
1252        assert_eq!(cascaded, 1);
1253
1254        let updated = store.get("gotcha:test-rule").await.unwrap().unwrap();
1255        assert!((updated.staleness.value - 0.10).abs() < 0.01);
1256        assert!(updated.staleness.signals.iter().any(|s| {
1257            matches!(s, StalenessSignal::LinkedFileChanged { path } if path == "src/main.rs")
1258        }));
1259
1260        store.close().await.unwrap();
1261    }
1262
1263    #[tokio::test]
1264    async fn cascade_noop_when_no_gotcha_keys() {
1265        let dir = TempDir::new().unwrap();
1266        let store = Store::open(dir.path()).await.unwrap();
1267
1268        let file_record = FileRecord {
1269            path: "src/main.rs".into(),
1270            purpose: String::new(),
1271            entry_points: vec![],
1272            imports: vec![],
1273            gotcha_keys: vec![],
1274            decision_keys: vec![],
1275            todos: vec![],
1276            unsafe_count: 0,
1277            unwrap_count: 0,
1278            change_frequency: 0,
1279            last_author: None,
1280            is_hotspot: false,
1281            token_cost_estimate: 0,
1282            last_modified_session: 0,
1283            content_hash: None,
1284            line_count: 0,
1285            blast_radius: None,
1286            propagated_staleness: None,
1287        };
1288
1289        let cascaded = cascade_staleness_to_gotchas(&store, &file_record)
1290            .await
1291            .unwrap();
1292        assert_eq!(cascaded, 0);
1293
1294        store.close().await.unwrap();
1295    }
1296
1297    #[tokio::test]
1298    async fn cascade_skips_missing_gotcha_records() {
1299        let dir = TempDir::new().unwrap();
1300        let store = Store::open(dir.path()).await.unwrap();
1301
1302        let file_record = FileRecord {
1303            path: "src/main.rs".into(),
1304            purpose: String::new(),
1305            entry_points: vec![],
1306            imports: vec![],
1307            gotcha_keys: vec!["gotcha:nonexistent".into()],
1308            decision_keys: vec![],
1309            todos: vec![],
1310            unsafe_count: 0,
1311            unwrap_count: 0,
1312            change_frequency: 0,
1313            last_author: None,
1314            is_hotspot: false,
1315            token_cost_estimate: 0,
1316            last_modified_session: 0,
1317            content_hash: None,
1318            line_count: 0,
1319            blast_radius: None,
1320            propagated_staleness: None,
1321        };
1322
1323        let cascaded = cascade_staleness_to_gotchas(&store, &file_record)
1324            .await
1325            .unwrap();
1326        assert_eq!(cascaded, 0);
1327
1328        store.close().await.unwrap();
1329    }
1330
1331    // ── M-13-A: StalenessAnalyzer tests ─────────────────────────────────────
1332
1333    /// Helper: create a record with specific timestamps for time_factor tests.
1334    fn make_record_at(key: &str, updated_at: u64, last_accessed: u64) -> Record {
1335        Record {
1336            key: key.to_string(),
1337            value: String::new(),
1338            category: Category::File,
1339            priority: Priority::Normal,
1340            tags: vec![],
1341            created_at: updated_at,
1342            updated_at,
1343            ref_url: None,
1344            staleness: StalenessScore {
1345                value: 0.0,
1346                tier: StalenessTier::Fresh,
1347                signals: vec![],
1348                computed_at: 0,
1349                last_record_sha: String::new(),
1350            },
1351            lifecycle: RecordLifecycle::Active,
1352            version: RecordVersion {
1353                device_id: uuid::Uuid::new_v4(),
1354                logical_clock: 1,
1355                wall_clock: updated_at,
1356            },
1357            quality: QualityScore::layer0_default(),
1358            access_count: 0,
1359            last_accessed,
1360            source: RecordSource::StaticAnalysis,
1361            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
1362            gap_analysis_score: 0.0,
1363            payload: None,
1364        }
1365    }
1366
1367    /// Helper: make a file record with FileRecord value JSON.
1368    fn make_file_record_full(
1369        key: &str,
1370        imports: Vec<String>,
1371        gotcha_keys: Vec<String>,
1372        decision_keys: Vec<String>,
1373        last_modified_session: u64,
1374    ) -> Record {
1375        let fr = FileRecord {
1376            path: key.strip_prefix("file:").unwrap_or(key).to_string(),
1377            purpose: String::new(),
1378            entry_points: vec![],
1379            imports,
1380            gotcha_keys: gotcha_keys.clone(),
1381            decision_keys: decision_keys.clone(),
1382            todos: vec![],
1383            unsafe_count: 0,
1384            unwrap_count: 0,
1385            change_frequency: 0,
1386            last_author: None,
1387            is_hotspot: false,
1388            token_cost_estimate: 0,
1389            last_modified_session,
1390            content_hash: None,
1391            line_count: 0,
1392            blast_radius: None,
1393            propagated_staleness: None,
1394        };
1395        Record {
1396            key: key.to_string(),
1397            value: serde_json::to_string(&fr).unwrap(),
1398            category: Category::File,
1399            priority: Priority::Normal,
1400            tags: vec![],
1401            created_at: 1_000_000,
1402            updated_at: 1_000_000,
1403            ref_url: None,
1404            staleness: StalenessScore::fresh(),
1405            lifecycle: RecordLifecycle::Active,
1406            version: RecordVersion {
1407                device_id: uuid::Uuid::new_v4(),
1408                logical_clock: 1,
1409                wall_clock: 1_000_000,
1410            },
1411            quality: QualityScore::layer0_default(),
1412            access_count: 0,
1413            last_accessed: 0,
1414            source: RecordSource::StaticAnalysis,
1415            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
1416            gap_analysis_score: 0.0,
1417            payload: None,
1418        }
1419    }
1420
1421    // ── time_factor tests ───────────────────────────────────────────────────
1422
1423    #[test]
1424    fn time_factor_zero_when_just_updated() {
1425        let now = 10_000_000u64;
1426        let record = make_record_at("file:test.rs", now, 0);
1427        let factor = time_factor(&record, now);
1428        assert!(factor.abs() < 0.001, "expected ~0.0, got {factor}");
1429    }
1430
1431    #[test]
1432    fn time_factor_half_at_45_days() {
1433        let now = 10_000_000u64;
1434        let forty_five_days_ago = now - (45 * 86400);
1435        let record = make_record_at("file:test.rs", forty_five_days_ago, 0);
1436        let factor = time_factor(&record, now);
1437        assert!(
1438            (factor - 0.5).abs() < 0.02,
1439            "expected ~0.5 at 45 days, got {factor}"
1440        );
1441    }
1442
1443    #[test]
1444    fn time_factor_max_at_90_days() {
1445        let now = 10_000_000u64;
1446        let ninety_days_ago = now - (90 * 86400);
1447        let record = make_record_at("file:test.rs", ninety_days_ago, 0);
1448        let factor = time_factor(&record, now);
1449        assert!(
1450            (factor - 1.0).abs() < 0.02,
1451            "expected ~1.0 at 90 days, got {factor}"
1452        );
1453    }
1454
1455    #[test]
1456    fn time_factor_uses_last_accessed_when_newer() {
1457        let now = 10_000_000u64;
1458        // updated_at is old, but last_accessed is recent.
1459        let record = make_record_at("file:test.rs", now - (90 * 86400), now - 86400);
1460        let factor = time_factor(&record, now);
1461        // Should use last_accessed (1 day ago), not updated_at (90 days ago).
1462        assert!(
1463            factor < 0.05,
1464            "expected near-zero with recent access, got {factor}"
1465        );
1466    }
1467
1468    // ── git_factor tests ────────────────────────────────────────────────────
1469
1470    #[test]
1471    fn git_factor_zero_when_no_repo() {
1472        let analyzer = StalenessAnalyzer {
1473            repo: None,
1474            now: 2_000_000,
1475            head_commit: None,
1476        };
1477        // When there's no repo, git_factor can't be called via the analyzer path,
1478        // so compute_staleness will return (0.0, None) for git.
1479        // Test the direct path:
1480        assert!(analyzer.repo.is_none());
1481    }
1482
1483    // ── dep_factor tests ────────────────────────────────────────────────────
1484
1485    #[test]
1486    fn dep_factor_zero_when_no_imports() {
1487        let fr = FileRecord {
1488            path: "src/main.rs".into(),
1489            purpose: String::new(),
1490            entry_points: vec![],
1491            imports: vec![],
1492            gotcha_keys: vec![],
1493            decision_keys: vec![],
1494            todos: vec![],
1495            unsafe_count: 0,
1496            unwrap_count: 0,
1497            change_frequency: 0,
1498            last_author: None,
1499            is_hotspot: false,
1500            token_cost_estimate: 0,
1501            last_modified_session: 1_000_000,
1502            content_hash: None,
1503            line_count: 0,
1504            blast_radius: None,
1505            propagated_staleness: None,
1506        };
1507        let cache = HashMap::new();
1508        let factor = dep_factor(Some(&fr), &cache);
1509        assert!(factor.abs() < 0.001);
1510    }
1511
1512    #[test]
1513    fn dep_factor_detects_bumped_dep() {
1514        let fr = FileRecord {
1515            path: "src/main.rs".into(),
1516            purpose: String::new(),
1517            entry_points: vec![],
1518            imports: vec!["tokio::sync::Mutex".into()],
1519            gotcha_keys: vec![],
1520            decision_keys: vec![],
1521            todos: vec![],
1522            unsafe_count: 0,
1523            unwrap_count: 0,
1524            change_frequency: 0,
1525            last_author: None,
1526            is_hotspot: false,
1527            token_cost_estimate: 0,
1528            last_modified_session: 1_000_000,
1529            content_hash: None,
1530            line_count: 0,
1531            blast_radius: None,
1532            propagated_staleness: None,
1533        };
1534
1535        // Create a dep record for tokio that was updated after the file.
1536        let mut dep_rec = Record {
1537            key: "dep:cargo:tokio".to_string(),
1538            value: String::new(),
1539            category: Category::Dependency,
1540            priority: Priority::Normal,
1541            tags: vec![],
1542            created_at: 500_000,
1543            updated_at: 2_000_000, // Updated after file's last_modified_session.
1544            ref_url: None,
1545            staleness: StalenessScore {
1546                value: 0.0,
1547                tier: StalenessTier::Fresh,
1548                signals: vec![StalenessSignal::DependencyBumped {
1549                    dep: "tokio".into(),
1550                    old_ver: "1.0".into(),
1551                    new_ver: "1.1".into(),
1552                }],
1553                computed_at: 0,
1554                last_record_sha: String::new(),
1555            },
1556            lifecycle: RecordLifecycle::Active,
1557            version: RecordVersion {
1558                device_id: uuid::Uuid::new_v4(),
1559                logical_clock: 1,
1560                wall_clock: 2_000_000,
1561            },
1562            quality: QualityScore::layer0_default(),
1563            access_count: 0,
1564            last_accessed: 0,
1565            source: RecordSource::StaticAnalysis,
1566            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
1567            gap_analysis_score: 0.0,
1568            payload: None,
1569        };
1570
1571        let mut cache = HashMap::new();
1572        cache.insert("dep:cargo:tokio".to_string(), dep_rec.clone());
1573
1574        let factor = dep_factor(Some(&fr), &cache);
1575        assert!(
1576            factor > 0.5,
1577            "expected high dep factor for bumped dep, got {factor}"
1578        );
1579
1580        // With no bump signal and same updated_at, factor should be zero.
1581        dep_rec.staleness.signals.clear();
1582        dep_rec.updated_at = 1_000_000; // Same as file.
1583        cache.insert("dep:cargo:tokio".to_string(), dep_rec);
1584        let factor2 = dep_factor(Some(&fr), &cache);
1585        assert!(
1586            factor2.abs() < 0.001,
1587            "expected zero when dep not bumped, got {factor2}"
1588        );
1589    }
1590
1591    #[test]
1592    fn dep_factor_detects_bumped_npm_dep_from_subpath_import() {
1593        let fr = FileRecord {
1594            path: "src/app.ts".into(),
1595            purpose: String::new(),
1596            entry_points: vec![],
1597            imports: vec!["@types/node/fs".into()],
1598            gotcha_keys: vec![],
1599            decision_keys: vec![],
1600            todos: vec![],
1601            unsafe_count: 0,
1602            unwrap_count: 0,
1603            change_frequency: 0,
1604            last_author: None,
1605            is_hotspot: false,
1606            token_cost_estimate: 0,
1607            line_count: 0,
1608            blast_radius: None,
1609            propagated_staleness: None,
1610            last_modified_session: 1_000_000,
1611            content_hash: None,
1612        };
1613
1614        let dep_rec = Record {
1615            key: "dep:npm:@types/node".to_string(),
1616            value: String::new(),
1617            category: Category::Dependency,
1618            priority: Priority::Normal,
1619            tags: vec![],
1620            created_at: 500_000,
1621            updated_at: 2_000_000,
1622            ref_url: None,
1623            staleness: StalenessScore {
1624                value: 0.0,
1625                tier: StalenessTier::Fresh,
1626                signals: vec![StalenessSignal::DependencyBumped {
1627                    dep: "@types/node".into(),
1628                    old_ver: "20.0.0".into(),
1629                    new_ver: "20.1.0".into(),
1630                }],
1631                computed_at: 0,
1632                last_record_sha: String::new(),
1633            },
1634            lifecycle: RecordLifecycle::Active,
1635            version: RecordVersion {
1636                device_id: uuid::Uuid::new_v4(),
1637                logical_clock: 1,
1638                wall_clock: 2_000_000,
1639            },
1640            quality: QualityScore::layer0_default(),
1641            access_count: 0,
1642            last_accessed: 0,
1643            source: RecordSource::StaticAnalysis,
1644            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
1645            gap_analysis_score: 0.0,
1646            payload: None,
1647        };
1648
1649        let mut cache = HashMap::new();
1650        cache.insert(dep_rec.key.clone(), dep_rec);
1651
1652        let factor = dep_factor(Some(&fr), &cache);
1653        assert!(
1654            factor > 0.5,
1655            "expected high dep factor for bumped npm dep, got {factor}"
1656        );
1657    }
1658
1659    #[test]
1660    fn dep_factor_detects_bumped_go_dep_from_subpackage_import() {
1661        let fr = FileRecord {
1662            path: "internal/server.go".into(),
1663            purpose: String::new(),
1664            entry_points: vec![],
1665            imports: vec!["github.com/gin-gonic/gin/render".into()],
1666            gotcha_keys: vec![],
1667            decision_keys: vec![],
1668            todos: vec![],
1669            unsafe_count: 0,
1670            unwrap_count: 0,
1671            change_frequency: 0,
1672            last_author: None,
1673            is_hotspot: false,
1674            token_cost_estimate: 0,
1675            line_count: 0,
1676            blast_radius: None,
1677            propagated_staleness: None,
1678            last_modified_session: 1_000_000,
1679            content_hash: None,
1680        };
1681
1682        let dep_rec = Record {
1683            key: "dep:go:github.com/gin-gonic/gin".to_string(),
1684            value: String::new(),
1685            category: Category::Dependency,
1686            priority: Priority::Normal,
1687            tags: vec![],
1688            created_at: 500_000,
1689            updated_at: 2_000_000,
1690            ref_url: None,
1691            staleness: StalenessScore {
1692                value: 0.0,
1693                tier: StalenessTier::Fresh,
1694                signals: vec![StalenessSignal::DependencyBumped {
1695                    dep: "github.com/gin-gonic/gin".into(),
1696                    old_ver: "1.9.0".into(),
1697                    new_ver: "1.9.1".into(),
1698                }],
1699                computed_at: 0,
1700                last_record_sha: String::new(),
1701            },
1702            lifecycle: RecordLifecycle::Active,
1703            version: RecordVersion {
1704                device_id: uuid::Uuid::new_v4(),
1705                logical_clock: 1,
1706                wall_clock: 2_000_000,
1707            },
1708            quality: QualityScore::layer0_default(),
1709            access_count: 0,
1710            last_accessed: 0,
1711            source: RecordSource::StaticAnalysis,
1712            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
1713            gap_analysis_score: 0.0,
1714            payload: None,
1715        };
1716
1717        let mut cache = HashMap::new();
1718        cache.insert(dep_rec.key.clone(), dep_rec);
1719
1720        let factor = dep_factor(Some(&fr), &cache);
1721        assert!(
1722            factor > 0.5,
1723            "expected high dep factor for bumped go dep, got {factor}"
1724        );
1725    }
1726
1727    // ── cascade_factor tests ────────────────────────────────────────────────
1728
1729    #[tokio::test]
1730    async fn cascade_factor_zero_when_no_linked() {
1731        let dir = TempDir::new().unwrap();
1732        let store = Store::open(dir.path()).await.unwrap();
1733
1734        let fr = FileRecord {
1735            path: "src/main.rs".into(),
1736            purpose: String::new(),
1737            entry_points: vec![],
1738            imports: vec![],
1739            gotcha_keys: vec![],
1740            decision_keys: vec![],
1741            todos: vec![],
1742            unsafe_count: 0,
1743            unwrap_count: 0,
1744            change_frequency: 0,
1745            last_author: None,
1746            is_hotspot: false,
1747            token_cost_estimate: 0,
1748            last_modified_session: 0,
1749            content_hash: None,
1750            line_count: 0,
1751            blast_radius: None,
1752            propagated_staleness: None,
1753        };
1754
1755        let record = make_file_record_full("file:src/main.rs", vec![], vec![], vec![], 0);
1756
1757        let factor = cascade_factor(&record, Some(&fr), &store).await;
1758        assert!(factor.abs() < 0.001);
1759
1760        store.close().await.unwrap();
1761    }
1762
1763    #[tokio::test]
1764    async fn cascade_factor_detects_stale_linked_gotcha() {
1765        let dir = TempDir::new().unwrap();
1766        let store = Store::open(dir.path()).await.unwrap();
1767
1768        // Create a stale gotcha.
1769        let mut gotcha = make_gotcha_record("gotcha:stale-rule");
1770        gotcha.staleness.value = 0.6;
1771        gotcha.staleness.tier = StalenessTier::Stale;
1772        store.put("gotcha:stale-rule", &gotcha).await.unwrap();
1773
1774        let fr = FileRecord {
1775            path: "src/main.rs".into(),
1776            purpose: String::new(),
1777            entry_points: vec![],
1778            imports: vec![],
1779            gotcha_keys: vec!["gotcha:stale-rule".into()],
1780            decision_keys: vec![],
1781            todos: vec![],
1782            unsafe_count: 0,
1783            unwrap_count: 0,
1784            change_frequency: 0,
1785            last_author: None,
1786            is_hotspot: false,
1787            token_cost_estimate: 0,
1788            last_modified_session: 0,
1789            content_hash: None,
1790            line_count: 0,
1791            blast_radius: None,
1792            propagated_staleness: None,
1793        };
1794
1795        let record = make_file_record_full(
1796            "file:src/main.rs",
1797            vec![],
1798            vec!["gotcha:stale-rule".into()],
1799            vec![],
1800            0,
1801        );
1802
1803        let factor = cascade_factor(&record, Some(&fr), &store).await;
1804        assert!(
1805            factor > 0.5,
1806            "expected positive cascade factor for stale linked gotcha, got {factor}"
1807        );
1808
1809        store.close().await.unwrap();
1810    }
1811
1812    #[tokio::test]
1813    async fn cascade_factor_gotcha_detects_stale_affected_file() {
1814        let dir = TempDir::new().unwrap();
1815        let store = Store::open(dir.path()).await.unwrap();
1816
1817        // Create a stale file record.
1818        let mut file_rec = make_file_record_with_staleness(0.6);
1819        file_rec.key = "file:src/main.rs".to_string();
1820        store.put("file:src/main.rs", &file_rec).await.unwrap();
1821
1822        // Create a gotcha that references this file.
1823        let gotcha_record = make_gotcha_record("gotcha:test-cascade");
1824        let factor = cascade_factor(&gotcha_record, None, &store).await;
1825        assert!(
1826            factor > 0.5,
1827            "expected positive cascade factor for stale affected file, got {factor}"
1828        );
1829
1830        store.close().await.unwrap();
1831    }
1832
1833    // ── Hard override tests ─────────────────────────────────────────────────
1834
1835    #[tokio::test]
1836    async fn hard_override_file_deleted_sets_tombstone() {
1837        let dir = TempDir::new().unwrap();
1838        let store = Store::open(dir.path()).await.unwrap();
1839
1840        // Use a path that definitely doesn't exist on disk.
1841        let mut record = make_file_record_with_staleness(0.0);
1842        record.key = "file:/tmp/definitely_nonexistent_mati_test_file_xyz.rs".to_string();
1843        store.put(&record.key, &record).await.unwrap();
1844
1845        let analyzer = StalenessAnalyzer::new_with_now(dir.path(), 2_000_000);
1846        let dep_cache = HashMap::new();
1847        analyzer
1848            .compute_staleness(&mut record, &store, &dep_cache)
1849            .await
1850            .unwrap();
1851
1852        assert_eq!(record.staleness.tier, StalenessTier::Tombstone);
1853        assert!((record.staleness.value - 1.0).abs() < 0.01);
1854
1855        store.close().await.unwrap();
1856    }
1857
1858    #[tokio::test]
1859    async fn hard_override_file_renamed_sets_liability() {
1860        let dir = TempDir::new().unwrap();
1861        let store = Store::open(dir.path()).await.unwrap();
1862
1863        // Create both old and new files on disk.
1864        let old_path = dir.path().join("old_file.rs");
1865        let new_path = dir.path().join("renamed.rs");
1866        std::fs::write(&old_path, "fn main() {}").unwrap();
1867        std::fs::write(&new_path, "fn main() {}").unwrap();
1868
1869        let mut record = make_file_record_with_staleness(0.0);
1870        record.key = format!("file:{}", old_path.to_string_lossy());
1871        record.staleness.signals.push(StalenessSignal::FileRenamed {
1872            new_path: new_path.to_string_lossy().to_string(),
1873        });
1874
1875        let analyzer = StalenessAnalyzer::new_with_now(dir.path(), 2_000_000);
1876        let dep_cache = HashMap::new();
1877        analyzer
1878            .compute_staleness(&mut record, &store, &dep_cache)
1879            .await
1880            .unwrap();
1881
1882        assert_eq!(record.staleness.tier, StalenessTier::Liability);
1883        assert!((record.staleness.value - 0.85).abs() < 0.01);
1884
1885        store.close().await.unwrap();
1886    }
1887
1888    #[tokio::test]
1889    async fn file_restored_clears_deleted_override() {
1890        let dir = TempDir::new().unwrap();
1891        let store = Store::open(dir.path()).await.unwrap();
1892
1893        // Create a file on disk.
1894        let file_path = dir.path().join("restored.rs");
1895        std::fs::write(&file_path, "fn main() {}").unwrap();
1896
1897        // Record has a FileDeleted signal, but the file now exists on disk.
1898        let mut record = make_file_record_with_staleness(0.5);
1899        record.key = format!("file:{}", file_path.to_string_lossy());
1900        record.staleness.signals.push(StalenessSignal::FileDeleted);
1901
1902        let analyzer = StalenessAnalyzer::new_with_now(dir.path(), 2_000_000);
1903        let dep_cache = HashMap::new();
1904        analyzer
1905            .compute_staleness(&mut record, &store, &dep_cache)
1906            .await
1907            .unwrap();
1908
1909        // Should NOT be tombstoned since file exists.
1910        assert_ne!(record.staleness.tier, StalenessTier::Tombstone);
1911        // FileDeleted signal should be cleared.
1912        assert!(
1913            !record
1914                .staleness
1915                .signals
1916                .iter()
1917                .any(|s| matches!(s, StalenessSignal::FileDeleted)),
1918            "FileDeleted signal should be cleared when file is restored"
1919        );
1920
1921        store.close().await.unwrap();
1922    }
1923
1924    // ── staleness_changed tests ─────────────────────────────────────────────
1925
1926    #[test]
1927    fn staleness_changed_detects_tier_change() {
1928        let mut old = make_file_record_with_staleness(0.19);
1929        let mut new = old.clone();
1930        new.staleness.value = 0.21;
1931        new.staleness.tier = StalenessTier::Aging;
1932        old.staleness.tier = StalenessTier::Fresh;
1933        assert!(staleness_changed(&old, &new));
1934    }
1935
1936    #[test]
1937    fn staleness_changed_ignores_small_delta() {
1938        let old = make_file_record_with_staleness(0.10);
1939        let mut new = old.clone();
1940        new.staleness.value = 0.105; // Delta 0.005 < 0.01 threshold.
1941        assert!(!staleness_changed(&old, &new));
1942    }
1943
1944    #[test]
1945    fn staleness_changed_detects_sha_change() {
1946        let old = make_file_record_with_staleness(0.10);
1947        let mut new = old.clone();
1948        new.staleness.last_record_sha = "abc123".to_string();
1949        assert!(staleness_changed(&old, &new));
1950    }
1951
1952    #[test]
1953    fn staleness_changed_detects_signal_count_change() {
1954        let old = make_file_record_with_staleness(0.10);
1955        let mut new = old.clone();
1956        new.staleness
1957            .signals
1958            .push(StalenessSignal::LinesChangedPct(0.5));
1959        assert!(staleness_changed(&old, &new));
1960    }
1961
1962    // ── analyze_all tests ───────────────────────────────────────────────────
1963
1964    #[tokio::test]
1965    async fn analyze_all_updates_stale_records() {
1966        let dir = TempDir::new().unwrap();
1967        let store = Store::open(dir.path()).await.unwrap();
1968
1969        // Create a record that should become stale (old timestamp).
1970        // Use a file path that exists on disk so it doesn't get tombstoned.
1971        let file_path = dir.path().join("old_file.rs");
1972        std::fs::write(&file_path, "fn main() {}").unwrap();
1973
1974        let now = SystemTime::now()
1975            .duration_since(UNIX_EPOCH)
1976            .unwrap()
1977            .as_secs();
1978        let sixty_days_ago = now - (60 * 86400);
1979
1980        let mut record = make_record_at(
1981            &format!("file:{}", file_path.to_string_lossy()),
1982            sixty_days_ago,
1983            0,
1984        );
1985        record.lifecycle = RecordLifecycle::Active;
1986        store.put(&record.key, &record).await.unwrap();
1987
1988        let analyzer = StalenessAnalyzer::new(dir.path());
1989        let report = analyzer.analyze_all(&store).await.unwrap();
1990
1991        assert!(report.scanned >= 1, "should scan at least 1 record");
1992        // The time factor at 60 days = 60/90 = ~0.67 * 0.20 = ~0.13
1993        // That's enough to register a change from 0.0.
1994        assert!(report.updated >= 1, "should update stale record");
1995
1996        store.close().await.unwrap();
1997    }
1998
1999    #[tokio::test]
2000    async fn analyze_all_skips_non_active() {
2001        let dir = TempDir::new().unwrap();
2002        let store = Store::open(dir.path()).await.unwrap();
2003
2004        let now = SystemTime::now()
2005            .duration_since(UNIX_EPOCH)
2006            .unwrap()
2007            .as_secs();
2008        let old = now - (60 * 86400);
2009
2010        let mut record = make_record_at("file:tombstoned.rs", old, 0);
2011        record.lifecycle = RecordLifecycle::Tombstoned {
2012            reason: TombstoneReason::ManualDeletion,
2013            at: now,
2014        };
2015        store.put(&record.key, &record).await.unwrap();
2016
2017        let analyzer = StalenessAnalyzer::new_with_now(dir.path(), now);
2018        let report = analyzer.analyze_all(&store).await.unwrap();
2019
2020        // Record was scanned but not updated because it's tombstoned.
2021        assert_eq!(report.updated, 0);
2022
2023        store.close().await.unwrap();
2024    }
2025
2026    // ── commits_to_factor tests ─────────────────────────────────────────────
2027
2028    #[test]
2029    fn commits_to_factor_mapping() {
2030        assert!((commits_to_factor(0) - 0.0).abs() < 0.001);
2031        assert!((commits_to_factor(1) - 0.15).abs() < 0.001);
2032        assert!((commits_to_factor(2) - 0.30).abs() < 0.001);
2033        assert!((commits_to_factor(3) - 0.50).abs() < 0.001);
2034        assert!((commits_to_factor(4) - 0.70).abs() < 0.001);
2035        assert!((commits_to_factor(5) - 1.0).abs() < 0.001);
2036        assert!((commits_to_factor(100) - 1.0).abs() < 0.001);
2037    }
2038
2039    // ── reparse signal preservation tests ───────────────────────────────────
2040
2041    #[tokio::test]
2042    async fn reparse_signals_preserved_within_24h() {
2043        let dir = TempDir::new().unwrap();
2044        let store = Store::open(dir.path()).await.unwrap();
2045
2046        let file_path = dir.path().join("recent_reparse.rs");
2047        std::fs::write(&file_path, "fn main() {}").unwrap();
2048
2049        let now = 2_000_000u64;
2050        let recent = now - 3600; // 1 hour ago
2051
2052        let mut record = make_record_at(
2053            &format!("file:{}", file_path.to_string_lossy()),
2054            now - 100,
2055            0,
2056        );
2057        // Simulate recent reparse: computed_at within 24h, with reparse signals.
2058        record.staleness.computed_at = recent;
2059        record.staleness.value = 0.3;
2060        record.staleness.tier = StalenessTier::Aging;
2061        record.staleness.signals = vec![
2062            StalenessSignal::EntryPointsChanged(2),
2063            StalenessSignal::ImportsChanged(1),
2064        ];
2065
2066        let analyzer = StalenessAnalyzer::new_with_now(dir.path(), now);
2067        let dep_cache = HashMap::new();
2068        analyzer
2069            .compute_staleness(&mut record, &store, &dep_cache)
2070            .await
2071            .unwrap();
2072
2073        // The 5-factor formula would compute a low value (record is ~100s old),
2074        // but reparse signal preservation should keep the higher value.
2075        assert!(
2076            record.staleness.value >= 0.3,
2077            "reparse signal preservation should keep value >= 0.3, got {}",
2078            record.staleness.value
2079        );
2080
2081        // Reparse signals should be preserved.
2082        let has_ep = record
2083            .staleness
2084            .signals
2085            .iter()
2086            .any(|s| matches!(s, StalenessSignal::EntryPointsChanged(_)));
2087        assert!(has_ep, "EntryPointsChanged signal should be preserved");
2088
2089        store.close().await.unwrap();
2090    }
2091
2092    #[test]
2093    fn signal_cap_at_20_for_reparse_signals() {
2094        let mut record = make_file_record_with_staleness(0.0);
2095
2096        // Add 25 signals via repeated reparse applications.
2097        for i in 0..25 {
2098            let diff = ReparseDiff {
2099                entry_points_added: vec![format!("fn_{i}")],
2100                ..empty_diff()
2101            };
2102            apply_reparse_staleness(&mut record, &diff);
2103        }
2104
2105        // Signal list should be capped at 20.
2106        assert!(
2107            record.staleness.signals.len() <= 20,
2108            "signals should be capped at 20, got {}",
2109            record.staleness.signals.len()
2110        );
2111    }
2112
2113    // ── is_reparse_signal tests ─────────────────────────────────────────────
2114
2115    #[test]
2116    fn is_reparse_signal_identifies_reparse_signals() {
2117        assert!(is_reparse_signal(&StalenessSignal::EntryPointsChanged(1)));
2118        assert!(is_reparse_signal(&StalenessSignal::ImportsChanged(2)));
2119        assert!(is_reparse_signal(&StalenessSignal::TodosChanged));
2120        assert!(is_reparse_signal(&StalenessSignal::UnsafeCountChanged(1)));
2121        assert!(is_reparse_signal(&StalenessSignal::UnwrapCountChanged(-1)));
2122
2123        // Non-reparse signals.
2124        assert!(!is_reparse_signal(&StalenessSignal::FileDeleted));
2125        assert!(!is_reparse_signal(&StalenessSignal::LinesChangedPct(0.5)));
2126        assert!(!is_reparse_signal(&StalenessSignal::NotAccessedDays(7)));
2127    }
2128}