Add rulespec extensions: new predicate rules, when conditions, null handling, solon agent
Features: - New predicate rules: NotContains, AnyOf, NoneOf - Conditional predicates via when clauses (WhenCondition/CompiledWhenCondition) - Null handling: YAML null treated as absent for exists/not_exists - Solon agent for rulespec authoring (agents/solon.md) - Rulespec schema documentation (prompts/schemas/rulespec.schema.md) Bugfix: - Fixed when condition evaluation in datalog path: catch-all branch did naive string contains instead of delegating to evaluate_predicate_datalog(). Rules like matches (regex) were silently ignored, causing vacuous pass and letting violations through. Now delegates to evaluate_predicate_datalog() which handles all 12 rule types correctly. Tests: 34 new tests covering all new rules, null handling, when conditions, and the when+matches bugfix (butler rulespec pattern).
This commit is contained in:
@@ -98,7 +98,7 @@ pub async fn run_agent_mode(
|
||||
// Load agent prompt: workspace agents/<name>.md first, then embedded fallback
|
||||
let (agent_prompt, from_disk) = load_agent_prompt(agent_name, &workspace_dir).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Agent '{}' not found.\nAvailable embedded agents: breaker, carmack, euler, fowler, hopper, lamport, scout\nOr create agents/{}.md in your workspace.",
|
||||
"Agent '{}' not found.\nAvailable embedded agents: breaker, carmack, euler, fowler, hopper, lamport, scout, solon\nOr create agents/{}.md in your workspace.",
|
||||
agent_name,
|
||||
agent_name
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ static EMBEDDED_AGENTS: &[(&str, &str)] = &[
|
||||
("huffman", include_str!("../../../agents/huffman.md")),
|
||||
("lamport", include_str!("../../../agents/lamport.md")),
|
||||
("scout", include_str!("../../../agents/scout.md")),
|
||||
("solon", include_str!("../../../agents/solon.md")),
|
||||
];
|
||||
|
||||
/// Get an embedded agent prompt by name.
|
||||
@@ -89,7 +90,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_embedded_agents_exist() {
|
||||
// Verify all expected agents are embedded
|
||||
let expected = ["breaker", "carmack", "euler", "fowler", "hopper", "huffman", "lamport", "scout"];
|
||||
let expected = ["breaker", "carmack", "euler", "fowler", "hopper", "huffman", "lamport", "scout", "solon"];
|
||||
for name in expected {
|
||||
assert!(
|
||||
get_embedded_agent(name).is_some(),
|
||||
@@ -102,7 +103,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_list_embedded_agents() {
|
||||
let agents = list_embedded_agents();
|
||||
assert!(agents.len() >= 8, "Should have at least 8 embedded agents");
|
||||
assert!(agents.len() >= 9, "Should have at least 9 embedded agents");
|
||||
assert!(agents.contains(&"carmack"));
|
||||
assert!(agents.contains(&"hopper"));
|
||||
}
|
||||
|
||||
@@ -63,6 +63,18 @@ pub struct CompiledPredicate {
|
||||
pub source: InvariantSource,
|
||||
/// Optional notes
|
||||
pub notes: Option<String>,
|
||||
/// Optional when condition (compiled)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub when: Option<CompiledWhenCondition>,
|
||||
}
|
||||
|
||||
/// A compiled when condition for datalog execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompiledWhenCondition {
|
||||
pub claim_name: String,
|
||||
pub selector: String,
|
||||
pub rule: PredicateRule,
|
||||
pub expected_value: Option<String>,
|
||||
}
|
||||
|
||||
/// Compiled rulespec ready for datalog execution.
|
||||
@@ -136,6 +148,15 @@ pub fn compile_rulespec(
|
||||
expected_value,
|
||||
source: predicate.source,
|
||||
notes: predicate.notes.clone(),
|
||||
when: predicate.when.as_ref().map(|w| {
|
||||
let when_selector = claims.get(&w.claim).cloned().unwrap_or_default();
|
||||
CompiledWhenCondition {
|
||||
claim_name: w.claim.clone(),
|
||||
selector: when_selector,
|
||||
rule: w.rule.clone(),
|
||||
expected_value: w.value.as_ref().map(yaml_value_to_string),
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,10 +269,9 @@ fn extract_values_recursive(claim_name: &str, value: &YamlValue, facts: &mut Has
|
||||
}
|
||||
}
|
||||
YamlValue::Null => {
|
||||
facts.insert(Fact {
|
||||
claim_name: claim_name.to_string(),
|
||||
value: "null".to_string(),
|
||||
});
|
||||
// Null values are intentionally NOT inserted as facts.
|
||||
// This ensures `exists` returns false and `not_exists` returns true
|
||||
// for null envelope values (e.g., `breaking_changes: null`).
|
||||
}
|
||||
_ => {
|
||||
// Scalar values
|
||||
@@ -355,7 +375,38 @@ pub fn execute_rules(
|
||||
|
||||
// Now evaluate each predicate
|
||||
for pred in &compiled.predicates {
|
||||
let result = evaluate_predicate_datalog(pred, &fact_lookup);
|
||||
// Check when condition — if present and not met, skip (vacuous pass)
|
||||
let result = if let Some(when) = &pred.when {
|
||||
// Build a synthetic predicate to evaluate the when condition
|
||||
// using the same logic as regular predicate evaluation.
|
||||
let when_pred = CompiledPredicate {
|
||||
id: usize::MAX, // sentinel — not a real predicate
|
||||
claim_name: when.claim_name.clone(),
|
||||
selector: when.selector.clone(),
|
||||
rule: when.rule.clone(),
|
||||
expected_value: when.expected_value.clone(),
|
||||
source: pred.source,
|
||||
notes: None,
|
||||
when: None,
|
||||
};
|
||||
let when_met = evaluate_predicate_datalog(&when_pred, &fact_lookup).passed;
|
||||
if !when_met {
|
||||
DatalogPredicateResult {
|
||||
id: pred.id,
|
||||
claim_name: pred.claim_name.clone(),
|
||||
rule: pred.rule.clone(),
|
||||
expected_value: pred.expected_value.clone(),
|
||||
passed: true,
|
||||
reason: "Skipped (when condition not met)".to_string(),
|
||||
source: pred.source,
|
||||
notes: pred.notes.clone(),
|
||||
}
|
||||
} else {
|
||||
evaluate_predicate_datalog(pred, &fact_lookup)
|
||||
}
|
||||
} else {
|
||||
evaluate_predicate_datalog(pred, &fact_lookup)
|
||||
};
|
||||
|
||||
if result.passed {
|
||||
passed_count += 1;
|
||||
@@ -534,6 +585,50 @@ fn evaluate_predicate_datalog(
|
||||
(false, format!("Claim '{}' has no values", pred.claim_name))
|
||||
}
|
||||
}
|
||||
PredicateRule::NotContains => {
|
||||
let expected = pred.expected_value.as_deref().unwrap_or("");
|
||||
if let Some(values) = claim_values {
|
||||
if values.contains(expected) {
|
||||
(false, format!("Contains '{}' but should not", expected))
|
||||
} else {
|
||||
(true, format!("Does not contain '{}'", expected))
|
||||
}
|
||||
} else {
|
||||
(true, format!("Claim '{}' has no values (not_contains passes vacuously)", pred.claim_name))
|
||||
}
|
||||
}
|
||||
PredicateRule::AnyOf => {
|
||||
let expected_set: Vec<&str> = pred.expected_value.as_deref()
|
||||
.map(|v| v.trim_matches(|c| c == '[' || c == ']')
|
||||
.split(", ")
|
||||
.collect())
|
||||
.unwrap_or_default();
|
||||
if let Some(values) = claim_values {
|
||||
if values.iter().any(|v| expected_set.contains(v)) {
|
||||
(true, format!("Value is in allowed set"))
|
||||
} else {
|
||||
(false, format!("Value is not in allowed set"))
|
||||
}
|
||||
} else {
|
||||
(false, format!("Claim '{}' has no values", pred.claim_name))
|
||||
}
|
||||
}
|
||||
PredicateRule::NoneOf => {
|
||||
let forbidden_set: Vec<&str> = pred.expected_value.as_deref()
|
||||
.map(|v| v.trim_matches(|c| c == '[' || c == ']')
|
||||
.split(", ")
|
||||
.collect())
|
||||
.unwrap_or_default();
|
||||
if let Some(values) = claim_values {
|
||||
if values.iter().any(|v| forbidden_set.contains(v)) {
|
||||
(false, format!("Value is in forbidden set"))
|
||||
} else {
|
||||
(true, format!("Value is not in forbidden set"))
|
||||
}
|
||||
} else {
|
||||
(true, format!("Claim '{}' has no values (none_of passes vacuously)", pred.claim_name))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
DatalogPredicateResult {
|
||||
@@ -701,6 +796,25 @@ pub fn format_datalog_program(
|
||||
id, claim, expected,
|
||||
));
|
||||
}
|
||||
PredicateRule::NotContains => {
|
||||
out.push_str(&format!(
|
||||
"predicate_pass({}) :- !claim_value(\"{}\", \"{}\").\n",
|
||||
id, claim, expected,
|
||||
));
|
||||
}
|
||||
PredicateRule::AnyOf => {
|
||||
// any_of: pass if claim value matches any element in the set
|
||||
out.push_str(&format!(
|
||||
"predicate_pass({}) :- claim_value(\"{}\", V), any_of(\"{}\", V).\n",
|
||||
id, claim, expected,
|
||||
));
|
||||
}
|
||||
PredicateRule::NoneOf => {
|
||||
out.push_str(&format!(
|
||||
"predicate_pass({}) :- !claim_value(\"{}\", V), none_of(\"{}\", V).\n",
|
||||
id, claim, expected,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Derive failure as the negation of pass
|
||||
@@ -784,6 +898,7 @@ pub fn format_datalog_results(result: &DatalogExecutionResult) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::invariants::WhenCondition;
|
||||
|
||||
fn make_test_rulespec() -> Rulespec {
|
||||
let mut rulespec = Rulespec::new();
|
||||
@@ -975,10 +1090,8 @@ mod tests {
|
||||
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
assert!(facts.contains(&Fact {
|
||||
claim_name: "test".to_string(),
|
||||
value: "null".to_string(),
|
||||
}));
|
||||
// Null values should NOT produce facts — this ensures not_exists passes for null
|
||||
assert!(facts.is_empty(), "Null values should not produce facts, got: {:?}", facts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1569,4 +1682,342 @@ mod tests {
|
||||
assert_eq!(escape_datalog_string("tab\there"), "tab\\there");
|
||||
assert_eq!(escape_datalog_string("back\\slash"), "back\\\\slash");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// New Rules: NotContains, AnyOf, NoneOf in Datalog
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_execute_rules_not_contains_pass() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("caps", "feature.capabilities"));
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("caps", PredicateRule::NotContains, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::String("deprecated".to_string())),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test", 1).unwrap();
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("feature", serde_yaml::from_str("capabilities: [handle_csv, handle_tsv]").unwrap());
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let result = execute_rules(&compiled, &facts);
|
||||
assert!(result.all_passed(), "not_contains should pass when element absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_rules_not_contains_fail() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("caps", "feature.capabilities"));
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("caps", PredicateRule::NotContains, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::String("handle_csv".to_string())),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test", 1).unwrap();
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("feature", serde_yaml::from_str("capabilities: [handle_csv, handle_tsv]").unwrap());
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let result = execute_rules(&compiled, &facts);
|
||||
assert_eq!(result.failed_count, 1, "not_contains should fail when element present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_rules_any_of_pass() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("format", "feature.output_format"));
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("format", PredicateRule::AnyOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("json".to_string()),
|
||||
YamlValue::String("yaml".to_string()),
|
||||
])),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test", 1).unwrap();
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("feature", serde_yaml::from_str("output_format: json").unwrap());
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let result = execute_rules(&compiled, &facts);
|
||||
assert!(result.all_passed(), "any_of should pass when value is in set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_rules_any_of_fail() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("format", "feature.output_format"));
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("format", PredicateRule::AnyOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("json".to_string()),
|
||||
YamlValue::String("yaml".to_string()),
|
||||
])),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test", 1).unwrap();
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("feature", serde_yaml::from_str("output_format: xml").unwrap());
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let result = execute_rules(&compiled, &facts);
|
||||
assert_eq!(result.failed_count, 1, "any_of should fail when value not in set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_rules_none_of_pass() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("format", "feature.output_format"));
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("format", PredicateRule::NoneOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("xml".to_string()),
|
||||
YamlValue::String("csv".to_string()),
|
||||
])),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test", 1).unwrap();
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("feature", serde_yaml::from_str("output_format: json").unwrap());
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let result = execute_rules(&compiled, &facts);
|
||||
assert!(result.all_passed(), "none_of should pass when value not in forbidden set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_rules_none_of_fail() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("format", "feature.output_format"));
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("format", PredicateRule::NoneOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("xml".to_string()),
|
||||
YamlValue::String("csv".to_string()),
|
||||
])),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test", 1).unwrap();
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("feature", serde_yaml::from_str("output_format: xml").unwrap());
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let result = execute_rules(&compiled, &facts);
|
||||
assert_eq!(result.failed_count, 1, "none_of should fail when value in forbidden set");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// When Conditions in Datalog
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_execute_rules_when_condition_met() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("is_breaking", "api.breaking"));
|
||||
rulespec.claims.push(Claim::new("caps", "feature.capabilities"));
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("caps", PredicateRule::MinLength, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Number(2.into()))
|
||||
.with_when(WhenCondition::new("is_breaking", PredicateRule::Equals)
|
||||
.with_value(YamlValue::Bool(true))),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test", 1).unwrap();
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("api", serde_yaml::from_str("breaking: true").unwrap());
|
||||
envelope.add_fact("feature", serde_yaml::from_str("capabilities: [a, b, c]").unwrap());
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let result = execute_rules(&compiled, &facts);
|
||||
assert!(result.all_passed(), "When met + predicate passes should pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_rules_when_condition_not_met_vacuous_pass() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("is_breaking", "api.breaking"));
|
||||
rulespec.claims.push(Claim::new("caps", "feature.capabilities"));
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("caps", PredicateRule::MinLength, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Number(100.into())) // would fail if evaluated
|
||||
.with_when(WhenCondition::new("is_breaking", PredicateRule::Equals)
|
||||
.with_value(YamlValue::Bool(true))),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test", 1).unwrap();
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("api", serde_yaml::from_str("breaking: false").unwrap());
|
||||
envelope.add_fact("feature", serde_yaml::from_str("capabilities: [a]").unwrap());
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let result = execute_rules(&compiled, &facts);
|
||||
assert!(result.all_passed(), "When not met should be vacuous pass");
|
||||
assert!(result.predicate_results[0].reason.contains("Skipped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_rules_when_exists_on_null() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("has_tests", "testing.tests"));
|
||||
rulespec.claims.push(Claim::new("coverage", "testing.coverage"));
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("coverage", PredicateRule::GreaterThan, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Number(80.into()))
|
||||
.with_when(WhenCondition::new("has_tests", PredicateRule::Exists)),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test", 1).unwrap();
|
||||
|
||||
// tests is null → when(exists) not met → vacuous pass
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("testing", serde_yaml::from_str("tests: null\ncoverage: 50").unwrap());
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let result = execute_rules(&compiled, &facts);
|
||||
assert!(result.all_passed(), "When exists on null should skip (vacuous pass)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_rules_when_matches_condition_met() {
|
||||
// This is the butler rulespec pattern: when subject matches ^Re:
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("subject", "subject"));
|
||||
rulespec.claims.push(Claim::new("reply_to", "reply_to_message_id"));
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("reply_to", PredicateRule::Exists, InvariantSource::TaskPrompt)
|
||||
.with_when(WhenCondition::new("subject", PredicateRule::Matches)
|
||||
.with_value(YamlValue::String("^Re: ".to_string()))),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test", 1).unwrap();
|
||||
|
||||
// Reply email WITH reply_to_message_id → should pass
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("subject", YamlValue::String("Re: Hello".to_string()));
|
||||
envelope.add_fact("reply_to_message_id", YamlValue::String("<abc@example.com>".to_string()));
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let result = execute_rules(&compiled, &facts);
|
||||
assert!(result.all_passed(), "When matches met + exists should pass");
|
||||
// Crucially: should NOT say "Skipped"
|
||||
assert!(!result.predicate_results[0].reason.contains("Skipped"),
|
||||
"Should evaluate predicate, not skip it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_rules_when_matches_condition_met_but_predicate_fails() {
|
||||
// Reply email WITHOUT reply_to_message_id → when met, predicate fails
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("subject", "subject"));
|
||||
rulespec.claims.push(Claim::new("reply_to", "reply_to_message_id"));
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("reply_to", PredicateRule::Exists, InvariantSource::TaskPrompt)
|
||||
.with_when(WhenCondition::new("subject", PredicateRule::Matches)
|
||||
.with_value(YamlValue::String("^Re: ".to_string()))),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test", 1).unwrap();
|
||||
|
||||
// Reply email WITHOUT reply_to → when condition met, predicate should FAIL
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("subject", YamlValue::String("Re: Hello".to_string()));
|
||||
// No reply_to_message_id fact
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let result = execute_rules(&compiled, &facts);
|
||||
assert_eq!(result.failed_count, 1,
|
||||
"When matches met but exists fails → should fail, not vacuous pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_rules_when_matches_condition_not_met() {
|
||||
// Non-reply email → when condition not met → vacuous pass (skip)
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("subject", "subject"));
|
||||
rulespec.claims.push(Claim::new("reply_to", "reply_to_message_id"));
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("reply_to", PredicateRule::Exists, InvariantSource::TaskPrompt)
|
||||
.with_when(WhenCondition::new("subject", PredicateRule::Matches)
|
||||
.with_value(YamlValue::String("^Re: ".to_string()))),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test", 1).unwrap();
|
||||
|
||||
// Non-reply email → when not met → skip
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("subject", YamlValue::String("Hello World".to_string()));
|
||||
// No reply_to_message_id
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let result = execute_rules(&compiled, &facts);
|
||||
assert!(result.all_passed(), "Non-reply should get vacuous pass");
|
||||
assert!(result.predicate_results[0].reason.contains("Skipped"),
|
||||
"Should be skipped for non-reply");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// format_datalog_program for new rules
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_format_datalog_program_new_rules() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.claims.push(Claim::new("caps", "feature.capabilities"));
|
||||
rulespec.claims.push(Claim::new("format", "feature.output_format"));
|
||||
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("caps", PredicateRule::NotContains, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::String("deprecated".to_string())),
|
||||
);
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("format", PredicateRule::AnyOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("json".to_string()),
|
||||
YamlValue::String("yaml".to_string()),
|
||||
])),
|
||||
);
|
||||
rulespec.predicates.push(
|
||||
Predicate::new("format", PredicateRule::NoneOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("xml".to_string()),
|
||||
])),
|
||||
);
|
||||
|
||||
let compiled = compile_rulespec(&rulespec, "test-new-rules", 1).unwrap();
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("feature", serde_yaml::from_str("capabilities: [a, b]\noutput_format: json").unwrap());
|
||||
let facts = extract_facts(&envelope, &compiled);
|
||||
|
||||
let dl = format_datalog_program(&compiled, &facts);
|
||||
|
||||
// Verify header
|
||||
assert!(dl.contains("// Plan: test-new-rules"));
|
||||
|
||||
// Verify not_contains rule
|
||||
assert!(dl.contains("not_contains"), "Should contain not_contains rule comment");
|
||||
assert!(dl.contains("predicate_pass(0)"));
|
||||
|
||||
// Verify any_of rule
|
||||
assert!(dl.contains("any_of"), "Should contain any_of rule");
|
||||
assert!(dl.contains("predicate_pass(1)"));
|
||||
|
||||
// Verify none_of rule
|
||||
assert!(dl.contains("none_of"), "Should contain none_of rule");
|
||||
assert!(dl.contains("predicate_pass(2)"));
|
||||
|
||||
// Verify failure derivation for all
|
||||
assert!(dl.contains("predicate_fail(0) :- !predicate_pass(0)."));
|
||||
assert!(dl.contains("predicate_fail(1) :- !predicate_pass(1)."));
|
||||
assert!(dl.contains("predicate_fail(2) :- !predicate_pass(2)."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,12 @@ pub enum PredicateRule {
|
||||
MaxLength,
|
||||
/// Value matches a regex pattern
|
||||
Matches,
|
||||
/// Value does NOT contain the specified element (negation of Contains)
|
||||
NotContains,
|
||||
/// Value is one of the specified set of values
|
||||
AnyOf,
|
||||
/// Value is none of the specified set of values
|
||||
NoneOf,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PredicateRule {
|
||||
@@ -117,10 +123,40 @@ impl std::fmt::Display for PredicateRule {
|
||||
PredicateRule::MinLength => write!(f, "min_length"),
|
||||
PredicateRule::MaxLength => write!(f, "max_length"),
|
||||
PredicateRule::Matches => write!(f, "matches"),
|
||||
PredicateRule::NotContains => write!(f, "not_contains"),
|
||||
PredicateRule::AnyOf => write!(f, "any_of"),
|
||||
PredicateRule::NoneOf => write!(f, "none_of"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A predicate defines a rule to evaluate against a claim's value.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WhenCondition {
|
||||
/// Name of the claim to check for the condition
|
||||
pub claim: String,
|
||||
/// The rule to apply for the condition check
|
||||
pub rule: PredicateRule,
|
||||
/// Value to compare against (optional, depends on rule)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<YamlValue>,
|
||||
}
|
||||
|
||||
impl WhenCondition {
|
||||
pub fn new(claim: impl Into<String>, rule: PredicateRule) -> Self {
|
||||
Self {
|
||||
claim: claim.into(),
|
||||
rule,
|
||||
value: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_value(mut self, value: YamlValue) -> Self {
|
||||
self.value = Some(value);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A predicate defines a rule to evaluate against a claim's value.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Predicate {
|
||||
@@ -137,6 +173,10 @@ pub struct Predicate {
|
||||
/// Optional notes explaining the invariant or providing nuance
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
/// Optional condition that must be met for this predicate to be evaluated.
|
||||
/// If the condition is not met, the predicate is skipped (vacuous pass).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub when: Option<WhenCondition>,
|
||||
}
|
||||
|
||||
impl Predicate {
|
||||
@@ -151,6 +191,7 @@ impl Predicate {
|
||||
value: None,
|
||||
source,
|
||||
notes: None,
|
||||
when: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +205,11 @@ impl Predicate {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_when(mut self, when: WhenCondition) -> Self {
|
||||
self.when = Some(when);
|
||||
self
|
||||
}
|
||||
|
||||
/// Validate the predicate structure.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.claim.trim().is_empty() {
|
||||
@@ -185,6 +231,25 @@ impl Predicate {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate when condition if present
|
||||
if let Some(when) = &self.when {
|
||||
if when.claim.trim().is_empty() {
|
||||
return Err(anyhow!("When condition claim reference cannot be empty"));
|
||||
}
|
||||
// When conditions that need a value must have one
|
||||
match when.rule {
|
||||
PredicateRule::Exists | PredicateRule::NotExists => {}
|
||||
_ => {
|
||||
if when.value.is_none() {
|
||||
return Err(anyhow!(
|
||||
"When condition rule '{}' requires a value",
|
||||
when.rule
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -233,6 +298,15 @@ impl Rulespec {
|
||||
predicate.claim
|
||||
));
|
||||
}
|
||||
// Validate when condition claim reference
|
||||
if let Some(when) = &predicate.when {
|
||||
if !claim_names.contains(&when.claim) {
|
||||
return Err(anyhow!(
|
||||
"When condition references unknown claim: {}",
|
||||
when.claim
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -481,14 +555,22 @@ pub fn evaluate_predicate(
|
||||
) -> PredicateResult {
|
||||
match predicate.rule {
|
||||
PredicateRule::Exists => {
|
||||
if selected_values.is_empty() {
|
||||
// Filter out null values — null means "absent"
|
||||
let non_null: Vec<_> = selected_values.iter()
|
||||
.filter(|v| !v.is_null())
|
||||
.collect();
|
||||
if non_null.is_empty() {
|
||||
PredicateResult::fail("Value does not exist")
|
||||
} else {
|
||||
PredicateResult::pass("Value exists")
|
||||
}
|
||||
}
|
||||
PredicateRule::NotExists => {
|
||||
if selected_values.is_empty() {
|
||||
// Filter out null values — null means "absent"
|
||||
let non_null: Vec<_> = selected_values.iter()
|
||||
.filter(|v| !v.is_null())
|
||||
.collect();
|
||||
if non_null.is_empty() {
|
||||
PredicateResult::pass("Value does not exist as expected")
|
||||
} else {
|
||||
PredicateResult::fail("Value exists but should not")
|
||||
@@ -642,6 +724,65 @@ pub fn evaluate_predicate(
|
||||
}
|
||||
PredicateResult::fail(format!("No value matches pattern '{}'", pattern))
|
||||
}
|
||||
PredicateRule::NotContains => {
|
||||
let target = match &predicate.value {
|
||||
Some(v) => v,
|
||||
None => return PredicateResult::fail("No value specified for not_contains"),
|
||||
};
|
||||
|
||||
for value in selected_values {
|
||||
if value_contains(value, target) {
|
||||
return PredicateResult::fail(format!(
|
||||
"Value contains {:?} but should not",
|
||||
yaml_to_display(target)
|
||||
));
|
||||
}
|
||||
}
|
||||
PredicateResult::pass(format!(
|
||||
"Value does not contain {:?}",
|
||||
yaml_to_display(target)
|
||||
))
|
||||
}
|
||||
PredicateRule::AnyOf => {
|
||||
let allowed = match &predicate.value {
|
||||
Some(YamlValue::Sequence(seq)) => seq,
|
||||
Some(_) => return PredicateResult::fail("any_of requires an array value"),
|
||||
None => return PredicateResult::fail("No value specified for any_of"),
|
||||
};
|
||||
|
||||
for value in selected_values {
|
||||
if allowed.contains(value) {
|
||||
return PredicateResult::pass(format!(
|
||||
"Value {:?} is in allowed set",
|
||||
yaml_to_display(value)
|
||||
));
|
||||
}
|
||||
}
|
||||
PredicateResult::fail(format!(
|
||||
"Value is not in allowed set [{}]",
|
||||
allowed.iter().map(yaml_to_display).collect::<Vec<_>>().join(", ")
|
||||
))
|
||||
}
|
||||
PredicateRule::NoneOf => {
|
||||
let forbidden = match &predicate.value {
|
||||
Some(YamlValue::Sequence(seq)) => seq,
|
||||
Some(_) => return PredicateResult::fail("none_of requires an array value"),
|
||||
None => return PredicateResult::fail("No value specified for none_of"),
|
||||
};
|
||||
|
||||
for value in selected_values {
|
||||
if forbidden.contains(value) {
|
||||
return PredicateResult::fail(format!(
|
||||
"Value {:?} is in forbidden set",
|
||||
yaml_to_display(value)
|
||||
));
|
||||
}
|
||||
}
|
||||
PredicateResult::pass(format!(
|
||||
"Value is not in forbidden set [{}]",
|
||||
forbidden.iter().map(yaml_to_display).collect::<Vec<_>>().join(", ")
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,6 +926,41 @@ pub fn evaluate_rulespec(rulespec: &Rulespec, envelope: &ActionEnvelope) -> Rule
|
||||
.collect();
|
||||
|
||||
for predicate in &rulespec.predicates {
|
||||
// Check when condition — if present and not met, skip (vacuous pass)
|
||||
if let Some(when) = &predicate.when {
|
||||
let when_claim = claims.get(when.claim.as_str());
|
||||
let when_met = match when_claim {
|
||||
Some(claim) => {
|
||||
match Selector::parse(&claim.selector) {
|
||||
Ok(selector) => {
|
||||
let when_values = selector.select(&envelope_value);
|
||||
let when_pred = Predicate {
|
||||
claim: when.claim.clone(),
|
||||
rule: when.rule.clone(),
|
||||
value: when.value.clone(),
|
||||
source: predicate.source,
|
||||
notes: None,
|
||||
when: None,
|
||||
};
|
||||
evaluate_predicate(&when_pred, &when_values).passed
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if !when_met {
|
||||
passed_count += 1;
|
||||
predicate_results.push(PredicateEvaluation {
|
||||
predicate: predicate.clone(),
|
||||
claim_name: predicate.claim.clone(),
|
||||
selected_values: vec![],
|
||||
result: PredicateResult::pass("Skipped (when condition not met)"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let claim = claims.get(predicate.claim.as_str());
|
||||
|
||||
let (selected_values, result) = match claim {
|
||||
@@ -1446,4 +1622,411 @@ facts:
|
||||
assert_eq!(parsed.facts.get("breaking_changes").unwrap(), &YamlValue::Null);
|
||||
assert_eq!(parsed.facts.get("feature").unwrap(), &YamlValue::String("done".to_string()));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// New Predicate Rules: NotContains, AnyOf, NoneOf
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_predicate_not_contains_array_pass() {
|
||||
let predicate = Predicate::new("test", PredicateRule::NotContains, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::String("deprecated".to_string()));
|
||||
|
||||
let array = YamlValue::Sequence(vec![
|
||||
YamlValue::String("handle_csv".to_string()),
|
||||
YamlValue::String("handle_tsv".to_string()),
|
||||
]);
|
||||
|
||||
let result = evaluate_predicate(&predicate, &[array]);
|
||||
assert!(result.passed, "not_contains should pass when element is absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_not_contains_array_fail() {
|
||||
let predicate = Predicate::new("test", PredicateRule::NotContains, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::String("handle_csv".to_string()));
|
||||
|
||||
let array = YamlValue::Sequence(vec![
|
||||
YamlValue::String("handle_csv".to_string()),
|
||||
YamlValue::String("handle_tsv".to_string()),
|
||||
]);
|
||||
|
||||
let result = evaluate_predicate(&predicate, &[array]);
|
||||
assert!(!result.passed, "not_contains should fail when element is present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_not_contains_string_pass() {
|
||||
let predicate = Predicate::new("test", PredicateRule::NotContains, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::String("xml".to_string()));
|
||||
|
||||
let result = evaluate_predicate(
|
||||
&predicate,
|
||||
&[YamlValue::String("csv_importer".to_string())],
|
||||
);
|
||||
assert!(result.passed, "not_contains should pass when substring is absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_not_contains_string_fail() {
|
||||
let predicate = Predicate::new("test", PredicateRule::NotContains, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::String("csv".to_string()));
|
||||
|
||||
let result = evaluate_predicate(
|
||||
&predicate,
|
||||
&[YamlValue::String("csv_importer".to_string())],
|
||||
);
|
||||
assert!(!result.passed, "not_contains should fail when substring is present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_not_contains_empty_array() {
|
||||
let predicate = Predicate::new("test", PredicateRule::NotContains, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::String("anything".to_string()));
|
||||
|
||||
let array = YamlValue::Sequence(vec![]);
|
||||
let result = evaluate_predicate(&predicate, &[array]);
|
||||
assert!(result.passed, "not_contains on empty array should pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_any_of_pass() {
|
||||
let predicate = Predicate::new("test", PredicateRule::AnyOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("json".to_string()),
|
||||
YamlValue::String("yaml".to_string()),
|
||||
YamlValue::String("toml".to_string()),
|
||||
]));
|
||||
|
||||
let result = evaluate_predicate(
|
||||
&predicate,
|
||||
&[YamlValue::String("yaml".to_string())],
|
||||
);
|
||||
assert!(result.passed, "any_of should pass when value is in set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_any_of_fail() {
|
||||
let predicate = Predicate::new("test", PredicateRule::AnyOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("json".to_string()),
|
||||
YamlValue::String("yaml".to_string()),
|
||||
]));
|
||||
|
||||
let result = evaluate_predicate(
|
||||
&predicate,
|
||||
&[YamlValue::String("xml".to_string())],
|
||||
);
|
||||
assert!(!result.passed, "any_of should fail when value is not in set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_any_of_non_array_value_fails() {
|
||||
let predicate = Predicate::new("test", PredicateRule::AnyOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::String("not_an_array".to_string()));
|
||||
|
||||
let result = evaluate_predicate(
|
||||
&predicate,
|
||||
&[YamlValue::String("anything".to_string())],
|
||||
);
|
||||
assert!(!result.passed, "any_of with non-array value should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_any_of_single_element() {
|
||||
let predicate = Predicate::new("test", PredicateRule::AnyOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("only".to_string()),
|
||||
]));
|
||||
|
||||
let result = evaluate_predicate(
|
||||
&predicate,
|
||||
&[YamlValue::String("only".to_string())],
|
||||
);
|
||||
assert!(result.passed, "any_of with single-element set should work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_none_of_pass() {
|
||||
let predicate = Predicate::new("test", PredicateRule::NoneOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("xml".to_string()),
|
||||
YamlValue::String("csv".to_string()),
|
||||
]));
|
||||
|
||||
let result = evaluate_predicate(
|
||||
&predicate,
|
||||
&[YamlValue::String("json".to_string())],
|
||||
);
|
||||
assert!(result.passed, "none_of should pass when value is not in forbidden set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_none_of_fail() {
|
||||
let predicate = Predicate::new("test", PredicateRule::NoneOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("xml".to_string()),
|
||||
YamlValue::String("csv".to_string()),
|
||||
]));
|
||||
|
||||
let result = evaluate_predicate(
|
||||
&predicate,
|
||||
&[YamlValue::String("xml".to_string())],
|
||||
);
|
||||
assert!(!result.passed, "none_of should fail when value is in forbidden set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_none_of_non_array_value_fails() {
|
||||
let predicate = Predicate::new("test", PredicateRule::NoneOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::String("not_an_array".to_string()));
|
||||
|
||||
let result = evaluate_predicate(
|
||||
&predicate,
|
||||
&[YamlValue::String("anything".to_string())],
|
||||
);
|
||||
assert!(!result.passed, "none_of with non-array value should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_none_of_empty_forbidden_set() {
|
||||
let predicate = Predicate::new("test", PredicateRule::NoneOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![]));
|
||||
|
||||
let result = evaluate_predicate(
|
||||
&predicate,
|
||||
&[YamlValue::String("anything".to_string())],
|
||||
);
|
||||
assert!(result.passed, "none_of with empty forbidden set should pass");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Null Handling Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_predicate_exists_fails_for_null() {
|
||||
let predicate = Predicate::new("test", PredicateRule::Exists, InvariantSource::TaskPrompt);
|
||||
|
||||
let result = evaluate_predicate(&predicate, &[YamlValue::Null]);
|
||||
assert!(!result.passed, "exists should fail for null value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_not_exists_passes_for_null() {
|
||||
let predicate = Predicate::new("test", PredicateRule::NotExists, InvariantSource::TaskPrompt);
|
||||
|
||||
let result = evaluate_predicate(&predicate, &[YamlValue::Null]);
|
||||
assert!(result.passed, "not_exists should pass for null value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_exists_passes_for_empty_string() {
|
||||
let predicate = Predicate::new("test", PredicateRule::Exists, InvariantSource::TaskPrompt);
|
||||
|
||||
let result = evaluate_predicate(&predicate, &[YamlValue::String(String::new())]);
|
||||
assert!(result.passed, "exists should pass for empty string (not null)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_exists_passes_for_empty_array() {
|
||||
let predicate = Predicate::new("test", PredicateRule::Exists, InvariantSource::TaskPrompt);
|
||||
|
||||
let result = evaluate_predicate(&predicate, &[YamlValue::Sequence(vec![])]);
|
||||
assert!(result.passed, "exists should pass for empty array (not null)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_contains_on_null_fails() {
|
||||
let predicate = Predicate::new("test", PredicateRule::Contains, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::String("x".to_string()));
|
||||
|
||||
let result = evaluate_predicate(&predicate, &[YamlValue::Null]);
|
||||
assert!(!result.passed, "contains on null should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_exists_with_mixed_null_and_value() {
|
||||
let predicate = Predicate::new("test", PredicateRule::Exists, InvariantSource::TaskPrompt);
|
||||
|
||||
// If selected_values has both null and a real value, exists should pass
|
||||
let result = evaluate_predicate(
|
||||
&predicate,
|
||||
&[YamlValue::Null, YamlValue::String("real".to_string())],
|
||||
);
|
||||
assert!(result.passed, "exists should pass when at least one non-null value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_not_exists_fails_with_mixed_null_and_value() {
|
||||
let predicate = Predicate::new("test", PredicateRule::NotExists, InvariantSource::TaskPrompt);
|
||||
|
||||
let result = evaluate_predicate(
|
||||
&predicate,
|
||||
&[YamlValue::Null, YamlValue::String("real".to_string())],
|
||||
);
|
||||
assert!(!result.passed, "not_exists should fail when at least one non-null value");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// When Condition Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_when_condition_met_evaluates_predicate() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.add_claim(Claim::new("is_breaking", "api_changes.breaking"));
|
||||
rulespec.add_claim(Claim::new("caps", "feature.capabilities"));
|
||||
rulespec.add_predicate(
|
||||
Predicate::new("caps", PredicateRule::MinLength, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Number(2.into()))
|
||||
.with_when(WhenCondition::new("is_breaking", PredicateRule::Equals)
|
||||
.with_value(YamlValue::Bool(true)))
|
||||
);
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("api_changes", serde_yaml::from_str("breaking: true").unwrap());
|
||||
envelope.add_fact("feature", serde_yaml::from_str("capabilities: [a, b, c]").unwrap());
|
||||
|
||||
let eval = evaluate_rulespec(&rulespec, &envelope);
|
||||
assert_eq!(eval.passed_count, 1);
|
||||
assert_eq!(eval.failed_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_when_condition_not_met_vacuous_pass() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.add_claim(Claim::new("is_breaking", "api_changes.breaking"));
|
||||
rulespec.add_claim(Claim::new("caps", "feature.capabilities"));
|
||||
rulespec.add_predicate(
|
||||
Predicate::new("caps", PredicateRule::MinLength, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Number(100.into())) // would fail if evaluated
|
||||
.with_when(WhenCondition::new("is_breaking", PredicateRule::Equals)
|
||||
.with_value(YamlValue::Bool(true)))
|
||||
);
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("api_changes", serde_yaml::from_str("breaking: false").unwrap());
|
||||
envelope.add_fact("feature", serde_yaml::from_str("capabilities: [a]").unwrap());
|
||||
|
||||
let eval = evaluate_rulespec(&rulespec, &envelope);
|
||||
assert_eq!(eval.passed_count, 1, "When not met should be vacuous pass");
|
||||
assert_eq!(eval.failed_count, 0);
|
||||
assert!(eval.predicate_results[0].result.reason.contains("Skipped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_when_condition_with_exists() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.add_claim(Claim::new("has_tests", "feature.tests"));
|
||||
rulespec.add_claim(Claim::new("coverage", "feature.coverage"));
|
||||
rulespec.add_predicate(
|
||||
Predicate::new("coverage", PredicateRule::GreaterThan, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Number(80.into()))
|
||||
.with_when(WhenCondition::new("has_tests", PredicateRule::Exists))
|
||||
);
|
||||
|
||||
// No tests field → when condition not met → vacuous pass
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("feature", serde_yaml::from_str("coverage: 50").unwrap());
|
||||
|
||||
let eval = evaluate_rulespec(&rulespec, &envelope);
|
||||
assert_eq!(eval.passed_count, 1, "When exists not met should skip");
|
||||
assert_eq!(eval.failed_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_when_unknown_claim_fails_validation() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.add_claim(Claim::new("caps", "feature.capabilities"));
|
||||
rulespec.add_predicate(
|
||||
Predicate::new("caps", PredicateRule::Exists, InvariantSource::TaskPrompt)
|
||||
.with_when(WhenCondition::new("nonexistent", PredicateRule::Exists))
|
||||
);
|
||||
|
||||
let result = rulespec.validate();
|
||||
assert!(result.is_err(), "When referencing unknown claim should fail validation");
|
||||
assert!(result.unwrap_err().to_string().contains("unknown claim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_predicate_without_when_always_evaluated() {
|
||||
// Backward compatibility: no when field means always evaluated
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.add_claim(Claim::new("caps", "feature.capabilities"));
|
||||
rulespec.add_predicate(
|
||||
Predicate::new("caps", PredicateRule::Exists, InvariantSource::TaskPrompt)
|
||||
);
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("feature", serde_yaml::from_str("capabilities: [a]").unwrap());
|
||||
|
||||
let eval = evaluate_rulespec(&rulespec, &envelope);
|
||||
assert_eq!(eval.passed_count, 1);
|
||||
assert_eq!(eval.failed_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_when_condition_validate_requires_value() {
|
||||
let when = WhenCondition::new("test", PredicateRule::Equals);
|
||||
// No value set — validation should catch this
|
||||
let predicate = Predicate::new("test", PredicateRule::Exists, InvariantSource::TaskPrompt)
|
||||
.with_when(when);
|
||||
let result = predicate.validate();
|
||||
assert!(result.is_err(), "When condition with equals but no value should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_when_condition_exists_no_value_ok() {
|
||||
let when = WhenCondition::new("test", PredicateRule::Exists);
|
||||
let predicate = Predicate::new("test", PredicateRule::Exists, InvariantSource::TaskPrompt)
|
||||
.with_when(when);
|
||||
let result = predicate.validate();
|
||||
assert!(result.is_ok(), "When condition with exists and no value should be ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_rulespec_with_new_rules_full() {
|
||||
let mut rulespec = Rulespec::new();
|
||||
rulespec.add_claim(Claim::new("caps", "feature.capabilities"));
|
||||
rulespec.add_claim(Claim::new("format", "feature.output_format"));
|
||||
rulespec.add_claim(Claim::new("breaking", "breaking_changes"));
|
||||
|
||||
// not_contains: must not have deprecated
|
||||
rulespec.add_predicate(
|
||||
Predicate::new("caps", PredicateRule::NotContains, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::String("deprecated".to_string()))
|
||||
);
|
||||
// any_of: format must be json or yaml
|
||||
rulespec.add_predicate(
|
||||
Predicate::new("format", PredicateRule::AnyOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("json".to_string()),
|
||||
YamlValue::String("yaml".to_string()),
|
||||
]))
|
||||
);
|
||||
// none_of: format must not be xml or csv
|
||||
rulespec.add_predicate(
|
||||
Predicate::new("format", PredicateRule::NoneOf, InvariantSource::TaskPrompt)
|
||||
.with_value(YamlValue::Sequence(vec![
|
||||
YamlValue::String("xml".to_string()),
|
||||
YamlValue::String("csv".to_string()),
|
||||
]))
|
||||
);
|
||||
// not_exists: no breaking changes
|
||||
rulespec.add_predicate(
|
||||
Predicate::new("breaking", PredicateRule::NotExists, InvariantSource::TaskPrompt)
|
||||
);
|
||||
|
||||
let mut envelope = ActionEnvelope::new();
|
||||
envelope.add_fact("feature", serde_yaml::from_str(r#"
|
||||
capabilities: [handle_csv, handle_tsv]
|
||||
output_format: json
|
||||
"#).unwrap());
|
||||
envelope.add_fact("breaking_changes", YamlValue::Null);
|
||||
|
||||
let eval = evaluate_rulespec(&rulespec, &envelope);
|
||||
assert_eq!(eval.passed_count, 4, "All 4 predicates should pass");
|
||||
assert_eq!(eval.failed_count, 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user