diff --git a/bindings/nodejs/Makefile b/bindings/nodejs/Makefile index a0015c37..32848250 100644 --- a/bindings/nodejs/Makefile +++ b/bindings/nodejs/Makefile @@ -15,8 +15,8 @@ prepareLocalPublish: yarn createNpmDirs publish: - cd npm/wasm32-wasi && npm publish --registry $(REGISTRY) --ignore-scripts - cd npm/darwin-arm64 && npm publish --registry $(REGISTRY) --ignore-scripts - npm publish --registry $(REGISTRY) --ignore-scripts + cd npm/wasm32-wasi && npm publish --registry $(REGISTRY) --ignore-scripts --tag latest + cd npm/darwin-arm64 && npm publish --registry $(REGISTRY) --ignore-scripts --tag latest + npm publish --registry $(REGISTRY) --ignore-scripts --tag latest publishLocal: prepareLocalPublish wasm darwinArm publish diff --git a/bindings/nodejs/dts-header.d.ts b/bindings/nodejs/dts-header.d.ts index 293116bb..54aed323 100644 --- a/bindings/nodejs/dts-header.d.ts +++ b/bindings/nodejs/dts-header.d.ts @@ -193,6 +193,8 @@ export interface NlResult { diagnostics: NlDiagnostic[]; /** Resolved `$` type for unary requests (decision-table input cells); present even for empty text. */ subjectType?: PolicyVariableType; + /** Labeled options when the unary subject is an enum (dictionary labels applied); present even for empty text. */ + subjectOptions?: NlEnumOption[]; } /** @@ -212,6 +214,8 @@ export interface PolicyNlExpression { diagnostics: NlDiagnostic[]; /** Resolved `$` type for unary cells. */ subjectType?: PolicyVariableType; + /** Labeled options when the unary subject is an enum (dictionary labels applied). */ + subjectOptions?: NlEnumOption[]; } /** diff --git a/bindings/nodejs/index.d.ts b/bindings/nodejs/index.d.ts index 8a415940..b9b5a971 100644 --- a/bindings/nodejs/index.d.ts +++ b/bindings/nodejs/index.d.ts @@ -193,6 +193,8 @@ export interface NlResult { diagnostics: NlDiagnostic[]; /** Resolved `$` type for unary requests (decision-table input cells); present even for empty text. */ subjectType?: PolicyVariableType; + /** Labeled options when the unary subject is an enum (dictionary labels applied); present even for empty text. */ + subjectOptions?: NlEnumOption[]; } /** @@ -212,6 +214,8 @@ export interface PolicyNlExpression { diagnostics: NlDiagnostic[]; /** Resolved `$` type for unary cells. */ subjectType?: PolicyVariableType; + /** Labeled options when the unary subject is an enum (dictionary labels applied). */ + subjectOptions?: NlEnumOption[]; } /** @@ -362,30 +366,14 @@ export declare class PolicyWorkspace { constructor() setPolicy(path: string, document: any): void removePolicy(path: string): boolean - /** - * Upsert a single block in an existing policy (replace-by-id or append). - * Errors when the policy does not exist — call `setPolicy` first to - * create one. - */ updateBlock(req: PolicyUpdateBlockRequest): void - /** - * Remove a block from an existing policy by id. Returns `true` when a - * block was removed, `false` when the policy or block didn't exist. - */ removeBlock(req: PolicyRemoveBlockRequest): boolean policyPaths(): Array - /** - * `max_diagnostics` caps the returned list (default: 100). Pass `0` for - * no cap. - */ diagnostics(policyPath: string, maxDiagnostics?: number | undefined | null): Array - /** - * `max_diagnostics` caps the returned list (default: 100). Pass `0` for - * no cap. - */ allDiagnostics(maxDiagnostics?: number | undefined | null): Array entities(req: PolicyScopeRequest): Array globals(req: PolicyScopeRequest): Array + dictionaries(req: PolicyScopeRequest): Array inputs(req: PolicyScopeRequest): Array outputs(req: PolicyScopeRequest): Array conditionalSchema(req: PolicyScopeRequest): PolicyConditionalSchema @@ -394,32 +382,9 @@ export declare class PolicyWorkspace { nlTokenize(cursor: PolicyExpressionCursor, text: string): NlResult | null completions(cursor: PolicyExpressionCursor): Array prepareRename(cursor: PolicyExpressionCursor): PolicyPrepareRenameResult | null - /** - * Returns block-level edits the host applies via id-keyed swap (same - * path as `update_block`). Each edit's `kind` field discriminates the - * variant; `replaceBlock` carries a `newBlock` payload that is the - * rewritten wire-format `BlockDoc`. - */ rename(req: PolicyRenameRequest): PolicyEngineEdit[] - /** - * Returns every site in the workspace where `target` is used. Same - * visitor as `rename`; carries policy/block/expression/source/span/kind - * for each site so hosts can render a "find references" panel or drive - * navigation. - */ references(target: any): PolicyReferenceSite[] - /** - * Default-valued JSON object that matches the workspace's input shape - * for `req.policy_path` (and optionally `req.goals`). Hosts use it as - * the initial value of a "Run simulation" panel so `evaluate` can be - * called immediately without first authoring an input by hand. - */ inputSkeleton(req: PolicyScopeRequest): unknown - /** - * Returns the transitive dependency tree rooted at `target`. Inverse - * of `references()`. Per-write granularity — multi-output blocks - * don't conflate sibling outputs' deps. - */ dependencies(target: string): PolicyDependencyNode evaluate(req: PolicyEvaluateRequest): PolicyEvaluationResult enhanceTrace(req: PolicyEvaluateRequest): PolicyEvaluationResult @@ -446,7 +411,7 @@ export declare class ZenEngine { getDecision(key: string): Promise safeEvaluate(key: string, context: any, opts?: ZenEvaluateOptions | undefined | null): Promise<{ success: true, data: ZenEngineResponse } | { success: false; error: any; }> safeGetDecision(key: string): Promise<{ success: true, data: ZenDecision } | { success: false; error: any; }> - evaluateBatch(requests: Array, opts?: ZenEvaluateOptions | undefined | null): Promise> + evaluateBatch(requests: Array, opts?: ZenEvaluateOptions | undefined | null): Promise> reload(): Promise compileFailures(): Array<{ key: string; kind: string; diagnostics?: Array<{ code: string; message: string; severity: string }>; error?: string }> dispose(): void @@ -472,12 +437,6 @@ export interface EvaluateBatchRequest { context: any } -export interface EvaluateBatchResult { - success: boolean - data?: any - error?: any -} - export declare function evaluateExpression(expression: string, context?: any | undefined | null): Promise export declare function evaluateExpressionSync(expression: string, context?: any | undefined | null): any @@ -524,6 +483,17 @@ export interface PolicyDiagnostic { target?: PolicyCursorTarget } +export interface PolicyDictionaryEntryInfo { + value: string + label: string +} + +export interface PolicyDictionaryInfo { + name: string + source: string + entries: Array +} + export interface PolicyDiscriminantVariant { value?: string arm: string @@ -550,7 +520,6 @@ export interface PolicyEntityInfo { export interface PolicyEvaluateRequest { policyPath: string input: unknown - /** Goals to evaluate. Omit or pass empty for full evaluation. */ goals?: Array trace?: boolean } @@ -559,18 +528,9 @@ export interface PolicyExpressionCursor { policyPath: string blockId: string pos: number - /** - * Tagged `{ kind, ...payload }` discriminating what kind of span the - * cursor sits in. See `PolicyCursorTarget` in the TypeScript types. - */ target: PolicyCursorTarget } -/** - * Kept as a `#[napi(object)]` struct purely so NAPI-RS emits the TS type - * used by `PolicyFieldOrigin.schema.fieldKind`. The runtime shape is - * hand-built in [`field_kind_to_json`]. - */ export interface PolicyFieldKindInfo { kind: PolicyFieldKind target?: string @@ -640,19 +600,11 @@ export interface PolicySchemaGroup { export interface PolicyScopeRequest { policyPath: string - /** - * Goals to constrain schema introspection to. Omit or pass empty - * for everything reachable from the policy. - */ goals?: Array } export interface PolicyUpdateBlockRequest { policyPath: string - /** - * A single wire block (same shape as one entry of `PolicyDocument.blocks`). - * Upserted by `block.id`: replaces in place if present, appends otherwise. - */ block: unknown } diff --git a/bindings/nodejs/index.js b/bindings/nodejs/index.js index 1111f7df..cac5b0ff 100644 --- a/bindings/nodejs/index.js +++ b/bindings/nodejs/index.js @@ -581,6 +581,8 @@ module.exports.evaluateExpression = nativeBinding.evaluateExpression module.exports.evaluateExpressionSync = nativeBinding.evaluateExpressionSync module.exports.evaluateUnaryExpression = nativeBinding.evaluateUnaryExpression module.exports.evaluateUnaryExpressionSync = nativeBinding.evaluateUnaryExpressionSync +module.exports.nlEncodeString = nativeBinding.nlEncodeString +module.exports.nlTokenizeBatch = nativeBinding.nlTokenizeBatch module.exports.overrideConfig = nativeBinding.overrideConfig module.exports.renderTemplate = nativeBinding.renderTemplate module.exports.renderTemplateSync = nativeBinding.renderTemplateSync diff --git a/bindings/nodejs/src/policy.rs b/bindings/nodejs/src/policy.rs index ed68ea7d..86c71b44 100644 --- a/bindings/nodejs/src/policy.rs +++ b/bindings/nodejs/src/policy.rs @@ -103,6 +103,19 @@ pub struct PolicyGlobalInfo { pub origin: Value, } +#[napi(object)] +pub struct PolicyDictionaryInfo { + pub name: String, + pub source: String, + pub entries: Vec, +} + +#[napi(object)] +pub struct PolicyDictionaryEntryInfo { + pub value: String, + pub label: String, +} + #[napi(object)] pub struct PolicyInputProperty { pub path: String, @@ -540,6 +553,26 @@ impl PolicyWorkspace { .collect() } + #[napi] + pub fn dictionaries(&self, req: PolicyScopeRequest) -> Vec { + self.inner + .dictionaries(&req.into()) + .into_iter() + .map(|d| PolicyDictionaryInfo { + name: d.name.to_string(), + source: d.source.to_string(), + entries: d + .entries + .iter() + .map(|e| PolicyDictionaryEntryInfo { + value: e.value.to_string(), + label: e.label.to_string(), + }) + .collect(), + }) + .collect() + } + #[napi] pub fn inputs(&self, req: PolicyScopeRequest) -> Vec { self.inner diff --git a/core/engine/src/policy/db.rs b/core/engine/src/policy/db.rs index 9a17d100..746674c3 100644 --- a/core/engine/src/policy/db.rs +++ b/core/engine/src/policy/db.rs @@ -12,7 +12,9 @@ use crate::policy::blocks::{ SharedIntelliSense, }; use crate::policy::evaluator::EvalArtifact; -use crate::policy::ir::{DataModelIr, ParsedPolicy, Policy, Property, PropertyPath, Scope}; +use crate::policy::ir::{ + DataModelIr, DictionaryIr, ParsedPolicy, Policy, Property, PropertyPath, Scope, +}; use crate::policy::queries::dependency::{ DataModelPaths, DependencyGraph, EnrichedState, EvalGraph, RuleShallowAnalysis, ShallowAnalyses, }; @@ -141,6 +143,14 @@ pub struct Unit { opcode_cache: OnceCell>, pub data_models: Vec, pub entities: HashMap, Arc>, + pub dictionaries: HashMap, Arc>, + pub dictionary_blocks: Vec, +} + +pub struct DictionaryUnitEntry { + pub policy_path: Arc, + pub block_id: Arc, + pub ir: Arc, } pub struct Db { @@ -624,6 +634,26 @@ impl Snapshot { }); let entities = Self::compute_unit_entities(&subset); + let dictionaries = Self::compute_dictionary_map(&subset); + + let mut dictionary_blocks: Vec = subset + .iter() + .flat_map(|(path, p)| { + p.policy + .dictionaries + .iter() + .map(move |block| DictionaryUnitEntry { + policy_path: path.clone(), + block_id: block.id.clone(), + ir: block.ir.clone(), + }) + }) + .collect(); + dictionary_blocks.sort_by(|a, b| { + a.ir.name + .cmp(&b.ir.name) + .then_with(|| a.policy_path.cmp(&b.policy_path)) + }); Unit { members: member_set, @@ -639,6 +669,8 @@ impl Snapshot { opcode_cache: OnceCell::new(), data_models, entities, + dictionaries, + dictionary_blocks, } } diff --git a/core/engine/src/policy/editor.rs b/core/engine/src/policy/editor.rs index 86b74e8b..ae55508f 100644 --- a/core/engine/src/policy/editor.rs +++ b/core/engine/src/policy/editor.rs @@ -44,29 +44,63 @@ impl Db { return Vec::new(); }; let scope = self.enriched(policy).scope.shallow_clone(); + let labels = self.nl_label_resolver(policy); let intellisense = self.intellisense(); let mut is = intellisense.borrow_mut(); + is.set_nl_labels(labels); let mut out = Vec::new(); for rule in parsed.policy.rules() { out.extend(rule.nl(&policy_arc, &scope, &mut is)); } + is.set_nl_labels(None); out } pub fn nl_tokenize(&self, cursor: &Cursor, text: &str) -> Option { let (kind, scope) = self.nl_scope(cursor)?; let unary = matches!(kind, ExpressionKind::Unary); + let labels = self.nl_label_resolver(&cursor.policy_path); let intellisense = self.intellisense(); - let mut result = - intellisense - .borrow_mut() - .nl_tokenize_scoped(&cursor.block_id, text, unary, &scope); + let mut is = intellisense.borrow_mut(); + is.set_nl_labels(labels); + let mut result = is.nl_tokenize_scoped(&cursor.block_id, text, unary, &scope); if unary { - result.subject_type = Some(scope.get("$")); + let subject = scope.get("$"); + result.subject_options = is.nl_subject_options(&subject); + result.subject_type = Some(subject); } + is.set_nl_labels(None); Some(result) } + fn nl_label_resolver( + &self, + policy: &str, + ) -> Option { + let unit = self.unit(policy); + if unit.dictionary_blocks.is_empty() { + return None; + } + let mut labels: HashMap, HashMap, Arc>> = HashMap::new(); + for (name, dict) in &unit.dictionaries { + let entries: HashMap, Arc> = dict + .entries + .iter() + .filter(|e| !e.label.is_empty()) + .map(|e| (e.value.clone(), e.label.clone())) + .collect(); + if !entries.is_empty() { + labels.insert(name.clone(), entries); + } + } + if labels.is_empty() { + return None; + } + Some(std::rc::Rc::new(move |name: &str, value: &str| { + labels.get(name)?.get(value).map(|l| l.to_string()) + })) + } + fn nl_scope(&self, cursor: &Cursor) -> Option<(ExpressionKind, VariableType)> { let block = self.block_ir(&BlockRef { policy_path: cursor.policy_path.clone(), diff --git a/core/engine/src/policy/ir.rs b/core/engine/src/policy/ir.rs index c555104d..27a6cf58 100644 --- a/core/engine/src/policy/ir.rs +++ b/core/engine/src/policy/ir.rs @@ -6,7 +6,9 @@ use ahash::{HashMap, HashMapExt, HashSet, HashSetExt}; use zen_expression::variable::VariableType; use crate::policy::blocks::{AssertionIr, Block, DecisionTableIr, ExpressionIr, MatchIr}; -use crate::policy::raw::{BlockDoc, DataModelDoc, PolicyDocument, PropertyTypeDoc, ScopeDoc}; +use crate::policy::raw::{ + BlockDoc, DataModelDoc, DictionaryDoc, PolicyDocument, PropertyTypeDoc, ScopeDoc, +}; use crate::policy::types::{Diagnostic, DiagnosticCode, DiagnosticLocation, SchemaFieldKind}; use crate::policy::ArcStrTrim; @@ -16,6 +18,7 @@ pub type PropertyPath = Arc; pub struct Policy { pub rules: Vec, pub data_models: Vec, + pub dictionaries: Vec, pub imports: Vec>, } @@ -25,6 +28,12 @@ pub struct DataModelBlock { pub ir: Arc, } +#[derive(Debug, Clone)] +pub struct DictionaryBlock { + pub id: Arc, + pub ir: Arc, +} + #[derive(Debug, Clone)] pub struct ParsedPolicy { pub policy: Arc, @@ -36,6 +45,7 @@ impl Policy { let mut diagnostics = Vec::new(); let mut rules = Vec::new(); let mut data_models = Vec::new(); + let mut dictionaries = Vec::new(); for env in &doc.blocks { match env { @@ -59,6 +69,14 @@ impl Policy { }); } } + BlockDoc::Dictionary { id, data } => { + if let Some(ir) = DictionaryIr::parse(id, data, path, &mut diagnostics) { + dictionaries.push(DictionaryBlock { + id: id.clone(), + ir: Arc::new(ir), + }); + } + } BlockDoc::Ignored(_) => {} } } @@ -74,6 +92,7 @@ impl Policy { policy: Arc::new(Policy { rules, data_models, + dictionaries, imports, }), diagnostics: Arc::new(diagnostics), @@ -96,6 +115,10 @@ impl Policy { self.data_models().filter(|(_, dm)| dm.scope.is_global()) } + pub fn dictionaries(&self) -> impl Iterator, &Arc)> { + self.dictionaries.iter().map(|b| (&b.id, &b.ir)) + } + pub fn imports(&self) -> &[Arc] { &self.imports } @@ -194,6 +217,7 @@ impl DataModelIr { pub(crate) fn wire_property_type( prop: &Property, entities: &HashMap, Arc>, + dictionaries: &HashMap, Arc>, visited: &mut HashSet>, ) -> VariableType { let inner = match &prop.kind { @@ -202,7 +226,10 @@ impl DataModelIr { PropertyTypeIr::Number => VariableType::Number, PropertyTypeIr::Boolean => VariableType::Bool, PropertyTypeIr::Reference { .. } => VariableType::String, - PropertyTypeIr::Relationship { target } => Self::wire_object(target, entities, visited), + PropertyTypeIr::Relationship { target } => match dictionaries.get(target.as_ref()) { + Some(dict) if !entities.contains_key(target.as_ref()) => dict.enum_type(), + _ => Self::wire_object(target, entities, dictionaries, visited), + }, }; if prop.array { inner.array() @@ -214,6 +241,7 @@ impl DataModelIr { pub(crate) fn wire_object( name: &Arc, entities: &HashMap, Arc>, + dictionaries: &HashMap, Arc>, visited: &mut HashSet>, ) -> VariableType { if !visited.insert(name.clone()) { @@ -224,7 +252,7 @@ impl DataModelIr { for prop in &dm.properties { fields.insert( Rc::from(prop.name.as_ref()), - Self::wire_property_type(prop, entities, visited), + Self::wire_property_type(prop, entities, dictionaries, visited), ); } } @@ -232,7 +260,7 @@ impl DataModelIr { VariableType::Object(Rc::new(RefCell::new(fields))) } - fn validate_identifier(name: &str) -> Result<(), &'static str> { + pub(crate) fn validate_identifier(name: &str) -> Result<(), &'static str> { if name.is_empty() { return Err("is empty"); } @@ -393,6 +421,86 @@ impl DataModelIr { } } +#[derive(Debug, Clone)] +pub struct DictionaryIr { + pub name: Arc, + pub entries: Vec, +} + +#[derive(Debug, Clone)] +pub struct DictionaryEntry { + pub value: Arc, + pub label: Arc, +} + +impl DictionaryIr { + pub fn parse( + id: &Arc, + doc: &DictionaryDoc, + policy_path: &Arc, + diagnostics: &mut Vec, + ) -> Option { + let name = doc.name.trimmed(); + if name.is_empty() { + diagnostics.push(Diagnostic::error( + DiagnosticCode::ParseError, + DiagnosticLocation::block(policy_path.clone(), id.clone()), + "dictionary is missing a name", + )); + return None; + } + if let Err(reason) = DataModelIr::validate_identifier(&name) { + diagnostics.push(Diagnostic::error( + DiagnosticCode::InvalidName, + DiagnosticLocation::block(policy_path.clone(), id.clone()), + format!("dictionary name '{name}' {reason}"), + )); + return None; + } + + let mut entries: Vec = Vec::with_capacity(doc.entries.len()); + for entry in &doc.entries { + let value = entry.value.trimmed(); + if value.is_empty() { + continue; + } + if entries.iter().any(|e| e.value == value) { + diagnostics.push(Diagnostic::error( + DiagnosticCode::DuplicateEnumValue, + DiagnosticLocation::expression( + policy_path.clone(), + id.clone(), + entry.id.clone(), + None, + ), + format!("duplicate value '{value}' in dictionary '{name}'"), + )); + continue; + } + entries.push(DictionaryEntry { + value, + label: entry.label.trimmed(), + }); + } + + Some(DictionaryIr { name, entries }) + } + + pub fn values(&self) -> impl Iterator> { + self.entries.iter().map(|e| &e.value) + } + + pub(crate) fn enum_type(&self) -> VariableType { + VariableType::Enum( + Some(Rc::from(self.name.as_ref())), + self.entries + .iter() + .map(|e| Rc::from(e.value.as_ref())) + .collect(), + ) + } +} + impl PropertyTypeIr { pub(crate) fn to_schema_field_kind(&self, array: bool) -> SchemaFieldKind { match self { diff --git a/core/engine/src/policy/mod.rs b/core/engine/src/policy/mod.rs index b323b5a3..bf023886 100644 --- a/core/engine/src/policy/mod.rs +++ b/core/engine/src/policy/mod.rs @@ -19,12 +19,12 @@ mod workspace; pub use raw::{BlockDoc, PolicyDocument}; pub use types::{ BlockExecution, BlockRef, Completion, ConditionalSchema, Cursor, CursorTarget, DependencyNode, - Diagnostic, DiagnosticCode, DiagnosticLocation, DiscriminantVariant, DiscriminatedUnion, - EngineEdit, Entity, EntityField, EvaluateRequest, EvaluationError, EvaluationResult, - ExpressionKind, FieldOrigin, GuardedProperty, InputProperty, InputValidationError, - InspectResult, NlExpression, OutputProperty, PrepareRename, PropertyKind, ReferenceKind, - ReferenceSite, RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest, Severity, Span, Trace, - WriteConflict, WriteTrace, + Diagnostic, DiagnosticCode, DiagnosticLocation, Dictionary, DictionaryEntryInfo, + DiscriminantVariant, DiscriminatedUnion, EngineEdit, Entity, EntityField, EvaluateRequest, + EvaluationError, EvaluationResult, ExpressionKind, FieldOrigin, GuardedProperty, InputProperty, + InputValidationError, InspectResult, NlExpression, OutputProperty, PrepareRename, PropertyKind, + ReferenceKind, ReferenceSite, RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest, + Severity, Span, Trace, WriteConflict, WriteTrace, }; pub use workspace::PolicyWorkspace; diff --git a/core/engine/src/policy/queries/diagnostics.rs b/core/engine/src/policy/queries/diagnostics.rs index 6e8e5a9b..2b1948ce 100644 --- a/core/engine/src/policy/queries/diagnostics.rs +++ b/core/engine/src/policy/queries/diagnostics.rs @@ -49,6 +49,8 @@ impl Db { 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)); @@ -476,7 +478,10 @@ impl Db { if let PropertyTypeIr::Relationship { target: t } | PropertyTypeIr::Reference { target: t } = &prop.kind { - if !known_entities.contains(t) && policy_path == target { + let dictionary_target = + matches!(prop.kind, PropertyTypeIr::Relationship { .. }) + && unit.dictionaries.contains_key(t); + if !known_entities.contains(t) && !dictionary_target && policy_path == target { let owner = if is_global { format!("global property '{}'", prop.name) } else { @@ -499,4 +504,67 @@ impl Db { out } + + fn dictionary_diagnostics(&self, target: &Arc) -> Vec { + let mut out = Vec::new(); + let unit = self.unit(target); + let known_entities: HashSet> = unit + .data_models + .iter() + .filter(|e| !e.ir.scope.is_global()) + .map(|e| e.ir.name.clone()) + .collect(); + let global_property_names: HashSet> = unit + .data_models + .iter() + .filter(|e| e.ir.scope.is_global()) + .flat_map(|e| e.ir.properties.iter().map(|p| p.name.clone())) + .collect(); + + let mut first_by_name: HashMap, (Arc, Arc)> = HashMap::default(); + 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( + DiagnosticCode::DataModelCollision, + DiagnosticLocation::block( + entry.policy_path.clone(), + entry.block_id.clone(), + ), + format!( + "dictionary '{name}' is already defined in '{prev_policy}' (block '{prev_block}')" + ), + )); + } + continue; + } + first_by_name.insert( + name.clone(), + (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, + DiagnosticLocation::block(entry.policy_path.clone(), entry.block_id.clone()), + format!("dictionary name '{name}' collides with an entity of the same name"), + )); + } + if global_property_names.contains(name) { + out.push(Diagnostic::error( + DiagnosticCode::DataModelCollision, + DiagnosticLocation::block(entry.policy_path.clone(), entry.block_id.clone()), + format!( + "dictionary name '{name}' collides with a global property of the same name" + ), + )); + } + } + + out + } } diff --git a/core/engine/src/policy/queries/schema.rs b/core/engine/src/policy/queries/schema.rs index 1df7b10e..a76fd963 100644 --- a/core/engine/src/policy/queries/schema.rs +++ b/core/engine/src/policy/queries/schema.rs @@ -8,8 +8,8 @@ use crate::policy::ir::DataModelIr; use crate::policy::queries::dependency::{DependencyGraph, PathPrefix}; use crate::policy::queries::scope::PropertyScope; use crate::policy::types::{ - Entity, EntityField, FieldOrigin, Global, InputProperty, OutputProperty, PropertyKind, - ScopeRequest, + Dictionary, DictionaryEntryInfo, Entity, EntityField, FieldOrigin, Global, InputProperty, + OutputProperty, PropertyKind, ScopeRequest, }; impl Db { @@ -59,8 +59,12 @@ impl Db { } } let mut visited: HashSet> = HashSet::default(); - let resolved_type = - DataModelIr::wire_property_type(&vp.property, entities_map, &mut visited); + let resolved_type = DataModelIr::wire_property_type( + &vp.property, + entities_map, + &unit.dictionaries, + &mut visited, + ); out.push(Global { name: vp.property.name.clone(), resolved_type, @@ -99,6 +103,31 @@ impl Db { out } + pub fn dictionaries(&self, req: &ScopeRequest) -> Vec { + let unit = self.unit(&req.policy_path); + let mut seen: HashSet> = HashSet::default(); + let mut out: Vec = Vec::new(); + for entry in &unit.dictionary_blocks { + if !seen.insert(entry.ir.name.clone()) { + continue; + } + out.push(Dictionary { + name: entry.ir.name.clone(), + source: entry.policy_path.clone(), + entries: entry + .ir + .entries + .iter() + .map(|e| DictionaryEntryInfo { + value: e.value.clone(), + label: e.label.clone(), + }) + .collect(), + }); + } + out + } + pub fn inputs(&self, req: &ScopeRequest) -> Vec { let unit = self.unit(&req.policy_path); let visible = &unit.members; @@ -119,6 +148,7 @@ impl Db { resolved_type: DataModelIr::wire_property_type( &vp.property, entities, + &unit.dictionaries, &mut visited, ), } @@ -130,7 +160,8 @@ impl Db { continue; } let mut visited: HashSet> = HashSet::default(); - let entity_type = DataModelIr::wire_object(target, entities, &mut visited); + let entity_type = + DataModelIr::wire_object(target, entities, &unit.dictionaries, &mut visited); if !matches!(entity_type, VariableType::Any) { result.push(InputProperty { path: target.clone(), @@ -184,8 +215,12 @@ impl Db { return None; }; let mut visited: HashSet> = HashSet::default(); - let resolved_type = - DataModelIr::wire_property_type(&vp.property, entities, &mut visited); + let resolved_type = DataModelIr::wire_property_type( + &vp.property, + entities, + &unit.dictionaries, + &mut visited, + ); let origin = FieldOrigin::Schema { source: vp.policy_path, kind: vp.property.kind.to_schema_field_kind(vp.property.array), diff --git a/core/engine/src/policy/queries/scope.rs b/core/engine/src/policy/queries/scope.rs index 83e52d48..7c546535 100644 --- a/core/engine/src/policy/queries/scope.rs +++ b/core/engine/src/policy/queries/scope.rs @@ -8,7 +8,7 @@ use zen_expression::variable::{Variable, VariableType}; use crate::policy::blocks::InstanceSource; use crate::policy::db::{Db, Snapshot}; -use crate::policy::ir::{DataModelIr, ParsedPolicy, Property, PropertyTypeIr}; +use crate::policy::ir::{DataModelIr, DictionaryIr, ParsedPolicy, Property, PropertyTypeIr}; use crate::policy::queries::dependency::DependencyGraph; use crate::policy::types::InstanceTarget; @@ -288,6 +288,20 @@ impl Snapshot { }) } + pub(crate) fn compute_dictionary_map( + all_parsed: &HashMap, Arc>, + ) -> HashMap, Arc> { + let mut sorted: Vec<(&Arc, &Arc)> = all_parsed.iter().collect(); + sorted.sort_by(|a, b| a.0.cmp(b.0)); + let mut out: HashMap, Arc> = HashMap::new(); + for (_, parsed) in sorted { + for (_, dict) in parsed.policy.dictionaries() { + out.entry(dict.name.clone()).or_insert_with(|| dict.clone()); + } + } + out + } + pub(crate) fn compute_entity_graph( all_parsed: &HashMap, Arc>, entity_sources: &Arc, @@ -320,6 +334,7 @@ impl Snapshot { entity_sources: &EntitySources, ) -> VariableType { let mut entity_map: HashMap, VariableType> = HashMap::new(); + let dictionaries = Self::compute_dictionary_map(all_parsed); let mut models: Vec<(Arc, &DataModelIr)> = Self::iter_data_models(all_parsed).collect(); @@ -336,7 +351,7 @@ impl Snapshot { } for (_, dm) in models.iter().filter(|(_, dm)| !dm.scope.is_global()) { - dm.wire_relationships(&entity_map); + dm.wire_relationships(&entity_map, &dictionaries); } for (entity_name, source) in entity_sources.iter() { @@ -370,7 +385,7 @@ impl Snapshot { for (_, dm) in models.iter().filter(|(_, dm)| dm.scope.is_global()) { for prop in &dm.properties { let key = Rc::from(prop.name.as_ref()); - let value_type = prop.build_global_type(&entity_map); + let value_type = prop.build_global_type(&entity_map, &dictionaries); scope_fields.entry(key).or_insert(value_type); } } @@ -594,7 +609,11 @@ impl DataModelIr { } } - fn wire_relationships(&self, entity_map: &HashMap, VariableType>) { + fn wire_relationships( + &self, + entity_map: &HashMap, VariableType>, + dictionaries: &HashMap, Arc>, + ) { for prop in &self.properties { let target_name = match &prop.kind { PropertyTypeIr::Relationship { target } | PropertyTypeIr::Reference { target } => { @@ -603,14 +622,20 @@ impl DataModelIr { _ => continue, }; - let Some(target_entity) = entity_map.get(target_name.as_ref()) else { - continue; + let target_type = match entity_map.get(target_name.as_ref()) { + Some(target_entity) => target_entity.shallow_clone(), + None => match dictionaries.get(target_name.as_ref()) { + Some(dict) if matches!(prop.kind, PropertyTypeIr::Relationship { .. }) => { + dict.enum_type() + } + _ => continue, + }, }; let mut final_type = if prop.array { - target_entity.shallow_clone().array() + target_type.array() } else { - target_entity.shallow_clone() + target_type }; if prop.optional { final_type = VariableType::Nullable(Rc::new(final_type)); @@ -644,6 +669,7 @@ impl Property { pub(crate) fn build_global_type( &self, entity_map: &HashMap, VariableType>, + dictionaries: &HashMap, Arc>, ) -> VariableType { let inner = match &self.kind { PropertyTypeIr::String | PropertyTypeIr::Date => VariableType::String, @@ -655,7 +681,12 @@ impl Property { PropertyTypeIr::Relationship { target } | PropertyTypeIr::Reference { target } => { match entity_map.get(target.as_ref()) { Some(t) => t.shallow_clone(), - None => VariableType::Any, + None => match dictionaries.get(target.as_ref()) { + Some(dict) if matches!(self.kind, PropertyTypeIr::Relationship { .. }) => { + dict.enum_type() + } + _ => VariableType::Any, + }, } } }; diff --git a/core/engine/src/policy/raw.rs b/core/engine/src/policy/raw.rs index 0ccc9c94..2bbe65a5 100644 --- a/core/engine/src/policy/raw.rs +++ b/core/engine/src/policy/raw.rs @@ -34,6 +34,10 @@ pub enum BlockDoc { id: Arc, data: DataModelDoc, }, + Dictionary { + id: Arc, + data: DictionaryDoc, + }, Ignored(serde_json::Value), } @@ -44,7 +48,8 @@ impl BlockDoc { | Self::DecisionTable { id, .. } | Self::Expression { id, .. } | Self::Match { id, .. } - | Self::DataModel { id, .. } => Some(id), + | Self::DataModel { id, .. } + | Self::Dictionary { id, .. } => Some(id), Self::Ignored(value) => value.get("id").and_then(serde_json::Value::as_str), } } @@ -78,6 +83,10 @@ impl BlockDoc { id, data: serde_json::from_value(data)?, }), + BlockTag::Dictionary => Ok(Self::Dictionary { + id, + data: serde_json::from_value(data)?, + }), } } } @@ -124,6 +133,9 @@ impl Serialize for BlockDoc { Self::DataModel { id, data } => { TaggedBlockRef::new(BlockTag::DataModel, id, data).serialize(serializer) } + Self::Dictionary { id, data } => { + TaggedBlockRef::new(BlockTag::Dictionary, id, data).serialize(serializer) + } Self::Ignored(value) => value.serialize(serializer), } } @@ -136,6 +148,7 @@ enum BlockTag { Expression, Match, DataModel, + Dictionary, } impl BlockTag { @@ -146,6 +159,7 @@ impl BlockTag { "expression" => Some(Self::Expression), "match" => Some(Self::Match), "dataModel" => Some(Self::DataModel), + "dictionary" => Some(Self::Dictionary), _ => None, } } @@ -157,6 +171,7 @@ impl BlockTag { Self::Expression => "expression", Self::Match => "match", Self::DataModel => "dataModel", + Self::Dictionary => "dictionary", } } } @@ -227,6 +242,24 @@ pub struct PropertyDoc { pub optional: bool, } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DictionaryDoc { + pub name: Arc, + #[serde(default)] + pub entries: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DictionaryEntryDoc { + #[serde(default)] + pub id: Arc, + pub value: Arc, + #[serde(default)] + pub label: Arc, +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum PropertyTypeDoc { diff --git a/core/engine/src/policy/types/mod.rs b/core/engine/src/policy/types/mod.rs index dd01d946..82792975 100644 --- a/core/engine/src/policy/types/mod.rs +++ b/core/engine/src/policy/types/mod.rs @@ -18,8 +18,8 @@ pub use nl::NlExpression; pub use request::{EvaluateRequest, ScopeRequest}; pub use result::{ BlockExecution, BlockRef, BlockTrace, Completion, ConditionTrace, ConditionalSchema, - DecisionTableExtras, DependencyNode, DiscriminantVariant, DiscriminatedUnion, Entity, - EntityField, EvaluationResult, FieldOrigin, Global, GuardedProperty, InputProperty, - InstanceTarget, OutputProperty, PropertyKind, SchemaFieldKind, SchemaGroup, Trace, - WriteConflict, WriteTrace, + DecisionTableExtras, DependencyNode, Dictionary, DictionaryEntryInfo, DiscriminantVariant, + DiscriminatedUnion, Entity, EntityField, EvaluationResult, FieldOrigin, Global, + GuardedProperty, InputProperty, InstanceTarget, OutputProperty, PropertyKind, SchemaFieldKind, + SchemaGroup, Trace, WriteConflict, WriteTrace, }; diff --git a/core/engine/src/policy/types/nl.rs b/core/engine/src/policy/types/nl.rs index 4a31463b..4c0195d3 100644 --- a/core/engine/src/policy/types/nl.rs +++ b/core/engine/src/policy/types/nl.rs @@ -29,7 +29,9 @@ impl NlExpression { let unary = matches!(kind, ExpressionKind::Unary); let mut result = is.nl_tokenize_scoped(block_id, source, unary, scope); if unary { - result.subject_type = Some(scope.get("$")); + let subject = scope.get("$"); + result.subject_options = is.nl_subject_options(&subject); + result.subject_type = Some(subject); } Self { policy_path: policy_path.clone(), diff --git a/core/engine/src/policy/types/result.rs b/core/engine/src/policy/types/result.rs index a5f2e295..0ebea8f9 100644 --- a/core/engine/src/policy/types/result.rs +++ b/core/engine/src/policy/types/result.rs @@ -226,6 +226,21 @@ pub struct Global { pub origin: FieldOrigin, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Dictionary { + pub name: Arc, + pub source: Arc, + pub entries: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DictionaryEntryInfo { + pub value: Arc, + pub label: Arc, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct EntityField { diff --git a/core/engine/src/policy/validator.rs b/core/engine/src/policy/validator.rs index ea9be20e..4a6d7d9d 100644 --- a/core/engine/src/policy/validator.rs +++ b/core/engine/src/policy/validator.rs @@ -4,7 +4,7 @@ use ahash::{HashMap, HashMapExt, HashSet}; use zen_expression::variable::Variable; use crate::policy::db::Db; -use crate::policy::ir::{DataModelIr, Property, PropertyTypeIr}; +use crate::policy::ir::{DataModelIr, DictionaryIr, Property, PropertyTypeIr}; use crate::policy::refs::RefPoolIndex; use crate::policy::types::InputValidationError; use crate::policy::MAX_RECURSION_DEPTH; @@ -21,6 +21,7 @@ impl Db { globals, roots, ref_targets, + dictionaries: self.unit(policy_path).dictionaries.clone(), } } @@ -70,6 +71,7 @@ pub(crate) struct InputSchema { globals: HashMap, Property>, roots: HashSet>, ref_targets: HashSet>, + dictionaries: HashMap, Arc>, } impl InputSchema { @@ -77,6 +79,7 @@ impl InputSchema { let ref_pools = RefPoolIndex::from_input(input, self.ref_targets.iter().cloned()); let mut validator = InputValidator { entities: &self.entities, + dictionaries: &self.dictionaries, ref_pools: &ref_pools, errors: Vec::new(), depth: 0, @@ -113,6 +116,7 @@ impl InputSchema { struct InputValidator<'a> { entities: &'a HashMap, Arc>, + dictionaries: &'a HashMap, Arc>, ref_pools: &'a RefPoolIndex, errors: Vec, depth: usize, @@ -220,6 +224,13 @@ impl InputValidator<'_> { return; } PropertyTypeIr::Relationship { target } => { + if !self.entities.contains_key(target) { + if let Some(dict) = self.dictionaries.get(target) { + let values: Vec> = dict.values().cloned().collect(); + self.validate_enum(value, &values, path); + return; + } + } self.validate_entity(value, target, path); return; } diff --git a/core/engine/src/policy/workspace.rs b/core/engine/src/policy/workspace.rs index 727a650d..c4dca3d5 100644 --- a/core/engine/src/policy/workspace.rs +++ b/core/engine/src/policy/workspace.rs @@ -6,10 +6,10 @@ use crate::policy::raw::PolicyDocument; use zen_expression::nl::NlResult; use crate::policy::types::{ - Completion, ConditionalSchema, Cursor, DependencyNode, Diagnostic, EngineEdit, Entity, - EvaluateRequest, EvaluationError, EvaluationResult, Global, InputProperty, InspectResult, - NlExpression, OutputProperty, PrepareRename, ReferenceSite, RenameTarget, ScopeRequest, - WriteConflict, + Completion, ConditionalSchema, Cursor, DependencyNode, Diagnostic, Dictionary, EngineEdit, + Entity, EvaluateRequest, EvaluationError, EvaluationResult, Global, InputProperty, + InspectResult, NlExpression, OutputProperty, PrepareRename, ReferenceSite, RenameTarget, + ScopeRequest, WriteConflict, }; pub struct PolicyWorkspace { @@ -64,6 +64,10 @@ impl PolicyWorkspace { self.db.globals(req) } + pub fn dictionaries(&self, req: &ScopeRequest) -> Vec { + self.db.dictionaries(req) + } + pub fn inputs(&self, req: &ScopeRequest) -> Vec { self.db.inputs(req) } diff --git a/core/engine/tests/data/policy/diagnostics.toml b/core/engine/tests/data/policy/diagnostics.toml index c1d10290..1b5945d7 100644 --- a/core/engine/tests/data/policy/diagnostics.toml +++ b/core/engine/tests/data/policy/diagnostics.toml @@ -11,311 +11,6 @@ # - `error_codes = [...]` — every listed code must appear at least once. # - `warning_codes = [...]` — same, for warnings. -# ═══════════════════════════════════════════════════════════════════════════════ -# Clean compilations — no errors expected -# ═══════════════════════════════════════════════════════════════════════════════ - -[[test]] -name = "analysis document compiles cleanly" -policies = ["analysis.json"] -policy = "analysis.json" -no_errors = true - -[[test]] -name = "assertion policy compiles cleanly" -policies = ["assertion_policy.json"] -policy = "assertion_policy.json" -no_errors = true - -[[test]] -name = "collect table compiles cleanly" -policies = ["collect_table.json"] -policy = "collect_table.json" -no_errors = true - -# Write-then-read across separate expression blocks: `s1` writes -# `customer.ageGroup`, `s2` reads it. The dependency graph orders `s1` -# before `s2`, so the read resolves with no self-reference. -[[test]] -name = "scope enrichment — write then read across blocks" -policies = ["scope_enrichment.json"] -policy = "scope_enrichment.json" -no_errors = true - -# Ordered use-after-write across blocks: `ds2` reads -# `customer.profitableCompanies` written by `ds1`. With each assignment -# in its own expression block the dependency graph orders `ds1` before -# `ds2`, so the read resolves cleanly. -[[test]] -name = "filter then read across blocks" -policies = ["filter_instanceof.json"] -policy = "filter_instanceof.json" -no_errors = true - -# ═══════════════════════════════════════════════════════════════════════════════ -# Error diagnostics -# ═══════════════════════════════════════════════════════════════════════════════ - -# Mutually-dependent computed properties trip the rule-level cyclic -# dependency detector. (The previous expectation of UndefinedVariable -# reflected an older behavior where the dep-graph edges weren't built -# from dynamic reads.) -[[test]] -name = "mutually dependent computed properties form a dependency cycle" -policies = ["cyclic_deps.json"] -policy = "cyclic_deps.json" -error_codes = ["CyclicDependency"] - -[[test]] -name = "duplicate writer detected" -policies = ["duplicate_writer.json"] -policy = "duplicate_writer.json" -error_codes = ["DuplicateWriter"] - -[[test]] -name = "writing to a DataModel input is rejected" -policies = ["input_override.json"] -policy = "input_override.json" -error_codes = ["InputOverride"] - -[[test]] -name = "undefined variable in expression" -policies = ["undefined_var.json"] -policy = "undefined_var.json" -error_codes = ["UndefinedVariable"] - -[[test]] -name = "parse error in expression" -policies = ["parse_error.json"] -policy = "parse_error.json" -error_codes = ["ParseError"] - -[[test]] -name = "unknown relationship target" -policies = ["unknown_target.json"] -policy = "unknown_target.json" -error_codes = ["UnknownDataModelTarget"] - -[[test]] -name = "conflicting property types across data models" -policies = ["duplicate_entity.json"] -policy = "duplicate_entity.json" -error_codes = ["DataModelCollision"] - -[[test]] -name = "compatible entity merge — same property same type is fine" -policies = ["merge_entity.json"] -policy = "merge_entity.json" -no_errors = true - -[[test]] -name = "compatible entity merge across policies" -policies = ["merge_policy_a.json", "merge_policy_b.json"] -policy = "merge_policy_a.json" -no_errors = true - -[[test]] -name = "import not found" -policies = ["import_not_found.json"] -policy = "import_not_found.json" -error_codes = ["ImportNotFound"] - -[[test]] -name = "circular imports" -policies = ["circular_import_a.json", "circular_import_b.json"] -policy = "circular_import_a.json" -error_codes = ["CircularImport"] - -# ═══════════════════════════════════════════════════════════════════════════════ -# Match exhaustiveness -# ═══════════════════════════════════════════════════════════════════════════════ - -[[test]] -name = "non-exhaustive match without a default arm errors" -policies = ["missing_default_branch.json"] -policy = "missing_default_branch.json" -error_codes = ["MissingDefaultBranch"] - -# ═══════════════════════════════════════════════════════════════════════════════ -# Warning diagnostics -# ═══════════════════════════════════════════════════════════════════════════════ - -[[test]] -name = "empty assertion block warns" -policies = ["empty_blocks.json"] -policy = "empty_blocks.json" -warning_codes = ["EmptyBlock"] - -# ═══════════════════════════════════════════════════════════════════════════════ -# Type mismatch -# ═══════════════════════════════════════════════════════════════════════════════ - -[[test]] -name = "type mismatch across branches" -policies = ["type_mismatch.json"] -policy = "type_mismatch.json" -error_codes = ["TypeMismatch"] - -# ═══════════════════════════════════════════════════════════════════════════════ -# Multi-policy -# ═══════════════════════════════════════════════════════════════════════════════ - -[[test]] -name = "decision table input column invalid field" -policies = ["dt_invalid_field.json"] -policy = "dt_invalid_field.json" -error_codes = ["UndefinedVariable"] - -[[test]] -name = "closure alias invalid member access" -policies = ["closure_invalid_member.json"] -policy = "closure_invalid_member.json" -error_codes = ["UndefinedVariable"] - -[[test]] -name = "block writing to multiple entities is mixed scope" -policies = ["mixed_scope.json"] -policy = "mixed_scope.json" -error_codes = ["MixedScope"] - -# ═══════════════════════════════════════════════════════════════════════════════ -# Multi-policy -# ═══════════════════════════════════════════════════════════════════════════════ - -[[test]] -name = "multi-policy with imports compiles cleanly" -policies = ["multi_main.json", "multi_shared.json"] -policy = "multi_main.json" -no_errors = true - -[[test]] -name = "shared policy also clean" -policies = ["multi_main.json", "multi_shared.json"] -policy = "multi_shared.json" -no_errors = true - -# ═══════════════════════════════════════════════════════════════════════════════ -# Inline-content tests — self-contained fixtures for focused type-system -# behaviors (any-escape detection, built-in call-type inference, etc.). -# `content` inlines the policy JSON so the behavior under test lives next -# to the assertion. -# ═══════════════════════════════════════════════════════════════════════════════ - -# Any-typed value ends up written to a property → must error. -[[test]] -name = "merge([]) yields any[] and errors on write" -content = ''' -{ - "blocks": [ - { - "id": "dm", - "type": "dataModel", - "props": { - "data": { - "name": "customer", - "properties": [ - { - "id": "p1", - "name": "id", - "type": "string", - "array": false, - "optional": false - } - ] - } - } - }, - { - "id": "s1", - "type": "expression", - "props": { - "data": { - "key": "customer.mystery", - "value": "merge([])" - } - } - } - ] -} -''' -error_codes = ["TypeMismatch"] - -[[test]] -name = "reading undefined property surfaces any and errors on write" -content = ''' -{ - "blocks": [ - { - "id": "dm", - "type": "dataModel", - "props": { - "data": { - "name": "customer", - "properties": [ - { - "id": "p1", - "name": "id", - "type": "string", - "array": false, - "optional": false - } - ] - } - } - }, - { - "id": "s1", - "type": "expression", - "props": { - "data": { - "key": "customer.x", - "value": "customer.phantom" - } - } - } - ] -} -''' -error_codes = ["UndefinedVariable", "TypeMismatch"] - -[[test]] -name = "flatten of any-typed input yields array of any and errors" -content = ''' -{ - "blocks": [ - { - "id": "dm", - "type": "dataModel", - "props": { - "data": { - "name": "customer", - "properties": [ - { - "id": "p1", - "name": "id", - "type": "string", - "array": false, - "optional": false - } - ] - } - } - }, - { - "id": "s1", - "type": "expression", - "props": { - "data": { - "key": "customer.x", - "value": "flatten(customer.phantom)" - } - } - } - ] -} -''' -error_codes = ["TypeMismatch"] - # Branch merge widens incompatible types to any → must error. [[test]] name = "two branches writing incompatible types to the same key errors" @@ -323,14 +18,14 @@ content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", "props": { "data": { "name": "customer", "properties": [ { - "id": "p1", + "id": "prop1", "name": "age", "type": "number", "array": false, @@ -341,19 +36,19 @@ content = ''' } }, { - "id": "tree", + "id": "grid", "type": "match", "props": { "data": { "key": "customer.tag", "arms": [ { - "id": "b1", + "id": "branch1", "condition": "customer.age > 18", "value": "42" }, { - "id": "b2", + "id": "branch2", "condition": "", "value": "\"young\"" } @@ -366,108 +61,118 @@ content = ''' ''' error_codes = ["TypeMismatch"] -# Built-in call-type inference — each of these should type-check cleanly -# (no `any` escapes) with the updated type provider. [[test]] -name = "keys and values calls type-check" +name = "collect table compiles cleanly" +policies = ["collect_table.json"] +policy = "collect_table.json" +no_errors = true + +[[test]] +name = "single ternary does not suggest a match block" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", - "props": { - "data": { - "name": "customer", - "properties": [ - { - "id": "p1", - "name": "id", - "type": "string", - "array": false, - "optional": false - } - ] - } - } - }, - { - "id": "s1", - "type": "expression", - "props": { - "data": { - "key": "customer.keyCount", - "value": "len(keys({a: 1, b: 2}))" - } - } + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "prop1", "name": "minutes", "type": "number", "array": false, "optional": false } + ] } } }, { - "id": "s2", + "id": "calc1", "type": "expression", - "props": { - "data": { - "key": "customer.valueSum", - "value": "sum(values({a: 1, b: 2}))" - } - } + "props": { "data": { "key": "threshold", "value": "minutes <= 30 ? 10000 : 18000" } } } ] } ''' no_errors = true +hint_count = 0 [[test]] -name = "merge of object literals" +name = "parse error in expression" +policies = ["parse_error.json"] +policy = "parse_error.json" +error_codes = ["ParseError"] + +[[test]] +name = "conflicting property types across data models" +policies = ["duplicate_entity.json"] +policy = "duplicate_entity.json" +error_codes = ["DataModelCollision"] + +[[test]] +name = "necessary or clarifying parentheses are not flagged" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", - "props": { - "data": { - "name": "customer", - "properties": [ - { - "id": "p1", - "name": "id", - "type": "string", - "array": false, - "optional": false - } - ] - } - } + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "prop1", "name": "a", "type": "number", "array": false, "optional": false }, + { "id": "prop2", "name": "b", "type": "number", "array": false, "optional": false }, + { "id": "prop3", "name": "c", "type": "number", "array": false, "optional": false }, + { "id": "prop4", "name": "flag", "type": "boolean", "array": false, "optional": false }, + { "id": "prop5", "name": "other", "type": "boolean", "array": false, "optional": false }, + { "id": "prop6", "name": "revenue", "type": "number", "array": false, "optional": true } + ] } } }, - { - "id": "s1", - "type": "expression", - "props": { - "data": { - "key": "customer.merged", - "value": "merge([{a: 10}, {b: 20}])" - } - } - } + { "id": "calc1", "type": "expression", "props": { "data": { "key": "row1", "value": "(a + b) * c" } } }, + { "id": "calc2", "type": "expression", "props": { "data": { "key": "row2", "value": "a - (b - c)" } } }, + { "id": "calc3", "type": "expression", "props": { "data": { "key": "row3", "value": "(revenue ?? 0) * 2" } } }, + { "id": "calc4", "type": "expression", "props": { "data": { "key": "row4", "value": "(flag and other) or (a > b)" } } }, + { "id": "calc5", "type": "expression", "props": { "data": { "key": "row5", "value": "flag ? (a + b) : c" } } } ] } ''' no_errors = true +hint_count = 0 [[test]] -name = "mergeDeep of object literals" +name = "analysis document compiles cleanly" +policies = ["analysis.json"] +policy = "analysis.json" +no_errors = true + +[[test]] +name = "writing to a DataModel input is rejected" +policies = ["input_override.json"] +policy = "input_override.json" +error_codes = ["InputOverride"] + +# Warning diagnostics + +[[test]] +name = "empty assertion block warns" +policies = ["empty_blocks.json"] +policy = "empty_blocks.json" +warning_codes = ["EmptyBlock"] + +[[test]] +name = "circular imports" +policies = ["circular_import_a.json", "circular_import_b.json"] +policy = "circular_import_a.json" +error_codes = ["CircularImport"] + +# A single malformed expression must produce at most one diagnostic per +# failure mode: the real error (`+` on incompatible types) plus the `any` +# backstop — not duplicates from nested per-statement / post-merge checks. +[[test]] +name = "malformed expression emits at most one any-error alongside the real error" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", "props": { "data": { - "name": "customer", + "name": "employee", "properties": [ { - "id": "p1", + "id": "prop1", "name": "id", "type": "string", "array": false, @@ -478,51 +183,120 @@ content = ''' } }, { - "id": "s1", + "id": "step1", "type": "expression", "props": { "data": { - "key": "customer.cfg", - "value": "mergeDeep([{a: 1, nested: {x: 10}}, {b: 2, nested: {y: 20}}])" + "key": "employee.asd", + "value": "'hello' + 123" } } } ] } ''' +error_codes = ["TypeMismatch"] +error_count = 2 + +# Disjoint nested assembly across blocks (no whole-object write of +# `portfolio`) merges cleanly at runtime via `dot_insert`, so it is allowed. +[[test]] +name = "disjoint nested assembly across blocks is allowed" +policy = "p" no_errors = true +content = ''' +{ + "blocks": [ + { "id": "schema", "type": "dataModel", "props": { "data": { + "name": "globals", "scope": "global", + "properties": [ + { "id": "gate1", "name": "principal", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "step1", "type": "expression", "props": { "data": { "key": "portfolio.byBucket.CURRENT.balance", "value": "principal * 0.7" } } }, + { "id": "step2", "type": "expression", "props": { "data": { "key": "portfolio.byBucket.LATE.balance", "value": "principal * 0.3" } } } + ] +} +''' +# Ordered use-after-write across blocks: `ds2` reads +# `customer.profitableCompanies` written by `ds1`. With each assignment +# in its own expression block the dependency graph orders `ds1` before +# `ds2`, so the read resolves cleanly. [[test]] -name = "flatten of nested array peels to concrete element type" +name = "filter then read across blocks" +policies = ["filter_instanceof.json"] +policy = "filter_instanceof.json" +no_errors = true + +[[test]] +name = "import not found" +policies = ["import_not_found.json"] +policy = "import_not_found.json" +error_codes = ["ImportNotFound"] + +[[test]] +name = "parentheses around higher-precedence arithmetic are flagged" +content = ''' +{ + "blocks": [ + { + "id": "schema", + "type": "dataModel", + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "prop1", "name": "a", "type": "number", "array": false, "optional": false }, + { "id": "prop2", "name": "b", "type": "number", "array": false, "optional": false }, + { "id": "prop3", "name": "c", "type": "number", "array": false, "optional": false } + ] } } + }, + { "id": "calc1", "type": "expression", "props": { "data": { "key": "due", "value": "(a * b) + c" } } } + ] +} +''' +no_errors = true +hint_codes = ["RedundantParentheses"] +hint_count = 1 + +# Indexing an optional relationship array then reading a field of the element +# must not surface a false UndefinedVariable: `customer.companies[0].revenue` +# walks through an array element the scope walker can't traverse, but the +# expression type-checks cleanly. +[[test]] +name = "optional relationship array indexing resolves element field" content = ''' { "blocks": [ { - "id": "dm", + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { "id": "prop1", "name": "id", "type": "string", "array": false, "optional": false }, + { "id": "prop2", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + } + } + }, + { + "id": "dm-customer", "type": "dataModel", "props": { "data": { "name": "customer", "properties": [ - { - "id": "p1", - "name": "id", - "type": "string", - "array": false, - "optional": false - } + { "id": "prop3", "name": "name", "type": "string", "array": false, "optional": false }, + { "id": "prop4", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": true } ] } } }, { - "id": "s1", + "id": "step1", "type": "expression", "props": { - "data": { - "key": "customer.nums", - "value": "flatten([[1, 2], [3, 4]])" - } + "data": { "key": "customer.topRevenue", "value": "customer.companies[0].revenue" } } } ] @@ -531,19 +305,19 @@ content = ''' no_errors = true [[test]] -name = "values yields union of field types" +name = "merge of object literals" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", "props": { "data": { "name": "customer", "properties": [ { - "id": "p1", + "id": "prop1", "name": "id", "type": "string", "array": false, @@ -554,12 +328,12 @@ content = ''' } }, { - "id": "s1", + "id": "step1", "type": "expression", "props": { "data": { - "key": "customer.vs", - "value": "values({a: 1, b: 2, c: 3})" + "key": "customer.merged", + "value": "merge([{a: 10}, {b: 20}])" } } } @@ -568,63 +342,92 @@ content = ''' ''' no_errors = true -# A single malformed expression must produce at most one diagnostic per -# failure mode: the real error (`+` on incompatible types) plus the `any` -# backstop — not duplicates from nested per-statement / post-merge checks. [[test]] -name = "malformed expression emits at most one any-error alongside the real error" +name = "assertion policy compiles cleanly" +policies = ["assertion_policy.json"] +policy = "assertion_policy.json" +no_errors = true + +[[test]] +name = "unknown relationship target" +policies = ["unknown_target.json"] +policy = "unknown_target.json" +error_codes = ["UnknownDataModelTarget"] + +[[test]] +name = "closure alias invalid member access" +policies = ["closure_invalid_member.json"] +policy = "closure_invalid_member.json" +error_codes = ["UndefinedVariable"] + +# Multi-policy + +[[test]] +name = "decision table input column invalid field" +policies = ["dt_invalid_field.json"] +policy = "dt_invalid_field.json" +error_codes = ["UndefinedVariable"] + +[[test]] +name = "duplicate row and non-discriminating column are flagged" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", - "props": { - "data": { - "name": "employee", - "properties": [ - { - "id": "p1", - "name": "id", - "type": "string", - "array": false, - "optional": false - } - ] - } - } + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "prop1", "name": "kind", "type": "string", "array": false, "optional": false }, + { "id": "prop2", "name": "tier", "type": "string", "array": false, "optional": false } + ] } } }, { - "id": "s1", - "type": "expression", - "props": { - "data": { - "key": "employee.asd", - "value": "'hello' + 123" - } - } + "id": "table1", + "type": "decisionTable", + "props": { "data": { + "hitPolicy": "first", + "inputs": [ + { "id": "col1", "name": "Kind", "field": "kind" }, + { "id": "col2", "name": "Tier", "field": "tier" } + ], + "outputs": [ { "id": "out1", "name": "Rate", "field": "rate" } ], + "rules": [ + { "_id": "row1", "col1": "\"card\"", "col2": "\"high\"", "out1": "0.012" }, + { "_id": "row2", "col1": "\"loan\"", "col2": "\"high\"", "out1": "0.012" }, + { "_id": "row3", "col1": "\"card\"", "col2": "\"high\"", "out1": "0.012" }, + { "_id": "row4", "col1": "", "col2": "", "out1": "0.012" } + ] + } } } ] } ''' -error_codes = ["TypeMismatch"] -error_count = 2 +no_errors = true +hint_codes = ["RedundantTableRow", "NonDiscriminatingColumn"] +hint_count = 2 + +# Multi-policy -# Intra-tree forward reads: statement 2 sees statement 1's write. [[test]] -name = "intra-tree forward reads see earlier top-level writes" +name = "multi-policy with imports compiles cleanly" +policies = ["multi_main.json", "multi_shared.json"] +policy = "multi_main.json" +no_errors = true + +[[test]] +name = "flatten of any-typed input yields array of any and errors" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", "props": { "data": { - "name": "employee", + "name": "customer", "properties": [ { - "id": "p1", + "id": "prop1", "name": "id", "type": "string", "array": false, @@ -635,126 +438,101 @@ content = ''' } }, { - "id": "s1", - "type": "expression", - "props": { - "data": { - "key": "employee.som", - "value": "merge([{a: 10}])" - } - } - }, - { - "id": "s2", + "id": "step1", "type": "expression", "props": { "data": { - "key": "employee.aa", - "value": "employee.som" + "key": "customer.x", + "value": "flatten(customer.phantom)" } } } ] } ''' +error_codes = ["TypeMismatch"] + +[[test]] +name = "shared policy also clean" +policies = ["multi_main.json", "multi_shared.json"] +policy = "multi_shared.json" no_errors = true -# Indexing an optional relationship array then reading a field of the element -# must not surface a false UndefinedVariable: `customer.companies[0].revenue` -# walks through an array element the scope walker can't traverse, but the -# expression type-checks cleanly. [[test]] -name = "optional relationship array indexing resolves element field" +name = "compatible entity merge across policies" +policies = ["merge_policy_a.json", "merge_policy_b.json"] +policy = "merge_policy_a.json" +no_errors = true + +[[test]] +name = "valued column gating fall-through to a different-output catch-all is not flagged" content = ''' { "blocks": [ { - "id": "dm-company", - "type": "dataModel", - "props": { - "data": { - "name": "company", - "properties": [ - { "id": "p1", "name": "id", "type": "string", "array": false, "optional": false }, - { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } - ] - } - } - }, - { - "id": "dm-customer", + "id": "schema", "type": "dataModel", - "props": { - "data": { - "name": "customer", - "properties": [ - { "id": "p3", "name": "name", "type": "string", "array": false, "optional": false }, - { "id": "p4", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": true } - ] - } - } + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "prop1", "name": "dateKey", "type": "string", "array": false, "optional": false }, + { "id": "prop2", "name": "item", "type": "string", "array": false, "optional": false } + ] } } }, { - "id": "s1", - "type": "expression", - "props": { - "data": { "key": "customer.topRevenue", "value": "customer.companies[0].revenue" } - } + "id": "table1", + "type": "decisionTable", + "props": { "data": { + "hitPolicy": "first", + "inputs": [ + { "id": "col1", "name": "Date", "field": "dateKey" }, + { "id": "col2", "name": "Item", "field": "item" } + ], + "outputs": [ { "id": "out1", "name": "Pct", "field": "pct" } ], + "rules": [ + { "_id": "row1", "col1": "\"2024-01-01\"", "col2": "\"PREMIUM\"", "out1": "0.25" }, + { "_id": "row2", "col1": "\"2024-01-01\"", "col2": "\"STANDARD\"", "out1": "0.25" }, + { "_id": "row3", "col1": "\"2018-01-01\"", "col2": "\"PREMIUM\"", "out1": "0.2" }, + { "_id": "row4", "col1": "\"2018-01-01\"", "col2": "\"STANDARD\"", "out1": "0.2" }, + { "_id": "row5", "col1": "", "col2": "", "out1": "0" } + ] + } } } ] } ''' no_errors = true +hint_count = 0 -# A field computed per-entity (`customer.riskScore`) must be visible in a -# global-scope expression that reaches the entity through a relationship array -# whose collection is itself a `filter(...)`. The alias `c` binds through the -# filter to `customers`, so `c.riskScore` resolves and the per-entity writer -# is ordered before the global aggregate. [[test]] -name = "computed entity field read in global scope through filtered relationship" +name = "values yields union of field types" content = ''' { "blocks": [ { - "id": "dm-customer", + "id": "schema", "type": "dataModel", "props": { "data": { "name": "customer", "properties": [ - { "id": "p1", "name": "name", "type": "string", "array": false, "optional": false }, - { "id": "p2", "name": "country", "type": "string", "array": false, "optional": false }, - { "id": "p3", "name": "income", "type": "number", "array": false, "optional": false } - ] - } - } - }, - { - "id": "dm-global", - "type": "dataModel", - "props": { - "data": { - "name": "globals", - "scope": "global", - "properties": [ - { "id": "g1", "name": "customers", "type": "relationship", "target": "customer", "array": true, "optional": false } + { + "id": "prop1", + "name": "id", + "type": "string", + "array": false, + "optional": false + } ] } } }, { - "id": "rs1", - "type": "expression", - "props": { - "data": { "key": "customer.riskScore", "value": "customer.income * 0.1" } - } - }, - { - "id": "as1", + "id": "step1", "type": "expression", "props": { - "data": { "key": "totalRisk", "value": "sum(map(filter(customers as c, c.country == \"US\") as x, x.riskScore))" } + "data": { + "key": "customer.vs", + "value": "values({a: 1, b: 2, c: 3})" + } } } ] @@ -763,50 +541,54 @@ content = ''' no_errors = true [[test]] -name = "closure alias over an unresolvable collection is not flagged as undefined" -policy = "p" -no_errors = true +name = "duplicate writer detected" +policies = ["duplicate_writer.json"] +policy = "duplicate_writer.json" +error_codes = ["DuplicateWriter"] + +# Inline-content tests — self-contained fixtures for focused type-system +# behaviors (any-escape detection, built-in call-type inference, etc.). +# `content` inlines the policy JSON so the behavior under test lives next +# to the assertion. + +# Any-typed value ends up written to a property → must error. +[[test]] +name = "merge([]) yields any[] and errors on write" content = ''' { "blocks": [ { - "id": "dm-entry", + "id": "schema", "type": "dataModel", "props": { "data": { - "name": "entry", - "scope": "entity", + "name": "customer", "properties": [ - { "id": "e1", "name": "amount", "type": "number", "array": false, "optional": false } + { + "id": "prop1", + "name": "id", + "type": "string", + "array": false, + "optional": false + } ] } } }, { - "id": "dm-global", - "type": "dataModel", + "id": "step1", + "type": "expression", "props": { "data": { - "name": "globals", - "scope": "global", - "properties": [ - { "id": "g1", "name": "flag", "type": "boolean", "array": false, "optional": false }, - { "id": "g2", "name": "listA", "type": "relationship", "target": "entry", "array": true, "optional": false }, - { "id": "g3", "name": "listB", "type": "relationship", "target": "entry", "array": true, "optional": false } - ] + "key": "customer.mystery", + "value": "merge([])" } } - }, - { - "id": "s1", - "type": "expression", - "props": { - "data": { "key": "total", "value": "sum(map((flag ? listA : listB) as t, t.amount))" } - } } ] } ''' +error_codes = ["TypeMismatch"] [[test]] name = "nested computed write paths build an object spine without errors" @@ -815,85 +597,108 @@ no_errors = true content = ''' { "blocks": [ - { "id": "dm", "type": "dataModel", "props": { "data": { + { "id": "schema", "type": "dataModel", "props": { "data": { "name": "globals", "scope": "global", "properties": [ - { "id": "g1", "name": "principal", "type": "number", "array": false, "optional": false } + { "id": "gate1", "name": "principal", "type": "number", "array": false, "optional": false } ] }}}, - { "id": "ra1", "type": "expression", "props": { "data": { "key": "rate", "value": "principal > 100000 ? 0.05 : 0.04" } } }, - { "id": "s1", "type": "expression", "props": { "data": { "key": "loanSummary.byBucket.CURRENT.interestDue", "value": "principal * rate" } } }, - { "id": "s2", "type": "expression", "props": { "data": { "key": "loanSummary.grandTotal", "value": "principal * (1 + rate)" } } }, - { "id": "u1", "type": "expression", "props": { "data": { "key": "tierLabel", "value": "loanSummary.grandTotal > 10000 ? \"large\" : \"standard\"" } } }, - { "id": "u2", "type": "expression", "props": { "data": { "key": "bucketCount", "value": "len(keys(loanSummary.byBucket))" } } } + { "id": "assert1", "type": "expression", "props": { "data": { "key": "rate", "value": "principal > 100000 ? 0.05 : 0.04" } } }, + { "id": "step1", "type": "expression", "props": { "data": { "key": "loanSummary.byBucket.CURRENT.interestDue", "value": "principal * rate" } } }, + { "id": "step2", "type": "expression", "props": { "data": { "key": "loanSummary.grandTotal", "value": "principal * (1 + rate)" } } }, + { "id": "unit1", "type": "expression", "props": { "data": { "key": "tierLabel", "value": "loanSummary.grandTotal > 10000 ? \"large\" : \"standard\"" } } }, + { "id": "unit2", "type": "expression", "props": { "data": { "key": "bucketCount", "value": "len(keys(loanSummary.byBucket))" } } } ] } ''' -# Disjoint nested assembly across blocks (no whole-object write of -# `portfolio`) merges cleanly at runtime via `dot_insert`, so it is allowed. +# Type mismatch + [[test]] -name = "disjoint nested assembly across blocks is allowed" -policy = "p" -no_errors = true +name = "type mismatch across branches" +policies = ["type_mismatch.json"] +policy = "type_mismatch.json" +error_codes = ["TypeMismatch"] + +[[test]] +name = "small derivation repeated only twice is not flagged" content = ''' { "blocks": [ - { "id": "dm", "type": "dataModel", "props": { "data": { - "name": "globals", "scope": "global", - "properties": [ - { "id": "g1", "name": "principal", "type": "number", "array": false, "optional": false } - ] - }}}, - { "id": "s1", "type": "expression", "props": { "data": { "key": "portfolio.byBucket.CURRENT.balance", "value": "principal * 0.7" } } }, - { "id": "s2", "type": "expression", "props": { "data": { "key": "portfolio.byBucket.LATE.balance", "value": "principal * 0.3" } } } + { + "id": "schema", + "type": "dataModel", + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "prop1", "name": "revenue", "type": "number", "array": false, "optional": true } + ] } } + }, + { "id": "calc1", "type": "expression", "props": { "data": { "key": "a", "value": "(revenue ?? 0) * 2" } } }, + { "id": "calc2", "type": "expression", "props": { "data": { "key": "b", "value": "(revenue ?? 0) + 1" } } } ] } ''' +no_errors = true +hint_count = 0 -# A single block writing an object whole AND into a nested path of it: the -# whole-object write clobbers the nested write at runtime. Must be flagged. [[test]] -name = "single block writes whole object and a nested path is flagged" -policy = "p" -error_codes = ["PartialObjectWrite"] +name = "row shadowed by an earlier wildcard row is flagged as unreachable" content = ''' { "blocks": [ - { "id": "dm", "type": "dataModel", "props": { "data": { - "name": "globals", "scope": "global", - "properties": [ - { "id": "g1", "name": "principal", "type": "number", "array": false, "optional": false } - ] - }}}, - { "id": "s1", "type": "expression", "props": { "data": { "key": "summary", "value": "{ grandTotal: principal }" } } }, - { "id": "s2", "type": "expression", "props": { "data": { "key": "summary.byBucket.CURRENT.balance", "value": "principal * 0.7" } } } + { + "id": "schema", + "type": "dataModel", + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "prop1", "name": "kind", "type": "string", "array": false, "optional": false }, + { "id": "prop2", "name": "tier", "type": "string", "array": false, "optional": false } + ] } } + }, + { + "id": "table1", + "type": "decisionTable", + "props": { "data": { + "hitPolicy": "first", + "inputs": [ + { "id": "col1", "name": "Kind", "field": "kind" }, + { "id": "col2", "name": "Tier", "field": "tier" } + ], + "outputs": [ { "id": "out1", "name": "Rate", "field": "rate" } ], + "rules": [ + { "_id": "row1", "col1": "", "col2": "\"high\"", "out1": "1" }, + { "_id": "row2", "col1": "\"card\"", "col2": "\"high\"", "out1": "2" } + ] + } } + } ] } ''' +no_errors = true +hint_codes = ["RedundantTableRow"] +hint_count = 1 [[test]] -name = "whole-object write plus a nested write to the same object is flagged" -policy = "p" -error_codes = ["PartialObjectWrite"] +name = "redundant parentheses inside arguments and at the root are flagged" content = ''' { "blocks": [ - { "id": "dm", "type": "dataModel", "props": { "data": { - "name": "globals", "scope": "global", - "properties": [ - { "id": "g1", "name": "principal", "type": "number", "array": false, "optional": false } - ] - }}}, - { "id": "s1", "type": "expression", "props": { "data": { "key": "summary", "value": "{ byBucket: { CURRENT: { balance: principal } }, grandTotal: principal }" } } }, - { "id": "s2", "type": "expression", "props": { "data": { "key": "summary.byBucket.CURRENT.balance", "value": "0" } } } + { + "id": "schema", + "type": "dataModel", + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "prop1", "name": "a", "type": "number", "array": false, "optional": false }, + { "id": "prop2", "name": "b", "type": "number", "array": false, "optional": false } + ] } } + }, + { "id": "calc1", "type": "expression", "props": { "data": { "key": "row1", "value": "(a + b)" } } }, + { "id": "calc2", "type": "expression", "props": { "data": { "key": "row2", "value": "abs((a + b))" } } } ] } ''' +no_errors = true +hint_codes = ["RedundantParentheses"] +hint_count = 2 -# ═══════════════════════════════════════════════════════════════════════════════ # Lints (hint severity) -# ═══════════════════════════════════════════════════════════════════════════════ [[test]] name = "chained ternary over one scrutinee suggests a match block" @@ -901,14 +706,14 @@ content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "minutes", "type": "number", "array": false, "optional": false } + { "id": "prop1", "name": "minutes", "type": "number", "array": false, "optional": false } ] } } }, { - "id": "e1", + "id": "calc1", "type": "expression", "props": { "data": { "key": "threshold", "value": "minutes <= 30 ? 10000 : minutes <= 60 ? 18000 : minutes <= 90 ? 24000 : 18000" } } } @@ -920,27 +725,57 @@ hint_codes = ["PreferMatch"] hint_count = 1 [[test]] -name = "single ternary does not suggest a match block" +name = "complex derivation repeated twice is flagged without double-counting nested fragments" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "minutes", "type": "number", "array": false, "optional": false } + { "id": "prop1", "name": "revenue", "type": "number", "array": false, "optional": true }, + { "id": "prop2", "name": "rate", "type": "number", "array": false, "optional": true }, + { "id": "prop3", "name": "base", "type": "number", "array": false, "optional": false } ] } } }, - { - "id": "e1", - "type": "expression", - "props": { "data": { "key": "threshold", "value": "minutes <= 30 ? 10000 : 18000" } } - } + { "id": "calc1", "type": "expression", "props": { "data": { "key": "a", "value": "(revenue ?? 0) * (rate ?? 1) + base" } } }, + { "id": "calc2", "type": "expression", "props": { "data": { "key": "b", "value": "(revenue ?? 0) * (rate ?? 1) + base" } } } ] } ''' no_errors = true -hint_count = 0 +hint_codes = ["RepeatedDerivation"] +hint_count = 2 + +# A single block writing an object whole AND into a nested path of it: the +# whole-object write clobbers the nested write at runtime. Must be flagged. +[[test]] +name = "single block writes whole object and a nested path is flagged" +policy = "p" +error_codes = ["PartialObjectWrite"] +content = ''' +{ + "blocks": [ + { "id": "schema", "type": "dataModel", "props": { "data": { + "name": "globals", "scope": "global", + "properties": [ + { "id": "gate1", "name": "principal", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "step1", "type": "expression", "props": { "data": { "key": "summary", "value": "{ grandTotal: principal }" } } }, + { "id": "step2", "type": "expression", "props": { "data": { "key": "summary.byBucket.CURRENT.balance", "value": "principal * 0.7" } } } + ] +} +''' + +# Write-then-read across separate expression blocks: `s1` writes +# `customer.ageGroup`, `s2` reads it. The dependency graph orders `s1` +# before `s2`, so the read resolves with no self-reference. +[[test]] +name = "scope enrichment — write then read across blocks" +policies = ["scope_enrichment.json"] +policy = "scope_enrichment.json" +no_errors = true [[test]] name = "small derivation repeated three times is flagged at every site" @@ -948,15 +783,15 @@ content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "revenue", "type": "number", "array": false, "optional": true } + { "id": "prop1", "name": "revenue", "type": "number", "array": false, "optional": true } ] } } }, - { "id": "e1", "type": "expression", "props": { "data": { "key": "a", "value": "(revenue ?? 0) * 2" } } }, - { "id": "e2", "type": "expression", "props": { "data": { "key": "b", "value": "(revenue ?? 0) + 1" } } }, - { "id": "e3", "type": "expression", "props": { "data": { "key": "c", "value": "(revenue ?? 0) / 4" } } } + { "id": "calc1", "type": "expression", "props": { "data": { "key": "a", "value": "(revenue ?? 0) * 2" } } }, + { "id": "calc2", "type": "expression", "props": { "data": { "key": "b", "value": "(revenue ?? 0) + 1" } } }, + { "id": "calc3", "type": "expression", "props": { "data": { "key": "c", "value": "(revenue ?? 0) / 4" } } } ] } ''' @@ -964,207 +799,281 @@ no_errors = true hint_codes = ["RepeatedDerivation"] hint_count = 3 +# Built-in call-type inference — each of these should type-check cleanly +# (no `any` escapes) with the updated type provider. [[test]] -name = "small derivation repeated only twice is not flagged" +name = "keys and values calls type-check" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", - "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "revenue", "type": "number", "array": false, "optional": true } - ] } } + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "prop1", + "name": "id", + "type": "string", + "array": false, + "optional": false + } + ] + } + } + }, + { + "id": "step1", + "type": "expression", + "props": { + "data": { + "key": "customer.keyCount", + "value": "len(keys({a: 1, b: 2}))" + } + } }, - { "id": "e1", "type": "expression", "props": { "data": { "key": "a", "value": "(revenue ?? 0) * 2" } } }, - { "id": "e2", "type": "expression", "props": { "data": { "key": "b", "value": "(revenue ?? 0) + 1" } } } + { + "id": "step2", + "type": "expression", + "props": { + "data": { + "key": "customer.valueSum", + "value": "sum(values({a: 1, b: 2}))" + } + } + } ] } ''' no_errors = true -hint_count = 0 [[test]] -name = "complex derivation repeated twice is flagged without double-counting nested fragments" +name = "nullish coalesce on an optional property is not flagged" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "revenue", "type": "number", "array": false, "optional": true }, - { "id": "p2", "name": "rate", "type": "number", "array": false, "optional": true }, - { "id": "p3", "name": "base", "type": "number", "array": false, "optional": false } + { "id": "prop1", "name": "score", "type": "number", "array": false, "optional": true } ] } } }, - { "id": "e1", "type": "expression", "props": { "data": { "key": "a", "value": "(revenue ?? 0) * (rate ?? 1) + base" } } }, - { "id": "e2", "type": "expression", "props": { "data": { "key": "b", "value": "(revenue ?? 0) * (rate ?? 1) + base" } } } + { "id": "calc1", "type": "expression", "props": { "data": { "key": "adjusted", "value": "(score ?? 0) + 1" } } } ] } ''' no_errors = true -hint_codes = ["RepeatedDerivation"] -hint_count = 2 - -[[test]] -name = "derivation repeated across imported policies is flagged in the importing policy" -policies = ["lint_shared_main.json", "lint_shared_dep.json"] -policy = "lint_shared_main.json" -no_errors = true -hint_codes = ["RepeatedDerivation"] -hint_count = 1 +hint_count = 0 [[test]] -name = "duplicate row and non-discriminating column are flagged" +name = "parentheses around a lone identifier are flagged" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "kind", "type": "string", "array": false, "optional": false }, - { "id": "p2", "name": "tier", "type": "string", "array": false, "optional": false } + { "id": "prop1", "name": "principal", "type": "number", "array": false, "optional": false }, + { "id": "prop2", "name": "interestRate", "type": "number", "array": false, "optional": false }, + { "id": "prop3", "name": "feePct", "type": "number", "array": false, "optional": false } ] } } }, - { - "id": "t1", - "type": "decisionTable", - "props": { "data": { - "hitPolicy": "first", - "inputs": [ - { "id": "i1", "name": "Kind", "field": "kind" }, - { "id": "i2", "name": "Tier", "field": "tier" } - ], - "outputs": [ { "id": "o1", "name": "Rate", "field": "rate" } ], - "rules": [ - { "_id": "r1", "i1": "\"card\"", "i2": "\"high\"", "o1": "0.012" }, - { "_id": "r2", "i1": "\"loan\"", "i2": "\"high\"", "o1": "0.012" }, - { "_id": "r3", "i1": "\"card\"", "i2": "\"high\"", "o1": "0.012" }, - { "_id": "r4", "i1": "", "i2": "", "o1": "0.012" } - ] - } } - } + { "id": "calc1", "type": "expression", "props": { "data": { "key": "due", "value": "(principal) * interestRate * feePct" } } } ] } ''' no_errors = true -hint_codes = ["RedundantTableRow", "NonDiscriminatingColumn"] -hint_count = 2 +hint_codes = ["RedundantParentheses"] +hint_count = 1 [[test]] -name = "row shadowed by an earlier wildcard row is flagged as unreachable" +name = "mergeDeep of object literals" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", - "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "kind", "type": "string", "array": false, "optional": false }, - { "id": "p2", "name": "tier", "type": "string", "array": false, "optional": false } - ] } } + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "prop1", + "name": "id", + "type": "string", + "array": false, + "optional": false + } + ] + } + } }, { - "id": "t1", - "type": "decisionTable", - "props": { "data": { - "hitPolicy": "first", - "inputs": [ - { "id": "i1", "name": "Kind", "field": "kind" }, - { "id": "i2", "name": "Tier", "field": "tier" } - ], - "outputs": [ { "id": "o1", "name": "Rate", "field": "rate" } ], - "rules": [ - { "_id": "r1", "i1": "", "i2": "\"high\"", "o1": "1" }, - { "_id": "r2", "i1": "\"card\"", "i2": "\"high\"", "o1": "2" } - ] - } } + "id": "step1", + "type": "expression", + "props": { + "data": { + "key": "customer.cfg", + "value": "mergeDeep([{a: 1, nested: {x: 10}}, {b: 2, nested: {y: 20}}])" + } + } } ] } ''' no_errors = true -hint_codes = ["RedundantTableRow"] -hint_count = 1 +# Error diagnostics + +# Mutually-dependent computed properties trip the rule-level cyclic +# dependency detector. (The previous expectation of UndefinedVariable +# reflected an older behavior where the dep-graph edges weren't built +# from dynamic reads.) [[test]] -name = "nullish coalesce on a non-nullable property is redundant" +name = "mutually dependent computed properties form a dependency cycle" +policies = ["cyclic_deps.json"] +policy = "cyclic_deps.json" +error_codes = ["CyclicDependency"] + +[[test]] +name = "undefined variable in expression" +policies = ["undefined_var.json"] +policy = "undefined_var.json" +error_codes = ["UndefinedVariable"] + +# Match exhaustiveness + +[[test]] +name = "non-exhaustive match without a default arm errors" +policies = ["missing_default_branch.json"] +policy = "missing_default_branch.json" +error_codes = ["MissingDefaultBranch"] + +[[test]] +name = "whole-object write plus a nested write to the same object is flagged" +policy = "p" +error_codes = ["PartialObjectWrite"] content = ''' { "blocks": [ - { - "id": "dm", - "type": "dataModel", - "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "score", "type": "number", "array": false, "optional": false } - ] } } - }, - { "id": "e1", "type": "expression", "props": { "data": { "key": "adjusted", "value": "(score ?? 0) + 1" } } } + { "id": "schema", "type": "dataModel", "props": { "data": { + "name": "globals", "scope": "global", + "properties": [ + { "id": "gate1", "name": "principal", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "step1", "type": "expression", "props": { "data": { "key": "summary", "value": "{ byBucket: { CURRENT: { balance: principal } }, grandTotal: principal }" } } }, + { "id": "step2", "type": "expression", "props": { "data": { "key": "summary.byBucket.CURRENT.balance", "value": "0" } } } ] } ''' -no_errors = true -hint_codes = ["RedundantNullish"] -hint_count = 1 +# A field computed per-entity (`customer.riskScore`) must be visible in a +# global-scope expression that reaches the entity through a relationship array +# whose collection is itself a `filter(...)`. The alias `c` binds through the +# filter to `customers`, so `c.riskScore` resolves and the per-entity writer +# is ordered before the global aggregate. [[test]] -name = "nullish coalesce on an optional property is not flagged" +name = "computed entity field read in global scope through filtered relationship" content = ''' { "blocks": [ { - "id": "dm", + "id": "dm-customer", "type": "dataModel", - "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "score", "type": "number", "array": false, "optional": true } - ] } } + "props": { + "data": { + "name": "customer", + "properties": [ + { "id": "prop1", "name": "name", "type": "string", "array": false, "optional": false }, + { "id": "prop2", "name": "country", "type": "string", "array": false, "optional": false }, + { "id": "prop3", "name": "income", "type": "number", "array": false, "optional": false } + ] + } + } + }, + { + "id": "dm-global", + "type": "dataModel", + "props": { + "data": { + "name": "globals", + "scope": "global", + "properties": [ + { "id": "gate1", "name": "customers", "type": "relationship", "target": "customer", "array": true, "optional": false } + ] + } + } + }, + { + "id": "rule1", + "type": "expression", + "props": { + "data": { "key": "customer.riskScore", "value": "customer.income * 0.1" } + } }, - { "id": "e1", "type": "expression", "props": { "data": { "key": "adjusted", "value": "(score ?? 0) + 1" } } } + { + "id": "check1", + "type": "expression", + "props": { + "data": { "key": "totalRisk", "value": "sum(map(filter(customers as c, c.country == \"US\") as x, x.riskScore))" } + } + } ] } ''' no_errors = true -hint_count = 0 [[test]] -name = "valued column gating fall-through to a different-output catch-all is not flagged" +name = "closure alias over an unresolvable collection is not flagged as undefined" +policy = "p" +no_errors = true content = ''' { "blocks": [ { - "id": "dm", + "id": "dm-entry", "type": "dataModel", - "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "dateKey", "type": "string", "array": false, "optional": false }, - { "id": "p2", "name": "item", "type": "string", "array": false, "optional": false } - ] } } + "props": { + "data": { + "name": "entry", + "scope": "entity", + "properties": [ + { "id": "calc1", "name": "amount", "type": "number", "array": false, "optional": false } + ] + } + } }, { - "id": "t1", - "type": "decisionTable", - "props": { "data": { - "hitPolicy": "first", - "inputs": [ - { "id": "i1", "name": "Date", "field": "dateKey" }, - { "id": "i2", "name": "Item", "field": "item" } - ], - "outputs": [ { "id": "o1", "name": "Pct", "field": "pct" } ], - "rules": [ - { "_id": "r1", "i1": "\"2022-07-01\"", "i2": "\"SVOD\"", "o1": "0.198" }, - { "_id": "r2", "i1": "\"2022-07-01\"", "i2": "\"NBC\"", "o1": "0.198" }, - { "_id": "r3", "i1": "\"2014-07-01\"", "i2": "\"SVOD\"", "o1": "0.166" }, - { "_id": "r4", "i1": "\"2014-07-01\"", "i2": "\"NBC\"", "o1": "0.166" }, - { "_id": "r5", "i1": "", "i2": "", "o1": "0" } - ] - } } + "id": "dm-global", + "type": "dataModel", + "props": { + "data": { + "name": "globals", + "scope": "global", + "properties": [ + { "id": "gate1", "name": "flag", "type": "boolean", "array": false, "optional": false }, + { "id": "gate2", "name": "listA", "type": "relationship", "target": "entry", "array": true, "optional": false }, + { "id": "gate3", "name": "listB", "type": "relationship", "target": "entry", "array": true, "optional": false } + ] + } + } + }, + { + "id": "step1", + "type": "expression", + "props": { + "data": { "key": "total", "value": "sum(map((flag ? listA : listB) as t, t.amount))" } + } } ] } ''' -no_errors = true -hint_count = 0 [[test]] name = "valued column with an in-group wildcard row is safe to flag" @@ -1172,27 +1081,27 @@ content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "dateKey", "type": "string", "array": false, "optional": false }, - { "id": "p2", "name": "item", "type": "string", "array": false, "optional": false } + { "id": "prop1", "name": "dateKey", "type": "string", "array": false, "optional": false }, + { "id": "prop2", "name": "item", "type": "string", "array": false, "optional": false } ] } } }, { - "id": "t1", + "id": "table1", "type": "decisionTable", "props": { "data": { "hitPolicy": "first", "inputs": [ - { "id": "i1", "name": "Date", "field": "dateKey" }, - { "id": "i2", "name": "Item", "field": "item" } + { "id": "col1", "name": "Date", "field": "dateKey" }, + { "id": "col2", "name": "Item", "field": "item" } ], - "outputs": [ { "id": "o1", "name": "Pct", "field": "pct" } ], + "outputs": [ { "id": "out1", "name": "Pct", "field": "pct" } ], "rules": [ - { "_id": "r1", "i1": "\"2022-07-01\"", "i2": "\"SVOD\"", "o1": "0.198" }, - { "_id": "r2", "i1": "\"2022-07-01\"", "i2": "", "o1": "0.198" }, - { "_id": "r3", "i1": "", "i2": "", "o1": "0" } + { "_id": "row1", "col1": "\"2024-01-01\"", "col2": "\"PREMIUM\"", "out1": "0.25" }, + { "_id": "row2", "col1": "\"2024-01-01\"", "col2": "", "out1": "0.25" }, + { "_id": "row3", "col1": "", "col2": "", "out1": "0" } ] } } } @@ -1204,117 +1113,188 @@ hint_codes = ["NonDiscriminatingColumn"] hint_count = 1 [[test]] -name = "parentheses around a lone identifier are flagged" +name = "block writing to multiple entities is mixed scope" +policies = ["mixed_scope.json"] +policy = "mixed_scope.json" +error_codes = ["MixedScope"] + +[[test]] +name = "reading undefined property surfaces any and errors on write" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", - "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "principal", "type": "number", "array": false, "optional": false }, - { "id": "p2", "name": "interestRate", "type": "number", "array": false, "optional": false }, - { "id": "p3", "name": "feePct", "type": "number", "array": false, "optional": false } - ] } } + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "prop1", + "name": "id", + "type": "string", + "array": false, + "optional": false + } + ] + } + } }, - { "id": "e1", "type": "expression", "props": { "data": { "key": "due", "value": "(principal) * interestRate * feePct" } } } + { + "id": "step1", + "type": "expression", + "props": { + "data": { + "key": "customer.x", + "value": "customer.phantom" + } + } + } ] } ''' -no_errors = true -hint_codes = ["RedundantParentheses"] -hint_count = 1 +error_codes = ["UndefinedVariable", "TypeMismatch"] [[test]] -name = "parentheses matching left associativity are flagged" +name = "nullish coalesce on a non-nullable property is redundant" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "principal", "type": "number", "array": false, "optional": false }, - { "id": "p2", "name": "interestRate", "type": "number", "array": false, "optional": false }, - { "id": "p3", "name": "feePct", "type": "number", "array": false, "optional": false } + { "id": "prop1", "name": "score", "type": "number", "array": false, "optional": false } ] } } }, - { "id": "e1", "type": "expression", "props": { "data": { "key": "due", "value": "(principal * interestRate) * feePct" } } } + { "id": "calc1", "type": "expression", "props": { "data": { "key": "adjusted", "value": "(score ?? 0) + 1" } } } ] } ''' no_errors = true -hint_codes = ["RedundantParentheses"] +hint_codes = ["RedundantNullish"] hint_count = 1 [[test]] -name = "parentheses around higher-precedence arithmetic are flagged" +name = "compatible entity merge — same property same type is fine" +policies = ["merge_entity.json"] +policy = "merge_entity.json" +no_errors = true + +[[test]] +name = "derivation repeated across imported policies is flagged in the importing policy" +policies = ["lint_shared_main.json", "lint_shared_dep.json"] +policy = "lint_shared_main.json" +no_errors = true +hint_codes = ["RepeatedDerivation"] +hint_count = 1 + +[[test]] +name = "flatten of nested array peels to concrete element type" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", - "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "a", "type": "number", "array": false, "optional": false }, - { "id": "p2", "name": "b", "type": "number", "array": false, "optional": false }, - { "id": "p3", "name": "c", "type": "number", "array": false, "optional": false } - ] } } + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "prop1", + "name": "id", + "type": "string", + "array": false, + "optional": false + } + ] + } + } }, - { "id": "e1", "type": "expression", "props": { "data": { "key": "due", "value": "(a * b) + c" } } } + { + "id": "step1", + "type": "expression", + "props": { + "data": { + "key": "customer.nums", + "value": "flatten([[1, 2], [3, 4]])" + } + } + } ] } ''' no_errors = true -hint_codes = ["RedundantParentheses"] -hint_count = 1 +# Intra-tree forward reads: statement 2 sees statement 1's write. [[test]] -name = "necessary or clarifying parentheses are not flagged" +name = "intra-tree forward reads see earlier top-level writes" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", - "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "a", "type": "number", "array": false, "optional": false }, - { "id": "p2", "name": "b", "type": "number", "array": false, "optional": false }, - { "id": "p3", "name": "c", "type": "number", "array": false, "optional": false }, - { "id": "p4", "name": "flag", "type": "boolean", "array": false, "optional": false }, - { "id": "p5", "name": "other", "type": "boolean", "array": false, "optional": false }, - { "id": "p6", "name": "revenue", "type": "number", "array": false, "optional": true } - ] } } + "props": { + "data": { + "name": "employee", + "properties": [ + { + "id": "prop1", + "name": "id", + "type": "string", + "array": false, + "optional": false + } + ] + } + } + }, + { + "id": "step1", + "type": "expression", + "props": { + "data": { + "key": "employee.som", + "value": "merge([{a: 10}])" + } + } }, - { "id": "e1", "type": "expression", "props": { "data": { "key": "r1", "value": "(a + b) * c" } } }, - { "id": "e2", "type": "expression", "props": { "data": { "key": "r2", "value": "a - (b - c)" } } }, - { "id": "e3", "type": "expression", "props": { "data": { "key": "r3", "value": "(revenue ?? 0) * 2" } } }, - { "id": "e4", "type": "expression", "props": { "data": { "key": "r4", "value": "(flag and other) or (a > b)" } } }, - { "id": "e5", "type": "expression", "props": { "data": { "key": "r5", "value": "flag ? (a + b) : c" } } } + { + "id": "step2", + "type": "expression", + "props": { + "data": { + "key": "employee.aa", + "value": "employee.som" + } + } + } ] } ''' no_errors = true -hint_count = 0 [[test]] -name = "redundant parentheses inside arguments and at the root are flagged" +name = "parentheses matching left associativity are flagged" content = ''' { "blocks": [ { - "id": "dm", + "id": "schema", "type": "dataModel", "props": { "data": { "name": "inputs", "scope": "global", "properties": [ - { "id": "p1", "name": "a", "type": "number", "array": false, "optional": false }, - { "id": "p2", "name": "b", "type": "number", "array": false, "optional": false } + { "id": "prop1", "name": "principal", "type": "number", "array": false, "optional": false }, + { "id": "prop2", "name": "interestRate", "type": "number", "array": false, "optional": false }, + { "id": "prop3", "name": "feePct", "type": "number", "array": false, "optional": false } ] } } }, - { "id": "e1", "type": "expression", "props": { "data": { "key": "r1", "value": "(a + b)" } } }, - { "id": "e2", "type": "expression", "props": { "data": { "key": "r2", "value": "abs((a + b))" } } } + { "id": "calc1", "type": "expression", "props": { "data": { "key": "due", "value": "(principal * interestRate) * feePct" } } } ] } ''' no_errors = true hint_codes = ["RedundantParentheses"] -hint_count = 2 +hint_count = 1 diff --git a/core/engine/tests/policy_dictionary.rs b/core/engine/tests/policy_dictionary.rs new file mode 100644 index 00000000..cbc4636e --- /dev/null +++ b/core/engine/tests/policy_dictionary.rs @@ -0,0 +1,541 @@ +use serde_json::json; +use std::sync::Arc; +use zen_engine::policy::{ + Cursor, CursorTarget, EvaluateRequest, EvaluationError, PolicyWorkspace, ScopeRequest, +}; +use zen_expression::nl::{EditHint, NlTokenKind}; +use zen_expression::variable::Variable; + +fn dictionary_block(id: &str, name: &str, entries: &[(&str, &str)]) -> serde_json::Value { + json!({ + "id": id, + "type": "dictionary", + "props": { + "data": { + "name": name, + "entries": entries.iter().enumerate().map(|(i, (value, label))| json!({ + "id": format!("e{i}"), + "value": value, + "label": label, + })).collect::>(), + } + } + }) +} + +fn tier_dictionary() -> serde_json::Value { + dictionary_block( + "dict1", + "customerTier", + &[("VIP", "Very important"), ("STD", "Standard")], + ) +} + +fn expression_block(id: &str, key: &str, value: &str) -> serde_json::Value { + json!({ + "id": id, + "type": "expression", + "props": { "data": { "key": key, "value": value } } + }) +} + +fn workspace_with(blocks: Vec) -> PolicyWorkspace { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "main", + serde_json::from_value(json!({ "blocks": blocks })).unwrap(), + ); + ws +} + +fn evaluate( + ws: &PolicyWorkspace, + input: serde_json::Value, +) -> Result { + let result = ws.evaluate(&EvaluateRequest { + policy_path: Arc::from("main"), + input: Variable::from(input), + goals: Vec::new(), + trace: false, + })?; + Ok(result.output.to_value()) +} + +#[test] +fn dictionary_is_not_referenceable_in_expressions() { + for member in ["customerTier.VIP", "customerTier.GOLD"] { + let ws = workspace_with(vec![ + tier_dictionary(), + expression_block("e1", "tier", member), + ]); + + let diagnostics = ws.diagnostics("main"); + assert!( + diagnostics + .iter() + .any(|d| format!("{d:?}").contains("customerTier")), + "expected '{member}' to be an unknown property, got: {diagnostics:?}" + ); + } +} + +#[test] +fn dictionary_typed_field_compares_as_plain_string() { + let ws = workspace_with(vec![ + tier_dictionary(), + json!({ + "id": "dm1", + "type": "dataModel", + "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false } + ] + }} + }), + expression_block("e1", "customer.isVip", "customer.tier == 'VIP'"), + ]); + + let diagnostics = ws.diagnostics("main"); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + + let output = evaluate(&ws, json!({ "customer": { "tier": "VIP" } })).unwrap(); + assert_eq!(output["customer"]["isVip"], json!(true)); +} + +#[test] +fn duplicate_values_are_diagnosed() { + let ws = workspace_with(vec![dictionary_block( + "dict1", + "customerTier", + &[("VIP", "One"), ("VIP", "Two")], + )]); + + let diagnostics = ws.diagnostics("main"); + assert!( + diagnostics + .iter() + .any(|d| format!("{d:?}").contains("duplicate value 'VIP'")), + "got: {diagnostics:?}" + ); +} + +#[test] +fn dictionary_name_collisions_are_diagnosed() { + let ws = workspace_with(vec![ + tier_dictionary(), + dictionary_block("dict2", "customerTier", &[("A", "")]), + ]); + let diagnostics = ws.diagnostics("main"); + assert!( + diagnostics + .iter() + .any(|d| format!("{d:?}").contains("already defined")), + "got: {diagnostics:?}" + ); + + let ws = workspace_with(vec![ + tier_dictionary(), + json!({ + "id": "dm1", + "type": "dataModel", + "props": { "data": { + "name": "customerTier", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + }} + }), + ]); + let diagnostics = ws.diagnostics("main"); + assert!( + diagnostics + .iter() + .any(|d| format!("{d:?}").contains("collides with an entity")), + "got: {diagnostics:?}" + ); +} + +#[test] +fn data_model_property_can_reference_dictionary() { + let ws = workspace_with(vec![ + tier_dictionary(), + json!({ + "id": "dm1", + "type": "dataModel", + "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false } + ] + }} + }), + expression_block("e1", "customer.isVip", "customer.tier == 'VIP'"), + ]); + + let diagnostics = ws.diagnostics("main"); + assert!( + !diagnostics + .iter() + .any(|d| format!("{d:?}").contains("unknown entity")), + "dictionary target must not be an unknown entity: {diagnostics:?}" + ); + + let output = evaluate(&ws, json!({ "customer": { "tier": "VIP" } })).unwrap(); + assert_eq!(output["customer"]["isVip"], json!(true)); + + let invalid = evaluate(&ws, json!({ "customer": { "tier": "GOLD" } })); + assert!( + matches!(invalid, Err(EvaluationError::InputValidationFailed { .. })), + "value outside the dictionary must fail input validation" + ); +} + +#[test] +fn dictionary_membership_is_validated_for_arrays_and_globals() { + let ws = workspace_with(vec![ + tier_dictionary(), + json!({ + "id": "dm1", + "type": "dataModel", + "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "tiers", "type": "relationship", "target": "customerTier", "array": true, "optional": false } + ] + }} + }), + json!({ + "id": "dm2", + "type": "dataModel", + "props": { "data": { + "name": "global", + "scope": "global", + "properties": [ + { "id": "p2", "name": "defaultTier", "type": "relationship", "target": "customerTier", "array": false, "optional": true } + ] + }} + }), + expression_block("e1", "customer.first", "customer.tiers[0]"), + ]); + + let ok = evaluate( + &ws, + json!({ "customer": { "tiers": ["VIP", "STD"] }, "defaultTier": "STD" }), + ) + .unwrap(); + assert_eq!(ok["customer"]["first"], json!("VIP")); + + let bad_element = evaluate(&ws, json!({ "customer": { "tiers": ["VIP", "GOLD"] } })); + assert!(matches!( + bad_element, + Err(EvaluationError::InputValidationFailed { .. }) + )); + + let bad_global = evaluate( + &ws, + json!({ "customer": { "tiers": [] }, "defaultTier": "GOLD" }), + ); + assert!(matches!( + bad_global, + Err(EvaluationError::InputValidationFailed { .. }) + )); + + let not_a_string = evaluate(&ws, json!({ "customer": { "tiers": [42] } })); + assert!(matches!( + not_a_string, + Err(EvaluationError::InputValidationFailed { .. }) + )); +} + +#[test] +fn input_key_matching_dictionary_name_is_plain_data() { + let ws = workspace_with(vec![ + tier_dictionary(), + expression_block("e1", "echo", "input"), + ]); + + let output = evaluate(&ws, json!({ "input": 1, "customerTier": "boom" })).unwrap(); + assert_eq!(output["echo"], json!(1)); + assert_eq!(output["customerTier"], json!("boom")); +} + +#[test] +fn dictionaries_query_exposes_labels() { + let ws = workspace_with(vec![tier_dictionary()]); + let dictionaries = ws.dictionaries(&ScopeRequest { + policy_path: Arc::from("main"), + goals: Vec::new(), + }); + + assert_eq!(dictionaries.len(), 1); + let dict = &dictionaries[0]; + assert_eq!(dict.name.as_ref(), "customerTier"); + assert_eq!(dict.entries.len(), 2); + assert_eq!(dict.entries[0].value.as_ref(), "VIP"); + assert_eq!(dict.entries[0].label.as_ref(), "Very important"); +} + +#[test] +fn nl_tokenize_uses_dictionary_labels() { + let ws = workspace_with(vec![ + tier_dictionary(), + json!({ + "id": "dm1", + "type": "dataModel", + "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false } + ] + }} + }), + json!({ + "id": "dt1", + "type": "decisionTable", + "props": { "data": { + "hitPolicy": "first", + "inputs": [ { "id": "in1", "name": "Tier", "field": "customer.tier" } ], + "outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ], + "rules": [ { "_id": "row1", "in1": "'VIP'", "out1": "'vip'" } ] + }} + }), + ]); + + let cursor = Cursor { + policy_path: "main".into(), + block_id: "dt1".into(), + pos: 0, + target: CursorTarget::DecisionTableCell { + row: "row1".into(), + col: "in1".into(), + }, + }; + let result = ws.nl_tokenize(&cursor, "'VIP'").expect("cursor resolves"); + + let str_tok = result + .tokens + .iter() + .find(|t| matches!(t.token, NlTokenKind::Str { .. })) + .expect("string token present"); + let Some(EditHint::Select { options }) = str_tok.hint else { + panic!("expected select hint, got {:?}", str_tok.hint); + }; + let options = &result.enums[options as usize]; + let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect(); + assert_eq!(labels, vec!["Very important", "Standard"]); + let sources: Vec<&str> = options.iter().filter_map(|o| o.source.as_deref()).collect(); + assert_eq!(sources, vec!["\"VIP\"", "\"STD\""]); +} + +#[test] +fn nl_tokenize_empty_cell_exposes_labeled_subject_options() { + let ws = workspace_with(vec![ + tier_dictionary(), + json!({ + "id": "dm1", + "type": "dataModel", + "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false } + ] + }} + }), + json!({ + "id": "dt1", + "type": "decisionTable", + "props": { "data": { + "hitPolicy": "first", + "inputs": [ { "id": "in1", "name": "Tier", "field": "customer.tier" } ], + "outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ], + "rules": [ { "_id": "row1", "in1": "", "out1": "'vip'" } ] + }} + }), + ]); + + let cursor = Cursor { + policy_path: "main".into(), + block_id: "dt1".into(), + pos: 0, + target: CursorTarget::DecisionTableCell { + row: "row1".into(), + col: "in1".into(), + }, + }; + let result = ws.nl_tokenize(&cursor, "").expect("cursor resolves"); + + let options = result.subject_options.expect("subject options present"); + let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect(); + assert_eq!(labels, vec!["Very important", "Standard"]); + let sources: Vec<&str> = options.iter().filter_map(|o| o.source.as_deref()).collect(); + assert_eq!(sources, vec!["\"VIP\"", "\"STD\""]); + + let batch = ws.nl("main"); + let cell = batch + .iter() + .find(|e| { + e.block_id.as_ref() == "dt1" + && matches!(&e.target, CursorTarget::DecisionTableCell { col, .. } if col.as_ref() == "in1") + }) + .expect("batch projection for the table cell"); + let batch_options = cell + .result + .subject_options + .as_ref() + .expect("batch projection carries subject options"); + let batch_labels: Vec<&str> = batch_options.iter().map(|o| o.label.as_str()).collect(); + assert_eq!(batch_labels, vec!["Very important", "Standard"]); +} + +#[test] +fn nl_tokenize_closure_membership_gets_enum_multiselect() { + let ws = workspace_with(vec![ + tier_dictionary(), + json!({ + "id": "dm1", + "type": "dataModel", + "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "tags", "type": "relationship", "target": "customerTier", "array": true, "optional": false } + ] + }} + }), + json!({ + "id": "dt1", + "type": "decisionTable", + "props": { "data": { + "hitPolicy": "first", + "inputs": [ { "id": "in1", "name": "Tags", "field": "customer.tags" } ], + "outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ], + "rules": [ { "_id": "row1", "in1": "all(['VIP'], # in $)", "out1": "'vip'" } ] + }} + }), + ]); + + let cursor = Cursor { + policy_path: "main".into(), + block_id: "dt1".into(), + pos: 0, + target: CursorTarget::DecisionTableCell { + row: "row1".into(), + col: "in1".into(), + }, + }; + let result = ws + .nl_tokenize(&cursor, "all(['VIP'], # in $)") + .expect("cursor resolves"); + + let list = result + .tokens + .iter() + .find(|t| matches!(&t.token, NlTokenKind::EnumList { .. })) + .expect("array literal projects as enum list"); + let NlTokenKind::EnumList { selected } = &list.token else { + unreachable!(); + }; + assert_eq!(selected.as_slice(), [Box::from("VIP")]); + let Some(EditHint::MultiSelect { options }) = list.hint else { + panic!("expected multi-select hint, got {:?}", list.hint); + }; + let options = &result.enums[options as usize]; + let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect(); + assert_eq!(labels, vec!["Very important", "Standard"]); +} + +#[test] +fn nl_tokenize_reversed_membership_and_contains_get_enum_hints() { + let ws = workspace_with(vec![ + tier_dictionary(), + json!({ + "id": "dm1", + "type": "dataModel", + "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "tags", "type": "relationship", "target": "customerTier", "array": true, "optional": false } + ] + }} + }), + json!({ + "id": "dt1", + "type": "decisionTable", + "props": { "data": { + "hitPolicy": "first", + "inputs": [ { "id": "in1", "name": "Tags", "field": "customer.tags" } ], + "outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ], + "rules": [ { "_id": "row1", "in1": "'VIP' in $", "out1": "'vip'" } ] + }} + }), + ]); + + let cursor = Cursor { + policy_path: "main".into(), + block_id: "dt1".into(), + pos: 0, + target: CursorTarget::DecisionTableCell { + row: "row1".into(), + col: "in1".into(), + }, + }; + + let assert_labeled_select = |result: zen_expression::nl::NlResult| { + let str_tok = result + .tokens + .iter() + .find(|t| matches!(t.token, NlTokenKind::Str { .. })) + .expect("string token present"); + let Some(EditHint::Select { options }) = str_tok.hint else { + panic!("expected select hint, got {:?}", str_tok.hint); + }; + let labels: Vec<&str> = result.enums[options as usize] + .iter() + .map(|o| o.label.as_str()) + .collect(); + assert_eq!(labels, vec!["Very important", "Standard"]); + }; + + assert_labeled_select( + ws.nl_tokenize(&cursor, "'VIP' in $") + .expect("cursor resolves"), + ); + assert_labeled_select( + ws.nl_tokenize(&cursor, "contains($, 'VIP')") + .expect("cursor resolves"), + ); +} + +#[test] +fn dictionary_block_round_trips_through_wire_format() { + let block_json = tier_dictionary(); + let block: zen_engine::policy::BlockDoc = serde_json::from_value(block_json.clone()).unwrap(); + let serialized = serde_json::to_value(&block).unwrap(); + assert_eq!(serialized, block_json); +} + +#[test] +fn non_member_literal_comparison_is_diagnosed() { + let ws = workspace_with(vec![ + tier_dictionary(), + json!({ + "id": "dm1", + "type": "dataModel", + "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false } + ] + }} + }), + expression_block("e1", "customer.flag", "customer.tier == 'GOLD'"), + ]); + + let diagnostics = ws.diagnostics("main"); + assert!( + diagnostics + .iter() + .any(|d| format!("{d:?}").contains("GOLD")), + "expected non-member literal to be diagnosed, got: {diagnostics:?}" + ); +} diff --git a/core/expression/src/intellisense/mod.rs b/core/expression/src/intellisense/mod.rs index 9c9b8888..7df4e5fa 100644 --- a/core/expression/src/intellisense/mod.rs +++ b/core/expression/src/intellisense/mod.rs @@ -53,10 +53,13 @@ pub struct ExpressionAnalysis { pub diagnostics: Vec, } +pub type NlLabelResolver = Rc Option>; + pub struct IntelliSense { arena: Bump, lexer: Lexer, strict: bool, + nl_labels: Option, } impl IntelliSense { @@ -65,6 +68,7 @@ impl IntelliSense { arena: Bump::new(), lexer: Lexer::new(), strict: false, + nl_labels: None, } } @@ -73,6 +77,10 @@ impl IntelliSense { self } + pub fn set_nl_labels(&mut self, labels: Option) { + self.nl_labels = labels; + } + pub fn completions( &mut self, source: &str, @@ -203,11 +211,17 @@ impl IntelliSense { let mut result = self.nl_tokenize_scoped(&request.id, &request.expression, request.unary, &scope); if request.unary { - result.subject_type = Some(scope.get("$")); + let subject = scope.get("$"); + result.subject_options = self.nl_subject_options(&subject); + result.subject_type = Some(subject); } result } + pub fn nl_subject_options(&self, subject: &VariableType) -> Option> { + crate::nl::subject_enum_options(subject, self.nl_labels.as_ref()) + } + pub fn nl_tokenize_scoped( &mut self, id: &str, @@ -221,6 +235,7 @@ impl IntelliSense { enums: Vec::new(), diagnostics: Vec::new(), subject_type: None, + subject_options: None, }; self.arena.reset(); @@ -270,7 +285,8 @@ impl IntelliSense { let type_data = TypesProvider::generate(ast, scope, self.strict); collect_type_diagnostics(ast, &type_data, &metadata, &mut result.diagnostics); - let (tokens, enums) = Projector::new(source, &type_data, &metadata, unary).run(ast); + let (tokens, enums) = + Projector::new(source, &type_data, &metadata, unary, self.nl_labels.clone()).run(ast); result.tokens = tokens; result.enums = enums; result diff --git a/core/expression/src/nl/mod.rs b/core/expression/src/nl/mod.rs index fd5ed933..7793db86 100644 --- a/core/expression/src/nl/mod.rs +++ b/core/expression/src/nl/mod.rs @@ -4,8 +4,10 @@ pub mod token; pub use token::{EditHint, EnumOption, NlToken, NlTokenKind, OpChoice, OpSym, TypeTag, WordSym}; use serde::Serialize; +use std::rc::Rc; use crate::intellisense::diagnostic::Diagnostic; +use crate::intellisense::NlLabelResolver; use crate::variable::VariableType; pub fn encode_string(value: &str) -> Option { @@ -18,6 +20,37 @@ pub fn encode_string(value: &str) -> Option { } } +pub(crate) fn enum_options( + name: Option<&str>, + values: &[Rc], + labels: Option<&NlLabelResolver>, +) -> Vec { + values + .iter() + .map(|v| EnumOption { + label: labels + .zip(name) + .and_then(|(resolve, n)| resolve(n, v)) + .filter(|l| !l.is_empty()) + .unwrap_or_else(|| v.to_string()), + source: encode_string(v), + }) + .collect() +} + +pub(crate) fn subject_enum_options( + subject: &VariableType, + labels: Option<&NlLabelResolver>, +) -> Option> { + match subject { + VariableType::Enum(name, values) => Some(enum_options(name.as_deref(), values, labels)), + VariableType::Nullable(inner) | VariableType::Array(inner) => { + subject_enum_options(inner, labels) + } + _ => None, + } +} + #[derive(Debug, Clone)] pub struct NlRequest { pub id: String, @@ -35,4 +68,6 @@ pub struct NlResult { pub diagnostics: Vec, #[serde(skip)] pub subject_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject_options: Option>, } diff --git a/core/expression/src/nl/project.rs b/core/expression/src/nl/project.rs index de43b656..4531b106 100644 --- a/core/expression/src/nl/project.rs +++ b/core/expression/src/nl/project.rs @@ -2,9 +2,8 @@ use std::rc::Rc; use crate::functions::FunctionKind; use crate::intellisense::type_provider::TypesProvider; -use crate::intellisense::AstMetadata; -use crate::lexer::{Bracket, Operator}; -use crate::nl::encode_string; +use crate::intellisense::{AstMetadata, NlLabelResolver}; +use crate::lexer::{Bracket, ComparisonOperator, Operator}; use crate::nl::token::{ EditHint, EnumOption, NlToken, NlTokenKind, OpChoice, OpSym, TypeTag, WordSym, }; @@ -21,6 +20,7 @@ pub(crate) struct Projector<'a> { types: &'a TypesProvider, metadata: &'a AstMetadata, unary: bool, + labels: Option, aliases: Vec, pending_elide: bool, pending_implied: bool, @@ -34,12 +34,14 @@ impl<'a> Projector<'a> { types: &'a TypesProvider, metadata: &'a AstMetadata, unary: bool, + labels: Option, ) -> Self { Self { source, types, metadata, unary, + labels, aliases: Vec::new(), pending_elide: false, pending_implied: false, @@ -67,8 +69,8 @@ impl<'a> Projector<'a> { ), Node::String(value) => { let hint = match Self::enum_values(expected.as_ref()) { - Some(values) => Some(EditHint::Select { - options: self.intern_enum(&values), + Some((name, values)) => Some(EditHint::Select { + options: self.intern_enum(name.as_deref(), &values), }), None if Self::expects_date(expected.as_ref()) => Some(EditHint::DatePicker), None => None, @@ -141,7 +143,14 @@ impl<'a> Projector<'a> { operator, right, } => { - let (exp_left, exp_right) = self.operand_expectations(*operator, left, right); + let (exp_left, exp_right) = if matches!( + operator, + Operator::Logical(crate::lexer::LogicalOperator::NullishCoalescing) + ) { + (expected.clone(), expected) + } else { + self.operand_expectations(*operator, left, right) + }; let context_subject = matches!(operator, Operator::Comparison(_)) && matches!(left, Node::Identifier(name) if *name == "$"); if !context_subject && !self.is_elided_subject(left) { @@ -195,14 +204,14 @@ impl<'a> Projector<'a> { NlTokenKind::Word { sym: WordSym::Then }, (self.span_of(condition).1, self.span_of(on_true).0), ); - self.project(on_true, None); + self.project(on_true, expected.clone()); self.push( NlTokenKind::Word { sym: WordSym::Otherwise, }, (self.span_of(on_true).1, self.span_of(on_false).0), ); - self.project(on_false, None); + self.project(on_false, expected); } Node::Interval { @@ -242,7 +251,7 @@ impl<'a> Projector<'a> { Node::Array(items) => { let enum_domain = Self::enum_values(expected.as_ref()) .filter(|_| items.iter().all(|item| matches!(item, Node::String(_)))); - if let Some(values) = enum_domain { + if let Some((name, values)) = enum_domain { let selected = items .iter() .filter_map(|item| match item { @@ -251,7 +260,7 @@ impl<'a> Projector<'a> { }) .collect(); let hint = EditHint::MultiSelect { - options: self.intern_enum(&values), + options: self.intern_enum(name.as_deref(), &values), }; self.push_hint(NlTokenKind::EnumList { selected }, span, Some(hint)); return; @@ -355,7 +364,11 @@ impl<'a> Projector<'a> { }, (self.span_of(left).1, self.span_of(right).0), ); - self.project(right, None); + let needle_expected = match self.type_of(left) { + haystack @ VariableType::Array(_) => Some(haystack), + _ => None, + }; + self.project(right, needle_expected); return; } if !(self.unary && sym.as_ref() == "bool" && self.out.is_empty()) { @@ -393,8 +406,9 @@ impl<'a> Projector<'a> { ); self.push(NlTokenKind::Word { sym: WordSym::In }, (span.0, span.0)); } + let membership = self.closure_membership_expectation(arguments, alias); if let Some(collection) = arguments.first() { - self.project(collection, None); + self.project(collection, membership); } let leftmost = match arguments.get(1) { Some(Node::Closure { body, .. }) => Some(Self::leftmost_leaf(body)), @@ -438,6 +452,36 @@ impl<'a> Projector<'a> { } } + fn closure_membership_expectation( + &self, + arguments: &[&Node], + alias: Option<&str>, + ) -> Option { + let Some(Node::Closure { body, .. }) = arguments.get(1) else { + return None; + }; + let mut node: &Node = body; + while let Node::Parenthesized(inner) = node { + node = inner; + } + let Node::Binary { + left, + operator: Operator::Comparison(cmp), + right, + } = node + else { + return None; + }; + if !matches!(cmp, ComparisonOperator::In | ComparisonOperator::NotIn) { + return None; + } + if !Self::is_binding_leaf(left, alias) { + return None; + } + let rhs = self.type_of(right); + Self::enum_values(Some(&rhs)).map(|(name, values)| VariableType::Enum(name, values)) + } + fn leftmost_leaf<'n>(body: &'n Node<'n>) -> &'n Node<'n> { let mut node = body; loop { @@ -561,14 +605,10 @@ impl<'a> Projector<'a> { left: &Node, right: &Node, ) -> (Option, Option) { - use crate::lexer::ComparisonOperator::{In, NotIn}; - let Operator::Comparison(comparison) = operator else { + let Operator::Comparison(_) = operator else { return (None, None); }; - match comparison { - In | NotIn => (None, Some(self.type_of(left))), - _ => (Some(self.type_of(right)), Some(self.type_of(left))), - } + (Some(self.type_of(right)), Some(self.type_of(left))) } fn code(&mut self, span: (u32, u32)) { @@ -616,8 +656,8 @@ impl<'a> Projector<'a> { VariableType::Object(_) => TypeTag::Object, VariableType::Null => TypeTag::Null, VariableType::Any => TypeTag::Unknown, - VariableType::Enum(_, values) => TypeTag::Enum { - index: self.intern_enum(values), + VariableType::Enum(name, values) => TypeTag::Enum { + index: self.intern_enum(name.as_deref(), values), }, VariableType::Array(inner) => TypeTag::Array { items: Box::new(self.tag_of(inner)), @@ -626,28 +666,19 @@ impl<'a> Projector<'a> { } } - fn intern_enum(&mut self, values: &[Rc]) -> u32 { - let existing = self.enums.iter().position(|e| { - e.len() == values.len() && e.iter().zip(values).all(|(a, b)| a.label == b.as_ref()) - }); + fn intern_enum(&mut self, name: Option<&str>, values: &[Rc]) -> u32 { + let options = crate::nl::enum_options(name, values, self.labels.as_ref()); + let existing = self.enums.iter().position(|e| *e == options); if let Some(index) = existing { return index as u32; } - self.enums.push( - values - .iter() - .map(|v| EnumOption { - label: v.to_string(), - source: encode_string(v), - }) - .collect(), - ); + self.enums.push(options); (self.enums.len() - 1) as u32 } - fn enum_values(ty: Option<&VariableType>) -> Option>> { + fn enum_values(ty: Option<&VariableType>) -> Option<(Option>, Vec>)> { match ty? { - VariableType::Enum(_, values) => Some(values.clone()), + VariableType::Enum(name, values) => Some((name.clone(), values.clone())), VariableType::Nullable(inner) | VariableType::Array(inner) => { Self::enum_values(Some(inner)) }