use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Component, Path};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::graph::edges::{Edge, EdgeKind};
use crate::store::db::Store;
use crate::store::record::{
Category, GotchaRecord, Priority, Record, RecordLifecycle, RecordSource, RecordVersion,
StalenessScore, TombstoneReason,
};
#[allow(async_fn_in_trait)]
pub trait RepairReader {
async fn get(&self, key: &str) -> Result<Option<Record>>;
async fn scan_prefix(&self, prefix: &str) -> Result<Vec<Record>>;
async fn scan_keys(&self, prefix: &str) -> Result<Vec<String>>;
}
impl RepairReader for Store {
async fn get(&self, key: &str) -> Result<Option<Record>> {
Store::get(self, key).await
}
async fn scan_prefix(&self, prefix: &str) -> Result<Vec<Record>> {
Store::scan_prefix(self, prefix).await
}
async fn scan_keys(&self, prefix: &str) -> Result<Vec<String>> {
Store::scan_keys(self, prefix).await
}
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub const DIRTY_MARKER_KEY: &str = "analytics:integrity:gotcha_links";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepairReport {
pub scanned_gotchas: usize,
pub scanned_files: usize,
pub missing_file_links: Vec<DriftEntry>,
pub stale_file_links: Vec<DriftEntry>,
pub missing_edges: Vec<DriftEntry>,
pub stale_edges: Vec<DriftEntry>,
#[serde(default)]
pub unnormalized_paths: Vec<DriftEntry>,
#[serde(default)]
pub orphaned_files: Vec<OrphanEntry>,
pub repaired_count: usize,
pub verification_passed: bool,
pub dirty_marker_cleared: bool,
}
impl RepairReport {
pub fn has_drift(&self) -> bool {
!self.missing_file_links.is_empty()
|| !self.stale_file_links.is_empty()
|| !self.missing_edges.is_empty()
|| !self.stale_edges.is_empty()
|| !self.unnormalized_paths.is_empty()
}
pub fn total_drift(&self) -> usize {
self.missing_file_links.len()
+ self.stale_file_links.len()
+ self.missing_edges.len()
+ self.stale_edges.len()
+ self.unnormalized_paths.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftEntry {
pub gotcha_key: String,
pub file_path: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OrphanKind {
OutsideRepo,
DeletedInRepo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrphanEntry {
pub key: String,
pub kind: OrphanKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirtyMarker {
pub dirty: bool,
pub dirty_since: u64,
pub cause: String,
pub affected_keys: Vec<String>,
pub last_checked_at: u64,
pub last_repaired_at: u64,
}
impl DirtyMarker {
pub fn clean() -> Self {
Self {
dirty: false,
dirty_since: 0,
cause: String::new(),
affected_keys: vec![],
last_checked_at: 0,
last_repaired_at: 0,
}
}
}
pub async fn mark_dirty(store: &Store, gotcha_key: &str, cause: &str) {
let now = now_secs();
let mut marker = read_dirty_marker(store)
.await
.unwrap_or_else(DirtyMarker::clean);
marker.dirty = true;
if marker.dirty_since == 0 {
marker.dirty_since = now;
}
marker.cause = cause.to_string();
if !marker.affected_keys.contains(&gotcha_key.to_string()) {
marker.affected_keys.push(gotcha_key.to_string());
}
let record = Record {
key: DIRTY_MARKER_KEY.to_string(),
value: cause.to_string(),
payload: serde_json::to_value(&marker).ok(),
category: Category::Analytics,
priority: Priority::Normal,
tags: vec![],
created_at: now,
updated_at: now,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: crate::store::stable_device_id(),
logical_clock: 1,
wall_clock: now,
},
quality: crate::store::record::QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: crate::store::record::ConfidenceScore::for_new_record(
&RecordSource::StaticAnalysis,
),
gap_analysis_score: 0.0,
};
let _ = store.put(DIRTY_MARKER_KEY, &record).await;
}
pub async fn read_dirty_marker<R: RepairReader>(reader: &R) -> Option<DirtyMarker> {
reader
.get(DIRTY_MARKER_KEY)
.await
.ok()
.flatten()
.and_then(|r| r.payload_as::<DirtyMarker>())
}
pub async fn is_dirty<R: RepairReader>(reader: &R) -> bool {
read_dirty_marker(reader)
.await
.map(|m| m.dirty)
.unwrap_or(false)
}
pub async fn check_gotcha_indexes<R: RepairReader>(
reader: &R,
repo_root: &Path,
) -> Result<RepairReport> {
let mut desired = derive_desired_state(reader, repo_root).await?;
let actual = read_actual_file_links(reader).await?;
let actual_edges = read_actual_edges(reader).await?;
desired.drop_tombstoned(&actual.tombstoned);
let (missing_file_links, stale_file_links) =
diff_file_links(&desired.file_links, &actual.links);
let (missing_edges, stale_edges) = diff_edges(&desired.edges, &actual_edges);
Ok(RepairReport {
scanned_gotchas: desired.scanned,
scanned_files: actual.scanned,
missing_file_links,
stale_file_links,
missing_edges,
stale_edges,
unnormalized_paths: desired.unnormalized,
orphaned_files: vec![],
repaired_count: 0,
verification_passed: true, dirty_marker_cleared: false,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepairMode {
Full,
Fast,
}
pub async fn repair_gotcha_indexes(
store: &Store,
repo_root: &Path,
mode: RepairMode,
) -> Result<RepairReport> {
let now = now_secs();
if mode == RepairMode::Fast {
return repair_fast(store, now).await;
}
let (unnormalized_paths, path_repairs) =
repair_unnormalized_paths(store, repo_root, now).await?;
let mut desired = derive_desired_state(store, repo_root).await?;
let scanned_gotchas = desired.scanned;
let actual = read_actual_file_links(store).await?;
let actual_edges = read_actual_edges(store).await?;
desired.drop_tombstoned(&actual.tombstoned);
let scanned_files = actual.scanned;
let (missing_file_links, stale_file_links) =
diff_file_links(&desired.file_links, &actual.links);
let (missing_edges, stale_edges) = diff_edges(&desired.edges, &actual_edges);
let total_drift =
missing_file_links.len() + stale_file_links.len() + missing_edges.len() + stale_edges.len();
if total_drift == 0 {
clear_dirty_marker(store, now).await;
return Ok(RepairReport {
scanned_gotchas,
scanned_files,
missing_file_links: vec![],
stale_file_links: vec![],
missing_edges: vec![],
stale_edges: vec![],
unnormalized_paths,
orphaned_files: vec![],
repaired_count: path_repairs,
verification_passed: true,
dirty_marker_cleared: true,
});
}
let mut repaired = path_repairs;
for (file_path, desired_keys) in &desired.file_links {
let file_key = format!("file:{file_path}");
if let Ok(Some(mut record)) = store.get(&file_key).await {
let current_keys = extract_gotcha_keys(&record);
let desired_sorted: Vec<&String> = desired_keys.iter().collect();
let current_sorted: Vec<&String> = current_keys.iter().collect();
if desired_sorted != current_sorted {
set_gotcha_keys(&mut record, desired_keys.iter().cloned().collect());
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
if store.put(&file_key, &record).await.is_ok() {
repaired += 1;
}
}
}
}
let actual_file_links_2 = read_actual_file_links(store).await?.links;
for (file_path, actual_keys) in &actual_file_links_2 {
if !desired.file_links.contains_key(file_path.as_str()) && !actual_keys.is_empty() {
let file_key = format!("file:{file_path}");
if let Ok(Some(mut record)) = store.get(&file_key).await {
set_gotcha_keys(&mut record, vec![]);
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
if store.put(&file_key, &record).await.is_ok() {
repaired += 1;
}
}
}
}
let ts = now.to_le_bytes();
for entry in &missing_edges {
let file_key = format!("file:{}", entry.file_path);
let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, &entry.gotcha_key).to_key();
if store.put_raw(&edge_key, &ts).await.is_ok() {
repaired += 1;
}
}
for entry in &stale_edges {
let file_key = format!("file:{}", entry.file_path);
let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, &entry.gotcha_key).to_key();
if store.delete(&edge_key).await.is_ok() {
repaired += 1;
}
}
let verify = check_gotcha_indexes(store, repo_root).await?;
let verification_passed = !verify.has_drift();
if verification_passed {
clear_dirty_marker(store, now).await;
}
Ok(RepairReport {
scanned_gotchas,
scanned_files,
missing_file_links,
stale_file_links,
missing_edges,
stale_edges,
unnormalized_paths,
orphaned_files: vec![],
repaired_count: repaired,
verification_passed,
dirty_marker_cleared: verification_passed,
})
}
async fn repair_unnormalized_paths(
store: &Store,
repo_root: &Path,
now: u64,
) -> Result<(Vec<DriftEntry>, usize)> {
let mut drift: Vec<DriftEntry> = Vec::new();
let mut repaired = 0usize;
for mut record in store.scan_prefix("gotcha:").await? {
if !matches!(record.lifecycle, RecordLifecycle::Active) {
continue;
}
let Some(gotcha) = record.payload_as::<GotchaRecord>() else {
continue;
};
let normalized =
crate::store::gotcha_ops::normalize_affected_files(&gotcha.affected_files, repo_root);
if normalized == gotcha.affected_files {
continue;
}
drift.extend(unnormalized_entries(
&record.key,
&gotcha.affected_files,
repo_root,
));
let Some(obj) = record.payload.as_mut().and_then(|p| p.as_object_mut()) else {
continue;
};
obj.insert(
"affected_files".into(),
serde_json::Value::Array(
normalized
.iter()
.cloned()
.map(serde_json::Value::String)
.collect(),
),
);
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
store.put(&record.key, &record).await?;
repaired += 1;
if let Err(e) = crate::store::gotcha_ops::sync_gotcha_file_links(
store,
&record.key,
&gotcha.affected_files,
&normalized,
)
.await
{
tracing::warn!("repair: file link re-point failed for {}: {e}", record.key);
}
}
Ok((drift, repaired))
}
pub const ORPHAN_PURGE_MAX_SHARE: f64 = 0.9;
pub struct OrphanScan {
pub orphans: Vec<OrphanEntry>,
pub active_files: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PurgeOutcome {
Purged {
tombstoned: usize,
unlinked: usize,
},
Refused {
orphans: usize,
active_files: usize,
},
}
pub async fn find_orphaned_files<R: RepairReader>(reader: &R, root: &Path) -> Result<OrphanScan> {
let mut orphans = Vec::new();
let mut active_files = 0usize;
for record in reader.scan_prefix("file:").await? {
if !matches!(record.lifecycle, RecordLifecycle::Active) {
continue;
}
active_files += 1;
let path = record.key.strip_prefix("file:").unwrap_or_default();
if path.is_empty() || root.join(path).exists() {
continue;
}
orphans.push(OrphanEntry {
key: record.key.clone(),
kind: classify_orphan(root, path),
});
}
Ok(OrphanScan {
orphans,
active_files,
})
}
fn classify_orphan(root: &Path, path: &str) -> OrphanKind {
let path = Path::new(path);
if path.is_absolute() {
return OrphanKind::OutsideRepo;
}
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::ParentDir), _) => OrphanKind::OutsideRepo,
(Some(Component::Normal(top)), Some(_)) if !root.join(top).is_dir() => {
OrphanKind::OutsideRepo
}
_ => OrphanKind::DeletedInRepo,
}
}
pub async fn purge_orphaned_files(store: &Store, scan: &OrphanScan) -> Result<PurgeOutcome> {
if scan.orphans.len() as f64 > scan.active_files as f64 * ORPHAN_PURGE_MAX_SHARE {
return Ok(PurgeOutcome::Refused {
orphans: scan.orphans.len(),
active_files: scan.active_files,
});
}
let now = now_secs();
let mut tombstoned = 0usize;
let mut unlinked = 0usize;
for orphan in &scan.orphans {
let Some(mut record) = store.get(&orphan.key).await? else {
continue;
};
for gotcha_key in extract_gotcha_keys(&record) {
let edge = Edge::new(&record.key, EdgeKind::HasGotcha, &gotcha_key).to_key();
let _ = store.delete(&edge).await;
unlinked += 1;
}
set_gotcha_keys(&mut record, vec![]);
let dead =
crate::store::gotcha_ops::tombstoned_copy(&record, TombstoneReason::FileDeleted, now);
store.put(&dead.key, &dead).await?;
tombstoned += 1;
}
Ok(PurgeOutcome::Purged {
tombstoned,
unlinked,
})
}
async fn repair_fast(store: &Store, now: u64) -> Result<RepairReport> {
let marker = match read_dirty_marker(store).await {
Some(m) if m.dirty => m,
_ => {
return Ok(RepairReport {
scanned_gotchas: 0,
scanned_files: 0,
missing_file_links: vec![],
stale_file_links: vec![],
missing_edges: vec![],
stale_edges: vec![],
unnormalized_paths: vec![],
orphaned_files: vec![],
repaired_count: 0,
verification_passed: true,
dirty_marker_cleared: false,
});
}
};
let mut repaired = 0usize;
let ts = now.to_le_bytes();
for gotcha_key in &marker.affected_keys {
let desired_files: Vec<String> = match store.get(gotcha_key).await? {
Some(record) if matches!(record.lifecycle, RecordLifecycle::Active) => record
.payload_as::<GotchaRecord>()
.map(|g| g.affected_files)
.unwrap_or_default(),
_ => vec![],
};
for file_path in &desired_files {
let file_key = format!("file:{file_path}");
if let Ok(Some(mut record)) = store.get(&file_key).await {
let keys = extract_gotcha_keys(&record);
if !keys.contains(gotcha_key) {
let mut new_keys = keys;
new_keys.push(gotcha_key.clone());
set_gotcha_keys(&mut record, new_keys);
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
if store.put(&file_key, &record).await.is_ok() {
repaired += 1;
}
}
}
let file_key = format!("file:{file_path}");
let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, gotcha_key.as_str()).to_key();
if store.put_raw(&edge_key, &ts).await.is_ok() {
repaired += 1;
}
}
{
let desired_set: std::collections::HashSet<&str> =
desired_files.iter().map(String::as_str).collect();
let files = store.scan_prefix("file:").await?;
for mut file_record in files {
let file_path = file_record
.key
.strip_prefix("file:")
.unwrap_or(&file_record.key);
if desired_set.contains(file_path) {
continue;
}
let keys = extract_gotcha_keys(&file_record);
if keys.contains(gotcha_key) {
let new_keys: Vec<String> =
keys.into_iter().filter(|k| k != gotcha_key).collect();
set_gotcha_keys(&mut file_record, new_keys);
file_record.updated_at = now;
file_record.version.logical_clock += 1;
file_record.version.wall_clock = now;
if store.put(&file_record.key, &file_record).await.is_ok() {
repaired += 1;
}
}
let edge_key =
Edge::new(&file_record.key, EdgeKind::HasGotcha, gotcha_key.as_str()).to_key();
let _ = store.delete(&edge_key).await;
}
}
}
if repaired > 0 {
clear_dirty_marker(store, now).await;
}
Ok(RepairReport {
scanned_gotchas: marker.affected_keys.len(),
scanned_files: 0,
missing_file_links: vec![],
stale_file_links: vec![],
missing_edges: vec![],
stale_edges: vec![],
unnormalized_paths: vec![],
orphaned_files: vec![],
repaired_count: repaired,
verification_passed: true,
dirty_marker_cleared: repaired > 0,
})
}
struct DesiredState {
file_links: HashMap<String, BTreeSet<String>>,
edges: BTreeSet<(String, String)>,
scanned: usize,
unnormalized: Vec<DriftEntry>,
}
impl DesiredState {
fn drop_tombstoned(&mut self, tombstoned: &HashSet<String>) {
self.file_links.retain(|path, _| !tombstoned.contains(path));
self.edges.retain(|(path, _)| !tombstoned.contains(path));
}
}
async fn derive_desired_state<R: RepairReader>(
reader: &R,
repo_root: &Path,
) -> Result<DesiredState> {
let gotchas = reader.scan_prefix("gotcha:").await?;
let scanned = gotchas.len();
let mut file_links: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut edges: BTreeSet<(String, String)> = BTreeSet::new();
let mut unnormalized: Vec<DriftEntry> = Vec::new();
for record in &gotchas {
if !matches!(record.lifecycle, RecordLifecycle::Active) {
continue;
}
let Some(gotcha) = record.payload_as::<GotchaRecord>() else {
continue;
};
unnormalized.extend(unnormalized_entries(
&record.key,
&gotcha.affected_files,
repo_root,
));
for file_path in &gotcha.affected_files {
file_links
.entry(file_path.clone())
.or_default()
.insert(record.key.clone());
edges.insert((file_path.clone(), record.key.clone()));
}
}
Ok(DesiredState {
file_links,
edges,
scanned,
unnormalized,
})
}
fn unnormalized_entries(
gotcha_key: &str,
affected_files: &[String],
repo_root: &Path,
) -> Vec<DriftEntry> {
let normalized = crate::store::gotcha_ops::normalize_affected_files(affected_files, repo_root);
if normalized == affected_files {
return Vec::new();
}
affected_files
.iter()
.filter(|path| !normalized.contains(*path))
.map(|path| DriftEntry {
gotcha_key: gotcha_key.to_string(),
file_path: path.clone(),
})
.collect()
}
struct ActualFileLinks {
links: HashMap<String, Vec<String>>,
scanned: usize,
tombstoned: HashSet<String>,
}
async fn read_actual_file_links<R: RepairReader>(reader: &R) -> Result<ActualFileLinks> {
let files = reader.scan_prefix("file:").await?;
let scanned = files.len();
let mut links: HashMap<String, Vec<String>> = HashMap::new();
let mut tombstoned: HashSet<String> = HashSet::new();
for record in &files {
let path = record
.key
.strip_prefix("file:")
.unwrap_or(&record.key)
.to_string();
if !matches!(record.lifecycle, RecordLifecycle::Active) {
tombstoned.insert(path.clone());
}
let keys = extract_gotcha_keys(record);
if !keys.is_empty() {
links.insert(path, keys);
}
}
Ok(ActualFileLinks {
links,
scanned,
tombstoned,
})
}
async fn read_actual_edges<R: RepairReader>(reader: &R) -> Result<BTreeSet<(String, String)>> {
let edge_keys = reader.scan_keys("graph:edge:").await?;
let mut actual = BTreeSet::new();
for key in &edge_keys {
if let Some(edge) = Edge::from_key(key) {
if edge.kind == EdgeKind::HasGotcha {
let file_path = edge
.from
.strip_prefix("file:")
.unwrap_or(&edge.from)
.to_string();
actual.insert((file_path, edge.to));
}
}
}
Ok(actual)
}
fn diff_file_links(
desired: &HashMap<String, BTreeSet<String>>,
actual: &HashMap<String, Vec<String>>,
) -> (Vec<DriftEntry>, Vec<DriftEntry>) {
let mut missing = Vec::new();
let mut stale = Vec::new();
for (file_path, desired_keys) in desired {
let actual_keys: BTreeSet<String> = actual
.get(file_path)
.map(|v| v.iter().cloned().collect())
.unwrap_or_default();
for key in desired_keys {
if !actual_keys.contains(key) {
missing.push(DriftEntry {
gotcha_key: key.clone(),
file_path: file_path.clone(),
});
}
}
}
for (file_path, actual_keys) in actual {
let desired_keys = desired.get(file_path);
for key in actual_keys {
let is_desired = desired_keys.map(|d| d.contains(key)).unwrap_or(false);
if !is_desired {
stale.push(DriftEntry {
gotcha_key: key.clone(),
file_path: file_path.clone(),
});
}
}
}
(missing, stale)
}
fn diff_edges(
desired: &BTreeSet<(String, String)>,
actual: &BTreeSet<(String, String)>,
) -> (Vec<DriftEntry>, Vec<DriftEntry>) {
let missing: Vec<DriftEntry> = desired
.difference(actual)
.map(|(file_path, gotcha_key)| DriftEntry {
gotcha_key: gotcha_key.clone(),
file_path: file_path.clone(),
})
.collect();
let stale: Vec<DriftEntry> = actual
.difference(desired)
.map(|(file_path, gotcha_key)| DriftEntry {
gotcha_key: gotcha_key.clone(),
file_path: file_path.clone(),
})
.collect();
(missing, stale)
}
fn extract_gotcha_keys(record: &Record) -> Vec<String> {
record
.payload
.as_ref()
.and_then(|p| p.get("gotcha_keys"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn set_gotcha_keys(record: &mut Record, keys: Vec<String>) {
if let Some(payload) = record.payload.as_mut() {
if let Some(obj) = payload.as_object_mut() {
obj.insert(
"gotcha_keys".into(),
serde_json::Value::Array(keys.into_iter().map(serde_json::Value::String).collect()),
);
}
}
}
pub async fn clear_dirty_key_if_solo(store: &Store, gotcha_key: &str) {
let Some(mut marker) = read_dirty_marker(store).await else {
return;
};
if !marker.dirty {
return;
}
let only_ours = marker.affected_keys.len() == 1 && marker.affected_keys[0] == gotcha_key;
if !only_ours {
return;
}
let now = now_secs();
marker.dirty = false;
marker.affected_keys.clear();
marker.last_repaired_at = now;
let record = Record {
key: DIRTY_MARKER_KEY.to_string(),
value: String::new(),
payload: serde_json::to_value(&marker).ok(),
category: Category::Analytics,
priority: Priority::Normal,
tags: vec![],
created_at: now,
updated_at: now,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: crate::store::stable_device_id(),
logical_clock: 1,
wall_clock: now,
},
quality: crate::store::record::QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: crate::store::record::ConfidenceScore::for_new_record(
&RecordSource::StaticAnalysis,
),
gap_analysis_score: 0.0,
};
let _ = store.put(DIRTY_MARKER_KEY, &record).await;
}
async fn clear_dirty_marker(store: &Store, now: u64) {
if let Some(mut marker) = read_dirty_marker(store).await {
marker.dirty = false;
marker.affected_keys.clear();
marker.last_repaired_at = now;
let record = Record {
key: DIRTY_MARKER_KEY.to_string(),
value: String::new(),
payload: serde_json::to_value(&marker).ok(),
category: Category::Analytics,
priority: Priority::Normal,
tags: vec![],
created_at: now,
updated_at: now,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: crate::store::stable_device_id(),
logical_clock: 1,
wall_clock: now,
},
quality: crate::store::record::QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: crate::store::record::ConfidenceScore::for_new_record(
&RecordSource::StaticAnalysis,
),
gap_analysis_score: 0.0,
};
let _ = store.put(DIRTY_MARKER_KEY, &record).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::record::FileRecord;
fn make_gotcha(key: &str, files: &[&str]) -> Record {
let gotcha = GotchaRecord {
rule: "test".into(),
reason: "test".into(),
severity: Priority::High,
affected_files: files.iter().map(|s| s.to_string()).collect(),
ref_url: None,
discovered_session: 1_000_000,
confirmed: true,
confirmed_content: Default::default(),
};
Record {
key: key.to_string(),
value: "test".into(),
payload: serde_json::to_value(&gotcha).ok(),
category: Category::Gotcha,
priority: Priority::High,
tags: vec![],
created_at: 1_000_000,
updated_at: 1_000_000,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 1_000_000,
},
quality: crate::store::record::QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::DeveloperManual,
confidence: crate::store::record::ConfidenceScore::for_new_record(
&RecordSource::DeveloperManual,
),
gap_analysis_score: 0.0,
}
}
fn make_file(path: &str, gotcha_keys: &[&str]) -> Record {
let file = FileRecord {
path: path.to_string(),
purpose: String::new(),
entry_points: vec![],
imports: vec![],
gotcha_keys: gotcha_keys.iter().map(|s| s.to_string()).collect(),
decision_keys: vec![],
todos: vec![],
unsafe_count: 0,
unwrap_count: 0,
change_frequency: 0,
last_author: None,
is_hotspot: false,
token_cost_estimate: 0,
last_modified_session: 0,
content_hash: None,
line_count: 0,
blast_radius: None,
propagated_staleness: None,
};
Record {
key: format!("file:{path}"),
value: String::new(),
payload: serde_json::to_value(&file).ok(),
category: Category::File,
priority: Priority::Normal,
tags: vec![],
created_at: 1_000_000,
updated_at: 1_000_000,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 1_000_000,
},
quality: crate::store::record::QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: crate::store::record::ConfidenceScore::for_new_record(
&RecordSource::StaticAnalysis,
),
gap_analysis_score: 0.0,
}
}
#[tokio::test]
async fn check_detects_no_drift_when_consistent() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("gotcha:g1", &make_gotcha("gotcha:g1", &["src/a.rs"]))
.await
.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:g1"]))
.await
.unwrap();
let edge = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:g1");
store
.put_raw(&edge.to_key(), &now_secs().to_le_bytes())
.await
.unwrap();
let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(!report.has_drift());
assert_eq!(report.scanned_gotchas, 1);
assert_eq!(report.scanned_files, 1);
store.close().await.unwrap();
}
#[tokio::test]
async fn codeowners_candidate_write_has_no_index_drift() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
for path in ["src/payments/card.rs", "src/payments/wallet.rs"] {
store
.put(&format!("file:{path}"), &make_file(path, &[]))
.await
.unwrap();
}
let rules = crate::analysis::onboarding::parse_codeowners("src/payments/** @team\n");
let repo_files = vec![
"src/payments/card.rs".to_string(),
"src/payments/wallet.rs".to_string(),
];
let candidate = crate::analysis::onboarding::codeowners_candidates(
&rules,
&repo_files,
uuid::Uuid::nil(),
1,
1_000_000,
)
.pop()
.unwrap();
let gotcha = candidate
.payload_as::<GotchaRecord>()
.expect("candidate must carry a GotchaRecord");
crate::store::gotcha_ops::apply_gotcha_write(
&store,
dir.path(),
&candidate,
&[],
&gotcha.affected_files,
true,
)
.await
.unwrap();
let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(!report.has_drift());
store.close().await.unwrap();
}
#[tokio::test]
async fn check_detects_missing_file_link() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("gotcha:g1", &make_gotcha("gotcha:g1", &["src/a.rs"]))
.await
.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &[]))
.await
.unwrap();
let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(report.has_drift());
assert_eq!(report.missing_file_links.len(), 1);
assert_eq!(report.missing_file_links[0].gotcha_key, "gotcha:g1");
assert_eq!(report.missing_file_links[0].file_path, "src/a.rs");
store.close().await.unwrap();
}
#[tokio::test]
async fn check_detects_stale_file_link() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:deleted"]))
.await
.unwrap();
let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(report.has_drift());
assert_eq!(report.stale_file_links.len(), 1);
assert_eq!(report.stale_file_links[0].gotcha_key, "gotcha:deleted");
store.close().await.unwrap();
}
#[tokio::test]
async fn repair_fixes_missing_links_and_verifies() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put(
"gotcha:g1",
&make_gotcha("gotcha:g1", &["src/a.rs", "src/b.rs"]),
)
.await
.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &[]))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file("src/b.rs", &[]))
.await
.unwrap();
let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Full)
.await
.unwrap();
assert!(report.verification_passed);
assert!(report.repaired_count > 0);
assert!(report.dirty_marker_cleared);
let a = store.get("file:src/a.rs").await.unwrap().unwrap();
let b = store.get("file:src/b.rs").await.unwrap().unwrap();
assert!(extract_gotcha_keys(&a).contains(&"gotcha:g1".to_string()));
assert!(extract_gotcha_keys(&b).contains(&"gotcha:g1".to_string()));
let edges = store.scan_keys("graph:edge:").await.unwrap();
let edge_a = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:g1").to_key();
let edge_b = Edge::new("file:src/b.rs", EdgeKind::HasGotcha, "gotcha:g1").to_key();
assert!(edges.contains(&edge_a));
assert!(edges.contains(&edge_b));
store.close().await.unwrap();
}
#[tokio::test]
async fn repair_removes_stale_links() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:ghost"]))
.await
.unwrap();
let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Full)
.await
.unwrap();
assert!(report.verification_passed);
let a = store.get("file:src/a.rs").await.unwrap().unwrap();
assert!(extract_gotcha_keys(&a).is_empty());
store.close().await.unwrap();
}
#[tokio::test]
async fn check_detects_unnormalized_affected_files() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("gotcha:g1", &make_gotcha("gotcha:g1", &["./src/a.rs"]))
.await
.unwrap();
let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(report.has_drift(), "--check must exit non-zero on this");
assert_eq!(report.unnormalized_paths.len(), 1);
assert_eq!(report.unnormalized_paths[0].gotcha_key, "gotcha:g1");
assert_eq!(report.unnormalized_paths[0].file_path, "./src/a.rs");
let stored = store.get("gotcha:g1").await.unwrap().unwrap();
let gotcha = stored.payload_as::<GotchaRecord>().unwrap();
assert_eq!(gotcha.affected_files, vec!["./src/a.rs".to_string()]);
store.close().await.unwrap();
}
#[tokio::test]
async fn check_reports_no_unnormalized_drift_for_clean_paths_and_globs() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put(
"gotcha:g1",
&make_gotcha("gotcha:g1", &["src/a.rs", "src/payments/**"]),
)
.await
.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:g1"]))
.await
.unwrap();
let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(report.unnormalized_paths.is_empty());
store.close().await.unwrap();
}
#[tokio::test]
async fn repair_rewrites_unnormalized_paths_and_repoints_links() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("gotcha:g1", &make_gotcha("gotcha:g1", &["./src/a.rs"]))
.await
.unwrap();
store
.put("file:./src/a.rs", &make_file("./src/a.rs", &["gotcha:g1"]))
.await
.unwrap();
let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Full)
.await
.unwrap();
assert_eq!(report.unnormalized_paths.len(), 1);
assert!(report.repaired_count > 0);
assert!(report.verification_passed);
let stored = store.get("gotcha:g1").await.unwrap().unwrap();
let gotcha = stored.payload_as::<GotchaRecord>().unwrap();
assert_eq!(gotcha.affected_files, vec!["src/a.rs".to_string()]);
let good = store
.get("file:src/a.rs")
.await
.unwrap()
.expect("corrected path must be linked, stubbed if it had no record");
assert_eq!(extract_gotcha_keys(&good), vec!["gotcha:g1".to_string()]);
let raw = store.get("file:./src/a.rs").await.unwrap().unwrap();
assert!(
extract_gotcha_keys(&raw).is_empty(),
"link at the raw spelling must be dropped"
);
let again = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(!again.has_drift());
store.close().await.unwrap();
}
#[tokio::test]
async fn repair_skips_unnormalized_paths_on_tombstoned_gotchas() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
let mut record = make_gotcha("gotcha:dead", &["./src/a.rs"]);
record.lifecycle = RecordLifecycle::Tombstoned {
reason: crate::store::record::TombstoneReason::ManualDeletion,
at: 1_000_000,
};
store.put("gotcha:dead", &record).await.unwrap();
let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(report.unnormalized_paths.is_empty());
store.close().await.unwrap();
}
fn seed_repo(dir: &Path) {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/a.rs"), "fn main() {}").unwrap();
}
#[tokio::test]
async fn orphan_scan_separates_outside_repo_from_deleted_in_repo() {
let dir = tempfile::TempDir::new().unwrap();
seed_repo(dir.path());
let store = Store::open(dir.path()).await.unwrap();
for path in [
"src/a.rs",
"src/gone.rs",
"hclsyntax/parser.go",
"/tmp/scratchpad/notes.md",
] {
store
.put(&format!("file:{path}"), &make_file(path, &[]))
.await
.unwrap();
}
let scan = find_orphaned_files(&store, dir.path()).await.unwrap();
assert_eq!(scan.active_files, 4);
let kind = |key: &str| scan.orphans.iter().find(|o| o.key == key).map(|o| o.kind);
assert_eq!(kind("file:src/a.rs"), None, "the file is right there");
assert_eq!(kind("file:src/gone.rs"), Some(OrphanKind::DeletedInRepo));
assert_eq!(
kind("file:hclsyntax/parser.go"),
Some(OrphanKind::OutsideRepo),
"a top-level directory this repo does not have"
);
assert_eq!(
kind("file:/tmp/scratchpad/notes.md"),
Some(OrphanKind::OutsideRepo)
);
let gone = store.get("file:src/gone.rs").await.unwrap().unwrap();
assert!(matches!(gone.lifecycle, RecordLifecycle::Active));
store.close().await.unwrap();
}
#[tokio::test]
async fn purge_tombstones_orphans_and_unlinks_their_gotchas() {
let dir = tempfile::TempDir::new().unwrap();
seed_repo(dir.path());
let store = Store::open(dir.path()).await.unwrap();
store
.put(
"gotcha:g1",
&make_gotcha("gotcha:g1", &["src/a.rs", "src/gone.rs"]),
)
.await
.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:g1"]))
.await
.unwrap();
store
.put(
"file:src/gone.rs",
&make_file("src/gone.rs", &["gotcha:g1"]),
)
.await
.unwrap();
for path in ["file:src/a.rs", "file:src/gone.rs"] {
let edge = Edge::new(path, EdgeKind::HasGotcha, "gotcha:g1");
store
.put_raw(&edge.to_key(), &now_secs().to_le_bytes())
.await
.unwrap();
}
let scan = find_orphaned_files(&store, dir.path()).await.unwrap();
let outcome = purge_orphaned_files(&store, &scan).await.unwrap();
assert_eq!(
outcome,
PurgeOutcome::Purged {
tombstoned: 1,
unlinked: 1
}
);
let gone = store.get("file:src/gone.rs").await.unwrap().unwrap();
assert!(matches!(gone.lifecycle, RecordLifecycle::Tombstoned { .. }));
assert!(extract_gotcha_keys(&gone).is_empty());
let edges = store.scan_keys("graph:edge:").await.unwrap();
let dead_edge = Edge::new("file:src/gone.rs", EdgeKind::HasGotcha, "gotcha:g1").to_key();
assert!(!edges.contains(&dead_edge));
let live = store.get("file:src/a.rs").await.unwrap().unwrap();
assert_eq!(extract_gotcha_keys(&live), vec!["gotcha:g1".to_string()]);
let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(
!report.has_drift(),
"purged orphans must not read as missing links: missing_file={:?}, missing_edges={:?}",
report.missing_file_links.len(),
report.missing_edges.len(),
);
store.close().await.unwrap();
}
#[tokio::test]
async fn purge_refuses_when_orphans_are_most_of_the_store() {
let dir = tempfile::TempDir::new().unwrap();
seed_repo(dir.path());
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &[]))
.await
.unwrap();
for i in 0..19 {
let path = format!("src/gone{i}.rs");
store
.put(&format!("file:{path}"), &make_file(&path, &[]))
.await
.unwrap();
}
let scan = find_orphaned_files(&store, dir.path()).await.unwrap();
assert_eq!(
purge_orphaned_files(&store, &scan).await.unwrap(),
PurgeOutcome::Refused {
orphans: 19,
active_files: 20
}
);
let untouched = store.get("file:src/gone0.rs").await.unwrap().unwrap();
assert!(matches!(untouched.lifecycle, RecordLifecycle::Active));
store.close().await.unwrap();
}
#[tokio::test]
async fn dirty_marker_lifecycle() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
assert!(!is_dirty(&store).await);
mark_dirty(&store, "gotcha:test", "link sync failed").await;
assert!(is_dirty(&store).await);
let marker = read_dirty_marker(&store).await.unwrap();
assert!(marker.dirty);
assert_eq!(marker.affected_keys, vec!["gotcha:test"]);
clear_dirty_marker(&store, now_secs()).await;
assert!(!is_dirty(&store).await);
store.close().await.unwrap();
}
#[tokio::test]
async fn partial_failure_recovery_contract() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &[]))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file("src/b.rs", &[]))
.await
.unwrap();
let gotcha = make_gotcha("gotcha:partial", &["src/a.rs", "src/b.rs"]);
store.put("gotcha:partial", &gotcha).await.unwrap();
mark_dirty(&store, "gotcha:partial", "link sync failed").await;
let canonical = store.get("gotcha:partial").await.unwrap();
assert!(canonical.is_some(), "canonical gotcha record must persist");
let a = store.get("file:src/a.rs").await.unwrap().unwrap();
let b = store.get("file:src/b.rs").await.unwrap().unwrap();
assert!(
extract_gotcha_keys(&a).is_empty(),
"file link should be missing (secondary write failed)"
);
assert!(
extract_gotcha_keys(&b).is_empty(),
"file link should be missing (secondary write failed)"
);
assert!(is_dirty(&store).await, "dirty marker must be set");
let marker = read_dirty_marker(&store).await.unwrap();
assert!(marker.affected_keys.contains(&"gotcha:partial".to_string()));
let pre = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(pre.has_drift());
assert_eq!(pre.missing_file_links.len(), 2);
assert_eq!(pre.missing_edges.len(), 2);
let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Full)
.await
.unwrap();
assert!(report.repaired_count > 0, "repair should fix something");
assert!(
report.verification_passed,
"post-repair verification must pass"
);
assert!(
report.dirty_marker_cleared,
"dirty marker must be cleared after verified repair"
);
let a2 = store.get("file:src/a.rs").await.unwrap().unwrap();
let b2 = store.get("file:src/b.rs").await.unwrap().unwrap();
assert!(extract_gotcha_keys(&a2).contains(&"gotcha:partial".to_string()));
assert!(extract_gotcha_keys(&b2).contains(&"gotcha:partial".to_string()));
let edges = store.scan_keys("graph:edge:").await.unwrap();
let edge_a = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:partial").to_key();
let edge_b = Edge::new("file:src/b.rs", EdgeKind::HasGotcha, "gotcha:partial").to_key();
assert!(edges.contains(&edge_a));
assert!(edges.contains(&edge_b));
assert!(!is_dirty(&store).await);
let post = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(!post.has_drift());
store.close().await.unwrap();
}
#[tokio::test]
async fn fast_repair_removes_stale_links_on_move() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:moved"]))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file("src/b.rs", &["gotcha:moved"]))
.await
.unwrap();
store
.put("file:src/c.rs", &make_file("src/c.rs", &[]))
.await
.unwrap();
store
.put(
"gotcha:moved",
&make_gotcha("gotcha:moved", &["src/b.rs", "src/c.rs"]),
)
.await
.unwrap();
let stale_edge = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:moved");
store
.put_raw(&stale_edge.to_key(), &now_secs().to_le_bytes())
.await
.unwrap();
mark_dirty(&store, "gotcha:moved", "affected_files changed").await;
let report = repair_fast(&store, now_secs()).await.unwrap();
assert!(
report.repaired_count > 0,
"fast repair should fix something"
);
assert!(report.dirty_marker_cleared);
let a = store.get("file:src/a.rs").await.unwrap().unwrap();
assert!(
!extract_gotcha_keys(&a).contains(&"gotcha:moved".to_string()),
"stale link on file A should be removed"
);
let b = store.get("file:src/b.rs").await.unwrap().unwrap();
assert!(extract_gotcha_keys(&b).contains(&"gotcha:moved".to_string()));
let c = store.get("file:src/c.rs").await.unwrap().unwrap();
assert!(extract_gotcha_keys(&c).contains(&"gotcha:moved".to_string()));
let check = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(
!check.has_drift(),
"no drift should remain after fast repair: missing_file_links={}, stale_file_links={}, missing_edges={}, stale_edges={}",
check.missing_file_links.len(),
check.stale_file_links.len(),
check.missing_edges.len(),
check.stale_edges.len(),
);
store.close().await.unwrap();
}
#[tokio::test]
async fn auto_drain_on_reopen_clears_dirty_marker_and_drift() {
let dir = tempfile::TempDir::new().unwrap();
{
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:moved"]))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file("src/b.rs", &["gotcha:moved"]))
.await
.unwrap();
store
.put("file:src/c.rs", &make_file("src/c.rs", &[]))
.await
.unwrap();
store
.put(
"gotcha:moved",
&make_gotcha("gotcha:moved", &["src/b.rs", "src/c.rs"]),
)
.await
.unwrap();
let stale_edge = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:moved");
store
.put_raw(&stale_edge.to_key(), &now_secs().to_le_bytes())
.await
.unwrap();
mark_dirty(&store, "gotcha:moved", "simulated partial-write").await;
let pre = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(pre.has_drift(), "drift must exist before shutdown");
assert!(is_dirty(&store).await, "marker must be set before shutdown");
store.close().await.unwrap();
}
{
let store = Store::open(dir.path()).await.unwrap();
assert!(
is_dirty(&store).await,
"dirty marker should survive reopen across sessions"
);
let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Fast)
.await
.unwrap();
assert!(report.repaired_count > 0, "Fast drain must apply repairs");
assert!(
report.dirty_marker_cleared,
"Fast drain must clear the dirty marker on success"
);
assert!(
!is_dirty(&store).await,
"auto-drain should leave no dirty marker behind"
);
let post = check_gotcha_indexes(&store, dir.path()).await.unwrap();
assert!(
!post.has_drift(),
"no drift after auto-drain: missing_file={}, stale_file={}, missing_edge={}, stale_edge={}",
post.missing_file_links.len(),
post.stale_file_links.len(),
post.missing_edges.len(),
post.stale_edges.len(),
);
store.close().await.unwrap();
}
}
}