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:
Dhanji R. Prasanna
2026-02-07 16:38:27 +11:00
parent 328eecfcad
commit edbae60ff3
8 changed files with 1958 additions and 16 deletions

View File

@@ -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)."));
}
}

View File

@@ -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);
}
}