fix: v1 behaviour hardening (#502)

This commit is contained in:
stefan-gorules
2026-08-07 10:30:43 +02:00
committed by GitHub
parent fe43b4c1d7
commit ed0c286d9f
6 changed files with 179 additions and 8 deletions
+1
View File
@@ -45,6 +45,7 @@ impl fmt::Display for CompileFailure {
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum EvaluationError {
#[error("Loader error")]
LoaderError(LoaderError),
-2
View File
@@ -118,8 +118,6 @@
//! }
//! ```
// `deny` rather than `forbid` so `nodes::variable_json` can opt in: reading a
// `RefCell` without a guard is the only way to hand jsonschema `'a` borrows.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used)]
#![allow(clippy::module_inception)]
+8 -4
View File
@@ -99,10 +99,14 @@ impl JsonNumber for VariableNumber {
}
fn to_number(&self) -> Cow<'_, serde_json::Number> {
let number = self
.0
.to_f64()
.and_then(serde_json::Number::from_f64)
let normalized = self.0.normalize().to_string();
#[cfg(feature = "arbitrary_precision")]
let number = serde_json::Number::from_string_unchecked(normalized);
#[cfg(not(feature = "arbitrary_precision"))]
let number = normalized
.parse()
.ok()
.or_else(|| self.0.to_f64().and_then(serde_json::Number::from_f64))
.unwrap_or_else(|| serde_json::Number::from(0));
Cow::Owned(number)
}
+163 -1
View File
@@ -3,7 +3,9 @@ use serde_json::json;
use std::ops::Deref;
use std::sync::Arc;
use tokio::runtime::Builder;
use zen_engine::{Decision, DecisionGraphValidationError, EvaluationError, EvaluationOptions};
use zen_engine::{
Decision, DecisionGraphValidationError, EvaluationError, EvaluationOptions, Variable,
};
mod support;
@@ -244,3 +246,163 @@ async fn decision_table_missing_cell_key_is_treated_as_empty() {
.unwrap();
assert_eq!(traced.result, result.result);
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
async fn node_handlers_do_not_mutate_aliased_inputs() {
let content = serde_json::from_value(json!({
"nodes": [
{ "id": "in", "name": "in", "type": "inputNode", "content": {} },
{
"id": "e1", "name": "e1", "type": "expressionNode",
"content": {
"passThrough": true,
"expressions": [{ "id": "x1", "key": "first", "value": "a + 1" }]
}
},
{
"id": "sw", "name": "sw", "type": "switchNode",
"content": {
"hitPolicy": "first",
"statements": [{ "id": "s1", "condition": "" }]
}
},
{
"id": "e2", "name": "e2", "type": "expressionNode",
"content": {
"passThrough": true,
"expressions": [{ "id": "x2", "key": "second", "value": "first + 1" }]
}
},
{
"id": "dt", "name": "dt", "type": "decisionTableNode",
"content": {
"passThrough": true,
"hitPolicy": "first",
"inputs": [{ "id": "i1", "name": "A", "field": "a" }],
"outputs": [{ "id": "o1", "name": "Result", "field": "dtResult" }],
"rules": [{ "_id": "r1", "i1": "", "o1": "'hit'" }]
}
},
{ "id": "out", "name": "out", "type": "outputNode", "content": {} }
],
"edges": [
{ "id": "ed1", "sourceId": "in", "targetId": "e1" },
{ "id": "ed2", "sourceId": "e1", "targetId": "sw" },
{ "id": "ed3", "sourceId": "sw", "targetId": "e2", "sourceHandle": "s1" },
{ "id": "ed4", "sourceId": "e2", "targetId": "dt" },
{ "id": "ed5", "sourceId": "dt", "targetId": "out" }
]
}))
.unwrap();
let decision = Decision::from(Arc::new(content));
let input: Variable = json!({ "a": 1, "nested": { "k": "v" } }).into();
let snapshot = input.to_value();
let response = decision
.evaluate_with_opts(
input.clone(),
EvaluationOptions {
trace: true,
..Default::default()
},
)
.await
.unwrap();
assert_eq!(input.to_value(), snapshot);
assert_eq!(
response.result,
json!({ "a": 1, "nested": { "k": "v" }, "first": 2, "second": 3, "dtResult": "hit" })
.into()
);
let trace = response.trace.unwrap().into_graph().unwrap();
let output_of = |id: &str| trace.get(id).unwrap().output.clone();
assert_eq!(
output_of("in"),
json!({ "a": 1, "nested": { "k": "v" } }).into()
);
assert_eq!(
output_of("e1"),
json!({ "a": 1, "nested": { "k": "v" }, "first": 2 }).into()
);
assert_eq!(
output_of("sw"),
json!({ "a": 1, "nested": { "k": "v" }, "first": 2 }).into()
);
assert_eq!(
output_of("e2"),
json!({ "a": 1, "nested": { "k": "v" }, "first": 2, "second": 3 }).into()
);
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
async fn merged_inputs_do_not_mutate_parent_node_outputs() {
let content = serde_json::from_value(json!({
"nodes": [
{ "id": "in", "name": "in", "type": "inputNode", "content": {} },
{
"id": "pa", "name": "pa", "type": "expressionNode",
"content": {
"passThrough": true,
"expressions": [{ "id": "x1", "key": "pa", "value": "10" }]
}
},
{
"id": "pb", "name": "pb", "type": "expressionNode",
"content": {
"passThrough": true,
"expressions": [{ "id": "x2", "key": "pb", "value": "20" }]
}
},
{
"id": "join", "name": "join", "type": "expressionNode",
"content": {
"passThrough": true,
"expressions": [{ "id": "x3", "key": "sum", "value": "pa + pb" }]
}
},
{ "id": "out", "name": "out", "type": "outputNode", "content": {} }
],
"edges": [
{ "id": "ed1", "sourceId": "in", "targetId": "pa" },
{ "id": "ed2", "sourceId": "in", "targetId": "pb" },
{ "id": "ed3", "sourceId": "pa", "targetId": "join" },
{ "id": "ed4", "sourceId": "pb", "targetId": "join" },
{ "id": "ed5", "sourceId": "join", "targetId": "out" }
]
}))
.unwrap();
let decision = Decision::from(Arc::new(content));
let input: Variable = json!({ "a": 1 }).into();
let snapshot = input.to_value();
let response = decision
.evaluate_with_opts(
input.clone(),
EvaluationOptions {
trace: true,
..Default::default()
},
)
.await
.unwrap();
assert_eq!(input.to_value(), snapshot);
assert_eq!(
response.result,
json!({ "a": 1, "pa": 10, "pb": 20, "sum": 30 }).into()
);
let trace = response.trace.unwrap().into_graph().unwrap();
let output_of = |id: &str| trace.get(id).unwrap().output.clone();
assert_eq!(output_of("in"), json!({ "a": 1 }).into());
assert_eq!(output_of("pa"), json!({ "a": 1, "pa": 10 }).into());
assert_eq!(output_of("pb"), json!({ "a": 1, "pb": 20 }).into());
}
+1 -1
View File
@@ -476,7 +476,7 @@ pub(crate) mod imp {
let a = args.array(0)?;
let arr = a.borrow();
let Some(first) = arr.first() else {
let Some(first) = arr.iter().find(|item| !matches!(item, V::Null)) else {
return Ok(V::empty_object());
};
+6
View File
@@ -161,6 +161,11 @@ flatMap([[1, 2], [3, 4], [5, 6]], #);; [1, 2, 3, 4, 5, 6]
keys([10, 11, 12]);;[0, 1, 2]
merge([[1, 2], [3, 4], [5]]);;[1, 2, 3, 4, 5]
merge([[1, 2], [], [3]]);;[1, 2, 3]
merge([null, [1, 2], [3]]);;[1, 2, 3]
merge([[1, 2], null, [3]]);;[1, 2, 3]
merge([null, {"a": 1}, {"b": 2}]);;{"a": 1, "b": 2}
merge([null]);;{}
merge([null, null]);;{}
merge([]);;{}
merge([{"a": 1}, {"b": 2}, {"c": 3}]);;{"a": 1, "b": 2, "c": 3}
merge([{"a": 1, "b": 2}, {"b": 3, "c": 4}]);;{"a": 1, "b": 3, "c": 4}
@@ -170,6 +175,7 @@ mergeDeep([{"a": {"x": 1}}, {"a": {"y": 2}}]);;{"a": {"x": 1, "y": 2}}
mergeDeep([{"a": [1, 2]}, {"a": [3, 4]}]);;{"a": [1, 2, 3, 4]}
mergeDeep([{"a": {"b": {"c": 1}}}, {"a": {"b": {"d": 2}}}]);;{"a": {"b": {"c": 1, "d": 2}}}
mergeDeep([{"a": 1}, {"a": 2}]);;{"a": 2}
mergeDeep([null, {"a": 1}]);;{"a": 1}
mergeDeep([]);;{}
# Dates
Can't render this file because it contains an unexpected character in line 14 and column 13.