mirror of
https://github.com/gorules/zen.git
synced 2026-10-04 16:02:18 +00:00
* fix: scope policy evaluation to the import closure A policy is now analysed and evaluated together with the policies it imports (transitively), instead of every policy connected to it by imports in either direction. Importers and siblings no longer leak into each other: compiled evaluate() of an imported policy used to run its importers' and siblings' blocks. - Evaluation gate analyses the entry's import closure as one unit, so a child may read fields and reference entities its importer declares when evaluated through that importer - compile() reports a policy that is only valid through an importer with kind "policyImportOnly" instead of failing it as a plain "policy"; errors are computed once per key - Scope diagnostics (duplicate writers, input overrides, data model and dictionary collisions, cycles) are reported for the whole closure, and conflicts introduced by combining imports are reported on the importer - Single-entity scope checks run per import closure instead of workspace-wide * fix: unknown import relationship targets and renames across imports - An unresolved relationship/reference target inside an imported policy now blocks the importer's evaluation and compile(), as on master; the check was still limited to the policy being analysed, so the importer compiled and the broken child was reported as "policyImportOnly" - Rename and find-references analyse each policy's expressions in every importer's context too, so reads of a field declared by an importer are found in the imported policy again
600 lines
19 KiB
Rust
600 lines
19 KiB
Rust
use serde_json::json;
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
use zen_engine::loader::{LoaderError, MemoryLoader};
|
|
use zen_engine::model::{DecisionContent, PolicyContent};
|
|
use zen_engine::{
|
|
DecisionEngine, EvaluationError, EvaluationOptions, EvaluationSerializedOptions,
|
|
EvaluationTraceKind,
|
|
};
|
|
|
|
mod support;
|
|
use support::load_test_data;
|
|
|
|
fn simple_policy_json() -> serde_json::Value {
|
|
json!({
|
|
"blocks": [
|
|
{
|
|
"id": "dm-customer",
|
|
"type": "dataModel",
|
|
"props": {
|
|
"data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
}
|
|
},
|
|
"children": []
|
|
},
|
|
{
|
|
"id": "assert1",
|
|
"type": "assertion",
|
|
"props": {
|
|
"data": {
|
|
"output": "customer.isAdult",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
|
|
]
|
|
}
|
|
},
|
|
"children": []
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
fn importing_policy_json(import_path: &str, output: &str) -> serde_json::Value {
|
|
json!({
|
|
"imports": [import_path],
|
|
"blocks": [
|
|
{
|
|
"id": format!("assert-{output}"),
|
|
"type": "assertion",
|
|
"props": {
|
|
"data": {
|
|
"output": format!("customer.{output}"),
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
|
|
]
|
|
}
|
|
},
|
|
"children": []
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
fn make_policy_content(json: serde_json::Value) -> DecisionContent {
|
|
let policy: zen_engine::policy::PolicyDocument =
|
|
serde_json::from_value(json).expect("valid policy fixture");
|
|
DecisionContent::Policy(PolicyContent(Arc::new(policy)))
|
|
}
|
|
|
|
fn engine_with(loader: Arc<MemoryLoader>) -> DecisionEngine {
|
|
DecisionEngine::default().with_loader(loader)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_single_policy() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("policy", make_policy_content(simple_policy_json()));
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate("policy", json!({ "customer": { "age": 30 } }).into())
|
|
.await
|
|
.expect("evaluate ok");
|
|
|
|
let result_json: serde_json::Value = result.result.into();
|
|
assert_eq!(
|
|
result_json.pointer("/customer/isAdult"),
|
|
Some(&json!(true)),
|
|
"expected customer.isAdult=true, got {result_json:#?}",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_with_single_import() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("base", make_policy_content(simple_policy_json()));
|
|
loader.add(
|
|
"entry",
|
|
make_policy_content(importing_policy_json("base", "fromEntry")),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate("entry", json!({ "customer": { "age": 70 } }).into())
|
|
.await
|
|
.expect("evaluate ok");
|
|
|
|
let result_json: serde_json::Value = result.result.into();
|
|
assert_eq!(result_json.pointer("/customer/isAdult"), Some(&json!(true)));
|
|
assert_eq!(
|
|
result_json.pointer("/customer/fromEntry"),
|
|
Some(&json!(true))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_chained_imports() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("base", make_policy_content(simple_policy_json()));
|
|
loader.add(
|
|
"middle",
|
|
make_policy_content(importing_policy_json("base", "fromMiddle")),
|
|
);
|
|
loader.add(
|
|
"entry",
|
|
make_policy_content(importing_policy_json("middle", "fromEntry")),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate("entry", json!({ "customer": { "age": 80 } }).into())
|
|
.await
|
|
.expect("evaluate ok");
|
|
|
|
let result_json: serde_json::Value = result.result.into();
|
|
assert_eq!(result_json.pointer("/customer/isAdult"), Some(&json!(true)));
|
|
assert_eq!(
|
|
result_json.pointer("/customer/fromMiddle"),
|
|
Some(&json!(true))
|
|
);
|
|
assert_eq!(
|
|
result_json.pointer("/customer/fromEntry"),
|
|
Some(&json!(true))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cycle_terminates() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("a", make_policy_content(importing_policy_json("b", "aOut")));
|
|
loader.add("b", make_policy_content(importing_policy_json("a", "bOut")));
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate("a", json!({ "customer": { "age": 25 } }).into())
|
|
.await;
|
|
let _ = result;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn self_import_terminates() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add(
|
|
"a",
|
|
make_policy_content(importing_policy_json("a", "selfOut")),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate("a", json!({ "customer": { "age": 25 } }).into())
|
|
.await;
|
|
let _ = result;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn policy_importing_graph_fails() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add(
|
|
"entry",
|
|
make_policy_content(importing_policy_json("g", "fromEntry")),
|
|
);
|
|
loader.add("g", load_test_data("table.json"));
|
|
let engine = engine_with(loader);
|
|
|
|
let err = engine
|
|
.evaluate("entry", json!({ "customer": { "age": 25 } }).into())
|
|
.await
|
|
.expect_err("policy importing a graph should fail");
|
|
|
|
match *err {
|
|
EvaluationError::ContentKindMismatch { expected, got, .. } => {
|
|
assert_eq!(expected, "policy");
|
|
assert_eq!(got, "graph");
|
|
}
|
|
other => panic!("expected ContentKindMismatch, got: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_import_is_loader_error() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add(
|
|
"entry",
|
|
make_policy_content(importing_policy_json("does-not-exist", "fromEntry")),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let err = engine
|
|
.evaluate("entry", json!({ "customer": { "age": 25 } }).into())
|
|
.await
|
|
.expect_err("missing import should surface loader error");
|
|
|
|
assert!(
|
|
matches!(*err, EvaluationError::LoaderError(_)),
|
|
"expected LoaderError, got: {err:?}"
|
|
);
|
|
}
|
|
|
|
fn assert_string_trace(value: serde_json::Value) {
|
|
let trace = value.pointer("/trace").expect("trace key present");
|
|
let trace_str = trace.as_str().expect("trace must serialize as a string");
|
|
let parsed: serde_json::Value = serde_json::from_str(trace_str).expect("trace string is JSON");
|
|
assert!(parsed.pointer("/properties").is_some());
|
|
assert!(parsed.pointer("/executions").is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn serialized_string_trace_for_policies() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("policy", make_policy_content(simple_policy_json()));
|
|
let engine = engine_with(loader);
|
|
|
|
let options = EvaluationSerializedOptions {
|
|
trace: EvaluationTraceKind::String,
|
|
..Default::default()
|
|
};
|
|
let lazy = engine
|
|
.evaluate_serialized(
|
|
"policy",
|
|
json!({ "customer": { "age": 30 } }).into(),
|
|
options,
|
|
)
|
|
.await
|
|
.expect("evaluate ok");
|
|
assert_string_trace(lazy);
|
|
|
|
engine.compile();
|
|
let precompiled = engine
|
|
.evaluate_serialized(
|
|
"policy",
|
|
json!({ "customer": { "age": 30 } }).into(),
|
|
options,
|
|
)
|
|
.await
|
|
.expect("evaluate ok");
|
|
assert_string_trace(precompiled);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn diamond_imports_load_each_key_once() {
|
|
let mut contents: HashMap<String, Arc<DecisionContent>> = HashMap::new();
|
|
contents.insert(
|
|
"entry".into(),
|
|
Arc::new(make_policy_content(json!({
|
|
"imports": ["a", "b"],
|
|
"blocks": [
|
|
{
|
|
"id": "assert-entry",
|
|
"type": "assertion",
|
|
"props": {
|
|
"data": {
|
|
"output": "customer.fromEntry",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
|
|
]
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}))),
|
|
);
|
|
contents.insert(
|
|
"a".into(),
|
|
Arc::new(make_policy_content(importing_policy_json("base", "fromA"))),
|
|
);
|
|
contents.insert(
|
|
"b".into(),
|
|
Arc::new(make_policy_content(importing_policy_json("base", "fromB"))),
|
|
);
|
|
contents.insert(
|
|
"base".into(),
|
|
Arc::new(make_policy_content(simple_policy_json())),
|
|
);
|
|
|
|
let counts: Arc<Mutex<HashMap<String, usize>>> = Arc::new(Mutex::new(HashMap::new()));
|
|
let counts_in_loader = counts.clone();
|
|
let engine = DecisionEngine::default().with_closure_loader(move |key| {
|
|
let counts = counts_in_loader.clone();
|
|
let content = contents.get(&key).cloned();
|
|
async move {
|
|
*counts.lock().unwrap().entry(key.clone()).or_insert(0) += 1;
|
|
content.ok_or_else(|| LoaderError::NotFound(key))
|
|
}
|
|
});
|
|
|
|
engine
|
|
.evaluate("entry", json!({ "customer": { "age": 30 } }).into())
|
|
.await
|
|
.expect("evaluate ok");
|
|
|
|
let counts = counts.lock().unwrap();
|
|
for key in ["entry", "a", "b", "base"] {
|
|
assert_eq!(
|
|
counts.get(key),
|
|
Some(&1),
|
|
"key {key} should load exactly once, got {counts:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn trace_serializes_for_policies() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add("policy", make_policy_content(simple_policy_json()));
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate_with_opts(
|
|
"policy",
|
|
json!({ "customer": { "age": 30 } }).into(),
|
|
EvaluationOptions {
|
|
trace: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("evaluate ok");
|
|
|
|
assert!(result.trace.is_some(), "trace requested but None returned");
|
|
let serialized = serde_json::to_value(&result).expect("serialize ok");
|
|
let trace = serialized.pointer("/trace").expect("trace key present");
|
|
assert!(
|
|
trace.pointer("/properties").is_some(),
|
|
"policy trace should serialize with `properties` field"
|
|
);
|
|
assert!(
|
|
trace.pointer("/executions").is_some(),
|
|
"policy trace should serialize with `executions` field"
|
|
);
|
|
}
|
|
|
|
fn parent_declares_child_reads(child_condition: &str) -> Arc<MemoryLoader> {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add(
|
|
"main",
|
|
make_policy_content(json!({
|
|
"imports": ["shared"],
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "name", "type": "string", "array": false, "optional": false },
|
|
{ "id": "p2", "name": "age", "type": "number", "array": false, "optional": false }
|
|
]
|
|
} }, "children": [] },
|
|
{ "id": "s1", "type": "expression", "props": { "data": {
|
|
"key": "customer.discount", "value": "customer.tier"
|
|
} } }
|
|
]
|
|
})),
|
|
);
|
|
loader.add(
|
|
"shared",
|
|
make_policy_content(json!({
|
|
"blocks": [
|
|
{ "id": "m", "type": "match", "props": { "data": {
|
|
"key": "customer.tier",
|
|
"arms": [
|
|
{ "id": "a1", "condition": child_condition, "value": "\"gold\"" },
|
|
{ "id": "a2", "condition": "", "value": "\"silver\"" }
|
|
]
|
|
} } }
|
|
]
|
|
})),
|
|
);
|
|
loader
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_child_reading_parent_declared_fields() {
|
|
let engine = engine_with(parent_declares_child_reads("customer.age > 50"));
|
|
|
|
let result = engine
|
|
.evaluate(
|
|
"main",
|
|
json!({ "customer": { "name": "Ann", "age": 60 } }).into(),
|
|
)
|
|
.await
|
|
.expect("evaluate ok");
|
|
|
|
let result_json: serde_json::Value = result.result.into();
|
|
assert_eq!(result_json.pointer("/customer/tier"), Some(&json!("gold")));
|
|
assert_eq!(
|
|
result_json.pointer("/customer/discount"),
|
|
Some(&json!("gold"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_child_with_real_error_is_refused() {
|
|
let engine = engine_with(parent_declares_child_reads("customer.missing > 50"));
|
|
|
|
let result = engine
|
|
.evaluate(
|
|
"main",
|
|
json!({ "customer": { "name": "Ann", "age": 60 } }).into(),
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
format!("{result:?}").contains("CompilationErrors"),
|
|
"expected compilation error, got {result:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_refuses_child_block_mixed_under_parent_data_model() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
let data_model = |id: &str, name: &str| {
|
|
json!({ "id": id, "type": "dataModel", "props": { "data": {
|
|
"name": name,
|
|
"properties": [
|
|
{ "id": format!("{id}-p"), "name": "name", "type": "string", "array": false, "optional": false }
|
|
]
|
|
} }, "children": [] })
|
|
};
|
|
loader.add(
|
|
"main",
|
|
make_policy_content(json!({
|
|
"imports": ["child"],
|
|
"blocks": [data_model("dm-customer", "customer"), data_model("dm-company", "company")]
|
|
})),
|
|
);
|
|
loader.add(
|
|
"child",
|
|
make_policy_content(json!({
|
|
"blocks": [
|
|
{ "id": "bad-block", "type": "decisionTable", "props": { "data": {
|
|
"hitPolicy": "first",
|
|
"inputs": [],
|
|
"outputs": [
|
|
{ "id": "s1", "name": "", "field": "customer.label" },
|
|
{ "id": "s2", "name": "", "field": "company.label" }
|
|
],
|
|
"rules": [{ "_id": "r1", "s1": "\"x\"", "s2": "\"y\"" }]
|
|
} } }
|
|
]
|
|
})),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate(
|
|
"main",
|
|
json!({ "customer": { "name": "a" }, "company": { "name": "b" } }).into(),
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
format!("{result:?}").contains("CompilationErrors"),
|
|
"expected compilation error, got {result:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_refuses_child_unreachable_read_under_parent_data_model() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add(
|
|
"main",
|
|
make_policy_content(json!({
|
|
"imports": ["child"],
|
|
"blocks": [
|
|
{ "id": "dm-customer", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
} } },
|
|
{ "id": "dm-company", "type": "dataModel", "props": { "data": {
|
|
"name": "company",
|
|
"properties": [
|
|
{ "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false }
|
|
]
|
|
} } }
|
|
]
|
|
})),
|
|
);
|
|
loader.add(
|
|
"child",
|
|
make_policy_content(json!({
|
|
"blocks": [
|
|
{ "id": "assert", "type": "assertion", "props": { "data": {
|
|
"output": "globalFlag",
|
|
"conditions": [
|
|
{ "id": "c1", "expression": "company.revenue > 0", "operator": "and", "depth": 0 }
|
|
]
|
|
} } }
|
|
]
|
|
})),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine
|
|
.evaluate(
|
|
"main",
|
|
json!({ "customer": { "companies": [{ "revenue": 5 }] } }).into(),
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
format!("{result:?}").contains("CompilationErrors"),
|
|
"expected compilation error, got {result:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compile_accepts_child_reading_parent_declared_fields() {
|
|
let engine = engine_with(parent_declares_child_reads("customer.age > 50"));
|
|
let failures = engine.compile();
|
|
let reported: Vec<(&str, &str)> = failures.iter().map(|f| (f.key.as_ref(), f.kind)).collect();
|
|
assert_eq!(
|
|
reported,
|
|
vec![("shared", "policyImportOnly")],
|
|
"{failures:#?}"
|
|
);
|
|
|
|
let result = engine
|
|
.evaluate(
|
|
"main",
|
|
json!({ "customer": { "name": "Ann", "age": 60 } }).into(),
|
|
)
|
|
.await
|
|
.expect("evaluate ok");
|
|
let result_json: serde_json::Value = result.result.into();
|
|
assert_eq!(result_json.pointer("/customer/tier"), Some(&json!("gold")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compile_reports_child_with_real_error() {
|
|
let engine = engine_with(parent_declares_child_reads("customer.missing > 50"));
|
|
let failures = engine.compile();
|
|
let keys: Vec<&str> = failures.iter().map(|f| f.key.as_ref()).collect();
|
|
assert!(keys.contains(&"main"), "{failures:#?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_relationship_target_in_import_refuses_importer() {
|
|
let loader = Arc::new(MemoryLoader::default());
|
|
loader.add(
|
|
"main",
|
|
make_policy_content(json!({
|
|
"imports": ["child"],
|
|
"blocks": [
|
|
{ "id": "s1", "type": "expression", "props": { "data": {
|
|
"key": "flag", "value": "true"
|
|
} } }
|
|
]
|
|
})),
|
|
);
|
|
loader.add(
|
|
"child",
|
|
make_policy_content(json!({
|
|
"blocks": [
|
|
{ "id": "dm", "type": "dataModel", "props": { "data": {
|
|
"name": "customer",
|
|
"properties": [
|
|
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
|
|
]
|
|
} }, "children": [] }
|
|
]
|
|
})),
|
|
);
|
|
let engine = engine_with(loader);
|
|
|
|
let result = engine.evaluate("main", json!({}).into()).await;
|
|
assert!(
|
|
format!("{result:?}").contains("CompilationErrors"),
|
|
"expected compilation error, got {result:?}"
|
|
);
|
|
|
|
let failures = engine.compile();
|
|
let reported: Vec<(&str, &str)> = failures.iter().map(|f| (f.key.as_ref(), f.kind)).collect();
|
|
assert!(reported.contains(&("main", "policy")), "{failures:#?}");
|
|
assert!(reported.contains(&("child", "policy")), "{failures:#?}");
|
|
}
|