use std::collections::{BTreeMap, BTreeSet};
use crate::mcp::dispatch_v2::RequestContext;
use crate::mcp::handlers;
use crate::mcp::metadata::PeerContext;
use crate::mcp::protocol;
use crate::store::gotcha_ops;
use crate::store::record::{
Category, ConfidenceScore, GotchaRecord, Priority, QualityScore, Record, RecordLifecycle,
RecordSource, RecordVersion, StalenessScore,
};
use crate::store::Store;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Path {
Direct,
Daemon,
}
fn test_ctx(repo_root: &std::path::Path) -> RequestContext {
RequestContext {
peer: PeerContext {
uid: 501,
pid: Some(4242),
},
daemon_session: uuid::Uuid::nil(),
repo_root: repo_root.to_path_buf(),
policy_matcher: std::sync::Arc::new(tokio::sync::RwLock::new(
crate::hooks::policy_match::PolicyMatcherSet::empty(),
)),
}
}
async fn drive_write(
path: Path,
store: &Store,
repo_root: &std::path::Path,
record: &Record,
old_files: &[String],
new_files: &[String],
is_new: bool,
) -> Result<(), String> {
match path {
Path::Direct => {
gotcha_ops::apply_gotcha_write(store, repo_root, record, old_files, new_files, is_new)
.await
.map_err(|e| e.to_string())
}
Path::Daemon => {
let gotcha = record
.payload_as::<GotchaRecord>()
.unwrap_or_else(|| GotchaRecord {
rule: record.value.clone(),
reason: String::new(),
severity: Priority::Normal,
affected_files: new_files.to_vec(),
ref_url: None,
discovered_session: 0,
confirmed: false,
confirmed_content: Default::default(),
});
let source = match &record.source {
RecordSource::DeveloperManual => Some("developer_manual".to_string()),
RecordSource::Import => Some("import".to_string()),
_ => None,
};
let input = protocol::GotchaDraftInput {
key: record.key.clone(),
rule: gotcha.rule,
reason: gotcha.reason,
severity: gotcha.severity.into(),
affected_files: new_files.to_vec(),
ref_url: gotcha.ref_url,
tags: record.tags.clone(),
priority: record.priority.clone().into(),
source,
confirmed: gotcha.confirmed,
};
handlers::handle_gotcha_upsert(store, &test_ctx(repo_root), uuid::Uuid::nil(), &input)
.await
.map(|_| ())
.map_err(|(code, msg)| format!("{code:?}: {msg}"))
}
}
}
async fn drive_confirm(
path: Path,
store: &Store,
repo_root: &std::path::Path,
key: &str,
) -> Result<(), String> {
let mut record = store
.get(key)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("no record found for '{key}'"))?;
if record.category != Category::Gotcha {
return Err(format!("'{key}' is not a Gotcha record"));
}
if !matches!(record.lifecycle, RecordLifecycle::Active) {
return Err(format!("'{key}' is tombstoned — cannot confirm"));
}
if let Some(obj) = record.payload.as_mut().and_then(|p| p.as_object_mut()) {
if let Some(sev) = obj
.get("severity")
.and_then(|v| v.as_str())
.map(str::to_lowercase)
{
obj.insert("severity".to_string(), serde_json::Value::String(sev));
}
obj.insert("confirmed".to_string(), serde_json::Value::Bool(true));
}
let now = now_secs();
record.source = RecordSource::DeveloperManual;
record.confidence.value = ConfidenceScore::base_for_source(&RecordSource::DeveloperManual);
record.confidence.confirmation_count += 1;
record.quality = crate::health::quality::analyze(&record);
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
let affected_files = gotcha_ops::normalize_affected_files(
&record
.payload_as::<GotchaRecord>()
.map(|g| g.affected_files)
.unwrap_or_default(),
repo_root,
);
match path {
Path::Direct => {
gotcha_ops::apply_gotcha_confirm(store, repo_root, &record, &affected_files)
.await
.map_err(|e| e.to_string())?;
gotcha_ops::propagate_confirmation_to_files(store, &affected_files).await;
}
Path::Daemon => {
let input = protocol::GotchaConfirmInput {
key: key.to_string(),
via_elicitation: false,
};
handlers::handle_gotcha_confirm(store, &test_ctx(repo_root), uuid::Uuid::nil(), &input)
.await
.map_err(|(code, msg)| format!("{code:?}: {msg}"))?;
}
}
Ok(())
}
async fn drive_tombstone(
path: Path,
store: &Store,
repo_root: &std::path::Path,
key: &str,
) -> Result<(), String> {
let affected: Vec<String> = store
.get(key)
.await
.map_err(|e| e.to_string())?
.and_then(|r| r.payload_as::<GotchaRecord>())
.map(|g| g.affected_files)
.unwrap_or_default();
match path {
Path::Direct => gotcha_ops::apply_gotcha_tombstone(store, key, &affected)
.await
.map_err(|e| e.to_string()),
Path::Daemon => {
let input = protocol::GotchaTombstoneInput {
key: key.to_string(),
};
handlers::handle_gotcha_tombstone(
store,
&test_ctx(repo_root),
uuid::Uuid::nil(),
&input,
)
.await
.map(|_| ())
.map_err(|(code, msg)| format!("{code:?}: {msg}"))
}
}
}
type Snapshot = BTreeMap<String, String>;
async fn snapshot(store: &Store, key: &str, watched_files: &[&str], baseline: u64) -> Snapshot {
let mut s = Snapshot::new();
let bumped = |ts: u64| -> String { format!("bumped={}", ts >= baseline) };
match store.get(key).await.ok().flatten() {
None => {
s.insert("record".into(), "ABSENT".into());
}
Some(r) => {
s.insert("record".into(), "present".into());
s.insert("record.value".into(), r.value.clone());
s.insert("record.category".into(), format!("{:?}", r.category));
s.insert("record.priority".into(), format!("{:?}", r.priority));
s.insert("record.tags".into(), format!("{:?}", r.tags));
s.insert("record.source".into(), format!("{:?}", r.source));
s.insert("record.ref_url".into(), format!("{:?}", r.ref_url));
s.insert(
"record.lifecycle".into(),
match &r.lifecycle {
RecordLifecycle::Tombstoned { reason, .. } => {
format!("Tombstoned({reason:?})")
}
other => format!("{other:?}"),
},
);
s.insert(
"confidence.value".into(),
format!("{:.4}", r.confidence.value),
);
s.insert(
"confidence.confirmation_count".into(),
r.confidence.confirmation_count.to_string(),
);
s.insert(
"confidence.contributor_count".into(),
r.confidence.contributor_count.to_string(),
);
s.insert("quality.value".into(), format!("{:.4}", r.quality.value));
s.insert("quality.tier".into(), format!("{:?}", r.quality.tier));
s.insert(
"quality.signals".into(),
format!("{}", r.quality.signals.len()),
);
s.insert(
"version.logical_clock".into(),
r.version.logical_clock.to_string(),
);
s.insert("version.wall_clock".into(), bumped(r.version.wall_clock));
s.insert("record.updated_at".into(), bumped(r.updated_at));
let payload = r.payload_as::<GotchaRecord>();
match payload {
None => {
s.insert("payload".into(), "UNPARSEABLE".into());
}
Some(g) => {
s.insert("payload.rule".into(), g.rule);
s.insert("payload.reason".into(), g.reason);
s.insert("payload.severity".into(), format!("{:?}", g.severity));
s.insert(
"payload.affected_files".into(),
format!("{:?}", g.affected_files),
);
s.insert("payload.ref_url".into(), format!("{:?}", g.ref_url));
s.insert("payload.confirmed".into(), g.confirmed.to_string());
s.insert(
"payload.confirmed_content".into(),
format!("{:?}", g.confirmed_content.keys().collect::<Vec<_>>()),
);
}
}
}
}
for path in watched_files {
let file_key = format!("file:{path}");
match store.get(&file_key).await.ok().flatten() {
None => {
s.insert(format!("file[{path}]"), "ABSENT".into());
}
Some(fr) => {
let keys: Vec<String> = fr
.payload
.as_ref()
.and_then(|p| p.get("gotcha_keys"))
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
s.insert(format!("file[{path}].gotcha_keys"), format!("{keys:?}"));
s.insert(
format!("file[{path}].confirmation_count"),
fr.confidence.confirmation_count.to_string(),
);
s.insert(
format!("file[{path}].logical_clock"),
fr.version.logical_clock.to_string(),
);
}
}
}
let edges: BTreeSet<String> = store
.scan_keys("graph:edge:")
.await
.unwrap_or_default()
.into_iter()
.filter(|k| k.ends_with(key))
.collect();
s.insert("graph.has_gotcha_edges".into(), format!("{edges:?}"));
let ex_key = crate::store::extraction::key_for(key);
match store.get(&ex_key).await.ok().flatten() {
None => {
s.insert("extraction".into(), "ABSENT".into());
}
Some(r) => {
let parsed = r.payload_as::<crate::store::extraction::ExtractionRecord>();
s.insert(
"extraction".into(),
match parsed {
Some(e) => format!(
"outcome={:?} depth={:?} file={} config={:?}",
e.outcome, e.depth, e.file_path, e.config
),
None => "UNPARSEABLE".into(),
},
);
}
}
let negs: BTreeSet<String> = store
.scan_keys(crate::store::negative_exemplar::NEG_EXEMPLAR_PREFIX)
.await
.unwrap_or_default()
.into_iter()
.collect();
s.insert("negative_exemplars".into(), format!("{negs:?}"));
let events = crate::store::enforcement::scan_enforcement_events(store, 0, u64::MAX)
.await
.unwrap_or_default();
let rendered: Vec<String> = events
.iter()
.map(|e| {
format!(
"{:?}/{:?}/{}/{}",
e.event_type, e.subject_kind, e.subject_key, e.decision_reason_code
)
})
.collect();
s.insert("enforcement_events".into(), format!("{rendered:?}"));
s.insert(
"dirty_marker".into(),
match crate::store::repair::read_dirty_marker(store).await {
None => "ABSENT".into(),
Some(m) => format!("dirty={} keys={:?}", m.dirty, m.affected_keys),
},
);
let receipts: BTreeSet<String> = store
.scan_keys("session:consulted:")
.await
.unwrap_or_default()
.into_iter()
.collect();
s.insert("consultation_receipts".into(), format!("{receipts:?}"));
let audits = store
.scan_keys("audit:knowledge:")
.await
.unwrap_or_default()
.len();
s.insert("audit_knowledge_rows".into(), audits.to_string());
s
}
fn diff(direct: &Snapshot, daemon: &Snapshot) -> BTreeMap<String, (String, String)> {
let mut out = BTreeMap::new();
let keys: BTreeSet<&String> = direct.keys().chain(daemon.keys()).collect();
for k in keys {
let a = direct.get(k).cloned().unwrap_or_else(|| "<missing>".into());
let b = daemon.get(k).cloned().unwrap_or_else(|| "<missing>".into());
if a != b {
out.insert(k.clone(), (a, b));
}
}
out
}
fn assert_divergences(scenario: &str, d: &BTreeMap<String, (String, String)>, expected: &[&str]) {
let observed: BTreeSet<&str> = d.keys().map(String::as_str).collect();
let pinned: BTreeSet<&str> = expected.iter().copied().collect();
if observed == pinned {
return;
}
let mut report = format!("\n{scenario}: gotcha write paths drifted\n\n");
for (field, (a, b)) in d {
let mark = if pinned.contains(field.as_str()) {
"known"
} else {
"NEW "
};
report.push_str(&format!(
" [{mark}] {field}\n direct: {a}\n daemon: {b}\n"
));
}
for missing in pinned.difference(&observed) {
report.push_str(&format!(
" [GONE ] {missing}\n the paths now agree here — drop it from EXPECTED_DIVERGENCES\n"
));
}
panic!("{report}");
}
const CREATE_DIVERGENCES: &[&str] = &["audit_knowledge_rows"];
const EDIT_DIVERGENCES: &[&str] = &["audit_knowledge_rows"];
const EDIT_OF_TOMBSTONED_KEY_DIVERGENCES: &[&str] = &[
"audit_knowledge_rows",
"enforcement_events",
"version.logical_clock",
];
const CONFIRM_DIVERGENCES: &[&str] = &["audit_knowledge_rows"];
const CONFIRM_LEGACY_DIVERGENCES: &[&str] = &["audit_knowledge_rows"];
const TOMBSTONE_DIVERGENCES: &[&str] = &["audit_knowledge_rows"];
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before UNIX epoch")
.as_secs()
}
fn manual_add_record(key: &str, rule: &str, reason: &str, files: &[&str]) -> Record {
let now = now_secs();
let gotcha = GotchaRecord {
rule: rule.into(),
reason: reason.into(),
severity: Priority::High,
affected_files: files.iter().map(|s| s.to_string()).collect(),
ref_url: None,
discovered_session: now,
confirmed: true,
confirmed_content: Default::default(),
};
let mut record = Record {
key: key.into(),
value: format!("{rule} because {reason}"),
payload: serde_json::to_value(&gotcha).ok(),
category: Category::Gotcha,
priority: Priority::High,
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: QualityScore::developer_entry_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::DeveloperManual,
confidence: ConfidenceScore::for_new_record(&RecordSource::DeveloperManual),
gap_analysis_score: 0.0,
};
record.confidence.confirmation_count = 1;
record.quality = crate::health::quality::analyze(&record);
record
}
fn file_record(path: &str) -> Record {
let now = now_secs();
let mut fr = crate::store::record::FileRecord::layer0_stub(
path,
vec![],
vec![],
vec![],
0,
0,
0,
None,
false,
0,
now,
);
fr.content_hash = Some(format!("hash-of-{path}"));
let mut rec = Record::layer0_file_stub(
format!("file:{path}"),
crate::store::stable_device_id(),
1,
now,
);
rec.payload = serde_json::to_value(&fr).ok();
rec
}
async fn seed_file(store: &Store, repo_root: &std::path::Path, path: &str) {
let on_disk = repo_root.join(path);
std::fs::create_dir_all(on_disk.parent().expect("path has a parent")).expect("seed dirs");
std::fs::write(&on_disk, format!("// {path}\n")).expect("seed file bytes");
let file_key = format!("file:{path}");
store
.put(&file_key, &file_record(path))
.await
.expect("seed file record");
}
async fn seed_verbatim(store: &Store, record: &Record) {
store.put(&record.key, record).await.expect("seed gotcha");
}
async fn compare<F, Fut>(
watched: &[&str],
gotcha_key: &str,
body: F,
) -> BTreeMap<String, (String, String)>
where
F: Fn(Path, Store, std::path::PathBuf) -> Fut,
Fut: std::future::Future<Output = Store>,
{
let baseline = now_secs();
let mut snaps = Vec::new();
for path in [Path::Direct, Path::Daemon] {
let dir = tempfile::TempDir::new().expect("tempdir");
let store = Store::open(dir.path()).await.expect("open store");
let store = body(path, store, dir.path().to_path_buf()).await;
snaps.push(snapshot(&store, gotcha_key, watched, baseline).await);
store.close().await.expect("close store");
}
diff(&snaps[0], &snaps[1])
}
const KEY: &str = "gotcha:never-swallow-the-write-error";
const RULE: &str = "Never swallow the error returned by Store::put";
const REASON: &str =
"the canonical record is the source of truth, so a dropped error loses the write silently";
#[tokio::test]
async fn create_paths_diverge_only_where_pinned() {
let d = compare(&["src/a.rs"], KEY, |path, store, repo_root| async move {
seed_file(&store, &repo_root, "src/a.rs").await;
let record = manual_add_record(KEY, RULE, REASON, &["src/a.rs"]);
drive_write(
path,
&store,
&repo_root,
&record,
&[],
&["src/a.rs".into()],
true,
)
.await
.expect("create");
store
})
.await;
assert_divergences("create", &d, CREATE_DIVERGENCES);
}
#[tokio::test]
async fn create_with_multiple_affected_files_diverges_only_where_pinned() {
let files = ["src/a.rs", "src/b.rs", "src/c.rs"];
let d = compare(&files, KEY, |path, store, repo_root| async move {
for f in files {
seed_file(&store, &repo_root, f).await;
}
let record = manual_add_record(KEY, RULE, REASON, &files);
let new: Vec<String> = files.iter().map(|s| s.to_string()).collect();
drive_write(path, &store, &repo_root, &record, &[], &new, true)
.await
.expect("create");
store
})
.await;
assert_divergences("create multi-file", &d, CREATE_DIVERGENCES);
}
#[tokio::test]
async fn create_below_quality_gate_diverges_only_where_pinned() {
let d = compare(&["src/a.rs"], KEY, |path, store, repo_root| async move {
seed_file(&store, &repo_root, "src/a.rs").await;
let record = manual_add_record(KEY, "be careful", "it breaks", &["src/a.rs"]);
assert!(
crate::health::quality::below_quality_gate(&record.quality),
"fixture must sit below the quality gate for this test to mean anything"
);
drive_write(
path,
&store,
&repo_root,
&record,
&[],
&["src/a.rs".into()],
true,
)
.await
.expect("create");
store
})
.await;
assert_divergences("create below quality gate", &d, CREATE_DIVERGENCES);
}
#[tokio::test]
async fn create_on_existing_slug_diverges_only_where_pinned() {
let d = compare(&["src/a.rs"], KEY, |path, store, repo_root| async move {
seed_file(&store, &repo_root, "src/a.rs").await;
let first = manual_add_record(KEY, RULE, REASON, &["src/a.rs"]);
gotcha_ops::apply_gotcha_write(&store, &repo_root, &first, &[], &["src/a.rs".into()], true)
.await
.expect("seed");
let second = manual_add_record(KEY, "Always close the store handle", REASON, &["src/a.rs"]);
let _ = drive_write(
path,
&store,
&repo_root,
&second,
&[],
&["src/a.rs".into()],
true,
)
.await;
store
})
.await;
assert_divergences(
"create on existing slug",
&d,
&[
"record.value",
"payload.rule",
"quality.value",
"version.logical_clock",
"enforcement_events",
"audit_knowledge_rows",
],
);
}
#[tokio::test]
async fn edit_paths_diverge_only_where_pinned() {
let d = compare(
&["src/a.rs", "src/b.rs"],
KEY,
|path, store, repo_root| async move {
for f in ["src/a.rs", "src/b.rs"] {
seed_file(&store, &repo_root, f).await;
}
let original = manual_add_record(KEY, RULE, REASON, &["src/a.rs"]);
gotcha_ops::apply_gotcha_write(
&store,
&repo_root,
&original,
&[],
&["src/a.rs".into()],
true,
)
.await
.expect("seed");
let mut edited = store.get(KEY).await.unwrap().unwrap();
let mut g = edited.payload_as::<GotchaRecord>().unwrap();
g.rule = "Always close the store handle".into();
g.affected_files = vec!["src/b.rs".into()];
edited.value = format!("{} because {}", g.rule, g.reason);
edited.payload = serde_json::to_value(&g).ok();
edited.version.logical_clock += 1;
edited.quality = crate::health::quality::analyze(&edited);
drive_write(
path,
&store,
&repo_root,
&edited,
&["src/a.rs".into()],
&["src/b.rs".into()],
false,
)
.await
.expect("edit");
store
},
)
.await;
assert_divergences("edit", &d, EDIT_DIVERGENCES);
}
#[tokio::test]
async fn edit_of_a_tombstoned_key_un_tombstones_on_both_paths() {
let d = compare(&["src/a.rs"], KEY, |path, store, repo_root| async move {
seed_file(&store, &repo_root, "src/a.rs").await;
let original = manual_add_record(KEY, RULE, REASON, &["src/a.rs"]);
gotcha_ops::apply_gotcha_write(
&store,
&repo_root,
&original,
&[],
&["src/a.rs".into()],
true,
)
.await
.expect("seed");
gotcha_ops::apply_gotcha_tombstone(&store, KEY, &["src/a.rs".into()])
.await
.expect("tombstone");
let mut edited = store.get(KEY).await.unwrap().unwrap();
let mut g = edited.payload_as::<GotchaRecord>().unwrap();
g.rule = "Always close the store handle".into();
edited.value = format!("{} because {}", g.rule, g.reason);
edited.payload = serde_json::to_value(&g).ok();
edited.version.logical_clock += 1;
edited.quality = crate::health::quality::analyze(&edited);
drive_write(
path,
&store,
&repo_root,
&edited,
&["src/a.rs".into()],
&["src/a.rs".into()],
false,
)
.await
.expect("edit of tombstoned key");
store
})
.await;
assert!(
!d.contains_key("record.lifecycle"),
"record.lifecycle must agree on both paths after an edit reaches a \
tombstoned key: {:?}",
d.get("record.lifecycle")
);
assert_divergences(
"edit of a tombstoned key",
&d,
EDIT_OF_TOMBSTONED_KEY_DIVERGENCES,
);
}
#[tokio::test]
async fn confirm_paths_diverge_only_where_pinned() {
let d = compare(&["src/a.rs"], KEY, |path, store, repo_root| async move {
seed_file(&store, &repo_root, "src/a.rs").await;
let mut record = manual_add_record(KEY, RULE, REASON, &["src/a.rs"]);
let mut g = record.payload_as::<GotchaRecord>().unwrap();
g.confirmed = false;
record.payload = serde_json::to_value(&g).ok();
record.source = RecordSource::ClaudeEnrich;
record.confidence = ConfidenceScore::for_new_record(&RecordSource::ClaudeEnrich);
gotcha_ops::apply_gotcha_write(
&store,
&repo_root,
&record,
&[],
&["src/a.rs".into()],
true,
)
.await
.expect("seed");
crate::store::session::log_hit(&store, "file:src/a.rs")
.await
.expect("seed receipt");
drive_confirm(path, &store, &repo_root, KEY)
.await
.expect("confirm");
store
})
.await;
assert_divergences("confirm", &d, CONFIRM_DIVERGENCES);
}
#[tokio::test]
async fn confirm_of_legacy_unnormalized_paths_diverges_only_where_pinned() {
let d = compare(&["src/a.rs"], KEY, |path, store, repo_root| async move {
seed_file(&store, &repo_root, "src/a.rs").await;
let mut record = manual_add_record(KEY, RULE, REASON, &["./src/a.rs"]);
let mut g = record.payload_as::<GotchaRecord>().unwrap();
g.confirmed = false;
g.affected_files = vec!["./src/a.rs".into()];
record.payload = serde_json::to_value(&g).ok();
record.source = RecordSource::ClaudeEnrich;
record.confidence = ConfidenceScore::for_new_record(&RecordSource::ClaudeEnrich);
seed_verbatim(&store, &record).await;
drive_confirm(path, &store, &repo_root, KEY)
.await
.expect("confirm");
store
})
.await;
assert_divergences("confirm legacy paths", &d, CONFIRM_LEGACY_DIVERGENCES);
}
#[tokio::test]
async fn daemon_confirm_re_keys_a_legacy_gotcha_into_the_read_gate() {
let dir = tempfile::TempDir::new().expect("tempdir");
let store = Store::open(dir.path()).await.expect("open store");
let repo_root = dir.path();
seed_file(&store, repo_root, "src/a.rs").await;
let mut record = manual_add_record(KEY, RULE, REASON, &["./src/a.rs"]);
let mut g = record.payload_as::<GotchaRecord>().unwrap();
g.confirmed = false;
g.affected_files = vec!["./src/a.rs".into()];
record.payload = serde_json::to_value(&g).ok();
record.source = RecordSource::ClaudeEnrich;
record.confidence = ConfidenceScore::for_new_record(&RecordSource::ClaudeEnrich);
seed_verbatim(&store, &record).await;
drive_confirm(Path::Daemon, &store, repo_root, KEY)
.await
.expect("confirm");
let stored = store.get(KEY).await.unwrap().unwrap();
let gotcha = stored.payload_as::<GotchaRecord>().unwrap();
assert_eq!(gotcha.affected_files, vec!["src/a.rs".to_string()]);
assert!(gotcha.confirmed);
let file = store.get("file:src/a.rs").await.unwrap().unwrap();
let keys: Vec<String> = file
.payload
.as_ref()
.and_then(|p| p.get("gotcha_keys"))
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
assert_eq!(
keys,
vec![KEY.to_string()],
"the derived link must survive the confirmation-count bump staged for the same key"
);
assert_eq!(file.confidence.confirmation_count, 1);
let edges = store.scan_keys("graph:edge:").await.unwrap();
assert!(
edges
.iter()
.any(|e| e.contains("file:src/a.rs") && e.ends_with(KEY)),
"HasGotcha edge must name the normalized file key, got {edges:?}"
);
store.close().await.expect("close");
}
#[tokio::test]
async fn tombstone_paths_diverge_only_where_pinned() {
let files = ["src/a.rs", "src/b.rs"];
let d = compare(&files, KEY, |path, store, repo_root| async move {
for f in files {
seed_file(&store, &repo_root, f).await;
}
let record = manual_add_record(KEY, RULE, REASON, &files);
let new: Vec<String> = files.iter().map(|s| s.to_string()).collect();
gotcha_ops::apply_gotcha_write(&store, &repo_root, &record, &[], &new, true)
.await
.expect("seed");
drive_tombstone(path, &store, &repo_root, KEY)
.await
.expect("tombstone");
store
})
.await;
assert_divergences("tombstone", &d, TOMBSTONE_DIVERGENCES);
}
#[tokio::test]
async fn enriched_tag_extraction_row_matches_on_both_paths() {
let d = compare(&["src/a.rs"], KEY, |path, store, repo_root| async move {
seed_file(&store, &repo_root, "src/a.rs").await;
let mut record = manual_add_record(KEY, RULE, REASON, &["src/a.rs"]);
record.tags = vec![
"enriched".into(),
"depth:deep".into(),
"signal-source:ast".into(),
];
drive_write(
path,
&store,
&repo_root,
&record,
&[],
&["src/a.rs".into()],
true,
)
.await
.expect("create");
store
})
.await;
assert!(
!d.contains_key("extraction"),
"extraction row drifted between write paths: {:?}",
d.get("extraction")
);
assert_divergences("create with enriched tags", &d, CREATE_DIVERGENCES);
}
#[tokio::test]
async fn direct_edit_of_tombstoned_key_restores_links_and_edges() {
let dir = tempfile::TempDir::new().expect("tempdir");
let store = Store::open(dir.path()).await.expect("open store");
let repo_root = dir.path();
seed_file(&store, repo_root, "src/a.rs").await;
let original = manual_add_record(KEY, RULE, REASON, &["src/a.rs"]);
gotcha_ops::apply_gotcha_write(
&store,
repo_root,
&original,
&[],
&["src/a.rs".into()],
true,
)
.await
.expect("seed");
gotcha_ops::apply_gotcha_tombstone(&store, KEY, &["src/a.rs".into()])
.await
.expect("tombstone");
let mut edited = store.get(KEY).await.unwrap().unwrap();
let mut g = edited.payload_as::<GotchaRecord>().unwrap();
g.rule = "Always close the store handle".into();
edited.value = format!("{} because {}", g.rule, g.reason);
edited.payload = serde_json::to_value(&g).ok();
edited.version.logical_clock += 1;
edited.quality = crate::health::quality::analyze(&edited);
gotcha_ops::apply_gotcha_write(
&store,
repo_root,
&edited,
&["src/a.rs".into()],
&["src/a.rs".into()],
false,
)
.await
.expect("edit of tombstoned key");
let file = store.get("file:src/a.rs").await.unwrap().unwrap();
let keys: Vec<String> = file
.payload
.as_ref()
.and_then(|p| p.get("gotcha_keys"))
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
assert_eq!(
keys,
vec![KEY.to_string()],
"file-record gotcha_keys link must be restored on resurrection"
);
let edges = store.scan_keys("graph:edge:").await.unwrap();
assert!(
edges
.iter()
.any(|e| e.contains("file:src/a.rs") && e.ends_with(KEY)),
"HasGotcha edge must be restored on resurrection, got {edges:?}"
);
store.close().await.expect("close");
}