Skip to main content

wallfacer_core/property/
runner.rs

1//! Property invariant evaluator.
2//!
3//! Phase D rewrites the runner to:
4//!
5//! * use [`Operand::resolve`] instead of the legacy `starts_with('$')`
6//!   heuristic, while preserving full v1 backwards compatibility;
7//! * support boolean combinators (`all_of`, `any_of`, `not`),
8//!   element-wise iteration (`for_each`), and inline schema validation
9//!   (`matches_schema`);
10//! * forbid `unwrap` / `expect` at the module level so any future
11//!   regression is caught by the workspace clippy gate.
12
13#![deny(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
14
15use jsonschema::validator_for;
16use rand::RngCore;
17use regex::Regex;
18use serde_json::{json, Map, Number, Value};
19use thiserror::Error;
20use tracing::warn;
21
22use super::{
23    dsl::{
24        Assertion, FixtureExpect, Invariant, JsonType, Operand, TestFixture, ValueKind, ValueSpec,
25    },
26    jsonpath,
27};
28
29/// Errors raised while evaluating a single invariant case.
30#[derive(Debug, Error)]
31pub enum RunnerError {
32    /// An assertion produced a human-readable failure.
33    #[error("{0}")]
34    Assertion(String),
35    /// A JSONPath used by the assertion did not resolve cleanly.
36    #[error(transparent)]
37    JsonPath(#[from] jsonpath::JsonPathError),
38    /// A regex inside a `matches_regex` assertion failed to compile.
39    #[error("invalid regex `{pattern}`: {source}")]
40    Regex {
41        pattern: String,
42        #[source]
43        source: regex::Error,
44    },
45    /// A JSON Schema inside a `matches_schema` assertion failed to compile.
46    #[error("invalid inline schema in `matches_schema`: {0}")]
47    Schema(String),
48}
49
50pub type Result<T> = std::result::Result<T, RunnerError>;
51
52pub fn input_for_case(invariant: &Invariant, case_index: u32, rng: &mut impl RngCore) -> Value {
53    if let Some(fixed) = &invariant.fixed {
54        return Value::Object(
55            fixed
56                .iter()
57                .map(|(key, value)| (key.clone(), value.clone()))
58                .collect(),
59        );
60    }
61
62    let mut input = Map::new();
63    if let Some(generate) = &invariant.generate {
64        for (key, spec) in generate {
65            let value = if case_index == 0 {
66                boundary_value(spec)
67            } else {
68                generated_value(spec, rng)
69            };
70            input.insert(key.clone(), value);
71        }
72    }
73    Value::Object(input)
74}
75
76/// Top-level entry point: evaluates every assertion of an invariant against
77/// a freshly built `{input, response}` context.
78pub fn evaluate(invariant: &Invariant, input: Value, response: Value) -> Result<()> {
79    evaluate_step_assertions(&invariant.assertions, input, response)
80}
81
82/// Like [`evaluate`] but also injects a `$.tool` block holding the
83/// matched tool's `name`, `description`, and `annotations` (Phase
84/// T). Packs that scan tool metadata for poisoning markers reach
85/// for `$.tool.description` etc.; older invariants ignore the
86/// extra field.
87pub fn evaluate_with_tool(
88    invariant: &Invariant,
89    input: Value,
90    response: Value,
91    tool: Option<&rmcp::model::Tool>,
92) -> Result<()> {
93    evaluate_step_assertions_with_tool(&invariant.assertions, input, response, tool)
94}
95
96/// Evaluates a free-form list of assertions against a fresh
97/// `{input, response}` context. Used by the sequence runner where
98/// the assertions live on a [`super::dsl::SequenceStep`] rather than
99/// an [`Invariant`]. Behaviour is otherwise identical to
100/// [`evaluate`].
101pub fn evaluate_step_assertions(
102    assertions: &[Assertion],
103    input: Value,
104    response: Value,
105) -> Result<()> {
106    evaluate_step_assertions_with_tool(assertions, input, response, None)
107}
108
109/// Like [`evaluate_step_assertions`] but injects `$.tool` when the
110/// caller has the matched live tool in hand. Used by the property
111/// runner when an invariant came out of a `for_each_tool` block.
112pub fn evaluate_step_assertions_with_tool(
113    assertions: &[Assertion],
114    input: Value,
115    response: Value,
116    tool: Option<&rmcp::model::Tool>,
117) -> Result<()> {
118    let tool_value = tool
119        .map(|t| {
120            let annotations = serde_json::to_value(&t.annotations).unwrap_or(Value::Null);
121            json!({
122                "name": t.name.as_ref(),
123                "description": t.description.as_deref().unwrap_or(""),
124                "annotations": annotations,
125            })
126        })
127        .unwrap_or(Value::Null);
128    let context = json!({
129        "input": input,
130        "response": response,
131        "tool": tool_value,
132    });
133    for assertion in assertions {
134        evaluate_assertion(assertion, &context)?;
135    }
136    Ok(())
137}
138
139/// Outcome of [`evaluate_fixture`]: the comparison between what the
140/// fixture asked for and what the runner actually produced.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum FixtureOutcome {
143    /// Observed matches `expected`; this fixture passed.
144    Match,
145    /// Observed differs from `expected`; this fixture failed.
146    Mismatch {
147        /// What the fixture said should happen.
148        expected: FixtureExpect,
149        /// What the runner actually observed.
150        observed: FixtureExpect,
151        /// Human-readable detail (assertion message when the runner
152        /// returned `Err`, empty string on `Ok`).
153        detail: String,
154    },
155    /// The runner returned a structural error (bad path, malformed
156    /// regex / schema). The invariant itself is broken — this fixture
157    /// can neither pass nor fail meaningfully.
158    Structural {
159        /// Pass-through of the structural error.
160        error: String,
161    },
162}
163
164/// Phase H — evaluates an invariant against a synthetic
165/// `(input, response)` pair scripted by a [`TestFixture`] and reports
166/// whether the observed outcome matches the fixture's `expect`.
167pub fn evaluate_fixture(invariant: &Invariant, fixture: &TestFixture) -> FixtureOutcome {
168    let input = fixture.input.clone().unwrap_or_else(|| {
169        let map = invariant
170            .fixed
171            .clone()
172            .unwrap_or_default()
173            .into_iter()
174            .collect::<serde_json::Map<_, _>>();
175        Value::Object(map)
176    });
177    match evaluate(invariant, input, fixture.response.clone()) {
178        Ok(()) => match fixture.expect {
179            FixtureExpect::Pass => FixtureOutcome::Match,
180            FixtureExpect::Fail => FixtureOutcome::Mismatch {
181                expected: FixtureExpect::Fail,
182                observed: FixtureExpect::Pass,
183                detail: String::new(),
184            },
185        },
186        Err(RunnerError::Assertion(message)) => match fixture.expect {
187            FixtureExpect::Fail => FixtureOutcome::Match,
188            FixtureExpect::Pass => FixtureOutcome::Mismatch {
189                expected: FixtureExpect::Pass,
190                observed: FixtureExpect::Fail,
191                detail: message,
192            },
193        },
194        Err(other) => FixtureOutcome::Structural {
195            error: other.to_string(),
196        },
197    }
198}
199
200fn evaluate_assertion(assertion: &Assertion, context: &Value) -> Result<()> {
201    match assertion {
202        Assertion::Equals { lhs, rhs } => {
203            let left = lhs.resolve(context)?;
204            let right = rhs.resolve(context)?;
205            if left == right {
206                Ok(())
207            } else {
208                Err(RunnerError::Assertion(format!(
209                    "expected {left} to equal {right}"
210                )))
211            }
212        }
213        Assertion::NotEquals { lhs, rhs } => {
214            let left = lhs.resolve(context)?;
215            let right = rhs.resolve(context)?;
216            if left != right {
217                Ok(())
218            } else {
219                Err(RunnerError::Assertion(format!(
220                    "expected {left} to differ from {right}"
221                )))
222            }
223        }
224        Assertion::AtMost { path, value } => compare_number(path, value, context, |o| {
225            matches!(o, std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
226        }),
227        Assertion::AtLeast { path, value } => compare_number(path, value, context, |o| {
228            matches!(o, std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
229        }),
230        Assertion::LengthEq { path, value } => compare_length(path, value, context, |a, b| a == b),
231        Assertion::LengthAtMost { path, value } => {
232            compare_length(path, value, context, |a, b| a <= b)
233        }
234        Assertion::LengthAtLeast { path, value } => {
235            compare_length(path, value, context, |a, b| a >= b)
236        }
237        Assertion::IsType { path, expected } => {
238            let value = jsonpath::resolve_one(context, path)?;
239            let actual = json_type(&value);
240            if actual == *expected {
241                Ok(())
242            } else {
243                Err(RunnerError::Assertion(format!(
244                    "expected {path} to be {expected:?}, got {actual:?}"
245                )))
246            }
247        }
248        Assertion::MatchesRegex { path, pattern } => {
249            let value = jsonpath::resolve_one(context, path)?;
250            let Some(text) = value.as_str() else {
251                return Err(RunnerError::Assertion(format!(
252                    "expected {path} to resolve to a string"
253                )));
254            };
255            let regex = Regex::new(pattern).map_err(|source| RunnerError::Regex {
256                pattern: pattern.clone(),
257                source,
258            })?;
259            if regex.is_match(text) {
260                Ok(())
261            } else {
262                Err(RunnerError::Assertion(format!(
263                    "expected {path} to match {pattern}"
264                )))
265            }
266        }
267        Assertion::AllOf { assertions } => {
268            for child in assertions {
269                evaluate_assertion(child, context)?;
270            }
271            Ok(())
272        }
273        Assertion::AnyOf { assertions } => evaluate_any_of(assertions, context),
274        Assertion::Not { assertion } => match evaluate_assertion(assertion, context) {
275            Ok(()) => Err(RunnerError::Assertion(
276                "expected child assertion to fail under `not`".to_string(),
277            )),
278            // A failing child *is* OK semantically when:
279            //  - it produced an `Assertion` failure;
280            //  - it referenced a path that did not resolve. Missing
281            //    paths are equivalent to "child assertion does not
282            //    hold" rather than a malformed invariant — promoting
283            //    them to hard errors would make `not(matches_regex
284            //    path=...)` surprising whenever the path is absent.
285            // Other errors (regex compile, schema compile, malformed
286            // YAML) are genuinely structural and propagate.
287            Err(RunnerError::Assertion(_)) => Ok(()),
288            Err(RunnerError::JsonPath(jsonpath::JsonPathError::Missing(_))) => Ok(()),
289            Err(other) => Err(other),
290        },
291        Assertion::ForEach { path, assertions } => evaluate_for_each(path, assertions, context),
292        Assertion::MatchesSchema { path, schema } => {
293            let target = jsonpath::resolve_one(context, path)?;
294            let validator =
295                validator_for(schema).map_err(|err| RunnerError::Schema(err.to_string()))?;
296            if validator.is_valid(&target) {
297                Ok(())
298            } else {
299                let errors = validator
300                    .iter_errors(&target)
301                    .map(|err| format!("{err} at {}", err.instance_path()))
302                    .collect::<Vec<_>>()
303                    .join("; ");
304                Err(RunnerError::Assertion(format!(
305                    "value at {path} does not validate against inline schema: {errors}"
306                )))
307            }
308        }
309    }
310}
311
312fn evaluate_any_of(assertions: &[Assertion], context: &Value) -> Result<()> {
313    if assertions.is_empty() {
314        return Err(RunnerError::Assertion(
315            "`any_of` requires at least one child assertion".to_string(),
316        ));
317    }
318    let mut last_assertion_error: Option<String> = None;
319    for child in assertions {
320        match evaluate_assertion(child, context) {
321            Ok(()) => return Ok(()),
322            Err(RunnerError::Assertion(message)) => {
323                last_assertion_error = Some(message);
324            }
325            // A missing path inside one branch of `any_of` simply means
326            // "this branch does not apply" — try the next one. Other
327            // structural errors (regex compile, schema compile) signal
328            // a broken invariant and propagate.
329            Err(RunnerError::JsonPath(jsonpath::JsonPathError::Missing(path))) => {
330                last_assertion_error = Some(format!("path `{path}` did not resolve"));
331            }
332            Err(other) => return Err(other),
333        }
334    }
335    Err(RunnerError::Assertion(format!(
336        "no `any_of` branch matched (last failure: {})",
337        last_assertion_error.unwrap_or_else(|| "unknown".to_string())
338    )))
339}
340
341fn evaluate_for_each(path: &str, assertions: &[Assertion], context: &Value) -> Result<()> {
342    let nodes = jsonpath::resolve(context, path)?;
343    if nodes.is_empty() {
344        // `for_each` over an empty set is vacuously true. Emit a warning
345        // so an author who fat-fingered a JSONPath does not ship an
346        // invariant that silently always passes. Run `wallfacer property
347        // -v` to see this.
348        warn!(
349            jsonpath = path,
350            "for_each path matched zero nodes; the assertion is vacuously true. \
351             Double-check the path or wrap intentional empty-set cases in `any_of` / `not`."
352        );
353        return Ok(());
354    }
355    for (index, node) in nodes.into_iter().enumerate() {
356        // Build a child context with the iterated node exposed under
357        // `$.item`. Original `$.input` / `$.response` remain accessible so
358        // assertions can correlate the current element with global state.
359        let Some(base) = context.as_object() else {
360            return Err(RunnerError::Assertion(
361                "internal: evaluation context must be an object".to_string(),
362            ));
363        };
364        let mut child = base.clone();
365        child.insert("item".to_string(), node);
366        child.insert("index".to_string(), json!(index));
367        let child_context = Value::Object(child);
368        for assertion in assertions {
369            evaluate_assertion(assertion, &child_context).map_err(|err| match err {
370                RunnerError::Assertion(message) => {
371                    RunnerError::Assertion(format!("for_each at {path}[{index}]: {message}"))
372                }
373                other => other,
374            })?;
375        }
376    }
377    Ok(())
378}
379
380fn compare_number(
381    path: &str,
382    value: &Operand,
383    context: &Value,
384    compare: impl Fn(std::cmp::Ordering) -> bool,
385) -> Result<()> {
386    let left = jsonpath::resolve_one(context, path)?;
387    let right = value.resolve(context)?;
388
389    // Fast path: when both operands fit in `i128` (covers any JSON
390    // integer, signed or unsigned), compare without going through f64.
391    // f64 only has 53 bits of mantissa, so values like
392    // `9_007_199_254_740_993` lose precision and equal their
393    // `…992` neighbour — silently corrupting `at_most`/`at_least`.
394    if let (Some(l), Some(r)) = (as_i128(&left), as_i128(&right)) {
395        if compare(l.cmp(&r)) {
396            return Ok(());
397        }
398        return Err(RunnerError::Assertion(format!(
399            "numeric comparison failed: {l} vs {r}"
400        )));
401    }
402
403    let Some(left_f) = left.as_f64() else {
404        return Err(RunnerError::Assertion(format!(
405            "expected {path} to resolve to a number"
406        )));
407    };
408    let Some(right_f) = right.as_f64() else {
409        return Err(RunnerError::Assertion(
410            "expected comparison value to be a number".to_string(),
411        ));
412    };
413    let ordering = left_f
414        .partial_cmp(&right_f)
415        .ok_or_else(|| RunnerError::Assertion("comparison against NaN".to_string()))?;
416    if compare(ordering) {
417        Ok(())
418    } else {
419        Err(RunnerError::Assertion(format!(
420            "numeric comparison failed: {left_f} vs {right_f}"
421        )))
422    }
423}
424
425fn as_i128(value: &Value) -> Option<i128> {
426    let Value::Number(n) = value else {
427        return None;
428    };
429    if let Some(i) = n.as_i64() {
430        Some(i as i128)
431    } else {
432        n.as_u64().map(|u| u as i128)
433    }
434}
435
436fn compare_length(
437    path: &str,
438    value: &Operand,
439    context: &Value,
440    compare: impl FnOnce(usize, usize) -> bool,
441) -> Result<()> {
442    let left = jsonpath::resolve_one(context, path)?;
443    let right = value.resolve(context)?;
444    let Some(right) = right.as_u64().map(|value| value as usize) else {
445        return Err(RunnerError::Assertion(
446            "expected comparison value to be an integer".to_string(),
447        ));
448    };
449    let Some(left) = length(&left) else {
450        return Err(RunnerError::Assertion(format!(
451            "expected {path} to resolve to an array or string"
452        )));
453    };
454    if compare(left, right) {
455        Ok(())
456    } else {
457        Err(RunnerError::Assertion(format!(
458            "length comparison failed: {left} vs {right}"
459        )))
460    }
461}
462
463fn length(value: &Value) -> Option<usize> {
464    match value {
465        Value::Array(items) => Some(items.len()),
466        Value::String(text) => Some(text.chars().count()),
467        _ => None,
468    }
469}
470
471fn json_type(value: &Value) -> JsonType {
472    match value {
473        Value::Null => JsonType::Null,
474        Value::Bool(_) => JsonType::Boolean,
475        Value::Number(number) if number.is_i64() || number.is_u64() => JsonType::Integer,
476        Value::Number(_) => JsonType::Number,
477        Value::String(_) => JsonType::String,
478        Value::Array(_) => JsonType::Array,
479        Value::Object(_) => JsonType::Object,
480    }
481}
482
483impl Operand {
484    /// Resolves an operand against the `{input, response}` context.
485    ///
486    /// * [`Operand::Path`] → JSONPath lookup (errors if missing).
487    /// * [`Operand::Literal`] → returned verbatim.
488    /// * [`Operand::Direct`] strings starting with `$` → JSONPath lookup
489    ///   (legacy v1 contract); anything else → returned verbatim.
490    pub fn resolve(&self, context: &Value) -> Result<Value> {
491        match self {
492            Operand::Path { path } => Ok(jsonpath::resolve_one(context, path)?),
493            Operand::Literal { value } => Ok(value.clone()),
494            Operand::Direct(Value::String(s)) if s.starts_with('$') => {
495                Ok(jsonpath::resolve_one(context, s)?)
496            }
497            Operand::Direct(value) => Ok(value.clone()),
498        }
499    }
500}
501
502fn boundary_value(spec: &ValueSpec) -> Value {
503    match spec.kind {
504        ValueKind::String => {
505            let len = spec.max_length.or(spec.min_length).unwrap_or(8).min(1024);
506            Value::String("x".repeat(len))
507        }
508        ValueKind::Integer => json!(spec.max.or(spec.min).unwrap_or(1)),
509        ValueKind::Number => Number::from_f64(spec.max.or(spec.min).unwrap_or(1) as f64)
510            .map(Value::Number)
511            .unwrap_or(Value::Null),
512        ValueKind::Boolean => Value::Bool(true),
513        ValueKind::Array => {
514            let len = spec.max_items.or(spec.min_items).unwrap_or(1).min(64);
515            let item_spec = spec.items.as_deref();
516            Value::Array(
517                (0..len)
518                    .map(|_| item_spec.map(boundary_value).unwrap_or(Value::Null))
519                    .collect(),
520            )
521        }
522    }
523}
524
525fn generated_value(spec: &ValueSpec, rng: &mut impl RngCore) -> Value {
526    match spec.kind {
527        ValueKind::String => {
528            let min = spec.min_length.unwrap_or(0);
529            let max = spec.max_length.unwrap_or(32).max(min).min(1024);
530            let len = min + (rng.next_u64() as usize % (max - min + 1));
531            Value::String("a".repeat(len))
532        }
533        ValueKind::Integer => {
534            let min = spec.min.unwrap_or(-100);
535            let max = spec.max.unwrap_or(100).max(min);
536            let span = (max as i128 - min as i128 + 1) as u64;
537            json!(min + (rng.next_u64() % span) as i64)
538        }
539        ValueKind::Number => {
540            let min = spec.min.unwrap_or(-100) as f64;
541            let max = (spec.max.unwrap_or(100) as f64).max(min);
542            let unit = rng.next_u64() as f64 / u64::MAX as f64;
543            Number::from_f64(min + (max - min) * unit)
544                .map(Value::Number)
545                .unwrap_or(Value::Null)
546        }
547        ValueKind::Boolean => Value::Bool((rng.next_u64() & 1) == 0),
548        ValueKind::Array => {
549            let min = spec.min_items.unwrap_or(0);
550            let max = spec.max_items.unwrap_or(8).max(min).min(64);
551            let len = min + (rng.next_u64() as usize % (max - min + 1));
552            let item_spec = spec.items.as_deref();
553            Value::Array(
554                (0..len)
555                    .map(|_| {
556                        item_spec
557                            .map(|item| generated_value(item, rng))
558                            .unwrap_or(Value::Null)
559                    })
560                    .collect(),
561            )
562        }
563    }
564}
565
566#[cfg(test)]
567#[allow(
568    clippy::expect_used,
569    clippy::unwrap_used,
570    clippy::panic,
571    clippy::unwrap_in_result
572)]
573mod tests {
574    use super::*;
575    use crate::property::dsl::parse;
576
577    fn evaluate_yaml(source: &str, input: Value, response: Value) -> Result<()> {
578        let file = parse(source).unwrap();
579        evaluate(&file.invariants[0], input, response)
580    }
581
582    #[test]
583    fn explicit_path_operand_works() {
584        let source = r#"
585version: 2
586invariants:
587  - name: t
588    tool: x
589    fixed: {}
590    assert:
591      - kind: equals
592        lhs: { path: "$.response.x" }
593        rhs: { value: 42 }
594"#;
595        evaluate_yaml(source, json!({}), json!({"x": 42})).unwrap();
596        assert!(evaluate_yaml(source, json!({}), json!({"x": 41})).is_err());
597    }
598
599    #[test]
600    fn at_least_uses_integer_comparison_beyond_f64_mantissa() {
601        // 9_007_199_254_740_993 == 2^53 + 1, the first integer that loses
602        // precision when round-tripped through f64. With the previous
603        // f64-only path, `>= 9007199254740993` accepted 9007199254740992,
604        // which is silently wrong.
605        let source = r#"
606version: 2
607invariants:
608  - name: precision
609    tool: x
610    fixed: {}
611    assert:
612      - kind: at_least
613        path: "$.response.n"
614        value: { value: 9007199254740993 }
615"#;
616        // Equal: must pass.
617        evaluate_yaml(source, json!({}), json!({"n": 9_007_199_254_740_993_i64})).unwrap();
618        // One below: must fail (used to silently pass via f64 rounding).
619        let err =
620            evaluate_yaml(source, json!({}), json!({"n": 9_007_199_254_740_992_i64})).unwrap_err();
621        assert!(matches!(err, RunnerError::Assertion(_)));
622    }
623
624    #[test]
625    fn legacy_string_operand_still_works() {
626        let source = r#"
627version: 1
628invariants:
629  - name: t
630    tool: x
631    fixed: {}
632    assert:
633      - kind: equals
634        lhs: "$.response.x"
635        rhs: "$.input.expected"
636"#;
637        evaluate_yaml(source, json!({"expected": 7}), json!({"x": 7})).unwrap();
638    }
639
640    #[test]
641    fn all_of_combines_assertions() {
642        let source = r#"
643version: 2
644invariants:
645  - name: t
646    tool: x
647    fixed: {}
648    assert:
649      - kind: all_of
650        assert:
651          - kind: equals
652            lhs: { path: "$.response.a" }
653            rhs: { value: 1 }
654          - kind: at_most
655            path: "$.response.b"
656            value: { value: 5 }
657"#;
658        evaluate_yaml(source, json!({}), json!({"a": 1, "b": 4})).unwrap();
659        let err = evaluate_yaml(source, json!({}), json!({"a": 1, "b": 99})).unwrap_err();
660        assert!(matches!(err, RunnerError::Assertion(_)));
661    }
662
663    #[test]
664    fn any_of_succeeds_when_one_branch_passes() {
665        let source = r#"
666version: 2
667invariants:
668  - name: t
669    tool: x
670    fixed: {}
671    assert:
672      - kind: any_of
673        assert:
674          - kind: equals
675            lhs: { path: "$.response.a" }
676            rhs: { value: 1 }
677          - kind: equals
678            lhs: { path: "$.response.a" }
679            rhs: { value: 2 }
680"#;
681        evaluate_yaml(source, json!({}), json!({"a": 2})).unwrap();
682        let err = evaluate_yaml(source, json!({}), json!({"a": 9})).unwrap_err();
683        assert!(matches!(err, RunnerError::Assertion(message) if message.contains("any_of")));
684    }
685
686    #[test]
687    fn not_inverts_assertion_outcome() {
688        let source = r#"
689version: 2
690invariants:
691  - name: t
692    tool: x
693    fixed: {}
694    assert:
695      - kind: not
696        assertion:
697          kind: equals
698          lhs: { path: "$.response.a" }
699          rhs: { value: 0 }
700"#;
701        evaluate_yaml(source, json!({}), json!({"a": 5})).unwrap();
702        let err = evaluate_yaml(source, json!({}), json!({"a": 0})).unwrap_err();
703        assert!(matches!(err, RunnerError::Assertion(_)));
704    }
705
706    #[test]
707    fn for_each_visits_every_node() {
708        let source = r#"
709version: 2
710invariants:
711  - name: t
712    tool: x
713    fixed: {}
714    assert:
715      - kind: for_each
716        path: "$.response.items[*]"
717        assert:
718          - kind: at_least
719            path: "$.item.score"
720            value: { value: 0 }
721"#;
722        evaluate_yaml(
723            source,
724            json!({}),
725            json!({"items": [{"score": 1}, {"score": 5}]}),
726        )
727        .unwrap();
728        let err = evaluate_yaml(
729            source,
730            json!({}),
731            json!({"items": [{"score": 1}, {"score": -3}]}),
732        )
733        .unwrap_err();
734        assert!(matches!(err, RunnerError::Assertion(message) if message.contains("for_each at")));
735    }
736
737    #[test]
738    fn matches_schema_validates_inline_schema() {
739        let source = r#"
740version: 2
741invariants:
742  - name: t
743    tool: x
744    fixed: {}
745    assert:
746      - kind: matches_schema
747        path: "$.response.user"
748        schema:
749          type: object
750          required: [name]
751          properties:
752            name: { type: string }
753            age: { type: integer, minimum: 0 }
754"#;
755        evaluate_yaml(
756            source,
757            json!({}),
758            json!({"user": {"name": "alice", "age": 30}}),
759        )
760        .unwrap();
761        let err = evaluate_yaml(source, json!({}), json!({"user": {"age": -1}})).unwrap_err();
762        assert!(matches!(err, RunnerError::Assertion(message) if message.contains("schema")));
763    }
764}