use std::{
fs,
path::{Path, PathBuf},
time::{Duration, Instant},
};
use crate::ledger::LedgerStore;
use super::{
extract::{fingerprint, slugify, title_from_slug},
scan::{
run_configured_secret_detector_with_timeout, scan_memory_skill_advisory,
scan_memory_skill_candidate,
},
store::{repo_root_for_state_dir, resolve_approved_dir},
util::unix_now,
};
use proptest::prelude::*;
use crate::{
config::{
MemorySkillCandidateKind, MemorySkillConfig, MemorySkillMode, SecretDetectionMode,
TruthMirrorConfig,
},
ledger::{
FindingSeverity, LedgerEntry, MemorySkillClassification, MemorySkillClassificationKind,
ReviewerConfig, StructuredFinding, Verdict,
},
};
use super::*;
fn config_enabled() -> TruthMirrorConfig {
let mut config = TruthMirrorConfig::default();
config.skills.enabled = true;
config.memory_skill.enabled = true;
config.memory_skill.signals.min_occurrences = 2;
config
}
fn pass_entry(sha: &str, claim_what: &str, timestamp: u64) -> LedgerEntry {
LedgerEntry::new_at(
sha,
Verdict::Pass,
format!("CLAIM: {claim_what} | verified: cargo test | evidence: tests:memory-skill"),
vec!["tests:memory-skill".to_owned()],
ReviewerConfig::new("codex", "model-b", false),
Vec::new(),
timestamp,
)
.with_structured_review("The claim is substantiated.", Vec::new(), Vec::new(), "{}")
.with_memory_skill_classification(memory_skill_classification(
MemorySkillClassificationKind::HowToSkill,
claim_what,
))
}
fn reject_entry(sha: &str, title: &str, timestamp: u64) -> LedgerEntry {
reject_entry_with_finding(
sha,
title,
"The evidence does not support the claim.",
"Add direct evidence.",
timestamp,
)
}
fn reject_entry_with_finding(
sha: &str,
title: &str,
body: &str,
recommendation: &str,
timestamp: u64,
) -> LedgerEntry {
reject_entry_with_classification(
sha,
title,
body,
recommendation,
timestamp,
MemorySkillClassificationKind::AntiPatternSkill,
title,
)
}
fn reject_entry_with_classification(
sha: &str,
title: &str,
body: &str,
recommendation: &str,
timestamp: u64,
classification_kind: MemorySkillClassificationKind,
learning_source: &str,
) -> LedgerEntry {
LedgerEntry::new_at(
sha,
Verdict::Reject,
"CLAIM: avoid bad claim | verified: cargo test | evidence: tests:memory-skill",
vec!["tests:memory-skill".to_owned()],
ReviewerConfig::new("codex", "model-b", false),
vec![title.to_owned()],
timestamp,
)
.with_structured_review(
body,
vec![StructuredFinding {
severity: FindingSeverity::High,
title: title.to_owned(),
body: body.to_owned(),
file: "src/lib.rs".to_owned(),
line_start: 1,
line_end: 1,
confidence: 95,
recommendation: recommendation.to_owned(),
}],
Vec::new(),
"{}",
)
.with_memory_skill_classification(memory_skill_classification(
classification_kind,
learning_source,
))
}
fn memory_skill_classification(
kind: MemorySkillClassificationKind,
learning_source: &str,
) -> MemorySkillClassification {
MemorySkillClassification {
kind,
learning_source: learning_source.to_owned(),
reasoning: "reviewer proposed this memory-skill classification".to_owned(),
}
}
fn candidate_record(id: &str, commit_sha: &str) -> MemorySkillCandidate {
let ledger_ref = EvidenceRef::ledger(commit_sha);
MemorySkillCandidate {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: id.to_owned(),
fingerprint: format!("sha256:v1:{id}"),
learning_key: id.to_owned(),
status: CandidateStatus::Pending,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
scope: "project".to_owned(),
source_commit: commit_sha.to_owned(),
source_claim: format!("CLAIM: {id} | verified: cargo test | evidence: tests:memory-skill"),
truth_label: Verdict::Pass,
ledger_entry_ref: ledger_ref.clone(),
source_run_ref: EvidenceRef::run(id),
occurrence_count: 1,
occurrence_refs: vec![ledger_ref.clone()],
slug: id.to_owned(),
title: title_from_slug(id),
description: "test candidate".to_owned(),
when_to_use: vec!["When the test candidate applies.".to_owned()],
procedure_steps: vec!["Run the test candidate workflow.".to_owned()],
pitfalls: vec!["Do not drop evidence.".to_owned()],
verification_steps: vec!["Read the candidate back.".to_owned()],
evidence: vec![ledger_ref],
created_at_unix: 100,
}
}
fn advisory_record(id: &str, commit_sha: &str) -> MemorySkillAdvisory {
MemorySkillAdvisory {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: id.to_owned(),
fingerprint: format!("sha256:v1:{id}"),
dismissed: false,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
slug: id.to_owned(),
title: title_from_slug(id),
source_commit: commit_sha.to_owned(),
truth_label: Verdict::Pass,
occurrence_count: 1,
occurrence_refs: vec![EvidenceRef::ledger(commit_sha)],
reason: "test advisory".to_owned(),
created_at_unix: 100,
}
}
#[test]
fn evaluate_review_completion_is_disabled_by_default() {
let temp = tempfile::tempdir().unwrap();
let config = TruthMirrorConfig::default();
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
assert_eq!(
report.skipped_reason.as_deref(),
Some("memory skill gate disabled")
);
assert!(!temp.path().join("skills/candidates").exists());
}
#[test]
fn evaluate_review_completion_honors_mode_off() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.mode = MemorySkillMode::Off;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
assert_eq!(
report.skipped_reason.as_deref(),
Some("memory skill gate disabled")
);
}
#[test]
fn recurrence_requires_distinct_commits() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let first = pass_entry("abc123", "run reusable smoke test", 100);
let second_same_commit = pass_entry("abc123", "run reusable smoke test", 101);
let store = LedgerStore::new(temp.path());
store.append_entry(&first).unwrap();
store.append_entry(&second_same_commit).unwrap();
let report =
evaluate_review_completion(temp.path(), &config, "run-1", &[first, second_same_commit])
.unwrap();
assert_eq!(
report.skipped_reason.as_deref(),
Some("recurrence threshold not met")
);
}
#[test]
fn current_unpersisted_entry_counts_when_reviewed_entry_not_required() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.review.require_reviewed_ledger_entry = false;
let first = pass_entry("abc123", "run reusable smoke test", 100);
let current = pass_entry("def456", "run reusable smoke test", 101);
LedgerStore::new(temp.path()).append_entry(&first).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[current]).unwrap();
assert_eq!(report.staged.len(), 1);
let candidate = MemorySkillStore::new(temp.path(), &config.memory_skill)
.unwrap()
.show(report.staged.first().unwrap())
.unwrap();
assert_eq!(candidate.occurrence_count, 2);
}
#[test]
fn two_pass_final_rejection_does_not_stage_how_to() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let pass = pass_entry("abc123", "run reusable smoke test", 100);
let reject = reject_entry("abc123", "unsupported evidence", 101);
let store = LedgerStore::new(temp.path());
store.append_entry(&pass).unwrap();
store.append_entry(&reject).unwrap();
let report =
evaluate_review_completion(temp.path(), &config, "run-1", &[pass, reject]).unwrap();
assert_eq!(report.staged.len(), 1);
let candidate = MemorySkillStore::new(temp.path(), &config.memory_skill)
.unwrap()
.read_candidates()
.unwrap()
.pop()
.unwrap();
assert_eq!(
candidate.candidate_kind,
MemorySkillCandidateKind::AntiPatternSkill
);
}
#[test]
fn any_rejection_in_completed_run_wins_effective_verdict() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let reject = reject_entry("abc123", "unsupported evidence", 100);
let pass = pass_entry("abc123", "run reusable smoke test", 101);
let store = LedgerStore::new(temp.path());
store.append_entry(&reject).unwrap();
store.append_entry(&pass).unwrap();
let report =
evaluate_review_completion(temp.path(), &config, "run-1", &[reject, pass]).unwrap();
assert_eq!(report.staged.len(), 1);
let candidate = MemorySkillStore::new(temp.path(), &config.memory_skill)
.unwrap()
.read_candidates()
.unwrap()
.pop()
.unwrap();
assert_eq!(
candidate.candidate_kind,
MemorySkillCandidateKind::AntiPatternSkill
);
}
#[test]
fn reviewer_antipattern_classification_stages_antipattern_even_when_remediation_is_first() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
config.memory_skill.signals.rejection_precedence = vec![
MemorySkillCandidateKind::RemediationSkill,
MemorySkillCandidateKind::AntiPatternSkill,
];
let reject = reject_entry("abc123", "unsupported evidence", 100);
LedgerStore::new(temp.path()).append_entry(&reject).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[reject]).unwrap();
assert_eq!(report.staged.len(), 1);
let candidate = MemorySkillStore::new(temp.path(), &config.memory_skill)
.unwrap()
.read_candidates()
.unwrap()
.pop()
.unwrap();
assert_eq!(
candidate.candidate_kind,
MemorySkillCandidateKind::AntiPatternSkill
);
}
#[test]
fn implementation_repair_rejection_stages_remediation() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let reject = reject_entry_with_classification(
"abc123",
"missing cleanup leaves failing test",
"The implementation is broken because cleanup is missing.",
"Repair the cleanup workflow and rerun the failing test.",
100,
MemorySkillClassificationKind::RemediationSkill,
"missing cleanup repair workflow",
);
LedgerStore::new(temp.path()).append_entry(&reject).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[reject]).unwrap();
assert_eq!(report.staged.len(), 1);
let candidate = MemorySkillStore::new(temp.path(), &config.memory_skill)
.unwrap()
.read_candidates()
.unwrap()
.pop()
.unwrap();
assert_eq!(
candidate.candidate_kind,
MemorySkillCandidateKind::RemediationSkill
);
}
#[test]
fn ambiguous_rejection_finding_does_not_stage_candidate() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let reject = reject_entry_with_classification(
"abc123",
"unclear review concern",
"The reviewer could not classify this concern.",
"Look again.",
100,
MemorySkillClassificationKind::None,
"",
);
LedgerStore::new(temp.path()).append_entry(&reject).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[reject]).unwrap();
assert_eq!(
report.skipped_reason.as_deref(),
Some("final entry did not qualify")
);
}
#[test]
fn keyword_like_rejection_text_does_not_stage_without_classification() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let reject = reject_entry_with_classification(
"abc123",
"unsupported evidence",
"The evidence does not prove the claim.",
"Review the trace.",
100,
MemorySkillClassificationKind::None,
"",
);
LedgerStore::new(temp.path()).append_entry(&reject).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[reject]).unwrap();
assert_eq!(
report.skipped_reason.as_deref(),
Some("final entry did not qualify")
);
}
#[test]
fn broad_rejection_words_do_not_stage_remediation_without_classification() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let reject = reject_entry_with_classification(
"abc123",
"process workflow configuration",
"The process, workflow, and configuration need another look.",
"Review the implementation.",
100,
MemorySkillClassificationKind::None,
"",
);
LedgerStore::new(temp.path()).append_entry(&reject).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[reject]).unwrap();
assert_eq!(
report.skipped_reason.as_deref(),
Some("final entry did not qualify")
);
}
#[test]
fn task_log_claim_does_not_stage_how_to_candidate() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "fix issue 123", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
assert_eq!(
report.skipped_reason.as_deref(),
Some("final entry did not qualify")
);
}
#[test]
fn task_log_phrase_matching_does_not_match_partial_words() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "this project workflow", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
assert_eq!(report.staged.len(), 1);
}
#[test]
fn capture_flag_disables_candidate_kind() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.capture_passes_as_how_to = false;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
assert_eq!(
report.skipped_reason.as_deref(),
Some("final entry did not qualify")
);
}
#[test]
fn configured_evidence_patterns_are_used_for_memory_claims() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.gates.evidence_patterns = vec!["ticket:".to_owned()];
config.memory_skill.signals.min_occurrences = 1;
let entry = LedgerEntry::new_at(
"abc123",
Verdict::Pass,
"CLAIM: run reusable smoke test | verified: cargo test | evidence: ticket:TM-1",
vec!["ticket:TM-1".to_owned()],
ReviewerConfig::new("codex", "model-b", false),
Vec::new(),
100,
)
.with_structured_review("The claim is substantiated.", Vec::new(), Vec::new(), "{}")
.with_memory_skill_classification(memory_skill_classification(
MemorySkillClassificationKind::HowToSkill,
"run reusable smoke test",
));
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
assert_eq!(report.staged.len(), 1);
}
#[test]
fn scan_rejects_blocked_pattern_case_insensitively() {
let config = MemorySkillConfig::default();
let err = scan_text("This includes API_KEY material.", &config).unwrap_err();
assert!(err.to_string().contains("blocked pattern"));
}
#[test]
fn scan_rejects_spaced_secret_assignment_by_default() {
let config = MemorySkillConfig::default();
let err = scan_text("password : hunter2", &config).unwrap_err();
assert!(err.to_string().contains("blocked pattern"));
scan_text("Use a password manager.", &config).unwrap();
}
#[test]
fn scan_keeps_default_patterns_when_configured_list_is_empty() {
let mut config = MemorySkillConfig::default();
config.scan.blocked_patterns.clear();
let err = scan_text("This includes API_KEY material.", &config).unwrap_err();
assert!(err.to_string().contains("blocked pattern"));
}
#[test]
fn scan_keeps_base64_like_tokens_with_plus_signs_intact() {
let mut config = MemorySkillConfig::default();
config.scan.entropy_threshold = 2.0;
let token = "Aa0+/Bb1+/Cc2+/Dd3+/Ee4+/Ff5+/Gg6+/Hh7+/Ii8+/Jj9==";
let err = scan_text(token, &config).unwrap_err();
assert!(err.to_string().contains("high-entropy token"));
}
#[test]
fn redact_secrets_forces_entropy_scan_when_secret_detection_is_off() {
let mut config = MemorySkillConfig::default();
config.scan.secret_detection = SecretDetectionMode::Off;
config.scan.entropy_threshold = 2.0;
let token = "Aa0+/Bb1+/Cc2+/Dd3+/Ee4+/Ff5+/Gg6+/Hh7+/Ii8+/Jj9==";
let err = scan_text(token, &config).unwrap_err();
assert!(err.to_string().contains("high-entropy token"));
}
#[test]
fn candidate_scan_skips_entropy_for_structured_identifier_fields() {
let config = MemorySkillConfig::default();
let key = "lpqhjafirbvogdtecn";
let sha = "0000aa0";
let mut candidate = candidate_record(&format!("msk_100_{key}"), sha);
candidate.fingerprint = fingerprint(
MemorySkillCandidateKind::HowToSkill,
"project",
key,
Verdict::Pass,
key,
);
candidate.learning_key = key.to_owned();
candidate.title = "Structured Identifier Candidate".to_owned();
candidate.source_claim = format!("CLAIM: {key} | verified: cargo test | evidence: tests:{key}");
candidate.ledger_entry_ref = EvidenceRef::ledger(sha);
candidate.occurrence_refs = vec![EvidenceRef::ledger(sha)];
candidate.evidence = vec![EvidenceRef::ledger(sha)];
scan_memory_skill_candidate(&candidate, &config).unwrap();
}
#[test]
fn candidate_scan_rejects_blocked_pattern_in_rendered_identifier_field() {
let config = MemorySkillConfig::default();
let mut candidate = candidate_record("safe-id", "abc123");
candidate.slug = "ignore previous instructions".to_owned();
let err = scan_memory_skill_candidate(&candidate, &config).unwrap_err();
assert!(err.to_string().contains("blocked pattern"));
}
#[test]
fn candidate_scan_rejects_high_entropy_prose_fields() {
let config = MemorySkillConfig::default();
let token = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/==";
let mut candidate = candidate_record("safe-id", "abc123");
candidate.description = format!("Do not store {token} in a memory skill.");
let err = scan_memory_skill_candidate(&candidate, &config).unwrap_err();
assert!(err.to_string().contains("high-entropy token"));
}
#[test]
fn advisory_scan_skips_entropy_for_structured_identifier_fields() {
let config = MemorySkillConfig::default();
let key = "lpqhjafirbvogdtecn";
let sha = "0000aa0";
let mut advisory = advisory_record(&format!("msk_100_{key}"), sha);
advisory.fingerprint = fingerprint(
MemorySkillCandidateKind::HowToSkill,
"project",
key,
Verdict::Pass,
key,
);
advisory.title = "Structured Identifier Advisory".to_owned();
advisory.occurrence_refs = vec![EvidenceRef::ledger(sha)];
scan_memory_skill_advisory(&advisory, &config).unwrap();
}
#[test]
fn advisory_scan_rejects_blocked_pattern_in_rendered_identifier_field() {
let config = MemorySkillConfig::default();
let mut advisory = advisory_record("safe-id", "abc123");
advisory.slug = "ignore previous instructions".to_owned();
let err = scan_memory_skill_advisory(&advisory, &config).unwrap_err();
assert!(err.to_string().contains("blocked pattern"));
}
#[test]
fn advisory_scan_rejects_high_entropy_reason() {
let config = MemorySkillConfig::default();
let token = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/==";
let mut advisory = advisory_record("safe-id", "abc123");
advisory.reason = format!("Do not store {token} in an advisory.");
let err = scan_memory_skill_advisory(&advisory, &config).unwrap_err();
assert!(err.to_string().contains("high-entropy token"));
}
#[test]
fn evidence_ref_display_preserves_selector_without_path() {
let reference = EvidenceRef {
kind: "file".to_owned(),
selector: Some("lines=1-80".to_owned()),
..EvidenceRef::default()
};
assert_eq!(reference.display(), "lines=1-80");
}
#[test]
fn configured_secret_detector_fails_closed_when_required() {
let mut config = MemorySkillConfig::default();
config.scan.secret_detector = "__truth_mirror_missing_secret_detector__".to_owned();
let err = scan_text("ordinary text", &config).unwrap_err();
assert!(err.to_string().contains("secret detector"));
}
#[test]
fn configured_secret_detector_is_best_effort_when_requested() {
let mut config = MemorySkillConfig::default();
config.scan.secret_detection = SecretDetectionMode::BestEffort;
config.scan.secret_detector = "__truth_mirror_missing_secret_detector__".to_owned();
scan_text("ordinary text", &config).unwrap();
}
#[cfg(unix)]
#[test]
fn configured_secret_detector_findings_reject_even_in_best_effort_mode() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let detector = temp.path().join("detector");
fs::write(&detector, "#!/bin/sh\necho finding >&2\nexit 1\n").unwrap();
let mut permissions = fs::metadata(&detector).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&detector, permissions).unwrap();
let mut config = MemorySkillConfig::default();
config.scan.secret_detection = SecretDetectionMode::BestEffort;
config.scan.secret_detector = detector.to_string_lossy().into_owned();
config.scan.allow_secret_detector_command = true;
let err = scan_text("ordinary text", &config).unwrap_err();
assert!(
err.to_string()
.contains("secret detector found blocked content")
);
}
#[cfg(unix)]
#[test]
fn configured_secret_detector_failure_redacts_stderr() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let detector = temp.path().join("detector");
fs::write(
&detector,
"#!/bin/sh\necho 'API_KEY=secret-value' >&2\nexit 2\n",
)
.unwrap();
let mut permissions = fs::metadata(&detector).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&detector, permissions).unwrap();
let mut config = MemorySkillConfig::default();
config.scan.secret_detector = detector.to_string_lossy().into_owned();
config.scan.allow_secret_detector_command = true;
let err = scan_text("ordinary text", &config).unwrap_err();
let message = err.to_string();
assert!(message.contains("bytes on stderr"));
assert!(!message.contains("secret-value"));
assert!(!message.contains("API_KEY"));
}
#[cfg(unix)]
#[test]
fn configured_secret_detector_timeout_covers_pipe_draining() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let detector = temp.path().join("detector");
fs::write(&detector, "#!/bin/sh\n(trap '' HUP; sleep 2) &\nexit 0\n").unwrap();
let mut permissions = fs::metadata(&detector).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&detector, permissions).unwrap();
let mut config = MemorySkillConfig::default();
config.scan.secret_detector = detector.to_string_lossy().into_owned();
config.scan.allow_secret_detector_command = true;
let timeout = Duration::from_millis(200);
let started_at = Instant::now();
let err =
run_configured_secret_detector_with_timeout("ordinary text", &config, timeout).unwrap_err();
assert!(
started_at.elapsed() < timeout * 10,
"detector timeout did not cover pipe draining"
);
let message = err.to_string();
assert!(
message.contains("did not finish after detector exit")
|| message.contains("timed out after")
);
}
#[cfg(unix)]
#[test]
fn configured_secret_detector_command_accepts_arguments() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let detector = temp.path().join("detector");
fs::write(
&detector,
"#!/bin/sh\nif [ \"$1\" != \"--ok\" ]; then echo bad-arg >&2; exit 2; fi\ncat >/dev/null\n",
)
.unwrap();
let mut permissions = fs::metadata(&detector).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&detector, permissions).unwrap();
let mut config = MemorySkillConfig::default();
config.scan.secret_detector = format!("{} --ok", detector.display());
config.scan.allow_secret_detector_command = true;
scan_text("ordinary text", &config).unwrap();
}
#[test]
fn render_skill_escapes_yaml_frontmatter_control_characters() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let mut candidate = store.show(report.staged.first().unwrap()).unwrap();
candidate.description = "contains \u{0001} control".to_owned();
let rendered = render_skill(&candidate);
assert!(rendered.contains("description: \"contains \\u0001 control\""));
}
#[test]
fn suggest_mode_deduplicates_advisories_by_fingerprint() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.mode = MemorySkillMode::Suggest;
config.memory_skill.signals.min_occurrences = 1;
config.memory_skill.max_candidates_per_commit = 2;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let first =
evaluate_review_completion(temp.path(), &config, "run-1", std::slice::from_ref(&entry))
.unwrap();
let second = evaluate_review_completion(temp.path(), &config, "run-2", &[entry]).unwrap();
assert_eq!(first.advised.len(), 1);
assert_eq!(
second.skipped_reason.as_deref(),
Some("advisory fingerprint already recorded")
);
assert_eq!(
MemorySkillStore::new(temp.path(), &config.memory_skill)
.unwrap()
.read_advisories()
.unwrap()
.len(),
1
);
}
#[test]
fn suggest_mode_ignores_dismissed_advisories_for_commit_quota() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.mode = MemorySkillMode::Suggest;
config.memory_skill.signals.min_occurrences = 1;
config.memory_skill.max_candidates_per_commit = 1;
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
store
.append_advisory(&MemorySkillAdvisory {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: "adv_old".to_owned(),
fingerprint: "sha256:v1:old".to_owned(),
dismissed: true,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
slug: "old".to_owned(),
title: "Old".to_owned(),
source_commit: "abc123".to_owned(),
truth_label: Verdict::Pass,
occurrence_count: 1,
occurrence_refs: vec![EvidenceRef::ledger("abc123")],
reason: "dismissed".to_owned(),
created_at_unix: 90,
})
.unwrap();
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
assert_eq!(report.advised.len(), 1);
}
#[test]
fn reinjection_skips_dismissed_advisories() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
store
.append_advisory(&MemorySkillAdvisory {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: "adv_dismissed".to_owned(),
fingerprint: "sha256:v1:dismissed".to_owned(),
dismissed: true,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
slug: "dismissed".to_owned(),
title: "Dismissed".to_owned(),
source_commit: "abc123".to_owned(),
truth_label: Verdict::Pass,
occurrence_count: 2,
occurrence_refs: vec![EvidenceRef::ledger("abc123")],
reason: "test".to_owned(),
created_at_unix: 100,
})
.unwrap();
let section = reinjection_section(temp.path(), &config).unwrap();
assert!(section.is_empty());
}
#[test]
fn reinjection_skips_candidate_that_fails_current_scan_config() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let mut candidate = candidate_record("candidate-api-key", "abc123");
candidate.description = "This includes API_KEY material.".to_owned();
fs::create_dir_all(store.candidate_dir()).unwrap();
fs::write(
store.candidate_json_path(&candidate.id),
serde_json::to_string_pretty(&candidate).unwrap(),
)
.unwrap();
let section = reinjection_section(temp.path(), &config).unwrap();
assert!(!section.contains("candidate-api-key"));
assert!(section.contains("1 more pending candidates omitted"));
}
#[test]
fn reinjection_skips_advisory_that_fails_current_scan_config() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let mut advisory = advisory_record("advisory-api-key", "abc123");
advisory.reason = "This includes API_KEY material.".to_owned();
fs::create_dir_all(store.candidate_dir()).unwrap();
fs::write(
store.candidate_dir().join(ADVISORIES_FILE),
format!("{}\n", serde_json::to_string(&advisory).unwrap()),
)
.unwrap();
let section = reinjection_section(temp.path(), &config).unwrap();
assert!(!section.contains("advisory-api-key"));
assert!(section.contains("1 more suggested candidates omitted"));
}
#[test]
fn reinjection_caps_scan_attempts_for_unsafe_imported_candidates() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
fs::create_dir_all(store.candidate_dir()).unwrap();
for index in 0..90 {
let mut candidate = candidate_record(&format!("candidate-{index:03}-unsafe"), "abc123");
candidate.description = "This includes API_KEY material.".to_owned();
fs::write(
store.candidate_json_path(&candidate.id),
serde_json::to_string_pretty(&candidate).unwrap(),
)
.unwrap();
}
let safe = candidate_record("candidate-999-safe", "def456");
fs::write(
store.candidate_json_path(&safe.id),
serde_json::to_string_pretty(&safe).unwrap(),
)
.unwrap();
let section = reinjection_section(temp.path(), &config).unwrap();
assert!(!section.contains("candidate-999-safe"));
assert!(section.contains("more pending candidates omitted"));
}
#[test]
fn reinjection_candidate_scan_cap_does_not_starve_advisories() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
fs::create_dir_all(store.candidate_dir()).unwrap();
for index in 0..90 {
let mut candidate = candidate_record(&format!("candidate-{index:03}-unsafe"), "abc123");
candidate.description = "This includes API_KEY material.".to_owned();
fs::write(
store.candidate_json_path(&candidate.id),
serde_json::to_string_pretty(&candidate).unwrap(),
)
.unwrap();
}
store
.append_advisory(&advisory_record("advisory-safe", "def456"))
.unwrap();
let section = reinjection_section(temp.path(), &config).unwrap();
assert!(section.contains("advisory-safe"));
assert!(section.contains("more pending candidates omitted"));
}
#[test]
fn dismiss_advisory_persists_and_clears_reinjection() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
store
.append_advisory(&MemorySkillAdvisory {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: "adv_active".to_owned(),
fingerprint: "sha256:v1:active".to_owned(),
dismissed: false,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
slug: "active".to_owned(),
title: "Active".to_owned(),
source_commit: "abc123".to_owned(),
truth_label: Verdict::Pass,
occurrence_count: 2,
occurrence_refs: vec![EvidenceRef::ledger("abc123")],
reason: "test".to_owned(),
created_at_unix: 100,
})
.unwrap();
assert!(
reinjection_section(temp.path(), &config)
.unwrap()
.contains("adv_active")
);
store.dismiss_advisory("adv_active").unwrap();
assert!(
reinjection_section(temp.path(), &config)
.unwrap()
.is_empty()
);
assert!(
store
.read_advisories()
.unwrap()
.into_iter()
.any(|advisory| advisory.id == "adv_active" && advisory.dismissed)
);
}
#[test]
fn reinjection_uses_one_combined_candidate_and_advisory_budget() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
for index in 0..15 {
store
.write_candidate(&candidate_record(
&format!("candidate-{index:02}"),
&format!("commit-candidate-{index:02}"),
))
.unwrap();
}
for index in 0..15 {
store
.append_advisory(&advisory_record(
&format!("advisory-{index:02}"),
&format!("commit-advisory-{index:02}"),
))
.unwrap();
}
let section = reinjection_section(temp.path(), &config).unwrap();
let rendered_records = section
.lines()
.filter(|line| line.starts_with("- candidate-") || line.starts_with("- advisory-"))
.count();
assert_eq!(rendered_records, 20);
assert!(section.contains("advisory-04"));
assert!(!section.contains("advisory-05"));
assert!(section.contains("10 more suggested candidates omitted"));
}
#[test]
fn reinjection_reports_omitted_pending_candidates_after_total_budget() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
for index in 0..21 {
store
.write_candidate(&candidate_record(
&format!("candidate-{index:02}"),
&format!("commit-candidate-{index:02}"),
))
.unwrap();
}
let section = reinjection_section(temp.path(), &config).unwrap();
assert!(section.contains("candidate-19"));
assert!(!section.contains("candidate-20 how_to_skill"));
assert!(section.contains("1 more pending candidates omitted"));
}
#[test]
fn malformed_advisory_jsonl_records_are_skipped() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
fs::create_dir_all(store.candidate_dir()).unwrap();
let advisory = MemorySkillAdvisory {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: "adv_valid".to_owned(),
fingerprint: "sha256:v1:valid".to_owned(),
dismissed: false,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
slug: "valid".to_owned(),
title: "Valid".to_owned(),
source_commit: "abc123".to_owned(),
truth_label: Verdict::Pass,
occurrence_count: 1,
occurrence_refs: vec![EvidenceRef::ledger("abc123")],
reason: "valid".to_owned(),
created_at_unix: 100,
};
fs::write(
store.candidate_dir().join(ADVISORIES_FILE),
format!(
"{{not json}}\n{}\n",
serde_json::to_string(&advisory).unwrap()
),
)
.unwrap();
let advisories = store.read_advisories().unwrap();
assert_eq!(advisories.len(), 1);
assert_eq!(advisories[0].id, "adv_valid");
}
#[test]
fn dismiss_advisory_preserves_malformed_jsonl_records() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
fs::create_dir_all(store.candidate_dir()).unwrap();
let advisory = advisory_record("adv_valid", "abc123");
fs::write(
store.candidate_dir().join(ADVISORIES_FILE),
format!(
"{{not json}}\n{}\n",
serde_json::to_string(&advisory).unwrap()
),
)
.unwrap();
store.dismiss_advisory("adv_valid").unwrap();
let contents = fs::read_to_string(store.candidate_dir().join(ADVISORIES_FILE)).unwrap();
assert!(contents.contains("{not json}"));
assert!(
store
.read_advisories()
.unwrap()
.into_iter()
.any(|advisory| advisory.id == "adv_valid" && advisory.dismissed)
);
}
#[test]
fn fresh_malformed_transition_lock_blocks_dismissal() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
store
.append_advisory(&MemorySkillAdvisory {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: "adv_active".to_owned(),
fingerprint: "sha256:v1:active".to_owned(),
dismissed: false,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
slug: "active".to_owned(),
title: "Active".to_owned(),
source_commit: "abc123".to_owned(),
truth_label: Verdict::Pass,
occurrence_count: 1,
occurrence_refs: vec![EvidenceRef::ledger("abc123")],
reason: "active".to_owned(),
created_at_unix: 100,
})
.unwrap();
fs::write(store.candidate_dir().join("transitions.lock"), "pid=123\n").unwrap();
let err = store.dismiss_advisory("adv_active").unwrap_err();
assert!(matches!(err, MemorySkillError::TransitionLocked { .. }));
assert!(
store
.read_advisories()
.unwrap()
.iter()
.any(|advisory| advisory.id == "adv_active" && !advisory.dismissed)
);
}
#[test]
fn fresh_transition_lock_blocks_advisory_append() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
fs::create_dir_all(store.candidate_dir()).unwrap();
fs::write(
store.candidate_dir().join("transitions.lock"),
format!("pid=123\ncreated_at_unix={}\n", unix_now()),
)
.unwrap();
let err = store
.append_advisory(&MemorySkillAdvisory {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: "adv_locked".to_owned(),
fingerprint: "sha256:v1:locked".to_owned(),
dismissed: false,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
slug: "locked".to_owned(),
title: "Locked".to_owned(),
source_commit: "abc123".to_owned(),
truth_label: Verdict::Pass,
occurrence_count: 1,
occurrence_refs: vec![EvidenceRef::ledger("abc123")],
reason: "locked".to_owned(),
created_at_unix: 100,
})
.unwrap_err();
assert!(matches!(err, MemorySkillError::TransitionLocked { .. }));
}
#[test]
fn public_transition_append_honors_transition_lock() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
fs::create_dir_all(store.candidate_dir()).unwrap();
fs::write(
store.candidate_dir().join("transitions.lock"),
format!("pid=123\ncreated_at_unix={}\n", unix_now()),
)
.unwrap();
let err = store
.append_transition(&CandidateTransition {
schema_version: CANDIDATE_SCHEMA_VERSION,
candidate_id: "candidate".to_owned(),
fingerprint: "sha256:v1:transition".to_owned(),
from: CandidateStatus::Pending,
to: CandidateStatus::Approved,
action: "test".to_owned(),
actor: "test".to_owned(),
reason: "test".to_owned(),
rendered_path: None,
created_at_unix: 100,
})
.unwrap_err();
assert!(matches!(err, MemorySkillError::TransitionLocked { .. }));
}
#[test]
fn max_candidates_per_commit_caps_staged_records() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
config.memory_skill.max_candidates_per_commit = 1;
let first = pass_entry("abc123", "run reusable smoke test", 100);
let second = pass_entry("abc123", "run reusable migration test", 101);
let store = LedgerStore::new(temp.path());
store.append_entry(&first).unwrap();
store.append_entry(&second).unwrap();
let staged =
evaluate_review_completion(temp.path(), &config, "run-1", std::slice::from_ref(&first))
.unwrap();
let capped = evaluate_review_completion(temp.path(), &config, "run-2", &[second]).unwrap();
assert_eq!(staged.staged.len(), 1);
assert_eq!(
capped.skipped_reason.as_deref(),
Some("max_candidates_per_commit reached")
);
}
#[test]
fn oversized_candidate_is_rejected_before_staging() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
config.memory_skill.max_skill_bytes = 10;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let candidate_dir = MemorySkillStore::new(temp.path(), &config.memory_skill)
.unwrap()
.candidate_dir()
.to_path_buf();
let err = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap_err();
assert!(matches!(
err,
MemorySkillError::RenderedSkillTooLarge { .. }
));
assert!(!candidate_dir.exists());
}
#[test]
fn suggest_mode_skips_rendered_skill_size_limit() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.mode = MemorySkillMode::Suggest;
config.memory_skill.signals.min_occurrences = 1;
config.memory_skill.max_skill_bytes = 10;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
assert_eq!(report.advised.len(), 1);
}
#[test]
fn suggest_mode_scans_candidate_fields_not_rendered_in_advisory() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.mode = MemorySkillMode::Suggest;
config.memory_skill.signals.min_occurrences = 1;
let mut entry = pass_entry("abc123", "run reusable smoke test", 100);
entry.claim = "CLAIM: run reusable smoke test with API_KEY material | verified: cargo test | evidence: tests:memory-skill".to_owned();
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let err = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap_err();
assert!(matches!(err, MemorySkillError::ScanRejected { .. }));
assert!(
MemorySkillStore::new(temp.path(), &config.memory_skill)
.unwrap()
.read_advisories()
.unwrap()
.is_empty()
);
}
#[test]
fn scan_sees_raw_rejection_learning_source_before_normalization() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
config.memory_skill.scan.blocked_patterns = vec!["foo:bar".to_owned()];
let entry = reject_entry("abc123", "foo:bar", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let err = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap_err();
assert!(matches!(err, MemorySkillError::ScanRejected { .. }));
}
#[test]
fn candidate_write_refuses_existing_candidate_file() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let candidate = store.show(report.staged.first().unwrap()).unwrap();
let err = store.write_candidate(&candidate).unwrap_err();
match err {
MemorySkillError::Io(error) => {
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
}
other => panic!("expected already-exists IO error, got {other}"),
}
}
#[test]
fn candidate_write_rolls_back_json_when_markdown_write_fails() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let source_store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let candidate = source_store.show(report.staged.first().unwrap()).unwrap();
let target_state_dir = temp.path().join("target-state");
let target_store = MemorySkillStore::new(&target_state_dir, &config.memory_skill).unwrap();
fs::create_dir_all(target_store.candidate_markdown_path(&candidate.id)).unwrap();
let err = target_store.write_candidate(&candidate).unwrap_err();
assert!(matches!(err, MemorySkillError::Io(_)));
assert!(!target_store.candidate_json_path(&candidate.id).exists());
}
#[test]
fn read_candidates_skips_invalid_candidate_json() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
fs::write(store.candidate_dir().join("bad.json"), "{").unwrap();
let candidates = store.read_candidates().unwrap();
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].id, *report.staged.first().unwrap());
}
#[test]
fn approved_dir_uses_nearest_state_root_parent() {
let root = repo_root_for_state_dir(Path::new("/repo/.truth/sub/dir"));
assert_eq!(root, PathBuf::from("/repo"));
}
#[test]
fn approved_dir_prefers_git_root_for_custom_state_dir() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let state_dir = repo.join("custom/state");
fs::create_dir_all(repo.join(".git")).unwrap();
fs::create_dir_all(&state_dir).unwrap();
let root = repo_root_for_state_dir(&state_dir);
assert_eq!(root, repo);
}
#[test]
fn generated_slug_is_bounded_for_filesystem_segments() {
let long_slug = slugify(&"word ".repeat(200));
assert!(long_slug.len() <= 96);
assert!(!long_slug.ends_with('-'));
}
#[test]
fn relative_approved_dir_stays_repo_rooted_when_global_writes_allowed() {
let mut config = MemorySkillConfig {
allow_global_writes: true,
..MemorySkillConfig::default()
};
config.approved_dir = ".agents/skills".to_owned();
let approved_dir = resolve_approved_dir(Path::new("/repo/.truth/sub/dir"), &config);
assert_eq!(approved_dir, PathBuf::from("/repo/.agents/skills"));
}
#[test]
fn candidate_dir_rejects_parent_dir_traversal() {
let config = MemorySkillConfig {
candidate_dir: "../escape".to_owned(),
..MemorySkillConfig::default()
};
let err = MemorySkillStore::new(Path::new(".truth"), &config).unwrap_err();
assert!(matches!(err, MemorySkillError::UnsafeStatePath { .. }));
}
#[test]
fn legacy_candidate_dir_resolves_under_absolute_legacy_state_dir() {
let temp = tempfile::tempdir().unwrap();
let state_dir = temp.path().join(".truth-mirror");
let config = MemorySkillConfig {
candidate_dir: ".truth-mirror/skills/candidates".to_owned(),
..MemorySkillConfig::default()
};
let store = MemorySkillStore::new(&state_dir, &config).unwrap();
assert_eq!(store.candidate_dir(), state_dir.join("skills/candidates"));
}
#[test]
fn approved_skill_rejects_parent_dir_without_global_writes() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
config.memory_skill.approved_dir = "../skills".to_owned();
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let err = store
.approve(report.staged.first().unwrap(), true)
.unwrap_err();
assert!(matches!(err, MemorySkillError::UnsafeApprovedPath { .. }));
}
#[test]
fn approved_skill_rejects_unsafe_slug_prefix() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
config.memory_skill.approved_slug_prefix = "../escape".to_owned();
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let err = store
.approve(report.staged.first().unwrap(), true)
.unwrap_err();
assert!(matches!(err, MemorySkillError::UnsafeApprovedPath { .. }));
}
#[test]
fn approved_skill_write_refuses_existing_file() {
let temp = tempfile::tempdir().unwrap();
let state_dir = temp.path().join(".truth");
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(&state_dir).append_entry(&entry).unwrap();
let report = evaluate_review_completion(&state_dir, &config, "run-1", &[entry]).unwrap();
let existing = temp
.path()
.join(".agents/skills/generated-run-reusable-smoke-test/SKILL.md");
fs::create_dir_all(existing.parent().unwrap()).unwrap();
fs::write(&existing, "existing").unwrap();
let store = MemorySkillStore::new(&state_dir, &config.memory_skill).unwrap();
let err = store
.approve(report.staged.first().unwrap(), true)
.unwrap_err();
match err {
MemorySkillError::Io(error) => {
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
}
other => panic!("expected already-exists IO error, got {other}"),
}
}
#[test]
fn approved_skill_write_reconciles_existing_identical_file() {
let temp = tempfile::tempdir().unwrap();
let state_dir = temp.path().join(".truth");
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(&state_dir).append_entry(&entry).unwrap();
let report = evaluate_review_completion(&state_dir, &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(&state_dir, &config.memory_skill).unwrap();
let id = report.staged.first().unwrap();
let candidate = store.show(id).unwrap();
let existing = temp
.path()
.join(".agents/skills/generated-run-reusable-smoke-test/SKILL.md");
fs::create_dir_all(existing.parent().unwrap()).unwrap();
fs::write(&existing, render_skill(&candidate)).unwrap();
let rendered = store.approve(id, true).unwrap().unwrap();
let applied = store.show(id).unwrap();
assert_eq!(rendered, existing);
assert_eq!(applied.status, CandidateStatus::Applied);
}
#[test]
fn standalone_apply_requires_prior_approval() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let err = store.apply(report.staged.first().unwrap()).unwrap_err();
assert!(matches!(
err,
MemorySkillError::InvalidTransition {
from: CandidateStatus::Pending,
to: CandidateStatus::Applied
}
));
}
#[cfg(unix)]
#[test]
fn approved_skill_write_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let state_dir = temp.path().join(".truth");
let outside = temp.path().join("outside");
fs::create_dir_all(&outside).unwrap();
fs::create_dir_all(temp.path().join(".agents")).unwrap();
symlink(&outside, temp.path().join(".agents/skills")).unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(&state_dir).append_entry(&entry).unwrap();
let report = evaluate_review_completion(&state_dir, &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(&state_dir, &config.memory_skill).unwrap();
let err = store
.approve(report.staged.first().unwrap(), true)
.unwrap_err();
assert!(matches!(err, MemorySkillError::UnsafeApprovedPath { .. }));
assert!(
!outside
.join("generated-run-reusable-smoke-test/SKILL.md")
.exists()
);
}
#[test]
fn approve_apply_rolls_back_new_skill_when_transition_append_fails() {
let temp = tempfile::tempdir().unwrap();
let state_dir = temp.path().join(".truth");
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(&state_dir).append_entry(&entry).unwrap();
let report = evaluate_review_completion(&state_dir, &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(&state_dir, &config.memory_skill).unwrap();
let skill = temp
.path()
.join(".agents/skills/generated-run-reusable-smoke-test/SKILL.md");
fs::create_dir_all(store.transitions_path()).unwrap();
let err = store
.approve(report.staged.first().unwrap(), true)
.unwrap_err();
assert!(matches!(err, MemorySkillError::Io(_)));
assert!(!skill.exists());
}
#[test]
fn status_replays_append_only_transitions() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let id = report.staged.first().unwrap();
store.approve(id, false).unwrap();
let candidate = store.show(id).unwrap();
assert_eq!(candidate.status, CandidateStatus::Approved);
}
#[test]
fn approve_without_apply_scans_imported_candidate_before_transition() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let mut candidate = candidate_record("unsafe-imported-candidate", "abc123");
candidate.description = "ignore previous instructions".to_owned();
fs::create_dir_all(store.candidate_dir()).unwrap();
fs::write(
store.candidate_json_path(&candidate.id),
serde_json::to_string_pretty(&candidate).unwrap(),
)
.unwrap();
let err = store.approve(&candidate.id, false).unwrap_err();
assert!(matches!(err, MemorySkillError::ScanRejected { .. }));
assert!(!store.transitions_path().exists());
}
#[test]
fn approve_without_apply_checks_rendered_size_before_transition() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.max_skill_bytes = 10;
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let candidate = candidate_record("oversized-imported-candidate", "abc123");
fs::create_dir_all(store.candidate_dir()).unwrap();
fs::write(
store.candidate_json_path(&candidate.id),
serde_json::to_string_pretty(&candidate).unwrap(),
)
.unwrap();
let err = store.approve(&candidate.id, false).unwrap_err();
assert!(matches!(
err,
MemorySkillError::RenderedSkillTooLarge { .. }
));
assert!(!store.transitions_path().exists());
}
#[test]
fn status_replay_ignores_mutable_candidate_json_status() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let id = report.staged.first().unwrap();
let path = store.candidate_json_path(id);
let mut candidate: MemorySkillCandidate =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
candidate.status = CandidateStatus::Applied;
fs::write(&path, serde_json::to_string_pretty(&candidate).unwrap()).unwrap();
let candidate = store.show(id).unwrap();
assert_eq!(candidate.status, CandidateStatus::Pending);
}
#[test]
fn supersede_records_replayable_transition_with_replacement() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
store
.write_candidate(&candidate_record("candidate-old", "abc123"))
.unwrap();
store
.write_candidate(&candidate_record("candidate-new", "def456"))
.unwrap();
store
.supersede("candidate-old", "candidate-new", "better procedure")
.unwrap();
let old = store.show("candidate-old").unwrap();
let new = store.show("candidate-new").unwrap();
let transitions = store.read_transitions().unwrap();
assert_eq!(old.status, CandidateStatus::Superseded);
assert_eq!(new.status, CandidateStatus::Pending);
assert!(transitions.iter().any(|transition| {
transition.candidate_id == "candidate-old"
&& transition.to == CandidateStatus::Superseded
&& transition.reason.contains("candidate-new")
}));
}
#[test]
fn supersede_rejects_self_and_dead_replacements_without_mutating_old_candidate() {
let temp = tempfile::tempdir().unwrap();
let config = config_enabled();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
store
.write_candidate(&candidate_record("candidate-old", "abc123"))
.unwrap();
store
.write_candidate(&candidate_record("candidate-dead", "def456"))
.unwrap();
let empty_error = store
.supersede("candidate-old", " ", "missing replacement")
.unwrap_err();
assert!(matches!(
empty_error,
MemorySkillError::EmptySupersedeReason
));
let self_error = store
.supersede("candidate-old", "candidate-old", "same candidate")
.unwrap_err();
assert!(matches!(self_error, MemorySkillError::SelfSupersede { .. }));
store.reject("candidate-dead", "not useful").unwrap();
let dead_error = store
.supersede("candidate-old", "candidate-dead", "bad replacement")
.unwrap_err();
assert!(matches!(
dead_error,
MemorySkillError::InvalidSupersedeReplacement {
status: CandidateStatus::Rejected,
..
}
));
assert_eq!(
store.show("candidate-old").unwrap().status,
CandidateStatus::Pending
);
assert!(!store.read_transitions().unwrap().iter().any(|transition| {
transition.candidate_id == "candidate-old" && transition.to == CandidateStatus::Superseded
}));
}
#[test]
fn transition_lock_blocks_concurrent_candidate_mutation() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
fs::write(
store.candidate_dir().join("transitions.lock"),
format!("pid=123\ncreated_at_unix={}\n", unix_now()),
)
.unwrap();
let err = store
.approve(report.staged.first().unwrap(), false)
.unwrap_err();
assert!(matches!(err, MemorySkillError::TransitionLocked { .. }));
}
#[test]
fn stale_transition_lock_is_recovered() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
fs::write(
store.candidate_dir().join("transitions.lock"),
"pid=0\ncreated_at_unix=0\n",
)
.unwrap();
store
.approve(report.staged.first().unwrap(), false)
.unwrap();
assert!(!store.candidate_dir().join("transitions.lock").exists());
assert_eq!(
store.show(report.staged.first().unwrap()).unwrap().status,
CandidateStatus::Approved
);
}
#[test]
fn status_replay_ignores_transitions_for_stale_fingerprints() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let id = report.staged.first().unwrap();
let candidate = store.show(id).unwrap();
store
.append_transition(&CandidateTransition {
schema_version: CANDIDATE_SCHEMA_VERSION,
candidate_id: candidate.id.clone(),
fingerprint: "sha256:v1:stale".to_owned(),
from: CandidateStatus::Pending,
to: CandidateStatus::Approved,
action: "approve".to_owned(),
actor: "human".to_owned(),
reason: "stale transition".to_owned(),
rendered_path: None,
created_at_unix: 101,
})
.unwrap();
let candidate = store.show(id).unwrap();
assert_eq!(candidate.status, CandidateStatus::Pending);
}
#[test]
fn write_candidate_scans_serialized_json_payload() {
let temp = tempfile::tempdir().unwrap();
let mut config = config_enabled();
config.memory_skill.signals.min_occurrences = 1;
let entry = pass_entry("abc123", "run reusable smoke test", 100);
LedgerStore::new(temp.path()).append_entry(&entry).unwrap();
let report = evaluate_review_completion(temp.path(), &config, "run-1", &[entry]).unwrap();
let source_store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
let mut candidate = source_store.show(report.staged.first().unwrap()).unwrap();
candidate.id = "msk_json_secret".to_owned();
candidate.fingerprint = "sha256:v1:json-secret".to_owned();
candidate.source_claim = "CLAIM: contains API_KEY material".to_owned();
let target_state_dir = temp.path().join("target-state");
let target_store = MemorySkillStore::new(&target_state_dir, &config.memory_skill).unwrap();
let err = target_store.write_candidate(&candidate).unwrap_err();
assert!(matches!(err, MemorySkillError::ScanRejected { .. }));
assert!(!target_store.candidate_json_path(&candidate.id).exists());
}
proptest! {
#[test]
fn fingerprint_is_stable_for_same_semantic_inputs(
learning in "[a-zA-Z0-9]{3,40}"
) {
let variant = format!(" {}!! ", learning.to_ascii_uppercase());
let left = fingerprint(
MemorySkillCandidateKind::HowToSkill,
"project",
&slugify(&learning),
Verdict::Pass,
&learning,
);
let right = fingerprint(
MemorySkillCandidateKind::HowToSkill,
"project",
&slugify(&variant),
Verdict::Pass,
&variant,
);
prop_assert_eq!(left, right);
}
#[test]
fn evidence_ref_json_round_trip_preserves_selector(
sha in "[a-f0-9]{7,40}"
) {
let reference = EvidenceRef::ledger(&sha);
let encoded = serde_json::to_string(&reference).unwrap();
let decoded: EvidenceRef = serde_json::from_str(&encoded).unwrap();
prop_assert_eq!(decoded.selector, Some(format!("commit={sha}")));
}
#[test]
fn candidate_store_round_trip_preserves_evidence(
sha in "[a-f0-9]{7,40}",
key in "[a-z]{3,20}"
) {
let temp = tempfile::tempdir().unwrap();
let config = MemorySkillConfig {
candidate_dir: "skills/candidates".to_owned(),
..MemorySkillConfig::default()
};
let store = MemorySkillStore::new(temp.path(), &config).unwrap();
let ledger_ref = EvidenceRef::ledger(&sha);
let candidate = MemorySkillCandidate {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: format!("msk_100_{key}"),
fingerprint: fingerprint(
MemorySkillCandidateKind::HowToSkill,
"project",
&key,
Verdict::Pass,
&key,
),
learning_key: key.clone(),
status: CandidateStatus::Pending,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
scope: "project".to_owned(),
source_commit: sha.clone(),
source_claim: format!(
"CLAIM: {key} | verified: cargo test | evidence: tests:{key}"
),
truth_label: Verdict::Pass,
ledger_entry_ref: ledger_ref.clone(),
source_run_ref: EvidenceRef::run("run-1"),
occurrence_count: 1,
occurrence_refs: vec![ledger_ref.clone()],
slug: key.clone(),
title: title_from_slug(&key),
description: "property candidate".to_owned(),
when_to_use: vec!["When the property applies.".to_owned()],
procedure_steps: vec!["Run the property workflow.".to_owned()],
pitfalls: vec!["Do not drop evidence.".to_owned()],
verification_steps: vec!["Read the candidate back.".to_owned()],
evidence: vec![ledger_ref],
created_at_unix: 100,
};
store.write_candidate(&candidate).unwrap();
let round_tripped = store.show(&candidate.id).unwrap();
prop_assert_eq!(round_tripped.evidence, candidate.evidence);
prop_assert_eq!(round_tripped.ledger_entry_ref.selector, candidate.ledger_entry_ref.selector);
}
}