1use 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
24const MAX_REPARSE_INCREMENT: f32 = 0.4;
26
27const ENTRY_POINT_WEIGHT: f32 = 0.15;
29
30const IMPORT_WEIGHT: f32 = 0.10;
32
33const TODOS_WEIGHT: f32 = 0.05;
35
36const UNSAFE_WEIGHT: f32 = 0.10;
38
39const UNWRAP_WEIGHT: f32 = 0.05;
41
42const CASCADE_WEIGHT: f32 = 0.10;
44
45#[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 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
70pub 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 increment = increment.min(MAX_REPARSE_INCREMENT);
118
119 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 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
136pub 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
181const SECS_PER_DAY: f64 = 86_400.0;
185
186const TIME_STALE_DAYS: f64 = 90.0;
188
189const TIME_WEIGHT: f32 = 0.20;
191
192const GIT_WEIGHT: f32 = 0.35;
194
195#[allow(dead_code)]
197const SEMANTIC_WEIGHT: f32 = 0.25;
198
199const DEP_WEIGHT: f32 = 0.10;
201
202const CASCADE_WEIGHT_FACTOR: f32 = 0.10;
204
205const GIT_REVWALK_LIMIT: usize = 2000;
207
208const GIT_CAP_HIT_COMMITS: u32 = 3;
211
212const MAX_RECOMPUTE_SIGNALS: usize = 10;
214
215const ANALYZE_TIME_BUDGET_MS: u64 = 2000;
218
219const STALENESS_PREFIXES: &[&str] = &["file:", "gotcha:", "decision:", "dep:", "dev_note:"];
221
222const REPARSE_WINDOW_SECS: u64 = 86_400;
224
225#[derive(Debug, Clone)]
229pub struct StalenessReport {
230 pub scanned: u32,
232 pub updated: u32,
234 pub tombstoned: u32,
236 pub liability: u32,
238 pub stale: u32,
240}
241
242pub struct StalenessAnalyzer {
248 repo: Option<git2::Repository>,
249 now: u64,
250 head_commit: Option<String>,
251}
252
253impl StalenessAnalyzer {
254 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 #[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 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 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 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 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 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 async fn compute_staleness(
392 &self,
393 record: &mut Record,
394 store: &Store,
395 dep_cache: &HashMap<String, Record>,
396 ) -> Result<()> {
397 let file_record: Option<FileRecord> = if record.key.starts_with("file:") {
399 record.payload_as::<FileRecord>()
400 } else {
401 None
402 };
403
404 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 record
417 .staleness
418 .signals
419 .retain(|s| !matches!(s, StalenessSignal::FileDeleted));
420 } else {
421 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 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 let has_rename = record
443 .staleness
444 .signals
445 .iter()
446 .any(|s| matches!(s, StalenessSignal::FileRenamed { .. }));
447 if has_rename {
448 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 record.staleness.value = 0.85;
459 record.staleness.tier = StalenessTier::Liability;
460 record.staleness.computed_at = self.now;
461 return Ok(());
462 }
463 }
466
467 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 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 let final_value = if had_recent_reparse && clamped < old_value {
508 old_value
509 } else {
510 clamped
511 };
512
513 let mut new_signals = Vec::new();
515
516 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 if git_f > 0.0 {
525 new_signals.push(StalenessSignal::LinesChangedPct(git_f));
528 }
529
530 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 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 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 if last_record_sha.is_empty() {
570 return (0.0, Some(head_sha));
571 }
572
573 if last_record_sha == head_sha {
575 return (0.0, None);
576 }
577
578 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 return (0.0, Some(head_sha));
586 }
587 (None, _) => {
588 return (0.0, Some(head_sha));
590 }
591 _ => {
592 }
594 }
595
596 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 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 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 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 if !found_since && count == 0 && total_iterations >= GIT_REVWALK_LIMIT {
655 return GIT_CAP_HIT_COMMITS;
656 }
657
658 count
659 }
660}
661
662fn 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
682fn semantic_factor() -> f32 {
687 0.0
688}
689
690fn 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 if dep_record.updated_at > fr.last_modified_session {
720 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 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
823async 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 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
893fn head_commit_sha(repo: &git2::Repository) -> Option<String> {
897 repo.head().ok()?.target().map(|oid| oid.to_string())
898}
899
900fn 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
909fn 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#[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
960fn 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
981fn 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 commit.parent_count() == 0 {
998 return file_entry.is_some();
999 }
1000
1001 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, (None, Some(_)) => true, (None, None) => false,
1019 }
1020}
1021
1022fn 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
1048fn 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#[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 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 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 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 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 #[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 let record = make_record_at("file:test.rs", now - (90 * 86400), now - 86400);
1460 let factor = time_factor(&record, now);
1461 assert!(
1463 factor < 0.05,
1464 "expected near-zero with recent access, got {factor}"
1465 );
1466 }
1467
1468 #[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 assert!(analyzer.repo.is_none());
1481 }
1482
1483 #[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 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, 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 dep_rec.staleness.signals.clear();
1582 dep_rec.updated_at = 1_000_000; 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 #[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 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 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 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 #[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 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 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 let file_path = dir.path().join("restored.rs");
1895 std::fs::write(&file_path, "fn main() {}").unwrap();
1896
1897 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 assert_ne!(record.staleness.tier, StalenessTier::Tombstone);
1911 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 #[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; 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 #[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 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 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 assert_eq!(report.updated, 0);
2022
2023 store.close().await.unwrap();
2024 }
2025
2026 #[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 #[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; let mut record = make_record_at(
2053 &format!("file:{}", file_path.to_string_lossy()),
2054 now - 100,
2055 0,
2056 );
2057 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 assert!(
2076 record.staleness.value >= 0.3,
2077 "reparse signal preservation should keep value >= 0.3, got {}",
2078 record.staleness.value
2079 );
2080
2081 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 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 assert!(
2107 record.staleness.signals.len() <= 20,
2108 "signals should be capped at 20, got {}",
2109 record.staleness.signals.len()
2110 );
2111 }
2112
2113 #[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 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}