fix: policy sibling issues

This commit is contained in:
Ivan Miletic
2026-09-22 14:48:02 +02:00
parent 6ed668072b
commit 268c5c7670
13 changed files with 464 additions and 126 deletions
@@ -5,7 +5,7 @@ use fixedbitset::FixedBitSet;
use serde::{Deserialize, Serialize};
use zen_expression::intellisense::{ArmTest, IntelliSense, NumberCover};
use zen_expression::variable::{Variable, VariableType};
use zen_expression::Isolate;
use zen_expression::{Isolate, IsolateError};
use zen_types::decision::{
DecisionTableHitPolicy, DecisionTableInputField, DecisionTableOutputField,
};
@@ -1212,7 +1212,26 @@ impl DecisionTableIr {
.map_err(|e| cx.expression_error(field, e))?;
isolate
.run_unary(cell)
.map_err(|e| cx.expression_error(cell, e))
.map_err(|source| {
let value_type = col_refs[col_idx]
.as_ref()
.map(Variable::type_name)
.unwrap_or("unknown");
let value_description = if value_type == "null" {
"missing or null"
} else {
value_type
};
cx.expression_error(
cell,
IsolateError::ContextError {
context: format!(
"Cannot evaluate condition {cell:?} for input {field:?} ({value_description})"
),
source: Box::new(source),
},
)
})
}
_ => {
let result = isolate
@@ -23,10 +23,8 @@ use crate::workspace::types::{BlockRef, Diagnostic, DiagnosticCode, DiagnosticLo
#[derive(Debug)]
pub struct ShallowAnalyses {
pub per_rule: Vec<RuleShallowAnalysis>,
pub diagnostics: Vec<Diagnostic>,
by_block: HashMap<BlockRef, usize>,
rules_by_path: HashMap<Arc<str>, std::ops::Range<usize>>,
diags_by_path: HashMap<Arc<str>, std::ops::Range<usize>>,
}
impl ShallowAnalyses {
@@ -42,13 +40,6 @@ impl ShallowAnalyses {
.map(|r| &self.per_rule[r.clone()])
.unwrap_or(&[])
}
pub fn diags_for(&self, path: &Arc<str>) -> &[Diagnostic] {
self.diags_by_path
.get(path)
.map(|r| &self.diagnostics[r.clone()])
.unwrap_or(&[])
}
}
#[derive(Debug, Clone)]
@@ -372,25 +363,17 @@ impl Snapshot {
pub(crate) fn compute_shallow(
base_scope: &VariableType,
all_parsed: &HashMap<Arc<str>, Arc<ParsedPolicy>>,
classifier: &PathClassifier,
intellisense: &SharedIntelliSense,
cache: &PolicyDerivedCache,
) -> ShallowAnalyses {
let mut per_rule: Vec<RuleShallowAnalysis> = Vec::new();
let mut diagnostics: Vec<Diagnostic> = Vec::new();
let mut rules_by_path: HashMap<Arc<str>, std::ops::Range<usize>> = HashMap::new();
let mut diags_by_path: HashMap<Arc<str>, std::ops::Range<usize>> = HashMap::new();
let mut sorted_paths: Vec<&Arc<str>> = all_parsed.keys().collect();
sorted_paths.sort();
for path in sorted_paths {
let p = &all_parsed[path];
let rules_start = per_rule.len();
let diags_start = diagnostics.len();
for rule in p.policy.rules() {
rule.check_single_entity_scope(path, classifier, &mut diagnostics);
}
let no_dictionaries: SharedDictionaryTypes = Rc::new(ahash::HashMap::default());
let no_poison: SharedPoisonedPaths = Default::default();
@@ -420,7 +403,6 @@ impl Snapshot {
});
per_rule.extend(policy_shallow.iter().cloned());
rules_by_path.insert(path.clone(), rules_start..per_rule.len());
diags_by_path.insert(path.clone(), diags_start..diagnostics.len());
}
let by_block = per_rule
@@ -439,10 +421,8 @@ impl Snapshot {
ShallowAnalyses {
per_rule,
diagnostics,
by_block,
rules_by_path,
diags_by_path,
}
}
+40 -38
View File
@@ -17,10 +17,28 @@ impl Db {
out.extend(parsed.diagnostics.iter().cloned());
}
let shallow = self.shallow();
out.extend(shallow.diags_for(path).iter().cloned());
let unit = self.unit(path);
if let Some(parsed) = self.parsed(path) {
for rule in parsed.policy.rules() {
rule.check_single_entity_scope(path, &unit.classifier, &mut out);
}
}
out.extend(self.graph_diagnostics(path));
// Independently valid imports can conflict when composed. Surface those
// conflicts on the importing entry, even if neither writer is local.
let mut scope_diagnostics = self.graph_diagnostics(path);
scope_diagnostics.extend(self.data_model_diagnostics(path));
scope_diagnostics.extend(self.dictionary_diagnostics(path));
for mut diagnostic in scope_diagnostics {
if !diagnostic.is_in(path) {
diagnostic.message = format!(
"in imported policy '{}': {}",
diagnostic.location.policy_path, diagnostic.message
);
diagnostic.location = DiagnosticLocation::policy(path.clone());
}
out.push(diagnostic);
}
let enriched = self.enriched(path);
out.extend(
@@ -40,10 +58,6 @@ impl Db {
out.extend(self.import_diagnostics(path));
out.extend(self.data_model_diagnostics(path));
out.extend(self.dictionary_diagnostics(path));
out.extend(self.unreachable_reads_diagnostics(path));
out.extend(self.nested_iteration_diagnostics(path));
@@ -178,25 +192,20 @@ impl Db {
.and_then(|b| b.kind.write_target(&write.path));
if let Some(matched) = data_model_paths.matches_prefix(&write.path) {
if in_target {
out.push(Diagnostic::error(
DiagnosticCode::InputOverride,
DiagnosticLocation::block(
rule.policy_path.clone(),
rule.block_id.clone(),
)
out.push(Diagnostic::error(
DiagnosticCode::InputOverride,
DiagnosticLocation::block(rule.policy_path.clone(), rule.block_id.clone())
.maybe_target(wtarget.clone()),
format!(
"cannot write to '{}': '{}' is defined as a DataModel input",
write.path, matched
),
));
}
format!(
"cannot write to '{}': '{}' is defined as a DataModel input",
write.path, matched
),
));
continue;
}
match first_writer.get(&write.path) {
Some(existing) if in_target => {
Some(existing) => {
out.push(Diagnostic::error(
DiagnosticCode::DuplicateWriter,
DiagnosticLocation::block(
@@ -214,7 +223,6 @@ impl Db {
),
));
}
Some(_) => {}
None => {
first_writer.insert(write.path.clone(), block_ref.clone());
}
@@ -276,7 +284,11 @@ impl Db {
blocks.push((block_ref, *in_t));
}
}
let Some((owner, _)) = blocks.iter().find(|(_, in_t)| *in_t) else {
let Some((owner, _)) = blocks
.iter()
.find(|(_, in_t)| *in_t)
.or_else(|| blocks.first())
else {
continue;
};
let cross_policy = blocks
@@ -305,12 +317,7 @@ impl Db {
let graph = &unit.dep_graph;
let cyclic = graph.cyclic_paths();
let target_in_cycle = cyclic.iter().any(|path| {
graph
.writer_for(path)
.is_some_and(|owner| owner.policy_path == *target)
});
if target_in_cycle {
if !cyclic.is_empty() {
out.push(Diagnostic::error(
DiagnosticCode::CyclicDependency,
DiagnosticLocation::policy(target.clone()),
@@ -369,7 +376,7 @@ impl Db {
let dm = &entry.ir;
let is_global = dm.scope.is_global();
if !is_global && global_property_names.contains(&dm.name) && policy_path == target {
if !is_global && global_property_names.contains(&dm.name) {
out.push(Diagnostic::error(
DiagnosticCode::DataModelCollision,
DiagnosticLocation::block(policy_path.clone(), block_id.clone()),
@@ -381,7 +388,7 @@ impl Db {
}
for prop in &dm.properties {
if is_global && known_entities.contains(&prop.name) && policy_path == target {
if is_global && known_entities.contains(&prop.name) {
out.push(Diagnostic::error(
DiagnosticCode::DataModelCollision,
DiagnosticLocation::expression(
@@ -408,7 +415,7 @@ impl Db {
let conflicts = !prop.kind.same_shape_as(&prev_kind)
|| prev_array != prop.array
|| prev_optional != prop.optional;
if conflicts && policy_path == target {
if conflicts {
let location = if is_global {
format!("global property '{}'", prop.name)
} else {
@@ -490,8 +497,7 @@ impl Db {
for entry in &unit.dictionary_blocks {
let name = &entry.ir.name;
if let Some((prev_policy, prev_block)) = first_by_name.get(name) {
if entry.policy_path == *target {
out.push(Diagnostic::error(
out.push(Diagnostic::error(
DiagnosticCode::DataModelCollision,
DiagnosticLocation::block(
entry.policy_path.clone(),
@@ -501,7 +507,6 @@ impl Db {
"dictionary '{name}' is already defined in '{prev_policy}' (block '{prev_block}')"
),
));
}
continue;
}
first_by_name.insert(
@@ -509,9 +514,6 @@ impl Db {
(entry.policy_path.clone(), entry.block_id.clone()),
);
if entry.policy_path != *target {
continue;
}
if known_entities.contains(name) {
out.push(Diagnostic::error(
DiagnosticCode::DataModelCollision,
+21 -13
View File
@@ -139,7 +139,7 @@ pub struct Snapshot {
pub(crate) shallow: Arc<ShallowAnalyses>,
pub(crate) components: Vec<Vec<Arc<str>>>,
pub(crate) policy_to_component: HashMap<Arc<str>, usize>,
pub(crate) units: RefCell<HashMap<usize, Arc<Unit>>>,
pub(crate) units: RefCell<HashMap<Arc<str>, Arc<Unit>>>,
pub(crate) policy_diagnostics: RefCell<HashMap<Arc<str>, Arc<Vec<Diagnostic>>>>,
pub(crate) eval_artifacts: RefCell<HashMap<Arc<str>, Arc<EvalArtifact>>>,
pub(crate) path_set: OnceCell<Arc<HashSet<Arc<str>>>>,
@@ -450,23 +450,33 @@ impl Db {
pub fn unit(&self, policy: &str) -> Arc<Unit> {
let snap = self.snapshot();
let Some(&idx) = snap.policy_to_component.get(policy) else {
return self.cache.unit_or_compute(&[], &[], || {
Snapshot::compute_unit(&[], &snap.all_parsed, &snap.shallow)
});
};
if let Some(u) = snap.units.borrow().get(&idx).cloned() {
if let Some(u) = snap.units.borrow().get(policy).cloned() {
return u;
}
let members = &snap.components[idx];
// Connectivity is useful for workspace navigation, but an import only
// makes its dependencies visible. Other importers are separate entries.
let mut seen = HashSet::default();
let mut stack = vec![Arc::<str>::from(policy)];
while let Some(path) = stack.pop() {
let Some(parsed) = snap.all_parsed.get(&path) else {
continue;
};
if seen.insert(path) {
stack.extend(parsed.policy.imports().iter().cloned());
}
}
let mut members: Vec<_> = seen.into_iter().collect();
members.sort();
let parsed: Vec<Arc<ParsedPolicy>> = members
.iter()
.filter_map(|m| snap.all_parsed.get(m).cloned())
.collect();
let unit = self.cache.unit_or_compute(members, &parsed, || {
Snapshot::compute_unit(members, &snap.all_parsed, &snap.shallow)
let unit = self.cache.unit_or_compute(&members, &parsed, || {
Snapshot::compute_unit(&members, &snap.all_parsed, &snap.shallow)
});
snap.units.borrow_mut().insert(idx, unit.clone());
snap.units
.borrow_mut()
.insert(Arc::from(policy), unit.clone());
unit
}
@@ -771,11 +781,9 @@ impl Snapshot {
let entity_sources = Self::compute_entity_sources(&all_parsed);
let base_scope = Self::compute_base_scope(&all_parsed, &entity_sources);
let classifier = Self::compute_path_classifier(&all_parsed);
let shallow = Arc::new(Self::compute_shallow(
&base_scope,
&all_parsed,
&classifier,
intellisense,
cache,
));
@@ -54,9 +54,7 @@ entity = "customer"
no_duplicate_fields = true
# ── Visibility from policy B's perspective ───────────────────────────────────
# v1 visibility was directional (B does not import A → B saw only its own
# fields). v2 units are import components, so A and B share one unit and B
# sees A's fields too, with source attribution preserved.
# Imports are directional: B must not see fields declared by its importer A.
[[test]]
name = "entities(B): has own fields"
@@ -80,17 +78,7 @@ field = "label"
property_kind = "computed"
[[test]]
name = "entities(B): sees age from A via component visibility"
name = "entities(B): importer fields remain hidden"
policy = "merge_policy_b.json"
entity = "customer"
field = "age"
property_kind = "input"
source = "merge_policy_a.json"
[[test]]
name = "entities(B): sees greeting computed by A via component visibility"
policy = "merge_policy_b.json"
entity = "customer"
field = "greeting"
property_kind = "computed"
source = "merge_policy_a.json"
absent_fields = ["age", "greeting"]
@@ -265,7 +265,7 @@ kind = "expression"
[[test]]
name = "cross-policy — eligible high-income → platinum"
policies = ["cross_dep_base.json", "cross_dep_consumer.json"]
policies = ["cross_dep_consumer.json", "cross_dep_base.json"]
input = { customer = { name = "Alice", age = 30, income = 120000 } }
output = { customer = { isEligible = true, tier = "platinum" } }
@@ -279,7 +279,7 @@ matched_rows = [0]
[[test]]
name = "cross-policy — eligible mid-income → gold"
policies = ["cross_dep_base.json", "cross_dep_consumer.json"]
policies = ["cross_dep_consumer.json", "cross_dep_base.json"]
input = { customer = { name = "Bob", age = 25, income = 60000 } }
output = { customer = { isEligible = true, tier = "gold" } }
@@ -293,7 +293,7 @@ matched_rows = [1]
[[test]]
name = "cross-policy — eligible low-income → silver"
policies = ["cross_dep_base.json", "cross_dep_consumer.json"]
policies = ["cross_dep_consumer.json", "cross_dep_base.json"]
input = { customer = { name = "Carol", age = 40, income = 35000 } }
output = { customer = { isEligible = true, tier = "silver" } }
@@ -303,7 +303,7 @@ matched_rows = [2]
[[test]]
name = "cross-policy — ineligible (underage) → rejected"
policies = ["cross_dep_base.json", "cross_dep_consumer.json"]
policies = ["cross_dep_consumer.json", "cross_dep_base.json"]
input = { customer = { name = "Dave", age = 16, income = 50000 } }
output = { customer = { isEligible = false, tier = "rejected" } }
@@ -321,7 +321,7 @@ matched_rows = [3]
[[test]]
name = "cross-policy — ineligible (low income) → rejected"
policies = ["cross_dep_base.json", "cross_dep_consumer.json"]
policies = ["cross_dep_consumer.json", "cross_dep_base.json"]
input = { customer = { name = "Eve", age = 25, income = 20000 } }
output = { customer = { isEligible = false, tier = "rejected" } }
@@ -3,32 +3,6 @@
"multi_shared.json"
],
"blocks": [
{
"id": "dm-customer",
"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",
@@ -38,6 +12,16 @@
"value": "customer.tier"
}
}
},
{
"id": "age-read",
"type": "expression",
"props": {
"data": {
"key": "customer.ageCopy",
"value": "customer.age"
}
}
}
]
}
@@ -1,5 +1,31 @@
{
"blocks": [
{
"id": "dm-customer",
"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": "tree1",
"type": "match",
@@ -14,7 +14,7 @@ edits = [
]
# customer.age IS referenced in shared policy arm condition "customer.age > 50"
# and defined in multi_main's dm-customer data model.
# and declared in shared's dm-customer data model, with a read in main.
[[test]]
name = "rename customer.age finds reference in shared policy condition"
entity = "customer"
@@ -22,7 +22,8 @@ field = "age"
new_name = "years"
edits = [
{ policy = "multi_shared.json", block_id = "tree1", expression_id = "b1" },
{ policy = "multi_main.json", block_id = "dm-customer", target_kind = "fieldName" },
{ policy = "multi_shared.json", block_id = "dm-customer", target_kind = "fieldName" },
{ policy = "multi_main.json", block_id = "age-read", expression_id = "age-read" },
]
[[test]]
+268
View File
@@ -0,0 +1,268 @@
use std::sync::Arc;
use serde_json::{json, Value};
use zen_engine::loader::MemoryLoader;
use zen_engine::policy::{EvaluateRequest, PolicyWorkspace, ScopeRequest, Severity};
use zen_engine::DecisionEngine;
fn expression(key: &str, value: &str) -> Value {
json!({"id": key, "type": "expression", "props": {"data": {"key": key, "value": value}}})
}
fn dictionary(name: &str, value: &str) -> Value {
json!({"id": name, "type": "dictionary", "props": {"data": {
"name": name, "entries": [{"value": value, "label": value}]
}}})
}
fn policy(imports: &[&str], blocks: Vec<Value>) -> Value {
json!({"contentType": "policy", "imports": imports, "blocks": blocks})
}
fn siblings(duplicate: bool) -> Vec<(&'static str, Value)> {
vec![
("A", policy(&[], vec![expression("base", "10")])),
(
"B",
policy(
&["A"],
vec![
dictionary("choice", "approve"),
expression("decision", "\"approve\""),
],
),
),
(
"C",
policy(
&["A"],
vec![
dictionary(if duplicate { "choice" } else { "otherChoice" }, "decline"),
expression(if duplicate { "decision" } else { "other" }, "\"decline\""),
],
),
),
]
}
fn workspace(docs: &[(&str, Value)]) -> PolicyWorkspace {
let mut ws = PolicyWorkspace::new();
for (path, doc) in docs {
ws.set_policy(*path, serde_json::from_value(doc.clone()).unwrap());
}
ws
}
fn request(path: &str, input: Value) -> EvaluateRequest {
EvaluateRequest {
policy_path: Arc::from(path),
input: input.into(),
goals: vec![],
trace: true,
}
}
fn output(ws: &PolicyWorkspace, path: &str) -> Value {
serde_json::to_value(ws.evaluate(&request(path, json!({}))).unwrap().output).unwrap()
}
#[test]
fn workspace_and_trace_only_execute_outgoing_imports() {
let ws = workspace(&siblings(false));
for (path, expected, count) in [
("A", json!({"base":10}), 1),
("B", json!({"base":10,"decision":"approve"}), 2),
("C", json!({"base":10,"other":"decline"}), 2),
] {
assert_eq!(output(&ws, path), expected);
let enhanced = ws.enhance_trace(&request(path, json!({}))).unwrap();
assert_eq!(serde_json::to_value(enhanced.output).unwrap(), expected);
assert_eq!(enhanced.trace.unwrap().executions.len(), count);
}
}
#[tokio::test]
async fn compiled_and_lazy_engines_agree_with_sibling_duplicate_names() {
for duplicate in [false, true] {
let docs = siblings(duplicate);
for precompile in [false, true] {
let loader = Arc::new(MemoryLoader::default());
for (path, doc) in &docs {
loader.add(
*path,
serde_json::from_value::<zen_engine::model::DecisionContent>(doc.clone())
.unwrap(),
);
}
let engine = DecisionEngine::default().with_loader(loader);
if precompile {
assert!(engine.compile().is_empty());
}
// Switching entries repeatedly must never reuse another entry's scope.
for path in ["B", "C", "A", "C", "B"] {
let result = engine.evaluate(path, json!({}).into()).await.unwrap();
let result = serde_json::to_value(result.result).unwrap();
let expected = match path {
"B" => json!({"base":10,"decision":"approve"}),
"C" if duplicate => json!({"base":10,"decision":"decline"}),
"C" => json!({"base":10,"other":"decline"}),
_ => json!({"base":10}),
};
assert_eq!(result, expected, "precompile={precompile}, entry={path}");
}
}
}
}
#[test]
fn sibling_dictionaries_and_diagnostics_stay_separate() {
let ws = workspace(&siblings(true));
for (path, values) in [
("A", vec![]),
("B", vec!["approve"]),
("C", vec!["decline"]),
] {
let errors: Vec<_> = ws
.diagnostics(path)
.into_iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "{path}: {errors:?}");
let dictionaries = ws.dictionaries(&ScopeRequest::for_policy(path));
let actual: Vec<_> = dictionaries
.iter()
.flat_map(|d| d.entries.iter().map(|e| e.value.as_ref()))
.collect();
assert_eq!(actual, values);
}
}
#[tokio::test]
async fn explicitly_composing_conflicting_siblings_reports_errors_at_the_entry() {
let mut docs = siblings(true);
docs.push(("D", policy(&["B", "C"], vec![])));
let ws = workspace(&docs);
let diagnostics = ws.diagnostics("D");
for code in ["DuplicateWriter", "DataModelCollision"] {
assert!(
diagnostics.iter().any(|d| format!("{:?}", d.code) == code),
"{diagnostics:?}"
);
}
assert!(diagnostics
.iter()
.all(|d| d.location.policy_path.as_ref() == "D"));
for precompile in [false, true] {
let loader = Arc::new(MemoryLoader::default());
for (path, doc) in &docs {
loader.add(
*path,
serde_json::from_value::<zen_engine::model::DecisionContent>(doc.clone()).unwrap(),
);
}
let engine = DecisionEngine::default().with_loader(loader);
if precompile {
let failures = engine.compile();
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].key.as_ref(), "D");
}
assert!(engine.evaluate("D", json!({}).into()).await.is_err());
assert!(engine.evaluate("B", json!({}).into()).await.is_ok());
assert!(engine.evaluate("C", json!({}).into()).await.is_ok());
}
}
#[test]
fn diamond_imports_execute_the_shared_dependency_once() {
let mut docs = siblings(false);
docs.push((
"D",
policy(&["B", "C"], vec![expression("total", "base + 5")]),
));
let ws = workspace(&docs);
let result = ws.enhance_trace(&request("D", json!({}))).unwrap();
assert_eq!(
serde_json::to_value(result.output).unwrap(),
json!({"base":10,"decision":"approve","other":"decline","total":15})
);
let trace = result.trace.unwrap();
assert_eq!(trace.executions.len(), 4);
assert_eq!(
trace
.executions
.iter()
.filter(|e| e.block_id.as_ref() == "base")
.count(),
1
);
}
#[test]
fn scope_updates_after_import_and_sibling_edits() {
let mut ws = workspace(&siblings(false));
assert_eq!(output(&ws, "B"), json!({"base":10,"decision":"approve"}));
ws.set_policy(
"C",
serde_json::from_value(policy(&["A"], vec![expression("decision", "\"decline\"")]))
.unwrap(),
);
assert_eq!(output(&ws, "B"), json!({"base":10,"decision":"approve"}));
ws.set_policy(
"B",
serde_json::from_value(policy(&[], vec![expression("decision", "\"approve\"")])).unwrap(),
);
assert_eq!(output(&ws, "B"), json!({"decision":"approve"}));
ws.set_policy(
"B",
serde_json::from_value(policy(&["C"], vec![expression("more", "base + 1")])).unwrap(),
);
assert_eq!(
output(&ws, "B"),
json!({"base":10,"decision":"decline","more":11})
);
}
#[test]
fn unrelated_sibling_input_and_entity_scope_cannot_poison_evaluation() {
let docs = vec![
("shared", policy(&[], vec![])),
(
"Test",
policy(
&["shared"],
vec![
json!({"id":"dm", "type":"dataModel", "props":{"data":{"name":"person", "properties":[{"id":"age", "name":"age", "type":"number"}]}}}),
expression("decision", "age > 50"),
],
),
),
(
"underwriting",
policy(
&["shared"],
vec![
json!({"id":"rule", "type":"decisionTable", "props":{"data":{
"hitPolicy":"first", "inputs":[], "outputs":[
{"id":"x", "name":"", "field":"person.result"},
{"id":"y", "name":"", "field":"other.result"}
], "rules":[{"_id":"r1","x":"1","y":"2"}]
}}}),
],
),
),
];
let ws = workspace(&docs);
let errors: Vec<_> = ws
.diagnostics("underwriting")
.into_iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
assert_eq!(
output(&ws, "underwriting"),
json!({"person":{"result":1},"other":{"result":2}})
);
assert!(ws
.enhance_trace(&request("underwriting", json!({})))
.is_ok());
}
+7 -3
View File
@@ -1403,6 +1403,10 @@ fn graph_table_heads_complete_for_outputs_and_fieldless_inputs() {
}
fn sibling_writer_workspace() -> PolicyWorkspace {
import_scope_workspace(false)
}
fn import_scope_workspace(import_rule: bool) -> PolicyWorkspace {
let shared = json!({
"blocks": [
{
@@ -1456,7 +1460,7 @@ fn sibling_writer_workspace() -> PolicyWorkspace {
]
});
let entry = json!({
"imports": ["shared"],
"imports": if import_rule { vec!["shared", "rule"] } else { vec!["shared"] },
"blocks": [
{ "id": "ex_triggered", "type": "expression", "props": { "data": { "key": "triggeredRules", "value": "rules.apu.triggered ? [\"APU\"] : []" } } },
{ "id": "ex_count", "type": "expression", "props": { "data": { "key": "triggeredCount", "value": "len(triggeredRules)" } } }
@@ -1545,8 +1549,8 @@ fn sibling_policy_writes_are_hidden_until_scheduled() {
}
#[test]
fn upstream_policy_writes_stay_visible_downstream() {
let ws = sibling_writer_workspace();
fn explicitly_imported_policy_writes_stay_visible_downstream() {
let ws = import_scope_workspace(true);
let reader = cursor_in("entry", "ex_triggered", expression("ex_triggered"));
let labels = completion_labels(&ws, &reader);
+49 -2
View File
@@ -1,8 +1,8 @@
use serde_json::json;
use std::sync::Arc;
use zen_engine::policy::{
CursorTarget, EngineEdit, EvaluateRequest, PolicyWorkspace, RenameTarget, ScopeRequest,
Severity,
CursorTarget, EngineEdit, EvaluateRequest, EvaluationError, PolicyWorkspace, RenameTarget,
ScopeRequest, Severity,
};
use zen_expression::variable::{Variable, VariableType};
@@ -72,6 +72,53 @@ fn strict_table_doc() -> serde_json::Value {
})
}
#[test]
fn failed_input_comparisons_report_the_field_and_missing_value() {
let ws = workspace_with(strict_table_doc());
for (input, value_description) in [
(json!({}), "missing or null"),
(json!({ "order": { "amount": null } }), "missing or null"),
] {
let error = ws.evaluate(&request(input, true)).unwrap_err();
let EvaluationError::ExpressionFailed {
block_id,
expression,
source,
..
} = error
else {
panic!("expected a contextual expression error, got {error:?}");
};
assert_eq!(block_id.as_ref(), "dt");
assert_eq!(expression.as_ref(), ">= 100");
let serialized = serde_json::to_value(&source).unwrap();
assert_eq!(serialized["type"], "contextError");
assert_eq!(serialized["source"]["type"], "vmError");
let source = source.to_string();
assert!(source.contains("order.amount"), "{source}");
assert!(source.contains(">= 100"), "{source}");
assert!(source.contains(value_description), "{source}");
assert!(source.contains("Opcode Compare"), "{source}");
}
// A failed run must not poison the workspace or silently select a fallback rule.
assert_eq!(
evaluate_output(&ws, json!({ "order": { "amount": 100, "region": "US" } }))
.pointer("/order/shippingCost"),
Some(&json!(0))
);
}
#[test]
fn a_condition_that_handles_missing_input_still_evaluates_normally() {
let mut doc = strict_table_doc();
doc["blocks"][1]["props"]["data"]["rules"][0]["i1"] = json!("null");
let ws = workspace_with(doc);
assert_eq!(
evaluate_output(&ws, json!({ "order": { "region": "US" } })).pointer("/order/shippingCost"),
Some(&json!(0))
);
}
#[test]
fn no_match_emits_null_for_scalar_output() {
let ws = workspace_with(strict_table_doc());
+11
View File
@@ -197,6 +197,12 @@ impl Isolate {
/// Errors which happen within isolate or during evaluation
#[derive(Debug, Error)]
pub enum IsolateError {
#[error("{context}: {source}")]
ContextError {
context: String,
source: Box<IsolateError>,
},
#[error("Lexer error: {source}")]
LexerError { source: LexerError },
@@ -227,6 +233,11 @@ impl Serialize for IsolateError {
let mut map = serializer.serialize_map(None)?;
match &self {
IsolateError::ContextError { context, source } => {
map.serialize_entry("type", "contextError")?;
map.serialize_entry("context", context)?;
map.serialize_entry("source", source)?;
}
IsolateError::ReferenceError => {
map.serialize_entry("type", "referenceError")?;
}