diff --git a/Cargo.lock b/Cargo.lock index 5fbb8b9b..2e81d8bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aes" version = "0.8.4" @@ -133,6 +139,15 @@ dependencies = [ "object", ] +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -255,7 +270,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -284,6 +299,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.0" @@ -731,6 +752,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.5.1" @@ -855,6 +885,38 @@ dependencies = [ "cmov", ] +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "der" version = "0.7.10" @@ -1035,6 +1097,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fluent-uri" version = "0.3.2" @@ -1703,10 +1775,11 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.28" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" dependencies = [ + "defmt", "jiff-static", "jiff-tzdb-platform", "log", @@ -1718,9 +1791,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.28" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" dependencies = [ "proc-macro2", "quote", @@ -1839,6 +1912,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1887,12 +1969,31 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -1911,7 +2012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad513ff22558f1830b595ea6eb4091da48145d09a222ce157e781896f78be0b9" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.0", "ctor", "futures", "napi-build", @@ -1980,7 +2081,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -2402,6 +2503,28 @@ dependencies = [ "toml_edit 0.25.12+spec-1.1.0", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2748,7 +2871,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -3209,7 +3332,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -3263,7 +3386,7 @@ version = "15.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cfg-if", "clipboard-win", "fd-lock", @@ -3421,6 +3544,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -3507,6 +3641,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simdutf8" version = "0.1.5" @@ -3945,7 +4085,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -3994,6 +4134,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -4360,7 +4506,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -4719,7 +4865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.0", "indexmap", "log", "serde", @@ -4793,14 +4939,18 @@ version = "0.55.1" dependencies = [ "ahash 0.8.12", "anyhow", + "arc-swap", "async-trait", + "base64", "criterion", "downcast-rs", "fixedbitset", + "flate2", "http", "insta", "json_dotpath", "jsonschema", + "mimalloc", "nohash-hasher", "once_cell", "petgraph", @@ -4810,13 +4960,16 @@ dependencies = [ "rust_decimal", "serde", "serde_json", + "serde_path_to_error", "sha2 0.10.9", "strum", "thiserror 1.0.69", "tokio", + "toml 0.8.23", "zen-expression", "zen-tmpl", "zen-types", + "zip", ] [[package]] @@ -5032,6 +5185,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/bindings/c/src/engine.rs b/bindings/c/src/engine.rs index 59e9645b..9a2b6340 100644 --- a/bindings/c/src/engine.rs +++ b/bindings/c/src/engine.rs @@ -97,7 +97,10 @@ pub extern "C" fn zen_engine_create_decision( }; let zen_engine = unsafe { &*(engine as *mut ZenEngine) }; - let decision = zen_engine.create_decision(Arc::new(decision_content)); + let decision = match zen_engine.create_decision(Arc::new(decision_content)) { + Ok(d) => d, + Err(_) => return ZenResult::error(ZenError::InvalidArgument), + }; let zen_decision = ZenDecision::from(decision); ZenResult::ok(Box::into_raw(Box::new(zen_decision)) as *mut ZenDecisionStruct) @@ -163,7 +166,8 @@ pub extern "C" fn zen_engine_get_decision( let zen_engine = unsafe { &*(engine as *mut ZenEngine) }; let decision = match tokio_runtime().block_on(zen_engine.get_decision(str_key)) { - Ok(d) => d, + Ok(Ok(d)) => d, + Ok(Err(_)) => return ZenResult::error(ZenError::InvalidArgument), Err(e) => return ZenResult::from(&e), }; diff --git a/bindings/nodejs/dts-header.d.ts b/bindings/nodejs/dts-header.d.ts new file mode 100644 index 00000000..04c990ba --- /dev/null +++ b/bindings/nodejs/dts-header.d.ts @@ -0,0 +1,225 @@ +/* auto-generated by NAPI-RS */ +/* eslint-disable */ + +export type PolicyPropertyKind = 'input' | 'computed'; +export type PolicyFieldKind = 'scalar' | 'enum' | 'relationship' | 'reference'; +export type PolicySeverity = 'error' | 'warning' | 'hint'; + +/** Text range, `[start, end)` byte offsets. */ +export type PolicySpan = [number, number]; + +/** Rename target — a top-level entity, an entity field, or a global property. */ +export type PolicyRenameTarget = + | { kind: 'entity'; name: string } + | { kind: 'field'; entity: string; field: string } + | { kind: 'global'; name: string }; + +/** What kind of span a cursor is sitting in, with any disambiguation. */ +export type PolicyCursorTarget = + | { kind: 'expression'; id: string } + | { kind: 'assertionOutput' } + | { kind: 'expressionKey' } + | { kind: 'matchTarget' } + | { kind: 'matchValue'; id: string } + | { kind: 'decisionTableHead'; col: string } + | { kind: 'decisionTableCell'; row: string; col: string } + | { kind: 'dataModelName' } + | { kind: 'dataModelProperty'; id: string }; + +/** + * How an entity field came into existence — declared by a DataModel + * (`schema`) or produced by a rule block (`computed`). Computed fields + * whose value provably consists of entity instances (identity preserved + * through filter / index / slice) carry `instanceOf`. + */ +export type PolicyFieldOrigin = + | { origin: 'schema'; source: string; fieldKind: PolicyFieldKindInfo } + | { origin: 'computed'; writtenBy: PolicyPropertyWriter; instanceOf?: PolicyInstanceOf }; +export type PolicyDiagnosticCode = + | 'UNDEFINED_VARIABLE' + | 'TYPE_MISMATCH' + | 'INVALID_EXPRESSION' + | 'PARSE_ERROR' + | 'MISSING_DEFAULT_BRANCH' + | 'EMPTY_BLOCK' + | 'MIXED_SCOPE' + | 'CYCLIC_DEPENDENCY' + | 'DUPLICATE_WRITER' + | 'INVALID_WRITE_PATH' + | 'INPUT_OVERRIDE' + | 'SELF_REFERENCING_WRITE' + | 'UNREACHABLE_ENTITY_READ' + | 'PARTIAL_OBJECT_WRITE' + | 'DATA_MODEL_COLLISION' + | 'UNKNOWN_DATA_MODEL_TARGET' + | 'DUPLICATE_PROPERTY' + | 'DUPLICATE_ENUM_VALUE' + | 'INVALID_NAME' + | 'MAX_DEPTH_EXCEEDED' + | 'IMPORT_NOT_FOUND' + | 'CIRCULAR_IMPORT' + | 'REDUNDANT_NULLISH' + | 'REPEATED_DERIVATION' + | 'PREFER_MATCH' + | 'REDUNDANT_TABLE_ROW' + | 'NON_DISCRIMINATING_COLUMN' + | 'REDUNDANT_PARENTHESES'; + +export type PolicyVariableType = + | { type: 'any' } + | { type: 'null' } + | { type: 'bool' } + | { type: 'string' } + | { type: 'number' } + | { type: 'date' } + | { type: 'interval' } + | { type: 'const'; value: string } + | { type: 'enum'; name: string | null; values: string[] } + | { type: 'array'; items: PolicyVariableType } + | { type: 'object'; fields: Record } + | { type: 'nullable'; inner: PolicyVariableType }; + +/** + * Wire-format block — the same shape passed into `setPolicy` / `updateBlock`. + * The engine emits these inside `replaceBlock` / `insertBlock` edits so a + * host can swap them in by id without text-span surgery. + */ +export interface PolicyWireBlock { + id: string; + type: string; + props: { dataJson?: string; schemaJson?: string }; +} + +/** + * Block-level edit emitted by code-mod operations (rename today; cascade + * delete / code-actions / extract-refactor planned). Hosts apply each by + * id-keyed swap (same path as `updateBlock` / `removeBlock`). + * + * - `replaceBlock` — overwrite an existing block's wire content. + * - `deleteBlock` — remove a block by id. + * - `insertBlock` — append a new block (after `afterBlockId` if given). + */ +export type PolicyEngineEdit = + | { + kind: 'replaceBlock'; + policyPath: string; + blockId: string; + newBlock: PolicyWireBlock; + } + | { kind: 'deleteBlock'; policyPath: string; blockId: string } + | { + kind: 'insertBlock'; + policyPath: string; + afterBlockId?: string; + newBlock: PolicyWireBlock; + }; + +/** + * What kind of usage site a `PolicyReferenceSite` represents. + * + * - `expressionRead` — a read in an expression body (assertion condition, + * decision-table cell, decision-tree statement value). + * - `writeKey` — an `entity.field` write target (assertion output, DT + * column head, tree statement key). + * - `dataModel` — a declaration site in a data-model block (entity name, + * property name, or relationship/reference target). + */ +export type PolicyReferenceKind = 'expressionRead' | 'writeKey' | 'dataModel'; + +/** + * One usage site of a `PolicyRenameTarget` in the workspace, returned by + * `references()`. Carries enough context to navigate to the site or render + * a "find references" panel. + */ +export interface PolicyReferenceSite { + policyPath: string; + blockId: string; + expressionId?: string; + /** The full original source string this site lives in. */ + source: string; + /** Character offsets within `source` (LSP-style). */ + span: PolicySpan; + kind: PolicyReferenceKind; +} + +/** + * One node in the transitive dependency tree returned by `dependencies()`. + * Each node names a property; `writtenBy` is the block that produces it + * (absent for inputs — those are leaves). `deps` is the next layer of the + * dependency tree, recursively. Cycles in the dep graph (cut by the + * engine's self-edge skip) are protected here too — any revisited node has + * empty `deps`. + */ +export interface PolicyDependencyNode { + property: string; + writtenBy?: PolicyPropertyWriter; + unresolved?: boolean; + resolvedType: PolicyVariableType; + deps: PolicyDependencyNode[]; +} + +export interface PolicyEvaluationResult { + output?: unknown; + /** Evaluation duration in microseconds. */ + duration?: number; + trace?: PolicyTrace; + /** Present when a block failed mid-evaluation; `trace` then holds the partial trace up to the failure. */ + error?: PolicyEvaluationFailure; +} + +export interface PolicyEvaluationFailure { + policyPath: string; + blockId: string; + expression: string; + message: string; +} + +export interface PolicyTrace { + engineVersion: string; + properties: Record; + executions: PolicyBlockExecution[]; +} + +export interface PolicyBlockExecution { + blockId: string; + policyPath?: string; + instancePath?: string; + trace: PolicyBlockTrace; + operandValues?: Record; + writes?: PolicyWriteTrace[]; + reads?: string[]; +} + +export interface PolicyWriteTrace { + path: string; + value: unknown; +} + +export interface PolicyDecisionTableExtras { + inputPass: string; +} + +export type PolicyBlockTrace = + | { + kind: 'assertion'; + result: boolean; + conditions: { id: string; result: boolean }[]; + } + | { + kind: 'decisionTable'; + matchedRows: number[]; + evaluations: Record[]; + extras?: PolicyDecisionTableExtras; + } + | { + kind: 'expression'; + property: string; + value: unknown; + } + | { + kind: 'match'; + matchedArm?: string; + value: unknown; + arms: { id: string; result: boolean }[]; + }; + diff --git a/bindings/nodejs/index.d.ts b/bindings/nodejs/index.d.ts index af3e13f8..a37fd86e 100644 --- a/bindings/nodejs/index.d.ts +++ b/bindings/nodejs/index.d.ts @@ -1,5 +1,295 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ + +export type PolicyPropertyKind = 'input' | 'computed'; +export type PolicyFieldKind = 'scalar' | 'enum' | 'relationship' | 'reference'; +export type PolicySeverity = 'error' | 'warning' | 'hint'; + +/** Text range, `[start, end)` byte offsets. */ +export type PolicySpan = [number, number]; + +/** Rename target — a top-level entity, an entity field, or a global property. */ +export type PolicyRenameTarget = + | { kind: 'entity'; name: string } + | { kind: 'field'; entity: string; field: string } + | { kind: 'global'; name: string }; + +/** What kind of span a cursor is sitting in, with any disambiguation. */ +export type PolicyCursorTarget = + | { kind: 'expression'; id: string } + | { kind: 'assertionOutput' } + | { kind: 'expressionKey' } + | { kind: 'matchTarget' } + | { kind: 'matchValue'; id: string } + | { kind: 'decisionTableHead'; col: string } + | { kind: 'decisionTableCell'; row: string; col: string } + | { kind: 'dataModelName' } + | { kind: 'dataModelProperty'; id: string }; + +/** + * How an entity field came into existence — declared by a DataModel + * (`schema`) or produced by a rule block (`computed`). Computed fields + * whose value provably consists of entity instances (identity preserved + * through filter / index / slice) carry `instanceOf`. + */ +export type PolicyFieldOrigin = + | { origin: 'schema'; source: string; fieldKind: PolicyFieldKindInfo } + | { origin: 'computed'; writtenBy: PolicyPropertyWriter; instanceOf?: PolicyInstanceOf }; +export type PolicyDiagnosticCode = + | 'UNDEFINED_VARIABLE' + | 'TYPE_MISMATCH' + | 'INVALID_EXPRESSION' + | 'PARSE_ERROR' + | 'MISSING_DEFAULT_BRANCH' + | 'EMPTY_BLOCK' + | 'MIXED_SCOPE' + | 'CYCLIC_DEPENDENCY' + | 'DUPLICATE_WRITER' + | 'INVALID_WRITE_PATH' + | 'INPUT_OVERRIDE' + | 'SELF_REFERENCING_WRITE' + | 'UNREACHABLE_ENTITY_READ' + | 'PARTIAL_OBJECT_WRITE' + | 'DATA_MODEL_COLLISION' + | 'UNKNOWN_DATA_MODEL_TARGET' + | 'DUPLICATE_PROPERTY' + | 'DUPLICATE_ENUM_VALUE' + | 'INVALID_NAME' + | 'MAX_DEPTH_EXCEEDED' + | 'IMPORT_NOT_FOUND' + | 'CIRCULAR_IMPORT' + | 'REDUNDANT_NULLISH' + | 'REPEATED_DERIVATION' + | 'PREFER_MATCH' + | 'REDUNDANT_TABLE_ROW' + | 'NON_DISCRIMINATING_COLUMN' + | 'REDUNDANT_PARENTHESES'; + +export type PolicyVariableType = + | { type: 'any' } + | { type: 'null' } + | { type: 'bool' } + | { type: 'string' } + | { type: 'number' } + | { type: 'date' } + | { type: 'interval' } + | { type: 'const'; value: string } + | { type: 'enum'; name: string | null; values: string[] } + | { type: 'array'; items: PolicyVariableType } + | { type: 'object'; fields: Record } + | { type: 'nullable'; inner: PolicyVariableType }; + +/** + * Wire-format block — the same shape passed into `setPolicy` / `updateBlock`. + * The engine emits these inside `replaceBlock` / `insertBlock` edits so a + * host can swap them in by id without text-span surgery. + */ +export interface PolicyWireBlock { + id: string; + type: string; + props: { dataJson?: string; schemaJson?: string }; +} + +/** + * Block-level edit emitted by code-mod operations (rename today; cascade + * delete / code-actions / extract-refactor planned). Hosts apply each by + * id-keyed swap (same path as `updateBlock` / `removeBlock`). + * + * - `replaceBlock` — overwrite an existing block's wire content. + * - `deleteBlock` — remove a block by id. + * - `insertBlock` — append a new block (after `afterBlockId` if given). + */ +export type PolicyEngineEdit = + | { + kind: 'replaceBlock'; + policyPath: string; + blockId: string; + newBlock: PolicyWireBlock; + } + | { kind: 'deleteBlock'; policyPath: string; blockId: string } + | { + kind: 'insertBlock'; + policyPath: string; + afterBlockId?: string; + newBlock: PolicyWireBlock; + }; + +/** + * What kind of usage site a `PolicyReferenceSite` represents. + * + * - `expressionRead` — a read in an expression body (assertion condition, + * decision-table cell, decision-tree statement value). + * - `writeKey` — an `entity.field` write target (assertion output, DT + * column head, tree statement key). + * - `dataModel` — a declaration site in a data-model block (entity name, + * property name, or relationship/reference target). + */ +export type PolicyReferenceKind = 'expressionRead' | 'writeKey' | 'dataModel'; + +/** + * One usage site of a `PolicyRenameTarget` in the workspace, returned by + * `references()`. Carries enough context to navigate to the site or render + * a "find references" panel. + */ +export interface PolicyReferenceSite { + policyPath: string; + blockId: string; + expressionId?: string; + /** The full original source string this site lives in. */ + source: string; + /** Character offsets within `source` (LSP-style). */ + span: PolicySpan; + kind: PolicyReferenceKind; +} + +/** + * One node in the transitive dependency tree returned by `dependencies()`. + * Each node names a property; `writtenBy` is the block that produces it + * (absent for inputs — those are leaves). `deps` is the next layer of the + * dependency tree, recursively. Cycles in the dep graph (cut by the + * engine's self-edge skip) are protected here too — any revisited node has + * empty `deps`. + */ +export interface PolicyDependencyNode { + property: string; + writtenBy?: PolicyPropertyWriter; + unresolved?: boolean; + resolvedType: PolicyVariableType; + deps: PolicyDependencyNode[]; +} + +export interface PolicyEvaluationResult { + output?: unknown; + /** Evaluation duration in microseconds. */ + duration?: number; + trace?: PolicyTrace; + /** Present when a block failed mid-evaluation; `trace` then holds the partial trace up to the failure. */ + error?: PolicyEvaluationFailure; +} + +export interface PolicyEvaluationFailure { + policyPath: string; + blockId: string; + expression: string; + message: string; +} + +export interface PolicyTrace { + engineVersion: string; + properties: Record; + executions: PolicyBlockExecution[]; +} + +export interface PolicyBlockExecution { + blockId: string; + policyPath?: string; + instancePath?: string; + trace: PolicyBlockTrace; + operandValues?: Record; + writes?: PolicyWriteTrace[]; + reads?: string[]; +} + +export interface PolicyWriteTrace { + path: string; + value: unknown; +} + +export interface PolicyDecisionTableExtras { + inputPass: string; +} + +export type PolicyBlockTrace = + | { + kind: 'assertion'; + result: boolean; + conditions: { id: string; result: boolean }[]; + } + | { + kind: 'decisionTable'; + matchedRows: number[]; + evaluations: Record[]; + extras?: PolicyDecisionTableExtras; + } + | { + kind: 'expression'; + property: string; + value: unknown; + } + | { + kind: 'match'; + matchedArm?: string; + value: unknown; + arms: { id: string; result: boolean }[]; + }; + +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 + inputs(req: PolicyScopeRequest): Array + outputs(req: PolicyScopeRequest): Array + conditionalSchema(req: PolicyScopeRequest): PolicyConditionalSchema + inspect(cursor: PolicyExpressionCursor): PolicyInspectResult | 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 + componentMembers(policy: string): Array + crossComponentWriteConflicts(): Array +} + export declare class ZenDecision { constructor() evaluate(context: any, opts?: ZenEvaluateOptions | undefined | null): Promise @@ -19,6 +309,9 @@ 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> + reload(): Promise + compileFailures(): Array<{ key: string; kind: string; diagnostics?: Array<{ code: string; message: string; severity: string }>; error?: string }> dispose(): void } @@ -37,6 +330,17 @@ export interface DecisionNode { config: any } +export interface EvaluateBatchRequest { + key: string + 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 @@ -47,6 +351,168 @@ export declare function evaluateUnaryExpressionSync(expression: string, context: export declare function overrideConfig(config: ZenConfig): void +export interface PolicyCompletion { + label: string + kind: string + detail: string + info: string +} + +export interface PolicyConditionalSchema { + kind: "union" | "flat" + common: PolicySchemaGroup + union?: PolicyDiscriminatedUnion + conditional?: PolicySchemaGroup +} + +export interface PolicyDiagnostic { + code: PolicyDiagnosticCode + message: string + severity: PolicySeverity + policyPath: string + blockId?: string + span?: PolicySpan + expressionId?: string + target?: PolicyCursorTarget +} + +export interface PolicyDiscriminantVariant { + value?: string + arm: string + group: PolicySchemaGroup +} + +export interface PolicyDiscriminatedUnion { + property: string + resolvedType: PolicyVariableType + variants: Array +} + +export interface PolicyEntityFieldInfo { + name: string + resolvedType: PolicyVariableType + origin: PolicyFieldOrigin +} + +export interface PolicyEntityInfo { + name: string + fields: Array +} + +export interface PolicyEvaluateRequest { + policyPath: string + input: unknown + /** Goals to evaluate. Omit or pass empty for full evaluation. */ + goals?: Array + trace?: boolean +} + +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 + array?: boolean +} + +export interface PolicyGlobalInfo { + name: string + resolvedType: PolicyVariableType + origin: PolicyFieldOrigin +} + +export interface PolicyGuardedProperty { + path: string + resolvedType: PolicyVariableType + requiredWhen?: string +} + +export interface PolicyInputProperty { + path: string + resolvedType: PolicyVariableType +} + +export interface PolicyInspectResult { + span: PolicySpan + kind: PolicyVariableType + label: string +} + +export interface PolicyInstanceOf { + target: string + array: boolean +} + +export interface PolicyOutputProperty { + path: string + resolvedType: PolicyVariableType + kind: PolicyPropertyKind + writtenBy?: PolicyPropertyWriter + instanceOf?: PolicyInstanceOf +} + +export interface PolicyPrepareRenameResult { + target: PolicyRenameTarget + span: PolicySpan +} + +export interface PolicyPropertyWriter { + policyPath: string + blockId: string +} + +export interface PolicyRemoveBlockRequest { + policyPath: string + blockId: string +} + +export interface PolicyRenameRequest { + target: PolicyRenameTarget + newName: string +} + +export interface PolicySchemaGroup { + inputs: Array + outputs: Array +} + +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 +} + +export interface PolicyWriteConflict { + path: string + policies: Array +} + export declare function renderTemplate(template: string, context: any): Promise export declare function renderTemplateSync(template: string, context: any): any @@ -63,9 +529,9 @@ export interface ZenEngineHandlerResponse { } export interface ZenEngineOptions { - loader?: (key: string) => Promise - customHandler?: (request: ZenEngineHandlerRequest) => Promise - httpHandler?: (request: ZenHttpHandlerRequest) => Promise +loader?: ((key: string) => Promise) | { type: 'static'; content: Record } | { type: 'fs'; path: string } | { type: 'zip'; bytes: Buffer } +customHandler?: (request: ZenEngineHandlerRequest) => Promise +httpHandler?: (request: ZenHttpHandlerRequest) => Promise } export interface ZenEngineResponse { diff --git a/bindings/nodejs/index.js b/bindings/nodejs/index.js index a8622217..cde6209b 100644 --- a/bindings/nodejs/index.js +++ b/bindings/nodejs/index.js @@ -77,8 +77,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-android-arm64') const bindingPackageVersion = require('@gorules/zen-engine-android-arm64/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +93,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-android-arm-eabi') const bindingPackageVersion = require('@gorules/zen-engine-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -114,8 +114,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-win32-x64-gnu') const bindingPackageVersion = require('@gorules/zen-engine-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -130,8 +130,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-win32-x64-msvc') const bindingPackageVersion = require('@gorules/zen-engine-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -147,8 +147,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-win32-ia32-msvc') const bindingPackageVersion = require('@gorules/zen-engine-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +163,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-win32-arm64-msvc') const bindingPackageVersion = require('@gorules/zen-engine-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +182,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-darwin-universal') const bindingPackageVersion = require('@gorules/zen-engine-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +198,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-darwin-x64') const bindingPackageVersion = require('@gorules/zen-engine-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +214,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-darwin-arm64') const bindingPackageVersion = require('@gorules/zen-engine-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +234,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-freebsd-x64') const bindingPackageVersion = require('@gorules/zen-engine-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +250,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-freebsd-arm64') const bindingPackageVersion = require('@gorules/zen-engine-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +271,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-linux-x64-musl') const bindingPackageVersion = require('@gorules/zen-engine-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +287,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-linux-x64-gnu') const bindingPackageVersion = require('@gorules/zen-engine-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +305,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-linux-arm64-musl') const bindingPackageVersion = require('@gorules/zen-engine-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +321,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-linux-arm64-gnu') const bindingPackageVersion = require('@gorules/zen-engine-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +339,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-linux-arm-musleabihf') const bindingPackageVersion = require('@gorules/zen-engine-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +355,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-linux-arm-gnueabihf') const bindingPackageVersion = require('@gorules/zen-engine-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +373,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-linux-loong64-musl') const bindingPackageVersion = require('@gorules/zen-engine-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +389,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-linux-loong64-gnu') const bindingPackageVersion = require('@gorules/zen-engine-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +407,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-linux-riscv64-musl') const bindingPackageVersion = require('@gorules/zen-engine-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +423,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-linux-riscv64-gnu') const bindingPackageVersion = require('@gorules/zen-engine-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +440,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-linux-ppc64-gnu') const bindingPackageVersion = require('@gorules/zen-engine-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +456,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-linux-s390x-gnu') const bindingPackageVersion = require('@gorules/zen-engine-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +476,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-openharmony-arm64') const bindingPackageVersion = require('@gorules/zen-engine-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +492,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-openharmony-x64') const bindingPackageVersion = require('@gorules/zen-engine-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +508,8 @@ function requireNative() { try { const binding = require('@gorules/zen-engine-openharmony-arm') const bindingPackageVersion = require('@gorules/zen-engine-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.50.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.50.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.54.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.54.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -572,6 +572,7 @@ if (!nativeBinding) { } module.exports = nativeBinding +module.exports.PolicyWorkspace = nativeBinding.PolicyWorkspace module.exports.ZenDecision = nativeBinding.ZenDecision module.exports.ZenDecisionContent = nativeBinding.ZenDecisionContent module.exports.ZenEngine = nativeBinding.ZenEngine diff --git a/bindings/nodejs/package.json b/bindings/nodejs/package.json index 0ddd0b72..fdaa703d 100644 --- a/bindings/nodejs/package.json +++ b/bindings/nodejs/package.json @@ -25,6 +25,7 @@ "aarch64-apple-darwin", "wasm32-wasi-preview1-threads" ], + "dtsHeaderFile": "./dts-header.d.ts", "npmClient": "yarn", "wasm": { "browser": { diff --git a/bindings/nodejs/src/content.rs b/bindings/nodejs/src/content.rs index abe00eaf..6e0039e3 100644 --- a/bindings/nodejs/src/content.rs +++ b/bindings/nodejs/src/content.rs @@ -23,7 +23,9 @@ impl ZenDecisionContent { serde_json::from_value(serde_val)? } }; - decision_content.compile(); + if let DecisionContent::Graph(g) = &mut decision_content { + g.compile(); + } Ok(Self { inner: Arc::new(decision_content), diff --git a/bindings/nodejs/src/engine.rs b/bindings/nodejs/src/engine.rs index 6e25cb1e..413584da 100644 --- a/bindings/nodejs/src/engine.rs +++ b/bindings/nodejs/src/engine.rs @@ -21,6 +21,7 @@ use crate::loader::{DecisionLoader, LoaderTsfn}; use crate::mt::spawn_worker; use crate::safe_result::SafeResult; use crate::types::{ZenEngineHandlerRequest, ZenEngineHandlerResponse}; +use zen_engine::loader::{DynamicLoader, LoaderConfig}; use zen_engine::model::DecisionContent; use zen_engine::{DecisionEngine, EvaluationSerializedOptions, EvaluationTraceKind}; @@ -103,11 +104,29 @@ impl From for EvaluationSerializedOptions { } } +#[napi(object)] +pub struct EvaluateBatchRequest { + pub key: String, + pub context: Value, +} + +#[napi(object)] +pub struct EvaluateBatchResult { + pub success: bool, + pub data: Option, + pub error: Option, +} + #[napi(object)] pub struct ZenEngineOptions { - #[napi(ts_type = "(key: string) => Promise")] + #[napi( + ts_type = "((key: string) => Promise) | { type: 'static'; content: Record } | { type: 'fs'; path: string } | { type: 'zip'; bytes: Buffer }" + )] pub loader: Option< - Function<'static, String, Promise>>>, + Either< + Function<'static, String, Promise>>>, + Object<'static>, + >, >, #[napi(ts_type = "(request: ZenEngineHandlerRequest) => Promise")] @@ -122,7 +141,7 @@ pub struct ZenEngineOptions { #[napi] impl ZenEngine { #[napi(constructor)] - pub fn new(options: Option) -> napi::Result { + pub fn new(env: Env, options: Option) -> napi::Result { let Some(opts) = options else { return Ok(Self { graph: DecisionEngine::new( @@ -141,10 +160,10 @@ impl ZenEngine { let mut http_handler_tsfn_opt: Option = None; let mut custom_node_tsfn_opt: Option = None; - let loader = match opts.loader { - None => DecisionLoader::default(), - Some(l) => { - let loader_tsfn = l + let loader: DynamicLoader = match opts.loader { + None => Arc::new(DecisionLoader::default()), + Some(Either::A(func)) => { + let loader_tsfn = func .build_threadsafe_function() .max_queue_size::<0>() .callee_handled::() @@ -153,7 +172,23 @@ impl ZenEngine { let arc_loader_tsfn = Arc::new(loader_tsfn); loader_tsfn_opt = Some(arc_loader_tsfn.clone()); - DecisionLoader::new(arc_loader_tsfn) + Arc::new(DecisionLoader::new(arc_loader_tsfn)) + } + Some(Either::B(config_obj)) => { + let loader_type: Option = config_obj.get("type")?; + let config: LoaderConfig = match loader_type.as_deref() { + Some("zip") => { + let bytes: Buffer = config_obj + .get("bytes")? + .ok_or_else(|| anyhow!("zip loader requires a 'bytes' buffer"))?; + LoaderConfig::Zip { + bytes: bytes.to_vec(), + } + } + _ => env.from_js_value(config_obj)?, + }; + + config.into_loader().map_err(|e| anyhow!(e))? } }; @@ -173,7 +208,7 @@ impl ZenEngine { } }; - let mut decision_engine = DecisionEngine::new(Arc::new(loader), Arc::new(custom_node)); + let mut decision_engine = DecisionEngine::new(loader, Arc::new(custom_node)); if let Some(h) = opts.http_handler { let http_tsfn = h .build_threadsafe_function() @@ -188,6 +223,8 @@ impl ZenEngine { .with_http_handler(Some(Arc::new(NodeHttpHandler::new(arc_http_handler_tsfn)))); } + decision_engine.compile(); + Ok(Self { graph: Arc::new(decision_engine), @@ -236,7 +273,10 @@ impl ZenEngine { } }; - let decision = self.graph.create_decision(decision_content); + let decision = self + .graph + .create_decision(decision_content) + .map_err(|e| anyhow!(e.to_string()))?; Ok(ZenDecision::from(decision)) } @@ -246,7 +286,8 @@ impl ZenEngine { .graph .get_decision(&key) .await - .with_context(|| format!("Failed to find decision with key = {key}"))?; + .with_context(|| format!("Failed to find decision with key = {key}"))? + .map_err(|e| anyhow!(e.to_string()))?; // TODO: Investigate why reference leak? Ok(ZenDecision::from(decision)) @@ -271,6 +312,66 @@ impl ZenEngine { self.get_decision(key).await.into() } + #[napi] + pub async fn evaluate_batch( + &self, + requests: Vec, + opts: Option, + ) -> napi::Result> { + let options: EvaluationSerializedOptions = opts.unwrap_or_default().into(); + + let mut handles = Vec::with_capacity(requests.len()); + for req in requests { + let engine = self.graph.clone(); + let EvaluateBatchRequest { key, context } = req; + handles.push(spawn_worker(move || async move { + engine + .evaluate_serialized(&key, context.into(), options) + .await + })); + } + + let mut out = Vec::with_capacity(handles.len()); + for handle in handles { + out.push(match handle.await { + Ok(Ok(data)) => EvaluateBatchResult { + success: true, + data: Some(data), + error: None, + }, + Ok(Err(error)) => EvaluateBatchResult { + success: false, + data: None, + error: Some(error), + }, + Err(_) => EvaluateBatchResult { + success: false, + data: None, + error: Some(Value::String("evaluation worker panicked".into())), + }, + }); + } + + Ok(out) + } + + #[napi] + pub async fn reload(&self) -> napi::Result<()> { + let graph = self.graph.clone(); + spawn_worker(|| async move { graph.compile() }) + .await + .map_err(|_| anyhow!("Hook timed out"))?; + Ok(()) + } + + #[napi( + ts_return_type = "Array<{ key: string; kind: string; diagnostics?: Array<{ code: string; message: string; severity: string }>; error?: string }>" + )] + pub fn compile_failures(&self) -> napi::Result { + let failures = self.graph.compile_failures(); + Ok(serde_json::to_value(&failures).map_err(|e| anyhow!(e))?) + } + #[napi] pub fn dispose(&self) { if let Some(loader_tsfn) = &self.loader_tsfn { diff --git a/bindings/nodejs/src/lib.rs b/bindings/nodejs/src/lib.rs index b854cd09..7664cf65 100644 --- a/bindings/nodejs/src/lib.rs +++ b/bindings/nodejs/src/lib.rs @@ -8,5 +8,6 @@ mod expression; mod http_handler; mod loader; mod mt; +mod policy; mod safe_result; mod types; diff --git a/bindings/nodejs/src/policy.rs b/bindings/nodejs/src/policy.rs new file mode 100644 index 00000000..47e0632c --- /dev/null +++ b/bindings/nodejs/src/policy.rs @@ -0,0 +1,746 @@ +use std::sync::Arc; + +use napi::anyhow::anyhow; +use napi_derive::napi; +use serde_json::Value; + +use zen_engine::policy; + +#[napi(object)] +pub struct PolicyExpressionCursor { + pub policy_path: String, + pub block_id: String, + pub pos: u32, + #[napi(ts_type = "PolicyCursorTarget")] + pub target: Value, +} + +impl TryFrom for policy::Cursor { + type Error = napi::Error; + + fn try_from(c: PolicyExpressionCursor) -> napi::Result { + Ok(Self { + policy_path: c.policy_path.into(), + block_id: c.block_id.into(), + pos: c.pos, + target: serde_json::from_value(c.target) + .map_err(|e| napi::Error::from_reason(format!("invalid cursor target: {e}")))?, + }) + } +} + +#[allow(dead_code)] +#[napi(object)] +pub struct PolicyFieldKindInfo { + #[napi(ts_type = "PolicyFieldKind")] + pub kind: String, + pub target: Option, + pub array: Option, +} + +#[napi(object)] +pub struct PolicyDiagnostic { + #[napi(ts_type = "PolicyDiagnosticCode")] + pub code: String, + pub message: String, + #[napi(ts_type = "PolicySeverity")] + pub severity: String, + pub policy_path: String, + pub block_id: Option, + #[napi(ts_type = "PolicySpan")] + pub span: Option>, + pub expression_id: Option, + #[napi(ts_type = "PolicyCursorTarget")] + pub target: Option, +} + +impl From<&policy::Diagnostic> for PolicyDiagnostic { + fn from(d: &policy::Diagnostic) -> Self { + Self { + code: serde_json::to_value(&d.code) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(), + message: d.message.clone(), + severity: serde_json::to_value(&d.severity) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(), + policy_path: d.location.policy_path.to_string(), + block_id: d.location.block_id.as_ref().map(|s| s.to_string()), + span: d.location.span.map(|(s, e)| vec![s, e]), + expression_id: d.location.expression_id.as_ref().map(|s| s.to_string()), + target: d + .location + .target + .as_ref() + .and_then(|t| serde_json::to_value(t).ok()), + } + } +} + +#[napi(object)] +pub struct PolicyEntityFieldInfo { + pub name: String, + #[napi(ts_type = "PolicyVariableType")] + pub resolved_type: Value, + #[napi(ts_type = "PolicyFieldOrigin")] + pub origin: Value, +} + +#[napi(object)] +pub struct PolicyEntityInfo { + pub name: String, + pub fields: Vec, +} + +#[napi(object)] +pub struct PolicyGlobalInfo { + pub name: String, + #[napi(ts_type = "PolicyVariableType")] + pub resolved_type: Value, + #[napi(ts_type = "PolicyFieldOrigin")] + pub origin: Value, +} + +#[napi(object)] +pub struct PolicyInputProperty { + pub path: String, + #[napi(ts_type = "PolicyVariableType")] + pub resolved_type: Value, +} + +#[napi(object)] +pub struct PolicyOutputProperty { + pub path: String, + #[napi(ts_type = "PolicyVariableType")] + pub resolved_type: Value, + #[napi(ts_type = "PolicyPropertyKind")] + pub kind: String, + pub written_by: Option, + pub instance_of: Option, +} + +#[napi(object)] +pub struct PolicyInstanceOf { + pub target: String, + pub array: bool, +} + +#[napi(object)] +pub struct PolicyPropertyWriter { + pub policy_path: String, + pub block_id: String, +} + +impl From<&policy::BlockRef> for PolicyPropertyWriter { + fn from(b: &policy::BlockRef) -> Self { + Self { + policy_path: b.policy_path.to_string(), + block_id: b.block_id.to_string(), + } + } +} + +#[napi(object)] +pub struct PolicyWriteConflict { + pub path: String, + pub policies: Vec, +} + +#[napi(object)] +pub struct PolicyGuardedProperty { + pub path: String, + #[napi(ts_type = "PolicyVariableType")] + pub resolved_type: Value, + pub required_when: Option, +} + +#[napi(object)] +pub struct PolicySchemaGroup { + pub inputs: Vec, + pub outputs: Vec, +} + +#[napi(object)] +pub struct PolicyDiscriminantVariant { + pub value: Option, + pub arm: String, + pub group: PolicySchemaGroup, +} + +#[napi(object)] +pub struct PolicyDiscriminatedUnion { + pub property: String, + #[napi(ts_type = "PolicyVariableType")] + pub resolved_type: Value, + pub variants: Vec, +} + +#[napi(object)] +pub struct PolicyConditionalSchema { + #[napi(ts_type = "\"union\" | \"flat\"")] + pub kind: String, + pub common: PolicySchemaGroup, + pub union: Option, + pub conditional: Option, +} + +impl From for PolicyGuardedProperty { + fn from(p: policy::GuardedProperty) -> Self { + Self { + path: p.path.to_string(), + resolved_type: variable_type_to_json(&p.resolved_type), + required_when: p.required_when.map(|w| w.to_string()), + } + } +} + +impl From for PolicySchemaGroup { + fn from(g: policy::SchemaGroup) -> Self { + Self { + inputs: g.inputs.into_iter().map(Into::into).collect(), + outputs: g.outputs.into_iter().map(Into::into).collect(), + } + } +} + +impl From for PolicyDiscriminantVariant { + fn from(v: policy::DiscriminantVariant) -> Self { + Self { + value: v.value.map(|s| s.to_string()), + arm: v.arm.to_string(), + group: v.group.into(), + } + } +} + +impl From for PolicyDiscriminatedUnion { + fn from(u: policy::DiscriminatedUnion) -> Self { + Self { + property: u.property.to_string(), + resolved_type: variable_type_to_json(&u.resolved_type), + variants: u.variants.into_iter().map(Into::into).collect(), + } + } +} + +impl From for PolicyConditionalSchema { + fn from(schema: policy::ConditionalSchema) -> Self { + match schema { + policy::ConditionalSchema::Union { common, union } => Self { + kind: "union".to_string(), + common: common.into(), + union: Some(union.into()), + conditional: None, + }, + policy::ConditionalSchema::Flat { + common, + conditional, + } => Self { + kind: "flat".to_string(), + common: common.into(), + union: None, + conditional: Some(conditional.into()), + }, + } + } +} + +fn entity_field_to_info(f: &policy::EntityField) -> PolicyEntityFieldInfo { + PolicyEntityFieldInfo { + name: f.name.to_string(), + resolved_type: variable_type_to_json(&f.resolved_type), + origin: serde_json::to_value(&f.origin).expect("FieldOrigin serializes"), + } +} + +#[napi(object)] +pub struct PolicyInspectResult { + #[napi(ts_type = "PolicySpan")] + pub span: Vec, + #[napi(ts_type = "PolicyVariableType")] + pub kind: Value, + pub label: String, +} + +#[napi(object)] +pub struct PolicyPrepareRenameResult { + #[napi(ts_type = "PolicyRenameTarget")] + pub target: Value, + #[napi(ts_type = "PolicySpan")] + pub span: Vec, +} + +#[napi(object)] +pub struct PolicyCompletion { + pub label: String, + pub kind: String, + pub detail: String, + pub info: String, +} + +#[napi(object)] +pub struct PolicyEvaluateRequest { + pub policy_path: String, + #[napi(ts_type = "unknown")] + pub input: Value, + pub goals: Option>, + pub trace: Option, +} + +#[napi(object)] +pub struct PolicyScopeRequest { + pub policy_path: String, + pub goals: Option>, +} + +#[napi(object)] +pub struct PolicyRenameRequest { + #[napi(ts_type = "PolicyRenameTarget")] + pub target: Value, + pub new_name: String, +} + +#[napi(object)] +pub struct PolicyUpdateBlockRequest { + pub policy_path: String, + #[napi(ts_type = "unknown")] + pub block: Value, +} + +#[napi(object)] +pub struct PolicyRemoveBlockRequest { + pub policy_path: String, + pub block_id: String, +} + +fn goals_to_arc(goals: Option>) -> Vec> { + goals + .unwrap_or_default() + .into_iter() + .map(Arc::from) + .collect() +} + +fn resolve_diagnostic_cap(max: Option) -> usize { + match max { + None => 100, + Some(0) => usize::MAX, + Some(n) => n as usize, + } +} + +fn variable_type_to_json(vt: &zen_expression::variable::VariableType) -> Value { + use zen_expression::variable::VariableType; + + match vt { + VariableType::Any => serde_json::json!({ "type": "any" }), + VariableType::Null => serde_json::json!({ "type": "null" }), + VariableType::Bool => serde_json::json!({ "type": "bool" }), + VariableType::String => serde_json::json!({ "type": "string" }), + VariableType::Number => serde_json::json!({ "type": "number" }), + VariableType::Date => serde_json::json!({ "type": "date" }), + VariableType::Interval => serde_json::json!({ "type": "interval" }), + VariableType::Const(c) => serde_json::json!({ "type": "const", "value": c.as_ref() }), + VariableType::Enum(name, values) => { + let vals: Vec<&str> = values.iter().map(|v| v.as_ref()).collect(); + serde_json::json!({ + "type": "enum", + "name": name.as_ref().map(|n| n.as_ref()), + "values": vals, + }) + } + VariableType::Array(inner) => serde_json::json!({ + "type": "array", + "items": variable_type_to_json(inner), + }), + VariableType::Object(obj) => { + let fields: serde_json::Map = obj + .borrow() + .iter() + .map(|(k, v)| (k.to_string(), variable_type_to_json(v))) + .collect(); + serde_json::json!({ + "type": "object", + "fields": fields, + }) + } + VariableType::Nullable(inner) => serde_json::json!({ + "type": "nullable", + "inner": variable_type_to_json(inner), + }), + } +} + +fn dependency_node_to_json(node: &policy::DependencyNode) -> Value { + let mut obj = serde_json::Map::new(); + obj.insert("property".into(), Value::String(node.property.to_string())); + if let Some(writer) = &node.written_by { + obj.insert( + "writtenBy".into(), + serde_json::json!({ + "policyPath": writer.policy_path.as_ref(), + "blockId": writer.block_id.as_ref(), + }), + ); + } + if node.unresolved { + obj.insert("unresolved".into(), Value::Bool(true)); + } + obj.insert( + "resolvedType".into(), + variable_type_to_json(&node.resolved_type), + ); + obj.insert( + "deps".into(), + Value::Array(node.deps.iter().map(dependency_node_to_json).collect()), + ); + Value::Object(obj) +} + +impl From for policy::ScopeRequest { + fn from(r: PolicyScopeRequest) -> Self { + Self { + policy_path: r.policy_path.into(), + goals: goals_to_arc(r.goals), + } + } +} + +#[napi] +pub struct PolicyWorkspace { + inner: policy::PolicyWorkspace, +} + +#[napi] +impl PolicyWorkspace { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: policy::PolicyWorkspace::new(), + } + } + + #[napi] + pub fn set_policy(&mut self, path: String, document: Value) -> napi::Result<()> { + let doc: policy::PolicyDocument = serde_json::from_value(document) + .map_err(|e| anyhow!("Invalid policy document: {e}"))?; + self.inner.set_policy(path, doc); + Ok(()) + } + + #[napi] + pub fn remove_policy(&mut self, path: String) -> bool { + self.inner.remove_policy(&path) + } + + #[napi] + pub fn update_block(&mut self, req: PolicyUpdateBlockRequest) -> napi::Result<()> { + use zen_engine::policy::BlockDoc; + + let current = self.inner.get_policy(&req.policy_path).ok_or_else(|| { + napi::Error::from_reason(format!("policy '{}' not found", req.policy_path)) + })?; + let new_block: BlockDoc = + serde_json::from_value(req.block).map_err(|e| anyhow!("Invalid block: {e}"))?; + let new_id = new_block + .id() + .ok_or_else(|| napi::Error::from_reason("block is missing 'id'"))? + .to_string(); + + let mut doc = (*current).clone(); + match doc + .blocks + .iter() + .position(|b| b.id() == Some(new_id.as_str())) + { + Some(pos) => doc.blocks[pos] = new_block, + None => doc.blocks.push(new_block), + } + self.inner.set_policy(req.policy_path, doc); + Ok(()) + } + + #[napi] + pub fn remove_block(&mut self, req: PolicyRemoveBlockRequest) -> bool { + let Some(current) = self.inner.get_policy(&req.policy_path) else { + return false; + }; + let mut doc = (*current).clone(); + let Some(pos) = doc + .blocks + .iter() + .position(|b| b.id() == Some(req.block_id.as_str())) + else { + return false; + }; + doc.blocks.remove(pos); + self.inner.set_policy(req.policy_path, doc); + true + } + + #[napi] + pub fn policy_paths(&self) -> Vec { + self.inner + .policy_paths() + .into_iter() + .map(|p| p.to_string()) + .collect() + } + + #[napi] + pub fn diagnostics( + &self, + policy_path: String, + max_diagnostics: Option, + ) -> Vec { + let cap = resolve_diagnostic_cap(max_diagnostics); + self.inner + .diagnostics(&policy_path) + .iter() + .take(cap) + .map(PolicyDiagnostic::from) + .collect() + } + + #[napi] + pub fn all_diagnostics(&self, max_diagnostics: Option) -> Vec { + let cap = resolve_diagnostic_cap(max_diagnostics); + self.inner + .all_diagnostics() + .iter() + .take(cap) + .map(PolicyDiagnostic::from) + .collect() + } + + #[napi] + pub fn entities(&self, req: PolicyScopeRequest) -> Vec { + self.inner + .entities(&req.into()) + .into_iter() + .map(|e| PolicyEntityInfo { + name: e.name.to_string(), + fields: e.fields.iter().map(entity_field_to_info).collect(), + }) + .collect() + } + + #[napi] + pub fn globals(&self, req: PolicyScopeRequest) -> Vec { + self.inner + .globals(&req.into()) + .into_iter() + .map(|g| PolicyGlobalInfo { + name: g.name.to_string(), + resolved_type: variable_type_to_json(&g.resolved_type), + origin: serde_json::to_value(&g.origin).expect("FieldOrigin serializes"), + }) + .collect() + } + + #[napi] + pub fn inputs(&self, req: PolicyScopeRequest) -> Vec { + self.inner + .inputs(&req.into()) + .into_iter() + .map(|p| PolicyInputProperty { + path: p.path.to_string(), + resolved_type: variable_type_to_json(&p.resolved_type), + }) + .collect() + } + + #[napi] + pub fn outputs(&self, req: PolicyScopeRequest) -> Vec { + self.inner + .outputs(&req.into()) + .into_iter() + .map(|p| PolicyOutputProperty { + path: p.path.to_string(), + resolved_type: variable_type_to_json(&p.resolved_type), + kind: p.kind.to_string(), + written_by: p.written_by.as_ref().map(PolicyPropertyWriter::from), + instance_of: p.instance_of.as_ref().map(|i| PolicyInstanceOf { + target: i.target.to_string(), + array: i.array, + }), + }) + .collect() + } + + #[napi] + pub fn conditional_schema(&self, req: PolicyScopeRequest) -> PolicyConditionalSchema { + self.inner.conditional_schema(&req.into()).into() + } + + #[napi] + pub fn inspect( + &self, + cursor: PolicyExpressionCursor, + ) -> napi::Result> { + let cursor: policy::Cursor = cursor.try_into()?; + Ok(self + .inner + .inspect(&cursor) + .map(|result| PolicyInspectResult { + span: vec![result.span.0, result.span.1], + kind: variable_type_to_json(&result.kind), + label: result.label, + })) + } + + #[napi] + pub fn completions( + &self, + cursor: PolicyExpressionCursor, + ) -> napi::Result> { + let cursor: policy::Cursor = cursor.try_into()?; + Ok(self + .inner + .completions(&cursor) + .into_iter() + .map(|c| PolicyCompletion { + label: c.label, + kind: serde_json::to_value(&c.kind) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(), + detail: c.detail, + info: c.info, + }) + .collect()) + } + + #[napi] + pub fn prepare_rename( + &self, + cursor: PolicyExpressionCursor, + ) -> napi::Result> { + let cursor: policy::Cursor = cursor.try_into()?; + Ok(self + .inner + .prepare_rename(&cursor) + .map(|result| PolicyPrepareRenameResult { + target: serde_json::to_value(&result.target).expect("RenameTarget serializes"), + span: vec![result.span.0, result.span.1], + })) + } + + #[napi(ts_return_type = "PolicyEngineEdit[]")] + pub fn rename(&self, req: PolicyRenameRequest) -> napi::Result> { + let target: policy::RenameTarget = serde_json::from_value(req.target) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(self + .inner + .rename(&target, &req.new_name) + .into_iter() + .map(|e| serde_json::to_value(e).expect("EngineEdit serializes")) + .collect()) + } + + #[napi(ts_return_type = "PolicyReferenceSite[]")] + pub fn references(&self, target: Value) -> napi::Result> { + let target: policy::RenameTarget = + serde_json::from_value(target).map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(self + .inner + .references(&target) + .into_iter() + .map(|s| serde_json::to_value(s).expect("ReferenceSite serializes")) + .collect()) + } + + #[napi(ts_return_type = "unknown")] + pub fn input_skeleton(&self, req: PolicyScopeRequest) -> Value { + let inner = policy::ScopeRequest { + policy_path: req.policy_path.into(), + goals: goals_to_arc(req.goals), + }; + self.inner.input_skeleton(&inner) + } + + #[napi(ts_return_type = "PolicyDependencyNode")] + pub fn dependencies(&self, target: String) -> Value { + dependency_node_to_json(&self.inner.dependencies(&target)) + } + + #[napi(ts_return_type = "PolicyEvaluationResult")] + pub fn evaluate(&self, req: PolicyEvaluateRequest) -> napi::Result { + let inner_req = policy::EvaluateRequest { + policy_path: req.policy_path.into(), + input: req.input.into(), + goals: goals_to_arc(req.goals), + trace: req.trace.unwrap_or(false), + }; + Self::eval_to_value(self.inner.evaluate(&inner_req)) + } + + #[napi(ts_return_type = "PolicyEvaluationResult")] + pub fn enhance_trace(&self, req: PolicyEvaluateRequest) -> napi::Result { + let inner_req = policy::EvaluateRequest { + policy_path: req.policy_path.into(), + input: req.input.into(), + goals: goals_to_arc(req.goals), + trace: true, + }; + Self::eval_to_value(self.inner.enhance_trace(&inner_req)) + } + + fn eval_to_value( + result: Result, + ) -> napi::Result { + match result { + Ok(result) => { + serde_json::to_value(&result).map_err(|e| napi::Error::from_reason(e.to_string())) + } + Err(policy::EvaluationError::ExpressionFailed { + partial_trace, + policy_path, + block_id, + expression, + source, + }) => { + let mut obj = serde_json::Map::new(); + if let Some(trace) = partial_trace { + let trace = serde_json::to_value(&*trace) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + obj.insert("trace".to_string(), trace); + } + obj.insert( + "error".to_string(), + serde_json::json!({ + "policyPath": policy_path.to_string(), + "blockId": block_id.to_string(), + "expression": expression.to_string(), + "message": source.to_string(), + }), + ); + Ok(Value::Object(obj)) + } + Err(e) => Err(napi::Error::from_reason(e.to_string())), + } + } + + #[napi] + pub fn component_members(&self, policy: String) -> Vec { + self.inner + .component_members(&policy) + .into_iter() + .map(|p| p.to_string()) + .collect() + } + + #[napi] + pub fn cross_component_write_conflicts(&self) -> Vec { + self.inner + .cross_component_write_conflicts() + .into_iter() + .map(|c| PolicyWriteConflict { + path: c.path.to_string(), + policies: c.policies.into_iter().map(|p| p.to_string()).collect(), + }) + .collect() + } +} diff --git a/bindings/nodejs/src/types.rs b/bindings/nodejs/src/types.rs index 1c5a1c6e..db05f420 100644 --- a/bindings/nodejs/src/types.rs +++ b/bindings/nodejs/src/types.rs @@ -48,7 +48,7 @@ impl From for ZenEngineResponse { Self { performance: value.performance, result: value.result.to_value(), - trace: value.trace.map(|opt| { + trace: value.trace.and_then(|t| t.into_graph()).map(|opt| { opt.into_iter() .map(|(key, value)| (key, ZenEngineTrace::from(value))) .collect() diff --git a/bindings/nodejs/zen-engine.wasi-browser.js b/bindings/nodejs/zen-engine.wasi-browser.js index 4549b629..22414dd4 100644 --- a/bindings/nodejs/zen-engine.wasi-browser.js +++ b/bindings/nodejs/zen-engine.wasi-browser.js @@ -56,6 +56,7 @@ const { }, }) export default __napiModule.exports +export const PolicyWorkspace = __napiModule.exports.PolicyWorkspace export const ZenDecision = __napiModule.exports.ZenDecision export const ZenDecisionContent = __napiModule.exports.ZenDecisionContent export const ZenEngine = __napiModule.exports.ZenEngine diff --git a/bindings/nodejs/zen-engine.wasi.cjs b/bindings/nodejs/zen-engine.wasi.cjs index 0d5be24f..939e88d4 100644 --- a/bindings/nodejs/zen-engine.wasi.cjs +++ b/bindings/nodejs/zen-engine.wasi.cjs @@ -108,6 +108,7 @@ const { instance: __napiInstance, module: __wasiModule, napiModule: __napiModule }, }) module.exports = __napiModule.exports +module.exports.PolicyWorkspace = __napiModule.exports.PolicyWorkspace module.exports.ZenDecision = __napiModule.exports.ZenDecision module.exports.ZenDecisionContent = __napiModule.exports.ZenDecisionContent module.exports.ZenEngine = __napiModule.exports.ZenEngine diff --git a/bindings/python/src/content.rs b/bindings/python/src/content.rs index ffacf294..49f8273c 100644 --- a/bindings/python/src/content.rs +++ b/bindings/python/src/content.rs @@ -16,7 +16,9 @@ impl PyZenDecisionContent { pub fn new(data: &str) -> PyResult { let mut content: DecisionContent = serde_json::from_str(data).context("Failed to parse JSON")?; - content.compile(); + if let DecisionContent::Graph(g) = &mut content { + g.compile(); + } Ok(Self(Arc::new(content))) } } diff --git a/bindings/python/src/engine.rs b/bindings/python/src/engine.rs index 30cc6b46..724a26f0 100644 --- a/bindings/python/src/engine.rs +++ b/bindings/python/src/engine.rs @@ -168,13 +168,17 @@ impl PyZenEngine { } pub fn create_decision(&self, content: PyZenDecisionContentJson) -> PyResult { - let decision = self.engine.create_decision(content.0 .0); + let decision = self + .engine + .create_decision(content.0 .0) + .map_err(|e| anyhow!(e.to_string()))?; Ok(PyZenDecision::from(decision)) } pub fn get_decision<'py>(&'py self, _py: Python<'py>, key: &str) -> PyResult { let decision = block_on(self.engine.get_decision(key)) - .context("Failed to find decision with given key")?; + .context("Failed to find decision with given key")? + .map_err(|e| anyhow!(e.to_string()))?; Ok(PyZenDecision::from(decision)) } diff --git a/bindings/uniffi/src/engine.rs b/bindings/uniffi/src/engine.rs index 42459dbe..d00928f4 100644 --- a/bindings/uniffi/src/engine.rs +++ b/bindings/uniffi/src/engine.rs @@ -95,9 +95,13 @@ impl ZenEngine { } pub fn create_decision(&self, content: JsonBuffer) -> Result { - let decision = self.engine.create_decision(Arc::new( - serde_json::from_slice(&content.0).map_err(|_| ZenError::JsonDeserializationFailed)?, - )); + let decision = self + .engine + .create_decision(Arc::new( + serde_json::from_slice(&content.0) + .map_err(|_| ZenError::JsonDeserializationFailed)?, + )) + .map_err(|e| ZenError::ValidationError(e.to_string()))?; Ok(ZenDecision::from(decision)) } @@ -106,21 +110,23 @@ impl ZenEngine { let engine = self.engine.clone(); // Use spawn_blocking to run the non-Send code synchronously - let decision = task::spawn_blocking(move || { - // The blocking code that uses non-Send types - Handle::current().block_on(async move { - engine - .get_decision(&key) - .await - .map_err(|e| ZenError::LoaderInternalError { - key, - details: e.to_string(), - }) - .map(ZenDecision::from) + let decision = + task::spawn_blocking(move || { + // The blocking code that uses non-Send types + Handle::current().block_on(async move { + let outer = engine.get_decision(&key).await.map_err(|e| { + ZenError::LoaderInternalError { + key: key.clone(), + details: e.to_string(), + } + })?; + outer + .map_err(|e| ZenError::ValidationError(e.to_string())) + .map(ZenDecision::from) + }) }) - }) - .await - .map_err(|e| ZenError::EvaluationError(format!("Task failed: {:?}", e)))??; + .await + .map_err(|e| ZenError::EvaluationError(format!("Task failed: {:?}", e)))??; Ok(decision) } diff --git a/bindings/uniffi/src/types.rs b/bindings/uniffi/src/types.rs index 1a1fa2c4..79bf884f 100644 --- a/bindings/uniffi/src/types.rs +++ b/bindings/uniffi/src/types.rs @@ -87,6 +87,7 @@ impl TryFrom for ZenEngineResponse { result: JsonBuffer::try_from(value.result)?, trace: value .trace + .and_then(|t| t.into_graph()) .map(|opt| { opt.into_iter() .map(|(key, value)| Ok((key.to_string(), ZenEngineTrace::try_from(value)?))) diff --git a/core/engine/Cargo.toml b/core/engine/Cargo.toml index 113afa5c..3b36d9e0 100644 --- a/core/engine/Cargo.toml +++ b/core/engine/Cargo.toml @@ -13,10 +13,12 @@ doctest = false [dependencies] ahash = { workspace = true } anyhow = { workspace = true } +base64 = "0.22" thiserror = { workspace = true } petgraph = { workspace = true } serde_json = { workspace = true, features = [] } serde = { workspace = true, features = ["derive", "rc"] } +serde_path_to_error = "0.1" strum = { workspace = true, features = ["derive"] } once_cell = { workspace = true } json_dotpath = { workspace = true } @@ -29,11 +31,16 @@ zen-expression = { path = "../expression", version = "0.55.1" } zen-tmpl = { path = "../template", version = "0.55.1" } nohash-hasher = { workspace = true } downcast-rs = { version = "2.0", features = ["std", "sync"] } +arc-swap = "1" +flate2 = { version = "1", features = ["rust_backend"] } +zip = { version = "8", default-features = false, features = ["deflate-flate2"] } [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } criterion = { workspace = true, features = ["async_tokio"] } insta = { version = "1.43", features = ["yaml", "redactions"] } +toml = "0.8" +mimalloc = "0.1.52" [target.'cfg(not(target_family = "wasm"))'.dependencies] async-trait = { version = "0.1" } diff --git a/core/engine/README.md b/core/engine/README.md index a9c73115..9cdba3d3 100644 --- a/core/engine/README.md +++ b/core/engine/README.md @@ -66,7 +66,6 @@ use zen_engine::loader::{FilesystemLoader, FilesystemLoaderOptions}; async fn evaluate() { let engine = DecisionEngine::new(FilesystemLoader::new(FilesystemLoaderOptions { - keep_in_memory: true, // optionally, keep in memory for increase performance root: "/app/decisions" })); diff --git a/core/engine/benches/engine.rs b/core/engine/benches/engine.rs index fb4eb35e..ba4f7cf5 100644 --- a/core/engine/benches/engine.rs +++ b/core/engine/benches/engine.rs @@ -11,7 +11,6 @@ use zen_expression::variable::Variable; fn create_graph() -> DecisionEngine { let cargo_root = Path::new(env!("CARGO_MANIFEST_DIR")); let loader = FilesystemLoader::new(FilesystemLoaderOptions { - keep_in_memory: true, root: cargo_root .join("../../") .join("test-data") @@ -26,7 +25,7 @@ fn bench_decision(b: &mut Bencher, key: &str, context: Variable) { let rt = Runtime::new().unwrap(); let graph = create_graph(); - let decision = rt.block_on(graph.get_decision(key)).unwrap(); + let decision = rt.block_on(graph.get_decision(key)).unwrap().unwrap(); b.to_async(&rt).iter(|| async { criterion::black_box(decision.evaluate(context.clone()).await.unwrap()); }); @@ -35,7 +34,7 @@ fn bench_decision_8k(b: &mut Bencher, key: &str, context: Variable) { let rt = Runtime::new().unwrap(); let graph = create_graph(); - let decision = rt.block_on(graph.get_decision(key)).unwrap(); + let decision = rt.block_on(graph.get_decision(key)).unwrap().unwrap(); b.to_async(&rt).iter(|| async { criterion::black_box(decision.evaluate(context.clone()).await.unwrap()); }); @@ -45,7 +44,7 @@ fn bench_decision_8k_precompiled(b: &mut Bencher, key: &str, context: Variable) let rt = Runtime::new().unwrap(); let graph = create_graph(); - let mut decision = rt.block_on(graph.get_decision(key)).unwrap(); + let mut decision = rt.block_on(graph.get_decision(key)).unwrap().unwrap(); decision.compile(); b.to_async(&rt).iter(|| async { criterion::black_box(decision.evaluate(context.clone()).await.unwrap()); diff --git a/core/engine/src/decision.rs b/core/engine/src/decision.rs index 1ecd773b..f72e87e3 100644 --- a/core/engine/src/decision.rs +++ b/core/engine/src/decision.rs @@ -1,7 +1,7 @@ use crate::decision_graph::graph::{DecisionGraph, DecisionGraphConfig, DecisionGraphResponse}; use crate::engine::{EvaluationOptions, EvaluationSerializedOptions, EvaluationTraceKind}; use crate::loader::{DynamicLoader, NoopLoader}; -use crate::model::DecisionContent; +use crate::model::GraphContent; use crate::nodes::custom::{DynamicCustomNode, NoopCustomNode}; use crate::nodes::function::http_handler::DynamicHttpHandler; use crate::nodes::validator_cache::ValidatorCache; @@ -15,15 +15,15 @@ use zen_expression::variable::Variable; /// Represents a JDM decision which can be evaluated #[derive(Debug, Clone)] pub struct Decision { - content: Arc, + content: Arc, loader: DynamicLoader, adapter: DynamicCustomNode, http_handler: DynamicHttpHandler, validator_cache: ValidatorCache, } -impl From for Decision { - fn from(value: DecisionContent) -> Self { +impl From for Decision { + fn from(value: GraphContent) -> Self { Self { content: value.into(), loader: Arc::new(NoopLoader::default()), @@ -34,8 +34,8 @@ impl From for Decision { } } -impl From> for Decision { - fn from(value: Arc) -> Self { +impl From> for Decision { + fn from(value: Arc) -> Self { Self { content: value, loader: Arc::new(NoopLoader::default()), diff --git a/core/engine/src/decision_graph/graph.rs b/core/engine/src/decision_graph/graph.rs index 23f351a7..13e348ed 100644 --- a/core/engine/src/decision_graph/graph.rs +++ b/core/engine/src/decision_graph/graph.rs @@ -2,7 +2,7 @@ use crate::decision_graph::cleaner::VariableCleaner; use crate::decision_graph::tracer::NodeTracer; use crate::decision_graph::walker::{GraphWalker, NodeData, StableDiDecisionGraph}; use crate::engine::EvaluationTraceKind; -use crate::model::{DecisionContent, DecisionNodeKind}; +use crate::model::{DecisionNodeKind, GraphContent}; use crate::nodes::custom::CustomNodeHandler; use crate::nodes::decision::DecisionNodeHandler; use crate::nodes::decision_table::DecisionTableNodeHandler; @@ -20,7 +20,7 @@ use ahash::{HashMap, HashMapExt}; use petgraph::algo::is_cyclic_directed; use petgraph::matrix_graph::Zero; use serde::ser::SerializeMap; -use serde::{Deserialize, Serialize, Serializer}; +use serde::{Serialize, Serializer}; use std::cell::RefCell; use std::ops::Deref; use std::rc::Rc; @@ -38,7 +38,7 @@ pub struct DecisionGraph { #[derive(Debug)] pub struct DecisionGraphConfig { - pub content: Arc, + pub content: Arc, pub trace: bool, pub iteration: u8, pub max_depth: u8, @@ -56,7 +56,7 @@ impl DecisionGraph { } fn build_graph( - content: &DecisionContent, + content: &GraphContent, ) -> Result { let mut graph = StableDiDecisionGraph::new(); let mut index_map = HashMap::with_capacity(content.nodes.len()); @@ -237,18 +237,41 @@ impl DecisionGraph { Ok(DecisionGraphResponse { performance: format!("{:.1?}", root_start.elapsed()), result, - trace, + trace: trace.map(EvaluationTrace::Graph), }) } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum EvaluationTrace { + Graph(HashMap, DecisionGraphTrace>), + Policy(crate::policy::Trace), +} + +impl EvaluationTrace { + pub fn as_graph(&self) -> Option<&HashMap, DecisionGraphTrace>> { + match self { + Self::Graph(m) => Some(m), + Self::Policy(_) => None, + } + } + + pub fn into_graph(self) -> Option, DecisionGraphTrace>> { + match self { + Self::Graph(m) => Some(m), + Self::Policy(_) => None, + } + } +} + +#[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DecisionGraphResponse { pub performance: String, pub result: Variable, #[serde(skip_serializing_if = "Option::is_none")] - pub trace: Option, DecisionGraphTrace>>, + pub trace: Option, } impl DecisionGraphResponse { @@ -264,7 +287,25 @@ impl DecisionGraphResponse { map.serialize_entry("performance", &self.performance)?; map.serialize_entry("result", &self.result)?; if let Some(trace) = &self.trace { - map.serialize_entry("trace", &mode.serialize_trace(&trace.to_variable()))?; + match trace { + EvaluationTrace::Graph(graph_trace) => { + map.serialize_entry( + "trace", + &mode.serialize_trace(&graph_trace.to_variable()), + )?; + } + EvaluationTrace::Policy(policy_trace) => match mode { + EvaluationTraceKind::String | EvaluationTraceKind::ReferenceString => { + map.serialize_entry( + "trace", + &serde_json::to_string(policy_trace).unwrap_or_default(), + )?; + } + _ => { + map.serialize_entry("trace", policy_trace)?; + } + }, + } } map.end() diff --git a/core/engine/src/decision_graph/mod.rs b/core/engine/src/decision_graph/mod.rs index 25662b5f..f3db18f7 100644 --- a/core/engine/src/decision_graph/mod.rs +++ b/core/engine/src/decision_graph/mod.rs @@ -5,5 +5,5 @@ mod tracer; mod walker; pub use error::DecisionGraphValidationError; -pub use graph::DecisionGraphResponse; +pub use graph::{DecisionGraphResponse, EvaluationTrace}; pub use tracer::DecisionGraphTrace; diff --git a/core/engine/src/engine.rs b/core/engine/src/engine.rs index 3bb8e18c..f92af51f 100644 --- a/core/engine/src/engine.rs +++ b/core/engine/src/engine.rs @@ -1,10 +1,13 @@ use crate::decision::Decision; -use crate::decision_graph::graph::DecisionGraphResponse; +use crate::decision_graph::graph::{DecisionGraphResponse, EvaluationTrace}; +use crate::error::ContentKindError; use crate::loader::{ClosureLoader, DynamicLoader, LoaderResponse, LoaderResult, NoopLoader}; -use crate::model::DecisionContent; +use crate::model::{DecisionContent, GraphContent}; use crate::nodes::custom::{DynamicCustomNode, NoopCustomNode}; use crate::nodes::function::http_handler::DynamicHttpHandler; -use crate::EvaluationError; +use crate::policy::runtime::{CompiledEntry, CompiledSet}; +use crate::{CompileFailure, EvaluationError}; +use arc_swap::ArcSwapOption; use serde_json::Value; use std::fmt::Debug; use std::future::Future; @@ -13,11 +16,22 @@ use strum::{EnumString, IntoStaticStr}; use zen_expression::variable::Variable; /// Structure used for generating and evaluating JDM decisions -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct DecisionEngine { loader: DynamicLoader, adapter: DynamicCustomNode, http_handler: DynamicHttpHandler, + compiled: Arc>, +} + +impl Debug for DecisionEngine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DecisionEngine") + .field("loader", &self.loader) + .field("adapter", &self.adapter) + .field("http_handler", &self.http_handler) + .finish() + } } #[derive(Debug)] @@ -35,7 +49,7 @@ impl Default for EvaluationOptions { } } -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub struct EvaluationSerializedOptions { pub trace: EvaluationTraceKind, pub max_depth: u8, @@ -50,7 +64,7 @@ impl Default for EvaluationSerializedOptions { } } -#[derive(Debug, Default, PartialEq, Eq, EnumString, IntoStaticStr)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)] #[strum(serialize_all = "camelCase")] pub enum EvaluationTraceKind { #[default] @@ -85,6 +99,7 @@ impl Default for DecisionEngine { loader: Arc::new(NoopLoader::default()), adapter: Arc::new(NoopCustomNode::default()), http_handler: None, + compiled: Arc::new(ArcSwapOption::empty()), } } } @@ -95,21 +110,25 @@ impl DecisionEngine { loader, adapter, http_handler: None, + compiled: Arc::new(ArcSwapOption::empty()), } } pub fn with_adapter(mut self, adapter: DynamicCustomNode) -> Self { self.adapter = adapter; + self.compiled = Arc::new(ArcSwapOption::empty()); self } pub fn with_loader(mut self, loader: DynamicLoader) -> Self { self.loader = loader; + self.compiled = Arc::new(ArcSwapOption::empty()); self } pub fn with_http_handler(mut self, http_handler: DynamicHttpHandler) -> Self { self.http_handler = http_handler; + self.compiled = Arc::new(ArcSwapOption::empty()); self } @@ -119,9 +138,29 @@ impl DecisionEngine { O: Future + Send, { self.loader = Arc::new(ClosureLoader::new(loader)); + self.compiled = Arc::new(ArcSwapOption::empty()); self } + pub fn compile(&self) -> Vec { + let Some(keys) = self.loader.keys() else { + return Vec::new(); + }; + + let set = CompiledSet::build_sync(&self.loader, &keys); + + let failures = set.failures().to_vec(); + self.compiled.store(Some(Arc::new(set))); + failures + } + + pub fn compile_failures(&self) -> Vec { + self.compiled + .load_full() + .map(|set| set.failures().to_vec()) + .unwrap_or_default() + } + /// Evaluates a decision through loader using a key pub async fn evaluate( &self, @@ -145,9 +184,43 @@ impl DecisionEngine { where K: AsRef, { - let content = self.loader.load(key.as_ref()).await?; - let decision = self.create_decision(content); - decision.evaluate_with_opts(context, options).await + let key_str = key.as_ref(); + if let Some(set) = self.compiled.load_full() { + if let Some(entry) = set.get(key_str) { + return match entry { + CompiledEntry::Policy(artifact) => artifact + .evaluate_entry(key_str, context, options.trace) + .map(|r| DecisionGraphResponse { + performance: format!("{:.1?}", r.duration), + result: r.output, + trace: r.trace.map(EvaluationTrace::Policy), + }) + .map_err(|e| Box::new(EvaluationError::Policy(e))), + CompiledEntry::Graph(graph) => { + self.decision_from_graph(graph) + .evaluate_with_opts(context, options) + .await + } + }; + } + } + let content = self.loader.load(key_str).await?; + match content.as_ref() { + DecisionContent::Graph(_) => { + let decision = self.decision_from_graph_arc(content); + decision.evaluate_with_opts(context, options).await + } + DecisionContent::Policy(_) => { + crate::policy::runtime::evaluate_policy( + &self.loader, + key_str, + content, + context, + options, + ) + .await + } + } } pub async fn evaluate_serialized( @@ -159,26 +232,120 @@ impl DecisionEngine { where K: AsRef, { + let key_str = key.as_ref(); + if let Some(set) = self.compiled.load_full() { + if let Some(entry) = set.get(key_str) { + match entry { + CompiledEntry::Policy(artifact) => { + let trace_mode = options.trace; + let trace = options.trace != EvaluationTraceKind::None; + return match artifact.evaluate_entry(key_str, context, trace) { + Ok(r) => { + let response = DecisionGraphResponse { + performance: format!("{:.1?}", r.duration), + result: r.output, + trace: r.trace.map(EvaluationTrace::Policy), + }; + Ok(response + .serialize_with_mode(serde_json::value::Serializer, trace_mode) + .unwrap_or_default()) + } + Err(e) => { + let err = EvaluationError::Policy(e); + Err(err + .serialize_with_mode(serde_json::value::Serializer, trace_mode) + .unwrap_or_default()) + } + }; + } + CompiledEntry::Graph(graph) => { + return self + .decision_from_graph(graph) + .evaluate_serialized(context, options) + .await; + } + } + } + } let content = self .loader - .load(key.as_ref()) + .load(key_str) .await .map_err(|err| Value::String(err.to_string()))?; - let decision = self.create_decision(content); - decision.evaluate_serialized(context, options).await + match content.as_ref() { + DecisionContent::Graph(_) => { + let decision = self.decision_from_graph_arc(content); + decision.evaluate_serialized(context, options).await + } + DecisionContent::Policy(_) => { + let inner_opts = EvaluationOptions { + trace: options.trace != EvaluationTraceKind::None, + max_depth: options.max_depth, + }; + let trace_mode = options.trace; + let response = crate::policy::runtime::evaluate_policy( + &self.loader, + key_str, + content, + context, + inner_opts, + ) + .await; + match response { + Ok(ok) => Ok(ok + .serialize_with_mode(serde_json::value::Serializer, trace_mode) + .unwrap_or_default()), + Err(err) => Err(err + .serialize_with_mode(serde_json::value::Serializer, trace_mode) + .unwrap_or_default()), + } + } + } } - /// Creates a decision from DecisionContent, exists for easier binding creation - pub fn create_decision(&self, content: Arc) -> Decision { - Decision::from(content) + fn decision_from_graph_arc(&self, content: Arc) -> Decision { + let graph: Arc = match Arc::try_unwrap(content) { + Ok(DecisionContent::Graph(g)) => Arc::new(g), + Err(arc) => match arc.as_ref() { + DecisionContent::Graph(g) => Arc::new(g.clone()), + DecisionContent::Policy(_) => { + panic!("decision_from_graph_arc called with Policy variant") + } + }, + Ok(DecisionContent::Policy(_)) => { + panic!("decision_from_graph_arc called with Policy variant") + } + }; + self.decision_from_graph(graph) + } + + fn decision_from_graph(&self, graph: Arc) -> Decision { + Decision::from(graph) .with_loader(self.loader.clone()) .with_adapter(self.adapter.clone()) .with_http_handler(self.http_handler.clone()) } + /// Creates a decision from DecisionContent, exists for easier binding creation + pub fn create_decision( + &self, + content: Arc, + ) -> Result { + match content.as_ref() { + DecisionContent::Graph(_) => Ok(self.decision_from_graph_arc(content)), + DecisionContent::Policy(_) => Err(ContentKindError { + expected: "graph", + got: "policy", + }), + } + } + /// Retrieves a decision based on the loader - pub async fn get_decision(&self, key: &str) -> LoaderResult { + pub async fn get_decision( + &self, + key: &str, + ) -> LoaderResult> { let content = self.loader.load(key).await?; Ok(self.create_decision(content)) } diff --git a/core/engine/src/error.rs b/core/engine/src/error.rs index fcee0de8..bc50c089 100644 --- a/core/engine/src/error.rs +++ b/core/engine/src/error.rs @@ -4,10 +4,46 @@ use crate::DecisionGraphValidationError; use serde::ser::SerializeMap; use serde::{Serialize, Serializer}; use serde_json::Value; +use std::fmt; use std::sync::Arc; use thiserror::Error; use zen_types::variable::Variable; +#[derive(Debug, Error)] +#[error("expected {expected} content, got {got}")] +pub struct ContentKindError { + pub expected: &'static str, + pub got: &'static str, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompileFailure { + pub key: Arc, + pub kind: &'static str, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl fmt::Display for CompileFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} [{}]: ", self.key, self.kind)?; + match &self.error { + Some(error) => write!(f, "{error}"), + None => { + let messages: Vec<&str> = self + .diagnostics + .iter() + .map(|d| d.message.as_str()) + .collect(); + write!(f, "{}", messages.join("; ")) + } + } + } +} + #[derive(Debug, Error)] pub enum EvaluationError { #[error("Loader error")] @@ -28,6 +64,16 @@ pub enum EvaluationError { #[error("Validation failed")] Validation(Value), + + #[error("policy evaluation error: {0}")] + Policy(crate::policy::EvaluationError), + + #[error("expected {expected} content for key '{key}', got {got}")] + ContentKindMismatch { + expected: &'static str, + got: &'static str, + key: Arc, + }, } impl EvaluationError { @@ -78,6 +124,16 @@ impl EvaluationError { map.serialize_entry("type", "Validation")?; map.serialize_entry("source", err)?; } + EvaluationError::Policy(err) => { + map.serialize_entry("type", "PolicyError")?; + err.serialize_into_map(&mut map)?; + } + EvaluationError::ContentKindMismatch { expected, got, key } => { + map.serialize_entry("type", "ContentKindMismatch")?; + map.serialize_entry("expected", expected)?; + map.serialize_entry("got", got)?; + map.serialize_entry("key", key)?; + } } map.end() @@ -104,3 +160,9 @@ impl From for Box { Box::new(EvaluationError::InvalidGraph(error.into())) } } + +impl From for Box { + fn from(error: crate::policy::EvaluationError) -> Self { + Box::new(EvaluationError::Policy(error)) + } +} diff --git a/core/engine/src/lib.rs b/core/engine/src/lib.rs index b16743f5..04499167 100644 --- a/core/engine/src/lib.rs +++ b/core/engine/src/lib.rs @@ -48,7 +48,6 @@ //! //! async fn evaluate() { //! let engine = DecisionEngine::new(FilesystemLoader::new(FilesystemLoaderOptions { -//! keep_in_memory: true, // optionally, keep in memory for increase performance //! root: "/app/decisions" //! })); //! @@ -119,6 +118,7 @@ //! } //! ``` +#![forbid(unsafe_code)] #![deny(clippy::unwrap_used)] #![allow(clippy::module_inception)] @@ -130,12 +130,17 @@ pub mod error; pub mod loader; pub mod model; pub mod nodes; +pub mod policy; + +pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION"); pub use config::ZEN_CONFIG; pub use decision::Decision; -pub use decision_graph::{DecisionGraphResponse, DecisionGraphTrace, DecisionGraphValidationError}; +pub use decision_graph::{ + DecisionGraphResponse, DecisionGraphTrace, DecisionGraphValidationError, EvaluationTrace, +}; pub use engine::{ DecisionEngine, EvaluationOptions, EvaluationSerializedOptions, EvaluationTraceKind, }; -pub use error::EvaluationError; +pub use error::{CompileFailure, ContentKindError, EvaluationError}; pub use zen_expression::Variable; diff --git a/core/engine/src/loader/cached.rs b/core/engine/src/loader/cached.rs index 670ffbf7..fe040b1f 100644 --- a/core/engine/src/loader/cached.rs +++ b/core/engine/src/loader/cached.rs @@ -38,4 +38,79 @@ impl DecisionLoader for CachedLoader { Ok(decision_content) }) } + + fn keys(&self) -> Option>> { + self.loader.keys() + } + + fn load_sync(&self, key: &str) -> Option { + let Ok(mut cache) = self.cache.try_lock() else { + return self.loader.load_sync(key); + }; + if let Some(content) = cache.get(key) { + return Some(Ok(content.clone())); + } + let response = self.loader.load_sync(key)?; + if let Ok(content) = &response { + cache.insert(key.to_string(), content.clone()); + } + Some(response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::loader::MemoryLoader; + use crate::model::DecisionContent; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Debug, Default)] + struct CountingLoader { + inner: MemoryLoader, + sync_loads: AtomicUsize, + } + + impl DecisionLoader for CountingLoader { + fn load<'a>( + &'a self, + key: &'a str, + ) -> Pin + 'a + Send>> { + self.inner.load(key) + } + + fn load_sync(&self, key: &str) -> Option { + self.sync_loads.fetch_add(1, Ordering::SeqCst); + self.inner.load_sync(key) + } + } + + #[test] + fn load_sync_uses_cache_and_hits_inner_once() { + let counting = Arc::new(CountingLoader::default()); + counting.inner.add("graph.json", DecisionContent::default()); + let cached = CachedLoader::from(counting.clone() as DynamicLoader); + + let first = cached.load_sync("graph.json").unwrap().unwrap(); + let second = cached.load_sync("graph.json").unwrap().unwrap(); + + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(counting.sync_loads.load(Ordering::SeqCst), 1); + } + + #[test] + fn delegates_keys_and_load_sync_to_inner_loader() { + let memory_loader = MemoryLoader::default(); + memory_loader.add("graph.json", DecisionContent::default()); + + let cached = CachedLoader::from(Arc::new(memory_loader) as DynamicLoader); + + let keys = cached.keys().unwrap(); + assert_eq!(keys, vec![Arc::from("graph.json")]); + + let content = cached.load_sync("graph.json").unwrap().unwrap(); + assert!(content.as_graph().is_some()); + + assert!(cached.load_sync("missing.json").unwrap().is_err()); + } } diff --git a/core/engine/src/loader/config.rs b/core/engine/src/loader/config.rs new file mode 100644 index 00000000..a8cfbcdd --- /dev/null +++ b/core/engine/src/loader/config.rs @@ -0,0 +1,122 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::loader::{DynamicLoader, FilesystemLoader, FilesystemLoaderOptions, MemoryLoader}; +use crate::model::DecisionContent; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum LoaderConfig { + Static { + content: HashMap, + }, + #[serde(rename = "fs")] + Filesystem { + path: String, + }, + Zip { + bytes: Vec, + }, +} + +impl LoaderConfig { + pub fn into_loader(self) -> anyhow::Result { + match self { + LoaderConfig::Static { content } => { + let loader = MemoryLoader::default(); + for (key, decision_content) in content { + loader.add(key, decision_content); + } + Ok(Arc::new(loader)) + } + LoaderConfig::Filesystem { path } => { + Ok(Arc::new(FilesystemLoader::new(FilesystemLoaderOptions { + root: path, + }))) + } + LoaderConfig::Zip { bytes } => Self::loader_from_zip(&bytes), + } + } + + fn loader_from_zip(bytes: &[u8]) -> anyhow::Result { + use std::io::Read; + + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes))?; + let loader = MemoryLoader::default(); + for index in 0..archive.len() { + let mut entry = archive.by_index(index)?; + if !entry.is_file() || !entry.name().ends_with(".json") { + continue; + } + + let key = entry.name().to_string(); + let mut buffer = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut buffer)?; + let content: DecisionContent = serde_json::from_slice(&buffer)?; + loader.add(key, content); + } + + Ok(Arc::new(loader)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const GRAPH_JSON: &str = r#"{"nodes":[],"edges":[]}"#; + + #[tokio::test] + async fn static_config_serves_decisions_by_key() { + let mut content = HashMap::new(); + content.insert( + "graph.json".to_string(), + serde_json::from_str::(GRAPH_JSON).unwrap(), + ); + + let loader = LoaderConfig::Static { content }.into_loader().unwrap(); + assert!(loader.load("graph.json").await.is_ok()); + assert!(loader.load("missing.json").await.is_err()); + } + + #[test] + fn fs_config_reads_path() { + let config: LoaderConfig = serde_json::from_str(r#"{"type":"fs","path":"p"}"#).unwrap(); + + let LoaderConfig::Filesystem { path } = config else { + panic!("expected filesystem loader config"); + }; + + assert_eq!(path, "p"); + } + + #[tokio::test] + async fn zip_config_decompresses_and_serves_decisions() { + use std::io::Write; + + let mut cursor = std::io::Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + writer.start_file("graph.json", options).unwrap(); + writer.write_all(GRAPH_JSON.as_bytes()).unwrap(); + writer.finish().unwrap(); + } + + let loader = LoaderConfig::Zip { + bytes: cursor.into_inner(), + } + .into_loader() + .unwrap(); + + assert!(loader.load("graph.json").await.is_ok()); + assert!(loader.load("missing.json").await.is_err()); + } +} diff --git a/core/engine/src/loader/filesystem.rs b/core/engine/src/loader/filesystem.rs index f00a6f23..cb456fb9 100644 --- a/core/engine/src/loader/filesystem.rs +++ b/core/engine/src/loader/filesystem.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::fs::File; use std::future::Future; use std::io::BufReader; @@ -7,7 +6,6 @@ use std::pin::Pin; use std::sync::Arc; use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; use crate::loader::{DecisionLoader, LoaderError, LoaderResponse}; use crate::model::DecisionContent; @@ -16,13 +14,11 @@ use crate::model::DecisionContent; #[derive(Debug)] pub struct FilesystemLoader { root: String, - memory_refs: Option>>>, } #[derive(Serialize, Deserialize)] pub struct FilesystemLoaderOptions> { pub root: R, - pub keep_in_memory: bool, } impl FilesystemLoader { @@ -30,34 +26,19 @@ impl FilesystemLoader { where R: Into, { - let root = options.root.into(); - let memory_refs = if options.keep_in_memory { - Some(Default::default()) - } else { - None - }; - - Self { root, memory_refs } + Self { + root: options.root.into(), + } } fn key_to_path>(&self, key: K) -> PathBuf { Path::new(&self.root).join(key.as_ref()) } - async fn read_from_file(&self, key: K) -> LoaderResponse - where - K: AsRef, - { - if let Some(memory_refs) = &self.memory_refs { - let mref = memory_refs.read().await; - if let Some(decision_content) = mref.get(key.as_ref()) { - return Ok(decision_content.clone()); - } - } - + fn read_content>(&self, key: K) -> LoaderResponse { let path = self.key_to_path(key.as_ref()); if !Path::exists(&path) { - return Err(LoaderError::NotFound(String::from(key.as_ref())).into()); + return Err(LoaderError::NotFound(String::from(key.as_ref()))); } let file = File::open(path).map_err(|e| LoaderError::Internal { @@ -72,13 +53,7 @@ impl FilesystemLoader { source: e.into(), })?; - let ptr = Arc::new(result); - if let Some(memory_refs) = &self.memory_refs { - let mut mref = memory_refs.write().await; - mref.insert(key.as_ref().to_string(), ptr.clone()); - } - - Ok(ptr) + Ok(Arc::new(result)) } } @@ -87,6 +62,71 @@ impl DecisionLoader for FilesystemLoader { &'a self, key: &'a str, ) -> Pin + 'a + Send>> { - Box::pin(async move { self.read_from_file(key).await }) + Box::pin(async move { self.read_content(key) }) + } + + fn load_sync(&self, key: &str) -> Option { + Some(self.read_content(key)) + } + + fn keys(&self) -> Option>> { + let root = Path::new(&self.root); + let mut keys = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().and_then(|e| e.to_str()) == Some("json") { + let key = path.strip_prefix(root).ok().and_then(|rel| { + rel.components() + .map(|component| component.as_os_str().to_str()) + .collect::>>() + .map(|segments| segments.join("/")) + }); + if let Some(key) = key { + keys.push(Arc::from(key)); + } + } + } + } + Some(keys) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_loader() -> FilesystemLoader { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("test-data"); + FilesystemLoader::new(FilesystemLoaderOptions { + root: root.to_string_lossy().to_string(), + }) + } + + #[tokio::test] + async fn load_and_load_sync_resolve_existing_key() { + let loader = test_loader(); + + assert!(loader.load("table.json").await.is_ok()); + assert!(loader.load_sync("table.json").unwrap().is_ok()); + } + + #[tokio::test] + async fn load_reports_missing_key() { + let loader = test_loader(); + + assert!(loader.load("missing.json").await.is_err()); + assert!(loader.load_sync("missing.json").unwrap().is_err()); } } diff --git a/core/engine/src/loader/memory.rs b/core/engine/src/loader/memory.rs index 343dc5e8..f66996ad 100644 --- a/core/engine/src/loader/memory.rs +++ b/core/engine/src/loader/memory.rs @@ -48,4 +48,16 @@ impl DecisionLoader for MemoryLoader { .ok_or_else(|| LoaderError::NotFound(key.to_string()).into()) }) } + + fn keys(&self) -> Option>> { + let mref = self.memory_refs.read().unwrap(); + Some(mref.keys().map(|k| Arc::from(k.as_str())).collect()) + } + + fn load_sync(&self, key: &str) -> Option { + Some( + self.get(key) + .ok_or_else(|| LoaderError::NotFound(key.to_string())), + ) + } } diff --git a/core/engine/src/loader/mod.rs b/core/engine/src/loader/mod.rs index 4fc5906b..085c16e4 100644 --- a/core/engine/src/loader/mod.rs +++ b/core/engine/src/loader/mod.rs @@ -7,6 +7,7 @@ use thiserror::Error; pub use cached::CachedLoader; pub use closure::ClosureLoader; +pub use config::LoaderConfig; pub use filesystem::{FilesystemLoader, FilesystemLoaderOptions}; pub use memory::MemoryLoader; pub use noop::NoopLoader; @@ -15,6 +16,7 @@ use crate::model::DecisionContent; mod cached; mod closure; +mod config; mod filesystem; mod memory; mod noop; @@ -30,6 +32,14 @@ pub trait DecisionLoader: Debug + Send + Sync + DowncastSync { &'a self, key: &'a str, ) -> Pin + 'a + Send>>; + + fn keys(&self) -> Option>> { + None + } + + fn load_sync(&self, _key: &str) -> Option { + None + } } impl_downcast!(sync DecisionLoader); diff --git a/core/engine/src/model/decision_content.rs b/core/engine/src/model/decision_content.rs index 3410bf13..76d7b65b 100644 --- a/core/engine/src/model/decision_content.rs +++ b/core/engine/src/model/decision_content.rs @@ -1,11 +1,97 @@ -use serde::{Deserialize, Serialize}; +use crate::policy::PolicyDocument; +use serde::{Deserialize, Deserializer, Serialize}; use std::sync::Arc; use zen_expression::{ExpressionKind, Isolate, OpcodeCache}; use zen_types::decision::{DecisionEdge, DecisionNode, DecisionNodeKind}; +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] +pub enum DecisionContent { + Graph(GraphContent), + Policy(PolicyContent), +} + +impl<'de> Deserialize<'de> for DecisionContent { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let is_policy = value + .as_object() + .is_some_and(|object| object.contains_key("blocks")); + + let content = if is_policy { + serde_path_to_error::deserialize::<_, PolicyContent>(value).map(Self::Policy) + } else { + serde_path_to_error::deserialize::<_, GraphContent>(value).map(Self::Graph) + }; + + content.map_err(serde::de::Error::custom) + } +} + +impl Default for DecisionContent { + fn default() -> Self { + Self::Graph(GraphContent::default()) + } +} + +impl DecisionContent { + pub fn as_graph(&self) -> Option<&GraphContent> { + match self { + Self::Graph(g) => Some(g), + Self::Policy(_) => None, + } + } + + pub fn as_policy(&self) -> Option<&PolicyContent> { + match self { + Self::Policy(p) => Some(p), + Self::Graph(_) => None, + } + } + + pub fn kind(&self) -> &'static str { + match self { + Self::Graph(_) => "graph", + Self::Policy(_) => "policy", + } + } + + pub fn into_graph_arc(self: Arc) -> Option> { + match Arc::try_unwrap(self) { + Ok(Self::Graph(g)) => Some(Arc::new(g)), + Ok(Self::Policy(_)) => None, + Err(arc) => match arc.as_ref() { + Self::Graph(g) => Some(Arc::new(g.clone())), + Self::Policy(_) => None, + }, + } + } +} + +impl From for DecisionContent { + fn from(value: GraphContent) -> Self { + Self::Graph(value) + } +} + +impl From for DecisionContent { + fn from(value: PolicyContent) -> Self { + Self::Policy(value) + } +} + +impl From> for PolicyContent { + fn from(value: Arc) -> Self { + Self(value) + } +} + #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Default)] #[serde(rename_all = "camelCase")] -pub struct DecisionContent { +pub struct GraphContent { pub nodes: Vec>, pub edges: Vec>, @@ -13,10 +99,17 @@ pub struct DecisionContent { pub compiled_cache: Option>, } -impl DecisionContent { +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(transparent)] +pub struct PolicyContent(pub Arc); + +impl GraphContent { pub fn compile(&mut self) { - let mut sources: Vec<(Arc, ExpressionKind)> = Vec::new(); + if self.compiled_cache.is_some() { + return; + } + let mut sources: Vec<(Arc, ExpressionKind)> = Vec::new(); for node in &self.nodes { match &node.kind { DecisionNodeKind::ExpressionNode { content } => { @@ -83,3 +176,37 @@ impl DecisionContent { self.compiled_cache.replace(Arc::new(cache)); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn malformed_graph_error_mentions_field() { + let error = serde_json::from_str::(r#"{"nodes":[],"edges":"bad"}"#) + .unwrap_err() + .to_string(); + assert!(error.contains("edges"), "{error}"); + } + + #[test] + fn malformed_policy_error_mentions_inner_field() { + let json = r#"{"blocks":[{"type":"assertion","id":"b1","props":{"data":{}}}]}"#; + let error = serde_json::from_str::(json) + .unwrap_err() + .to_string(); + assert!(error.contains("output"), "{error}"); + } + + #[test] + fn valid_graph_routes_to_graph_variant() { + let content: DecisionContent = serde_json::from_str(r#"{"nodes":[],"edges":[]}"#).unwrap(); + assert!(content.as_graph().is_some()); + } + + #[test] + fn valid_policy_routes_to_policy_variant() { + let content: DecisionContent = serde_json::from_str(r#"{"blocks":[]}"#).unwrap(); + assert!(content.as_policy().is_some()); + } +} diff --git a/core/engine/src/model/mod.rs b/core/engine/src/model/mod.rs index f0fdfddb..006cd418 100644 --- a/core/engine/src/model/mod.rs +++ b/core/engine/src/model/mod.rs @@ -1,3 +1,3 @@ pub use zen_types::decision::*; mod decision_content; -pub use decision_content::DecisionContent; +pub use decision_content::{DecisionContent, GraphContent, PolicyContent}; diff --git a/core/engine/src/nodes/decision/mod.rs b/core/engine/src/nodes/decision/mod.rs index 208a6a24..ccd98826 100644 --- a/core/engine/src/nodes/decision/mod.rs +++ b/core/engine/src/nodes/decision/mod.rs @@ -40,13 +40,20 @@ impl NodeHandler for DecisionNodeHandler { async fn handle(&self, ctx: NodeContext) -> NodeResult { let loader = ctx.extensions.loader(); let sub_decision = loader.load(ctx.node.key.deref()).await.node_context(&ctx)?; + let sub_kind = sub_decision.kind(); + let Some(sub_graph) = sub_decision.into_graph_arc() else { + return ctx.error(format!( + "sub-decision '{}' is a {sub_kind}, expected graph", + ctx.node.key + )); + }; let mut decision_graph_ref = self.decision_graph.borrow_mut(); let decision_graph = match decision_graph_ref.as_mut() { Some(dg) => dg, None => { let dg = DecisionGraph::try_new(DecisionGraphConfig { - content: sub_decision, + content: sub_graph, extensions: ctx.extensions.clone(), trace: ctx.config.trace, iteration: ctx.iteration + 1, @@ -66,7 +73,12 @@ impl NodeHandler for DecisionNodeHandler { match evaluate_result { Ok(result) => { ctx.trace(|trace| { - *trace = result.trace.to_variable(); + *trace = result + .trace + .and_then(|t| t.into_graph()) + .as_ref() + .map(|m| m.to_variable()) + .unwrap_or(Variable::Null); }); ctx.success(result.result) diff --git a/core/engine/src/nodes/function/v2/module/http/auth/providers.rs b/core/engine/src/nodes/function/v2/module/http/auth/providers.rs index b39681ff..150d3d73 100644 --- a/core/engine/src/nodes/function/v2/module/http/auth/providers.rs +++ b/core/engine/src/nodes/function/v2/module/http/auth/providers.rs @@ -5,14 +5,44 @@ use http::HeaderValue; use reqsign::{aws, azure, google}; use reqwest::{Body, Request}; use sha2::{Digest, Sha256}; +use std::fmt::Debug; use std::ops::Deref; use std::sync::{Arc, OnceLock}; +#[derive(Debug)] +struct CachedProvider(Arc) +where + Provider: reqsign::ProvideCredential + Debug; + +impl reqsign::ProvideCredential for CachedProvider +where + Provider: reqsign::ProvideCredential + Debug, +{ + type Credential = Provider::Credential; + + async fn provide_credential( + &self, + ctx: &reqsign::Context, + ) -> reqsign::Result> { + self.0.provide_credential(ctx).await + } +} + +impl Clone for CachedProvider +where + Provider: reqsign::ProvideCredential + Debug, +{ + fn clone(&self) -> Self { + CachedProvider(self.0.clone()) + } +} + impl AwsIamAuth { pub async fn build_request(&self, http_request: HttpRequest) -> anyhow::Result { - static CACHED_PROVIDER: OnceLock> = OnceLock::new(); + static CACHED_PROVIDER: OnceLock> = + OnceLock::new(); let provider = CACHED_PROVIDER - .get_or_init(|| Arc::new(aws::DefaultCredentialProvider::new())) + .get_or_init(|| CachedProvider(Arc::new(aws::DefaultCredentialProvider::new()))) .clone(); let signer = aws::default_signer(self.service.deref(), self.region.0.deref()) @@ -42,9 +72,10 @@ impl AwsIamAuth { impl GcpIamAuth { pub async fn build_request(&self, http_request: HttpRequest) -> anyhow::Result { - static CACHED_PROVIDER: OnceLock> = OnceLock::new(); + static CACHED_PROVIDER: OnceLock> = + OnceLock::new(); let provider = CACHED_PROVIDER - .get_or_init(|| Arc::new(google::DefaultCredentialProvider::new())) + .get_or_init(|| CachedProvider(Arc::new(google::DefaultCredentialProvider::new()))) .clone(); let signer = @@ -62,9 +93,10 @@ impl GcpIamAuth { impl AzureIamAuth { pub async fn build_request(&self, http_request: HttpRequest) -> anyhow::Result { - static CACHED_PROVIDER: OnceLock> = OnceLock::new(); + static CACHED_PROVIDER: OnceLock> = + OnceLock::new(); let provider = CACHED_PROVIDER - .get_or_init(|| Arc::new(azure::DefaultCredentialProvider::new())) + .get_or_init(|| CachedProvider(Arc::new(azure::DefaultCredentialProvider::new()))) .clone(); let signer = azure::default_signer().with_credential_provider(provider); @@ -78,85 +110,3 @@ impl AzureIamAuth { Request::try_from(new_http_request).context("Failed to create request") } } - -#[cfg(all(test, not(miri)))] -mod tests { - use super::*; - use crate::nodes::function::v2::module::http::auth::AwsRegion; - use ::http::Request as HttpRequest; - use reqwest::Body; - - #[tokio::test] - async fn aws_iam_produces_sigv4_authorization_header() { - std::env::set_var("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE"); - std::env::set_var( - "AWS_SECRET_ACCESS_KEY", - "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - ); - std::env::set_var("AWS_REGION", "us-east-1"); - - let auth = AwsIamAuth { - region: AwsRegion("us-east-1".into()), - service: "s3".into(), - }; - let req = HttpRequest::builder() - .method("GET") - .uri("https://example-bucket.s3.amazonaws.com/key") - .body(Body::from("hello")) - .expect("build request"); - - let signed = auth - .build_request(req) - .await - .expect("signing should succeed"); - - assert!( - signed.headers().contains_key("authorization"), - "expected Authorization header" - ); - assert!(signed.headers().contains_key("x-amz-content-sha256")); - assert!(signed.headers().contains_key("x-amz-date")); - let auth_hdr = signed - .headers() - .get("authorization") - .expect("auth header present") - .to_str() - .expect("auth header is ascii"); - assert!( - auth_hdr.starts_with("AWS4-HMAC-SHA256"), - "unexpected auth scheme: {auth_hdr}" - ); - } - - #[tokio::test] - async fn gcp_iam_build_request_does_not_panic() { - let auth = GcpIamAuth { - service: "storage".into(), - }; - let req = HttpRequest::builder() - .method("GET") - .uri("https://storage.googleapis.com/b/foo/o/bar") - .body(Body::from("")) - .expect("build request"); - - // Without GOOGLE_APPLICATION_CREDENTIALS the provider returns no creds; - // we only assert the signer plumbing runs without panicking. - let _ = auth.build_request(req).await; - } - - // Ignored by default: Azure's DefaultCredentialProvider probes IMDS/MSI - // endpoints when no creds are present, adding ~60s wall time. Run with - // `cargo test -- --ignored` to exercise it on demand. - #[tokio::test] - #[ignore] - async fn azure_iam_build_request_does_not_panic() { - let auth = AzureIamAuth; - let req = HttpRequest::builder() - .method("GET") - .uri("https://account.blob.core.windows.net/container/blob") - .body(Body::from("")) - .expect("build request"); - - let _ = auth.build_request(req).await; - } -} diff --git a/core/engine/src/nodes/function/v2/module/zen.rs b/core/engine/src/nodes/function/v2/module/zen.rs index 9dbd0e44..764dc0f4 100644 --- a/core/engine/src/nodes/function/v2/module/zen.rs +++ b/core/engine/src/nodes/function/v2/module/zen.rs @@ -59,8 +59,15 @@ impl RuntimeListener for ZenListener { let load_result = loader.load(key.as_str()).await; let decision_content = load_result.or_throw(&ctx)?; + let kind = decision_content.kind(); + let Some(graph_content) = decision_content.into_graph_arc() else { + return Err(rquickjs::Exception::throw_message( + &ctx, + &format!("decision '{key}' is a {kind}, expected graph"), + )); + }; let mut sub_tree = DecisionGraph::try_new(DecisionGraphConfig { - content: decision_content, + content: graph_content, max_depth, iteration: iteration + 1, trace, diff --git a/core/engine/src/policy/blocks/assertion.rs b/core/engine/src/policy/blocks/assertion.rs new file mode 100644 index 00000000..611f7bb9 --- /dev/null +++ b/core/engine/src/policy/blocks/assertion.rs @@ -0,0 +1,319 @@ +use std::sync::Arc; + +use ahash::HashSet; +use serde::{Deserialize, Serialize}; +use zen_expression::variable::{Variable, VariableType}; +use zen_expression::Isolate; + +use crate::policy::types::{ + BlockTrace, ConditionTrace, Cursor, CursorTarget, Diagnostic, DiagnosticCode, ExpressionKind, +}; + +use crate::policy::ArcStrTrim; + +use super::context::{AnalysisContext, ExecutionContext, ExecutionError}; +use super::{ + Block, BlockKind, BlockReadPlan, ConditionalReads, ExpressionLocation, ParseContext, + ReadFlattenFn, WriteSite, WriteTarget, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssertionDoc { + pub output: Arc, + pub conditions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConditionDoc { + pub id: Arc, + pub expression: Arc, + pub operator: ConditionOperatorDoc, + #[serde(default)] + pub depth: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ConditionOperatorDoc { + And, + Or, +} + +#[derive(Debug, Clone)] +pub struct AssertionIr { + pub output: Arc, + pub conditions: Vec, +} + +#[derive(Debug, Clone)] +pub struct AssertionCondition { + pub id: Arc, + pub expression: Arc, + pub operator: ConditionOperator, + pub depth: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConditionOperator { + And, + Or, +} + +use crate::policy::MAX_RECURSION_DEPTH; + +impl ConditionOperator { + fn apply(&self, left: bool, right: bool) -> bool { + match self { + ConditionOperator::And => left && right, + ConditionOperator::Or => left || right, + } + } +} + +impl AssertionIr { + pub(crate) fn parse( + id: &Arc, + doc: &AssertionDoc, + policy_path: &Arc, + diagnostics: &mut Vec, + ) -> Block { + let mut cx = ParseContext { + policy_path, + block_id: id, + diagnostics, + }; + let mut output = doc.output.trimmed(); + + if output.is_empty() { + cx.block_warning( + DiagnosticCode::InvalidWritePath, + "assertion has no output property path", + ); + } else if let Err(reason) = WriteTarget::validate_path(&output) { + cx.block_error( + DiagnosticCode::InvalidWritePath, + format!("invalid write path '{output}': {reason}"), + ); + output = Arc::from(""); + } + + if doc.conditions.is_empty() { + cx.block_error(DiagnosticCode::EmptyBlock, "assertion has no conditions"); + } + + let conditions: Vec<_> = doc + .conditions + .iter() + .filter_map(|c| { + let expr = c.expression.trimmed(); + if expr.is_empty() { + cx.expression_warning( + &c.id, + DiagnosticCode::EmptyBlock, + "assertion condition is empty", + ); + return None; + } + if (c.depth as usize) >= MAX_RECURSION_DEPTH { + cx.expression_error( + &c.id, + DiagnosticCode::MaxDepthExceeded, + format!( + "assertion condition depth {} exceeds maximum of {}", + c.depth, MAX_RECURSION_DEPTH + ), + ); + return None; + } + Some(AssertionCondition { + id: c.id.clone(), + expression: expr, + operator: match c.operator { + ConditionOperatorDoc::And => ConditionOperator::And, + ConditionOperatorDoc::Or => ConditionOperator::Or, + }, + depth: c.depth, + }) + }) + .collect(); + + Block { + id: id.clone(), + kind: BlockKind::Assertion(Arc::new(AssertionIr { output, conditions })), + } + } + + pub(super) fn expressions(&self, block_id: &Arc) -> Vec { + self.conditions + .iter() + .filter_map(|c| { + ExpressionLocation::try_new( + block_id.clone(), + c.id.clone(), + ExpressionKind::Standard, + c.expression.clone(), + ) + }) + .collect() + } + + pub(super) fn write_sites(&self) -> Vec { + if self.output.is_empty() { + return Vec::new(); + } + let contributing: HashSet> = + self.conditions.iter().map(|c| c.id.clone()).collect(); + vec![WriteSite { + path: self.output.clone(), + expression_id: None, + resolved_type: VariableType::Bool, + contributing_expr_ids: contributing, + }] + } + + pub(super) fn analyze(&self, cx: &mut AnalysisContext) { + for condition in &self.conditions { + cx.analyze_standard(&condition.expression, Some(condition.id.clone())); + } + if !self.output.is_empty() { + cx.record_write( + self.output.clone(), + VariableType::Bool, + None, + Some(CursorTarget::AssertionOutput), + ); + } + } + + pub(super) fn write_target(&self, path: &str) -> Option { + (!self.output.is_empty() && self.output.as_ref() == path) + .then_some(CursorTarget::AssertionOutput) + } + + pub(super) fn read_plan(&self, flatten: &mut ReadFlattenFn) -> BlockReadPlan { + let mut unconditional = Vec::new(); + for condition in &self.conditions { + unconditional.extend(flatten(&condition.expression, ExpressionKind::Standard)); + } + BlockReadPlan { + unconditional: BlockReadPlan::dedup(unconditional), + conditional: ConditionalReads::None, + } + } + + pub(super) fn execute(&self, cx: &ExecutionContext) -> Result { + let mut isolate = cx.isolate.borrow_mut(); + let mut traces: Vec = Vec::new(); + let result = self.evaluate_conditions(&mut isolate, cx, &mut traces)?; + + if !self.output.is_empty() { + cx.write(&self.output, Variable::Bool(result)); + } + + Ok(BlockTrace::Assertion { + result, + conditions: traces, + }) + } + + pub(super) fn resolve_cursor( + &self, + cursor: &Cursor, + scope: VariableType, + ) -> Option<(Arc, ExpressionKind, VariableType)> { + match &cursor.target { + CursorTarget::AssertionOutput => { + if self.output.is_empty() { + return None; + } + Some((self.output.clone(), ExpressionKind::Standard, scope)) + } + CursorTarget::Expression { id } => { + let cond = self.conditions.iter().find(|c| c.id == *id)?; + Some((cond.expression.clone(), ExpressionKind::Unary, scope)) + } + _ => None, + } + } + + fn evaluate_conditions( + &self, + isolate: &mut Isolate, + cx: &ExecutionContext, + traces: &mut Vec, + ) -> Result { + if self.conditions.is_empty() { + return Ok(false); + } + + let mut stack: Vec = Vec::new(); + let mut acc = false; + let mut started = false; + let mut depth = 0u32; + let mut combine_next = ConditionOperator::And; + + for condition in &self.conditions { + while condition.depth > depth { + stack.push(Frame { + acc, + started, + combine: combine_next, + }); + acc = false; + started = false; + combine_next = ConditionOperator::And; + depth += 1; + } + while condition.depth < depth { + if let Some(frame) = stack.pop() { + acc = frame.close(acc); + started = true; + } + depth -= 1; + } + + let cond_result = isolate + .run_standard(&condition.expression) + .map_err(|e| cx.expression_error(&condition.expression, e))? + .as_bool() + .unwrap_or(false); + + traces.push(ConditionTrace { + id: condition.id.clone(), + result: cond_result, + }); + + acc = if started { + combine_next.apply(acc, cond_result) + } else { + cond_result + }; + started = true; + combine_next = condition.operator; + } + + while let Some(frame) = stack.pop() { + acc = frame.close(acc); + } + + Ok(acc) + } +} + +struct Frame { + acc: bool, + started: bool, + combine: ConditionOperator, +} + +impl Frame { + fn close(&self, group: bool) -> bool { + if self.started { + self.combine.apply(self.acc, group) + } else { + group + } + } +} diff --git a/core/engine/src/policy/blocks/context.rs b/core/engine/src/policy/blocks/context.rs new file mode 100644 index 00000000..8f5e5533 --- /dev/null +++ b/core/engine/src/policy/blocks/context.rs @@ -0,0 +1,485 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use zen_expression::intellisense::{ArmTest, ExpressionAnalysis, IntelliSense, ReadDependency}; +use zen_expression::variable::{Variable, VariableType}; +use zen_expression::{Isolate, IsolateError}; + +use super::property_read::ReadFlattener; +use super::type_check::TypeCheck; +use crate::policy::db::AnalysisPass; +use crate::policy::ir::PropertyPath; +use crate::policy::queries::scope::VariableTypeScope; +use crate::policy::types::{ + CursorTarget, Diagnostic, DiagnosticCode, DiagnosticLocation, ExpressionKind, Severity, + WriteTrace, +}; + +pub type SharedIntelliSense = Rc>; + +#[derive(Debug, Clone)] +pub struct ExpressionLocation { + pub block_id: Arc, + pub expression_id: Arc, + pub kind: ExpressionKind, + pub source: Arc, +} + +#[derive(Debug, Clone)] +pub struct WriteTarget { + pub path: PropertyPath, + pub resolved_type: VariableType, + pub instance_source: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstanceSource { + pub path: Arc, + pub element: bool, +} + +#[derive(Debug, Clone)] +pub struct PropertyRead { + pub path: PropertyPath, + pub expression_id: Option>, + pub span: Option<(u32, u32)>, + pub via_alias: bool, + pub unresolved: bool, +} + +#[derive(Debug)] +pub struct ExecutionError { + pub block_id: Arc, + pub policy_path: Arc, + pub expression: Arc, + pub source: IsolateError, +} + +pub struct AnalysisContext { + scope: VariableType, + policy_path: Arc, + block_id: Arc, + reads: Vec, + writes: Vec, + diagnostics: Vec, + pass: AnalysisPass, + intellisense: SharedIntelliSense, +} + +impl AnalysisContext { + pub fn new( + scope: VariableType, + policy_path: Arc, + block_id: Arc, + intellisense: SharedIntelliSense, + pass: AnalysisPass, + ) -> Self { + Self { + scope, + policy_path, + block_id, + reads: Vec::new(), + writes: Vec::new(), + diagnostics: Vec::new(), + pass, + intellisense, + } + } + + pub(super) fn scope(&self) -> &VariableType { + &self.scope + } + + pub fn analyze_standard( + &mut self, + source: &Arc, + expression_id: Option>, + ) -> Rc { + self.analyze_with(source, ExpressionKind::Standard, expression_id) + } + + fn analyze_with( + &mut self, + source: &Arc, + kind: ExpressionKind, + expression_id: Option>, + ) -> Rc { + let analysis = match self.pass { + AnalysisPass::Shallow => { + IntelliSenseSource::reads_only(&mut self.intellisense.borrow_mut(), source, kind) + } + AnalysisPass::Enriched => IntelliSenseSource::analyze( + &mut self.intellisense.borrow_mut(), + source, + kind, + &self.scope, + ), + }; + self.record_reads(&analysis, &expression_id); + self.absorb_diagnostics(&analysis, &expression_id); + analysis + } + + pub(super) fn arm_test(&mut self, source: &Arc) -> ArmTest { + IntelliSenseSource::arm_test(&mut self.intellisense.borrow_mut(), source) + } + + pub(super) fn cell_test(&mut self, source: &Arc) -> ArmTest { + IntelliSenseSource::cell_test(&mut self.intellisense.borrow_mut(), source) + } + + pub(super) fn is_enriched(&self) -> bool { + matches!(self.pass, AnalysisPass::Enriched) + } + + pub fn analyze_unary_in_scope( + &mut self, + source: &Arc, + scope: &VariableType, + expression_id: Option>, + ) -> Rc { + let analysis = match self.pass { + AnalysisPass::Shallow => IntelliSenseSource::reads_only( + &mut self.intellisense.borrow_mut(), + source, + ExpressionKind::Unary, + ), + AnalysisPass::Enriched => IntelliSenseSource::analyze( + &mut self.intellisense.borrow_mut(), + source, + ExpressionKind::Unary, + scope, + ), + }; + self.record_reads(&analysis, &expression_id); + self.absorb_diagnostics(&analysis, &expression_id); + analysis + } + + pub fn record_write( + &mut self, + path: PropertyPath, + resolved_type: VariableType, + expression_id: Option>, + target: Option, + ) { + self.record_write_sourced(path, resolved_type, expression_id, target, None); + } + + pub fn record_write_sourced( + &mut self, + path: PropertyPath, + resolved_type: VariableType, + expression_id: Option>, + target: Option, + instance_source: Option, + ) { + TypeCheck::check_no_any(self, &resolved_type, expression_id, target, &path); + if matches!(self.pass, AnalysisPass::Enriched) { + self.scope.insert_at_path(&path, &resolved_type, true); + } + self.writes.push(WriteTarget { + path, + resolved_type, + instance_source, + }); + } + + pub(super) fn flow_source(&mut self, source: &Arc) -> Option { + if matches!(self.pass, AnalysisPass::Enriched) { + return None; + } + let flow = self.intellisense.borrow_mut().flow_source(source)?; + let path: Vec<&str> = flow.path.iter().map(|s| s.as_ref()).collect(); + Some(InstanceSource { + path: Arc::from(path.join(".")), + element: flow.element, + }) + } + + pub fn error( + &mut self, + code: DiagnosticCode, + expression_id: Option>, + span: Option<(u32, u32)>, + message: impl Into, + ) { + self.error_with_target(code, expression_id, span, None, message); + } + + pub fn error_with_target( + &mut self, + code: DiagnosticCode, + expression_id: Option>, + span: Option<(u32, u32)>, + target: Option, + message: impl Into, + ) { + let mut location = self.location_with(expression_id, span); + if let Some(t) = target { + location = location.with_target(t); + } + self.diagnostics + .push(Diagnostic::error(code, location, message)); + } + + fn location_with( + &self, + expression_id: Option>, + span: Option<(u32, u32)>, + ) -> DiagnosticLocation { + DiagnosticLocation { + policy_path: self.policy_path.clone(), + block_id: Some(self.block_id.clone()), + expression_id, + span, + target: None, + } + } + + pub fn merge_types( + &mut self, + types: &[VariableType], + label: &str, + expression_id: Option>, + target: Option, + ) -> VariableType { + let Some(result) = types + .iter() + .map(|t| t.shallow_clone()) + .reduce(|acc, t| acc.merge(&t)) + else { + return VariableType::Null; + }; + + if matches!(result, VariableType::Any) + && types.len() > 1 + && !types.iter().any(|t| matches!(t, VariableType::Any)) + { + let span = target.as_ref().map(|_| Self::write_label_span(label)); + self.error_with_target( + DiagnosticCode::TypeMismatch, + expression_id, + span, + target, + format!( + "'{}' has incompatible types: {}", + label, + types + .iter() + .map(|t| format!("`{}`", t)) + .collect::>() + .join(", ") + ), + ); + } + result + } + + fn write_label_span(label: &str) -> (u32, u32) { + (0, label.chars().count() as u32) + } + + pub fn finish(mut self) -> AnalysisSummary { + self.reads.sort_by(|a, b| { + a.path + .cmp(&b.path) + .then_with(|| a.expression_id.cmp(&b.expression_id)) + }); + self.reads + .dedup_by(|a, b| a.path == b.path && a.expression_id == b.expression_id); + self.validate_reads(); + AnalysisSummary { + reads: self.reads, + writes: self.writes, + diagnostics: self.diagnostics, + } + } + + fn validate_reads(&mut self) { + let mut problems: Vec<(Option>, Option<(u32, u32)>, String)> = Vec::new(); + for read in &self.reads { + if matches!(read.path.as_ref(), "$" | "$root") || read.via_alias || read.unresolved { + continue; + } + let resolved = self.scope.resolve_at(&read.path); + let unknown = match resolved { + VariableType::Any => true, + VariableType::Null => !Self::path_declared(&self.scope, &read.path), + _ => false, + }; + if !unknown { + continue; + } + if self.read_already_diagnosed(read) { + continue; + } + problems.push(( + read.expression_id.clone(), + read.span, + format!("Unknown property '{}'", read.path), + )); + } + for (expr_id, span, msg) in problems { + self.error(DiagnosticCode::UndefinedVariable, expr_id, span, msg); + } + } + + fn path_declared(scope: &VariableType, path: &str) -> bool { + match path.rsplit_once('.') { + Some((parent, key)) => Self::scope_has_key(&scope.resolve_at(parent), key), + None => Self::scope_has_key(scope, path), + } + } + + fn scope_has_key(scope: &VariableType, key: &str) -> bool { + match scope { + VariableType::Object(obj) => obj.borrow().contains_key(key), + VariableType::Nullable(inner) => Self::scope_has_key(inner, key), + _ => false, + } + } + + fn read_already_diagnosed(&self, read: &PropertyRead) -> bool { + let Some(read_span) = read.span else { + return false; + }; + self.diagnostics.iter().any(|d| { + d.severity == Severity::Error + && d.location.expression_id == read.expression_id + && d.location + .span + .is_some_and(|s| s.0 < read_span.1 && read_span.0 < s.1) + }) + } + + fn record_reads(&mut self, analysis: &ExpressionAnalysis, expression_id: &Option>) { + ReadFlattener::extend_from_deps(&analysis.reads, expression_id, &mut self.reads); + } + + fn absorb_diagnostics( + &mut self, + analysis: &ExpressionAnalysis, + expression_id: &Option>, + ) { + for diag in &analysis.diagnostics { + let location = DiagnosticLocation { + policy_path: self.policy_path.clone(), + block_id: Some(self.block_id.clone()), + expression_id: expression_id.clone(), + span: Some(diag.span), + target: None, + }; + self.diagnostics + .push(Diagnostic::from_expression(diag, location)); + } + } +} + +#[derive(Clone)] +pub struct AnalysisSummary { + pub reads: Vec, + pub writes: Vec, + pub diagnostics: Vec, +} + +pub struct ExecutionContext<'a> { + pub store: &'a Variable, + pub policy_path: &'a Arc, + pub block_id: &'a Arc, + pub trace: bool, + pub extras: bool, + pub write_log: Option<&'a RefCell>>, + pub env_mirror: Option<&'a Variable>, + pub isolate: &'a RefCell, +} + +impl ExecutionContext<'_> { + pub fn expression_error(&self, expression: &Arc, source: IsolateError) -> ExecutionError { + ExecutionError { + block_id: self.block_id.clone(), + policy_path: self.policy_path.clone(), + expression: expression.clone(), + source, + } + } + + pub fn write(&self, path: &Arc, value: Variable) { + if let Some(log) = self.write_log { + log.borrow_mut().push(WriteTrace { + path: path.clone(), + value: value.deep_clone(), + }); + } + self.store.dot_insert(path, value); + self.mirror_top_level(path); + } + + fn mirror_top_level(&self, path: &str) { + let Some(env) = self.env_mirror else { + return; + }; + let (Some(store_fields), Some(env_fields)) = (self.store.as_object(), env.as_object()) + else { + return; + }; + let segment = &path[..path.find('.').unwrap_or(path.len())]; + let store_fields = store_fields.borrow(); + let Some((key, value)) = store_fields.get_key_value(segment) else { + return; + }; + env_fields + .borrow_mut() + .insert(key.clone(), value.shallow_clone()); + } +} + +pub(crate) struct IntelliSenseSource; + +impl IntelliSenseSource { + pub(crate) fn analyze( + is: &mut IntelliSense, + source: &Arc, + kind: ExpressionKind, + scope: &VariableType, + ) -> Rc { + match kind { + ExpressionKind::Standard => is.analyze(source, scope), + ExpressionKind::Unary => is.analyze_unary(source, scope), + } + } + + pub(crate) fn reads_only( + is: &mut IntelliSense, + source: &Arc, + kind: ExpressionKind, + ) -> Rc { + let (reads, return_type) = match kind { + ExpressionKind::Standard => (is.reads(source), VariableType::Any), + ExpressionKind::Unary => (is.reads_unary(source), VariableType::Bool), + }; + Rc::new(ExpressionAnalysis { + return_type, + reads, + references: Vec::new(), + diagnostics: Vec::new(), + }) + } + + pub(crate) fn arm_test(is: &mut IntelliSense, source: &Arc) -> ArmTest { + is.arm_test(source) + } + + pub(crate) fn cell_test(is: &mut IntelliSense, source: &Arc) -> ArmTest { + is.cell_test(source) + } + + pub(crate) fn field_reads( + is: &mut IntelliSense, + source: &Arc, + field_path: &[&str], + ) -> Option> { + is.field_reads(source, field_path) + } +} diff --git a/core/engine/src/policy/blocks/decision_table.rs b/core/engine/src/policy/blocks/decision_table.rs new file mode 100644 index 00000000..1d6ca0ea --- /dev/null +++ b/core/engine/src/policy/blocks/decision_table.rs @@ -0,0 +1,995 @@ +use std::sync::{Arc, OnceLock}; + +use ahash::{HashMap, HashSet}; +use fixedbitset::FixedBitSet; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use zen_expression::intellisense::{ArmTest, IntelliSense, NumberCover}; +use zen_expression::variable::{Variable, VariableType}; +use zen_expression::Isolate; +use zen_types::decision::{ + DecisionTableHitPolicy, DecisionTableInputField, DecisionTableOutputField, +}; + +use base64::Engine as _; + +use crate::policy::queries::scope::VariableTypeScope; +use crate::policy::types::{ + BlockTrace, Cursor, CursorTarget, DecisionTableExtras, Diagnostic, DiagnosticCode, + ExpressionKind, +}; + +use crate::policy::ArcStrTrim; + +use super::context::{AnalysisContext, ExecutionContext, ExecutionError}; +use super::{ + Block, BlockKind, BlockReadPlan, CellReads, ConditionalReads, ExpressionLocation, ParseContext, + ReadFlattenFn, WriteSite, WriteTarget, +}; + +pub(crate) struct TableSelection { + pub(crate) matched_rows: Vec, + pub(crate) used_cells: Vec<(u32, Arc)>, + input_bits: Option>, +} + +const ROW_ID_KEY: &str = "_id"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DecisionTableDoc { + #[serde(default)] + pub hit_policy: DecisionTableHitPolicy, + #[serde(default)] + pub inputs: Vec, + #[serde(default)] + pub outputs: Vec, + #[serde(default)] + pub rules: Vec, Arc>>, +} + +impl DecisionTableDoc { + pub(crate) fn decode_wire(mut value: serde_json::Value) -> Result { + if let Some(obj) = value.as_object_mut() { + let already_split = obj.contains_key("inputs") || obj.contains_key("outputs"); + if already_split && obj.contains_key("columns") { + return Err( + "decision-table wire payload has both `columns` and `inputs`/`outputs`; expected exactly one form".into(), + ); + } + if !already_split { + if let Some(serde_json::Value::Array(columns)) = obj.remove("columns") { + let mut inputs = Vec::new(); + let mut outputs = Vec::new(); + for col in columns { + let serde_json::Value::Object(mut col_obj) = col else { + return Err("decision-table column must be an object".into()); + }; + let kind = col_obj + .remove("kind") + .and_then(|k| k.as_str().map(str::to_owned)); + match kind.as_deref() { + Some("input") => inputs.push(serde_json::Value::Object(col_obj)), + Some("output") => outputs.push(serde_json::Value::Object(col_obj)), + Some(other) => { + return Err(format!( + "decision-table column has unknown kind '{other}' (expected 'input' or 'output')" + )); + } + None => { + return Err( + "decision-table column is missing a 'kind' field".into() + ); + } + } + } + obj.insert("inputs".to_string(), serde_json::Value::Array(inputs)); + obj.insert("outputs".to_string(), serde_json::Value::Array(outputs)); + } + } + } + + serde_json::from_value(value).map_err(|e| format!("invalid decision table: {e}")) + } +} + +#[derive(Debug, Clone)] +pub struct DecisionTableIr { + pub inputs: Vec, + pub outputs: Vec, + pub rules: Vec, Arc>>, + index: OnceLock>, +} + +const MIN_INDEX_ROWS: usize = 8; + +#[derive(Debug, Clone)] +struct TableIndex { + columns: Vec>, +} + +#[derive(Debug, Clone)] +struct ColumnIndex { + strings: HashMap, FixedBitSet>, + numbers: HashMap, + bools: HashMap, + captured: FixedBitSet, + fallback: FixedBitSet, +} + +impl TableIndex { + fn build(table: &DecisionTableIr) -> Option { + let rows = table.rules.len(); + if rows < MIN_INDEX_ROWS { + return None; + } + let mut intellisense = IntelliSense::new(); + let columns: Vec> = table + .inputs + .iter() + .map(|col| ColumnIndex::build(table, col, rows, &mut intellisense)) + .collect(); + columns + .iter() + .any(Option::is_some) + .then_some(TableIndex { columns }) + } + + fn decides(&self, col_idx: usize, row_idx: usize) -> bool { + self.columns + .get(col_idx) + .and_then(Option::as_ref) + .is_some_and(|c| c.captured.contains(row_idx)) + } +} + +impl ColumnIndex { + fn build( + table: &DecisionTableIr, + col: &DecisionTableInputField, + rows: usize, + intellisense: &mut IntelliSense, + ) -> Option { + if col.field.as_deref().is_none_or(|f| f.is_empty()) { + return None; + } + let mut strings: HashMap, FixedBitSet> = HashMap::default(); + let mut numbers: HashMap = HashMap::default(); + let mut bools: HashMap = HashMap::default(); + let mut captured = FixedBitSet::with_capacity(rows); + let mut fallback = FixedBitSet::with_capacity(rows); + + for (row_idx, rule) in table.rules.iter().enumerate() { + let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else { + fallback.insert(row_idx); + continue; + }; + match intellisense.cell_test(cell) { + ArmTest::Enum { values, .. } => { + for value in values { + strings + .entry(Arc::from(value.as_ref())) + .or_insert_with(|| FixedBitSet::with_capacity(rows)) + .insert(row_idx); + } + captured.insert(row_idx); + } + ArmTest::Bool { values, .. } => { + for value in values { + bools + .entry(value) + .or_insert_with(|| FixedBitSet::with_capacity(rows)) + .insert(row_idx); + } + captured.insert(row_idx); + } + ArmTest::Number { cover, .. } => match cover.points() { + Some(points) => { + for point in points { + numbers + .entry(point.normalize()) + .or_insert_with(|| FixedBitSet::with_capacity(rows)) + .insert(row_idx); + } + captured.insert(row_idx); + } + None => { + fallback.insert(row_idx); + } + }, + ArmTest::Default | ArmTest::Unrecognized => { + fallback.insert(row_idx); + } + } + } + + (captured.count_ones(..) > 0).then_some(ColumnIndex { + strings, + numbers, + bools, + captured, + fallback, + }) + } + + fn rows_for(&self, value: &Variable) -> Option<&FixedBitSet> { + match value { + Variable::String(s) => self.strings.get(s.as_ref()), + Variable::Number(n) => self.numbers.get(&n.normalize()), + Variable::Bool(b) => self.bools.get(b), + _ => None, + } + } +} + +#[derive(Debug, Clone)] +pub struct OutputColumn { + pub id: Arc, + pub field: Arc, + pub raw_field: Arc, + pub collect: bool, +} + +impl DecisionTableIr { + pub(crate) fn parse( + id: &Arc, + doc: &DecisionTableDoc, + policy_path: &Arc, + diagnostics: &mut Vec, + ) -> Block { + let mut cx = ParseContext { + policy_path, + block_id: id, + diagnostics, + }; + if doc.rules.is_empty() { + cx.block_warning(DiagnosticCode::EmptyBlock, "decision table has no rules"); + } + + let mut inputs = doc.inputs.clone(); + for col in inputs.iter_mut() { + if let Some(f) = col.field.as_ref() { + col.field = Some(f.trimmed()); + } + col.name = col.name.trimmed(); + } + + let collect_all = doc.hit_policy == DecisionTableHitPolicy::Collect; + let outputs = doc + .outputs + .iter() + .map(|col| Self::parse_output_column(col, collect_all, &mut cx)) + .collect(); + + Block { + id: id.clone(), + kind: BlockKind::DecisionTable(Arc::new(DecisionTableIr { + inputs, + outputs, + rules: doc.rules.clone(), + index: OnceLock::new(), + })), + } + } + + fn parse_output_column( + col: &DecisionTableOutputField, + collect_all: bool, + cx: &mut ParseContext, + ) -> OutputColumn { + let raw_field = col.field.trimmed(); + let (path, collect) = match raw_field.strip_suffix("[]") { + Some(stripped) => (stripped.trim_end(), true), + None => (raw_field.as_ref(), collect_all), + }; + + let field: Arc = if raw_field.is_empty() { + cx.expression_warning( + &col.id, + DiagnosticCode::InvalidWritePath, + "output column is missing a field path", + ); + Arc::from("") + } else if path.is_empty() { + cx.expression_error( + &col.id, + DiagnosticCode::InvalidWritePath, + "output field '[]' is missing a path before the collect marker", + ); + Arc::from("") + } else if path.contains("[]") { + cx.expression_error( + &col.id, + DiagnosticCode::InvalidWritePath, + format!("invalid write path '{raw_field}': `[]` may only appear at the end of an output field"), + ); + Arc::from("") + } else if let Err(reason) = WriteTarget::validate_path(path) { + cx.expression_error( + &col.id, + DiagnosticCode::InvalidWritePath, + format!("invalid write path '{path}': {reason}"), + ); + Arc::from("") + } else { + Arc::from(path) + }; + + OutputColumn { + id: col.id.clone(), + field, + raw_field, + collect, + } + } + + pub(super) fn expressions(&self, block_id: &Arc) -> Vec { + let mut out = Vec::new(); + for rule in &self.rules { + for col in &self.inputs { + let Some(cell) = rule.get(&col.id) else { + continue; + }; + let kind = match col.field.as_deref() { + Some(f) if !f.is_empty() => ExpressionKind::Unary, + _ => ExpressionKind::Standard, + }; + if let Some(loc) = ExpressionLocation::try_new( + block_id.clone(), + col.id.clone(), + kind, + cell.clone(), + ) { + out.push(loc); + } + } + for col in &self.outputs { + let Some(cell) = rule.get(&col.id) else { + continue; + }; + if let Some(loc) = ExpressionLocation::try_new( + block_id.clone(), + col.id.clone(), + ExpressionKind::Standard, + cell.clone(), + ) { + out.push(loc); + } + } + } + out + } + + pub(super) fn write_sites(&self) -> Vec { + let shared_inputs: HashSet> = self.inputs.iter().map(|c| c.id.clone()).collect(); + self.outputs + .iter() + .filter(|c| !c.field.is_empty()) + .map(|c| { + let mut contributing = shared_inputs.clone(); + contributing.insert(c.id.clone()); + WriteSite { + path: c.field.clone(), + expression_id: Some(c.id.clone()), + resolved_type: VariableType::Any, + contributing_expr_ids: contributing, + } + }) + .collect() + } + + pub(super) fn analyze(&self, cx: &mut AnalysisContext) { + let mut input_cell_scopes: HashMap, VariableType> = HashMap::default(); + let mut input_field_types: HashMap, VariableType> = HashMap::default(); + for col in &self.inputs { + let Some(field) = col.field.as_ref().filter(|f| !f.is_empty()) else { + continue; + }; + let field_analysis = cx.analyze_standard(field, Some(col.id.clone())); + input_field_types.insert(col.id.clone(), field_analysis.return_type.clone()); + input_cell_scopes.insert( + col.id.clone(), + cx.scope().with_dollar(&field_analysis.return_type), + ); + } + + for rule in &self.rules { + for col in &self.inputs { + let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else { + continue; + }; + + if let Some(cell_scope) = input_cell_scopes.get(&col.id) { + cx.analyze_unary_in_scope(cell, cell_scope, Some(col.id.clone())); + continue; + } + + let analysis = cx.analyze_standard(cell, Some(col.id.clone())); + if !matches!(analysis.return_type, VariableType::Bool | VariableType::Any) { + cx.error( + DiagnosticCode::TypeMismatch, + Some(col.id.clone()), + None, + format!( + "input condition must return a boolean, got {:?}", + analysis.return_type + ), + ); + } + } + } + + for col in &self.outputs { + if col.field.is_empty() { + continue; + } + + let mut cell_types: Vec = Vec::new(); + for rule in &self.rules { + let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else { + continue; + }; + let analysis = cx.analyze_standard(cell, Some(col.id.clone())); + cell_types.push(analysis.return_type.clone()); + } + + if !col.collect && cx.is_enriched() && !self.column_covered(col, cx, &input_field_types) + { + cell_types.push(VariableType::Null); + } + + let target = Some(CursorTarget::DecisionTableHead { + col: col.id.clone(), + }); + let mut resolved = cx.merge_types( + &cell_types, + &col.field, + Some(col.id.clone()), + target.clone(), + ); + + if col.collect { + resolved = resolved.array(); + } + + cx.record_write(col.field.clone(), resolved, Some(col.id.clone()), target); + } + } + + fn column_covered( + &self, + col: &OutputColumn, + cx: &mut AnalysisContext, + input_field_types: &HashMap, VariableType>, + ) -> bool { + let value_rows: Vec<&HashMap, Arc>> = self + .rules + .iter() + .filter(|rule| rule.get(&col.id).is_some_and(|c| !c.is_empty())) + .collect(); + if value_rows.is_empty() { + return false; + } + + let row_is_catch_all = |rule: &HashMap, Arc>| { + self.inputs + .iter() + .all(|ic| rule.get(&ic.id).is_none_or(|c| c.is_empty())) + }; + if value_rows.iter().any(|rule| row_is_catch_all(rule)) { + return true; + } + + let mut groups: HashMap, Vec> = HashMap::default(); + for rule in &value_rows { + let mut constrained = self + .inputs + .iter() + .filter(|ic| rule.get(&ic.id).is_some_and(|c| !c.is_empty())); + let (Some(ic), None) = (constrained.next(), constrained.next()) else { + continue; + }; + if ic.field.as_deref().is_none_or(str::is_empty) { + continue; + } + let Some(cell) = rule.get(&ic.id) else { + continue; + }; + groups + .entry(ic.id.clone()) + .or_default() + .push(cx.cell_test(cell)); + } + + groups.iter().any(|(col_id, tests)| { + input_field_types + .get(col_id) + .is_some_and(|t| Self::cells_cover(tests, t)) + }) + } + + fn cells_cover(tests: &[ArmTest], resolved_type: &VariableType) -> bool { + let (resolved, nullable) = resolved_type.unwrap_nullable(); + if nullable { + return false; + } + match resolved { + VariableType::Enum(_, declared) => { + let mut collected: HashSet<&str> = HashSet::default(); + for test in tests { + let ArmTest::Enum { values, .. } = test else { + return false; + }; + collected.extend(values.iter().map(|v| v.as_ref())); + } + declared.iter().all(|d| collected.contains(d.as_ref())) + } + VariableType::Bool => { + let (mut seen_true, mut seen_false) = (false, false); + for test in tests { + let ArmTest::Bool { values, .. } = test else { + return false; + }; + for value in values { + seen_true |= *value; + seen_false |= !*value; + } + } + seen_true && seen_false + } + VariableType::Number => { + let mut cover: Option = None; + for test in tests { + let ArmTest::Number { cover: segment, .. } = test else { + return false; + }; + match &mut cover { + Some(acc) => acc.merged_with(segment), + None => cover = Some(segment.clone()), + } + } + cover.is_some_and(|c| c.is_total()) + } + _ => false, + } + } + + pub(super) fn write_target(&self, path: &str) -> Option { + self.outputs + .iter() + .find(|c| c.field.as_ref() == path) + .map(|c| CursorTarget::DecisionTableHead { col: c.id.clone() }) + } + + pub(super) fn read_plan(&self, flatten: &mut ReadFlattenFn) -> BlockReadPlan { + let mut unconditional = Vec::new(); + for col in &self.inputs { + if let Some(field) = col.field.as_ref().filter(|f| !f.is_empty()) { + unconditional.extend(flatten(field, ExpressionKind::Standard)); + } + } + + let mut cells = Vec::new(); + for (row_idx, rule) in self.rules.iter().enumerate() { + for col in &self.inputs { + let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else { + continue; + }; + let kind = match col.field.as_deref() { + Some(f) if !f.is_empty() => ExpressionKind::Unary, + _ => ExpressionKind::Standard, + }; + unconditional.extend(flatten(cell, kind)); + } + for col in &self.outputs { + if col.field.is_empty() { + continue; + } + let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else { + continue; + }; + cells.push(CellReads { + row_idx: row_idx as u32, + col_id: col.id.clone(), + cell_reads: BlockReadPlan::dedup(flatten(cell, ExpressionKind::Standard)), + }); + } + } + + BlockReadPlan { + unconditional: BlockReadPlan::dedup(unconditional), + conditional: ConditionalReads::DecisionTable(Arc::from(cells)), + } + } + + pub(crate) fn select(&self, cx: &ExecutionContext) -> Result { + let mut isolate = cx.isolate.borrow_mut(); + let mut col_refs: Vec> = vec![None; self.inputs.len()]; + + let active: Vec<&OutputColumn> = self + .outputs + .iter() + .filter(|c| !c.field.is_empty()) + .collect(); + let has_collect = active.iter().any(|c| c.collect); + let mut pending_scalars = active.iter().filter(|c| !c.collect).count(); + let mut taken: Vec = vec![false; active.len()]; + + let mut matched_rows: Vec = Vec::new(); + let mut used_cells: Vec<(u32, Arc)> = Vec::new(); + let cols = self.inputs.len(); + let bytes_per_row = cols.div_ceil(8); + let mut input_bits: Option> = cx + .extras + .then(|| vec![0u8; self.rules.len() * bytes_per_row]); + let candidates = match input_bits { + None => self.candidate_rows(&mut isolate, &mut col_refs, cx)?, + Some(_) => None, + }; + + for (row_idx, rule) in self.rules.iter().enumerate() { + let satisfied = !has_collect && pending_scalars == 0 && !matched_rows.is_empty(); + if satisfied && !cx.extras { + break; + } + + let row_matches = match input_bits.as_mut() { + Some(bits) => { + let per_col = self.evaluate_row_inputs_full( + rule, + &mut isolate, + &mut col_refs, + cx, + satisfied, + )?; + for (col_idx, &passed) in per_col.iter().enumerate() { + if passed { + let byte = row_idx * bytes_per_row + col_idx / 8; + bits[byte] |= 1 << (col_idx % 8); + } + } + per_col.iter().all(|p| *p) + } + None => match &candidates { + Some(rows) if !rows.contains(row_idx) => false, + Some(_) => self.evaluate_row_inputs_pruned( + row_idx, + rule, + &mut isolate, + &mut col_refs, + cx, + )?, + None => self.evaluate_row_inputs(rule, &mut isolate, &mut col_refs, cx)?, + }, + }; + if !row_matches || satisfied { + continue; + } + + matched_rows.push(row_idx as u32); + for (col_pos, col) in active.iter().enumerate() { + if rule.get(&col.id).filter(|c| !c.is_empty()).is_none() { + continue; + } + if col.collect { + used_cells.push((row_idx as u32, col.id.clone())); + } else if !taken[col_pos] { + taken[col_pos] = true; + used_cells.push((row_idx as u32, col.id.clone())); + pending_scalars -= 1; + } + } + } + + Ok(TableSelection { + matched_rows, + used_cells, + input_bits, + }) + } + + pub(crate) fn commit( + &self, + cx: &ExecutionContext, + selection: &TableSelection, + ) -> Result { + let mut isolate = cx.isolate.borrow_mut(); + + let used: HashSet<(u32, &str)> = selection + .used_cells + .iter() + .map(|(row, col)| (*row, col.as_ref())) + .collect(); + let mut collected: HashMap, Vec> = HashMap::default(); + let mut scalar_written: HashSet> = HashSet::default(); + for col in &self.outputs { + if col.collect && !col.field.is_empty() { + collected.entry(col.field.clone()).or_default(); + } + } + + let mut evaluations: Vec, Variable>> = Vec::new(); + for &row_idx in &selection.matched_rows { + let Some(rule) = self.rules.get(row_idx as usize) else { + continue; + }; + let mut row_outputs: HashMap, Variable> = HashMap::default(); + + for col in &self.outputs { + if !used.contains(&(row_idx, col.id.as_ref())) { + continue; + } + let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else { + continue; + }; + + let value = isolate + .run_standard(cell) + .map_err(|e| cx.expression_error(cell, e))?; + + if cx.trace { + row_outputs.insert(col.field.clone(), value.deep_clone()); + } + + if col.collect { + collected.entry(col.field.clone()).or_default().push(value); + } else { + cx.write(&col.field, value); + scalar_written.insert(col.field.clone()); + } + } + if cx.trace { + evaluations.push(row_outputs); + } + } + + for col in &self.outputs { + if !col.collect && !col.field.is_empty() && !scalar_written.contains(&col.field) { + cx.write(&col.field, Variable::Null); + scalar_written.insert(col.field.clone()); + } + } + for (field, values) in collected { + cx.write(&field, Variable::from_array(values)); + } + + let extras = selection + .input_bits + .as_ref() + .map(|bits| DecisionTableExtras { + input_pass: base64::engine::general_purpose::STANDARD.encode(bits), + }); + + Ok(BlockTrace::DecisionTable { + matched_rows: selection.matched_rows.clone(), + evaluations, + extras, + }) + } + + pub(super) fn execute(&self, cx: &ExecutionContext) -> Result { + let selection = self.select(cx)?; + self.commit(cx, &selection) + } + + pub(super) fn resolve_cursor( + &self, + cursor: &Cursor, + scope: VariableType, + ) -> Option<(Arc, ExpressionKind, VariableType)> { + let (col_id, row_id) = match &cursor.target { + CursorTarget::DecisionTableHead { col } => { + let column = self.column_by_id(col)?; + let head = match column { + ColumnRef::Input(c) => c.field.clone().unwrap_or_else(|| Arc::from("")), + ColumnRef::Output(c) => c.field.clone(), + }; + return Some((head, ExpressionKind::Standard, scope)); + } + CursorTarget::DecisionTableCell { row, col } => (col.clone(), row.clone()), + _ => return None, + }; + + let column = self.column_by_id(&col_id)?; + let rule = self + .rules + .iter() + .find(|r| r.get(ROW_ID_KEY).map(|s| s.as_ref()) == Some(row_id.as_ref()))?; + let source = rule.get(&col_id).filter(|c| !c.is_empty()).cloned()?; + + let (kind, narrowed_scope) = match column { + ColumnRef::Input(c) => match c.field.as_deref() { + Some(f) if !f.is_empty() => (ExpressionKind::Unary, scope.resolve_at(f)), + _ => (ExpressionKind::Standard, scope), + }, + ColumnRef::Output(_) => (ExpressionKind::Standard, scope), + }; + Some((source, kind, narrowed_scope)) + } + + pub(super) fn write_keys(&self) -> Vec<(Option>, Arc)> { + let mut keys: Vec<_> = self + .inputs + .iter() + .filter_map(|col| { + col.field + .as_ref() + .filter(|f| !f.is_empty()) + .map(|f| (Some(col.id.clone()), f.clone())) + }) + .collect(); + keys.extend( + self.outputs + .iter() + .filter(|c| !c.field.is_empty()) + .map(|c| (Some(c.id.clone()), c.raw_field.clone())), + ); + keys + } +} + +enum ColumnRef<'a> { + Input(&'a DecisionTableInputField), + Output(&'a OutputColumn), +} + +impl DecisionTableIr { + fn column_by_id(&self, id: &Arc) -> Option> { + if let Some(c) = self.inputs.iter().find(|c| c.id == *id) { + return Some(ColumnRef::Input(c)); + } + self.outputs + .iter() + .find(|c| c.id == *id) + .map(ColumnRef::Output) + } + + fn table_index(&self) -> Option<&TableIndex> { + self.index.get_or_init(|| TableIndex::build(self)).as_ref() + } + + fn candidate_rows( + &self, + isolate: &mut Isolate, + col_refs: &mut [Option], + cx: &ExecutionContext, + ) -> Result, ExecutionError> { + let Some(index) = self.table_index() else { + return Ok(None); + }; + let mut acc: Option = None; + for (col_idx, column) in index.columns.iter().enumerate() { + let Some(column) = column else { + continue; + }; + let Some(field) = self.inputs[col_idx] + .field + .as_ref() + .filter(|f| !f.is_empty()) + else { + continue; + }; + let value = match &col_refs[col_idx] { + Some(value) => value.shallow_clone(), + None => { + let value = isolate + .run_standard(field) + .map_err(|e| cx.expression_error(field, e))?; + col_refs[col_idx] = Some(value.shallow_clone()); + value + } + }; + if matches!(value, Variable::Dynamic(_)) { + return Ok(None); + } + let mut col_rows = column.fallback.clone(); + if let Some(hit) = column.rows_for(&value) { + col_rows.union_with(hit); + } + match &mut acc { + None => acc = Some(col_rows), + Some(set) => set.intersect_with(&col_rows), + } + } + Ok(acc) + } + + fn evaluate_row_inputs_pruned( + &self, + row_idx: usize, + rule: &HashMap, Arc>, + isolate: &mut Isolate, + col_refs: &mut [Option], + cx: &ExecutionContext, + ) -> Result { + let index = self.table_index(); + for (col_idx, col) in self.inputs.iter().enumerate() { + if index.is_some_and(|ix| ix.decides(col_idx, row_idx)) { + continue; + } + if !self.evaluate_cell(col_idx, col, rule, isolate, col_refs, cx)? { + return Ok(false); + } + } + Ok(true) + } + + fn evaluate_row_inputs( + &self, + rule: &HashMap, Arc>, + isolate: &mut Isolate, + col_refs: &mut [Option], + cx: &ExecutionContext, + ) -> Result { + for (col_idx, col) in self.inputs.iter().enumerate() { + if !self.evaluate_cell(col_idx, col, rule, isolate, col_refs, cx)? { + return Ok(false); + } + } + Ok(true) + } + + fn evaluate_row_inputs_full( + &self, + rule: &HashMap, Arc>, + isolate: &mut Isolate, + col_refs: &mut [Option], + cx: &ExecutionContext, + row_unreached: bool, + ) -> Result, ExecutionError> { + let mut row_failed = false; + self.inputs + .iter() + .enumerate() + .map(|(col_idx, col)| { + let passed = match self.evaluate_cell(col_idx, col, rule, isolate, col_refs, cx) { + Ok(passed) => passed, + Err(_) if row_unreached || row_failed => false, + Err(e) => return Err(e), + }; + row_failed |= !passed; + Ok(passed) + }) + .collect() + } + + fn evaluate_cell( + &self, + col_idx: usize, + col: &DecisionTableInputField, + rule: &HashMap, Arc>, + isolate: &mut Isolate, + col_refs: &mut [Option], + cx: &ExecutionContext, + ) -> Result { + let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else { + return Ok(true); + }; + match col.field.as_ref() { + Some(field) if !field.is_empty() => { + let value = match &col_refs[col_idx] { + Some(value) => value.shallow_clone(), + None => { + let value = isolate + .run_standard(field) + .map_err(|e| cx.expression_error(field, e))?; + col_refs[col_idx] = Some(value.shallow_clone()); + value + } + }; + isolate + .set_reference_value(value) + .map_err(|e| cx.expression_error(field, e))?; + isolate + .run_unary(cell) + .map_err(|e| cx.expression_error(cell, e)) + } + _ => { + let result = isolate + .run_standard(cell) + .map_err(|e| cx.expression_error(cell, e))?; + Ok(result.as_bool().unwrap_or(false)) + } + } + } +} diff --git a/core/engine/src/policy/blocks/expression.rs b/core/engine/src/policy/blocks/expression.rs new file mode 100644 index 00000000..766e0d7c --- /dev/null +++ b/core/engine/src/policy/blocks/expression.rs @@ -0,0 +1,176 @@ +use std::sync::Arc; + +use ahash::HashSet; +use serde::{Deserialize, Serialize}; +use zen_expression::variable::{Variable, VariableType}; + +use crate::policy::types::{ + BlockTrace, Cursor, CursorTarget, Diagnostic, DiagnosticCode, ExpressionKind, +}; + +use crate::policy::ArcStrTrim; + +use super::context::{AnalysisContext, ExecutionContext, ExecutionError}; +use super::{ + Block, BlockKind, BlockReadPlan, ConditionalReads, ExpressionLocation, ParseContext, + ReadFlattenFn, WriteSite, WriteTarget, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExpressionDoc { + #[serde(default)] + pub key: Arc, + #[serde(default)] + pub value: Arc, +} + +#[derive(Debug, Clone)] +pub struct ExpressionIr { + pub id: Arc, + pub key: Arc, + pub value: Arc, +} + +impl ExpressionIr { + pub(crate) fn parse( + id: &Arc, + doc: &ExpressionDoc, + policy_path: &Arc, + diagnostics: &mut Vec, + ) -> Block { + let mut cx = ParseContext { + policy_path, + block_id: id, + diagnostics, + }; + let mut key = doc.key.trimmed(); + let value = doc.value.trimmed(); + + if key.is_empty() { + cx.block_warning( + DiagnosticCode::InvalidWritePath, + "expression has no target property path", + ); + } else if let Err(reason) = WriteTarget::validate_path(&key) { + cx.target_error( + id, + CursorTarget::ExpressionKey, + Some((0, key.chars().count() as u32)), + DiagnosticCode::InvalidWritePath, + format!("invalid write path '{key}': {reason}"), + ); + key = Arc::from(""); + } + if value.is_empty() { + cx.block_warning(DiagnosticCode::EmptyBlock, "expression has no value"); + } + + Block { + id: id.clone(), + kind: BlockKind::Expression(Arc::new(ExpressionIr { + id: id.clone(), + key, + value, + })), + } + } + + pub(super) fn expressions(&self, block_id: &Arc) -> Vec { + ExpressionLocation::try_new( + block_id.clone(), + block_id.clone(), + ExpressionKind::Standard, + self.value.clone(), + ) + .into_iter() + .collect() + } + + pub(super) fn write_sites(&self) -> Vec { + if self.key.is_empty() || self.value.is_empty() { + return Vec::new(); + } + let mut contributing = HashSet::default(); + contributing.insert(self.id.clone()); + vec![WriteSite { + path: self.key.clone(), + expression_id: Some(self.id.clone()), + resolved_type: VariableType::Any, + contributing_expr_ids: contributing, + }] + } + + pub(super) fn write_value_expressions(&self, key: &str) -> Vec> { + if self.key.as_ref() == key && !self.value.is_empty() { + vec![self.value.clone()] + } else { + Vec::new() + } + } + + pub(super) fn read_plan(&self, flatten: &mut ReadFlattenFn) -> BlockReadPlan { + BlockReadPlan { + unconditional: BlockReadPlan::dedup(flatten(&self.value, ExpressionKind::Standard)), + conditional: ConditionalReads::None, + } + } + + pub(super) fn write_target(&self, path: &str) -> Option { + (!self.key.is_empty() && self.key.as_ref() == path).then_some(CursorTarget::ExpressionKey) + } + + pub(super) fn analyze(&self, cx: &mut AnalysisContext) { + if self.value.is_empty() { + return; + } + let analysis = cx.analyze_standard(&self.value, Some(self.id.clone())); + if self.key.is_empty() { + return; + } + let instance_source = cx.flow_source(&self.value); + cx.record_write_sourced( + self.key.clone(), + analysis.return_type.clone(), + None, + Some(CursorTarget::ExpressionKey), + instance_source, + ); + } + + pub(super) fn execute(&self, cx: &ExecutionContext) -> Result { + if self.key.is_empty() || self.value.is_empty() { + return Ok(BlockTrace::Expression { + property: self.key.clone(), + value: Variable::Null, + }); + } + + let mut isolate = cx.isolate.borrow_mut(); + let result = isolate + .run_standard(&self.value) + .map_err(|e| cx.expression_error(&self.value, e))?; + let traced = cx.trace.then(|| result.deep_clone()); + cx.write(&self.key, result); + + Ok(BlockTrace::Expression { + property: self.key.clone(), + value: traced.unwrap_or(Variable::Null), + }) + } + + pub(super) fn resolve_cursor( + &self, + cursor: &Cursor, + scope: VariableType, + ) -> Option<(Arc, ExpressionKind, VariableType)> { + match &cursor.target { + CursorTarget::ExpressionKey => { + (!self.key.is_empty()).then(|| (self.key.clone(), ExpressionKind::Standard, scope)) + } + CursorTarget::Expression { .. } => (!self.value.is_empty()) + .then(|| (self.value.clone(), ExpressionKind::Standard, scope)), + _ => None, + } + } +} diff --git a/core/engine/src/policy/blocks/match_block.rs b/core/engine/src/policy/blocks/match_block.rs new file mode 100644 index 00000000..9748b689 --- /dev/null +++ b/core/engine/src/policy/blocks/match_block.rs @@ -0,0 +1,438 @@ +use std::sync::Arc; + +use ahash::HashSet; +use serde::{Deserialize, Serialize}; +use zen_expression::intellisense::{ArmTest, NumberCover}; +use zen_expression::variable::{Variable, VariableType}; + +use crate::policy::queries::scope::VariableTypeScope; + +use crate::policy::types::{ + BlockTrace, ConditionTrace, Cursor, CursorTarget, Diagnostic, DiagnosticCode, ExpressionKind, +}; + +use crate::policy::ArcStrTrim; + +use super::context::{AnalysisContext, ExecutionContext, ExecutionError, InstanceSource}; +use super::{ + ArmReads, Block, BlockKind, BlockReadPlan, ConditionalReads, ExpressionLocation, ParseContext, + ReadFlattenFn, WriteSite, WriteTarget, +}; + +pub(crate) struct MatchSelection { + pub(crate) matched_arm: Option>, + arms: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MatchDoc { + #[serde(default)] + pub key: Arc, + #[serde(default)] + pub arms: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MatchArmDoc { + pub id: Arc, + #[serde(default)] + pub condition: Arc, + #[serde(default)] + pub value: Arc, +} + +#[derive(Debug, Clone)] +pub struct MatchIr { + pub key: Arc, + pub arms: Vec, +} + +#[derive(Debug, Clone)] +pub struct MatchArm { + pub id: Arc, + pub condition: Arc, + pub value: Arc, +} + +impl MatchIr { + pub(crate) fn parse( + id: &Arc, + doc: &MatchDoc, + policy_path: &Arc, + diagnostics: &mut Vec, + ) -> Block { + let mut cx = ParseContext { + policy_path, + block_id: id, + diagnostics, + }; + let mut key = doc.key.trimmed(); + + if key.is_empty() { + cx.block_warning( + DiagnosticCode::InvalidWritePath, + "match has no target property path", + ); + } else if let Err(reason) = WriteTarget::validate_path(&key) { + cx.target_error( + id, + CursorTarget::MatchTarget, + Some((0, key.chars().count() as u32)), + DiagnosticCode::InvalidWritePath, + format!("invalid write path '{key}': {reason}"), + ); + key = Arc::from(""); + } + + if doc.arms.is_empty() { + cx.block_warning(DiagnosticCode::EmptyBlock, "match has no arms"); + } + + let mut arms = Vec::with_capacity(doc.arms.len()); + for arm in &doc.arms { + let value = arm.value.trimmed(); + if value.is_empty() { + cx.expression_warning( + &arm.id, + DiagnosticCode::EmptyBlock, + "match arm has no value; it writes null when selected", + ); + } + arms.push(MatchArm { + id: arm.id.clone(), + condition: arm.condition.trimmed(), + value, + }); + } + + Block { + id: id.clone(), + kind: BlockKind::Match(Arc::new(MatchIr { key, arms })), + } + } + + pub(super) fn expressions(&self, block_id: &Arc) -> Vec { + let mut out = Vec::new(); + for arm in &self.arms { + if let Some(loc) = ExpressionLocation::try_new( + block_id.clone(), + arm.id.clone(), + ExpressionKind::Standard, + arm.condition.clone(), + ) { + out.push(loc); + } + if let Some(loc) = ExpressionLocation::try_new( + block_id.clone(), + arm.id.clone(), + ExpressionKind::Standard, + arm.value.clone(), + ) { + out.push(loc); + } + } + out + } + + pub(super) fn write_sites(&self) -> Vec { + if self.key.is_empty() { + return Vec::new(); + } + let contributing: HashSet> = self.arms.iter().map(|a| a.id.clone()).collect(); + vec![WriteSite { + path: self.key.clone(), + expression_id: None, + resolved_type: VariableType::Any, + contributing_expr_ids: contributing, + }] + } + + pub(super) fn write_value_expressions(&self, key: &str) -> Vec> { + if self.key.as_ref() != key { + return Vec::new(); + } + self.arms + .iter() + .filter(|a| !a.value.is_empty()) + .map(|a| a.value.clone()) + .collect() + } + + pub(super) fn read_plan(&self, flatten: &mut ReadFlattenFn) -> BlockReadPlan { + let mut unconditional = Vec::new(); + let mut arms = Vec::new(); + for arm in &self.arms { + if !arm.condition.is_empty() { + unconditional.extend(flatten(&arm.condition, ExpressionKind::Standard)); + } + let value_reads = if arm.value.is_empty() { + Vec::new() + } else { + flatten(&arm.value, ExpressionKind::Standard) + }; + arms.push(ArmReads { + arm_id: arm.id.clone(), + value_reads: BlockReadPlan::dedup(value_reads), + }); + } + BlockReadPlan { + unconditional: BlockReadPlan::dedup(unconditional), + conditional: ConditionalReads::Match(Arc::from(arms)), + } + } + + pub(super) fn write_target(&self, path: &str) -> Option { + (!self.key.is_empty() && self.key.as_ref() == path).then_some(CursorTarget::MatchTarget) + } + + pub(super) fn analyze(&self, cx: &mut AnalysisContext) { + let mut value_types: Vec = Vec::new(); + let mut has_default = false; + let mut tests: Vec = Vec::new(); + + for arm in &self.arms { + if arm.condition.is_empty() { + has_default = true; + } else { + let analysis = cx.analyze_standard(&arm.condition, Some(arm.id.clone())); + if !matches!(analysis.return_type, VariableType::Bool | VariableType::Any) { + cx.error( + DiagnosticCode::TypeMismatch, + Some(arm.id.clone()), + None, + format!( + "match arm condition must return a boolean, got `{}`", + analysis.return_type + ), + ); + } + tests.push(cx.arm_test(&arm.condition)); + } + if arm.value.is_empty() { + value_types.push(VariableType::Null); + } else { + let analysis = cx.analyze_standard(&arm.value, Some(arm.id.clone())); + value_types.push(analysis.return_type.clone()); + } + } + + if self.key.is_empty() { + return; + } + + if !has_default && !Self::discriminant_covered(&tests, cx.scope()) { + value_types.push(VariableType::Null); + if cx.is_enriched() && !self.arms.is_empty() { + cx.error_with_target( + DiagnosticCode::MissingDefaultBranch, + None, + None, + Some(CursorTarget::MatchTarget), + "match is not exhaustive: add a default `_` arm, or make the arms a provable discriminated union (every enum value, both booleans, or a gap-free numeric range)", + ); + } + } + + let resolved = cx.merge_types( + &value_types, + &self.key, + None, + Some(CursorTarget::MatchTarget), + ); + let instance_source = self.arm_instance_source(cx); + cx.record_write_sourced( + self.key.clone(), + resolved, + None, + Some(CursorTarget::MatchTarget), + instance_source, + ); + } + + fn arm_instance_source(&self, cx: &mut AnalysisContext) -> Option { + let mut shared: Option = None; + for arm in &self.arms { + if arm.value.is_empty() { + continue; + } + let source = cx.flow_source(&arm.value)?; + match &shared { + None => shared = Some(source), + Some(existing) if *existing == source => {} + Some(_) => return None, + } + } + shared + } + + fn discriminant_covered(tests: &[ArmTest], scope: &VariableType) -> bool { + if tests.is_empty() { + return false; + } + let mut shared_path: Option<&Vec>> = None; + for test in tests { + let path = match test { + ArmTest::Enum { path, .. } + | ArmTest::Bool { path, .. } + | ArmTest::Number { path, .. } => path, + _ => return false, + }; + match shared_path { + None => shared_path = Some(path), + Some(p) if p == path => {} + Some(_) => return false, + } + } + let Some(path) = shared_path else { + return false; + }; + let dotted = path + .iter() + .map(|s| s.as_ref()) + .collect::>() + .join("."); + let resolved_type = scope.resolve_at(&dotted); + let (resolved, nullable) = resolved_type.unwrap_nullable(); + if nullable { + return false; + } + match resolved { + VariableType::Enum(_, declared) => { + let mut collected: HashSet<&str> = HashSet::default(); + for test in tests { + let ArmTest::Enum { values, .. } = test else { + return false; + }; + collected.extend(values.iter().map(|v| v.as_ref())); + } + declared.iter().all(|d| collected.contains(d.as_ref())) + } + VariableType::Bool => { + let (mut seen_true, mut seen_false) = (false, false); + for test in tests { + let ArmTest::Bool { values, .. } = test else { + return false; + }; + for value in values { + if *value { + seen_true = true; + } else { + seen_false = true; + } + } + } + seen_true && seen_false + } + VariableType::Number => { + let mut cover: Option = None; + for test in tests { + let ArmTest::Number { cover: segment, .. } = test else { + return false; + }; + match &mut cover { + Some(acc) => acc.merged_with(segment), + None => cover = Some(segment.clone()), + } + } + cover.is_some_and(|c| c.is_total()) + } + _ => false, + } + } + + pub(crate) fn select(&self, cx: &ExecutionContext) -> Result { + let mut isolate = cx.isolate.borrow_mut(); + + let mut arms: Vec = Vec::new(); + let mut matched_arm: Option> = None; + + for arm in &self.arms { + if matched_arm.is_some() && !cx.extras { + break; + } + let matches = if arm.condition.is_empty() { + true + } else { + match isolate.run_standard(&arm.condition) { + Ok(value) => value.as_bool().unwrap_or(false), + Err(_) if cx.extras && matched_arm.is_some() => false, + Err(e) => return Err(cx.expression_error(&arm.condition, e)), + } + }; + arms.push(ConditionTrace { + id: arm.id.clone(), + result: matches, + }); + if matches && matched_arm.is_none() { + matched_arm = Some(arm.id.clone()); + } + } + + Ok(MatchSelection { matched_arm, arms }) + } + + pub(crate) fn commit( + &self, + cx: &ExecutionContext, + selection: &MatchSelection, + ) -> Result { + let matched = selection + .matched_arm + .as_ref() + .and_then(|id| self.arms.iter().find(|a| &a.id == id)); + let value = match matched { + Some(arm) if !arm.value.is_empty() => { + let mut isolate = cx.isolate.borrow_mut(); + isolate + .run_standard(&arm.value) + .map_err(|e| cx.expression_error(&arm.value, e))? + } + _ => Variable::Null, + }; + + let traced = cx.trace.then(|| value.deep_clone()); + if !self.key.is_empty() { + cx.write(&self.key, value); + } + + Ok(BlockTrace::Match { + matched_arm: selection.matched_arm.clone(), + value: traced.unwrap_or(Variable::Null), + arms: if cx.trace { + selection.arms.clone() + } else { + Vec::new() + }, + }) + } + + pub(super) fn execute(&self, cx: &ExecutionContext) -> Result { + let selection = self.select(cx)?; + self.commit(cx, &selection) + } + + pub(super) fn resolve_cursor( + &self, + cursor: &Cursor, + scope: VariableType, + ) -> Option<(Arc, ExpressionKind, VariableType)> { + match &cursor.target { + CursorTarget::MatchTarget => { + (!self.key.is_empty()).then(|| (self.key.clone(), ExpressionKind::Standard, scope)) + } + CursorTarget::Expression { id } => { + let arm = self.arms.iter().find(|a| a.id == *id)?; + (!arm.condition.is_empty()) + .then(|| (arm.condition.clone(), ExpressionKind::Standard, scope)) + } + CursorTarget::MatchValue { id } => { + let arm = self.arms.iter().find(|a| a.id == *id)?; + (!arm.value.is_empty()) + .then(|| (arm.value.clone(), ExpressionKind::Standard, scope)) + } + _ => None, + } + } +} diff --git a/core/engine/src/policy/blocks/mod.rs b/core/engine/src/policy/blocks/mod.rs new file mode 100644 index 00000000..7209a8f3 --- /dev/null +++ b/core/engine/src/policy/blocks/mod.rs @@ -0,0 +1,342 @@ +mod assertion; +mod context; +mod decision_table; +mod expression; +mod match_block; +mod property_read; +mod type_check; + +use std::sync::Arc; + +use ahash::{HashMap, HashMapExt, HashSet}; +use zen_expression::variable::VariableType; + +use crate::policy::types::{ + BlockTrace, Cursor, CursorTarget, Diagnostic, DiagnosticCode, DiagnosticLocation, + ExpressionKind, Span, +}; + +#[derive(Debug, Clone)] +pub struct WriteSite { + pub path: Arc, + pub expression_id: Option>, + pub resolved_type: VariableType, + pub contributing_expr_ids: HashSet>, +} + +pub use assertion::{AssertionDoc, AssertionIr}; +pub(crate) use context::IntelliSenseSource; +pub use context::{ + AnalysisContext, AnalysisSummary, ExecutionContext, ExecutionError, ExpressionLocation, + InstanceSource, PropertyRead, SharedIntelliSense, WriteTarget, +}; +pub(crate) use decision_table::TableSelection; +pub use decision_table::{DecisionTableDoc, DecisionTableIr}; +pub use expression::{ExpressionDoc, ExpressionIr}; +pub(crate) use match_block::MatchSelection; +pub use match_block::{MatchDoc, MatchIr}; +pub(crate) use property_read::ReadFlattener; + +impl ExpressionLocation { + pub(crate) fn try_new( + block_id: Arc, + expression_id: Arc, + kind: ExpressionKind, + source: Arc, + ) -> Option { + (!source.is_empty()).then_some(ExpressionLocation { + block_id, + expression_id, + kind, + source, + }) + } +} + +impl WriteTarget { + pub(crate) fn validate_path(path: &str) -> Result<(), &'static str> { + if path.is_empty() { + return Err("write path is empty"); + } + for segment in path.split('.') { + if segment.is_empty() { + return Err("write path has an empty segment"); + } + if segment.chars().any(char::is_whitespace) { + return Err("write path segment contains whitespace"); + } + } + Ok(()) + } +} + +pub(crate) struct ParseContext<'a> { + pub(crate) policy_path: &'a Arc, + pub(crate) block_id: &'a Arc, + pub(crate) diagnostics: &'a mut Vec, +} + +impl ParseContext<'_> { + pub(crate) fn block_error(&mut self, code: DiagnosticCode, message: impl Into) { + self.diagnostics.push(Diagnostic::error( + code, + DiagnosticLocation::block(self.policy_path.clone(), self.block_id.clone()), + message, + )); + } + + pub(crate) fn block_warning(&mut self, code: DiagnosticCode, message: impl Into) { + self.diagnostics.push(Diagnostic::warning( + code, + DiagnosticLocation::block(self.policy_path.clone(), self.block_id.clone()), + message, + )); + } + + pub(crate) fn expression_error( + &mut self, + expression_id: &Arc, + code: DiagnosticCode, + message: impl Into, + ) { + self.diagnostics.push(Diagnostic::error( + code, + DiagnosticLocation::expression( + self.policy_path.clone(), + self.block_id.clone(), + expression_id.clone(), + None, + ), + message, + )); + } + + pub(crate) fn expression_warning( + &mut self, + expression_id: &Arc, + code: DiagnosticCode, + message: impl Into, + ) { + self.diagnostics.push(Diagnostic::warning( + code, + DiagnosticLocation::expression( + self.policy_path.clone(), + self.block_id.clone(), + expression_id.clone(), + None, + ), + message, + )); + } + + pub(crate) fn target_error( + &mut self, + expression_id: &Arc, + target: CursorTarget, + span: Option, + code: DiagnosticCode, + message: impl Into, + ) { + self.diagnostics.push(Diagnostic::error( + code, + DiagnosticLocation::expression( + self.policy_path.clone(), + self.block_id.clone(), + expression_id.clone(), + span, + ) + .with_target(target), + message, + )); + } +} + +pub(crate) struct BlockReadPlan { + pub(crate) unconditional: Arc<[Arc]>, + pub(crate) conditional: ConditionalReads, +} + +pub(crate) enum ConditionalReads { + None, + Match(Arc<[ArmReads]>), + DecisionTable(Arc<[CellReads]>), +} + +pub(crate) struct ArmReads { + pub(crate) arm_id: Arc, + pub(crate) value_reads: Arc<[Arc]>, +} + +pub(crate) struct CellReads { + pub(crate) row_idx: u32, + pub(crate) col_id: Arc, + pub(crate) cell_reads: Arc<[Arc]>, +} + +pub(crate) type ReadFlattenFn<'a> = dyn FnMut(&Arc, ExpressionKind) -> Vec> + 'a; + +impl BlockReadPlan { + pub(crate) fn dedup(mut paths: Vec>) -> Arc<[Arc]> { + paths.sort(); + paths.dedup(); + Arc::from(paths) + } + + pub(crate) fn match_arm_reads(&self, arm_id: &str) -> Option<&[Arc]> { + match &self.conditional { + ConditionalReads::Match(arms) => arms + .iter() + .find(|a| a.arm_id.as_ref() == arm_id) + .map(|a| a.value_reads.as_ref()), + _ => None, + } + } + + pub(crate) fn cell_reads(&self, row_idx: u32, col_id: &str) -> Vec> { + match &self.conditional { + ConditionalReads::DecisionTable(cells) => cells + .iter() + .filter(|c| c.row_idx == row_idx && c.col_id.as_ref() == col_id) + .flat_map(|c| c.cell_reads.iter().cloned()) + .collect(), + _ => Vec::new(), + } + } +} + +#[derive(Debug, Clone)] +pub struct Block { + pub id: Arc, + pub kind: BlockKind, +} + +#[derive(Debug, Clone)] +pub enum BlockKind { + Assertion(Arc), + DecisionTable(Arc), + Expression(Arc), + Match(Arc), +} + +impl Block { + pub fn execute(&self, cx: &ExecutionContext) -> Result { + self.kind.execute(cx) + } + + pub fn resolve_cursor( + &self, + cursor: &Cursor, + scope: VariableType, + ) -> Option<(Arc, ExpressionKind, VariableType)> { + self.kind.resolve_cursor(cursor, scope) + } +} + +impl BlockKind { + pub fn expressions(&self, block_id: &Arc) -> Vec { + match self { + BlockKind::Assertion(a) => a.expressions(block_id), + BlockKind::DecisionTable(d) => d.expressions(block_id), + BlockKind::Expression(e) => e.expressions(block_id), + BlockKind::Match(m) => m.expressions(block_id), + } + } + + pub fn write_sites(&self) -> Vec { + match self { + BlockKind::Assertion(a) => a.write_sites(), + BlockKind::DecisionTable(d) => d.write_sites(), + BlockKind::Expression(e) => e.write_sites(), + BlockKind::Match(m) => m.write_sites(), + } + } + + pub fn writes(&self) -> Vec { + self.write_sites() + .into_iter() + .map(|s| WriteTarget { + path: s.path, + resolved_type: s.resolved_type, + instance_source: None, + }) + .collect() + } + + pub fn write_target(&self, path: &str) -> Option { + match self { + BlockKind::Assertion(a) => a.write_target(path), + BlockKind::DecisionTable(d) => d.write_target(path), + BlockKind::Expression(e) => e.write_target(path), + BlockKind::Match(m) => m.write_target(path), + } + } + + pub fn write_value_expressions(&self, key: &str) -> Vec> { + match self { + BlockKind::Expression(e) => e.write_value_expressions(key), + BlockKind::Match(m) => m.write_value_expressions(key), + _ => Vec::new(), + } + } + + pub fn analyze(&self, cx: &mut AnalysisContext) { + match self { + BlockKind::Assertion(a) => a.analyze(cx), + BlockKind::DecisionTable(d) => d.analyze(cx), + BlockKind::Expression(e) => e.analyze(cx), + BlockKind::Match(m) => m.analyze(cx), + } + } + + pub fn execute(&self, cx: &ExecutionContext) -> Result { + match self { + BlockKind::Assertion(a) => a.execute(cx), + BlockKind::DecisionTable(d) => d.execute(cx), + BlockKind::Expression(e) => e.execute(cx), + BlockKind::Match(m) => m.execute(cx), + } + } + + pub(crate) fn read_plan(&self, flatten: &mut ReadFlattenFn) -> BlockReadPlan { + match self { + BlockKind::Assertion(a) => a.read_plan(flatten), + BlockKind::DecisionTable(d) => d.read_plan(flatten), + BlockKind::Expression(e) => e.read_plan(flatten), + BlockKind::Match(m) => m.read_plan(flatten), + } + } + + pub fn resolve_cursor( + &self, + cursor: &Cursor, + scope: VariableType, + ) -> Option<(Arc, ExpressionKind, VariableType)> { + match self { + BlockKind::Assertion(a) => a.resolve_cursor(cursor, scope), + BlockKind::DecisionTable(d) => d.resolve_cursor(cursor, scope), + BlockKind::Expression(e) => e.resolve_cursor(cursor, scope), + BlockKind::Match(m) => m.resolve_cursor(cursor, scope), + } + } + + pub fn write_keys(&self) -> Vec<(Option>, Arc)> { + match self { + BlockKind::DecisionTable(d) => d.write_keys(), + _ => self + .write_sites() + .into_iter() + .map(|s| (s.expression_id, s.path)) + .collect(), + } + } + + pub fn write_dependency_expr_ids(&self) -> HashMap, HashSet>> { + let mut out: HashMap, HashSet>> = HashMap::new(); + for site in self.write_sites() { + out.entry(site.path) + .or_default() + .extend(site.contributing_expr_ids); + } + out + } +} diff --git a/core/engine/src/policy/blocks/property_read.rs b/core/engine/src/policy/blocks/property_read.rs new file mode 100644 index 00000000..86f1799b --- /dev/null +++ b/core/engine/src/policy/blocks/property_read.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use zen_expression::intellisense::dependency::ReadDependency; + +use super::context::PropertyRead; + +pub(crate) struct ReadFlattener; + +impl ReadFlattener { + pub(crate) fn extend_from_deps( + deps: &[ReadDependency], + expression_id: &Option>, + out: &mut Vec, + ) { + let aliases: ahash::HashMap, Vec>> = + ahash::HashMap::default(); + Self::walk_deps(deps, expression_id, &aliases, out, 0); + } + + fn walk_deps( + deps: &[ReadDependency], + expression_id: &Option>, + aliases: &ahash::HashMap, Vec>>, + out: &mut Vec, + depth: usize, + ) { + if depth >= crate::policy::MAX_RECURSION_DEPTH { + return; + } + use crate::policy::queries::scope::PathSegments; + for dep in deps { + match dep { + ReadDependency::Direct { + path, + span, + via_index, + } => { + let was_aliased = !path.is_empty() && aliases.contains_key(&path[0]); + let resolved = Self::resolve_path(path, aliases); + if !resolved.is_empty() { + out.push(PropertyRead { + path: Arc::from(resolved.as_slice().to_dotted()), + expression_id: expression_id.clone(), + span: Some(*span), + via_alias: was_aliased || *via_index, + unresolved: false, + }); + } + } + ReadDependency::Unresolved { path, span } => { + let resolved = Self::resolve_path(path, aliases); + if !resolved.is_empty() { + out.push(PropertyRead { + path: Arc::from(resolved.as_slice().to_dotted()), + expression_id: expression_id.clone(), + span: Some(*span), + via_alias: true, + unresolved: true, + }); + } + } + ReadDependency::Iteration { + collection, + span, + alias, + reads, + } => { + let collection_was_aliased = + !collection.is_empty() && aliases.contains_key(&collection[0]); + let resolved_collection = Self::resolve_path(collection, aliases); + if !resolved_collection.is_empty() { + out.push(PropertyRead { + path: Arc::from(resolved_collection.as_slice().to_dotted()), + expression_id: expression_id.clone(), + span: Some(*span), + via_alias: collection_was_aliased, + unresolved: false, + }); + } + if let Some(a) = alias { + let mut inner_aliases = aliases.clone(); + inner_aliases.insert(a.clone(), resolved_collection); + Self::walk_deps(reads, expression_id, &inner_aliases, out, depth + 1); + } else { + Self::walk_deps(reads, expression_id, aliases, out, depth + 1); + } + } + } + } + } + + fn resolve_path( + path: &[std::rc::Rc], + aliases: &ahash::HashMap, Vec>>, + ) -> Vec> { + if path.is_empty() { + return Vec::new(); + } + if let Some(expansion) = aliases.get(&path[0]) { + let mut out = expansion.clone(); + out.extend_from_slice(&path[1..]); + out + } else { + path.to_vec() + } + } +} diff --git a/core/engine/src/policy/blocks/type_check.rs b/core/engine/src/policy/blocks/type_check.rs new file mode 100644 index 00000000..f1efbc87 --- /dev/null +++ b/core/engine/src/policy/blocks/type_check.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use zen_expression::variable::VariableType; + +use super::context::AnalysisContext; +use crate::policy::types::{CursorTarget, DiagnosticCode}; + +pub(super) struct TypeCheck; + +impl TypeCheck { + pub(super) fn check_no_any( + cx: &mut AnalysisContext, + ty: &VariableType, + expression_id: Option>, + target: Option, + label: &str, + ) { + if Self::type_contains_any(ty) { + let span = target.as_ref().map(|_| (0, label.chars().count() as u32)); + cx.error_with_target( + DiagnosticCode::TypeMismatch, + expression_id, + span, + target, + format!("'{label}' resolves to `{ty}` which is `any` — give it a concrete type"), + ); + } + } + + fn type_contains_any(ty: &VariableType) -> bool { + let mut visited: ahash::HashSet<*const ()> = ahash::HashSet::default(); + Self::contains_any_rec(ty, &mut visited) + } + + fn contains_any_rec(ty: &VariableType, visited: &mut ahash::HashSet<*const ()>) -> bool { + match ty { + VariableType::Any => true, + VariableType::Array(inner) | VariableType::Nullable(inner) => { + Self::contains_any_rec(inner, visited) + } + VariableType::Object(obj) => { + let ptr = std::rc::Rc::as_ptr(obj) as *const (); + if !visited.insert(ptr) { + return false; + } + let fields = obj.borrow(); + let result = fields.values().any(|v| Self::contains_any_rec(v, visited)); + visited.remove(&ptr); + result + } + _ => false, + } + } +} diff --git a/core/engine/src/policy/db.rs b/core/engine/src/policy/db.rs new file mode 100644 index 00000000..9a17d100 --- /dev/null +++ b/core/engine/src/policy/db.rs @@ -0,0 +1,706 @@ +use std::cell::{OnceCell, RefCell}; +use std::rc::Rc; +use std::sync::Arc; + +use ahash::{HashMap, HashMapExt, HashSet}; +use zen_expression::intellisense::IntelliSense; +use zen_expression::variable::VariableType; +use zen_expression::{Isolate, OpcodeCache}; + +use crate::policy::blocks::{ + Block, BlockKind, BlockReadPlan, IntelliSenseSource, PropertyRead, ReadFlattener, + SharedIntelliSense, +}; +use crate::policy::evaluator::EvalArtifact; +use crate::policy::ir::{DataModelIr, ParsedPolicy, Policy, Property, PropertyPath, Scope}; +use crate::policy::queries::dependency::{ + DataModelPaths, DependencyGraph, EnrichedState, EvalGraph, RuleShallowAnalysis, ShallowAnalyses, +}; +use crate::policy::queries::path::PathClassifier; +use crate::policy::queries::scope::{ + DataModelEntry, EntityForm, EntityGraph, EntitySources, ImportGraph, ReferenceField, + VariableTypeScope, +}; +use crate::policy::raw::PolicyDocument; +use crate::policy::types::{BlockRef, Diagnostic, ExpressionKind, InstanceTarget}; + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum AnalysisPass { + Shallow, + Enriched, +} + +#[derive(Default)] +pub(crate) struct PolicyDerivedCache { + parsed: RefCell, (Arc, Arc)>>, + shallow: RefCell, (Arc, Arc>)>>, + units: RefCell>, (Vec>, Arc)>>, +} + +impl PolicyDerivedCache { + fn retain(&self, live: &HashMap, Arc>) { + self.parsed + .borrow_mut() + .retain(|path, _| live.contains_key(path)); + self.shallow + .borrow_mut() + .retain(|path, _| live.contains_key(path)); + self.units + .borrow_mut() + .retain(|members, _| members.iter().all(|m| live.contains_key(m))); + } + + fn unit_or_compute( + &self, + members: &[Arc], + parsed: &[Arc], + compute: impl FnOnce() -> Unit, + ) -> Arc { + if let Some((cached_parsed, unit)) = self.units.borrow().get(members) { + if cached_parsed.len() == parsed.len() + && cached_parsed + .iter() + .zip(parsed) + .all(|(a, b)| Arc::ptr_eq(a, b)) + { + return unit.clone(); + } + } + let unit = Arc::new(compute()); + self.units + .borrow_mut() + .insert(members.to_vec(), (parsed.to_vec(), unit.clone())); + unit + } + + fn parsed_or_compute( + &self, + path: &Arc, + doc: &Arc, + compute: impl FnOnce() -> Arc, + ) -> Arc { + if let Some((cached_doc, parsed)) = self.parsed.borrow().get(path) { + if Arc::ptr_eq(cached_doc, doc) { + return parsed.clone(); + } + } + let parsed = compute(); + self.parsed + .borrow_mut() + .insert(path.clone(), (doc.clone(), parsed.clone())); + parsed + } + + pub(crate) fn shallow_or_compute( + &self, + path: &Arc, + parsed: &Arc, + compute: impl FnOnce() -> Vec, + ) -> Arc> { + if let Some((cached_parsed, shallow)) = self.shallow.borrow().get(path) { + if Arc::ptr_eq(cached_parsed, parsed) { + return shallow.clone(); + } + } + let shallow = Arc::new(compute()); + self.shallow + .borrow_mut() + .insert(path.clone(), (parsed.clone(), shallow.clone())); + shallow + } +} + +struct Inputs { + policies: HashMap, Arc>, +} + +pub struct Snapshot { + pub(crate) base_scope: VariableType, + pub(crate) all_parsed: Arc, Arc>>, + pub(crate) rule_by_ref: Arc>>, + pub(crate) import_graph: Arc, + pub(crate) shallow: Arc, + pub(crate) components: Vec>>, + pub(crate) policy_to_component: HashMap, usize>, + pub(crate) units: RefCell>>, + pub(crate) policy_diagnostics: RefCell, Arc>>>, + pub(crate) eval_artifacts: RefCell, Arc>>, +} + +pub struct Unit { + pub members: HashSet>, + pub entity_sources: Arc, + pub entity_graph: EntityGraph, + pub reference_fields: Vec, + pub data_model_paths: DataModelPaths, + pub classifier: PathClassifier, + pub dep_graph: DependencyGraph, + pub execution_order: Vec, + pub computed_instances: HashMap, InstanceTarget>, + enriched_once: OnceCell>, + opcode_cache: OnceCell>, + pub data_models: Vec, + pub entities: HashMap, Arc>, +} + +pub struct Db { + inputs: RefCell, + snapshot: RefCell>>, + cache: PolicyDerivedCache, + intellisense: SharedIntelliSense, + scope_roots: RefCell>, +} + +impl Drop for Db { + fn drop(&mut self) { + for root in self.scope_roots.borrow().iter() { + root.break_cycles(); + } + } +} + +impl Db { + pub fn new() -> Self { + Self { + inputs: RefCell::new(Inputs { + policies: HashMap::default(), + }), + snapshot: RefCell::new(None), + cache: PolicyDerivedCache::default(), + intellisense: Rc::new(RefCell::new(IntelliSense::new().with_strict(true))), + scope_roots: RefCell::new(Vec::new()), + } + } + + pub fn set_policy(&mut self, path: Arc, doc: Arc) { + self.inputs.borrow_mut().policies.insert(path, doc); + self.bump(); + } + + pub fn remove_policy(&mut self, path: &str) -> bool { + let existed = self.inputs.borrow_mut().policies.remove(path).is_some(); + if existed { + self.bump(); + } + existed + } + + pub fn policy_paths(&self) -> Vec> { + self.inputs.borrow().policies.keys().cloned().collect() + } + + pub fn raw_policy(&self, path: &str) -> Option> { + self.inputs.borrow().policies.get(path).cloned() + } + + fn bump(&mut self) { + *self.snapshot.borrow_mut() = None; + } + + pub fn intellisense(&self) -> SharedIntelliSense { + self.intellisense.clone() + } + + pub fn snapshot(&self) -> Arc { + if let Some(s) = self.snapshot.borrow().clone() { + return s; + } + let s = Arc::new(Snapshot::compute( + &self.inputs.borrow().policies, + &self.intellisense, + &self.cache, + )); + self.scope_roots + .borrow_mut() + .push(s.base_scope.shallow_clone()); + *self.snapshot.borrow_mut() = Some(s.clone()); + s + } + + pub fn parsed(&self, path: &Arc) -> Option> { + self.snapshot().all_parsed.get(path).cloned() + } + + pub fn unit(&self, policy: &str) -> Arc { + let snap = self.snapshot(); + let Some(&idx) = snap.policy_to_component.get(policy) else { + return self.cache.unit_or_compute(&[], &[], || { + Snapshot::compute_unit(&[], &snap.all_parsed, &snap.shallow) + }); + }; + if let Some(u) = snap.units.borrow().get(&idx).cloned() { + return u; + } + let members = &snap.components[idx]; + let parsed: Vec> = members + .iter() + .filter_map(|m| snap.all_parsed.get(m).cloned()) + .collect(); + let unit = self.cache.unit_or_compute(members, &parsed, || { + Snapshot::compute_unit(members, &snap.all_parsed, &snap.shallow) + }); + snap.units.borrow_mut().insert(idx, unit.clone()); + unit + } + + pub(crate) fn unit_for_property(&self, property: &str) -> Arc { + let snap = self.snapshot(); + let policy = Self::policy_touching(&snap.shallow, property) + .or_else(|| Self::policy_writing_longest_prefix(&snap.shallow, property)) + .or_else(|| snap.components.first().and_then(|c| c.first()).cloned()); + match policy { + Some(p) => self.unit(&p), + None => self.unit(""), + } + } + + fn policy_touching(shallow: &ShallowAnalyses, property: &str) -> Option> { + shallow.per_rule.iter().find_map(|r| { + let touches = r.writes.iter().any(|w| w.path.as_ref() == property) + || r.reads.iter().any(|rd| rd.path.as_ref() == property); + touches.then(|| r.policy_path.clone()) + }) + } + + fn policy_writing_longest_prefix( + shallow: &ShallowAnalyses, + property: &str, + ) -> Option> { + let mut end = property.len(); + while let Some(dot) = property[..end].rfind('.') { + let prefix = &property[..dot]; + let writer = shallow.per_rule.iter().find_map(|r| { + r.writes + .iter() + .any(|w| w.path.as_ref() == prefix) + .then(|| r.policy_path.clone()) + }); + if writer.is_some() { + return writer; + } + end = dot; + } + None + } + + pub(crate) fn enriched(&self, policy: &str) -> Arc { + let unit = self.unit(policy); + self.enriched_of_unit(&unit) + } + + pub(crate) fn enriched_of_unit(&self, unit: &Unit) -> Arc { + unit.enriched_once + .get_or_init(|| { + let snap = self.snapshot(); + let subset: HashMap, Arc> = snap + .all_parsed + .iter() + .filter(|(p, _)| unit.members.contains(*p)) + .map(|(p, v)| (p.clone(), v.clone())) + .collect(); + let base_scope = Snapshot::compute_base_scope(&subset, &unit.entity_sources); + self.scope_roots + .borrow_mut() + .push(base_scope.shallow_clone()); + Arc::new(Snapshot::compute_enriched( + &base_scope, + &unit.dep_graph, + &unit.execution_order, + &snap.rule_by_ref, + &unit.members, + &self.intellisense, + )) + }) + .clone() + } + + pub(crate) fn opcode_cache_of_unit(&self, unit: &Unit) -> Arc { + unit.opcode_cache + .get_or_init(|| { + let snap = self.snapshot(); + let mut sources: Vec<(Arc, ExpressionKind)> = Vec::new(); + for member in &unit.members { + let Some(parsed) = snap.all_parsed.get(member) else { + continue; + }; + for rule in parsed.policy.rules() { + for loc in rule.kind.expressions(&rule.id) { + sources.push((loc.source, loc.kind)); + } + if let BlockKind::DecisionTable(dt) = &rule.kind { + for col in &dt.inputs { + if let Some(field) = + col.field.as_ref().filter(|f| !f.as_ref().is_empty()) + { + sources.push((field.clone(), ExpressionKind::Standard)); + } + } + } + } + } + + let mut cache = OpcodeCache::new(); + let mut isolate = Isolate::new(); + for (source, kind) in &sources { + let map = match kind { + ExpressionKind::Standard => &mut cache.standard, + ExpressionKind::Unary => &mut cache.unary, + }; + if map.contains_key(source) { + continue; + } + let bytecode = match kind { + ExpressionKind::Standard => isolate + .compile_standard(source) + .map(|e| e.bytecode().to_vec()), + ExpressionKind::Unary => { + isolate.compile_unary(source).map(|e| e.bytecode().to_vec()) + } + }; + if let Ok(bc) = bytecode { + map.insert(source.clone(), Arc::from(bc)); + } + } + Arc::new(cache) + }) + .clone() + } + + pub(crate) fn eval_artifact(&self, policy: &str) -> Arc { + let snap = self.snapshot(); + if let Some(artifact) = snap.eval_artifacts.borrow().get(policy).cloned() { + return artifact; + } + + let unit = self.unit(policy); + let opcode_cache = self.opcode_cache_of_unit(&unit); + let input_schema = self.input_schema(policy); + let eval_graph = EvalGraph::from_graph(&unit.dep_graph); + let reads: HashMap> = snap + .rule_by_ref + .keys() + .filter(|r| unit.members.contains(&r.policy_path)) + .filter_map(|r| { + snap.shallow + .for_block(r) + .map(|s| (r.clone(), Arc::from(s.reads.clone()))) + }) + .collect(); + + let intellisense = self.intellisense(); + let entity_form = EntityForm::new(unit.entity_sources.as_ref()); + let read_plans: HashMap = snap + .rule_by_ref + .iter() + .filter(|(r, _)| unit.members.contains(&r.policy_path)) + .map(|(r, block)| { + let mut flatten = |src: &Arc, kind: ExpressionKind| -> Vec> { + if src.is_empty() { + return Vec::new(); + } + let analysis = + IntelliSenseSource::reads_only(&mut intellisense.borrow_mut(), src, kind); + let mut out = Vec::new(); + ReadFlattener::extend_from_deps(&analysis.reads, &None, &mut out); + out.into_iter() + .filter_map(|rd| { + if rd.unresolved { + None + } else if rd.via_alias { + entity_form + .rewrite(rd.path.as_ref()) + .map(Arc::from) + .or(Some(rd.path)) + } else { + Some(rd.path) + } + }) + .collect() + }; + (r.clone(), block.kind.read_plan(&mut flatten)) + }) + .collect(); + + let artifact = Arc::new(EvalArtifact { + members: unit.members.clone(), + eval_graph, + execution_order: unit.execution_order.clone(), + entity_sources: unit.entity_sources.clone(), + reference_fields: unit.reference_fields.clone(), + data_model_paths: unit.data_model_paths.clone(), + classifier: unit.classifier.clone(), + opcode_cache, + rule_by_ref: snap.rule_by_ref.clone(), + input_schema, + reads, + read_plans, + }); + snap.eval_artifacts + .borrow_mut() + .insert(Arc::from(policy), artifact.clone()); + artifact + } + + pub fn rule_by_ref(&self) -> Arc>> { + self.snapshot().rule_by_ref.clone() + } + + pub fn block_ir(&self, block_ref: &BlockRef) -> Option> { + self.snapshot().rule_by_ref.get(block_ref).cloned() + } + + pub fn block_doc(&self, block_ref: &BlockRef) -> Option { + let policy = self.raw_policy(&block_ref.policy_path)?; + policy + .blocks + .iter() + .find(|b| b.id() == Some(block_ref.block_id.as_ref())) + .cloned() + } + + pub fn import_graph(&self) -> Arc { + self.snapshot().import_graph.clone() + } + + pub fn shallow(&self) -> Arc { + self.snapshot().shallow.clone() + } + + pub fn policy_diagnostics(&self, path: &Arc) -> Arc> { + let snap = self.snapshot(); + if let Some(d) = snap.policy_diagnostics.borrow().get(path).cloned() { + return d; + } + let value = Arc::new(self.compute_policy_diagnostics(path)); + snap.policy_diagnostics + .borrow_mut() + .insert(path.clone(), value.clone()); + value + } + + pub fn all_diagnostics(&self) -> Vec { + let mut paths = self.policy_paths(); + paths.sort(); + paths + .iter() + .flat_map(|p| (*self.policy_diagnostics(p)).clone()) + .collect() + } +} + +impl Default for Db { + fn default() -> Self { + Self::new() + } +} + +impl Snapshot { + fn compute( + policies: &HashMap, Arc>, + intellisense: &SharedIntelliSense, + cache: &PolicyDerivedCache, + ) -> Snapshot { + cache.retain(policies); + let all_parsed = Arc::new(Self::parse_all(policies, cache)); + let rule_by_ref = Arc::new(Self::build_rule_by_ref(&all_parsed)); + let import_graph = Arc::new(Self::compute_import_graph(&all_parsed)); + + let entity_sources = Self::compute_entity_sources(&all_parsed); + let base_scope = Self::compute_base_scope(&all_parsed, &entity_sources); + let classifier = Self::compute_path_classifier(&all_parsed); + let shallow = Arc::new(Self::compute_shallow( + &base_scope, + &all_parsed, + &classifier, + intellisense, + cache, + )); + + let (components, policy_to_component) = + Self::compute_components(&import_graph, &all_parsed); + + Snapshot { + base_scope, + all_parsed, + rule_by_ref, + import_graph, + shallow, + components, + policy_to_component, + units: RefCell::new(HashMap::new()), + policy_diagnostics: RefCell::new(HashMap::new()), + eval_artifacts: RefCell::new(HashMap::new()), + } + } + + fn compute_components( + import_graph: &ImportGraph, + all_parsed: &HashMap, Arc>, + ) -> (Vec>>, HashMap, usize>) { + let mut seen: HashSet> = HashSet::default(); + let mut components: Vec>> = Vec::new(); + let mut sorted: Vec<&Arc> = all_parsed.keys().collect(); + sorted.sort(); + for path in sorted { + if seen.contains(path) { + continue; + } + let mut members: Vec> = Vec::new(); + let mut stack = vec![path.clone()]; + while let Some(p) = stack.pop() { + if !seen.insert(p.clone()) { + continue; + } + members.push(p.clone()); + if let Some(&idx) = import_graph.node_map.get(&p) { + for n in import_graph.graph.neighbors_undirected(idx) { + stack.push(import_graph.graph[n].clone()); + } + } + } + members.sort(); + components.push(members); + } + let mut policy_to_component: HashMap, usize> = HashMap::new(); + for (idx, members) in components.iter().enumerate() { + for m in members { + policy_to_component.insert(m.clone(), idx); + } + } + (components, policy_to_component) + } + + fn compute_unit( + members: &[Arc], + all_parsed: &HashMap, Arc>, + shallow: &ShallowAnalyses, + ) -> Unit { + let member_set: HashSet> = members.iter().cloned().collect(); + let subset: HashMap, Arc> = all_parsed + .iter() + .filter(|(p, _)| member_set.contains(*p)) + .map(|(p, v)| (p.clone(), v.clone())) + .collect(); + + let entity_sources = Arc::new(Self::compute_entity_sources(&subset)); + let mut entity_graph = Self::compute_entity_graph(&subset, &entity_sources); + let reference_fields = Self::compute_reference_fields(&subset); + let data_model_paths = Self::compute_data_model_paths(&subset); + let classifier = Self::compute_path_classifier(&subset); + + let per_rule: Vec<&RuleShallowAnalysis> = shallow + .per_rule + .iter() + .filter(|r| member_set.contains(&r.policy_path)) + .collect(); + let dep_graph = Self::compute_graph(&per_rule, &data_model_paths, &entity_sources); + let execution_order = Self::compute_execution_order(&dep_graph); + + let (_, pool_roots) = DataModelIr::classify_roots( + subset + .values() + .flat_map(|p| p.policy.data_models().map(|(_, dm)| dm)), + ); + let computed_instances = entity_graph.resolve_instance_targets(&dep_graph, &pool_roots); + entity_graph.register_computed(&computed_instances); + + let mut data_models: Vec = subset + .iter() + .flat_map(|(path, p)| { + p.policy + .data_models + .iter() + .map(move |block| DataModelEntry { + policy_path: path.clone(), + block_id: block.id.clone(), + ir: block.ir.clone(), + }) + }) + .collect(); + data_models.sort_by(|a, b| { + a.ir.name + .cmp(&b.ir.name) + .then_with(|| a.policy_path.cmp(&b.policy_path)) + }); + + let entities = Self::compute_unit_entities(&subset); + + Unit { + members: member_set, + entity_sources, + entity_graph, + reference_fields, + data_model_paths, + classifier, + dep_graph, + execution_order, + computed_instances, + enriched_once: OnceCell::new(), + opcode_cache: OnceCell::new(), + data_models, + entities, + } + } + + fn compute_unit_entities( + subset: &HashMap, Arc>, + ) -> HashMap, Arc> { + let mut sorted: Vec<&Arc> = subset.keys().collect(); + sorted.sort(); + let mut props_by_entity: HashMap, Vec> = HashMap::new(); + for pp in sorted { + for (_, dm) in subset[pp].policy.entity_data_models() { + let bucket = props_by_entity.entry(dm.name.clone()).or_default(); + for prop in &dm.properties { + if !bucket.iter().any(|p| p.name == prop.name) { + bucket.push(prop.clone()); + } + } + } + } + props_by_entity + .into_iter() + .map(|(name, properties)| { + let dm = Arc::new(DataModelIr { + name: name.clone(), + scope: Scope::Entity, + properties, + }); + (name, dm) + }) + .collect() + } + + fn parse_all( + policies: &HashMap, Arc>, + cache: &PolicyDerivedCache, + ) -> HashMap, Arc> { + policies + .iter() + .map(|(path, doc)| { + let parsed = + cache.parsed_or_compute(path, doc, || Arc::new(Policy::parse(path, doc))); + (path.clone(), parsed) + }) + .collect() + } + + fn build_rule_by_ref( + all_parsed: &HashMap, Arc>, + ) -> HashMap> { + all_parsed + .iter() + .flat_map(|(path, p)| { + p.policy.rules().map(move |rule| { + ( + BlockRef { + policy_path: path.clone(), + block_id: rule.id.clone(), + }, + Arc::new(rule.clone()), + ) + }) + }) + .collect() + } +} diff --git a/core/engine/src/policy/editor.rs b/core/engine/src/policy/editor.rs new file mode 100644 index 00000000..baf05f4f --- /dev/null +++ b/core/engine/src/policy/editor.rs @@ -0,0 +1,460 @@ +use std::sync::Arc; + +use ahash::{HashMap, HashMapExt}; +use serde_json::Value; +use zen_expression::intellisense::Reference; +use zen_expression::variable::VariableType; + +use crate::policy::blocks::IntelliSenseSource; +use crate::policy::db::{Db, Snapshot}; +use crate::policy::ir::{DataModelIr, PropertyTypeIr}; +use crate::policy::queries::scope::EntityGraph; +use crate::policy::types::{ + BlockRef, Completion, Cursor, CursorTarget, EngineEdit, ExpressionKind, InspectResult, + PrepareRename, ReferenceKind, ReferenceSite, RenameTarget, Span, SpanOps, +}; + +impl Db { + pub fn inspect(&self, cursor: &Cursor) -> Option { + let (source, _, scope) = self.resolve_cursor(cursor)?; + let r = self + .intellisense() + .borrow_mut() + .inspect(&source, cursor.pos, &scope)?; + Some(InspectResult { + span: r.span, + kind: r.kind, + label: r.label, + }) + } + + pub fn completions(&self, cursor: &Cursor) -> Vec { + let Some((source, _, scope)) = self.resolve_cursor(cursor) else { + return Vec::new(); + }; + self.intellisense() + .borrow_mut() + .completions(&source, cursor.pos, &scope) + } + + pub fn prepare_rename(&self, cursor: &Cursor) -> Option { + if let Some(result) = self.prepare_rename_data_model(cursor) { + return Some(result); + } + let unit = self.unit(&cursor.policy_path); + let (source, kind, scope) = self.resolve_cursor(cursor)?; + let intellisense = self.intellisense(); + let mut is = intellisense.borrow_mut(); + let analysis = IntelliSenseSource::analyze(&mut is, &source, kind, &scope); + let mut found: Option = None; + for reference in &analysis.references { + unit.entity_graph.walk_segment_targets(reference, |i, t| { + if found.is_some() { + return; + } + let Some(&span) = reference.spans.get(i) else { + return; + }; + if span.0 <= cursor.pos && cursor.pos < span.1 { + found = Some(PrepareRename { target: t, span }); + } + }); + if found.is_some() { + break; + } + } + found + } + + pub fn rename(&self, target: &RenameTarget, new_name: &str) -> Vec { + let mut per_block: HashMap> = HashMap::new(); + self.walk_renamable(target, |site| { + let key = BlockRef { + policy_path: site.policy_path.clone(), + block_id: site.block_id.clone(), + }; + per_block.entry(key).or_default().push(site); + }); + per_block + .into_iter() + .filter_map(|(block_ref, sites)| self.build_replace_block(block_ref, sites, new_name)) + .collect() + } + + fn build_replace_block( + &self, + block_ref: BlockRef, + sites: Vec, + new_name: &str, + ) -> Option { + let block = self.block_doc(&block_ref)?; + let mut block_json = serde_json::to_value(&block).ok()?; + + let rewrites = RenameRewrites::from_sites(&sites, new_name); + rewrites.apply_to(&mut block_json); + + Some(EngineEdit::ReplaceBlock { + policy_path: block_ref.policy_path, + block_id: block_ref.block_id, + new_block: block_json, + }) + } + + pub fn references(&self, target: &RenameTarget) -> Vec { + let mut sites = Vec::new(); + self.walk_renamable(target, |site| { + sites.push(site.into_reference()); + }); + sites.sort_by(|a, b| { + a.kind + .display_order() + .cmp(&b.kind.display_order()) + .then_with(|| a.policy_path.cmp(&b.policy_path)) + .then_with(|| a.block_id.cmp(&b.block_id)) + .then_with(|| a.span.0.cmp(&b.span.0)) + }); + sites + } + + fn prepare_rename_data_model(&self, cursor: &Cursor) -> Option { + let parsed = self.parsed(&cursor.policy_path)?; + let dm: &DataModelIr = parsed + .policy + .data_models + .iter() + .find(|b| b.id == cursor.block_id) + .map(|b| b.ir.as_ref())?; + let is_global = dm.scope.is_global(); + let (target, name) = match &cursor.target { + CursorTarget::DataModelName => { + if is_global { + return None; + } + ( + RenameTarget::Entity { + name: dm.name.clone(), + }, + &dm.name, + ) + } + CursorTarget::DataModelProperty { id } => { + let p = dm.properties.iter().find(|p| p.id == *id)?; + let target = if is_global { + RenameTarget::Global { + name: p.name.clone(), + } + } else { + RenameTarget::Field { + entity: dm.name.clone(), + field: p.name.clone(), + } + }; + (target, &p.name) + } + _ => return None, + }; + Some(PrepareRename { + target, + span: (0, SpanOps::char_len(name)), + }) + } + + fn resolve_cursor(&self, cursor: &Cursor) -> Option<(Arc, ExpressionKind, VariableType)> { + let rule = self.block_ir(&BlockRef { + policy_path: cursor.policy_path.clone(), + block_id: cursor.block_id.clone(), + })?; + let (source, kind, narrowed) = rule.resolve_cursor( + cursor, + self.enriched(&cursor.policy_path).scope.shallow_clone(), + )?; + (cursor.pos as usize <= source.chars().count()).then_some((source, kind, narrowed)) + } +} + +struct RenameSite { + policy_path: Arc, + block_id: Arc, + expression_id: Option>, + source: Arc, + span: Span, + kind: ReferenceKind, +} + +impl RenameSite { + fn into_reference(self) -> ReferenceSite { + ReferenceSite { + policy_path: self.policy_path, + block_id: self.block_id, + expression_id: self.expression_id, + source: self.source, + span: self.span, + kind: self.kind, + } + } + + fn write_key_span(source: &str, target: &RenameTarget) -> Option { + let source = source.strip_suffix("[]").unwrap_or(source); + match target { + RenameTarget::Global { name } => { + (source == name.as_ref()).then_some((0, SpanOps::char_len(source))) + } + RenameTarget::Entity { name } => { + let (src_entity, _) = source.split_once('.')?; + (src_entity == name.as_ref()).then_some((0, SpanOps::char_len(name))) + } + RenameTarget::Field { entity, field } => { + let (src_entity, rest) = source.split_once('.')?; + let src_field = rest.split_once('.').map_or(rest, |(first, _)| first); + if src_entity != entity.as_ref() || src_field != field.as_ref() { + return None; + } + let start = SpanOps::char_len(src_entity) + 1; + Some((start, start + SpanOps::char_len(src_field))) + } + } + } +} + +impl Db { + fn walk_renamable(&self, target: &RenameTarget, mut callback: impl FnMut(RenameSite)) { + let snap = self.snapshot(); + let intellisense = self.intellisense(); + + let mut policies: Vec> = snap.all_parsed.keys().cloned().collect(); + policies.sort(); + let units: Vec<( + Arc, + std::sync::Arc, + std::sync::Arc, + )> = policies + .iter() + .map(|p| { + let unit = self.unit(p); + let enriched = self.enriched_of_unit(&unit); + (p.clone(), unit, enriched) + }) + .collect(); + + let mut is = intellisense.borrow_mut(); + for (policy_path, unit, enriched) in &units { + let parsed = &snap.all_parsed[policy_path]; + let entities = &unit.entity_graph; + let scope = &enriched.scope; + for rule in parsed.policy.rules() { + for loc in rule.kind.expressions(&rule.id) { + let analysis = + IntelliSenseSource::analyze(&mut is, &loc.source, loc.kind, scope); + for reference in &analysis.references { + entities.walk_segment_targets(reference, |i, t| { + let Some(&span) = reference.spans.get(i).filter(|_| &t == target) + else { + return; + }; + callback(RenameSite { + policy_path: policy_path.clone(), + block_id: loc.block_id.clone(), + expression_id: Some(loc.expression_id.clone()), + source: loc.source.clone(), + span, + kind: ReferenceKind::ExpressionRead, + }); + }); + } + } + for (expression_id, source) in rule.kind.write_keys() { + if let Some(span) = RenameSite::write_key_span(&source, target) { + callback(RenameSite { + policy_path: policy_path.clone(), + block_id: rule.id.clone(), + expression_id, + source, + span, + kind: ReferenceKind::WriteKey, + }); + } + } + } + for (block_id, dm) in parsed.policy.data_models() { + Snapshot::emit_dm_sites(policy_path, block_id, dm, target, &mut callback); + } + } + } +} + +impl Snapshot { + fn emit_dm_sites( + policy_path: &Arc, + block_id: &Arc, + dm: &DataModelIr, + target: &RenameTarget, + callback: &mut impl FnMut(RenameSite), + ) { + let mut emit = |expression_id, name: &Arc| { + callback(RenameSite { + policy_path: policy_path.clone(), + block_id: block_id.clone(), + expression_id, + source: name.clone(), + span: (0, SpanOps::char_len(name)), + kind: ReferenceKind::DataModel, + }); + }; + match target { + RenameTarget::Entity { name } => { + if !dm.scope.is_global() && dm.name.as_ref() == name.as_ref() { + emit(None, &dm.name); + } + for prop in &dm.properties { + if let PropertyTypeIr::Relationship { target: t } + | PropertyTypeIr::Reference { target: t } = &prop.kind + { + if t.as_ref() == name.as_ref() { + emit(Some(prop.id.clone()), t); + } + } + } + } + RenameTarget::Field { entity, field } + if !dm.scope.is_global() && dm.name.as_ref() == entity.as_ref() => + { + if let Some(p) = dm + .properties + .iter() + .find(|p| p.name.as_ref() == field.as_ref()) + { + emit(Some(p.id.clone()), &p.name); + } + } + RenameTarget::Global { name } if dm.scope.is_global() => { + if let Some(p) = dm + .properties + .iter() + .find(|p| p.name.as_ref() == name.as_ref()) + { + emit(Some(p.id.clone()), &p.name); + } + } + _ => {} + } + } +} + +impl EntityGraph { + fn walk_segment_targets( + &self, + reference: &Reference, + mut visit: impl FnMut(usize, RenameTarget), + ) { + let start: Option<(Arc, usize)> = match (&reference.via_alias, &reference.via_index) { + (Some(alias), _) => self + .resolve_path_to_element(&alias.collection) + .map(|e| (e, 1)), + (_, Some(collection)) => self.resolve_path_to_element(collection).map(|e| (e, 0)), + (None, None) => reference.path.first().and_then(|seg| { + let first: Arc = Arc::from(seg.as_ref()); + if self.contains(&first) { + visit( + 0, + RenameTarget::Entity { + name: first.clone(), + }, + ); + return Some((first, 1)); + } + if self.next_entity_for_global(&first).is_some() + || self.global_property(&first).is_some() + { + visit( + 0, + RenameTarget::Global { + name: first.clone(), + }, + ); + match self.next_entity_for_global(&first) { + Some(target) => Some((target, 1)), + None => None, + } + } else { + None + } + }), + }; + let Some((mut current, start_idx)) = start else { + return; + }; + for i in start_idx..reference.path.len() { + let segment = reference.path[i].as_ref(); + visit( + i, + RenameTarget::Field { + entity: Arc::from(current.as_ref()), + field: Arc::from(segment), + }, + ); + match self.next_entity(¤t, segment) { + Some(next) => current = next, + None => break, + } + } + } +} + +struct RenameRewrites { + by_source: HashMap, String>, +} + +impl RenameRewrites { + fn from_sites(sites: &[RenameSite], new_name: &str) -> Self { + let mut spans_by_source: HashMap, Vec> = HashMap::new(); + for site in sites { + spans_by_source + .entry(site.source.clone()) + .or_default() + .push(site.span); + } + let by_source = spans_by_source + .into_iter() + .map(|(source, spans)| { + let new = SpanOps::replace_at_char_spans(&source, &spans, new_name); + (source, new) + }) + .collect(); + Self { by_source } + } + + fn apply_to(&self, value: &mut Value) { + match value { + Value::String(s) => { + if let Some(new) = self.by_source.get(s.as_str()) { + *s = new.clone(); + } + } + Value::Array(arr) => arr.iter_mut().for_each(|v| self.apply_to(v)), + Value::Object(obj) => { + for (key, child) in obj.iter_mut() { + if matches!(key.as_str(), "dataJson" | "schemaJson") { + self.apply_to_json_envelope(child); + } else { + self.apply_to(child); + } + } + } + _ => {} + } + } + + fn apply_to_json_envelope(&self, value: &mut Value) { + let Value::String(s) = value else { + return; + }; + let Ok(mut nested) = serde_json::from_str::(s) else { + return; + }; + let before = nested.clone(); + self.apply_to(&mut nested); + if nested != before { + *s = nested.to_string(); + } + } +} diff --git a/core/engine/src/policy/evaluator.rs b/core/engine/src/policy/evaluator.rs new file mode 100644 index 00000000..625adf4e --- /dev/null +++ b/core/engine/src/policy/evaluator.rs @@ -0,0 +1,810 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Instant; + +use ahash::{HashMap, HashMapExt, HashSet, HashSetExt}; +use zen_expression::variable::Variable; + +use zen_expression::{Isolate, OpcodeCache}; + +use crate::policy::blocks::{ + Block, BlockKind, BlockReadPlan, ExecutionContext, ExecutionError, MatchSelection, + PropertyRead, TableSelection, +}; +use crate::policy::db::Db; +use crate::policy::ir::PropertyPath; +use crate::policy::queries::dependency::{DataModelPaths, EvalGraph, WriteScope}; +use crate::policy::queries::path::PathClassifier; +use crate::policy::queries::scope::{EntitySources, ReferenceField}; +use crate::policy::refs::RefPoolIndex; +use crate::policy::types::{ + BlockExecution, BlockRef, BlockTrace, EvaluateRequest, EvaluationError, EvaluationResult, Trace, +}; +use crate::policy::validator::InputSchema; + +pub(crate) struct EvalArtifact { + pub(crate) members: HashSet>, + pub(crate) eval_graph: EvalGraph, + pub(crate) execution_order: Vec, + pub(crate) entity_sources: Arc, + pub(crate) reference_fields: Vec, + pub(crate) data_model_paths: DataModelPaths, + pub(crate) classifier: PathClassifier, + pub(crate) opcode_cache: Arc, + pub(crate) rule_by_ref: Arc>>, + pub(crate) input_schema: InputSchema, + pub(crate) reads: HashMap>, + pub(crate) read_plans: HashMap, +} + +impl Db { + pub fn evaluate(&self, req: &EvaluateRequest) -> Result { + if self.raw_policy(&req.policy_path).is_none() { + return Err(EvaluationError::PolicyNotFound(req.policy_path.clone())); + } + self.check_imports_resolved(&req.policy_path)?; + self.eval_artifact(&req.policy_path).evaluate(req, false) + } + + pub fn enhance_trace( + &self, + req: &EvaluateRequest, + ) -> Result { + if self.raw_policy(&req.policy_path).is_none() { + return Err(EvaluationError::PolicyNotFound(req.policy_path.clone())); + } + self.check_imports_resolved(&req.policy_path)?; + let mut req = req.clone(); + req.trace = true; + self.eval_artifact(&req.policy_path).evaluate(&req, true) + } + + fn check_imports_resolved(&self, entry: &Arc) -> Result<(), EvaluationError> { + let mut visited: HashSet> = HashSet::new(); + let mut queue: Vec> = vec![entry.clone()]; + visited.insert(entry.clone()); + + while let Some(path) = queue.pop() { + let Some(parsed) = self.parsed(&path) else { + continue; + }; + for import in parsed.policy.imports() { + if self.raw_policy(import).is_none() { + return Err(EvaluationError::ImportNotFound { + policy_path: path, + import: import.clone(), + }); + } + if visited.insert(import.clone()) { + queue.push(import.clone()); + } + } + } + Ok(()) + } +} + +impl EvalArtifact { + pub(crate) fn evaluate_entry( + &self, + key: &str, + input: Variable, + trace: bool, + ) -> Result { + let request = EvaluateRequest { + policy_path: Arc::from(key), + input, + goals: Vec::new(), + trace, + }; + self.evaluate(&request, false) + } + + pub(crate) fn evaluate( + &self, + req: &EvaluateRequest, + extras: bool, + ) -> Result { + let start = Instant::now(); + + self.validate_request(req)?; + + let order_to_run = self.compute_order_to_run(req)?; + + let store = req.input.depth_clone(1); + let ref_targets: HashSet> = self + .reference_fields + .iter() + .map(|f| f.target.clone()) + .collect(); + let pool_index = RefPoolIndex::from_input(&store, ref_targets); + store.hydrate_references(&self.reference_fields, &pool_index); + + let roots: Vec> = if req.goals.is_empty() { + self.eval_graph.terminal_sinks(&self.members) + } else { + req.goals.clone() + }; + let mut driver = Driver::new(self, &store, &req.policy_path, req.trace, extras); + let outcome = roots.iter().try_for_each(|root| driver.demand(root)); + + let trace = req.trace.then(|| Trace { + engine_version: Arc::from(crate::ENGINE_VERSION), + properties: store.snapshot(&order_to_run), + executions: driver.executions, + }); + + if let Err(error) = outcome { + return Err(error.with_partial_trace(trace)); + } + + Ok(EvaluationResult { + output: store, + duration: start.elapsed(), + trace, + }) + } + + fn validate_request(&self, req: &EvaluateRequest) -> Result<(), EvaluationError> { + for goal in &req.goals { + if !self.eval_graph.contains(goal) { + return Err(EvaluationError::GoalNotFound(goal.clone())); + } + } + let validation_errors = self.input_schema.validate(&req.input); + if !validation_errors.is_empty() { + return Err(EvaluationError::InputValidationFailed { + errors: validation_errors, + }); + } + Ok(()) + } + + fn compute_order_to_run( + &self, + req: &EvaluateRequest, + ) -> Result, EvaluationError> { + let visible = &self.members; + let visible_order: Vec = self + .execution_order + .iter() + .filter(|path| { + self.eval_graph + .writer_for(path) + .is_some_and(|o| visible.contains(&o.policy_path)) + }) + .cloned() + .collect(); + + if req.goals.is_empty() { + return Ok(visible_order); + } + + let reachable = self.eval_graph.reachable_from(&req.goals); + let mut missing: Vec = self + .eval_graph + .reachable_input_paths(&req.goals, visible) + .into_iter() + .filter(|p| { + !self.data_model_paths.is_optional(p) && !self.input_satisfied(&req.input, p) + }) + .collect(); + if !missing.is_empty() { + missing.sort(); + return Err(EvaluationError::MissingRequiredInputs { + goals: req.goals.clone(), + missing, + }); + } + + Ok(visible_order + .iter() + .filter(|p| reachable.contains(*p)) + .cloned() + .collect()) + } + + fn input_satisfied(&self, input: &Variable, path: &str) -> bool { + if Self::input_path_satisfied(input, path) { + return true; + } + let Some((entity, rest)) = path.split_once('.') else { + return false; + }; + match self.entity_sources.get(entity) { + Some(src) => { + let resolved = format!("{}.{}", src.path, rest); + Self::input_path_satisfied(input, &resolved) + } + None => false, + } + } + + fn input_path_satisfied(input: &Variable, path: &str) -> bool { + let mut current = input.shallow_clone(); + for segment in path.split('.') { + if current.as_array().is_some() { + return true; + } + match current.dot(segment) { + Some(v) => current = v, + None => return false, + } + } + true + } +} + +struct Driver<'a> { + artifact: &'a EvalArtifact, + store: &'a Variable, + env: Variable, + entry: &'a Arc, + trace: bool, + extras: bool, + isolate: Rc>, + ran: HashSet, + in_progress: HashSet, + executions: Vec, +} + +enum Pick { + Unconditional, + Match(MatchSelection), + Table(TableSelection), +} + +impl Pick { + fn collect_reads(&self, plan: &BlockReadPlan, out: &mut Vec>) { + match self { + Pick::Match(selection) => { + if let Some(arm_id) = &selection.matched_arm { + if let Some(reads) = plan.match_arm_reads(arm_id) { + out.extend(reads.iter().cloned()); + } + } + } + Pick::Table(selection) => { + for (row_idx, col_id) in &selection.used_cells { + out.extend(plan.cell_reads(*row_idx, col_id)); + } + } + Pick::Unconditional => {} + } + } +} + +struct PhaseScope { + scoped: Variable, + entity_key: Rc, +} + +impl PhaseScope { + fn new(store: &Variable, entity_key: Rc) -> Self { + Self { + scoped: store.depth_clone(1), + entity_key, + } + } + + fn bind( + &self, + instance: &Variable, + owner_binding: &Option<(String, Variable)>, + ) -> Option { + let scoped_fields = self.scoped.as_object()?; + + let needs_owner = match (owner_binding, instance.as_object()) { + (Some((name, _)), Some(fields)) => !fields.borrow().contains_key(name.as_str()), + _ => false, + }; + + let (bound, slot) = if needs_owner { + let wrapper = instance.depth_clone(1); + let synthetic_owner = match (owner_binding, wrapper.as_object()) { + (Some((name, owner_var)), Some(wrapper_fields)) => { + let key: Rc = Rc::from(name.as_str()); + let injected = owner_var.shallow_clone(); + wrapper_fields + .borrow_mut() + .insert(key.clone(), injected.shallow_clone()); + Some((key, injected)) + } + _ => None, + }; + let bound = wrapper.shallow_clone(); + ( + bound, + InstanceSlot::Wrapped { + wrapper, + synthetic_owner, + }, + ) + } else { + (instance.shallow_clone(), InstanceSlot::Direct) + }; + + { + let mut fields = scoped_fields.borrow_mut(); + fields.remove("$"); + fields.insert(self.entity_key.clone(), bound); + } + Some(slot) + } +} + +enum InstanceSlot { + Direct, + Wrapped { + wrapper: Variable, + synthetic_owner: Option<(Rc, Variable)>, + }, +} + +impl InstanceSlot { + fn write_back(&self, instance: &Variable) { + let Self::Wrapped { + wrapper, + synthetic_owner, + } = self + else { + return; + }; + let (Some(written), Some(target)) = (wrapper.as_object(), instance.as_object()) else { + return; + }; + let written = written.borrow(); + let mut target = target.borrow_mut(); + for (key, value) in written.iter() { + if Self::is_injected_owner(synthetic_owner, key, value) { + continue; + } + target.insert(key.clone(), value.shallow_clone()); + } + } + + fn is_injected_owner( + synthetic_owner: &Option<(Rc, Variable)>, + key: &Rc, + value: &Variable, + ) -> bool { + match synthetic_owner { + Some((owner_key, injected)) if owner_key.as_ref() == key.as_ref() => { + Self::same_ref(value, injected) + } + _ => false, + } + } + + fn same_ref(a: &Variable, b: &Variable) -> bool { + match (a, b) { + (Variable::Object(x), Variable::Object(y)) => Rc::ptr_eq(x, y), + (Variable::Array(x), Variable::Array(y)) => Rc::ptr_eq(x, y), + (Variable::String(x), Variable::String(y)) => Rc::ptr_eq(x, y), + _ => a == b, + } + } +} + +impl<'a> Driver<'a> { + fn new( + artifact: &'a EvalArtifact, + store: &'a Variable, + entry: &'a Arc, + trace: bool, + extras: bool, + ) -> Self { + Self { + isolate: Rc::new(RefCell::new( + Isolate::new().with_cache(Some(artifact.opcode_cache.clone())), + )), + artifact, + store, + env: store.depth_clone(1), + entry, + trace, + extras, + ran: HashSet::new(), + in_progress: HashSet::new(), + executions: Vec::new(), + } + } + + fn bind_env(&self, isolate: &RefCell) { + if let Some(fields) = self.env.as_object() { + fields.borrow_mut().remove("$"); + } + isolate + .borrow_mut() + .set_environment(self.env.shallow_clone()); + } + + fn demand(&mut self, prop: &str) -> Result<(), EvaluationError> { + self.writers_of_longest_prefix(prop) + .iter() + .try_for_each(|owner| self.run_block(owner)) + } + + fn writers_of_longest_prefix(&self, prop: &str) -> &'a [BlockRef] { + let graph = &self.artifact.eval_graph; + let direct = graph.demand_writers_for(prop); + if !direct.is_empty() { + return direct; + } + let mut end = prop.len(); + while let Some(dot) = prop[..end].rfind('.') { + let owners = graph.demand_writers_for(&prop[..dot]); + if !owners.is_empty() { + return owners; + } + end = dot; + } + &[] + } + + fn run_block(&mut self, owner: &BlockRef) -> Result<(), EvaluationError> { + if self.ran.contains(owner) || !self.in_progress.insert(owner.clone()) { + return Ok(()); + } + let result = self.run_block_inner(owner); + self.in_progress.remove(owner); + if result.is_ok() { + self.ran.insert(owner.clone()); + } + result + } + + fn run_block_inner(&mut self, owner: &BlockRef) -> Result<(), EvaluationError> { + let artifact = self.artifact; + let Some(rule) = artifact.rule_by_ref.get(owner) else { + return Ok(()); + }; + let rule = rule.clone(); + + if let Some(plan) = artifact.read_plans.get(owner) { + for path in plan.unconditional.iter() { + self.demand(path)?; + } + } + + let iterated = match rule.write_scope(&artifact.classifier) { + WriteScope::Entity(entity) => artifact + .entity_sources + .get(entity.as_ref()) + .map(|src| (entity, src.path.clone(), src.owner.clone())), + _ => None, + }; + + match iterated { + Some((entity, path, src_owner)) => { + self.run_iterated(owner, &rule, entity.as_ref(), &path, src_owner.as_deref()) + } + None => self.run_singleton(owner, &rule), + } + } + + fn select_pick(rule: &Block, ctx: &ExecutionContext) -> Result { + match &rule.kind { + BlockKind::Match(m) => m.select(ctx).map(Pick::Match), + BlockKind::DecisionTable(d) => d.select(ctx).map(Pick::Table), + BlockKind::Expression(_) | BlockKind::Assertion(_) => Ok(Pick::Unconditional), + } + } + + fn commit_pick( + rule: &Block, + ctx: &ExecutionContext, + pick: &Pick, + ) -> Result { + match (&rule.kind, pick) { + (BlockKind::Match(m), Pick::Match(selection)) => m.commit(ctx, selection), + (BlockKind::DecisionTable(d), Pick::Table(selection)) => d.commit(ctx, selection), + _ => rule.execute(ctx), + } + } + + fn run_singleton(&mut self, owner: &BlockRef, rule: &Block) -> Result<(), EvaluationError> { + let artifact = self.artifact; + let write_log = (self.trace && self.extras).then(|| RefCell::new(Vec::new())); + let isolate = Rc::clone(&self.isolate); + let env = self.env.shallow_clone(); + let ctx = ExecutionContext { + store: self.store, + policy_path: &owner.policy_path, + block_id: &rule.id, + trace: self.trace, + extras: self.extras, + write_log: write_log.as_ref(), + env_mirror: Some(&env), + isolate: &isolate, + }; + + if matches!(rule.kind, BlockKind::Match(_) | BlockKind::DecisionTable(_)) { + self.bind_env(&isolate); + } + let pick = Self::select_pick(rule, &ctx)?; + let mut demanded: Vec> = Vec::new(); + if let Some(plan) = artifact.read_plans.get(owner) { + pick.collect_reads(plan, &mut demanded); + } + for path in &demanded { + self.demand(path)?; + } + self.bind_env(&isolate); + let bt = Self::commit_pick(rule, &ctx, &pick)?; + + if self.trace { + let trace_policy_path = + (&owner.policy_path != self.entry).then(|| owner.policy_path.clone()); + let operand_values = + Block::operand_values(self.extras, self.reads_for(owner), self.store); + self.executions.push(BlockExecution { + block_id: rule.id.clone(), + policy_path: trace_policy_path, + instance_path: None, + trace: bt, + operand_values, + writes: write_log.map(RefCell::into_inner).unwrap_or_default(), + reads: self.execution_reads(owner, &pick), + }); + } + Ok(()) + } + + fn run_iterated( + &mut self, + owner: &BlockRef, + rule: &Block, + entity: &str, + iter_path: &Arc, + owner_name: Option<&str>, + ) -> Result<(), EvaluationError> { + let Some(arr) = self + .store + .dot(iter_path.as_ref()) + .and_then(|v| v.as_array()) + else { + return Ok(()); + }; + let instances: Vec = arr.borrow().iter().map(|v| v.shallow_clone()).collect(); + let owner_binding = owner_name.and_then(|name| { + let owner_path = iter_path.rsplit_once('.').map(|(o, _)| o)?; + self.store + .dot(owner_path) + .map(|var| (name.to_string(), var)) + }); + let entity_key: Rc = Rc::from(entity); + let artifact = self.artifact; + + let picks: Vec = + if matches!(rule.kind, BlockKind::Match(_) | BlockKind::DecisionTable(_)) { + let phase = PhaseScope::new(self.store, entity_key.clone()); + let isolate = Rc::clone(&self.isolate); + isolate + .borrow_mut() + .set_environment(phase.scoped.shallow_clone()); + let mut picks = Vec::with_capacity(instances.len()); + for instance in &instances { + let Some(_slot) = phase.bind(instance, &owner_binding) else { + picks.push(Pick::Unconditional); + continue; + }; + let ctx = ExecutionContext { + store: &phase.scoped, + policy_path: &owner.policy_path, + block_id: &rule.id, + trace: self.trace, + extras: self.extras, + write_log: None, + env_mirror: None, + isolate: &isolate, + }; + picks.push(Self::select_pick(rule, &ctx)?); + } + picks + } else { + instances.iter().map(|_| Pick::Unconditional).collect() + }; + + let mut demanded: Vec> = Vec::new(); + if let Some(plan) = artifact.read_plans.get(owner) { + for pick in &picks { + pick.collect_reads(plan, &mut demanded); + } + } + demanded.sort(); + demanded.dedup(); + for path in &demanded { + self.demand(path)?; + } + + let trace_policy_path = + (&owner.policy_path != self.entry).then(|| owner.policy_path.clone()); + let phase = PhaseScope::new(self.store, entity_key); + let isolate = Rc::clone(&self.isolate); + isolate + .borrow_mut() + .set_environment(phase.scoped.shallow_clone()); + for (idx, instance) in instances.iter().enumerate() { + let Some(slot) = phase.bind(instance, &owner_binding) else { + continue; + }; + let write_log = (self.trace && self.extras).then(|| RefCell::new(Vec::new())); + let ctx = ExecutionContext { + store: &phase.scoped, + policy_path: &owner.policy_path, + block_id: &rule.id, + trace: self.trace, + extras: self.extras, + write_log: write_log.as_ref(), + env_mirror: None, + isolate: &isolate, + }; + let result = Self::commit_pick(rule, &ctx, &picks[idx]); + let operand_values = match (&result, self.trace) { + (Ok(_), true) => { + Block::operand_values(self.extras, self.reads_for(owner), &phase.scoped) + } + _ => HashMap::default(), + }; + slot.write_back(instance); + let bt = result?; + if self.trace { + self.executions.push(BlockExecution { + block_id: rule.id.clone(), + policy_path: trace_policy_path.clone(), + instance_path: Some(format!("{iter_path}.{idx}").into()), + trace: bt, + operand_values, + writes: write_log.map(RefCell::into_inner).unwrap_or_default(), + reads: self.execution_reads(owner, &picks[idx]), + }); + } + } + Ok(()) + } + + fn reads_for(&self, owner: &BlockRef) -> &[PropertyRead] { + if self.extras { + self.artifact + .reads + .get(owner) + .map(|r| r.as_ref()) + .unwrap_or(&[]) + } else { + &[] + } + } + + fn execution_reads(&self, owner: &BlockRef, pick: &Pick) -> Vec> { + if !self.extras { + return Vec::new(); + } + let Some(plan) = self.artifact.read_plans.get(owner) else { + return Vec::new(); + }; + let mut reads: Vec> = plan.unconditional.to_vec(); + pick.collect_reads(plan, &mut reads); + reads.sort(); + reads.dedup(); + reads + } +} + +impl Block { + fn operand_values( + extras: bool, + reads: &[PropertyRead], + store: &Variable, + ) -> HashMap, Variable> { + let mut out: HashMap, Variable> = HashMap::new(); + if !extras { + return out; + } + for read in reads { + if read.via_alias || read.unresolved { + continue; + } + if let Some(value) = store.dot(&read.path) { + out.entry(read.path.clone()) + .or_insert_with(|| value.deep_clone()); + } + } + out + } +} + +trait StoreOps { + fn hydrate_references(&self, reference_fields: &[ReferenceField], pool_index: &RefPoolIndex); + fn snapshot(&self, order: &[PropertyPath]) -> HashMap, Variable>; +} + +impl StoreOps for Variable { + fn hydrate_references(&self, reference_fields: &[ReferenceField], pool_index: &RefPoolIndex) { + for field in reference_fields { + let Some(lookup) = pool_index.pool_for(field.target.as_ref()) else { + continue; + }; + let Some(ref_var) = self.dot(field.path.as_ref()) else { + continue; + }; + + if field.array { + let Some(ref_arr) = ref_var.as_array() else { + continue; + }; + let mut borrowed = ref_arr.borrow_mut(); + for slot in borrowed.iter_mut() { + if let Some(id) = slot.as_rc_str() { + if let Some(obj) = lookup.get(&id) { + *slot = obj.shallow_clone(); + } + } + } + } else if let Some(id) = ref_var.as_rc_str() { + if let Some(obj) = lookup.get(&id) { + self.dot_insert(field.path.as_ref(), obj.shallow_clone()); + } + } + } + } + + fn snapshot(&self, order: &[PropertyPath]) -> HashMap, Variable> { + let mut props = HashMap::new(); + for path in order { + if let Some(val) = self.dot(path) { + props.insert(path.clone(), val.deep_clone()); + } + } + props + } +} + +impl From for EvaluationError { + fn from(e: ExecutionError) -> Self { + Self::ExpressionFailed { + policy_path: e.policy_path, + block_id: e.block_id, + expression: e.expression, + source: e.source, + partial_trace: None, + } + } +} + +impl EvaluationError { + fn with_partial_trace(self, trace: Option) -> Self { + match (self, trace) { + ( + EvaluationError::ExpressionFailed { + policy_path, + block_id, + expression, + source, + .. + }, + Some(trace), + ) => EvaluationError::ExpressionFailed { + policy_path, + block_id, + expression, + source, + partial_trace: Some(Box::new(trace)), + }, + (other, _) => other, + } + } +} + +#[cfg(test)] +mod tests { + use super::EvalArtifact; + + const fn assert_send_sync() {} + + #[test] + fn eval_artifact_is_send_sync() { + assert_send_sync::(); + } +} diff --git a/core/engine/src/policy/ir.rs b/core/engine/src/policy/ir.rs new file mode 100644 index 00000000..c555104d --- /dev/null +++ b/core/engine/src/policy/ir.rs @@ -0,0 +1,452 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +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::types::{Diagnostic, DiagnosticCode, DiagnosticLocation, SchemaFieldKind}; +use crate::policy::ArcStrTrim; + +pub type PropertyPath = Arc; + +#[derive(Debug, Clone)] +pub struct Policy { + pub rules: Vec, + pub data_models: Vec, + pub imports: Vec>, +} + +#[derive(Debug, Clone)] +pub struct DataModelBlock { + pub id: Arc, + pub ir: Arc, +} + +#[derive(Debug, Clone)] +pub struct ParsedPolicy { + pub policy: Arc, + pub diagnostics: Arc>, +} + +impl Policy { + pub fn parse(path: &Arc, doc: &PolicyDocument) -> ParsedPolicy { + let mut diagnostics = Vec::new(); + let mut rules = Vec::new(); + let mut data_models = Vec::new(); + + for env in &doc.blocks { + match env { + BlockDoc::Assertion { id, data } => { + rules.push(AssertionIr::parse(id, data, path, &mut diagnostics)) + } + BlockDoc::DecisionTable { id, data } => { + rules.push(DecisionTableIr::parse(id, data, path, &mut diagnostics)) + } + BlockDoc::Expression { id, data } => { + rules.push(ExpressionIr::parse(id, data, path, &mut diagnostics)) + } + BlockDoc::Match { id, data } => { + rules.push(MatchIr::parse(id, data, path, &mut diagnostics)) + } + BlockDoc::DataModel { id, data } => { + if let Some(ir) = DataModelIr::parse(id, data, path, &mut diagnostics) { + data_models.push(DataModelBlock { + id: id.clone(), + ir: Arc::new(ir), + }); + } + } + BlockDoc::Ignored(_) => {} + } + } + + let imports: Vec> = doc + .imports + .iter() + .map(|p| p.trimmed()) + .filter(|p| !p.is_empty()) + .collect(); + + ParsedPolicy { + policy: Arc::new(Policy { + rules, + data_models, + imports, + }), + diagnostics: Arc::new(diagnostics), + } + } + + pub fn rules(&self) -> impl Iterator { + self.rules.iter() + } + + pub fn data_models(&self) -> impl Iterator, &DataModelIr)> { + self.data_models.iter().map(|b| (&b.id, b.ir.as_ref())) + } + + pub fn entity_data_models(&self) -> impl Iterator, &DataModelIr)> { + self.data_models().filter(|(_, dm)| !dm.scope.is_global()) + } + + pub fn global_data_models(&self) -> impl Iterator, &DataModelIr)> { + self.data_models().filter(|(_, dm)| dm.scope.is_global()) + } + + pub fn imports(&self) -> &[Arc] { + &self.imports + } +} + +#[derive(Debug, Clone)] +pub struct DataModelIr { + pub name: Arc, + pub scope: Scope, + pub properties: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + Entity, + Global, +} + +impl Scope { + pub fn is_global(self) -> bool { + matches!(self, Scope::Global) + } +} + +impl From for Scope { + fn from(value: ScopeDoc) -> Self { + match value { + ScopeDoc::Entity => Scope::Entity, + ScopeDoc::Global => Scope::Global, + } + } +} + +#[derive(Debug, Clone)] +pub struct Property { + pub id: Arc, + pub name: Arc, + pub kind: PropertyTypeIr, + pub array: bool, + pub optional: bool, +} + +#[derive(Debug, Clone)] +pub enum PropertyTypeIr { + String, + Enum(Vec>), + Number, + Boolean, + Date, + Relationship { target: Arc }, + Reference { target: Arc }, +} + +impl DataModelIr { + pub(crate) fn classify_roots<'a>( + models: impl IntoIterator, + ) -> (HashSet>, HashSet>) { + let mut entities: HashSet> = HashSet::new(); + let mut relationship_targets: HashSet> = HashSet::new(); + let mut ref_targets: HashSet> = HashSet::new(); + let mut global_relationship_targets: HashSet> = HashSet::new(); + for dm in models { + if !dm.scope.is_global() { + entities.insert(dm.name.clone()); + } + for prop in &dm.properties { + match &prop.kind { + PropertyTypeIr::Relationship { target } => { + if dm.scope.is_global() { + global_relationship_targets.insert(target.clone()); + } else { + relationship_targets.insert(target.clone()); + } + } + PropertyTypeIr::Reference { target } => { + ref_targets.insert(target.clone()); + } + _ => {} + } + } + } + let global_only_relationship: HashSet> = global_relationship_targets + .difference(&relationship_targets) + .filter(|t| !ref_targets.contains(*t)) + .cloned() + .collect(); + let nested: HashSet> = relationship_targets + .union(&ref_targets) + .cloned() + .chain(global_only_relationship.iter().cloned()) + .collect(); + let roots: HashSet> = entities.difference(&nested).cloned().collect(); + (roots, ref_targets) + } + + pub(crate) fn wire_property_type( + prop: &Property, + entities: &HashMap, Arc>, + visited: &mut HashSet>, + ) -> VariableType { + let inner = match &prop.kind { + PropertyTypeIr::String | PropertyTypeIr::Date => VariableType::String, + PropertyTypeIr::Enum(values) => VariableType::Enum(None, enum_values_to_rc(values)), + PropertyTypeIr::Number => VariableType::Number, + PropertyTypeIr::Boolean => VariableType::Bool, + PropertyTypeIr::Reference { .. } => VariableType::String, + PropertyTypeIr::Relationship { target } => Self::wire_object(target, entities, visited), + }; + if prop.array { + inner.array() + } else { + inner + } + } + + pub(crate) fn wire_object( + name: &Arc, + entities: &HashMap, Arc>, + visited: &mut HashSet>, + ) -> VariableType { + if !visited.insert(name.clone()) { + return VariableType::Any; + } + let mut fields: HashMap, VariableType> = HashMap::new(); + if let Some(dm) = entities.get(name) { + for prop in &dm.properties { + fields.insert( + Rc::from(prop.name.as_ref()), + Self::wire_property_type(prop, entities, visited), + ); + } + } + visited.remove(name); + VariableType::Object(Rc::new(RefCell::new(fields))) + } + + fn validate_identifier(name: &str) -> Result<(), &'static str> { + if name.is_empty() { + return Err("is empty"); + } + if name.starts_with(|c: char| c.is_ascii_digit()) { + return Err("starts with a digit"); + } + for c in name.chars() { + if c == '.' { + return Err("contains '.'"); + } + if c == '[' || c == ']' { + return Err("contains a bracket"); + } + if c.is_whitespace() { + return Err("contains whitespace"); + } + } + Ok(()) + } + + pub fn parse( + id: &Arc, + doc: &DataModelDoc, + policy_path: &Arc, + diagnostics: &mut Vec, + ) -> Option { + let name = doc.name.trimmed(); + let scope = Scope::from(doc.scope); + if name.is_empty() && !scope.is_global() { + diagnostics.push(Diagnostic::error( + DiagnosticCode::ParseError, + DiagnosticLocation::block(policy_path.clone(), id.clone()), + "data model is missing a name", + )); + return None; + } + if !name.is_empty() { + if let Err(reason) = Self::validate_identifier(&name) { + diagnostics.push(Diagnostic::error( + DiagnosticCode::InvalidName, + DiagnosticLocation::block(policy_path.clone(), id.clone()), + format!("entity name '{name}' {reason}"), + )); + return None; + } + } + + let mut seen: ahash::HashMap, Arc> = ahash::HashMap::default(); + let mut properties = Vec::with_capacity(doc.properties.len()); + + for prop in &doc.properties { + let prop_name = prop.name.trimmed(); + if prop_name.is_empty() { + diagnostics.push(Diagnostic::error( + DiagnosticCode::ParseError, + DiagnosticLocation::expression( + policy_path.clone(), + id.clone(), + prop.id.clone(), + None, + ), + format!("property in entity '{name}' is missing a name"), + )); + continue; + } + if let Err(reason) = Self::validate_identifier(&prop_name) { + diagnostics.push(Diagnostic::error( + DiagnosticCode::InvalidName, + DiagnosticLocation::expression( + policy_path.clone(), + id.clone(), + prop.id.clone(), + None, + ), + format!("property name '{prop_name}' in entity '{name}' {reason}"), + )); + continue; + } + if let Some(prev_id) = seen.get(&prop_name) { + diagnostics.push(Diagnostic::error( + DiagnosticCode::DuplicateProperty, + DiagnosticLocation::expression( + policy_path.clone(), + id.clone(), + prev_id.clone(), + None, + ), + format!("duplicate property '{prop_name}' in entity '{name}'"), + )); + continue; + } + seen.insert(prop_name.clone(), prop.id.clone()); + + let kind = match &prop.property_type { + PropertyTypeDoc::String { values } => { + let mut trimmed: Vec> = Vec::new(); + let mut duplicates: Vec> = Vec::new(); + if let Some(vs) = values.as_ref() { + for v in vs { + let v = v.trimmed(); + if v.is_empty() { + continue; + } + if trimmed.iter().any(|prev| *prev == v) { + if !duplicates.iter().any(|d| *d == v) { + duplicates.push(v); + } + continue; + } + trimmed.push(v); + } + } + for dup in &duplicates { + diagnostics.push(Diagnostic::error( + DiagnosticCode::DuplicateEnumValue, + DiagnosticLocation::expression( + policy_path.clone(), + id.clone(), + prop.id.clone(), + None, + ), + format!( + "duplicate enum value '{dup}' in property '{prop_name}' of entity '{name}'" + ), + )); + } + if trimmed.is_empty() { + PropertyTypeIr::String + } else { + PropertyTypeIr::Enum(trimmed) + } + } + PropertyTypeDoc::Number => PropertyTypeIr::Number, + PropertyTypeDoc::Boolean => PropertyTypeIr::Boolean, + PropertyTypeDoc::Date => PropertyTypeIr::Date, + PropertyTypeDoc::Relationship { target } => PropertyTypeIr::Relationship { + target: target.trimmed(), + }, + PropertyTypeDoc::Reference { target } => PropertyTypeIr::Reference { + target: target.trimmed(), + }, + }; + + properties.push(Property { + id: prop.id.clone(), + name: prop_name, + kind, + array: prop.array, + optional: prop.optional, + }); + } + + Some(DataModelIr { + name, + scope, + properties, + }) + } +} + +impl PropertyTypeIr { + pub(crate) fn to_schema_field_kind(&self, array: bool) -> SchemaFieldKind { + match self { + PropertyTypeIr::Relationship { target } => SchemaFieldKind::Relationship { + target: target.clone(), + array, + }, + PropertyTypeIr::Reference { target } => SchemaFieldKind::Reference { + target: target.clone(), + array, + }, + PropertyTypeIr::Enum(values) => SchemaFieldKind::Enum { + values: values.clone(), + array, + }, + _ => SchemaFieldKind::Scalar, + } + } + + pub(crate) fn same_shape_as(&self, other: &PropertyTypeIr) -> bool { + use PropertyTypeIr::*; + match (self, other) { + (String, String) | (Number, Number) | (Boolean, Boolean) | (Date, Date) => true, + (Enum(a), Enum(b)) => a == b, + (Relationship { target: a }, Relationship { target: b }) + | (Reference { target: a }, Reference { target: b }) => a == b, + _ => false, + } + } +} + +pub(crate) fn enum_values_to_rc(values: &[Arc]) -> Vec> { + values.iter().map(|v| Rc::from(v.as_ref())).collect() +} + +impl std::fmt::Display for PropertyTypeIr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PropertyTypeIr::String => f.write_str("string"), + PropertyTypeIr::Enum(values) => { + let rendered = values + .iter() + .map(|v| format!("'{v}'")) + .collect::>() + .join(", "); + write!(f, "enum ({rendered})") + } + PropertyTypeIr::Number => f.write_str("number"), + PropertyTypeIr::Boolean => f.write_str("bool"), + PropertyTypeIr::Date => f.write_str("date (string)"), + PropertyTypeIr::Reference { target } => { + write!(f, "reference id (string → {target})") + } + PropertyTypeIr::Relationship { target } => write!(f, "object ({target})"), + } + } +} diff --git a/core/engine/src/policy/linter/mod.rs b/core/engine/src/policy/linter/mod.rs new file mode 100644 index 00000000..d5952a30 --- /dev/null +++ b/core/engine/src/policy/linter/mod.rs @@ -0,0 +1,155 @@ +mod prefer_match; +mod redundant_parentheses; +mod repeated_derivation; +mod table_hygiene; + +use std::sync::Arc; + +use zen_expression::intellisense::AstMetadata; +use zen_expression::parser::Node; + +use crate::policy::blocks::Block; +use crate::policy::db::Db; +use crate::policy::ir::ParsedPolicy; +use crate::policy::types::{Diagnostic, ExpressionKind, Span}; + +pub(crate) use prefer_match::PreferMatch; +pub(crate) use redundant_parentheses::RedundantParentheses; +pub(crate) use repeated_derivation::RepeatedDerivation; +pub(crate) use table_hygiene::{NonDiscriminatingColumn, RedundantTableRow}; + +pub(crate) trait LintRule { + fn check(&self, cx: &LintContext, out: &mut Vec); +} + +pub(crate) struct LintContext<'a> { + db: &'a Db, + target: &'a Arc, + parsed: Arc, +} + +impl LintContext<'_> { + pub(crate) fn target(&self) -> &Arc { + self.target + } + + pub(crate) fn rules(&self) -> impl Iterator { + self.parsed.policy.rules() + } + + pub(crate) fn unit_policies(&self) -> Vec<(Arc, Arc)> { + let unit = self.db.unit(self.target); + let mut members: Vec> = unit.members.iter().cloned().collect(); + members.sort(); + members + .into_iter() + .filter_map(|path| self.db.parsed(&path).map(|parsed| (path, parsed))) + .collect() + } + + pub(crate) fn with_ast( + &self, + source: &str, + kind: ExpressionKind, + f: impl for<'arena> FnOnce(&'arena Node<'arena>, &AstMetadata) -> T, + ) -> Option { + let intellisense = self.db.intellisense(); + let mut intellisense = intellisense.borrow_mut(); + intellisense.with_ast(source, matches!(kind, ExpressionKind::Unary), f) + } +} + +pub(crate) struct AstOps; + +impl AstOps { + pub(crate) fn unwrap_parens<'a>(node: &'a Node<'a>) -> &'a Node<'a> { + match node { + Node::Parenthesized(inner) => Self::unwrap_parens(inner), + _ => node, + } + } + + pub(crate) fn dotted_path(node: &Node) -> Option { + match node { + Node::Identifier(name) => Some((*name).to_string()), + Node::Member { node, property } => { + let base = Self::dotted_path(Self::unwrap_parens(node))?; + match Self::unwrap_parens(property) { + Node::String(p) => Some(format!("{base}.{p}")), + _ => None, + } + } + _ => None, + } + } + + pub(crate) fn span(metadata: &AstMetadata, node: &Node) -> Option { + metadata + .get(&(node as *const Node as usize)) + .map(|m| m.span) + } + + pub(crate) fn fingerprint(source: &str, span: Span) -> String { + Self::chars_at(source, span) + .filter(|c| !c.is_whitespace()) + .collect() + } + + pub(crate) fn display_snippet(source: &str, span: Span) -> String { + let mut out = String::new(); + let mut pending_space = false; + for c in Self::chars_at(source, span) { + if c.is_whitespace() { + pending_space = !out.is_empty(); + } else { + if pending_space { + out.push(' '); + pending_space = false; + } + out.push(c); + } + } + if out.chars().count() > 60 { + let truncated: String = out.chars().take(59).collect(); + return format!("{truncated}…"); + } + out + } + + fn chars_at(source: &str, span: Span) -> impl Iterator + '_ { + source + .chars() + .skip(span.0 as usize) + .take((span.1 as usize).saturating_sub(span.0 as usize)) + } +} + +pub(crate) struct Linter { + rules: Vec>, +} + +impl Linter { + pub(crate) fn standard() -> Self { + Self { + rules: vec![ + Box::new(RepeatedDerivation), + Box::new(PreferMatch), + Box::new(RedundantTableRow), + Box::new(NonDiscriminatingColumn), + Box::new(RedundantParentheses), + ], + } + } + + pub(crate) fn run(&self, db: &Db, target: &Arc) -> Vec { + let Some(parsed) = db.parsed(target) else { + return Vec::new(); + }; + let cx = LintContext { db, target, parsed }; + let mut out = Vec::new(); + for rule in &self.rules { + rule.check(&cx, &mut out); + } + out + } +} diff --git a/core/engine/src/policy/linter/prefer_match.rs b/core/engine/src/policy/linter/prefer_match.rs new file mode 100644 index 00000000..80f93123 --- /dev/null +++ b/core/engine/src/policy/linter/prefer_match.rs @@ -0,0 +1,119 @@ +use std::cell::RefCell; + +use ahash::HashSet; +use zen_expression::intellisense::AstMetadata; +use zen_expression::parser::Node; + +use crate::policy::blocks::BlockKind; +use crate::policy::types::{Diagnostic, DiagnosticCode, DiagnosticLocation, ExpressionKind, Span}; + +use super::{AstOps, LintContext, LintRule}; + +pub(crate) struct PreferMatch; + +struct ChainInfo { + conditions: usize, + scrutinee: Option, + span: Option, +} + +impl PreferMatch { + const MIN_CONDITIONS: usize = 2; + + fn inspect(root: &Node, metadata: &AstMetadata) -> Option { + let node = AstOps::unwrap_parens(root); + let Node::Conditional { + condition, + on_false, + .. + } = node + else { + return None; + }; + + let mut condition_paths = vec![Self::maximal_paths(condition)]; + let mut tail = AstOps::unwrap_parens(on_false); + while let Node::Conditional { + condition, + on_false, + .. + } = tail + { + condition_paths.push(Self::maximal_paths(condition)); + tail = AstOps::unwrap_parens(on_false); + } + if condition_paths.len() < Self::MIN_CONDITIONS { + return None; + } + + let common = condition_paths + .iter() + .skip(1) + .fold(condition_paths[0].clone(), |acc, paths| { + acc.intersection(paths).cloned().collect() + }); + let scrutinee = match common.len() { + 1 => common.into_iter().next(), + _ => None, + }; + + Some(ChainInfo { + conditions: condition_paths.len(), + scrutinee, + span: AstOps::span(metadata, node), + }) + } + + fn maximal_paths(node: &Node) -> HashSet { + let all = RefCell::new(HashSet::default()); + node.walk(|n| { + if let Some(path) = AstOps::dotted_path(n) { + all.borrow_mut().insert(path); + } + }); + let all = all.into_inner(); + all.iter() + .filter(|path| { + !all.iter() + .any(|other| other.len() > path.len() && other.starts_with(&format!("{path}."))) + }) + .cloned() + .collect() + } +} + +impl LintRule for PreferMatch { + fn check(&self, cx: &LintContext, out: &mut Vec) { + for block in cx.rules() { + let BlockKind::Expression(expression) = &block.kind else { + continue; + }; + if expression.value.is_empty() { + continue; + } + let Some(Some(chain)) = + cx.with_ast(&expression.value, ExpressionKind::Standard, Self::inspect) + else { + continue; + }; + + let subject = match &chain.scrutinee { + Some(path) => format!( + "chained ternary with {} conditions on '{path}'", + chain.conditions + ), + None => format!("chained ternary with {} conditions", chain.conditions), + }; + out.push(Diagnostic::hint( + DiagnosticCode::PreferMatch, + DiagnosticLocation::expression( + cx.target().clone(), + block.id.clone(), + block.id.clone(), + chain.span, + ), + format!("{subject} — a match block expresses this more clearly"), + )); + } + } +} diff --git a/core/engine/src/policy/linter/redundant_parentheses.rs b/core/engine/src/policy/linter/redundant_parentheses.rs new file mode 100644 index 00000000..55ef8e78 --- /dev/null +++ b/core/engine/src/policy/linter/redundant_parentheses.rs @@ -0,0 +1,230 @@ +use zen_expression::intellisense::AstMetadata; +use zen_expression::lexer::Operator; +use zen_expression::parser::{Associativity, Node, ParserOperator}; + +use crate::policy::types::{Diagnostic, DiagnosticCode, DiagnosticLocation, ExpressionKind, Span}; + +use super::{AstOps, LintContext, LintRule}; + +pub(crate) struct RedundantParentheses; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Side { + Left, + Right, +} + +#[derive(Clone, Copy)] +enum ParenSite { + Delimited, + Operand { + operator: Operator, + info: &'static ParserOperator, + side: Side, + }, + PostfixBase, + Guarded, +} + +struct ParenScan<'m> { + metadata: &'m AstMetadata, + findings: Vec<(Option, Option)>, +} + +impl ParenScan<'_> { + fn visit(&mut self, node: &Node, site: ParenSite) { + match node { + Node::Parenthesized(inner) => { + if Self::is_redundant(inner, site) { + self.findings.push(( + AstOps::span(self.metadata, node), + AstOps::span(self.metadata, inner), + )); + } + match inner { + Node::Parenthesized(_) => self.visit(inner, site), + _ => self.visit(inner, ParenSite::Delimited), + } + } + Node::Binary { + left, + operator, + right, + } => { + self.visit(left, Self::operand_site(operator, Side::Left)); + self.visit(right, Self::operand_site(operator, Side::Right)); + } + Node::Unary { node, .. } => self.visit(node, ParenSite::Guarded), + Node::Conditional { + condition, + on_true, + on_false, + } => { + self.visit(condition, ParenSite::Guarded); + self.visit(on_true, ParenSite::Guarded); + self.visit(on_false, ParenSite::Guarded); + } + Node::Member { node, property } => { + self.visit(node, ParenSite::PostfixBase); + self.visit(property, ParenSite::Delimited); + } + Node::Slice { node, from, to } => { + self.visit(node, ParenSite::PostfixBase); + from.iter() + .for_each(|n| self.visit(n, ParenSite::Delimited)); + to.iter().for_each(|n| self.visit(n, ParenSite::Delimited)); + } + Node::Interval { left, right, .. } => { + self.visit(left, ParenSite::Delimited); + self.visit(right, ParenSite::Delimited); + } + Node::Array(items) => items + .iter() + .for_each(|n| self.visit(n, ParenSite::Delimited)), + Node::TemplateString(parts) => parts + .iter() + .for_each(|n| self.visit(n, ParenSite::Delimited)), + Node::Object(entries) => entries.iter().for_each(|(k, v)| { + self.visit(k, ParenSite::Delimited); + self.visit(v, ParenSite::Delimited); + }), + Node::Assignments { list, output } => { + list.iter().for_each(|(k, v)| { + self.visit(k, ParenSite::Guarded); + self.visit(v, ParenSite::Delimited); + }); + output + .iter() + .for_each(|n| self.visit(n, ParenSite::Delimited)); + } + Node::FunctionCall { arguments, .. } => arguments + .iter() + .for_each(|n| self.visit(n, ParenSite::Delimited)), + Node::MethodCall { + this, arguments, .. + } => { + self.visit(this, ParenSite::PostfixBase); + arguments + .iter() + .for_each(|n| self.visit(n, ParenSite::Delimited)); + } + Node::Closure { body, .. } => self.visit(body, ParenSite::Delimited), + Node::Error { node, .. } => node.iter().for_each(|n| self.visit(n, ParenSite::Guarded)), + _ => {} + } + } + + fn operand_site(operator: &Operator, side: Side) -> ParenSite { + ParserOperator::binary(operator).map_or(ParenSite::Guarded, |info| ParenSite::Operand { + operator: *operator, + info, + side, + }) + } + + fn is_redundant(inner: &Node, site: ParenSite) -> bool { + match site { + ParenSite::Delimited => Self::is_atom(inner) || Self::is_compound(inner), + ParenSite::Guarded => Self::is_atom(inner), + ParenSite::PostfixBase => matches!( + inner, + Node::Identifier(_) + | Node::Root + | Node::Pointer + | Node::Member { .. } + | Node::Slice { .. } + | Node::FunctionCall { .. } + | Node::MethodCall { .. } + | Node::Parenthesized(_) + ), + ParenSite::Operand { + operator: parent_operator, + info: parent, + side, + } => match inner { + Node::Binary { operator, .. } => { + Self::same_family(*operator, parent_operator) + && ParserOperator::binary(operator).is_some_and(|info| { + info.precedence > parent.precedence + || (info.precedence == parent.precedence + && matches!( + (side, parent.associativity), + (Side::Left, Associativity::Left) + | (Side::Right, Associativity::Right) + )) + }) + } + Node::Unary { operator, .. } => { + Self::same_family(*operator, parent_operator) + && ParserOperator::unary(operator) + .is_some_and(|info| info.precedence > parent.precedence) + } + _ => Self::is_atom(inner), + }, + } + } + + fn is_atom(node: &Node) -> bool { + !matches!( + node, + Node::Binary { .. } + | Node::Unary { .. } + | Node::Conditional { .. } + | Node::Closure { .. } + | Node::Assignments { .. } + | Node::Error { .. } + ) + } + + fn is_compound(node: &Node) -> bool { + matches!( + node, + Node::Binary { .. } | Node::Unary { .. } | Node::Conditional { .. } + ) + } + + fn same_family(a: Operator, b: Operator) -> bool { + a == b || matches!((a, b), (Operator::Arithmetic(_), Operator::Arithmetic(_))) + } +} + +impl LintRule for RedundantParentheses { + fn check(&self, cx: &LintContext, out: &mut Vec) { + for block in cx.rules() { + for expression in block.kind.expressions(&block.id) { + if !matches!(expression.kind, ExpressionKind::Standard) { + continue; + } + let findings = cx + .with_ast(&expression.source, expression.kind, |root, metadata| { + let mut scan = ParenScan { + metadata, + findings: Vec::new(), + }; + scan.visit(root, ParenSite::Delimited); + scan.findings + }) + .unwrap_or_default(); + for (span, inner_span) in findings { + let message = match inner_span { + Some(inner) => format!( + "unnecessary parentheses around '{}'", + AstOps::display_snippet(&expression.source, inner) + ), + None => "unnecessary parentheses".to_string(), + }; + out.push(Diagnostic::hint( + DiagnosticCode::RedundantParentheses, + DiagnosticLocation::expression( + cx.target().clone(), + block.id.clone(), + expression.expression_id.clone(), + span, + ), + message, + )); + } + } + } + } +} diff --git a/core/engine/src/policy/linter/repeated_derivation.rs b/core/engine/src/policy/linter/repeated_derivation.rs new file mode 100644 index 00000000..c3cfbf06 --- /dev/null +++ b/core/engine/src/policy/linter/repeated_derivation.rs @@ -0,0 +1,193 @@ +use std::cell::RefCell; +use std::sync::Arc; + +use ahash::{HashMap, HashMapExt, HashSet}; +use zen_expression::parser::Node; + +use crate::policy::blocks::BlockKind; +use crate::policy::types::{Diagnostic, DiagnosticCode, DiagnosticLocation, ExpressionKind, Span}; + +use super::{AstOps, LintContext, LintRule}; + +pub(crate) struct RepeatedDerivation; + +type SiteKey = (Arc, Arc, Arc); + +struct Occurrence { + site: SiteKey, + span: Span, +} + +struct Fragment { + ops: usize, + display: String, + policies: HashSet>, + occurrences: Vec, +} + +impl RepeatedDerivation { + const SIMPLE_THRESHOLD: usize = 3; + const COMPLEX_THRESHOLD: usize = 2; + const COMPLEX_OPS: usize = 3; + const MIN_FINGERPRINT_LEN: usize = 6; + + fn is_op(node: &Node) -> bool { + match node { + Node::Binary { .. } + | Node::Unary { .. } + | Node::Conditional { .. } + | Node::FunctionCall { .. } + | Node::MethodCall { .. } + | Node::Interval { .. } + | Node::Slice { .. } + | Node::TemplateString(_) => true, + Node::Object(entries) => !entries.is_empty(), + Node::Array(items) => !items.is_empty(), + _ => false, + } + } + + fn op_count(node: &Node) -> usize { + let count = RefCell::new(0usize); + node.walk(|n| { + if Self::is_op(n) { + *count.borrow_mut() += 1; + } + }); + count.into_inner() + } + + fn threshold(ops: usize) -> usize { + if ops >= Self::COMPLEX_OPS { + Self::COMPLEX_THRESHOLD + } else { + Self::SIMPLE_THRESHOLD + } + } + + fn collect(cx: &LintContext) -> HashMap { + let mut fragments: HashMap = HashMap::new(); + + for (policy_path, parsed) in cx.unit_policies() { + for block in parsed.policy.rules() { + if matches!(block.kind, BlockKind::DecisionTable(_)) { + continue; + } + for location in block.kind.expressions(&block.id) { + if !matches!(location.kind, ExpressionKind::Standard) { + continue; + } + let site: SiteKey = ( + policy_path.clone(), + location.block_id.clone(), + location.expression_id.clone(), + ); + let found = cx + .with_ast(&location.source, location.kind, |root, metadata| { + let collected = RefCell::new(Vec::new()); + root.walk(|node| { + if !Self::is_op(node) { + return; + } + let Some(span) = AstOps::span(metadata, node) else { + return; + }; + collected.borrow_mut().push((span, Self::op_count(node))); + }); + collected.into_inner() + }) + .unwrap_or_default(); + + for (span, ops) in found { + let key = AstOps::fingerprint(&location.source, span); + if key.chars().count() < Self::MIN_FINGERPRINT_LEN { + continue; + } + let fragment = fragments.entry(key).or_insert_with(|| Fragment { + ops, + display: AstOps::display_snippet(&location.source, span), + policies: HashSet::default(), + occurrences: Vec::new(), + }); + fragment.policies.insert(policy_path.clone()); + fragment.occurrences.push(Occurrence { + site: site.clone(), + span, + }); + } + } + } + } + + fragments + } +} + +impl LintRule for RepeatedDerivation { + fn check(&self, cx: &LintContext, out: &mut Vec) { + let fragments = Self::collect(cx); + + let mut ordered: Vec<(String, Fragment)> = fragments + .into_iter() + .filter(|(_, f)| f.occurrences.len() >= Self::threshold(f.ops)) + .collect(); + ordered.sort_by(|a, b| { + b.1.ops + .cmp(&a.1.ops) + .then_with(|| b.0.len().cmp(&a.0.len())) + .then_with(|| a.0.cmp(&b.0)) + }); + + let mut covered: HashMap> = HashMap::new(); + for (_, fragment) in ordered { + let live: Vec<&Occurrence> = fragment + .occurrences + .iter() + .filter(|occ| { + covered.get(&occ.site).is_none_or(|spans| { + !spans + .iter() + .any(|outer| outer.0 <= occ.span.0 && occ.span.1 <= outer.1) + }) + }) + .collect(); + if live.len() < Self::threshold(fragment.ops) { + continue; + } + + let count = live.len(); + let message = if fragment.policies.len() > 1 { + format!( + "'{}' is derived {count} times across {} policies — compute it once into a shared property and reuse that", + fragment.display, + fragment.policies.len() + ) + } else { + format!( + "'{}' is derived {count} times — compute it once into a dedicated property and reuse that", + fragment.display + ) + }; + + for occ in &live { + if occ.site.0 != *cx.target() { + continue; + } + out.push(Diagnostic::hint( + DiagnosticCode::RepeatedDerivation, + DiagnosticLocation::expression( + occ.site.0.clone(), + occ.site.1.clone(), + occ.site.2.clone(), + Some(occ.span), + ), + message.clone(), + )); + } + + for occ in live { + covered.entry(occ.site.clone()).or_default().push(occ.span); + } + } + } +} diff --git a/core/engine/src/policy/linter/table_hygiene.rs b/core/engine/src/policy/linter/table_hygiene.rs new file mode 100644 index 00000000..4586e460 --- /dev/null +++ b/core/engine/src/policy/linter/table_hygiene.rs @@ -0,0 +1,235 @@ +use std::sync::Arc; + +use ahash::HashSet; + +use crate::policy::blocks::{BlockKind, DecisionTableIr}; +use crate::policy::types::{Diagnostic, DiagnosticCode, DiagnosticLocation}; + +use super::{LintContext, LintRule}; + +pub(crate) struct RedundantTableRow; + +pub(crate) struct NonDiscriminatingColumn; + +struct TableView { + inputs: Vec<(Arc, Arc)>, + rows: Vec, +} + +struct RowView { + inputs: Vec, + outputs: Vec, +} + +impl TableView { + fn first_hit(table: &DecisionTableIr) -> Option { + if table.outputs.is_empty() || table.outputs.iter().any(|o| o.collect) { + return None; + } + let inputs: Vec<(Arc, Arc)> = table + .inputs + .iter() + .map(|col| { + let label = if col.name.is_empty() { + col.field.clone().unwrap_or_else(|| Arc::from("")) + } else { + col.name.clone() + }; + (col.id.clone(), label) + }) + .collect(); + if inputs.is_empty() { + return None; + } + let rows = table + .rules + .iter() + .map(|rule| RowView { + inputs: inputs.iter().map(|(id, _)| Self::cell(rule, id)).collect(), + outputs: table + .outputs + .iter() + .map(|col| Self::cell(rule, &col.id)) + .collect(), + }) + .collect(); + Some(Self { inputs, rows }) + } + + fn cell(rule: &ahash::HashMap, Arc>, id: &Arc) -> String { + rule.get(id) + .map(|c| c.trim().to_string()) + .unwrap_or_default() + } + + fn shadows(earlier: &RowView, later: &RowView) -> bool { + earlier + .inputs + .iter() + .zip(&later.inputs) + .all(|(e, l)| e.is_empty() || e == l) + } +} + +impl LintRule for RedundantTableRow { + fn check(&self, cx: &LintContext, out: &mut Vec) { + for block in cx.rules() { + let BlockKind::DecisionTable(table) = &block.kind else { + continue; + }; + let Some(view) = TableView::first_hit(table) else { + continue; + }; + + for later_idx in 1..view.rows.len() { + let later = &view.rows[later_idx]; + let Some(earlier_idx) = + (0..later_idx).find(|&i| TableView::shadows(&view.rows[i], later)) + else { + continue; + }; + let earlier = &view.rows[earlier_idx]; + let message = if earlier.inputs == later.inputs && earlier.outputs == later.outputs + { + format!( + "row {} duplicates row {} — remove it", + later_idx + 1, + earlier_idx + 1 + ) + } else { + format!( + "row {} is unreachable — row {} already matches every case it matches", + later_idx + 1, + earlier_idx + 1 + ) + }; + out.push(Diagnostic::hint( + DiagnosticCode::RedundantTableRow, + DiagnosticLocation::block(cx.target().clone(), block.id.clone()), + message, + )); + } + } + } +} + +impl LintRule for NonDiscriminatingColumn { + fn check(&self, cx: &LintContext, out: &mut Vec) { + for block in cx.rules() { + let BlockKind::DecisionTable(table) = &block.kind else { + continue; + }; + let Some(view) = TableView::first_hit(table) else { + continue; + }; + if view.rows.len() < 2 { + continue; + } + + for (col_idx, (_, name)) in view.inputs.iter().enumerate() { + let label = if name.is_empty() { + format!("#{}", col_idx + 1) + } else { + format!("'{name}'") + }; + if view.rows.iter().all(|r| r.inputs[col_idx].is_empty()) { + out.push(Diagnostic::hint( + DiagnosticCode::NonDiscriminatingColumn, + DiagnosticLocation::block(cx.target().clone(), block.id.clone()), + format!("input column {label} has no conditions — remove it"), + )); + continue; + } + if Self::never_affects_outcome(&view, col_idx) { + out.push(Diagnostic::hint( + DiagnosticCode::NonDiscriminatingColumn, + DiagnosticLocation::block(cx.target().clone(), block.id.clone()), + format!( + "input column {label} never changes the outcome — rows differing only in this column produce identical results; remove it and dedupe the rows" + ), + )); + } + } + } + } +} + +impl NonDiscriminatingColumn { + fn never_affects_outcome(view: &TableView, col_idx: usize) -> bool { + let mut groups: Vec<(Vec<&str>, Vec)> = Vec::new(); + for (row_idx, row) in view.rows.iter().enumerate() { + let key: Vec<&str> = row + .inputs + .iter() + .enumerate() + .filter(|(i, _)| *i != col_idx) + .map(|(_, cell)| cell.as_str()) + .collect(); + match groups.iter_mut().find(|(k, _)| *k == key) { + Some((_, members)) => members.push(row_idx), + None => groups.push((key, vec![row_idx])), + } + } + + let mut merges_anything = false; + for (key, members) in &groups { + let outputs = &view.rows[members[0]].outputs; + if members + .iter() + .any(|&idx| view.rows[idx].outputs != *outputs) + { + return false; + } + if members.len() == 1 { + if !view.rows[members[0]].inputs[col_idx].is_empty() { + return false; + } + continue; + } + let has_wildcard_member = members + .iter() + .any(|&idx| view.rows[idx].inputs[col_idx].is_empty()); + if !has_wildcard_member + && !Self::fall_through_preserved(view, col_idx, key, members, outputs) + { + return false; + } + let distinct: HashSet<&str> = members + .iter() + .map(|&idx| view.rows[idx].inputs[col_idx].as_str()) + .collect(); + if distinct.len() > 1 { + merges_anything = true; + } + } + merges_anything + } + + fn fall_through_preserved( + view: &TableView, + col_idx: usize, + key: &[&str], + members: &[usize], + outputs: &[String], + ) -> bool { + for (idx, row) in view.rows.iter().enumerate() { + let others_subsume = row + .inputs + .iter() + .enumerate() + .filter(|(j, _)| *j != col_idx) + .zip(key) + .all(|((_, cell), k)| cell.is_empty() || cell.as_str() == *k); + if !others_subsume { + continue; + } + if row.inputs[col_idx].is_empty() { + return row.outputs == outputs; + } + if !members.contains(&idx) { + return false; + } + } + false + } +} diff --git a/core/engine/src/policy/mod.rs b/core/engine/src/policy/mod.rs new file mode 100644 index 00000000..c0bcdd8b --- /dev/null +++ b/core/engine/src/policy/mod.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +pub(crate) const MAX_RECURSION_DEPTH: usize = 32; + +pub(crate) mod blocks; +pub(crate) mod db; +pub(crate) mod editor; +pub(crate) mod evaluator; +pub(crate) mod ir; +pub(crate) mod linter; +pub(crate) mod queries; +pub(crate) mod raw; +pub(crate) mod refs; +pub(crate) mod runtime; +mod types; +pub(crate) mod validator; +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, OutputProperty, PrepareRename, PropertyKind, ReferenceKind, ReferenceSite, + RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest, Severity, Span, Trace, WriteConflict, + WriteTrace, +}; +pub use workspace::PolicyWorkspace; + +pub(crate) trait ArcStrTrim { + fn trimmed(&self) -> Self; +} + +impl ArcStrTrim for Arc { + fn trimmed(&self) -> Self { + let trimmed = self.trim(); + if trimmed.len() == self.len() { + self.clone() + } else { + Arc::from(trimmed) + } + } +} diff --git a/core/engine/src/policy/queries/components.rs b/core/engine/src/policy/queries/components.rs new file mode 100644 index 00000000..a0b51ee1 --- /dev/null +++ b/core/engine/src/policy/queries/components.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use ahash::{HashMap, HashMapExt, HashSet}; + +use crate::policy::db::Db; +use crate::policy::types::WriteConflict; + +impl Db { + pub fn component_members(&self, policy: &str) -> Vec> { + let snap = self.snapshot(); + match snap.policy_to_component.get(policy) { + Some(&idx) => snap.components.get(idx).cloned().unwrap_or_default(), + None => Vec::new(), + } + } + + pub fn cross_component_write_conflicts(&self) -> Vec { + let snap = self.snapshot(); + let mut by_path: HashMap, Vec<(Arc, usize)>> = HashMap::new(); + for rule in &snap.shallow.per_rule { + let Some(&component) = snap.policy_to_component.get(&rule.policy_path) else { + continue; + }; + for write in &rule.writes { + if write.path.is_empty() { + continue; + } + by_path + .entry(write.path.clone()) + .or_default() + .push((rule.policy_path.clone(), component)); + } + } + + let mut conflicts: Vec = by_path + .into_iter() + .filter_map(|(path, writers)| { + let components: HashSet = writers.iter().map(|(_, c)| *c).collect(); + if components.len() < 2 { + return None; + } + let mut policies: Vec> = writers.into_iter().map(|(p, _)| p).collect(); + policies.sort(); + policies.dedup(); + Some(WriteConflict { path, policies }) + }) + .collect(); + conflicts.sort_by(|a, b| a.path.cmp(&b.path)); + conflicts + } +} diff --git a/core/engine/src/policy/queries/conditional.rs b/core/engine/src/policy/queries/conditional.rs new file mode 100644 index 00000000..bdf7408e --- /dev/null +++ b/core/engine/src/policy/queries/conditional.rs @@ -0,0 +1,357 @@ +use std::rc::Rc; +use std::sync::Arc; + +use ahash::{HashMap, HashMapExt, HashSet, HashSetExt}; +use zen_expression::intellisense::ArmTest; +use zen_expression::variable::VariableType; + +use crate::policy::blocks::{BlockKind, IntelliSenseSource, ReadFlattener}; +use crate::policy::db::{Db, Unit}; +use crate::policy::queries::dependency::PathPrefix; +use crate::policy::queries::scope::VariableTypeScope; +use crate::policy::types::{ + ConditionalSchema, DiscriminantVariant, DiscriminatedUnion, ExpressionKind, GuardedProperty, + InputProperty, OutputProperty, SchemaGroup, ScopeRequest, +}; + +struct ArmInfo { + id: Arc, + condition: Arc, + test: ArmTest, + value_reads: Vec>, + discriminant_value: Option>, +} + +struct MatchBlockInfo { + arms: Vec, +} + +struct DiscriminantInfo<'a> { + block: &'a MatchBlockInfo, + property: Arc, + resolved_type: VariableType, +} + +impl Db { + pub fn conditional_schema(&self, req: &ScopeRequest) -> ConditionalSchema { + let unit = self.unit(&req.policy_path); + let inputs = self.inputs(req); + let outputs = self.outputs(req); + let blocks = self.collect_match_blocks(&unit); + + match self.clean_discriminant(&unit, &blocks) { + Some(disc) => self.build_union(req, &unit, &disc, &inputs, &outputs), + None => self.build_flat(req, &unit, &blocks, &inputs, &outputs), + } + } + + fn collect_match_blocks(&self, unit: &Unit) -> Vec { + let mut members: Vec<&Arc> = unit.members.iter().collect(); + members.sort(); + let mut blocks = Vec::new(); + for pp in members { + let Some(parsed) = self.parsed(pp) else { + continue; + }; + for rule in parsed.policy.rules() { + let BlockKind::Match(m) = &rule.kind else { + continue; + }; + if m.key.is_empty() { + continue; + } + let arms = m + .arms + .iter() + .filter(|arm| !arm.condition.is_empty()) + .map(|arm| { + let test = IntelliSenseSource::arm_test( + &mut self.intellisense().borrow_mut(), + &arm.condition, + ); + let discriminant_value = Self::discriminant_value(&test); + ArmInfo { + id: arm.id.clone(), + condition: arm.condition.clone(), + test, + value_reads: self.flatten_reads(&arm.value), + discriminant_value, + } + }) + .collect(); + blocks.push(MatchBlockInfo { arms }); + } + } + blocks + } + + fn discriminant_value(test: &ArmTest) -> Option> { + match test { + ArmTest::Enum { values, .. } if values.len() == 1 => { + Some(Arc::from(values[0].as_ref())) + } + ArmTest::Bool { values, .. } if values.len() == 1 => { + Some(Arc::from(if values[0] { "true" } else { "false" })) + } + _ => None, + } + } + + fn clean_discriminant<'a>( + &self, + unit: &Unit, + blocks: &'a [MatchBlockInfo], + ) -> Option> { + let mut clean: Vec> = Vec::new(); + for block in blocks { + let Some(path) = Self::shared_enum_bool_path(&block.arms) else { + continue; + }; + let dotted: Arc = Arc::from( + path.iter() + .map(|s| s.as_ref()) + .collect::>() + .join("."), + ); + if !self.is_input_derived(unit, &dotted) { + continue; + } + let resolved_type = self.resolve_in_unit(unit, &dotted); + if !matches!(resolved_type, VariableType::Enum(..) | VariableType::Bool) { + continue; + } + clean.push(DiscriminantInfo { + block, + property: dotted, + resolved_type, + }); + } + match clean.len() { + 1 => clean.into_iter().next(), + _ => None, + } + } + + fn shared_enum_bool_path(arms: &[ArmInfo]) -> Option>> { + if arms.is_empty() { + return None; + } + let mut shared: Option<&Vec>> = None; + for arm in arms { + let path = match &arm.test { + ArmTest::Enum { path, .. } | ArmTest::Bool { path, .. } => path, + _ => return None, + }; + match shared { + None => shared = Some(path), + Some(p) if p == path => {} + Some(_) => return None, + } + } + shared.cloned() + } + + fn is_input_derived(&self, unit: &Unit, path: &str) -> bool { + match unit.dep_graph.node_map.get(path) { + Some(&idx) => match &unit.dep_graph.graph[idx].written_by { + None => true, + Some(owner) => !unit.members.contains(&owner.policy_path), + }, + None => true, + } + } + + fn resolve_in_unit(&self, unit: &Unit, path: &str) -> VariableType { + let enriched = self.enriched_of_unit(unit); + let resolved_type = enriched.scope.resolve_at(path); + let (resolved, _) = resolved_type.unwrap_nullable(); + resolved.shallow_clone() + } + + fn flatten_reads(&self, src: &Arc) -> Vec> { + if src.is_empty() { + return Vec::new(); + } + let analysis = IntelliSenseSource::reads_only( + &mut self.intellisense().borrow_mut(), + src, + ExpressionKind::Standard, + ); + let mut out = Vec::new(); + ReadFlattener::extend_from_deps(&analysis.reads, &None, &mut out); + out.into_iter() + .filter(|r| !r.via_alias && !r.unresolved) + .map(|r| r.path) + .collect() + } + + fn schema_roots(&self, unit: &Unit, req: &ScopeRequest) -> Vec> { + if !req.goals.is_empty() { + return req.goals.clone(); + } + unit.dep_graph + .computed_in(&unit.members) + .map(|(path, _, _)| path.clone()) + .collect() + } + + fn build_union( + &self, + req: &ScopeRequest, + unit: &Unit, + disc: &DiscriminantInfo, + inputs: &[InputProperty], + outputs: &[OutputProperty], + ) -> ConditionalSchema { + let graph = &unit.dep_graph; + let r_all = graph.reachable_from(&self.schema_roots(unit, req)); + + let cones: Vec>> = disc + .block + .arms + .iter() + .map(|arm| graph.reachable_from(&arm.value_reads)) + .collect(); + + let mut forced: HashSet> = HashSet::new(); + forced.insert(disc.property.clone()); + for arm in &disc.block.arms { + forced.extend(self.flatten_reads(&arm.condition)); + } + let inter = Self::intersection(&cones); + let union: HashSet> = cones.iter().flatten().cloned().collect(); + + let common_set: HashSet> = r_all + .iter() + .filter(|p| !union.contains(*p)) + .cloned() + .chain(inter) + .chain(forced) + .collect(); + + let variants = disc + .block + .arms + .iter() + .zip(&cones) + .map(|(arm, cone)| { + let variant_set: HashSet> = + cone.difference(&common_set).cloned().collect(); + DiscriminantVariant { + value: arm.discriminant_value.clone(), + arm: arm.id.clone(), + group: Self::group_for(&variant_set, inputs, outputs, |_| None), + } + }) + .collect(); + + ConditionalSchema::Union { + common: Self::group_for(&common_set, inputs, outputs, |_| None), + union: DiscriminatedUnion { + property: disc.property.clone(), + resolved_type: disc.resolved_type.shallow_clone(), + variants, + }, + } + } + + fn build_flat( + &self, + req: &ScopeRequest, + unit: &Unit, + blocks: &[MatchBlockInfo], + inputs: &[InputProperty], + outputs: &[OutputProperty], + ) -> ConditionalSchema { + let graph = &unit.dep_graph; + let r_all = graph.reachable_from(&self.schema_roots(unit, req)); + + let mut union: HashSet> = HashSet::new(); + let mut forced: HashSet> = HashSet::new(); + let mut guards: HashMap, Vec>> = HashMap::new(); + for block in blocks { + for arm in &block.arms { + forced.extend(self.flatten_reads(&arm.condition)); + for path in graph.reachable_from(&arm.value_reads) { + union.insert(path.clone()); + guards.entry(path).or_default().push(arm.condition.clone()); + } + } + } + + let common_set: HashSet> = r_all + .iter() + .filter(|p| !union.contains(*p)) + .cloned() + .chain(forced) + .collect(); + let conditional_set: HashSet> = union.difference(&common_set).cloned().collect(); + + let guard_for = |path: &str| -> Option> { + let mut conditions: Vec<&Arc> = guards + .iter() + .filter(|(p, _)| PathPrefix::extends(path, p) || PathPrefix::extends(p, path)) + .flat_map(|(_, c)| c.iter()) + .collect(); + conditions.sort(); + conditions.dedup(); + (!conditions.is_empty()).then(|| { + Arc::from( + conditions + .iter() + .map(|c| c.as_ref()) + .collect::>() + .join(" or "), + ) + }) + }; + + ConditionalSchema::Flat { + common: Self::group_for(&common_set, inputs, outputs, |_| None), + conditional: Self::group_for(&conditional_set, inputs, outputs, guard_for), + } + } + + fn intersection(cones: &[HashSet>]) -> HashSet> { + let Some((first, rest)) = cones.split_first() else { + return HashSet::new(); + }; + first + .iter() + .filter(|p| rest.iter().all(|c| c.contains(*p))) + .cloned() + .collect() + } + + fn group_for( + set: &HashSet>, + inputs: &[InputProperty], + outputs: &[OutputProperty], + guard: impl Fn(&str) -> Option>, + ) -> SchemaGroup { + let in_set = |path: &str| { + set.iter() + .any(|p| PathPrefix::extends(path, p) || PathPrefix::extends(p, path)) + }; + SchemaGroup { + inputs: inputs + .iter() + .filter(|i| in_set(&i.path)) + .map(|i| GuardedProperty { + path: i.path.clone(), + resolved_type: i.resolved_type.shallow_clone(), + required_when: guard(&i.path), + }) + .collect(), + outputs: outputs + .iter() + .filter(|o| in_set(&o.path)) + .map(|o| GuardedProperty { + path: o.path.clone(), + resolved_type: o.resolved_type.shallow_clone(), + required_when: guard(&o.path), + }) + .collect(), + } + } +} diff --git a/core/engine/src/policy/queries/dependencies.rs b/core/engine/src/policy/queries/dependencies.rs new file mode 100644 index 00000000..b65dc643 --- /dev/null +++ b/core/engine/src/policy/queries/dependencies.rs @@ -0,0 +1,274 @@ +use std::sync::Arc; + +use ahash::{HashMap, HashSet, HashSetExt}; + +use crate::policy::blocks::{IntelliSenseSource, ReadFlattener}; +use crate::policy::db::{Db, Snapshot}; +use crate::policy::queries::scope::{EntityForm, VariableTypeScope}; +use crate::policy::types::{BlockRef, DependencyNode}; +use zen_expression::variable::VariableType; + +impl Db { + pub fn dependencies(&self, target: &str) -> DependencyNode { + let snapshot = self.snapshot(); + let unit = self.unit_for_property(target); + let entity_form = EntityForm::new(&unit.entity_sources); + let enriched = self.enriched_of_unit(&unit); + let scope = &enriched.scope; + let mut visited: HashSet> = HashSet::new(); + let mut expr_ids_cache: HashMap, HashSet>>> = + HashMap::default(); + let mut node_cache: HashMap, DependencyNode> = HashMap::default(); + + if unit.dep_graph.writer_for(target).is_none() { + if let Some(node) = self.field_dependency_node( + &unit.dep_graph, + &snapshot, + &entity_form, + scope, + target, + &mut visited, + &mut expr_ids_cache, + &mut node_cache, + ) { + return node; + } + } + + let (node, _tainted) = Self::build_dep_node( + &unit.dep_graph, + &snapshot.shallow, + &snapshot.rule_by_ref, + &entity_form, + scope, + target, + false, + &mut visited, + &mut expr_ids_cache, + &mut node_cache, + ); + node + } + + #[allow(clippy::too_many_arguments)] + fn field_dependency_node( + &self, + graph: &crate::policy::queries::dependency::DependencyGraph, + snapshot: &Snapshot, + entity_form: &EntityForm, + scope: &VariableType, + target: &str, + visited: &mut HashSet>, + expr_ids_cache: &mut HashMap, HashSet>>>, + node_cache: &mut HashMap, DependencyNode>, + ) -> Option { + let target_type = scope.resolve_at(target).to_acyclic(); + let segments: Vec<&str> = target.split('.').collect(); + if segments.len() < 2 { + return None; + } + let (prefix, owner, tail_start) = (1..segments.len()).rev().find_map(|i| { + let prefix = segments[..i].join("."); + graph + .writer_for(&prefix) + .cloned() + .map(|owner| (prefix, owner, i)) + })?; + let tail: Vec<&str> = segments[tail_start..].to_vec(); + + let Some(block) = snapshot.rule_by_ref.get(&owner) else { + return None; + }; + + let undecomposable = DependencyNode { + property: Arc::from(target), + written_by: Some(owner.clone()), + unresolved: true, + resolved_type: target_type.clone(), + deps: Vec::new(), + }; + + let value_exprs = block.kind.write_value_expressions(&prefix); + if value_exprs.is_empty() { + return Some(undecomposable); + } + + let mut flat: Vec = Vec::new(); + let mut navigated = false; + { + let is = self.intellisense(); + let mut is = is.borrow_mut(); + for expr in &value_exprs { + if let Some(reads) = IntelliSenseSource::field_reads(&mut is, expr, &tail) { + navigated = true; + ReadFlattener::extend_from_deps(&reads, &None, &mut flat); + } + } + } + if !navigated { + return Some(undecomposable); + } + + let mut seen: HashSet> = HashSet::new(); + let mut deps: Vec = Vec::new(); + for read in flat { + if read.path.as_ref() == "$" || !seen.insert(read.path.clone()) { + continue; + } + let (child, _tainted) = Self::build_dep_node( + graph, + &snapshot.shallow, + &snapshot.rule_by_ref, + entity_form, + scope, + &read.path, + read.unresolved, + visited, + expr_ids_cache, + node_cache, + ); + deps.push(child); + } + deps.sort_by(|a, b| a.property.cmp(&b.property)); + + Some(DependencyNode { + property: Arc::from(target), + written_by: Some(owner), + unresolved: false, + resolved_type: target_type, + deps, + }) + } + + #[allow(clippy::too_many_arguments)] + fn build_dep_node( + graph: &crate::policy::queries::dependency::DependencyGraph, + shallow: &crate::policy::queries::dependency::ShallowAnalyses, + rule_by_ref: &HashMap>, + entity_form: &crate::policy::queries::scope::EntityForm, + scope: &VariableType, + target: &str, + target_unresolved: bool, + visited: &mut HashSet>, + expr_ids_cache: &mut HashMap, HashSet>>>, + node_cache: &mut HashMap, DependencyNode>, + ) -> (DependencyNode, bool) { + let property: Arc = if graph.writer_for(target).is_some() { + Arc::from(target) + } else { + match entity_form.rewrite(target) { + Some(ef) if graph.writer_for(&ef).is_some() => Arc::from(ef), + _ => Arc::from(target), + } + }; + if let Some(cached) = node_cache.get(&property) { + return (cached.clone(), false); + } + let written_by = graph.writer_for(&property).cloned(); + + let unresolved = written_by.is_none() && target_unresolved; + + let resolved_type = graph + .node_map + .get(property.as_ref()) + .map(|&idx| graph.graph[idx].resolved_type_in(scope, &property)) + .unwrap_or_else(|| scope.resolve_at(&property).to_acyclic()); + + if !visited.insert(property.clone()) { + return ( + DependencyNode { + property, + written_by, + unresolved, + resolved_type, + deps: Vec::new(), + }, + true, + ); + } + let Some(owner) = &written_by else { + visited.remove(&property); + let node = DependencyNode { + property: property.clone(), + written_by: None, + unresolved, + resolved_type, + deps: Vec::new(), + }; + node_cache.insert(property, node.clone()); + return (node, false); + }; + + let block_expr_ids = expr_ids_cache.entry(owner.clone()).or_insert_with(|| { + rule_by_ref + .get(owner) + .map(|block| block.kind.write_dependency_expr_ids()) + .unwrap_or_default() + }); + let unfiltered = block_expr_ids.is_empty(); + let allowed: HashSet> = match block_expr_ids.get(&property) { + Some(ids) => ids.clone(), + None => block_expr_ids + .iter() + .filter(|(k, _)| { + crate::policy::queries::dependency::PathPrefix::extends(&property, k) + }) + .flat_map(|(_, ids)| ids.iter().cloned()) + .collect(), + }; + + let direct_paths: Vec<(Arc, bool)> = shallow + .for_block(owner) + .map(|r| { + let mut by_path: HashMap, bool> = HashMap::default(); + for read in r.reads.iter().filter(|read| match &read.expression_id { + _ if unfiltered => true, + Some(id) => allowed.contains(id), + None => true, + }) { + by_path + .entry(read.path.clone()) + .and_modify(|u| *u = *u && read.unresolved) + .or_insert(read.unresolved); + } + let mut out: Vec<(Arc, bool)> = by_path.into_iter().collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + }) + .unwrap_or_default(); + + let mut tainted = false; + let deps: Vec = direct_paths + .into_iter() + .map(|(path, child_unresolved)| { + let (child, child_tainted) = Self::build_dep_node( + graph, + shallow, + rule_by_ref, + entity_form, + scope, + &path, + child_unresolved, + visited, + expr_ids_cache, + node_cache, + ); + tainted |= child_tainted; + child + }) + .collect(); + + visited.remove(&property); + let node = DependencyNode { + property: property.clone(), + written_by, + unresolved, + resolved_type, + deps, + }; + if !tainted { + node_cache.insert(property, node.clone()); + } + (node, tainted) + } +} diff --git a/core/engine/src/policy/queries/dependency.rs b/core/engine/src/policy/queries/dependency.rs new file mode 100644 index 00000000..15579505 --- /dev/null +++ b/core/engine/src/policy/queries/dependency.rs @@ -0,0 +1,765 @@ +use std::sync::Arc; + +use ahash::{HashMap, HashMapExt, HashSet, HashSetExt}; +use petgraph::algo::{tarjan_scc, toposort}; +use petgraph::prelude::{NodeIndex, StableDiGraph}; +use zen_expression::variable::VariableType; + +use crate::policy::blocks::{ + AnalysisContext, AnalysisSummary, Block, InstanceSource, PropertyRead, SharedIntelliSense, + WriteTarget, +}; +use crate::policy::db::{AnalysisPass, PolicyDerivedCache, Snapshot}; +use crate::policy::ir::{DataModelIr, ParsedPolicy, PropertyPath}; +use crate::policy::queries::path::{PathClassifier, PathRoot}; +use crate::policy::queries::scope::{EntityForm, VariableTypeScope}; +use crate::policy::types::{BlockRef, Diagnostic, DiagnosticCode, DiagnosticLocation}; + +#[derive(Debug)] +pub struct ShallowAnalyses { + pub per_rule: Vec, + pub diagnostics: Vec, + by_block: HashMap, +} + +impl ShallowAnalyses { + pub fn for_block(&self, block_ref: &BlockRef) -> Option<&RuleShallowAnalysis> { + self.by_block + .get(block_ref) + .and_then(|&i| self.per_rule.get(i)) + } +} + +#[derive(Debug, Clone)] +pub struct RuleShallowAnalysis { + pub policy_path: Arc, + pub block_id: Arc, + pub reads: Vec, + pub writes: Vec, +} + +impl RuleShallowAnalysis { + pub fn is_in(&self, policy_path: &Arc) -> bool { + self.policy_path == *policy_path + } +} + +#[derive(Debug)] +pub struct EnrichedState { + pub scope: VariableType, + pub per_rule: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone)] +pub struct RuleEnrichedAnalysis { + pub policy_path: Arc, + pub diagnostics: Vec, +} + +#[derive(Debug)] +pub struct DependencyGraph { + pub graph: StableDiGraph, + pub node_map: HashMap, +} + +#[derive(Debug, Clone)] +pub struct PropertyNode { + pub path: PropertyPath, + pub resolved_type: VariableType, + pub written_by: Option, + pub instance_source: Option, +} + +impl PropertyNode { + pub fn is_computed(&self) -> bool { + self.written_by.is_some() + } + + pub fn resolved_type_in(&self, scope: &VariableType, path: &str) -> VariableType { + match scope.resolve_at(path) { + VariableType::Any => self.resolved_type.to_acyclic(), + t => t.to_acyclic(), + } + } +} + +impl DependencyGraph { + pub fn writer_for(&self, path: &str) -> Option<&BlockRef> { + let idx = *self.node_map.get(path)?; + self.graph[idx].written_by.as_ref() + } + + pub fn computed_in<'a>( + &'a self, + visible: &'a HashSet>, + ) -> impl Iterator, &'a BlockRef, &'a PropertyNode)> + 'a { + self.node_map.iter().filter_map(move |(path, &idx)| { + let node = &self.graph[idx]; + let owner = node.written_by.as_ref()?; + if !visible.contains(&owner.policy_path) || self.has_computed_ancestor(path) { + return None; + } + Some((path, owner, node)) + }) + } + + fn has_computed_ancestor(&self, path: &str) -> bool { + let mut cut = 0; + while let Some(dot) = path[cut..].find('.') { + let prefix = &path[..cut + dot]; + cut += dot + 1; + if self.writer_for(prefix).is_some() { + return true; + } + } + false + } + + pub fn reachable_from(&self, goals: &[Arc]) -> HashSet> { + use petgraph::Incoming; + let mut reachable: HashSet> = HashSet::default(); + let mut stack: Vec<_> = goals + .iter() + .filter_map(|g| self.node_map.get(g).copied()) + .collect(); + while let Some(idx) = stack.pop() { + let node = &self.graph[idx]; + if !reachable.insert(node.path.clone()) { + continue; + } + for up in self.graph.neighbors_directed(idx, Incoming) { + stack.push(up); + } + } + reachable + } + + pub fn cyclic_paths(&self) -> HashSet> { + let mut out: HashSet> = HashSet::new(); + for scc in tarjan_scc(&self.graph) { + let is_cycle = scc.len() > 1 + || scc + .first() + .is_some_and(|&idx| self.graph.contains_edge(idx, idx)); + if !is_cycle { + continue; + } + for idx in scc { + out.insert(self.graph[idx].path.clone()); + } + } + out + } +} + +pub struct EvalGraph { + graph: StableDiGraph, + node_map: HashMap, + writers: HashMap, + demand_writers: HashMap>, +} + +impl EvalGraph { + pub fn from_graph(dep: &DependencyGraph) -> Self { + let mut graph = StableDiGraph::new(); + let mut node_map = HashMap::default(); + let mut writers = HashMap::default(); + let mut remap: HashMap = HashMap::default(); + + for (path, &old_idx) in &dep.node_map { + let new_idx = graph.add_node(path.clone()); + node_map.insert(path.clone(), new_idx); + remap.insert(old_idx, new_idx); + if let Some(owner) = &dep.graph[old_idx].written_by { + writers.insert(path.clone(), owner.clone()); + } + } + + for edge in dep.graph.edge_indices() { + if let Some((from, to)) = dep.graph.edge_endpoints(edge) { + if let (Some(&from), Some(&to)) = (remap.get(&from), remap.get(&to)) { + graph.add_edge(from, to, ()); + } + } + } + + let demand_writers = Self::collect_demand_writers(&writers); + + Self { + graph, + node_map, + writers, + demand_writers, + } + } + + fn collect_demand_writers( + writers: &HashMap, + ) -> HashMap> { + let mut out: HashMap> = HashMap::default(); + let mut sorted: Vec<(&PropertyPath, &BlockRef)> = writers.iter().collect(); + sorted.sort_by(|a, b| a.0.cmp(b.0)); + for (path, owner) in sorted { + let mut push = |target: &PropertyPath| { + let list = out.entry(target.clone()).or_default(); + if !list.contains(owner) { + list.push(owner.clone()); + } + }; + push(path); + let raw = path.as_ref(); + let mut cut = 0; + while let Some(dot) = raw[cut..].find('.') { + let prefix = &raw[..cut + dot]; + cut += dot + 1; + if let Some((ancestor, _)) = writers.get_key_value(prefix) { + push(ancestor); + } + } + } + out + } + + pub fn writer_for(&self, path: &str) -> Option<&BlockRef> { + self.writers.get(path) + } + + pub fn demand_writers_for(&self, path: &str) -> &[BlockRef] { + self.demand_writers + .get(path) + .map(Vec::as_slice) + .unwrap_or_default() + } + + pub fn contains(&self, path: &str) -> bool { + self.node_map.contains_key(path) + } + + pub fn reachable_from(&self, goals: &[Arc]) -> HashSet> { + use petgraph::Incoming; + let mut reachable: HashSet> = HashSet::default(); + let mut stack: Vec = goals + .iter() + .filter_map(|g| self.node_map.get(g).copied()) + .collect(); + while let Some(idx) = stack.pop() { + if !reachable.insert(self.graph[idx].clone()) { + continue; + } + for up in self.graph.neighbors_directed(idx, Incoming) { + stack.push(up); + } + } + reachable + } + + pub fn reachable_input_paths( + &self, + goals: &[Arc], + visible: &HashSet>, + ) -> HashSet> { + self.reachable_from(goals) + .into_iter() + .filter(|p| match self.writers.get(p.as_ref()) { + None => true, + Some(owner) => !visible.contains(&owner.policy_path), + }) + .collect() + } + + pub fn terminal_sinks(&self, visible: &HashSet>) -> Vec> { + use petgraph::Outgoing; + let mut sinks: Vec> = self + .node_map + .iter() + .filter(|(path, _)| { + self.writers + .get(path.as_ref()) + .is_some_and(|owner| visible.contains(&owner.policy_path)) + }) + .filter(|(_, &idx)| { + self.graph + .neighbors_directed(idx, Outgoing) + .next() + .is_none() + }) + .map(|(path, _)| path.clone()) + .collect(); + sinks.sort(); + sinks + } +} + +impl Snapshot { + fn analyze_block( + rule: &Block, + policy_path: &Arc, + rule_scope: VariableType, + pass: AnalysisPass, + intellisense: &SharedIntelliSense, + ) -> AnalysisSummary { + let mut ctx = AnalysisContext::new( + rule_scope, + policy_path.clone(), + rule.id.clone(), + intellisense.clone(), + pass, + ); + rule.kind.analyze(&mut ctx); + ctx.finish() + } + + pub(crate) fn compute_shallow( + base_scope: &VariableType, + all_parsed: &HashMap, Arc>, + classifier: &PathClassifier, + intellisense: &SharedIntelliSense, + cache: &PolicyDerivedCache, + ) -> ShallowAnalyses { + let mut per_rule: Vec = Vec::new(); + let mut diagnostics: Vec = Vec::new(); + + let mut sorted_paths: Vec<&Arc> = all_parsed.keys().collect(); + sorted_paths.sort(); + for path in sorted_paths { + let p = &all_parsed[path]; + + for rule in p.policy.rules() { + rule.check_single_entity_scope(path, classifier, &mut diagnostics); + } + + let policy_shallow = cache.shallow_or_compute(path, p, || { + p.policy + .rules() + .map(|rule| { + let summary = Self::analyze_block( + rule, + path, + base_scope.shallow_clone(), + AnalysisPass::Shallow, + intellisense, + ); + RuleShallowAnalysis { + policy_path: path.clone(), + block_id: rule.id.clone(), + reads: summary.reads, + writes: summary.writes, + } + }) + .collect() + }); + per_rule.extend(policy_shallow.iter().cloned()); + } + + let by_block = per_rule + .iter() + .enumerate() + .map(|(i, r)| { + ( + BlockRef { + policy_path: r.policy_path.clone(), + block_id: r.block_id.clone(), + }, + i, + ) + }) + .collect(); + + ShallowAnalyses { + per_rule, + diagnostics, + by_block, + } + } + + pub(crate) fn compute_graph( + per_rule: &[&RuleShallowAnalysis], + data_model_paths: &DataModelPaths, + entity_sources: &crate::policy::queries::scope::EntitySources, + ) -> DependencyGraph { + let mut graph = StableDiGraph::new(); + let mut node_map: HashMap = HashMap::new(); + let mut writers: HashMap, Arc)> = HashMap::new(); + + let entity_form_map = EntityForm::new(entity_sources); + let entity_form = |path: &str| -> Option { entity_form_map.rewrite(path) }; + + for &rule in per_rule { + for read in &rule.reads { + node_map.entry(read.path.clone()).or_insert_with(|| { + graph.add_node(PropertyNode { + path: read.path.clone(), + resolved_type: VariableType::Any, + written_by: None, + instance_source: None, + }) + }); + } + + for write in &rule.writes { + if data_model_paths.matches_prefix(&write.path).is_some() { + continue; + } + + let idx = *node_map.entry(write.path.clone()).or_insert_with(|| { + graph.add_node(PropertyNode { + path: write.path.clone(), + resolved_type: write.resolved_type.shallow_clone(), + written_by: None, + instance_source: None, + }) + }); + + if !writers.contains_key(&write.path) { + writers.insert( + write.path.clone(), + (rule.policy_path.clone(), rule.block_id.clone()), + ); + let node = &mut graph[idx]; + node.resolved_type = write.resolved_type.shallow_clone(); + node.written_by = Some(BlockRef { + policy_path: rule.policy_path.clone(), + block_id: rule.block_id.clone(), + }); + node.instance_source = write.instance_source.clone(); + } + + let path = write.path.as_ref(); + let mut cut = 0; + while let Some(dot) = path[cut..].find('.') { + let prefix = &path[..cut + dot]; + cut += dot + 1; + if data_model_paths.matches_prefix(prefix).is_some() { + continue; + } + let prefix_path: PropertyPath = Arc::from(prefix); + let anc_idx = *node_map.entry(prefix_path.clone()).or_insert_with(|| { + graph.add_node(PropertyNode { + path: prefix_path.clone(), + resolved_type: VariableType::Any, + written_by: None, + instance_source: None, + }) + }); + if !writers.contains_key(&prefix_path) { + writers.insert( + prefix_path.clone(), + (rule.policy_path.clone(), rule.block_id.clone()), + ); + graph[anc_idx].written_by = Some(BlockRef { + policy_path: rule.policy_path.clone(), + block_id: rule.block_id.clone(), + }); + } + if idx != anc_idx { + graph.add_edge(idx, anc_idx, ()); + } + } + } + } + + for &rule in per_rule { + for write in &rule.writes { + if data_model_paths.matches_prefix(&write.path).is_some() { + continue; + } + let Some(&write_idx) = node_map.get(&write.path) else { + continue; + }; + for read in &rule.reads { + if let Some(&read_idx) = node_map.get(&read.path) { + let reads_own_parent = PathPrefix::extends(&read.path, &write.path); + if read_idx != write_idx && !reads_own_parent { + graph.add_edge(read_idx, write_idx, ()); + } + } + if let Some(entity_path) = entity_form(&read.path) { + if let Some(&entity_idx) = node_map.get(entity_path.as_str()) { + if entity_idx != write_idx { + graph.add_edge(entity_idx, write_idx, ()); + } + } + } + + let read_path = read.path.as_ref(); + let mut cut = 0; + while let Some(dot) = read_path[cut..].find('.') { + let ancestor = &read_path[..cut + dot]; + cut += dot + 1; + if let Some(&ancestor_idx) = node_map.get(ancestor) { + if ancestor_idx != write_idx + && graph[ancestor_idx].written_by.is_some() + && !PathPrefix::extends(ancestor, &write.path) + { + graph.add_edge(ancestor_idx, write_idx, ()); + } + } + } + } + } + } + + DependencyGraph { graph, node_map } + } + + pub(crate) fn compute_execution_order(graph: &DependencyGraph) -> Vec { + if let Ok(order) = toposort(&graph.graph, None) { + return order + .into_iter() + .filter(|idx| graph.graph[*idx].written_by.is_some()) + .map(|idx| graph.graph[idx].path.clone()) + .collect(); + } + let mut out: Vec = Vec::new(); + for scc in tarjan_scc(&graph.graph).into_iter().rev() { + let mut paths: Vec = scc + .into_iter() + .filter(|idx| graph.graph[*idx].is_computed()) + .map(|idx| graph.graph[idx].path.clone()) + .collect(); + paths.sort(); + out.extend(paths); + } + out + } + + pub(crate) fn compute_enriched( + base_scope: &VariableType, + graph: &DependencyGraph, + order: &[PropertyPath], + rule_by_ref: &HashMap>, + members: &HashSet>, + intellisense: &SharedIntelliSense, + ) -> EnrichedState { + let scope = base_scope.shallow_clone(); + let mut per_rule: Vec = Vec::new(); + let mut diagnostics: Vec = Vec::new(); + + let writer_of: HashMap<&str, &BlockRef> = graph + .graph + .node_indices() + .filter_map(|idx| { + let node = &graph.graph[idx]; + node.written_by.as_ref().map(|o| (node.path.as_ref(), o)) + }) + .collect(); + + let mut analyzed: HashSet = HashSet::new(); + let mut schedule: Vec<(BlockRef, bool)> = Vec::new(); + for prop_path in order.iter() { + if let Some(owner) = writer_of.get(prop_path.as_ref()) { + if analyzed.insert((*owner).clone()) { + schedule.push(((*owner).clone(), true)); + } + } + } + let mut remaining: Vec<&BlockRef> = rule_by_ref + .keys() + .filter(|key| members.contains(&key.policy_path) && !analyzed.contains(*key)) + .collect(); + remaining.sort_by(|a, b| { + a.policy_path + .cmp(&b.policy_path) + .then_with(|| a.block_id.cmp(&b.block_id)) + }); + schedule.extend(remaining.into_iter().map(|key| (key.clone(), false))); + + for (key, splice) in schedule { + let Some(rule) = rule_by_ref.get(&key) else { + continue; + }; + let policy_path = &key.policy_path; + let summary = Self::analyze_block( + rule, + policy_path, + scope.shallow_clone(), + AnalysisPass::Enriched, + intellisense, + ); + + if splice { + for tw in &summary.writes { + if !scope.insert_at_path(&tw.path, &tw.resolved_type, true) { + diagnostics.push(Diagnostic::error( + DiagnosticCode::InvalidWritePath, + DiagnosticLocation::block(policy_path.clone(), rule.id.clone()) + .maybe_target(rule.kind.write_target(&tw.path)), + format!( + "cannot write to '{}': parent path is not an object", + tw.path + ), + )); + } + } + } + + per_rule.push(RuleEnrichedAnalysis { + policy_path: policy_path.clone(), + diagnostics: summary.diagnostics, + }); + } + + EnrichedState { + scope, + per_rule, + diagnostics, + } + } +} + +pub(crate) struct PathPrefix; + +impl PathPrefix { + pub(crate) fn extends(prefix: &str, path: &str) -> bool { + prefix == path + || (path.len() > prefix.len() + && path.starts_with(prefix) + && path.as_bytes()[prefix.len()] == b'.') + } +} + +#[derive(Clone)] +pub struct DataModelPaths { + all: HashSet, + optional: HashSet, +} + +impl DataModelPaths { + pub(crate) fn from_models<'a>(models: impl IntoIterator) -> Self { + let mut all = HashSet::default(); + let mut optional = HashSet::default(); + for dm in models { + let is_global = dm.scope.is_global(); + for prop in &dm.properties { + let path: PropertyPath = if is_global { + Arc::from(prop.name.as_ref()) + } else { + Arc::from(format!("{}.{}", dm.name, prop.name)) + }; + if prop.optional { + optional.insert(path.clone()); + } + all.insert(path); + } + } + Self { all, optional } + } + + pub fn matches_prefix(&self, write_path: &str) -> Option<&PropertyPath> { + if let Some(p) = self.all.get(write_path) { + return Some(p); + } + self.all + .iter() + .find(|p| PathPrefix::extends(p, write_path) || PathPrefix::extends(write_path, p)) + } + + pub fn is_optional(&self, path: &str) -> bool { + self.optional.contains(path) || self.optional.iter().any(|p| PathPrefix::extends(p, path)) + } +} + +impl Snapshot { + pub(crate) fn compute_data_model_paths( + all_parsed: &HashMap, Arc>, + ) -> DataModelPaths { + DataModelPaths::from_models( + all_parsed + .values() + .flat_map(|p| p.policy.data_models()) + .map(|(_, dm)| dm), + ) + } +} + +#[derive(Debug, Clone)] +pub enum WriteScope { + Entity(Arc), + Global, + Empty, + Mixed, +} + +impl Block { + pub(crate) fn check_single_entity_scope( + &self, + policy_path: &Arc, + classifier: &PathClassifier, + out: &mut Vec, + ) { + if !matches!(self.write_scope(classifier), WriteScope::Mixed) { + return; + } + let labels = self.write_bucket_labels(classifier); + out.push(Diagnostic::error( + DiagnosticCode::MixedScope, + DiagnosticLocation::block(policy_path.clone(), self.id.clone()), + format!( + "block writes to multiple scopes: {}. A block must be scoped to a single entity or to globals.", + labels.join(", ") + ), + )); + } + + pub(crate) fn write_scope(&self, classifier: &PathClassifier) -> WriteScope { + let mut current: Option = None; + for path in self.write_paths() { + if path.is_empty() { + continue; + } + let next = match classifier.classify(&path) { + PathRoot::Entity { entity, .. } => WriteScope::Entity(entity), + PathRoot::Global { .. } => WriteScope::Global, + }; + current = Some(match current { + None => next, + Some(prev) => prev.merge(next), + }); + } + current.unwrap_or(WriteScope::Empty) + } + + fn write_bucket_labels(&self, classifier: &PathClassifier) -> Vec { + let mut entities: Vec = Vec::new(); + let mut globals: Vec = Vec::new(); + for path in self.write_paths() { + if path.is_empty() { + continue; + } + match classifier.classify(&path) { + PathRoot::Entity { entity, .. } => { + let label = format!("entity '{entity}'"); + if !entities.contains(&label) { + entities.push(label); + } + } + PathRoot::Global { name } => { + let label = format!("global '{name}'"); + if !globals.contains(&label) { + globals.push(label); + } + } + } + } + entities.sort(); + globals.sort(); + entities.extend(globals); + entities + } + + pub(crate) fn write_paths(&self) -> Vec> { + self.kind.writes().into_iter().map(|w| w.path).collect() + } +} + +impl WriteScope { + fn merge(self, other: WriteScope) -> WriteScope { + match (self, other) { + (WriteScope::Empty, x) | (x, WriteScope::Empty) => x, + (WriteScope::Entity(a), WriteScope::Entity(b)) if a == b => WriteScope::Entity(a), + (WriteScope::Global, WriteScope::Global) => WriteScope::Global, + _ => WriteScope::Mixed, + } + } +} diff --git a/core/engine/src/policy/queries/diagnostics.rs b/core/engine/src/policy/queries/diagnostics.rs new file mode 100644 index 00000000..6e8e5a9b --- /dev/null +++ b/core/engine/src/policy/queries/diagnostics.rs @@ -0,0 +1,502 @@ +use std::sync::Arc; + +use ahash::{HashMap, HashMapExt, HashSet}; +use petgraph::algo::tarjan_scc; + +use crate::policy::db::Db; +use crate::policy::ir::PropertyTypeIr; +use crate::policy::linter::Linter; +use crate::policy::queries::dependency::WriteScope; +use crate::policy::queries::path::PathRoot; +use crate::policy::types::{BlockRef, Diagnostic, DiagnosticCode, DiagnosticLocation}; + +impl Db { + pub fn compute_policy_diagnostics(&self, path: &Arc) -> Vec { + let mut out: Vec = Vec::new(); + + if let Some(parsed) = self.parsed(path) { + out.extend(parsed.diagnostics.iter().cloned()); + } + + let shallow = self.shallow(); + out.extend( + shallow + .diagnostics + .iter() + .filter(|d| d.is_in(path)) + .cloned(), + ); + + out.extend(self.graph_diagnostics(path)); + + let enriched = self.enriched(path); + out.extend( + enriched + .diagnostics + .iter() + .filter(|d| d.is_in(path)) + .cloned(), + ); + out.extend( + enriched + .per_rule + .iter() + .filter(|rule| rule.policy_path == *path) + .flat_map(|rule| rule.diagnostics.iter().cloned()), + ); + + out.extend(self.import_diagnostics(path)); + + out.extend(self.data_model_diagnostics(path)); + + out.extend(self.unreachable_reads_diagnostics(path)); + + out.extend(self.nested_iteration_diagnostics(path)); + + out.extend(Linter::standard().run(self, path)); + + out + } + + fn nested_iteration_diagnostics(&self, target: &Arc) -> Vec { + let mut out = Vec::new(); + let shallow = self.shallow(); + let unit = self.unit(target); + let entity_sources = &unit.entity_sources; + let classifier = &unit.classifier; + + for rule_analysis in &shallow.per_rule { + if !rule_analysis.is_in(target) { + continue; + } + let mut flagged: HashSet> = HashSet::default(); + for write in &rule_analysis.writes { + let PathRoot::Entity { entity, .. } = classifier.classify(&write.path) else { + continue; + }; + let Some(src) = entity_sources.get(&entity) else { + continue; + }; + let root = src.path.split('.').next().unwrap_or_default(); + if root == entity.as_ref() || !entity_sources.contains_key(root) { + continue; + } + if !flagged.insert(entity.clone()) { + continue; + } + out.push(Diagnostic::error( + DiagnosticCode::UnsupportedNestedIteration, + DiagnosticLocation::block( + rule_analysis.policy_path.clone(), + rule_analysis.block_id.clone(), + ), + format!( + "cannot write to entity '{entity}': its collection '{}' is nested inside iterated entity '{root}'; only one level of relationship nesting is evaluated", + src.path + ), + )); + } + } + out + } + + fn unreachable_reads_diagnostics(&self, target: &Arc) -> Vec { + let mut out = Vec::new(); + let shallow = self.shallow(); + let unit = self.unit(target); + let entity_sources = &unit.entity_sources; + let classifier = &unit.classifier; + let rule_index = self.rule_by_ref(); + + for rule_analysis in &shallow.per_rule { + if !rule_analysis.is_in(target) { + continue; + } + let block_ref = BlockRef { + policy_path: rule_analysis.policy_path.clone(), + block_id: rule_analysis.block_id.clone(), + }; + let Some(rule) = rule_index.get(&block_ref) else { + continue; + }; + let write_scope = rule.write_scope(&classifier); + + for read in &rule_analysis.reads { + if read.via_alias { + continue; + } + let PathRoot::Entity { + entity: read_entity, + .. + } = classifier.classify(&read.path) + else { + continue; + }; + if !entity_sources.contains_key(&read_entity) { + continue; + } + let reachable = matches!(&write_scope, WriteScope::Entity(e) if e.as_ref() == read_entity.as_ref()); + if reachable { + continue; + } + let context = match &write_scope { + WriteScope::Entity(e) => format!("entity '{e}'"), + WriteScope::Global => "globals".to_string(), + WriteScope::Empty | WriteScope::Mixed => "this block".to_string(), + }; + out.push(Diagnostic::error( + DiagnosticCode::UnreachableEntityRead, + DiagnosticLocation::expression( + rule_analysis.policy_path.clone(), + rule_analysis.block_id.clone(), + read.expression_id.clone().unwrap_or_else(|| Arc::from("")), + read.span, + ), + format!( + "cannot read '{}' from {context}: entity '{read_entity}' is iterated; aggregate it with map/some/every/sum", + read.path + ), + )); + } + } + out + } + + fn graph_diagnostics(&self, target: &Arc) -> Vec { + use crate::policy::queries::dependency::PathPrefix; + + let mut out = Vec::new(); + let shallow = self.shallow(); + let unit = self.unit(target); + let data_model_paths = &unit.data_model_paths; + let visible = &unit.members; + let mut first_writer: HashMap, BlockRef> = HashMap::new(); + let mut all_writes: Vec<(BlockRef, bool, Arc)> = Vec::new(); + + for rule in &shallow.per_rule { + if !visible.contains(&rule.policy_path) { + continue; + } + let in_target = rule.is_in(target); + let block_ref = BlockRef { + policy_path: rule.policy_path.clone(), + block_id: rule.block_id.clone(), + }; + let block = self.block_ir(&block_ref); + + for write in &rule.writes { + let wtarget = block + .as_ref() + .and_then(|b| b.kind.write_target(&write.path)); + + if let Some(matched) = data_model_paths.matches_prefix(&write.path) { + if in_target { + out.push(Diagnostic::error( + DiagnosticCode::InputOverride, + DiagnosticLocation::block( + rule.policy_path.clone(), + rule.block_id.clone(), + ) + .maybe_target(wtarget.clone()), + format!( + "cannot write to '{}': '{}' is defined as a DataModel input", + write.path, matched + ), + )); + } + continue; + } + + match first_writer.get(&write.path) { + Some(existing) if in_target => { + out.push(Diagnostic::error( + DiagnosticCode::DuplicateWriter, + DiagnosticLocation::block( + rule.policy_path.clone(), + rule.block_id.clone(), + ) + .maybe_target(wtarget.clone()), + format!( + "property '{}' is written by both block '{}' (in '{}') and block '{}' (in '{}')", + write.path, + existing.block_id, + existing.policy_path, + rule.block_id, + rule.policy_path + ), + )); + } + Some(_) => {} + None => { + first_writer.insert(write.path.clone(), block_ref.clone()); + } + } + + all_writes.push((block_ref.clone(), in_target, write.path.clone())); + } + + for write in &rule.writes { + if !in_target { + continue; + } + let conflict = rule.reads.iter().find(|r| { + data_model_paths.matches_prefix(&r.path).is_none() + && PathPrefix::extends(&r.path, &write.path) + }); + if let Some(read) = conflict { + let wtarget = block + .as_ref() + .and_then(|b| b.kind.write_target(&write.path)); + let message = if read.path == write.path { + format!("block reads and writes the same property '{}'", write.path) + } else { + format!( + "block writes '{}' while reading the overlapping path '{}' — it would read a partially-built object", + write.path, read.path + ) + }; + out.push(Diagnostic::error( + DiagnosticCode::SelfReferencingWrite, + DiagnosticLocation::block(rule.policy_path.clone(), rule.block_id.clone()) + .maybe_target(wtarget), + message, + )); + } + } + } + + let mut containers: Vec> = Vec::new(); + let mut seen: HashSet> = HashSet::default(); + for (_, _, candidate) in &all_writes { + if !seen.insert(candidate.clone()) { + continue; + } + let has_nested = all_writes + .iter() + .any(|(_, _, w)| w != candidate && PathPrefix::extends(candidate, w)); + if has_nested { + containers.push(candidate.clone()); + } + } + containers.sort(); + for container in containers { + let mut blocks: Vec<(&BlockRef, bool)> = Vec::new(); + for (block_ref, in_t, write) in &all_writes { + if PathPrefix::extends(&container, write) + && !blocks.iter().any(|(b, _)| *b == block_ref) + { + blocks.push((block_ref, *in_t)); + } + } + let Some((owner, _)) = blocks.iter().find(|(_, in_t)| *in_t) else { + continue; + }; + let cross_policy = blocks + .iter() + .any(|(b, _)| b.policy_path != owner.policy_path); + let names: Vec = blocks + .iter() + .map(|(b, _)| { + if cross_policy { + format!("{}:{}", b.policy_path, b.block_id) + } else { + b.block_id.to_string() + } + }) + .collect(); + out.push(Diagnostic::error( + DiagnosticCode::PartialObjectWrite, + DiagnosticLocation::block(owner.policy_path.clone(), owner.block_id.clone()), + format!( + "object '{}' is written as a whole and also written into via nested paths ({}); the whole-object write overwrites the nested writes — assemble it in one place or merge explicitly", + container, + names.join(", ") + ), + )); + } + + let graph = &unit.dep_graph; + let cyclic = graph.cyclic_paths(); + let target_in_cycle = cyclic.iter().any(|path| { + graph + .writer_for(path) + .is_some_and(|owner| owner.policy_path == *target) + }); + if target_in_cycle { + out.push(Diagnostic::error( + DiagnosticCode::CyclicDependency, + DiagnosticLocation::policy(target.clone()), + "cyclic dependency detected among computed properties", + )); + } + + out + } + + fn import_diagnostics(&self, target: &Arc) -> Vec { + let mut out = Vec::new(); + let Some(parsed) = self.parsed(target) else { + return out; + }; + let all_paths: HashSet> = self.policy_paths().into_iter().collect(); + + for imported in parsed.policy.imports() { + if !all_paths.contains(imported) { + out.push(Diagnostic::error( + DiagnosticCode::ImportNotFound, + DiagnosticLocation::policy(target.clone()), + format!("imported policy '{}' not found in workspace", imported), + )); + } + } + + let import_graph = self.import_graph(); + for scc in tarjan_scc(&import_graph.graph) { + let is_cycle = scc.len() > 1 + || scc + .first() + .is_some_and(|&idx| import_graph.graph.contains_edge(idx, idx)); + if !is_cycle { + continue; + } + let mut members: Vec> = scc + .iter() + .map(|&idx| import_graph.graph[idx].clone()) + .collect(); + if !members.iter().any(|p| p == target) { + continue; + } + members.sort(); + let rendered: Vec = members.iter().map(|p| p.to_string()).collect(); + out.push(Diagnostic::error( + DiagnosticCode::CircularImport, + DiagnosticLocation::policy(target.clone()), + format!("circular import among: {}", rendered.join(", ")), + )); + } + out + } + + fn data_model_diagnostics(&self, target: &Arc) -> Vec { + let mut out = Vec::new(); + let mut seen: HashMap< + (Option>, Arc), + (Arc, Arc, PropertyTypeIr, bool, bool), + > = HashMap::default(); + let unit = self.unit(target); + let all_dms = &unit.data_models; + let known_entities: HashSet> = all_dms + .iter() + .filter(|e| !e.ir.scope.is_global()) + .map(|e| e.ir.name.clone()) + .collect(); + let global_property_names: HashSet> = all_dms + .iter() + .filter(|e| e.ir.scope.is_global()) + .flat_map(|e| e.ir.properties.iter().map(|p| p.name.clone())) + .collect(); + + for entry in all_dms { + let policy_path = &entry.policy_path; + let block_id = &entry.block_id; + let dm = &entry.ir; + let is_global = dm.scope.is_global(); + + if !is_global && global_property_names.contains(&dm.name) && policy_path == target { + out.push(Diagnostic::error( + DiagnosticCode::DataModelCollision, + DiagnosticLocation::block(policy_path.clone(), block_id.clone()), + format!( + "entity name '{}' collides with a global property of the same name", + dm.name + ), + )); + } + + for prop in &dm.properties { + if is_global && known_entities.contains(&prop.name) && policy_path == target { + out.push(Diagnostic::error( + DiagnosticCode::DataModelCollision, + DiagnosticLocation::expression( + policy_path.clone(), + block_id.clone(), + prop.id.clone(), + None, + ), + format!( + "global property '{}' collides with an entity of the same name", + prop.name + ), + )); + } + + let key = if is_global { + (None, prop.name.clone()) + } else { + (Some(dm.name.clone()), prop.name.clone()) + }; + if let Some((prev_policy, prev_block, prev_kind, prev_array, prev_optional)) = + seen.get(&key).cloned() + { + let conflicts = !prop.kind.same_shape_as(&prev_kind) + || prev_array != prop.array + || prev_optional != prop.optional; + if conflicts && policy_path == target { + let location = if is_global { + format!("global property '{}'", prop.name) + } else { + format!("property '{}' in entity '{}'", prop.name, dm.name) + }; + out.push(Diagnostic::error( + DiagnosticCode::DataModelCollision, + DiagnosticLocation::expression( + policy_path.clone(), + block_id.clone(), + prop.id.clone(), + None, + ), + format!( + "{location} conflicts with definition in '{prev_policy}' (block '{prev_block}')" + ), + )); + } + } else { + seen.insert( + key, + ( + policy_path.clone(), + block_id.clone(), + prop.kind.clone(), + prop.array, + prop.optional, + ), + ); + } + + if let PropertyTypeIr::Relationship { target: t } + | PropertyTypeIr::Reference { target: t } = &prop.kind + { + if !known_entities.contains(t) && policy_path == target { + let owner = if is_global { + format!("global property '{}'", prop.name) + } else { + format!("property '{}' in entity '{}'", prop.name, dm.name) + }; + out.push(Diagnostic::error( + DiagnosticCode::UnknownDataModelTarget, + DiagnosticLocation::expression( + policy_path.clone(), + block_id.clone(), + prop.id.clone(), + None, + ), + format!("{owner} references unknown entity '{t}'"), + )); + } + } + } + } + + out + } +} diff --git a/core/engine/src/policy/queries/mod.rs b/core/engine/src/policy/queries/mod.rs new file mode 100644 index 00000000..16d2e57c --- /dev/null +++ b/core/engine/src/policy/queries/mod.rs @@ -0,0 +1,9 @@ +pub mod components; +pub mod conditional; +pub mod dependencies; +pub mod dependency; +pub mod diagnostics; +pub mod path; +pub mod schema; +pub mod scope; +pub mod skeleton; diff --git a/core/engine/src/policy/queries/path.rs b/core/engine/src/policy/queries/path.rs new file mode 100644 index 00000000..a525af20 --- /dev/null +++ b/core/engine/src/policy/queries/path.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; + +use ahash::HashSet; + +#[derive(Debug, Clone)] +pub enum PathRoot { + Entity { entity: Arc }, + Global { name: Arc }, +} + +#[derive(Debug, Clone)] +pub struct PathClassifier { + entities: HashSet>, +} + +impl PathClassifier { + pub fn new(entities: HashSet>) -> Self { + Self { entities } + } + + pub fn classify(&self, path: &str) -> PathRoot { + let first = path.split_once('.').map_or(path, |(f, _)| f); + if self.entities.contains(first) { + return PathRoot::Entity { + entity: Arc::from(first), + }; + } + PathRoot::Global { + name: Arc::from(first), + } + } +} + +impl PathClassifier { + pub(crate) fn from_data_models<'a>( + models: impl IntoIterator, + ) -> Self { + let entities = models + .into_iter() + .filter(|dm| !dm.scope.is_global()) + .map(|dm| dm.name.clone()) + .collect(); + Self::new(entities) + } +} + +impl crate::policy::db::Snapshot { + pub(crate) fn compute_path_classifier( + all_parsed: &ahash::HashMap, Arc>, + ) -> PathClassifier { + PathClassifier::from_data_models( + all_parsed + .values() + .flat_map(|p| p.policy.data_models()) + .map(|(_, dm)| dm), + ) + } +} diff --git a/core/engine/src/policy/queries/schema.rs b/core/engine/src/policy/queries/schema.rs new file mode 100644 index 00000000..1df7b10e --- /dev/null +++ b/core/engine/src/policy/queries/schema.rs @@ -0,0 +1,281 @@ +use std::sync::Arc; + +use ahash::{HashMap, HashSet}; +use zen_expression::variable::VariableType; + +use crate::policy::db::Db; +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, +}; + +impl Db { + pub fn entities(&self, req: &ScopeRequest) -> Vec { + let entity_filter = (!req.goals.is_empty()).then(|| { + self.goal_reachable_entities(&self.unit(&req.policy_path).dep_graph, &req.goals) + }); + let mut by_entity: HashMap, Vec> = HashMap::default(); + + let fields = self + .walk_schema_fields(req) + .into_iter() + .chain(self.walk_computed_fields(req)); + for (entity, field) in fields { + if entity_filter.as_ref().is_some_and(|f| !f.contains(&entity)) { + continue; + } + by_entity.entry(entity).or_default().push(field); + } + + let mut result: Vec = by_entity + .into_iter() + .map(|(name, mut fields)| { + fields.sort_by(|a, b| a.name.cmp(&b.name)); + Entity { name, fields } + }) + .collect(); + result.sort_by(|a, b| a.name.cmp(&b.name)); + result + } + + pub fn globals(&self, req: &ScopeRequest) -> Vec { + let mut out: Vec = Vec::new(); + let unit = self.unit(&req.policy_path); + let visible = &unit.members; + let entities_map = &unit.entities; + let goal_filter = + (!req.goals.is_empty()).then(|| unit.dep_graph.reachable_from(&req.goals)); + + for vp in self.walk_visible_properties(&req.policy_path) { + if !matches!(vp.scope, PropertyScope::Global) { + continue; + } + if let Some(filter) = goal_filter.as_ref() { + if !filter.contains(&vp.property.name) { + continue; + } + } + let mut visited: HashSet> = HashSet::default(); + let resolved_type = + DataModelIr::wire_property_type(&vp.property, entities_map, &mut visited); + out.push(Global { + name: vp.property.name.clone(), + resolved_type, + origin: FieldOrigin::Schema { + source: vp.policy_path, + kind: vp.property.kind.to_schema_field_kind(vp.property.array), + }, + }); + } + + let mut seen: HashSet> = out.iter().map(|g| g.name.clone()).collect(); + let enriched = self.enriched_of_unit(&unit); + for (path, owner, node) in unit.dep_graph.computed_in(visible) { + if path.contains('.') { + continue; + } + if !seen.insert(path.clone()) { + continue; + } + if let Some(filter) = goal_filter.as_ref() { + if !filter.contains(path) { + continue; + } + } + out.push(Global { + name: path.clone(), + resolved_type: node.resolved_type_in(&enriched.scope, path), + origin: FieldOrigin::Computed { + written_by: owner.clone(), + instance_of: unit.computed_instances.get(path).cloned(), + }, + }); + } + + out.sort_by(|a, b| a.name.cmp(&b.name)); + out + } + + pub fn inputs(&self, req: &ScopeRequest) -> Vec { + let unit = self.unit(&req.policy_path); + let visible = &unit.members; + let entities = &unit.entities; + let (root_entities, ref_targets) = self.classify_root_entities(visible); + + let mut result: Vec = self + .walk_visible_properties(&req.policy_path) + .into_iter() + .filter(|vp| match &vp.scope { + PropertyScope::Entity(entity) => root_entities.contains(entity), + PropertyScope::Global => true, + }) + .map(|vp| { + let mut visited: HashSet> = HashSet::default(); + InputProperty { + path: vp.dotted_path(), + resolved_type: DataModelIr::wire_property_type( + &vp.property, + entities, + &mut visited, + ), + } + }) + .collect(); + + for target in &ref_targets { + if !entities.contains_key(target) { + continue; + } + let mut visited: HashSet> = HashSet::default(); + let entity_type = DataModelIr::wire_object(target, entities, &mut visited); + if !matches!(entity_type, VariableType::Any) { + result.push(InputProperty { + path: target.clone(), + resolved_type: entity_type.array(), + }); + } + } + + if !req.goals.is_empty() { + let reachable = self.goal_reachable_input_paths(&unit.dep_graph, &req.goals, visible); + result.retain(|p| { + reachable.iter().any(|r| { + PathPrefix::extends(p.path.as_ref(), r.as_ref()) + || PathPrefix::extends(r.as_ref(), p.path.as_ref()) + }) + }); + } + result.sort_by(|a, b| a.path.cmp(&b.path)); + result + } + + pub fn outputs(&self, req: &ScopeRequest) -> Vec { + let unit = self.unit(&req.policy_path); + let enriched = self.enriched_of_unit(&unit); + let goal_filter = + (!req.goals.is_empty()).then(|| unit.dep_graph.reachable_from(&req.goals)); + + let mut result: Vec = unit + .dep_graph + .computed_in(&unit.members) + .filter(|(path, _, _)| goal_filter.as_ref().is_none_or(|f| f.contains(*path))) + .map(|(path, owner, node)| OutputProperty { + path: path.clone(), + resolved_type: node.resolved_type_in(&enriched.scope, path), + kind: PropertyKind::Computed, + written_by: Some(owner.clone()), + instance_of: unit.computed_instances.get(path).cloned(), + }) + .collect(); + result.sort_by(|a, b| a.path.cmp(&b.path)); + result + } + + fn walk_schema_fields(&self, req: &ScopeRequest) -> Vec<(Arc, EntityField)> { + let unit = self.unit(&req.policy_path); + let entities = &unit.entities; + self.walk_visible_properties(&req.policy_path) + .into_iter() + .filter_map(|vp| { + let PropertyScope::Entity(entity) = vp.scope else { + return None; + }; + let mut visited: HashSet> = HashSet::default(); + let resolved_type = + DataModelIr::wire_property_type(&vp.property, entities, &mut visited); + let origin = FieldOrigin::Schema { + source: vp.policy_path, + kind: vp.property.kind.to_schema_field_kind(vp.property.array), + }; + Some(( + entity, + EntityField { + name: vp.property.name, + resolved_type, + origin, + }, + )) + }) + .collect() + } + + fn walk_computed_fields(&self, req: &ScopeRequest) -> Vec<(Arc, EntityField)> { + let unit = self.unit(&req.policy_path); + let enriched = self.enriched_of_unit(&unit); + + let mut sorted: Vec<(&Arc, &crate::policy::types::BlockRef, &_)> = + unit.dep_graph.computed_in(&unit.members).collect(); + sorted.sort_by(|a, b| a.0.cmp(b.0)); + + let mut seen: HashSet<(Arc, Arc)> = HashSet::default(); + sorted + .into_iter() + .filter_map(|(path, owner, node)| { + let (entity, name) = path.split_once('.')?; + let entity: Arc = Arc::from(entity); + let name: Arc = Arc::from(name); + if !seen.insert((entity.clone(), name.clone())) { + return None; + } + Some(( + entity, + EntityField { + name, + resolved_type: node.resolved_type_in(&enriched.scope, path), + origin: FieldOrigin::Computed { + written_by: owner.clone(), + instance_of: unit.computed_instances.get(path).cloned(), + }, + }, + )) + }) + .collect() + } + + fn classify_root_entities( + &self, + visible: &HashSet>, + ) -> (HashSet>, HashSet>) { + let parsed: Vec<_> = visible.iter().filter_map(|pp| self.parsed(pp)).collect(); + let models = parsed + .iter() + .flat_map(|p| p.policy.data_models().map(|(_, dm)| dm)); + DataModelIr::classify_roots(models) + } + + pub(crate) fn goal_reachable_input_paths( + &self, + graph: &DependencyGraph, + goals: &[Arc], + visible: &HashSet>, + ) -> HashSet> { + graph + .reachable_from(goals) + .iter() + .filter(|p| { + graph.node_map.get(p.as_ref()).is_some_and(|&idx| { + match &graph.graph[idx].written_by { + None => true, + Some(owner) => !visible.contains(&owner.policy_path), + } + }) + }) + .cloned() + .collect() + } + + fn goal_reachable_entities( + &self, + graph: &DependencyGraph, + goals: &[Arc], + ) -> HashSet> { + graph + .reachable_from(goals) + .iter() + .filter_map(|p| p.split('.').next().map(Arc::::from)) + .collect() + } +} diff --git a/core/engine/src/policy/queries/scope.rs b/core/engine/src/policy/queries/scope.rs new file mode 100644 index 00000000..83e52d48 --- /dev/null +++ b/core/engine/src/policy/queries/scope.rs @@ -0,0 +1,797 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use ahash::{HashMap, HashMapExt, HashSet, HashSetExt}; +use petgraph::stable_graph::StableDiGraph; +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::queries::dependency::DependencyGraph; +use crate::policy::types::InstanceTarget; + +#[derive(Debug, Clone)] +pub struct EntitySource { + pub path: Arc, + pub owner: Option>, +} + +pub type EntitySources = HashMap, EntitySource>; + +pub(crate) struct EntityForm { + iter_sources: Vec<(Arc, Arc)>, +} + +impl EntityForm { + pub(crate) fn new(entity_sources: &EntitySources) -> Self { + let mut iter_sources: Vec<(Arc, Arc)> = entity_sources + .iter() + .filter(|(entity, src)| src.path.as_ref() != entity.as_ref()) + .map(|(entity, src)| (src.path.clone(), entity.clone())) + .collect(); + iter_sources.sort_by_key(|(p, _)| std::cmp::Reverse(p.len())); + Self { iter_sources } + } + + pub(crate) fn rewrite(&self, path: &str) -> Option { + let mut current = path.to_string(); + let mut changed_at_least_once = false; + for _ in 0..crate::policy::MAX_RECURSION_DEPTH { + let mut changed = false; + for (src, entity) in &self.iter_sources { + let src_len = src.len(); + if current.len() > src_len + && current.as_bytes().get(src_len) == Some(&b'.') + && current.starts_with(src.as_ref()) + { + let next = format!("{}.{}", entity, ¤t[src_len + 1..]); + if next != current { + current = next; + changed = true; + changed_at_least_once = true; + break; + } + } + } + if !changed { + break; + } + } + changed_at_least_once.then_some(current) + } +} + +pub trait PathSegments { + fn to_dotted(&self) -> String; +} + +impl PathSegments for [Rc] { + fn to_dotted(&self) -> String { + self.iter() + .map(|s| s.as_ref()) + .collect::>() + .join(".") + } +} + +#[derive(Debug, Clone)] +pub struct ReferenceField { + pub path: Arc, + pub target: Arc, + pub array: bool, +} + +pub struct ImportGraph { + pub graph: StableDiGraph, ()>, + pub node_map: HashMap, petgraph::graph::NodeIndex>, +} + +pub struct EntityGraph { + models: HashMap, Arc>, + globals: HashMap, Property>, + entity_sources: Arc, + computed: HashMap, InstanceTarget>, +} + +impl EntityGraph { + pub fn contains(&self, name: &str) -> bool { + self.models.contains_key(name) + } + + pub fn next_entity(&self, current: &str, field: &str) -> Option> { + if let Some(dm) = self.models.get(current) { + for prop in &dm.properties { + if prop.name.as_ref() == field { + return match &prop.kind { + PropertyTypeIr::Relationship { target } + | PropertyTypeIr::Reference { target } => Some(target.clone()), + _ => None, + }; + } + } + } + if let Some(EntitySource { + owner: Some(owner), .. + }) = self.entity_sources.get(current) + { + if owner.as_ref() == field { + return Some(owner.clone()); + } + } + self.computed + .get(format!("{current}.{field}").as_str()) + .map(|t| t.target.clone()) + } + + pub fn next_entity_for_global(&self, name: &str) -> Option> { + if let Some(prop) = self.globals.get(name) { + return match &prop.kind { + PropertyTypeIr::Relationship { target } | PropertyTypeIr::Reference { target } => { + Some(target.clone()) + } + _ => None, + }; + } + self.computed + .get(name) + .filter(|_| !name.contains('.')) + .map(|t| t.target.clone()) + } + + pub fn global_property(&self, name: &str) -> Option<&Property> { + self.globals.get(name) + } + + pub fn resolve_path_to_element(&self, path: &[Rc]) -> Option> { + let first = path.first()?; + let root_str: &str = first.as_ref(); + let (mut current, start_idx) = if self.models.contains_key(root_str) { + (Arc::::from(root_str), 1) + } else if let Some(target) = self.next_entity_for_global(root_str) { + (target, 1) + } else { + return None; + }; + for segment in &path[start_idx..] { + current = self.next_entity(¤t, segment.as_ref())?; + } + Some(current) + } + + pub(crate) fn resolve_instance_targets( + &self, + dep_graph: &DependencyGraph, + pool_roots: &HashSet>, + ) -> HashMap, InstanceTarget> { + let sources: HashMap<&str, &InstanceSource> = dep_graph + .graph + .node_weights() + .filter(|n| n.written_by.is_some()) + .filter_map(|n| n.instance_source.as_ref().map(|s| (n.path.as_ref(), s))) + .collect(); + + let mut memo: HashMap, Option> = HashMap::new(); + let mut visiting: HashSet> = HashSet::new(); + for path in sources.keys() { + self.resolve_instance(path, &sources, pool_roots, &mut memo, &mut visiting); + } + memo.into_iter() + .filter_map(|(path, target)| target.map(|t| (path, t))) + .collect() + } + + fn resolve_instance( + &self, + path: &str, + sources: &HashMap<&str, &InstanceSource>, + pool_roots: &HashSet>, + memo: &mut HashMap, Option>, + visiting: &mut HashSet>, + ) -> Option { + if let Some(known) = memo.get(path) { + return known.clone(); + } + let key: Arc = Arc::from(path); + if !visiting.insert(key.clone()) { + return None; + } + let result = sources.get(path).copied().and_then(|src| { + let mut target = + self.resolve_source_path(&src.path, sources, pool_roots, memo, visiting)?; + if src.element { + if !target.array { + return None; + } + target.array = false; + } + Some(target) + }); + visiting.remove(&key); + memo.insert(key, result.clone()); + result + } + + fn resolve_source_path( + &self, + dotted: &str, + sources: &HashMap<&str, &InstanceSource>, + pool_roots: &HashSet>, + memo: &mut HashMap, Option>, + visiting: &mut HashSet>, + ) -> Option { + let mut segments = dotted.split('.'); + let root = segments.next()?; + + let mut current = if self.models.contains_key(root) { + InstanceTarget { + target: Arc::from(root), + array: pool_roots.contains(root), + } + } else if let Some(prop) = self.globals.get(root) { + match &prop.kind { + PropertyTypeIr::Relationship { target } | PropertyTypeIr::Reference { target } => { + InstanceTarget { + target: target.clone(), + array: prop.array, + } + } + _ => return None, + } + } else { + self.resolve_instance(root, sources, pool_roots, memo, visiting)? + }; + + let mut cumulative = String::from(root); + for segment in segments { + cumulative.push('.'); + cumulative.push_str(segment); + if current.array { + return None; + } + current = match self.declared_hop(¤t.target, segment) { + Some(hop) => hop, + None => self.resolve_instance(&cumulative, sources, pool_roots, memo, visiting)?, + }; + } + Some(current) + } + + fn declared_hop(&self, entity: &str, field: &str) -> Option { + let dm = self.models.get(entity)?; + let prop = dm.properties.iter().find(|p| p.name.as_ref() == field)?; + match &prop.kind { + PropertyTypeIr::Relationship { target } | PropertyTypeIr::Reference { target } => { + Some(InstanceTarget { + target: target.clone(), + array: prop.array, + }) + } + _ => None, + } + } + + pub(crate) fn register_computed(&mut self, targets: &HashMap, InstanceTarget>) { + self.computed = targets.clone(); + } +} + +impl Snapshot { + fn iter_data_models( + all_parsed: &HashMap, Arc>, + ) -> impl Iterator, &DataModelIr)> { + all_parsed.iter().flat_map(|(path, p)| { + p.policy + .data_models() + .map(move |(_, dm)| (path.clone(), dm)) + }) + } + + pub(crate) fn compute_entity_graph( + all_parsed: &HashMap, Arc>, + entity_sources: &Arc, + ) -> EntityGraph { + let mut models: HashMap, Arc> = HashMap::new(); + let mut globals: HashMap, Property> = HashMap::new(); + for (_, dm) in Self::iter_data_models(all_parsed) { + if dm.scope.is_global() { + for prop in &dm.properties { + globals + .entry(prop.name.clone()) + .or_insert_with(|| prop.clone()); + } + } else { + models + .entry(dm.name.clone()) + .or_insert_with(|| Arc::new(dm.clone())); + } + } + EntityGraph { + models, + globals, + entity_sources: entity_sources.clone(), + computed: HashMap::new(), + } + } + + pub(crate) fn compute_base_scope( + all_parsed: &HashMap, Arc>, + entity_sources: &EntitySources, + ) -> VariableType { + let mut entity_map: HashMap, VariableType> = HashMap::new(); + + let mut models: Vec<(Arc, &DataModelIr)> = + Self::iter_data_models(all_parsed).collect(); + models.sort_by(|a, b| a.1.name.cmp(&b.1.name).then_with(|| a.0.cmp(&b.0))); + + for (_, dm) in models.iter().filter(|(_, dm)| !dm.scope.is_global()) { + if let Some(existing) = entity_map.get(&dm.name) { + dm.merge_scalar_fields_into(existing); + } else { + let entity_type = + VariableType::Object(Rc::new(RefCell::new(dm.build_scalar_fields()))); + entity_map.insert(dm.name.clone(), entity_type); + } + } + + for (_, dm) in models.iter().filter(|(_, dm)| !dm.scope.is_global()) { + dm.wire_relationships(&entity_map); + } + + for (entity_name, source) in entity_sources.iter() { + let EntitySource { + owner: Some(owner_name), + .. + } = source + else { + continue; + }; + let (Some(entity_type), Some(owner_type)) = ( + entity_map.get(entity_name.as_ref()), + entity_map.get(owner_name.as_ref()), + ) else { + continue; + }; + let VariableType::Object(ref entity_obj) = entity_type else { + continue; + }; + entity_obj + .borrow_mut() + .entry(Rc::from(owner_name.as_ref())) + .or_insert_with(|| owner_type.shallow_clone()); + } + + let mut scope_fields: HashMap, VariableType> = HashMap::new(); + for (name, entity_type) in &entity_map { + scope_fields.insert(Rc::from(name.as_ref()), entity_type.shallow_clone()); + } + + 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); + scope_fields.entry(key).or_insert(value_type); + } + } + + VariableType::Object(Rc::new(RefCell::new(scope_fields))) + } + + pub(crate) fn compute_entity_sources( + all_parsed: &HashMap, Arc>, + ) -> EntitySources { + let mut sources: EntitySources = HashMap::new(); + let mut all_entities: HashSet> = HashSet::new(); + let mut referenced_only: HashSet> = HashSet::new(); + + let mut sorted: Vec<(Arc, &DataModelIr)> = + Self::iter_data_models(all_parsed).collect(); + sorted.sort_by(|a, b| a.1.name.cmp(&b.1.name).then_with(|| a.0.cmp(&b.0))); + + for (_, dm) in &sorted { + if !dm.scope.is_global() { + all_entities.insert(dm.name.clone()); + } + for prop in &dm.properties { + match &prop.kind { + PropertyTypeIr::Relationship { target } => { + let (path, owner) = if dm.scope.is_global() { + (Arc::from(prop.name.as_ref()), None) + } else { + ( + Arc::from(format!("{}.{}", dm.name, prop.name)), + Some(dm.name.clone()), + ) + }; + sources + .entry(target.clone()) + .or_insert(EntitySource { path, owner }); + } + PropertyTypeIr::Reference { target } => { + if !sources.contains_key(target) { + referenced_only.insert(target.clone()); + } + } + _ => {} + } + } + } + + let mut referenced_sorted: Vec> = referenced_only.into_iter().collect(); + referenced_sorted.sort(); + for entity in &referenced_sorted { + sources + .entry(entity.clone()) + .or_insert_with(|| EntitySource { + path: entity.clone(), + owner: None, + }); + } + sources + } + + pub(crate) fn compute_reference_fields( + all_parsed: &HashMap, Arc>, + ) -> Vec { + Self::iter_data_models(all_parsed) + .flat_map(|(_, dm)| { + let is_global = dm.scope.is_global(); + let dm_name = dm.name.clone(); + dm.properties.iter().filter_map(move |prop| { + let PropertyTypeIr::Reference { target } = &prop.kind else { + return None; + }; + let path: Arc = if is_global { + Arc::from(prop.name.as_ref()) + } else { + Arc::from(format!("{}.{}", dm_name, prop.name)) + }; + Some(ReferenceField { + path, + target: target.clone(), + array: prop.array, + }) + }) + }) + .collect() + } + + pub(crate) fn compute_import_graph( + all_parsed: &HashMap, Arc>, + ) -> ImportGraph { + let mut graph = StableDiGraph::new(); + let mut node_map = HashMap::new(); + + for path in all_parsed.keys() { + let idx = graph.add_node(path.clone()); + node_map.insert(path.clone(), idx); + } + + for (path, p) in all_parsed.iter() { + let Some(&src) = node_map.get(path) else { + continue; + }; + for imported in p.policy.imports() { + if let Some(&dst) = node_map.get(imported.as_ref()) { + graph.add_edge(src, dst, ()); + } + } + } + + ImportGraph { graph, node_map } + } +} + +impl Db { + pub fn visible_policies(&self, path: &str) -> Arc>> { + Arc::new(self.unit(path).members.clone()) + } + + pub(crate) fn visible_entities( + &self, + policy_path: &str, + ) -> Arc, Arc>> { + Arc::new(self.unit(policy_path).entities.clone()) + } + + pub(crate) fn walk_visible_properties(&self, policy_path: &str) -> Vec { + let visible = self.visible_policies(policy_path); + let mut sorted: Vec> = visible.iter().cloned().collect(); + sorted.sort(); + let mut out: Vec = Vec::new(); + let mut seen: HashSet<(Option>, Arc)> = HashSet::default(); + for pp in &sorted { + let Some(parsed) = self.parsed(pp) else { + continue; + }; + for (_, dm) in parsed.policy.data_models() { + for prop in &dm.properties { + let scope_key: Option> = if dm.scope.is_global() { + None + } else { + Some(dm.name.clone()) + }; + if seen.insert((scope_key.clone(), prop.name.clone())) { + let scope = match scope_key { + Some(entity) => PropertyScope::Entity(entity), + None => PropertyScope::Global, + }; + out.push(VisibleProperty { + policy_path: pp.clone(), + scope, + property: prop.clone(), + }); + } + } + } + } + out + } +} + +#[derive(Debug, Clone)] +pub struct DataModelEntry { + pub policy_path: Arc, + pub block_id: Arc, + pub ir: Arc, +} + +#[derive(Debug, Clone)] +pub(crate) struct VisibleProperty { + pub policy_path: Arc, + pub scope: PropertyScope, + pub property: Property, +} + +#[derive(Debug, Clone)] +pub(crate) enum PropertyScope { + Entity(Arc), + Global, +} + +impl VisibleProperty { + pub(crate) fn dotted_path(&self) -> Arc { + match &self.scope { + PropertyScope::Entity(entity) => { + Arc::from(format!("{}.{}", entity, self.property.name)) + } + PropertyScope::Global => self.property.name.clone(), + } + } +} + +impl DataModelIr { + fn build_scalar_fields(&self) -> HashMap, VariableType> { + let mut fields: HashMap, VariableType> = HashMap::new(); + for prop in &self.properties { + let Some(base) = prop.kind.as_scalar() else { + continue; + }; + let mut final_type = if prop.array { base.array() } else { base }; + if prop.optional { + final_type = VariableType::Nullable(Rc::new(final_type)); + } + fields.insert(Rc::from(prop.name.as_ref()), final_type); + } + fields + } + + fn merge_scalar_fields_into(&self, existing: &VariableType) { + let VariableType::Object(ref obj) = existing else { + return; + }; + for prop in &self.properties { + let Some(base) = prop.kind.as_scalar() else { + continue; + }; + let mut new_type = if prop.array { base.array() } else { base }; + if prop.optional { + new_type = VariableType::Nullable(Rc::new(new_type)); + } + let key = Rc::from(prop.name.as_ref()); + obj.borrow_mut().entry(key).or_insert(new_type); + } + } + + fn wire_relationships(&self, entity_map: &HashMap, VariableType>) { + for prop in &self.properties { + let target_name = match &prop.kind { + PropertyTypeIr::Relationship { target } | PropertyTypeIr::Reference { target } => { + target + } + _ => continue, + }; + + let Some(target_entity) = entity_map.get(target_name.as_ref()) else { + continue; + }; + + let mut final_type = if prop.array { + target_entity.shallow_clone().array() + } else { + target_entity.shallow_clone() + }; + if prop.optional { + final_type = VariableType::Nullable(Rc::new(final_type)); + } + + if let Some(VariableType::Object(ref obj)) = entity_map.get(&self.name) { + let key = Rc::from(prop.name.as_ref()); + obj.borrow_mut().entry(key).or_insert(final_type); + } + } + } +} + +impl PropertyTypeIr { + fn as_scalar(&self) -> Option { + match self { + PropertyTypeIr::String => Some(VariableType::String), + PropertyTypeIr::Enum(values) => Some(VariableType::Enum( + None, + crate::policy::ir::enum_values_to_rc(values), + )), + PropertyTypeIr::Number => Some(VariableType::Number), + PropertyTypeIr::Boolean => Some(VariableType::Bool), + PropertyTypeIr::Date => Some(VariableType::Date), + PropertyTypeIr::Relationship { .. } | PropertyTypeIr::Reference { .. } => None, + } + } +} + +impl Property { + pub(crate) fn build_global_type( + &self, + entity_map: &HashMap, VariableType>, + ) -> VariableType { + let inner = match &self.kind { + PropertyTypeIr::String | PropertyTypeIr::Date => VariableType::String, + PropertyTypeIr::Enum(values) => { + VariableType::Enum(None, crate::policy::ir::enum_values_to_rc(values)) + } + PropertyTypeIr::Number => VariableType::Number, + PropertyTypeIr::Boolean => VariableType::Bool, + PropertyTypeIr::Relationship { target } | PropertyTypeIr::Reference { target } => { + match entity_map.get(target.as_ref()) { + Some(t) => t.shallow_clone(), + None => VariableType::Any, + } + } + }; + let mut final_type = if self.array { inner.array() } else { inner }; + if self.optional { + final_type = VariableType::Nullable(Rc::new(final_type)); + } + final_type + } +} + +pub trait VariableTypeScope { + fn resolve_at(&self, path: &str) -> VariableType; + + fn insert_at_path(&self, path: &str, value_type: &VariableType, allow_fill: bool) -> bool; + + fn with_dollar(&self, field_type: &VariableType) -> VariableType; + + fn to_acyclic(&self) -> VariableType; + + fn break_cycles(&self); +} + +impl VariableTypeScope for VariableType { + fn resolve_at(&self, path: &str) -> VariableType { + let mut current = self.shallow_clone(); + for segment in path.split('.') { + current = current.get(segment); + } + current + } + + fn insert_at_path(&self, path: &str, value_type: &VariableType, allow_fill: bool) -> bool { + let segments: Vec<&str> = path.split('.').collect(); + if segments.is_empty() { + return false; + } + + let mut current = self.shallow_clone(); + for &segment in segments[..segments.len() - 1].iter() { + let next = current.get(segment); + match &next { + VariableType::Object(_) => current = next, + VariableType::Any if allow_fill => { + let VariableType::Object(ref obj) = current else { + return false; + }; + let new_obj = VariableType::empty_object(); + obj.borrow_mut() + .insert(Rc::from(segment), new_obj.shallow_clone()); + current = new_obj; + } + _ => return false, + } + } + + let VariableType::Object(ref obj) = current else { + return false; + }; + let final_key = segments[segments.len() - 1]; + obj.borrow_mut() + .insert(Rc::from(final_key), value_type.shallow_clone()); + true + } + + fn with_dollar(&self, field_type: &VariableType) -> VariableType { + let VariableType::Object(ref obj) = self else { + return self.shallow_clone(); + }; + let mut fields: HashMap, VariableType> = obj.borrow().clone(); + fields.insert(Variable::dollar_key(), field_type.shallow_clone()); + VariableType::Object(Rc::new(RefCell::new(fields))) + } + + fn to_acyclic(&self) -> VariableType { + let mut visited: HashSet<*const ()> = HashSet::default(); + AcyclicCloner::clone_type(self, &mut visited) + } + + fn break_cycles(&self) { + let mut visited: HashSet<*const ()> = HashSet::default(); + let mut cells = Vec::new(); + let mut stack: Vec = vec![self.shallow_clone()]; + while let Some(current) = stack.pop() { + match current { + VariableType::Object(obj) => { + if !visited.insert(Rc::as_ptr(&obj) as *const ()) { + continue; + } + stack.extend(obj.borrow().values().map(VariableType::shallow_clone)); + cells.push(obj); + } + VariableType::Array(inner) | VariableType::Nullable(inner) => { + stack.push(inner.shallow_clone()); + } + _ => {} + } + } + for cell in cells { + cell.borrow_mut().clear(); + } + } +} + +struct AcyclicCloner; + +impl AcyclicCloner { + fn clone_type(t: &VariableType, visited: &mut HashSet<*const ()>) -> VariableType { + match t { + VariableType::Any + | VariableType::Null + | VariableType::Bool + | VariableType::String + | VariableType::Number + | VariableType::Date + | VariableType::Interval => t.shallow_clone(), + VariableType::Const(c) => VariableType::Const(c.clone()), + VariableType::Enum(name, values) => VariableType::Enum(name.clone(), values.clone()), + VariableType::Array(inner) => { + VariableType::Array(Rc::new(Self::clone_type(inner, visited))) + } + VariableType::Nullable(inner) => { + VariableType::Nullable(Rc::new(Self::clone_type(inner, visited))) + } + VariableType::Object(obj) => { + let ptr = Rc::as_ptr(obj) as *const (); + if !visited.insert(ptr) { + return VariableType::Any; + } + let mut fields: HashMap, VariableType> = HashMap::new(); + for (k, v) in obj.borrow().iter() { + fields.insert(k.clone(), Self::clone_type(v, visited)); + } + visited.remove(&ptr); + VariableType::Object(Rc::new(RefCell::new(fields))) + } + } + } +} diff --git a/core/engine/src/policy/queries/skeleton.rs b/core/engine/src/policy/queries/skeleton.rs new file mode 100644 index 00000000..9a14cdd7 --- /dev/null +++ b/core/engine/src/policy/queries/skeleton.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; + +use serde_json::{Map, Value}; +use zen_expression::variable::VariableType; + +use crate::policy::db::Db; +use crate::policy::types::ScopeRequest; + +impl Db { + pub fn input_skeleton(&self, req: &ScopeRequest) -> Value { + let mut builder = SkeletonBuilder::new(); + for input in self.inputs(req) { + builder.insert(&input.path, &input.resolved_type); + } + builder.into_value() + } +} + +struct SkeletonBuilder { + root: Map, +} + +impl SkeletonBuilder { + fn new() -> Self { + Self { root: Map::new() } + } + + fn into_value(self) -> Value { + Value::Object(self.root) + } + + fn insert(&mut self, path: &Arc, ty: &VariableType) { + let value = Self::default_for(ty); + let mut segments = path.split('.').peekable(); + let mut current = &mut self.root; + while let Some(seg) = segments.next() { + if segments.peek().is_none() { + current.insert(seg.to_string(), value); + return; + } + let entry = current + .entry(seg.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !entry.is_object() { + *entry = Value::Object(Map::new()); + } + current = entry.as_object_mut().expect("just ensured object"); + } + } + + fn default_for(ty: &VariableType) -> Value { + match ty { + VariableType::String | VariableType::Date | VariableType::Interval => { + Value::String(String::new()) + } + VariableType::Number => Value::Number(0u64.into()), + VariableType::Bool => Value::Bool(false), + VariableType::Null | VariableType::Any => Value::Null, + VariableType::Nullable(_) => Value::Null, + VariableType::Array(inner) => Value::Array(vec![Self::default_for(inner)]), + VariableType::Const(c) => Value::String(c.to_string()), + VariableType::Enum(_, values) => values + .first() + .map(|v| Value::String(v.to_string())) + .unwrap_or(Value::Null), + VariableType::Object(obj) => { + let mut out = Map::new(); + for (k, v) in obj.borrow().iter() { + out.insert(k.to_string(), Self::default_for(v)); + } + Value::Object(out) + } + } + } +} diff --git a/core/engine/src/policy/raw.rs b/core/engine/src/policy/raw.rs new file mode 100644 index 00000000..0ccc9c94 --- /dev/null +++ b/core/engine/src/policy/raw.rs @@ -0,0 +1,351 @@ +use std::sync::Arc; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::policy::blocks::{AssertionDoc, DecisionTableDoc, ExpressionDoc, MatchDoc}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyDocument { + #[serde(default)] + pub imports: Vec>, + pub blocks: Vec, +} + +#[derive(Debug, Clone)] +pub enum BlockDoc { + Assertion { + id: Arc, + data: AssertionDoc, + }, + DecisionTable { + id: Arc, + data: DecisionTableDoc, + }, + Expression { + id: Arc, + data: ExpressionDoc, + }, + Match { + id: Arc, + data: MatchDoc, + }, + DataModel { + id: Arc, + data: DataModelDoc, + }, + Ignored(serde_json::Value), +} + +impl BlockDoc { + pub fn id(&self) -> Option<&str> { + match self { + Self::Assertion { id, .. } + | Self::DecisionTable { id, .. } + | Self::Expression { id, .. } + | Self::Match { id, .. } + | Self::DataModel { id, .. } => Some(id), + Self::Ignored(value) => value.get("id").and_then(serde_json::Value::as_str), + } + } + + fn decode_known(tag: BlockTag, value: serde_json::Value) -> Result { + use serde::de::Error; + + let BlockEnvelope { id, props } = serde_json::from_value(value)?; + let data = props + .data + .ok_or_else(|| serde_json::Error::missing_field("data"))?; + + match tag { + BlockTag::Assertion => Ok(Self::Assertion { + id, + data: serde_json::from_value(data)?, + }), + BlockTag::DecisionTable => Ok(Self::DecisionTable { + id, + data: DecisionTableDoc::decode_wire(data).map_err(serde_json::Error::custom)?, + }), + BlockTag::Expression => Ok(Self::Expression { + id, + data: serde_json::from_value(data)?, + }), + BlockTag::Match => Ok(Self::Match { + id, + data: serde_json::from_value(data)?, + }), + BlockTag::DataModel => Ok(Self::DataModel { + id, + data: serde_json::from_value(data)?, + }), + } + } +} + +impl<'de> Deserialize<'de> for BlockDoc { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + + let value = serde_json::Value::deserialize(deserializer)?; + let tag = match value.get("type") { + Some(serde_json::Value::String(name)) => BlockTag::from_name(name), + Some(_) => return Err(Error::custom("block `type` must be a string")), + None => return Err(Error::missing_field("type")), + }; + + match tag { + Some(tag) => Self::decode_known(tag, value).map_err(Error::custom), + None => Ok(Self::Ignored(value)), + } + } +} + +impl Serialize for BlockDoc { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Assertion { id, data } => { + TaggedBlockRef::new(BlockTag::Assertion, id, data).serialize(serializer) + } + Self::DecisionTable { id, data } => { + TaggedBlockRef::new(BlockTag::DecisionTable, id, data).serialize(serializer) + } + Self::Expression { id, data } => { + TaggedBlockRef::new(BlockTag::Expression, id, data).serialize(serializer) + } + Self::Match { id, data } => { + TaggedBlockRef::new(BlockTag::Match, id, data).serialize(serializer) + } + Self::DataModel { id, data } => { + TaggedBlockRef::new(BlockTag::DataModel, id, data).serialize(serializer) + } + Self::Ignored(value) => value.serialize(serializer), + } + } +} + +#[derive(Clone, Copy)] +enum BlockTag { + Assertion, + DecisionTable, + Expression, + Match, + DataModel, +} + +impl BlockTag { + fn from_name(name: &str) -> Option { + match name { + "assertion" => Some(Self::Assertion), + "decisionTable" => Some(Self::DecisionTable), + "expression" => Some(Self::Expression), + "match" => Some(Self::Match), + "dataModel" => Some(Self::DataModel), + _ => None, + } + } + + fn name(self) -> &'static str { + match self { + Self::Assertion => "assertion", + Self::DecisionTable => "decisionTable", + Self::Expression => "expression", + Self::Match => "match", + Self::DataModel => "dataModel", + } + } +} + +#[derive(Deserialize)] +struct BlockEnvelope { + id: Arc, + props: PropsEnvelope, +} + +#[derive(Deserialize)] +struct PropsEnvelope { + #[serde(default)] + data: Option, +} + +#[derive(Serialize)] +struct TaggedBlockRef<'a, T> { + #[serde(rename = "type")] + kind: &'static str, + id: &'a Arc, + props: PropsRef<'a, T>, +} + +impl<'a, T> TaggedBlockRef<'a, T> { + fn new(tag: BlockTag, id: &'a Arc, data: &'a T) -> Self { + Self { + kind: tag.name(), + id, + props: PropsRef { data }, + } + } +} + +#[derive(Serialize)] +struct PropsRef<'a, T> { + data: &'a T, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DataModelDoc { + pub name: Arc, + #[serde(default)] + pub scope: ScopeDoc, + #[serde(default)] + pub properties: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ScopeDoc { + #[default] + Entity, + Global, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PropertyDoc { + pub id: Arc, + pub name: Arc, + #[serde(flatten)] + pub property_type: PropertyTypeDoc, + #[serde(default)] + pub array: bool, + #[serde(default)] + pub optional: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PropertyTypeDoc { + String { + #[serde(default, rename = "enum", skip_serializing_if = "Option::is_none")] + values: Option>>, + }, + Number, + Boolean, + Date, + Relationship { + target: Arc, + }, + Reference { + target: Arc, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_block_round_trips_losslessly() { + let doc_json = serde_json::json!({ + "blocks": [ + {"type": "someLayoutBlock", "foo": 1} + ] + }); + + let doc: PolicyDocument = serde_json::from_value(doc_json).unwrap(); + assert!(matches!(doc.blocks.as_slice(), [BlockDoc::Ignored(_)])); + + let serialized = serde_json::to_value(&doc).unwrap(); + assert_eq!( + serialized["blocks"][0], + serde_json::json!({"type": "someLayoutBlock", "foo": 1}) + ); + } + + #[test] + fn known_block_round_trips() { + let block_json = serde_json::json!({ + "type": "expression", + "id": "b1", + "props": {"data": {"key": "a.b", "value": "1 + 1"}} + }); + + let block: BlockDoc = serde_json::from_value(block_json.clone()).unwrap(); + assert!(matches!(block, BlockDoc::Expression { .. })); + + let serialized = serde_json::to_value(&block).unwrap(); + assert_eq!(serialized, block_json); + } + + #[test] + fn ignored_block_exposes_id() { + let with_id: BlockDoc = + serde_json::from_value(serde_json::json!({"type": "someLayoutBlock", "id": "b1"})) + .unwrap(); + assert_eq!(with_id.id(), Some("b1")); + + let without_id: BlockDoc = + serde_json::from_value(serde_json::json!({"type": "someLayoutBlock"})).unwrap(); + assert_eq!(without_id.id(), None); + + let non_string_id: BlockDoc = + serde_json::from_value(serde_json::json!({"type": "someLayoutBlock", "id": 1})) + .unwrap(); + assert_eq!(non_string_id.id(), None); + } + + #[test] + fn upsert_by_id_replaces_ignored_block() { + let mut doc: PolicyDocument = serde_json::from_value(serde_json::json!({ + "blocks": [ + {"type": "someLayoutBlock", "id": "b1"} + ] + })) + .unwrap(); + + let new_block: BlockDoc = serde_json::from_value(serde_json::json!({ + "type": "expression", + "id": "b1", + "props": {"data": {"key": "a.b", "value": "1 + 1"}} + })) + .unwrap(); + let new_id = new_block.id().unwrap().to_string(); + + match doc + .blocks + .iter() + .position(|b| b.id() == Some(new_id.as_str())) + { + Some(pos) => doc.blocks[pos] = new_block, + None => doc.blocks.push(new_block), + } + + assert_eq!(doc.blocks.len(), 1); + assert!(matches!(doc.blocks[0], BlockDoc::Expression { .. })); + } + + #[test] + fn block_without_type_errors() { + let missing = serde_json::json!({"id": "b1", "props": {"data": {}}}); + let non_string = serde_json::json!({"type": 1, "id": "b1"}); + + assert!(serde_json::from_value::(missing).is_err()); + assert!(serde_json::from_value::(non_string).is_err()); + } + + #[test] + fn known_block_with_bad_payload_errors() { + let block_json = serde_json::json!({ + "type": "expression", + "id": "b1", + "props": {} + }); + + assert!(serde_json::from_value::(block_json).is_err()); + } +} diff --git a/core/engine/src/policy/refs.rs b/core/engine/src/policy/refs.rs new file mode 100644 index 00000000..9b6a3d87 --- /dev/null +++ b/core/engine/src/policy/refs.rs @@ -0,0 +1,42 @@ +use std::rc::Rc; +use std::sync::Arc; + +use ahash::{HashMap, HashMapExt}; +use zen_expression::variable::Variable; + +pub struct RefPoolIndex { + by_target: HashMap, HashMap, Variable>>, +} + +impl RefPoolIndex { + pub fn from_input(input: &Variable, ref_targets: impl IntoIterator>) -> Self { + let mut by_target: HashMap, HashMap, Variable>> = HashMap::new(); + for target in ref_targets { + let pool: HashMap, Variable> = input + .dot(target.as_ref()) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.borrow() + .iter() + .filter_map(|item| { + let id = item.dot("id")?.as_rc_str()?; + Some((id, item.shallow_clone())) + }) + .collect() + }) + .unwrap_or_default(); + by_target.insert(target, pool); + } + Self { by_target } + } + + pub fn contains(&self, target: &str, id: &Rc) -> bool { + self.by_target + .get(target) + .is_some_and(|p| p.contains_key(id)) + } + + pub fn pool_for(&self, target: &str) -> Option<&HashMap, Variable>> { + self.by_target.get(target) + } +} diff --git a/core/engine/src/policy/runtime.rs b/core/engine/src/policy/runtime.rs new file mode 100644 index 00000000..3a9db28e --- /dev/null +++ b/core/engine/src/policy/runtime.rs @@ -0,0 +1,219 @@ +use std::collections::VecDeque; +use std::sync::Arc; + +use ahash::{HashMap, HashSet, HashSetExt}; +use zen_expression::variable::Variable; + +use crate::decision::Decision; +use crate::decision_graph::graph::{DecisionGraphResponse, EvaluationTrace}; +use crate::engine::EvaluationOptions; +use crate::loader::DynamicLoader; +use crate::model::{DecisionContent, GraphContent}; +use crate::policy::evaluator::EvalArtifact; +use crate::policy::raw::PolicyDocument; +use crate::policy::types::{ + Diagnostic, EvaluateRequest, EvaluationError as PolicyEvaluationError, Severity, +}; +use crate::policy::workspace::PolicyWorkspace; +use crate::{CompileFailure, EvaluationError}; + +pub(crate) async fn evaluate_policy( + loader: &DynamicLoader, + entry_key: &str, + entry_content: Arc, + input: Variable, + options: EvaluationOptions, +) -> Result> { + let entry_path: Arc = Arc::from(entry_key); + + let documents = collect_transitive_policies(loader, entry_path.clone(), entry_content).await?; + + let mut workspace = PolicyWorkspace::new(); + for (path, doc) in documents { + workspace.set_policy_arc(path, doc); + } + + let blocking_errors = workspace + .all_diagnostics() + .into_iter() + .filter(|d| d.severity == Severity::Error) + .count(); + if blocking_errors > 0 { + return Err(Box::new(EvaluationError::Policy( + PolicyEvaluationError::CompilationErrors { + policy_path: entry_path.clone(), + }, + ))); + } + + let request = EvaluateRequest { + policy_path: entry_path, + input, + goals: vec![], + trace: options.trace, + }; + + let result = workspace + .evaluate(&request) + .map_err(|e| Box::new(EvaluationError::Policy(e)))?; + + Ok(DecisionGraphResponse { + performance: format!("{:.1?}", result.duration), + result: result.output, + trace: result.trace.map(EvaluationTrace::Policy), + }) +} + +async fn collect_transitive_policies( + loader: &DynamicLoader, + entry_path: Arc, + entry_content: Arc, +) -> Result, Arc)>, Box> { + let mut documents: Vec<(Arc, Arc)> = Vec::new(); + let mut enqueued: HashSet> = HashSet::new(); + let mut queue: VecDeque<(Arc, Arc)> = VecDeque::new(); + + enqueued.insert(entry_path.clone()); + queue.push_back((entry_path, entry_content)); + + while let Some((path, content)) = queue.pop_front() { + let doc = match content.as_ref() { + DecisionContent::Policy(policy) => policy.0.clone(), + DecisionContent::Graph(_) => { + return Err(Box::new(EvaluationError::ContentKindMismatch { + expected: "policy", + got: "graph", + key: path, + })); + } + }; + + for import_path in &doc.imports { + if !enqueued.insert(import_path.clone()) { + continue; + } + let next = loader + .load(import_path.as_ref()) + .await + .map_err(|e| Box::::from(e))?; + queue.push_back((import_path.clone(), next)); + } + + documents.push((path, doc)); + } + + Ok(documents) +} + +#[derive(Clone)] +pub(crate) enum CompiledEntry { + Policy(Arc), + Graph(Arc), +} + +pub(crate) struct CompiledSet { + entries: HashMap, CompiledEntry>, + failures: Vec, +} + +impl CompiledSet { + pub(crate) fn build_sync(loader: &DynamicLoader, keys: &[Arc]) -> CompiledSet { + let mut workspace = PolicyWorkspace::new(); + let mut policy_keys: Vec> = Vec::new(); + let mut failures: Vec = Vec::new(); + let mut entries: HashMap, CompiledEntry> = HashMap::default(); + + for key in keys { + let Some(load_result) = loader.load_sync(key.as_ref()) else { + continue; + }; + let content = match load_result { + Ok(content) => content, + Err(error) => { + failures.push(CompileFailure { + key: key.clone(), + kind: "load", + diagnostics: Vec::new(), + error: Some(error.to_string()), + }); + continue; + } + }; + match content.as_ref() { + DecisionContent::Policy(policy) => { + workspace.set_policy_arc(key.clone(), policy.0.clone()); + policy_keys.push(key.clone()); + } + DecisionContent::Graph(graph) => { + match Decision::from(Arc::new(graph.clone())).validate() { + Err(error) => failures.push(CompileFailure { + key: key.clone(), + kind: "graph", + diagnostics: Vec::new(), + error: Some(error.to_string()), + }), + Ok(()) => { + let mut compiled = graph.clone(); + compiled.compile(); + entries.insert(key.clone(), CompiledEntry::Graph(Arc::new(compiled))); + } + } + } + } + } + + for key in &policy_keys { + let diagnostics = Self::closure_error_diagnostics(&workspace, key); + if diagnostics.is_empty() { + entries.insert( + key.clone(), + CompiledEntry::Policy(workspace.eval_artifact(key)), + ); + } else { + failures.push(CompileFailure { + key: key.clone(), + kind: "policy", + diagnostics, + error: None, + }); + } + } + + CompiledSet { entries, failures } + } + + fn closure_error_diagnostics(workspace: &PolicyWorkspace, key: &Arc) -> Vec { + let mut diagnostics: Vec = Vec::new(); + let mut enqueued: HashSet> = HashSet::new(); + let mut queue: VecDeque> = VecDeque::new(); + + enqueued.insert(key.clone()); + queue.push_back(key.clone()); + + while let Some(path) = queue.pop_front() { + diagnostics.extend( + workspace + .diagnostics(path.as_ref()) + .into_iter() + .filter(|d| d.severity == Severity::Error), + ); + if let Some(doc) = workspace.get_policy(path.as_ref()) { + for import_path in &doc.imports { + if enqueued.insert(import_path.clone()) { + queue.push_back(import_path.clone()); + } + } + } + } + + diagnostics + } + + pub(crate) fn get(&self, key: &str) -> Option { + self.entries.get(key).cloned() + } + + pub(crate) fn failures(&self) -> &[CompileFailure] { + &self.failures + } +} diff --git a/core/engine/src/policy/types/cursor.rs b/core/engine/src/policy/types/cursor.rs new file mode 100644 index 00000000..30f7d6bf --- /dev/null +++ b/core/engine/src/policy/types/cursor.rs @@ -0,0 +1,89 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use zen_expression::variable::VariableType; + +use super::Span; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Cursor { + pub policy_path: Arc, + pub block_id: Arc, + pub pos: u32, + pub target: CursorTarget, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum CursorTarget { + Expression { id: Arc }, + AssertionOutput, + ExpressionKey, + MatchTarget, + MatchValue { id: Arc }, + DecisionTableHead { col: Arc }, + DecisionTableCell { row: Arc, col: Arc }, + DataModelName, + DataModelProperty { id: Arc }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ExpressionKind { + Standard, + Unary, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InspectResult { + pub span: Span, + pub kind: VariableType, + pub label: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PrepareRename { + pub target: RenameTarget, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum RenameTarget { + Entity { name: Arc }, + Field { entity: Arc, field: Arc }, + Global { name: Arc }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReferenceSite { + pub policy_path: Arc, + pub block_id: Arc, + #[serde(skip_serializing_if = "Option::is_none")] + pub expression_id: Option>, + pub source: Arc, + pub span: Span, + pub kind: ReferenceKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ReferenceKind { + ExpressionRead, + WriteKey, + DataModel, +} + +impl ReferenceKind { + pub fn display_order(self) -> u8 { + match self { + ReferenceKind::DataModel => 0, + ReferenceKind::ExpressionRead => 1, + ReferenceKind::WriteKey => 2, + } + } +} diff --git a/core/engine/src/policy/types/diagnostic.rs b/core/engine/src/policy/types/diagnostic.rs new file mode 100644 index 00000000..4f2238f8 --- /dev/null +++ b/core/engine/src/policy/types/diagnostic.rs @@ -0,0 +1,255 @@ +use std::sync::Arc; + +use serde::Serialize; + +use super::CursorTarget; + +pub type Span = (u32, u32); + +pub(crate) struct SpanOps; + +impl SpanOps { + pub(crate) fn char_len(s: &str) -> u32 { + s.chars().count() as u32 + } + + pub(crate) fn replace_at_char_spans(source: &str, spans: &[Span], new_text: &str) -> String { + let mut sorted = spans.to_vec(); + sorted.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1))); + sorted.dedup(); + let mut last_end: Option = None; + sorted.retain(|&(start, end)| match last_end { + Some(prev_end) if start < prev_end => false, + _ => { + last_end = Some(end); + true + } + }); + + let mut out = source.to_string(); + for (char_start, char_end) in sorted.into_iter().rev() { + let byte_start = out + .char_indices() + .nth(char_start as usize) + .map_or(out.len(), |(b, _)| b); + let byte_end = out + .char_indices() + .nth(char_end as usize) + .map_or(out.len(), |(b, _)| b); + out.replace_range(byte_start..byte_end, new_text); + } + out + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Diagnostic { + pub code: DiagnosticCode, + pub message: String, + pub severity: Severity, + pub location: DiagnosticLocation, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiagnosticLocation { + pub policy_path: Arc, + #[serde(skip_serializing_if = "Option::is_none")] + pub block_id: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub expression_id: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub span: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +impl DiagnosticLocation { + pub fn policy(policy_path: Arc) -> Self { + Self { + policy_path, + block_id: None, + expression_id: None, + span: None, + target: None, + } + } + + pub fn block(policy_path: Arc, block_id: Arc) -> Self { + Self { + policy_path, + block_id: Some(block_id), + expression_id: None, + span: None, + target: None, + } + } + + pub fn expression( + policy_path: Arc, + block_id: Arc, + expression_id: Arc, + span: Option, + ) -> Self { + Self { + policy_path, + block_id: Some(block_id), + expression_id: Some(expression_id), + span, + target: None, + } + } + + pub fn with_target(mut self, target: CursorTarget) -> Self { + self.target = Some(target); + self + } + + pub fn maybe_target(self, target: Option) -> Self { + match target { + Some(t) => self.with_target(t), + None => self, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Severity { + Error, + Warning, + Hint, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DiagnosticCode { + ParseError, + + UndefinedVariable, + TypeMismatch, + InvalidExpression, + + EmptyBlock, + MissingDefaultBranch, + MixedScope, + MaxDepthExceeded, + + CyclicDependency, + DuplicateWriter, + InvalidWritePath, + InputOverride, + SelfReferencingWrite, + UnreachableEntityRead, + PartialObjectWrite, + UnsupportedNestedIteration, + + DataModelCollision, + UnknownDataModelTarget, + DuplicateProperty, + DuplicateEnumValue, + InvalidName, + + ImportNotFound, + CircularImport, + + RedundantNullish, + RepeatedDerivation, + PreferMatch, + RedundantTableRow, + NonDiscriminatingColumn, + RedundantParentheses, +} + +impl DiagnosticCode { + pub(crate) fn from_expression_diagnostic( + diag: &zen_expression::intellisense::diagnostic::Diagnostic, + ) -> DiagnosticCode { + use zen_expression::intellisense::diagnostic::DiagnosticSource; + match diag.source { + DiagnosticSource::Lexer | DiagnosticSource::Parser => DiagnosticCode::ParseError, + DiagnosticSource::Compiler => DiagnosticCode::InvalidExpression, + DiagnosticSource::TypeCheck => { + let msg = diag.message.to_lowercase(); + if msg.contains("left-hand side of `??`") { + DiagnosticCode::RedundantNullish + } else if msg.contains("cannot be applied") + || msg.contains("cannot be used to index") + || msg.contains("incompatible") + { + DiagnosticCode::TypeMismatch + } else if msg.contains("not a valid member") { + DiagnosticCode::UndefinedVariable + } else { + DiagnosticCode::InvalidExpression + } + } + } + } +} + +impl Diagnostic { + pub fn error( + code: DiagnosticCode, + location: DiagnosticLocation, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + severity: Severity::Error, + location, + } + } + + pub fn warning( + code: DiagnosticCode, + location: DiagnosticLocation, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + severity: Severity::Warning, + location, + } + } + + pub fn hint( + code: DiagnosticCode, + location: DiagnosticLocation, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + severity: Severity::Hint, + location, + } + } + + pub(crate) fn from_expression( + diag: &zen_expression::intellisense::diagnostic::Diagnostic, + location: DiagnosticLocation, + ) -> Self { + let severity = match diag.severity { + zen_expression::intellisense::diagnostic::Severity::Error => Severity::Error, + zen_expression::intellisense::diagnostic::Severity::Warning => Severity::Warning, + zen_expression::intellisense::diagnostic::Severity::Hint => Severity::Hint, + }; + Self { + code: DiagnosticCode::from_expression_diagnostic(diag), + message: diag.message.clone(), + severity, + location: DiagnosticLocation { + span: location.span.or(Some(diag.span)), + ..location + }, + } + } + + pub fn is_in(&self, policy_path: &Arc) -> bool { + self.location.policy_path == *policy_path + } +} diff --git a/core/engine/src/policy/types/edit.rs b/core/engine/src/policy/types/edit.rs new file mode 100644 index 00000000..22c0f9ad --- /dev/null +++ b/core/engine/src/policy/types/edit.rs @@ -0,0 +1,28 @@ +use std::sync::Arc; + +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum EngineEdit { + ReplaceBlock { + policy_path: Arc, + block_id: Arc, + new_block: Value, + }, + DeleteBlock { + policy_path: Arc, + block_id: Arc, + }, + InsertBlock { + policy_path: Arc, + #[serde(skip_serializing_if = "Option::is_none")] + after_block_id: Option>, + new_block: Value, + }, +} diff --git a/core/engine/src/policy/types/error.rs b/core/engine/src/policy/types/error.rs new file mode 100644 index 00000000..0b4bda44 --- /dev/null +++ b/core/engine/src/policy/types/error.rs @@ -0,0 +1,133 @@ +use std::fmt; +use std::sync::Arc; + +use serde::ser::SerializeMap; +use thiserror::Error; +use zen_expression::IsolateError; + +#[derive(Debug, Clone)] +pub struct InputValidationError { + pub path: String, + pub expected: String, + pub got: String, +} + +impl fmt::Display for InputValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "'{}': expected {}, got {}", + self.path, self.expected, self.got + ) + } +} + +#[derive(Debug, Error)] +pub enum EvaluationError { + #[error("policy '{0}' not found in workspace")] + PolicyNotFound(Arc), + + #[error("policy '{policy_path}' imports '{import}' which is not in the workspace")] + ImportNotFound { + policy_path: Arc, + import: Arc, + }, + + #[error("policy '{policy_path}' has compilation errors; cannot evaluate")] + CompilationErrors { policy_path: Arc }, + + #[error("goal property '{0}' not found in policy")] + GoalNotFound(Arc), + + #[error( + "missing required inputs [{}] for goals [{}]", + FormatList(.missing), + FormatList(.goals) + )] + MissingRequiredInputs { + goals: Vec>, + missing: Vec>, + }, + + #[error("input validation failed: {}", FormatList(.errors))] + InputValidationFailed { errors: Vec }, + + #[error( + "expression `{expression}` failed in block '{block_id}' (policy '{policy_path}'): {source}" + )] + ExpressionFailed { + policy_path: Arc, + block_id: Arc, + expression: Arc, + source: IsolateError, + partial_trace: Option>, + }, +} + +impl EvaluationError { + pub fn serialize_into_map(&self, map: &mut M) -> Result<(), M::Error> { + match self { + Self::PolicyNotFound(path) => { + map.serialize_entry("kind", "PolicyNotFound")?; + map.serialize_entry("policyPath", path)?; + } + Self::ImportNotFound { + policy_path, + import, + } => { + map.serialize_entry("kind", "ImportNotFound")?; + map.serialize_entry("policyPath", policy_path)?; + map.serialize_entry("import", import)?; + } + Self::CompilationErrors { policy_path } => { + map.serialize_entry("kind", "CompilationErrors")?; + map.serialize_entry("policyPath", policy_path)?; + } + Self::GoalNotFound(name) => { + map.serialize_entry("kind", "GoalNotFound")?; + map.serialize_entry("goal", name)?; + } + Self::MissingRequiredInputs { goals, missing } => { + map.serialize_entry("kind", "MissingRequiredInputs")?; + map.serialize_entry("goals", goals)?; + map.serialize_entry("missing", missing)?; + } + Self::InputValidationFailed { errors } => { + map.serialize_entry("kind", "InputValidationFailed")?; + let messages: Vec = errors.iter().map(|e| e.to_string()).collect(); + map.serialize_entry("errors", &messages)?; + } + Self::ExpressionFailed { + policy_path, + block_id, + expression, + source, + partial_trace, + } => { + map.serialize_entry("kind", "ExpressionFailed")?; + map.serialize_entry("policyPath", policy_path)?; + map.serialize_entry("blockId", block_id)?; + map.serialize_entry("expression", expression)?; + map.serialize_entry("source", &source.to_string())?; + if let Some(trace) = partial_trace { + map.serialize_entry("trace", trace)?; + } + } + } + Ok(()) + } +} + +struct FormatList<'a, T>(&'a [T]); + +impl fmt::Display for FormatList<'_, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, item) in self.0.iter().enumerate() { + if i > 0 { + write!(f, "; ")?; + } + write!(f, "{item}")?; + } + Ok(()) + } +} diff --git a/core/engine/src/policy/types/mod.rs b/core/engine/src/policy/types/mod.rs new file mode 100644 index 00000000..895fa346 --- /dev/null +++ b/core/engine/src/policy/types/mod.rs @@ -0,0 +1,23 @@ +mod cursor; +mod diagnostic; +mod edit; +mod error; +mod request; +mod result; + +pub use cursor::{ + Cursor, CursorTarget, ExpressionKind, InspectResult, PrepareRename, ReferenceKind, + ReferenceSite, RenameTarget, +}; +pub(crate) use diagnostic::SpanOps; +pub use diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLocation, Severity, Span}; +pub use edit::EngineEdit; +pub use error::{EvaluationError, InputValidationError}; +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, +}; diff --git a/core/engine/src/policy/types/request.rs b/core/engine/src/policy/types/request.rs new file mode 100644 index 00000000..3b72c5f6 --- /dev/null +++ b/core/engine/src/policy/types/request.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use zen_expression::variable::Variable; + +#[derive(Debug, Clone)] +pub struct EvaluateRequest { + pub policy_path: Arc, + pub input: Variable, + pub goals: Vec>, + pub trace: bool, +} + +#[derive(Debug, Clone)] +pub struct ScopeRequest { + pub policy_path: Arc, + pub goals: Vec>, +} + +impl ScopeRequest { + pub fn for_policy(policy_path: impl Into>) -> Self { + Self { + policy_path: policy_path.into(), + goals: Vec::new(), + } + } +} diff --git a/core/engine/src/policy/types/result.rs b/core/engine/src/policy/types/result.rs new file mode 100644 index 00000000..a5f2e295 --- /dev/null +++ b/core/engine/src/policy/types/result.rs @@ -0,0 +1,263 @@ +use std::sync::Arc; +use std::time::Duration; + +use ahash::HashMap; +use serde::Serialize; +use zen_expression::intellisense::completion::Completion as _Completion; +use zen_expression::variable::{Variable, VariableType}; + +pub type Completion = _Completion; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EvaluationResult { + pub output: Variable, + #[serde(serialize_with = "EvaluationResult::serialize_duration_micros")] + pub duration: Duration, + #[serde(skip_serializing_if = "Option::is_none")] + pub trace: Option, +} + +impl EvaluationResult { + fn serialize_duration_micros( + d: &Duration, + s: S, + ) -> Result { + s.serialize_u128(d.as_micros()) + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Trace { + pub engine_version: Arc, + pub properties: HashMap, Variable>, + pub executions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockExecution { + pub block_id: Arc, + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_path: Option>, + pub trace: BlockTrace, + #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")] + pub operand_values: HashMap, Variable>, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub writes: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub reads: Vec>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteTrace { + pub path: Arc, + pub value: Variable, +} + +#[derive(Debug, Clone, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum BlockTrace { + Assertion { + result: bool, + conditions: Vec, + }, + DecisionTable { + matched_rows: Vec, + evaluations: Vec, Variable>>, + #[serde(skip_serializing_if = "Option::is_none")] + extras: Option, + }, + Expression { + property: Arc, + value: Variable, + }, + Match { + #[serde(skip_serializing_if = "Option::is_none")] + matched_arm: Option>, + value: Variable, + arms: Vec, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DecisionTableExtras { + pub input_pass: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConditionTrace { + pub id: Arc, + pub result: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteConflict { + pub path: Arc, + pub policies: Vec>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InputProperty { + pub path: Arc, + pub resolved_type: VariableType, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OutputProperty { + pub path: Arc, + pub resolved_type: VariableType, + pub kind: PropertyKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub written_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_of: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstanceTarget { + pub target: Arc, + pub array: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, strum::Display)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum PropertyKind { + Input, + Computed, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GuardedProperty { + pub path: Arc, + pub resolved_type: VariableType, + #[serde(skip_serializing_if = "Option::is_none")] + pub required_when: Option>, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SchemaGroup { + pub inputs: Vec, + pub outputs: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscriminantVariant { + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option>, + pub arm: Arc, + pub group: SchemaGroup, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscriminatedUnion { + pub property: Arc, + pub resolved_type: VariableType, + pub variants: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum ConditionalSchema { + Union { + common: SchemaGroup, + union: DiscriminatedUnion, + }, + Flat { + common: SchemaGroup, + conditional: SchemaGroup, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockRef { + pub policy_path: Arc, + pub block_id: Arc, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DependencyNode { + pub property: Arc, + #[serde(skip_serializing_if = "Option::is_none")] + pub written_by: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub unresolved: bool, + pub resolved_type: VariableType, + pub deps: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Entity { + pub name: Arc, + pub fields: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Global { + pub name: Arc, + pub resolved_type: VariableType, + pub origin: FieldOrigin, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EntityField { + pub name: Arc, + pub resolved_type: VariableType, + pub origin: FieldOrigin, +} + +#[derive(Debug, Clone, Serialize)] +#[serde( + tag = "origin", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum FieldOrigin { + Schema { + source: Arc, + #[serde(rename = "fieldKind")] + kind: SchemaFieldKind, + }, + Computed { + written_by: BlockRef, + #[serde(skip_serializing_if = "Option::is_none")] + instance_of: Option, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum SchemaFieldKind { + Scalar, + Enum { values: Vec>, array: bool }, + Relationship { target: Arc, array: bool }, + Reference { target: Arc, array: bool }, +} diff --git a/core/engine/src/policy/validator.rs b/core/engine/src/policy/validator.rs new file mode 100644 index 00000000..ea9be20e --- /dev/null +++ b/core/engine/src/policy/validator.rs @@ -0,0 +1,285 @@ +use std::sync::Arc; + +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::refs::RefPoolIndex; +use crate::policy::types::InputValidationError; +use crate::policy::MAX_RECURSION_DEPTH; + +impl Db { + pub(crate) fn input_schema(&self, policy_path: &str) -> InputSchema { + let entities = self.visible_entities(policy_path); + let globals = self.visible_globals(policy_path); + let visible_dms = self.visible_data_models(policy_path); + let (roots, ref_targets) = + DataModelIr::classify_roots(visible_dms.iter().map(|dm| dm.as_ref())); + InputSchema { + entities, + globals, + roots, + ref_targets, + } + } + + fn visible_globals(&self, policy_path: &str) -> HashMap, Property> { + let visible = self.visible_policies(policy_path); + let mut sorted: Vec> = visible.iter().cloned().collect(); + sorted.sort(); + let mut out: HashMap, Property> = HashMap::new(); + for pp in &sorted { + let Some(parsed) = self.parsed(pp) else { + continue; + }; + for (_, dm) in parsed.policy.global_data_models() { + for prop in &dm.properties { + out.entry(prop.name.clone()).or_insert_with(|| prop.clone()); + } + } + } + out + } + + fn visible_data_models(&self, policy_path: &str) -> Vec> { + let entities = self.visible_entities(policy_path); + let visible = self.visible_policies(policy_path); + let mut sorted: Vec> = visible.iter().cloned().collect(); + sorted.sort(); + let mut out: Vec> = entities.values().map(|d| d.name.clone()).collect(); + out.sort(); + let mut result: Vec> = out + .into_iter() + .filter_map(|name| entities.get(&name).cloned()) + .collect(); + for pp in &sorted { + let Some(parsed) = self.parsed(pp) else { + continue; + }; + for (_, dm) in parsed.policy.global_data_models() { + result.push(Arc::new(dm.clone())); + } + } + result + } +} + +pub(crate) struct InputSchema { + entities: Arc, Arc>>, + globals: HashMap, Property>, + roots: HashSet>, + ref_targets: HashSet>, +} + +impl InputSchema { + pub(crate) fn validate(&self, input: &Variable) -> Vec { + let ref_pools = RefPoolIndex::from_input(input, self.ref_targets.iter().cloned()); + let mut validator = InputValidator { + entities: &self.entities, + ref_pools: &ref_pools, + errors: Vec::new(), + depth: 0, + }; + + let Some(input_obj) = input.as_object() else { + if !matches!(input, Variable::Null) { + validator.errors.push(InputValidationError { + path: String::new(), + expected: "object".into(), + got: input.type_name().into(), + }); + } + return validator.errors; + }; + + for (key, val) in input_obj.borrow().iter() { + if matches!(val, Variable::Null) { + continue; + } + let key_str: &str = key.as_ref(); + if self.ref_targets.contains(key_str) { + validator.validate_array_of_entity(val, key_str, key_str.to_string()); + } else if self.roots.contains(key_str) { + validator.validate_entity(val, key_str, key_str.to_string()); + } else if let Some(prop) = self.globals.get(key_str) { + validator.validate_global(val, prop, key_str.to_string()); + } + } + + validator.errors + } +} + +struct InputValidator<'a> { + entities: &'a HashMap, Arc>, + ref_pools: &'a RefPoolIndex, + errors: Vec, + depth: usize, +} + +impl InputValidator<'_> { + fn validate_entity(&mut self, value: &Variable, entity_name: &str, path: String) { + if self.depth >= MAX_RECURSION_DEPTH { + self.errors.push(InputValidationError { + path, + expected: format!("entity nesting within {MAX_RECURSION_DEPTH} levels"), + got: "deeper".into(), + }); + return; + } + let Some(obj) = value.as_object() else { + self.errors.push(InputValidationError { + path, + expected: format!("object ({entity_name})"), + got: value.type_name().into(), + }); + return; + }; + let Some(dm) = self.entities.get(entity_name) else { + return; + }; + + self.depth += 1; + let dm_props = dm.clone(); + for (key, val) in obj.borrow().iter() { + if matches!(val, Variable::Null) { + continue; + } + let Some(prop) = dm_props + .properties + .iter() + .find(|p| p.name.as_ref() == key.as_ref()) + else { + continue; + }; + let child_path = format!("{path}.{}", prop.name); + if prop.array { + self.validate_array_of_property(val, prop, child_path); + } else { + self.validate_kind(val, &prop.kind, child_path); + } + } + self.depth -= 1; + } + + fn validate_global(&mut self, value: &Variable, prop: &Property, path: String) { + if prop.array { + self.validate_array_of_property(value, prop, path); + } else { + self.validate_kind(value, &prop.kind, path); + } + } + + fn validate_array_of_property(&mut self, value: &Variable, prop: &Property, path: String) { + let Some(arr) = value.as_array() else { + self.errors.push(InputValidationError { + path, + expected: format!("array of {}", prop.kind), + got: value.type_name().into(), + }); + return; + }; + for (i, item) in arr.borrow().iter().enumerate() { + if matches!(item, Variable::Null) { + continue; + } + self.validate_kind(item, &prop.kind, format!("{path}[{i}]")); + } + } + + fn validate_array_of_entity(&mut self, value: &Variable, entity_name: &str, path: String) { + let Some(arr) = value.as_array() else { + self.errors.push(InputValidationError { + path, + expected: format!("array of {entity_name}"), + got: value.type_name().into(), + }); + return; + }; + for (i, item) in arr.borrow().iter().enumerate() { + if matches!(item, Variable::Null) { + continue; + } + self.validate_entity(item, entity_name, format!("{path}[{i}]")); + } + } + + fn validate_kind(&mut self, value: &Variable, kind: &PropertyTypeIr, path: String) { + let ok = match kind { + PropertyTypeIr::String => matches!(value, Variable::String(_)), + PropertyTypeIr::Enum(values) => { + self.validate_enum(value, values, path); + return; + } + PropertyTypeIr::Number => matches!(value, Variable::Number(_)), + PropertyTypeIr::Boolean => matches!(value, Variable::Bool(_)), + PropertyTypeIr::Date => matches!(value, Variable::String(_)), + PropertyTypeIr::Reference { target } => { + self.validate_reference(value, target, path); + return; + } + PropertyTypeIr::Relationship { target } => { + self.validate_entity(value, target, path); + return; + } + }; + if !ok { + self.errors.push(InputValidationError { + path, + expected: kind.to_string(), + got: value.type_name().into(), + }); + } + } + + fn validate_enum(&mut self, value: &Variable, values: &[Arc], path: String) { + let Some(s) = value.as_rc_str() else { + self.errors.push(InputValidationError { + path, + expected: format!( + "one of {}", + values + .iter() + .map(|v| format!("'{v}'")) + .collect::>() + .join(", ") + ), + got: value.type_name().into(), + }); + return; + }; + if !values.iter().any(|v| v.as_ref() == s.as_ref()) { + self.errors.push(InputValidationError { + path, + expected: format!( + "one of {}", + values + .iter() + .map(|v| format!("'{v}'")) + .collect::>() + .join(", ") + ), + got: format!("'{s}'"), + }); + } + } + + fn validate_reference(&mut self, value: &Variable, target: &Arc, path: String) { + let Some(id) = value.as_rc_str() else { + self.errors.push(InputValidationError { + path, + expected: format!("reference id (string → {target})"), + got: value.type_name().into(), + }); + return; + }; + if !self.ref_pools.contains(target, &id) { + self.errors.push(InputValidationError { + path, + expected: format!("reference id present in '{target}' pool"), + got: format!("'{id}' (not found)"), + }); + } + } +} diff --git a/core/engine/src/policy/workspace.rs b/core/engine/src/policy/workspace.rs new file mode 100644 index 00000000..c47f6f95 --- /dev/null +++ b/core/engine/src/policy/workspace.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use crate::policy::db::Db; +use crate::policy::evaluator::EvalArtifact; +use crate::policy::raw::PolicyDocument; +use crate::policy::types::{ + Completion, ConditionalSchema, Cursor, DependencyNode, Diagnostic, EngineEdit, Entity, + EvaluateRequest, EvaluationError, EvaluationResult, Global, InputProperty, InspectResult, + OutputProperty, PrepareRename, ReferenceSite, RenameTarget, ScopeRequest, WriteConflict, +}; + +pub struct PolicyWorkspace { + db: Db, +} + +impl PolicyWorkspace { + pub fn new() -> Self { + Self { db: Db::new() } + } + + pub fn set_policy(&mut self, path: impl Into>, document: PolicyDocument) { + self.db.set_policy(path.into(), Arc::new(document)); + } + + pub fn set_policy_arc(&mut self, path: impl Into>, document: Arc) { + self.db.set_policy(path.into(), document); + } + + pub fn remove_policy(&mut self, path: &str) -> bool { + self.db.remove_policy(path) + } + + pub fn policy_paths(&self) -> Vec> { + self.db.policy_paths() + } + + pub fn get_policy(&self, path: &str) -> Option> { + self.db.raw_policy(path) + } + + pub fn evaluate(&self, req: &EvaluateRequest) -> Result { + self.db.evaluate(req) + } + + pub fn enhance_trace( + &self, + req: &EvaluateRequest, + ) -> Result { + self.db.enhance_trace(req) + } + + pub(crate) fn eval_artifact(&self, policy: &str) -> Arc { + self.db.eval_artifact(policy) + } + + pub fn entities(&self, req: &ScopeRequest) -> Vec { + self.db.entities(req) + } + + pub fn globals(&self, req: &ScopeRequest) -> Vec { + self.db.globals(req) + } + + pub fn inputs(&self, req: &ScopeRequest) -> Vec { + self.db.inputs(req) + } + + pub fn outputs(&self, req: &ScopeRequest) -> Vec { + self.db.outputs(req) + } + + pub fn conditional_schema(&self, req: &ScopeRequest) -> ConditionalSchema { + self.db.conditional_schema(req) + } + + pub fn component_members(&self, policy: &str) -> Vec> { + self.db.component_members(policy) + } + + pub fn cross_component_write_conflicts(&self) -> Vec { + self.db.cross_component_write_conflicts() + } + + pub fn diagnostics(&self, path: &str) -> Vec { + let path_arc: Arc = Arc::from(path); + (*self.db.policy_diagnostics(&path_arc)).clone() + } + + pub fn all_diagnostics(&self) -> Vec { + self.db.all_diagnostics() + } + + pub fn inspect(&self, cursor: &Cursor) -> Option { + self.db.inspect(cursor) + } + + pub fn completions(&self, cursor: &Cursor) -> Vec { + self.db.completions(cursor) + } + + pub fn prepare_rename(&self, cursor: &Cursor) -> Option { + self.db.prepare_rename(cursor) + } + + pub fn rename(&self, target: &RenameTarget, new_name: &str) -> Vec { + self.db.rename(target, new_name) + } + + pub fn references(&self, target: &RenameTarget) -> Vec { + self.db.references(target) + } + + pub fn input_skeleton(&self, req: &ScopeRequest) -> serde_json::Value { + self.db.input_skeleton(req) + } + + pub fn dependencies(&self, target: &str) -> DependencyNode { + self.db.dependencies(target) + } +} + +impl Default for PolicyWorkspace { + fn default() -> Self { + Self::new() + } +} diff --git a/core/engine/tests/data/policy/completions.toml b/core/engine/tests/data/policy/completions.toml new file mode 100644 index 00000000..728b458a --- /dev/null +++ b/core/engine/tests/data/policy/completions.toml @@ -0,0 +1,61 @@ +# Policy completion tests +# policies: list of fixture filenames loaded into workspace +# Each test targets a block/expression and checks includes/excludes. +# Optional: head = true targets a decision-table column head; row = "" +# targets a decision-table rule cell. +# +# NOTE: v1 scoped top-level completions to the block's write-root entity. +# v2 exposes every entity in every block scope (cross-entity reads are legal), +# so top-level cases assert all entities are offered; the meaningful scoping +# checks live at the property level (after a dot). + +policies = ["analysis.json"] + +[[test]] +name = "expression top-level: all entities offered" +policy = "analysis.json" +block_id = "ds1" +expression_id = "ds1" +pos = 0 +includes = ["customer", "company", "product", "creditReport"] + +[[test]] +name = "expression top-level: builtins still available" +policy = "analysis.json" +block_id = "ds1" +expression_id = "ds1" +pos = 0 +includes = ["sum", "map", "filter"] + +[[test]] +name = "expression after customer dot: entity properties offered" +policy = "analysis.json" +block_id = "ds1" +expression_id = "ds1" +pos = 17 +includes = ["companies", "name", "age", "country", "creditReport", "favoriteProduct"] + +[[test]] +name = "expression after customer dot: other entities not offered as properties" +policy = "analysis.json" +block_id = "ds1" +expression_id = "ds1" +pos = 17 +excludes = ["company", "product"] + +[[test]] +name = "match arm condition: entities offered" +policy = "analysis.json" +block_id = "f1" +expression_id = "db1" +pos = 0 +includes = ["customer"] + +[[test]] +name = "table output cell: entities offered" +policy = "analysis.json" +block_id = "dt1" +expression_id = "o1" +row = "r1" +pos = 0 +includes = ["customer"] diff --git a/core/engine/tests/data/policy/completions_multi_entity.toml b/core/engine/tests/data/policy/completions_multi_entity.toml new file mode 100644 index 00000000..a208c9c1 --- /dev/null +++ b/core/engine/tests/data/policy/completions_multi_entity.toml @@ -0,0 +1,12 @@ +# Completion scoping: block writes to multiple entities → no scoping restriction + +policies = ["multi_entity_block.json"] + +[[test]] +name = "multi-entity table: all entities offered (no lock)" +policy = "multi_entity_block.json" +block_id = "tree1" +expression_id = "s1" +row = "r1" +pos = 0 +includes = ["customer", "company"] diff --git a/core/engine/tests/data/policy/completions_scoping.toml b/core/engine/tests/data/policy/completions_scoping.toml new file mode 100644 index 00000000..c6552725 --- /dev/null +++ b/core/engine/tests/data/policy/completions_scoping.toml @@ -0,0 +1,88 @@ +# Completion edge cases — different block configurations. +# +# NOTE: v1 restricted top-level completions to the block's write-root entity +# ("scoped" vs "unscoped" blocks). v2 exposes every entity in every block +# scope, so these cases assert availability plus property-level resolution. + +policies = ["company_block.json", "unscoped_block.json", "analysis.json"] + +[[test]] +name = "company block: entities offered at top level" +policy = "company_block.json" +block_id = "s1" +expression_id = "s1" +pos = 0 +includes = ["company", "customer"] + +# Expression: "company.revenue * 0.1" — pos 8 is right after "company." +[[test]] +name = "company block: company properties after dot" +policy = "company_block.json" +block_id = "s1" +expression_id = "s1" +pos = 8 +includes = ["id", "name", "iban", "revenue"] + +# ═══════════════════════════════════════════════════════════════════════════════ +# Scenario: Decision table head cell (column field path) completions +# ═══════════════════════════════════════════════════════════════════════════════ + +# head = true targets the column's `field` (variable path) rather than any rule +# cell. The column o1 has field "customer.discount" — at pos 0 we should see +# the customer entity offered. +[[test]] +name = "table head cell: entity offered at start of column field" +policy = "analysis.json" +block_id = "dt1" +expression_id = "o1" +pos = 0 +head = true +includes = ["customer"] + +# pos 9 is right after "customer." — offers customer's properties. +[[test]] +name = "table head cell: properties offered after dot" +policy = "analysis.json" +block_id = "dt1" +expression_id = "o1" +pos = 9 +head = true +includes = ["companies", "creditReport", "age", "name", "country", "favoriteProduct"] + +# Sanity: a rule cell resolves through the rule-cell path. +[[test]] +name = "table rule cell: rule path still works" +policy = "analysis.json" +block_id = "dt1" +expression_id = "o1" +row = "r1" +pos = 0 +includes = ["customer"] + +# ═══════════════════════════════════════════════════════════════════════════════ +# Blocks with and without write keys all see the full entity set in v2. +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "match block with empty key: all entities offered" +policy = "unscoped_block.json" +block_id = "tree_unscoped" +expression_id = "b1" +pos = 0 +includes = ["customer", "company"] + +[[test]] +name = "expression block with empty key: all entities offered" +policy = "unscoped_block.json" +block_id = "s_unscoped" +expression_id = "s_unscoped" +pos = 0 +includes = ["customer", "company"] + +[[test]] +name = "expression block with customer key: entities still offered" +policy = "unscoped_block.json" +block_id = "s_scoped" +expression_id = "s_scoped" +pos = 0 +includes = ["customer", "company"] diff --git a/core/engine/tests/data/policy/dependencies.toml b/core/engine/tests/data/policy/dependencies.toml new file mode 100644 index 00000000..e12645c2 --- /dev/null +++ b/core/engine/tests/data/policy/dependencies.toml @@ -0,0 +1,110 @@ +# Per-property dependency-lineage tests (`PolicyWorkspace::dependencies`). +# +# policies / content / policy: load policies into the workspace (same as +# diagnostics.toml — fixtures by filename, or inline `content`). +# target: the property whose dependency tree is built. May be a sub-path of a +# computed object/array-of-object (per-field lineage). +# +# Assertions check classification of any node in the tree by its dotted path: +# computed = paths that must resolve to a writer block (lineage continues) +# inputs = paths that must be plain inputs (no writer, resolvable) +# unresolved = paths the analyzer could not pin down (flagged, not silently +# treated as inputs) + +[[test]] +name = "loan summary traces through per-account computed interest" +policies = ["loan_summary.json"] +target = "accountSummaries" +computed = ["account.interestDue"] +inputs = ["accounts.id", "account.balance", "account.apr"] + +[[test]] +name = "per-field: projected computed field traces to its specific source" +policies = ["loan_summary.json"] +target = "accountSummaries.interest" +computed = ["account.interestDue"] + +[[test]] +name = "per-field: projected input field traces to the input, not the computed sibling" +policies = ["loan_summary.json"] +target = "accountSummaries.id" +inputs = ["accounts.id"] + +[[test]] +name = "per-field: ternary-of-objects unions both branches" +target = "summary.total" +computed = ["base", "bonus"] +content = ''' +{ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "globals", "scope": "global", + "properties": [ + { "id": "g1", "name": "amount", "type": "number", "array": false, "optional": false }, + { "id": "g2", "name": "flag", "type": "boolean", "array": false, "optional": false } + ] + }}}, + { "id": "s1", "type": "expression", "props": { "data": { "key": "base", "value": "amount * 2" } } }, + { "id": "s2", "type": "expression", "props": { "data": { "key": "bonus", "value": "amount * 3" } } }, + { "id": "s3", "type": "expression", "props": { "data": { "key": "summary", "value": "flag ? { total: base } : { total: bonus }" } } } + ] +} +''' + +[[test]] +name = "per-field: non-decomposable computed object field is flagged unresolved, not silent" +target = "wrapper.a" +unresolved = ["wrapper.a"] +content = ''' +{ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "globals", "scope": "global", + "properties": [ + { "id": "g1", "name": "amount", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "s1", "type": "expression", "props": { "data": { "key": "base", "value": "{ a: amount }" } } }, + { "id": "s2", "type": "expression", "props": { "data": { "key": "wrapper", "value": "base" } } } + ] +} +''' + +[[test]] +name = "untrackable closure collection is flagged, resolvable operands stay inputs" +target = "ledgerTotals" +unresolved = ["t.amount"] +inputs = ["deposits", "withdrawals"] +content = ''' +{ + "blocks": [ + { "id": "dm-ledger", "type": "dataModel", "props": { "data": { + "name": "ledger", "scope": "global", + "properties": [ + { "id": "g1", "name": "deposits", "type": "number", "array": true, "optional": false }, + { "id": "g2", "name": "withdrawals", "type": "number", "array": true, "optional": false } + ] + }}}, + { "id": "s1", "type": "expression", "props": { "data": { "key": "ledgerTotals", "value": "map(deposits or withdrawals as t, t.amount)" } } } + ] +} +''' + +[[test]] +name = "nested write parent unions its leaf writers' dependencies" +target = "loanSummary" +inputs = ["principal"] +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": "loanSummary.byBucket.CURRENT.interestDue", "value": "principal * 0.04" } } }, + { "id": "s2", "type": "expression", "props": { "data": { "key": "loanSummary.grandTotal", "value": "principal * 1.04" } } } + ] +} +''' diff --git a/core/engine/tests/data/policy/diagnostics.toml b/core/engine/tests/data/policy/diagnostics.toml new file mode 100644 index 00000000..c1d10290 --- /dev/null +++ b/core/engine/tests/data/policy/diagnostics.toml @@ -0,0 +1,1320 @@ +# Policy diagnostics tests +# Each test loads policies, compiles, and checks for expected diagnostics. +# +# Two ways to supply a policy: +# - `policies = ["name.json"]` references a fixture in ./fixtures/. +# - `content = '''{...}'''` inlines the policy JSON directly; the test +# is loaded under the path given by `policy` (defaults to "p"). +# +# Assertions: +# - `no_errors = true` — asserts zero error-severity diagnostics. +# - `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" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + } + }, + { + "id": "tree", + "type": "match", + "props": { + "data": { + "key": "customer.tag", + "arms": [ + { + "id": "b1", + "condition": "customer.age > 18", + "value": "42" + }, + { + "id": "b2", + "condition": "", + "value": "\"young\"" + } + ] + } + } + } + ] +} +''' +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" +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.keyCount", + "value": "len(keys({a: 1, b: 2}))" + } + } + }, + { + "id": "s2", + "type": "expression", + "props": { + "data": { + "key": "customer.valueSum", + "value": "sum(values({a: 1, b: 2}))" + } + } + } + ] +} +''' +no_errors = true + +[[test]] +name = "merge of object literals" +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.merged", + "value": "merge([{a: 10}, {b: 20}])" + } + } + } + ] +} +''' +no_errors = true + +[[test]] +name = "mergeDeep of object literals" +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.cfg", + "value": "mergeDeep([{a: 1, nested: {x: 10}}, {b: 2, nested: {y: 20}}])" + } + } + } + ] +} +''' +no_errors = true + +[[test]] +name = "flatten of nested array peels to concrete element type" +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.nums", + "value": "flatten([[1, 2], [3, 4]])" + } + } + } + ] +} +''' +no_errors = true + +[[test]] +name = "values yields union of field types" +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.vs", + "value": "values({a: 1, b: 2, c: 3})" + } + } + } + ] +} +''' +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" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "type": "dataModel", + "props": { + "data": { + "name": "employee", + "properties": [ + { + "id": "p1", + "name": "id", + "type": "string", + "array": false, + "optional": false + } + ] + } + } + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "employee.asd", + "value": "'hello' + 123" + } + } + } + ] +} +''' +error_codes = ["TypeMismatch"] +error_count = 2 + +# Intra-tree forward reads: statement 2 sees statement 1's write. +[[test]] +name = "intra-tree forward reads see earlier top-level writes" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "type": "dataModel", + "props": { + "data": { + "name": "employee", + "properties": [ + { + "id": "p1", + "name": "id", + "type": "string", + "array": false, + "optional": false + } + ] + } + } + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "employee.som", + "value": "merge([{a: 10}])" + } + } + }, + { + "id": "s2", + "type": "expression", + "props": { + "data": { + "key": "employee.aa", + "value": "employee.som" + } + } + } + ] +} +''' +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" +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", + "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 } + ] + } + } + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { "key": "customer.topRevenue", "value": "customer.companies[0].revenue" } + } + } + ] +} +''' +no_errors = true + +# 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" +content = ''' +{ + "blocks": [ + { + "id": "dm-customer", + "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": "rs1", + "type": "expression", + "props": { + "data": { "key": "customer.riskScore", "value": "customer.income * 0.1" } + } + }, + { + "id": "as1", + "type": "expression", + "props": { + "data": { "key": "totalRisk", "value": "sum(map(filter(customers as c, c.country == \"US\") as x, x.riskScore))" } + } + } + ] +} +''' +no_errors = true + +[[test]] +name = "closure alias over an unresolvable collection is not flagged as undefined" +policy = "p" +no_errors = true +content = ''' +{ + "blocks": [ + { + "id": "dm-entry", + "type": "dataModel", + "props": { + "data": { + "name": "entry", + "scope": "entity", + "properties": [ + { "id": "e1", "name": "amount", "type": "number", "array": false, "optional": false } + ] + } + } + }, + { + "id": "dm-global", + "type": "dataModel", + "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 } + ] + } + } + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { "key": "total", "value": "sum(map((flag ? listA : listB) as t, t.amount))" } + } + } + ] +} +''' + +[[test]] +name = "nested computed write paths build an object spine without errors" +policy = "p" +no_errors = true +content = ''' +{ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "globals", "scope": "global", + "properties": [ + { "id": "g1", "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))" } } } + ] +} +''' + +# 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": "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" } } } + ] +} +''' + +# 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": "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" } } } + ] +} +''' + +[[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": "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" } } } + ] +} +''' + +# ═══════════════════════════════════════════════════════════════════════════════ +# Lints (hint severity) +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "chained ternary over one scrutinee suggests a match block" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "type": "dataModel", + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "p1", "name": "minutes", "type": "number", "array": false, "optional": false } + ] } } + }, + { + "id": "e1", + "type": "expression", + "props": { "data": { "key": "threshold", "value": "minutes <= 30 ? 10000 : minutes <= 60 ? 18000 : minutes <= 90 ? 24000 : 18000" } } + } + ] +} +''' +no_errors = true +hint_codes = ["PreferMatch"] +hint_count = 1 + +[[test]] +name = "single ternary does not suggest a match block" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "type": "dataModel", + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "p1", "name": "minutes", "type": "number", "array": false, "optional": false } + ] } } + }, + { + "id": "e1", + "type": "expression", + "props": { "data": { "key": "threshold", "value": "minutes <= 30 ? 10000 : 18000" } } + } + ] +} +''' +no_errors = true +hint_count = 0 + +[[test]] +name = "small derivation repeated three times is flagged at every site" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "type": "dataModel", + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "p1", "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" } } } + ] +} +''' +no_errors = true +hint_codes = ["RepeatedDerivation"] +hint_count = 3 + +[[test]] +name = "small derivation repeated only twice is not flagged" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "type": "dataModel", + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "p1", "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" } } } + ] +} +''' +no_errors = true +hint_count = 0 + +[[test]] +name = "complex derivation repeated twice is flagged without double-counting nested fragments" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "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": "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" } } } + ] +} +''' +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 + +[[test]] +name = "duplicate row and non-discriminating column are flagged" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "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": "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" } + ] + } } + } + ] +} +''' +no_errors = true +hint_codes = ["RedundantTableRow", "NonDiscriminatingColumn"] +hint_count = 2 + +[[test]] +name = "row shadowed by an earlier wildcard row is flagged as unreachable" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "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": "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" } + ] + } } + } + ] +} +''' +no_errors = true +hint_codes = ["RedundantTableRow"] +hint_count = 1 + +[[test]] +name = "nullish coalesce on a non-nullable property is redundant" +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" } } } + ] +} +''' +no_errors = true +hint_codes = ["RedundantNullish"] +hint_count = 1 + +[[test]] +name = "nullish coalesce on an optional property is not flagged" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "type": "dataModel", + "props": { "data": { "name": "inputs", "scope": "global", "properties": [ + { "id": "p1", "name": "score", "type": "number", "array": false, "optional": true } + ] } } + }, + { "id": "e1", "type": "expression", "props": { "data": { "key": "adjusted", "value": "(score ?? 0) + 1" } } } + ] +} +''' +no_errors = true +hint_count = 0 + +[[test]] +name = "valued column gating fall-through to a different-output catch-all is not flagged" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "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": "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" } + ] + } } + } + ] +} +''' +no_errors = true +hint_count = 0 + +[[test]] +name = "valued column with an in-group wildcard row is safe to flag" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "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": "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": "", "o1": "0.198" }, + { "_id": "r3", "i1": "", "i2": "", "o1": "0" } + ] + } } + } + ] +} +''' +no_errors = true +hint_codes = ["NonDiscriminatingColumn"] +hint_count = 1 + +[[test]] +name = "parentheses around a lone identifier are flagged" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "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": "e1", "type": "expression", "props": { "data": { "key": "due", "value": "(principal) * interestRate * feePct" } } } + ] +} +''' +no_errors = true +hint_codes = ["RedundantParentheses"] +hint_count = 1 + +[[test]] +name = "parentheses matching left associativity are flagged" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "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": "e1", "type": "expression", "props": { "data": { "key": "due", "value": "(principal * interestRate) * feePct" } } } + ] +} +''' +no_errors = true +hint_codes = ["RedundantParentheses"] +hint_count = 1 + +[[test]] +name = "parentheses around higher-precedence arithmetic are flagged" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "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": "e1", "type": "expression", "props": { "data": { "key": "due", "value": "(a * b) + c" } } } + ] +} +''' +no_errors = true +hint_codes = ["RedundantParentheses"] +hint_count = 1 + +[[test]] +name = "necessary or clarifying parentheses are not flagged" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "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 } + ] } } + }, + { "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" } } } + ] +} +''' +no_errors = true +hint_count = 0 + +[[test]] +name = "redundant parentheses inside arguments and at the root are flagged" +content = ''' +{ + "blocks": [ + { + "id": "dm", + "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": "e1", "type": "expression", "props": { "data": { "key": "r1", "value": "(a + b)" } } }, + { "id": "e2", "type": "expression", "props": { "data": { "key": "r2", "value": "abs((a + b))" } } } + ] +} +''' +no_errors = true +hint_codes = ["RedundantParentheses"] +hint_count = 2 diff --git a/core/engine/tests/data/policy/entities.toml b/core/engine/tests/data/policy/entities.toml new file mode 100644 index 00000000..a52485e9 --- /dev/null +++ b/core/engine/tests/data/policy/entities.toml @@ -0,0 +1,74 @@ +# Policy entities() tests +# Each test loads policies, compiles, and checks entity metadata + +policies = ["analysis.json"] + +# ═══════════════════════════════════════════════════════════════════════════════ +# Entity existence +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "all four entities present" +policy = "analysis.json" +entity_count = 4 + +# ═══════════════════════════════════════════════════════════════════════════════ +# Field kinds — DataModel fields +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "customer.companies is relationship to company (array)" +policy = "analysis.json" +entity = "customer" +field = "companies" +field_kind = { kind = "relationship", target = "company", array = true } +property_kind = "input" + +[[test]] +name = "customer.creditReport is relationship to creditReport (non-array)" +policy = "analysis.json" +entity = "customer" +field = "creditReport" +field_kind = { kind = "relationship", target = "creditReport", array = false } +property_kind = "input" + +[[test]] +name = "customer.favoriteProduct is reference to product" +policy = "analysis.json" +entity = "customer" +field = "favoriteProduct" +field_kind = { kind = "reference", target = "product", array = false } +property_kind = "input" + +[[test]] +name = "customer.name is scalar input" +policy = "analysis.json" +entity = "customer" +field = "name" +field_kind = { kind = "scalar" } +property_kind = "input" + +# ═══════════════════════════════════════════════════════════════════════════════ +# Computed properties — property kind +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "customer.creditTier is computed" +policy = "analysis.json" +entity = "customer" +field = "creditTier" +property_kind = "computed" + +[[test]] +name = "customer.totalRevenue is computed" +policy = "analysis.json" +entity = "customer" +field = "totalRevenue" +property_kind = "computed" + +[[test]] +name = "customer.discount is computed" +policy = "analysis.json" +entity = "customer" +field = "discount" +property_kind = "computed" diff --git a/core/engine/tests/data/policy/entities_filter.toml b/core/engine/tests/data/policy/entities_filter.toml new file mode 100644 index 00000000..52bee004 --- /dev/null +++ b/core/engine/tests/data/policy/entities_filter.toml @@ -0,0 +1,30 @@ +# Entity metadata for computed properties produced by filter/map expressions. +# +# Identity-preserving expressions (filter / index / slice over a declared +# relationship) carry `instance_of` on the computed origin; expressions that +# build new values (map into fresh objects, arithmetic) erase it. + +policies = ["filter_instanceof.json"] + +[[test]] +name = "filtered array keeps company identity" +policy = "filter_instanceof.json" +entity = "customer" +field = "profitableCompanies" +property_kind = "computed" +instance_of = { target = "company", array = true } + +[[test]] +name = "mapped array erases identity (new objects, not instances)" +policy = "filter_instanceof.json" +entity = "customer" +field = "companySummaries" +property_kind = "computed" +no_instance_of = true + +[[test]] +name = "company.profitMargin is scalar computed" +policy = "filter_instanceof.json" +entity = "company" +field = "profitMargin" +property_kind = "computed" diff --git a/core/engine/tests/data/policy/entities_merge.toml b/core/engine/tests/data/policy/entities_merge.toml new file mode 100644 index 00000000..f319cbb4 --- /dev/null +++ b/core/engine/tests/data/policy/entities_merge.toml @@ -0,0 +1,29 @@ +# Entity merging: same entity from multiple data models merges properties + +# ── Single policy, two data model blocks ───────────────────────────────────── + +policies = ["merge_entity.json"] + +[[test]] +name = "merged entity has all fields from both definitions" +policy = "merge_entity.json" +entity = "customer" +field = "name" +field_kind = { kind = "scalar" } +property_kind = "input" + +[[test]] +name = "merged entity has age from first definition" +policy = "merge_entity.json" +entity = "customer" +field = "age" +field_kind = { kind = "scalar" } +property_kind = "input" + +[[test]] +name = "merged entity has country from second definition" +policy = "merge_entity.json" +entity = "customer" +field = "country" +field_kind = { kind = "scalar" } +property_kind = "input" diff --git a/core/engine/tests/data/policy/entities_merge_multi_policy.toml b/core/engine/tests/data/policy/entities_merge_multi_policy.toml new file mode 100644 index 00000000..1cf734b1 --- /dev/null +++ b/core/engine/tests/data/policy/entities_merge_multi_policy.toml @@ -0,0 +1,96 @@ +# Entity merging across policies: customer defined in both merge_policy_a and merge_policy_b + +policies = ["merge_policy_a.json", "merge_policy_b.json"] + +[[test]] +name = "cross-policy merge: name from A (first wins, local)" +policy = "merge_policy_a.json" +entity = "customer" +field = "name" +field_kind = { kind = "scalar" } +property_kind = "input" +source_is_local = true + +[[test]] +name = "cross-policy merge: age from policy A (local)" +policy = "merge_policy_a.json" +entity = "customer" +field = "age" +field_kind = { kind = "scalar" } +property_kind = "input" +source_is_local = true + +[[test]] +name = "cross-policy merge: country from policy B (imported)" +policy = "merge_policy_a.json" +entity = "customer" +field = "country" +field_kind = { kind = "scalar" } +property_kind = "input" +source = "merge_policy_b.json" + +[[test]] +name = "cross-policy merge: computed greeting from policy A (local)" +policy = "merge_policy_a.json" +entity = "customer" +field = "greeting" +property_kind = "computed" +source_is_local = true + +[[test]] +name = "cross-policy merge: computed label from policy B (imported)" +policy = "merge_policy_a.json" +entity = "customer" +field = "label" +property_kind = "computed" +source = "merge_policy_b.json" + +# ── No duplicate fields after merge ────────────────────────────────────────── + +[[test]] +name = "entities(A): no duplicate fields after cross-policy merge" +policy = "merge_policy_a.json" +entity = "customer" +no_duplicate_fields = true + +# ── Visibility from policy B's perspective ─────────────────────────────────── +# v1 visibility was directional (B does not import A → B saw only its own +# fields). v2 units are import components, so A and B share one unit and B +# sees A's fields too, with source attribution preserved. + +[[test]] +name = "entities(B): has own fields" +policy = "merge_policy_b.json" +entity = "customer" +field = "name" +property_kind = "input" + +[[test]] +name = "entities(B): has country" +policy = "merge_policy_b.json" +entity = "customer" +field = "country" +property_kind = "input" + +[[test]] +name = "entities(B): has own computed label" +policy = "merge_policy_b.json" +entity = "customer" +field = "label" +property_kind = "computed" + +[[test]] +name = "entities(B): sees age from A via component visibility" +policy = "merge_policy_b.json" +entity = "customer" +field = "age" +property_kind = "input" +source = "merge_policy_a.json" + +[[test]] +name = "entities(B): sees greeting computed by A via component visibility" +policy = "merge_policy_b.json" +entity = "customer" +field = "greeting" +property_kind = "computed" +source = "merge_policy_a.json" diff --git a/core/engine/tests/data/policy/evaluation.toml b/core/engine/tests/data/policy/evaluation.toml new file mode 100644 index 00000000..6b06ab08 --- /dev/null +++ b/core/engine/tests/data/policy/evaluation.toml @@ -0,0 +1,419 @@ +# Policy evaluation tests +# Each test loads policies, compiles, provides input, and checks output + trace +# +# policies: list of fixture JSON filenames to load into the workspace +# input: JSON object matching the data model (the initial property store) +# output: JSON object of expected written properties (partial match) +# +# Trace expectations (optional): +# trace.blocks..kind = "assertion" | "decisionTable" | "expression" | "match" +# trace.blocks..result = true/false (assertion) +# trace.blocks..conditions = [{id, result}] (assertion) +# trace.blocks..matched_rows = [0, 1, ...] (decision table) +# trace.blocks..property = "customer.x" (expression — the write key) +# trace.blocks..value = (expression / match — the written value) +# trace.blocks..matchedArm = "armId" (match — id of the first matching arm, omitted if none) +# trace.blocks..arms = [{id, result}] (match — arm condition results) + +# ═══════════════════════════════════════════════════════════════════════════════ +# Assertion tests +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "assertion — eligible customer (both conditions true)" +policies = ["assertion_policy.json"] +input = { customer = { name = "Alice", age = 25, country = "US", companies = [], creditReport = { score = 750, delinquencies = 0, totalDebt = 1000 } } } +output = { customer = { isEligible = true } } + +[test.trace.blocks.assert1] +kind = "assertion" +result = true +conditions = [ + { id = "c1", result = true }, + { id = "c2", result = true }, +] + +[[test]] +name = "assertion — underage customer fails" +policies = ["assertion_policy.json"] +input = { customer = { name = "Bob", age = 16, country = "US", companies = [], creditReport = { score = 750, delinquencies = 0, totalDebt = 0 } } } +output = { customer = { isEligible = false } } + +[test.trace.blocks.assert1] +kind = "assertion" +result = false +conditions = [ + { id = "c1", result = false }, + { id = "c2", result = true }, +] + +[[test]] +name = "assertion — low credit score fails" +policies = ["assertion_policy.json"] +input = { customer = { name = "Carol", age = 30, country = "UK", companies = [], creditReport = { score = 500, delinquencies = 2, totalDebt = 5000 } } } +output = { customer = { isEligible = false } } + +[test.trace.blocks.assert1] +kind = "assertion" +result = false +conditions = [ + { id = "c1", result = true }, + { id = "c2", result = false }, +] + +[[test]] +name = "assertion — both conditions fail" +policies = ["assertion_policy.json"] +input = { customer = { name = "Dave", age = 15, country = "DE", companies = [], creditReport = { score = 400, delinquencies = 5, totalDebt = 10000 } } } +output = { customer = { isEligible = false } } + +[test.trace.blocks.assert1] +kind = "assertion" +result = false +conditions = [ + { id = "c1", result = false }, + { id = "c2", result = false }, +] + +# ═══════════════════════════════════════════════════════════════════════════════ +# Expression + match + decision table pipeline (analysis.json) +# ds1 (expression): computes totalRevenue +# f1 (match): computes creditTier (arm db1 condition, arm db2 default) +# dt1 (table): uses creditTier + totalRevenue to compute discount +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "analysis — excellent tier, high revenue → 20% discount" +policies = ["analysis.json"] +input = { customer = { name = "Alice", age = 35, country = "US", companies = [{ id = "c1", name = "ACME", iban = "DE123", revenue = 300000 }, { id = "c2", name = "Globex", iban = "DE456", revenue = 250000 }], creditReport = { score = 800, delinquencies = 0, totalDebt = 5000 } } } +output = { customer = { totalRevenue = 550000, creditTier = "excellent", discount = 0.2 } } + +[test.trace.blocks.ds1] +kind = "expression" +property = "customer.totalRevenue" +value = 550000 + +[test.trace.blocks.f1] +kind = "match" +matchedArm = "db1" +value = "excellent" +arms = [ + { id = "db1", result = true }, +] + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [0] + +[[test]] +name = "analysis — excellent tier, low revenue → 15% discount" +policies = ["analysis.json"] +input = { customer = { name = "Bob", age = 40, country = "UK", companies = [{ id = "c1", name = "SmallCo", iban = "GB123", revenue = 50000 }], creditReport = { score = 760, delinquencies = 0, totalDebt = 2000 } } } +output = { customer = { totalRevenue = 50000, creditTier = "excellent", discount = 0.15 } } + +[test.trace.blocks.ds1] +kind = "expression" +property = "customer.totalRevenue" +value = 50000 + +[test.trace.blocks.f1] +kind = "match" +matchedArm = "db1" +value = "excellent" +arms = [ + { id = "db1", result = true }, +] + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [1] + +[[test]] +name = "analysis — good tier, any revenue → 5% discount" +policies = ["analysis.json"] +input = { customer = { name = "Carol", age = 28, country = "DE", companies = [{ id = "c1", name = "MidCo", iban = "DE789", revenue = 100000 }], creditReport = { score = 650, delinquencies = 1, totalDebt = 15000 } } } +output = { customer = { totalRevenue = 100000, creditTier = "good", discount = 0.05 } } + +[test.trace.blocks.ds1] +kind = "expression" +property = "customer.totalRevenue" +value = 100000 + +[test.trace.blocks.f1] +kind = "match" +matchedArm = "db2" +value = "good" +arms = [ + { id = "db1", result = false }, + { id = "db2", result = true }, +] + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [2] + +[[test]] +name = "analysis — good tier, no companies → 5% discount" +policies = ["analysis.json"] +input = { customer = { name = "Dave", age = 50, country = "FR", companies = [], creditReport = { score = 700, delinquencies = 0, totalDebt = 0 } } } +output = { customer = { totalRevenue = 0, creditTier = "good", discount = 0.05 } } + +[test.trace.blocks.ds1] +kind = "expression" +property = "customer.totalRevenue" +value = 0 + +[test.trace.blocks.f1] +kind = "match" +matchedArm = "db2" +value = "good" +arms = [ + { id = "db1", result = false }, + { id = "db2", result = true }, +] + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [2] + +# ═══════════════════════════════════════════════════════════════════════════════ +# Decision table with collect hit policy +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "collect table — senior gets senior + adult + minor tags" +policies = ["collect_table.json"] +input = { customer = { name = "Edna", age = 70, country = "US", companies = [] } } +output = { customer = { tags = ["senior", "adult", "minor"] } } + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [0, 1, 2] + +[[test]] +name = "collect table — adult gets adult + minor tags" +policies = ["collect_table.json"] +input = { customer = { name = "Frank", age = 30, country = "US", companies = [] } } +output = { customer = { tags = ["adult", "minor"] } } + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [1, 2] + +[[test]] +name = "collect table — child gets only minor tag" +policies = ["collect_table.json"] +input = { customer = { name = "Grace", age = 10, country = "US", companies = [] } } +output = { customer = { tags = ["minor"] } } + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [2] + +# ═══════════════════════════════════════════════════════════════════════════════ +# Decision table — multi-entity writes (multi-scope block → decisionTable) +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "multi-entity table — writes to customer and company" +policies = ["multi_entity_block.json"] +input = { customer = { name = "Hank", age = 40, country = "NL", companies = [], creditReport = { score = 700, delinquencies = 0, totalDebt = 0 } }, company = { id = "c1", name = "TechCo", iban = "NL001", revenue = 500000 } } +output = { customer = { status = "active" }, company = { status = "verified" } } + +[test.trace.blocks.tree1] +kind = "decisionTable" +matched_rows = [0] + +# ═══════════════════════════════════════════════════════════════════════════════ +# Expression — simple statement, per instance (company_block.json) +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "company block — computes profit margin per instance" +policies = ["company_block.json"] +input = { customer = { name = "Ivy", age = 30, country = "US", creditReport = { score = 700, delinquencies = 0, totalDebt = 0 }, companies = [{ id = "c1", name = "BigCo", iban = "US123", revenue = 1000000 }, { id = "c2", name = "Smol", iban = "US456", revenue = 50000 }] } } +output = { customer = { companies = [{ profitMargin = 100000 }, { profitMargin = 5000 }] } } + +# Block s1 runs once per company instance; value differs per instance +# (100000, then 5000), so only the write property is asserted here. +[test.trace.blocks.s1] +kind = "expression" +property = "company.profitMargin" + +# ═══════════════════════════════════════════════════════════════════════════════ +# Multi-policy — independent blocks from imported policy both execute +# merge_policy_a imports merge_policy_b. Both write to customer. +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "multi-policy import — both policies write to customer" +policies = ["merge_policy_a.json", "merge_policy_b.json"] +input = { customer = { name = "Alice", age = 30, country = "France" } } +output = { customer = { greeting = "Alice", label = "France" } } + +# Note: both policies have block id "s1" — trace shows the last executed one, +# so the asserted property/value is order-dependent and left unspecified. +# Output check verifies both actually ran. +[test.trace.blocks.s1] +kind = "expression" + +# ═══════════════════════════════════════════════════════════════════════════════ +# Multi-policy — cross-policy dependency +# cross_dep_base: assertion writes customer.isEligible +# cross_dep_consumer: imports base, table reads isEligible to compute tier +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "cross-policy — eligible high-income → platinum" +policies = ["cross_dep_base.json", "cross_dep_consumer.json"] +input = { customer = { name = "Alice", age = 30, income = 120000 } } +output = { customer = { isEligible = true, tier = "platinum" } } + +[test.trace.blocks.assert1] +kind = "assertion" +result = true + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [0] + +[[test]] +name = "cross-policy — eligible mid-income → gold" +policies = ["cross_dep_base.json", "cross_dep_consumer.json"] +input = { customer = { name = "Bob", age = 25, income = 60000 } } +output = { customer = { isEligible = true, tier = "gold" } } + +[test.trace.blocks.assert1] +kind = "assertion" +result = true + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [1] + +[[test]] +name = "cross-policy — eligible low-income → silver" +policies = ["cross_dep_base.json", "cross_dep_consumer.json"] +input = { customer = { name = "Carol", age = 40, income = 35000 } } +output = { customer = { isEligible = true, tier = "silver" } } + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [2] + +[[test]] +name = "cross-policy — ineligible (underage) → rejected" +policies = ["cross_dep_base.json", "cross_dep_consumer.json"] +input = { customer = { name = "Dave", age = 16, income = 50000 } } +output = { customer = { isEligible = false, tier = "rejected" } } + +[test.trace.blocks.assert1] +kind = "assertion" +result = false +conditions = [ + { id = "c1", result = false }, + { id = "c2", result = true }, +] + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [3] + +[[test]] +name = "cross-policy — ineligible (low income) → rejected" +policies = ["cross_dep_base.json", "cross_dep_consumer.json"] +input = { customer = { name = "Eve", age = 25, income = 20000 } } +output = { customer = { isEligible = false, tier = "rejected" } } + +[test.trace.blocks.assert1] +kind = "assertion" +result = false + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [3] + +# ═══════════════════════════════════════════════════════════════════════════════ +# Per-instance execution — block scoped to a relationship-array entity runs +# once per item; reads/writes resolve against the current instance. +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "per-instance — company-risk runs per company" +policies = ["per_instance.json"] +input = { customer = { name = "Alice", companies = [{ name = "Acme", revenue = 300000 }, { name = "Smol", revenue = 50000 }] } } +output = { customer = { companies = [{ riskLevel = "low" }, { riskLevel = "high" }] } } + +[[test]] +name = "inverse relationship — company reads company.customer.age" +policies = ["inverse_relationship.json"] +input = { customer = { name = "Alice", age = 42, companies = [{ name = "Acme" }, { name = "Smol" }] } } +output = { customer = { companies = [{ ownerAge = 42 }, { ownerAge = 42 }] } } + +[[test]] +name = "reference iteration — customer.companies ids hydrated from top-level company[]" +policies = ["reference_iteration.json"] +input = { customer = { name = "Alice", companies = ["c1", "c2"] }, company = [{ id = "c1", revenue = 300000 }, { id = "c2", revenue = 50000 }] } +output = { customer = { totalRevenue = 350000 }, company = [{ id = "c1", riskLevel = "low" }, { id = "c2", riskLevel = "high" }] } + +# ═══════════════════════════════════════════════════════════════════════════════ +# Scope enrichment — within a single block, later statements read earlier values +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "scope enrichment — s2 reads computed value from s1" +policies = ["scope_enrichment.json"] +input = { customer = { name = "Alice", age = 45 } } +output = { customer = { ageGroup = true, label = true } } + +[test.trace.blocks.s1] +kind = "expression" +property = "customer.ageGroup" +value = true + +[test.trace.blocks.s2] +kind = "expression" +property = "customer.label" +value = true + +[[test]] +name = "scope enrichment — young customer" +policies = ["scope_enrichment.json"] +input = { customer = { name = "Bob", age = 20 } } +output = { customer = { ageGroup = false, label = false } } + +# ═══════════════════════════════════════════════════════════════════════════════ +# Decision table — no matching rows (strict table, no catch-all) +# ═══════════════════════════════════════════════════════════════════════════════ + +[[test]] +name = "strict table — US large order, free shipping" +policies = ["strict_table.json"] +input = { order = { amount = 200, region = "US" } } +output = { order = { shippingCost = 0 } } + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [0] + +[[test]] +name = "strict table — EU small order" +policies = ["strict_table.json"] +input = { order = { amount = 50, region = "EU" } } +output = { order = { shippingCost = 15 } } + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [3] + +# TOML has no null literal — `nulls` lists paths that must be explicitly null. +[[test]] +name = "strict table — unknown region, no rows match writes null" +policies = ["strict_table.json"] +input = { order = { amount = 50, region = "JP" } } +output = {} +nulls = ["order.shippingCost"] + +[test.trace.blocks.dt1] +kind = "decisionTable" +matched_rows = [] diff --git a/core/engine/tests/data/policy/fixtures/analysis.json b/core/engine/tests/data/policy/fixtures/analysis.json new file mode 100644 index 00000000..8920c8dc --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/analysis.json @@ -0,0 +1,253 @@ +{ + "blocks": [ + { + "id": "dm-heading", + "type": "heading", + "props": {}, + "children": [] + }, + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p3", + "name": "country", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "companies", + "type": "relationship", + "target": "company", + "array": true, + "optional": false + }, + { + "id": "p5", + "name": "creditReport", + "type": "relationship", + "target": "creditReport", + "array": false, + "optional": false + }, + { + "id": "p6", + "name": "favoriteProduct", + "type": "reference", + "target": "product", + "array": false, + "optional": true + } + ] + } + }, + "children": [] + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { + "id": "p7", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p8", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p9", + "name": "iban", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p10", + "name": "revenue", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-credit-report", + "type": "dataModel", + "props": { + "data": { + "name": "creditReport", + "properties": [ + { + "id": "p11", + "name": "score", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p12", + "name": "delinquencies", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p13", + "name": "totalDebt", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-product", + "type": "dataModel", + "props": { + "data": { + "name": "product", + "properties": [ + { + "id": "p14", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p15", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p16", + "name": "price", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "ds1", + "type": "expression", + "props": { + "data": { + "key": "customer.totalRevenue", + "value": "sum(map(customer.companies as c, c.revenue))" + } + } + }, + { + "id": "f1", + "type": "match", + "props": { + "data": { + "key": "customer.creditTier", + "arms": [ + { + "id": "db1", + "condition": "customer.creditReport.score >= 750", + "value": "\"excellent\"" + }, + { + "id": "db2", + "condition": "", + "value": "\"good\"" + } + ] + } + } + }, + { + "id": "dt1", + "type": "decisionTable", + "props": { + "data": { + "hitPolicy": "first", + "rules": [ + { + "i1": "\"excellent\"", + "i2": ">= 500000", + "o1": "0.2", + "_id": "r1" + }, + { + "i1": "\"excellent\"", + "i2": "", + "o1": "0.15", + "_id": "r2" + }, + { + "i1": "", + "i2": "", + "o1": "0.05", + "_id": "r3" + } + ], + "inputs": [ + { + "id": "i1", + "name": "", + "field": "customer.creditTier" + }, + { + "id": "i2", + "name": "", + "field": "customer.totalRevenue" + } + ], + "outputs": [ + { + "id": "o1", + "name": "", + "field": "customer.discount" + } + ] + } + }, + "children": [] + } + ] +} \ No newline at end of file diff --git a/core/engine/tests/data/policy/fixtures/assertion_policy.json b/core/engine/tests/data/policy/fixtures/assertion_policy.json new file mode 100644 index 00000000..0d96b510 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/assertion_policy.json @@ -0,0 +1,191 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p3", + "name": "country", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "companies", + "type": "relationship", + "target": "company", + "array": true, + "optional": false + }, + { + "id": "p5", + "name": "creditReport", + "type": "relationship", + "target": "creditReport", + "array": false, + "optional": false + }, + { + "id": "p6", + "name": "favoriteProduct", + "type": "reference", + "target": "product", + "array": false, + "optional": true + } + ] + } + }, + "children": [] + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { + "id": "p7", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p8", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p9", + "name": "iban", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p10", + "name": "revenue", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-credit-report", + "type": "dataModel", + "props": { + "data": { + "name": "creditReport", + "properties": [ + { + "id": "p11", + "name": "score", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p12", + "name": "delinquencies", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p13", + "name": "totalDebt", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-product", + "type": "dataModel", + "props": { + "data": { + "name": "product", + "properties": [ + { + "id": "p14", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p15", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p16", + "name": "price", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "assert1", + "type": "assertion", + "props": { + "data": { + "output": "customer.isEligible", + "conditions": [ + { + "id": "c1", + "expression": "customer.age >= 18", + "operator": "and", + "depth": 0 + }, + { + "id": "c2", + "expression": "customer.creditReport.score > 600", + "operator": "and", + "depth": 0 + } + ] + } + }, + "children": [] + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/circular_import_a.json b/core/engine/tests/data/policy/fixtures/circular_import_a.json new file mode 100644 index 00000000..d116bbbf --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/circular_import_a.json @@ -0,0 +1,26 @@ +{ + "imports": [ + "circular_import_b.json" + ], + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/circular_import_b.json b/core/engine/tests/data/policy/fixtures/circular_import_b.json new file mode 100644 index 00000000..61c0aa05 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/circular_import_b.json @@ -0,0 +1,6 @@ +{ + "imports": [ + "circular_import_a.json" + ], + "blocks": [] +} diff --git a/core/engine/tests/data/policy/fixtures/closure_invalid_member.json b/core/engine/tests/data/policy/fixtures/closure_invalid_member.json new file mode 100644 index 00000000..46e5ff41 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/closure_invalid_member.json @@ -0,0 +1,86 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { + "id": "p3", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "revenue", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-customer-companies", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p5", + "name": "companies", + "type": "relationship", + "target": "company", + "array": true, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "customer.total", + "value": "sum(map(customer.companies as c, c.revenuess))" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/collect_table.json b/core/engine/tests/data/policy/fixtures/collect_table.json new file mode 100644 index 00000000..663c6851 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/collect_table.json @@ -0,0 +1,123 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p3", + "name": "country", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "companies", + "type": "relationship", + "target": "company", + "array": true, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { + "id": "p7", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p8", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p9", + "name": "iban", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p10", + "name": "revenue", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dt1", + "type": "decisionTable", + "props": { + "data": { + "hitPolicy": "collect", + "rules": [ + { + "i1": ">= 65", + "o1": "\"senior\"" + }, + { + "i1": ">= 18", + "o1": "\"adult\"" + }, + { + "i1": "", + "o1": "\"minor\"" + } + ], + "inputs": [ + { + "id": "i1", + "name": "", + "field": "customer.age" + } + ], + "outputs": [ + { + "id": "o1", + "name": "", + "field": "customer.tags" + } + ] + } + }, + "children": [] + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/company_block.json b/core/engine/tests/data/policy/fixtures/company_block.json new file mode 100644 index 00000000..b0737c58 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/company_block.json @@ -0,0 +1,177 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p3", + "name": "country", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "companies", + "type": "relationship", + "target": "company", + "array": true, + "optional": false + }, + { + "id": "p5", + "name": "creditReport", + "type": "relationship", + "target": "creditReport", + "array": false, + "optional": false + }, + { + "id": "p6", + "name": "favoriteProduct", + "type": "reference", + "target": "product", + "array": false, + "optional": true + } + ] + } + }, + "children": [] + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { + "id": "p7", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p8", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p9", + "name": "iban", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p10", + "name": "revenue", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-credit-report", + "type": "dataModel", + "props": { + "data": { + "name": "creditReport", + "properties": [ + { + "id": "p11", + "name": "score", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p12", + "name": "delinquencies", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p13", + "name": "totalDebt", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-product", + "type": "dataModel", + "props": { + "data": { + "name": "product", + "properties": [ + { + "id": "p14", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p15", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p16", + "name": "price", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "company.profitMargin", + "value": "company.revenue * 0.1" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/cross_dep_base.json b/core/engine/tests/data/policy/fixtures/cross_dep_base.json new file mode 100644 index 00000000..ac8c1f00 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/cross_dep_base.json @@ -0,0 +1,61 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p3", + "name": "income", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "assert1", + "type": "assertion", + "props": { + "data": { + "output": "customer.isEligible", + "conditions": [ + { + "id": "c1", + "expression": "customer.age >= 18", + "operator": "and", + "depth": 0 + }, + { + "id": "c2", + "expression": "customer.income >= 30000", + "operator": "and", + "depth": 0 + } + ] + } + }, + "children": [] + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/cross_dep_consumer.json b/core/engine/tests/data/policy/fixtures/cross_dep_consumer.json new file mode 100644 index 00000000..5247cbd2 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/cross_dep_consumer.json @@ -0,0 +1,58 @@ +{ + "imports": [ + "cross_dep_base.json" + ], + "blocks": [ + { + "id": "dt1", + "type": "decisionTable", + "props": { + "data": { + "hitPolicy": "first", + "rules": [ + { + "i1": "true", + "i2": ">= 100000", + "o1": "\"platinum\"" + }, + { + "i1": "true", + "i2": ">= 50000", + "o1": "\"gold\"" + }, + { + "i1": "true", + "i2": "", + "o1": "\"silver\"" + }, + { + "i1": "false", + "i2": "", + "o1": "\"rejected\"" + } + ], + "inputs": [ + { + "id": "i1", + "name": "", + "field": "customer.isEligible" + }, + { + "id": "i2", + "name": "", + "field": "customer.income" + } + ], + "outputs": [ + { + "id": "o1", + "name": "", + "field": "customer.tier" + } + ] + } + }, + "children": [] + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/cyclic_deps.json b/core/engine/tests/data/policy/fixtures/cyclic_deps.json new file mode 100644 index 00000000..10914d85 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/cyclic_deps.json @@ -0,0 +1,50 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "customer.tier", + "value": "customer.score" + } + } + }, + { + "id": "s2", + "type": "expression", + "props": { + "data": { + "key": "customer.score", + "value": "customer.tier" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/dt_invalid_field.json b/core/engine/tests/data/policy/fixtures/dt_invalid_field.json new file mode 100644 index 00000000..14a3867e --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/dt_invalid_field.json @@ -0,0 +1,60 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dt1", + "type": "decisionTable", + "props": { + "data": { + "hitPolicy": "first", + "rules": [ + { + "i1": "\"a\"", + "o1": "0.1" + } + ], + "inputs": [ + { + "id": "i1", + "name": "", + "field": "customer.agee" + } + ], + "outputs": [ + { + "id": "o1", + "name": "", + "field": "customer.discount" + } + ] + } + }, + "children": [] + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/duplicate_entity.json b/core/engine/tests/data/policy/fixtures/duplicate_entity.json new file mode 100644 index 00000000..f80e8c13 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/duplicate_entity.json @@ -0,0 +1,42 @@ +{ + "blocks": [ + { + "id": "dm-customer-1", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-customer-2", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p2", + "name": "name", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/duplicate_writer.json b/core/engine/tests/data/policy/fixtures/duplicate_writer.json new file mode 100644 index 00000000..2c7ac73d --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/duplicate_writer.json @@ -0,0 +1,43 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "customer.status", + "value": "\"active\"" + } + } + }, + { + "id": "s2", + "type": "expression", + "props": { + "data": { + "key": "customer.status", + "value": "\"inactive\"" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/empty_blocks.json b/core/engine/tests/data/policy/fixtures/empty_blocks.json new file mode 100644 index 00000000..810cf221 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/empty_blocks.json @@ -0,0 +1,53 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "assert1", + "type": "assertion", + "props": { + "data": { + "output": "customer.valid", + "conditions": [] + } + }, + "children": [] + }, + { + "id": "dt1", + "type": "decisionTable", + "props": { + "data": { + "hitPolicy": "first", + "rules": [], + "inputs": [], + "outputs": [ + { + "id": "o1", + "name": "", + "field": "customer.status" + } + ] + } + }, + "children": [] + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/filter_instanceof.json b/core/engine/tests/data/policy/fixtures/filter_instanceof.json new file mode 100644 index 00000000..7063ff3f --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/filter_instanceof.json @@ -0,0 +1,197 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p3", + "name": "country", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "companies", + "type": "relationship", + "target": "company", + "array": true, + "optional": false + }, + { + "id": "p5", + "name": "creditReport", + "type": "relationship", + "target": "creditReport", + "array": false, + "optional": false + }, + { + "id": "p6", + "name": "favoriteProduct", + "type": "reference", + "target": "product", + "array": false, + "optional": true + } + ] + } + }, + "children": [] + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { + "id": "p7", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p8", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p9", + "name": "iban", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p10", + "name": "revenue", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-credit-report", + "type": "dataModel", + "props": { + "data": { + "name": "creditReport", + "properties": [ + { + "id": "p11", + "name": "score", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p12", + "name": "delinquencies", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p13", + "name": "totalDebt", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-product", + "type": "dataModel", + "props": { + "data": { + "name": "product", + "properties": [ + { + "id": "p14", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p15", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p16", + "name": "price", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "ds1", + "type": "expression", + "props": { + "data": { + "key": "customer.profitableCompanies", + "value": "filter(customer.companies, $.revenue > 0)" + } + } + }, + { + "id": "ds2", + "type": "expression", + "props": { + "data": { + "key": "customer.companySummaries", + "value": "map(customer.profitableCompanies as c, { name: c.name, revenue: c.revenue })" + } + } + }, + { + "id": "ds3", + "type": "expression", + "props": { + "data": { + "key": "company.profitMargin", + "value": "0.15" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/import_not_found.json b/core/engine/tests/data/policy/fixtures/import_not_found.json new file mode 100644 index 00000000..edf2f642 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/import_not_found.json @@ -0,0 +1,26 @@ +{ + "imports": [ + "nonexistent.json" + ], + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/input_override.json b/core/engine/tests/data/policy/fixtures/input_override.json new file mode 100644 index 00000000..9cca9c37 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/input_override.json @@ -0,0 +1,40 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "customer.age", + "value": "99" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/inverse_relationship.json b/core/engine/tests/data/policy/fixtures/inverse_relationship.json new file mode 100644 index 00000000..accf6303 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/inverse_relationship.json @@ -0,0 +1,67 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p3", + "name": "companies", + "type": "relationship", + "target": "company", + "array": true, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { + "id": "p4", + "name": "name", + "type": "string", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "company.ownerAge", + "value": "company.customer.age" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/lint_shared_dep.json b/core/engine/tests/data/policy/fixtures/lint_shared_dep.json new file mode 100644 index 00000000..5112b920 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/lint_shared_dep.json @@ -0,0 +1,14 @@ +{ + "blocks": [ + { + "id": "dm", + "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": "d1", "type": "expression", "props": { "data": { "key": "depTotal", "value": "(revenue ?? 0) * (rate ?? 1) + base" } } } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/lint_shared_main.json b/core/engine/tests/data/policy/fixtures/lint_shared_main.json new file mode 100644 index 00000000..582422a5 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/lint_shared_main.json @@ -0,0 +1,6 @@ +{ + "imports": ["lint_shared_dep.json"], + "blocks": [ + { "id": "m1", "type": "expression", "props": { "data": { "key": "mainTotal", "value": "(revenue ?? 0) * (rate ?? 1) + base" } } } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/loan_summary.json b/core/engine/tests/data/policy/fixtures/loan_summary.json new file mode 100644 index 00000000..4e73f9c5 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/loan_summary.json @@ -0,0 +1,24 @@ +{ + "blocks": [ + { "id": "dm-ledger", "type": "dataModel", "props": { "data": { + "name": "ledger", "scope": "global", + "properties": [ + { "id": "g1", "name": "accounts", "type": "relationship", "target": "account", "array": true, "optional": false } + ] + }}}, + { "id": "dm-account", "type": "dataModel", "props": { "data": { + "name": "account", + "properties": [ + { "id": "p1", "name": "id", "type": "string", "array": false, "optional": false }, + { "id": "p2", "name": "balance", "type": "number", "array": false, "optional": false }, + { "id": "p3", "name": "apr", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "s1", "type": "expression", "props": { "data": { + "key": "account.interestDue", "value": "account.balance * account.apr" + }}}, + { "id": "s2", "type": "expression", "props": { "data": { + "key": "accountSummaries", "value": "map(accounts as a, { id: a.id, interest: a.interestDue })" + }}} + ] +} diff --git a/core/engine/tests/data/policy/fixtures/merge_entity.json b/core/engine/tests/data/policy/fixtures/merge_entity.json new file mode 100644 index 00000000..f9e04106 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/merge_entity.json @@ -0,0 +1,56 @@ +{ + "blocks": [ + { + "id": "dm-customer-1", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-customer-2", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p3", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "country", + "type": "string", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/merge_policy_a.json b/core/engine/tests/data/policy/fixtures/merge_policy_a.json new file mode 100644 index 00000000..cb9717ff --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/merge_policy_a.json @@ -0,0 +1,43 @@ +{ + "imports": [ + "merge_policy_b.json" + ], + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "customer.greeting", + "value": "customer.name" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/merge_policy_b.json b/core/engine/tests/data/policy/fixtures/merge_policy_b.json new file mode 100644 index 00000000..8171c136 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/merge_policy_b.json @@ -0,0 +1,40 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p3", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "country", + "type": "string", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "customer.label", + "value": "customer.country" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/missing_default_branch.json b/core/engine/tests/data/policy/fixtures/missing_default_branch.json new file mode 100644 index 00000000..bc23c4ee --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/missing_default_branch.json @@ -0,0 +1,51 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "tree1", + "type": "match", + "props": { + "data": { + "key": "customer.tier", + "arms": [ + { + "id": "b1", + "condition": "customer.age > 30", + "value": "\"senior\"" + }, + { + "id": "b2", + "condition": "customer.age > 18", + "value": "\"adult\"" + } + ] + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/mixed_scope.json b/core/engine/tests/data/policy/fixtures/mixed_scope.json new file mode 100644 index 00000000..ba320d5a --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/mixed_scope.json @@ -0,0 +1,71 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { + "id": "p2", + "name": "name", + "type": "string", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "bad-block", + "type": "decisionTable", + "props": { + "data": { + "hitPolicy": "first", + "inputs": [], + "outputs": [ + { + "id": "s1", + "name": "", + "field": "customer.label" + }, + { + "id": "s2", + "name": "", + "field": "company.label" + } + ], + "rules": [ + { + "_id": "r1", + "s1": "\"x\"", + "s2": "\"y\"" + } + ] + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/multi_entity_block.json b/core/engine/tests/data/policy/fixtures/multi_entity_block.json new file mode 100644 index 00000000..94ab9d74 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/multi_entity_block.json @@ -0,0 +1,196 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p3", + "name": "country", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "companies", + "type": "relationship", + "target": "company", + "array": true, + "optional": false + }, + { + "id": "p5", + "name": "creditReport", + "type": "relationship", + "target": "creditReport", + "array": false, + "optional": false + }, + { + "id": "p6", + "name": "favoriteProduct", + "type": "reference", + "target": "product", + "array": false, + "optional": true + } + ] + } + }, + "children": [] + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { + "id": "p7", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p8", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p9", + "name": "iban", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p10", + "name": "revenue", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-credit-report", + "type": "dataModel", + "props": { + "data": { + "name": "creditReport", + "properties": [ + { + "id": "p11", + "name": "score", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p12", + "name": "delinquencies", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p13", + "name": "totalDebt", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-product", + "type": "dataModel", + "props": { + "data": { + "name": "product", + "properties": [ + { + "id": "p14", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p15", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p16", + "name": "price", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "tree1", + "type": "decisionTable", + "props": { + "data": { + "hitPolicy": "first", + "inputs": [], + "outputs": [ + { + "id": "s1", + "name": "", + "field": "customer.status" + }, + { + "id": "s2", + "name": "", + "field": "company.status" + } + ], + "rules": [ + { + "_id": "r1", + "s1": "\"active\"", + "s2": "\"verified\"" + } + ] + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/multi_main.json b/core/engine/tests/data/policy/fixtures/multi_main.json new file mode 100644 index 00000000..726456f4 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/multi_main.json @@ -0,0 +1,43 @@ +{ + "imports": [ + "multi_shared.json" + ], + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "customer.discount", + "value": "customer.tier" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/multi_shared.json b/core/engine/tests/data/policy/fixtures/multi_shared.json new file mode 100644 index 00000000..6f4d73b4 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/multi_shared.json @@ -0,0 +1,25 @@ +{ + "blocks": [ + { + "id": "tree1", + "type": "match", + "props": { + "data": { + "key": "customer.tier", + "arms": [ + { + "id": "b1", + "condition": "customer.age > 50", + "value": "\"gold\"" + }, + { + "id": "b2", + "condition": "", + "value": "\"silver\"" + } + ] + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/parse_error.json b/core/engine/tests/data/policy/fixtures/parse_error.json new file mode 100644 index 00000000..d4c56ecc --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/parse_error.json @@ -0,0 +1,33 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "customer.status", + "value": "if (( invalid )) {{" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/per_instance.json b/core/engine/tests/data/policy/fixtures/per_instance.json new file mode 100644 index 00000000..5855e9c7 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/per_instance.json @@ -0,0 +1,78 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "companies", + "type": "relationship", + "target": "company", + "array": true, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { + "id": "p3", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "revenue", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "company-risk", + "type": "match", + "props": { + "data": { + "key": "company.riskLevel", + "arms": [ + { + "id": "cr1", + "condition": "company.revenue >= 200000", + "value": "\"low\"" + }, + { + "id": "cr2", + "condition": "", + "value": "\"high\"" + } + ] + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/reference_iteration.json b/core/engine/tests/data/policy/fixtures/reference_iteration.json new file mode 100644 index 00000000..0ff97feb --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/reference_iteration.json @@ -0,0 +1,88 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "companies", + "type": "reference", + "target": "company", + "array": true, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { + "id": "p3", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "revenue", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "company-risk", + "type": "match", + "props": { + "data": { + "key": "company.riskLevel", + "arms": [ + { + "id": "cr1", + "condition": "company.revenue >= 200000", + "value": "\"low\"" + }, + { + "id": "cr2", + "condition": "", + "value": "\"high\"" + } + ] + } + } + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "customer.totalRevenue", + "value": "sum(map(customer.companies as c, c.revenue))" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/scope_enrichment.json b/core/engine/tests/data/policy/fixtures/scope_enrichment.json new file mode 100644 index 00000000..3718beca --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/scope_enrichment.json @@ -0,0 +1,50 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "customer.ageGroup", + "value": "customer.age > 30" + } + } + }, + { + "id": "s2", + "type": "expression", + "props": { + "data": { + "key": "customer.label", + "value": "customer.ageGroup" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/strict_table.json b/core/engine/tests/data/policy/fixtures/strict_table.json new file mode 100644 index 00000000..f8dd1264 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/strict_table.json @@ -0,0 +1,81 @@ +{ + "blocks": [ + { + "id": "dm-order", + "type": "dataModel", + "props": { + "data": { + "name": "order", + "properties": [ + { + "id": "p1", + "name": "amount", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "region", + "type": "string", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dt1", + "type": "decisionTable", + "props": { + "data": { + "hitPolicy": "first", + "rules": [ + { + "i1": ">= 100", + "i2": "\"US\"", + "o1": "0" + }, + { + "i1": ">= 100", + "i2": "\"EU\"", + "o1": "5" + }, + { + "i1": "", + "i2": "\"US\"", + "o1": "10" + }, + { + "i1": "", + "i2": "\"EU\"", + "o1": "15" + } + ], + "inputs": [ + { + "id": "i1", + "name": "", + "field": "order.amount" + }, + { + "id": "i2", + "name": "", + "field": "order.region" + } + ], + "outputs": [ + { + "id": "o1", + "name": "", + "field": "order.shippingCost" + } + ] + } + }, + "children": [] + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/type_mismatch.json b/core/engine/tests/data/policy/fixtures/type_mismatch.json new file mode 100644 index 00000000..d54ac6a0 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/type_mismatch.json @@ -0,0 +1,51 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "tree1", + "type": "match", + "props": { + "data": { + "key": "customer.label", + "arms": [ + { + "id": "b1", + "condition": "customer.age > 30", + "value": "\"senior\"" + }, + { + "id": "b2", + "condition": "", + "value": "42" + } + ] + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/undefined_var.json b/core/engine/tests/data/policy/fixtures/undefined_var.json new file mode 100644 index 00000000..9a0a0ace --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/undefined_var.json @@ -0,0 +1,40 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "s1", + "type": "expression", + "props": { + "data": { + "key": "customer.status", + "value": "customer.nonExistentField > 5" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/unknown_target.json b/core/engine/tests/data/policy/fixtures/unknown_target.json new file mode 100644 index 00000000..94cfc40f --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/unknown_target.json @@ -0,0 +1,24 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "orders", + "type": "relationship", + "target": "nonExistent", + "array": true, + "optional": false + } + ] + } + }, + "children": [] + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/unscoped_block.json b/core/engine/tests/data/policy/fixtures/unscoped_block.json new file mode 100644 index 00000000..eb2fbdae --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/unscoped_block.json @@ -0,0 +1,118 @@ +{ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "age", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": { + "name": "company", + "properties": [ + { + "id": "p3", + "name": "id", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "revenue", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "tree_unscoped", + "type": "match", + "props": { + "data": { + "key": "", + "arms": [ + { + "id": "b1", + "condition": "x", + "value": "" + } + ] + } + } + }, + { + "id": "tree_unscoped_empty_keys", + "type": "match", + "props": { + "data": { + "key": "", + "arms": [ + { + "id": "b1", + "condition": "x", + "value": "" + } + ] + } + } + }, + { + "id": "s_unscoped", + "type": "expression", + "props": { + "data": { + "key": "", + "value": "x" + } + } + }, + { + "id": "s_scoped", + "type": "expression", + "props": { + "data": { + "key": "customer.tier", + "value": "x" + } + } + }, + { + "id": "s2", + "type": "expression", + "props": { + "data": { + "key": "", + "value": "x" + } + } + } + ] +} \ No newline at end of file diff --git a/core/engine/tests/data/policy/prepare_rename.toml b/core/engine/tests/data/policy/prepare_rename.toml new file mode 100644 index 00000000..34e3cf57 --- /dev/null +++ b/core/engine/tests/data/policy/prepare_rename.toml @@ -0,0 +1,77 @@ +# Policy prepare_rename tests +# Each test places cursor at block_id/expression_id/pos +# If expected_entity is absent, result should be None + +policies = ["analysis.json"] + +# ── Expression block ds1: "sum(map(customer.companies as c, c.revenue))" ──── + +[[test]] +name = "cursor on 'companies' → customer.companies" +policy = "analysis.json" +block_id = "ds1" +expression_id = "ds1" +pos = 20 +expected_entity = "customer" +expected_field = "companies" + +[[test]] +name = "cursor on 'customer' → entity customer (no field)" +policy = "analysis.json" +block_id = "ds1" +expression_id = "ds1" +pos = 10 +expected_entity = "customer" +expected_field = "" + +[[test]] +name = "cursor on 'revenue' via alias c → company.revenue" +policy = "analysis.json" +block_id = "ds1" +expression_id = "ds1" +pos = 37 +expected_entity = "company" +expected_field = "revenue" + +# v1 resolved the alias root `c` to entity company; v2 treats alias roots as +# local bindings with no rename target (the field segment still resolves). +[[test]] +name = "cursor on alias 'c' → not renameable" +policy = "analysis.json" +block_id = "ds1" +expression_id = "ds1" +pos = 33 + +[[test]] +name = "cursor on builtin 'sum' → not renameable" +policy = "analysis.json" +block_id = "ds1" +expression_id = "ds1" +pos = 1 + +# ── Match arm condition f1/db1: "customer.creditReport.score >= 750" ──────── + +[[test]] +name = "cursor on 'creditReport' in condition" +policy = "analysis.json" +block_id = "f1" +expression_id = "db1" +pos = 15 +expected_entity = "customer" +expected_field = "creditReport" + +[[test]] +name = "cursor on 'customer' in condition" +policy = "analysis.json" +block_id = "f1" +expression_id = "db1" +pos = 3 +expected_entity = "customer" +expected_field = "" + +[[test]] +name = "cursor past expression end → not renameable" +policy = "analysis.json" +block_id = "f1" +expression_id = "db1" +pos = 100 diff --git a/core/engine/tests/data/policy/rename.toml b/core/engine/tests/data/policy/rename.toml new file mode 100644 index 00000000..70944438 --- /dev/null +++ b/core/engine/tests/data/policy/rename.toml @@ -0,0 +1,139 @@ +# Policy rename tests +# Each test resolves a RenameTarget (entity/field; empty field = entity rename) +# and checks workspace.references() sites plus that rename() emits edits. +# +# Expected edit forms: +# { policy, block_id, expression_id } — expression read or write-key site +# { policy, block_id } — write-key site without expression id (match key) +# { policy, block_id, target_kind = "…" } — data-model declaration site + +policies = ["analysis.json"] + +# ── Direct references ──────────────────────────────────────────────────────── + +[[test]] +name = "rename customer.companies in map expression" +entity = "customer" +field = "companies" +new_name = "orgs" +edits = [ + { policy = "analysis.json", block_id = "ds1", expression_id = "ds1" }, + { policy = "analysis.json", block_id = "dm-customer", target_kind = "fieldName" }, +] + +[[test]] +name = "rename customer.creditReport in condition" +entity = "customer" +field = "creditReport" +new_name = "report" +edits = [ + { policy = "analysis.json", block_id = "f1", expression_id = "db1" }, + { policy = "analysis.json", block_id = "dm-customer", target_kind = "fieldName" }, +] + +# ── Alias resolution ──────────────────────────────────────────────────────── + +[[test]] +name = "rename company.revenue resolves alias c → company" +entity = "company" +field = "revenue" +new_name = "income" +edits = [ + { policy = "analysis.json", block_id = "ds1", expression_id = "ds1" }, + { policy = "analysis.json", block_id = "dm-company", target_kind = "fieldName" }, +] + +# ── No matches ─────────────────────────────────────────────────────────────── + +[[test]] +name = "nonexistent field → no edits" +entity = "customer" +field = "nonexistent" +new_name = "something" +edits = [] + +# customer.name exists in the data model but is not referenced by expressions — +# only the dataModel property edit is emitted. +[[test]] +name = "field only in data model → single data model edit" +entity = "customer" +field = "name" +new_name = "fullName" +edits = [ + { policy = "analysis.json", block_id = "dm-customer", target_kind = "fieldName" }, +] + +# totalRevenue: write key of expression block ds1 + input column field dt1/i2 +[[test]] +name = "field in write keys and column fields also renamed" +entity = "customer" +field = "totalRevenue" +new_name = "totalIncome" +edits = [ + { policy = "analysis.json", block_id = "ds1", expression_id = "ds1" }, + { policy = "analysis.json", block_id = "dt1", expression_id = "i2" }, +] + +# creditTier: written by match block f1 (key, no expression id) + input column dt1/i1 +[[test]] +name = "rename creditTier updates keys and column fields" +entity = "customer" +field = "creditTier" +new_name = "riskBand" +edits = [ + { policy = "analysis.json", block_id = "f1" }, + { policy = "analysis.json", block_id = "dt1", expression_id = "i1" }, +] + +# discount: output column dt1/o1 +[[test]] +name = "rename discount updates output column field" +entity = "customer" +field = "discount" +new_name = "rebate" +edits = [ + { policy = "analysis.json", block_id = "dt1", expression_id = "o1" }, +] + +[[test]] +name = "nonexistent entity → no edits" +entity = "phantom" +field = "field" +new_name = "other" +edits = [] + +# ── Entity renames (field = "") ────────────────────────────────────────────── + +# Renaming entity "customer" → "client" touches: +# - dm-customer data model's name +# - expression reads rooted at customer. (ds1 value, f1/db1 condition) +# - write keys rooted at customer. (ds1 key; f1 match key; dt1 column heads) +[[test]] +name = "rename customer entity" +entity = "customer" +field = "" +new_name = "client" +edits = [ + { policy = "analysis.json", block_id = "dm-customer", target_kind = "entityName" }, + { policy = "analysis.json", block_id = "ds1", expression_id = "ds1" }, + { policy = "analysis.json", block_id = "ds1", expression_id = "ds1" }, + { policy = "analysis.json", block_id = "f1", expression_id = "db1" }, + { policy = "analysis.json", block_id = "f1" }, + { policy = "analysis.json", block_id = "dt1", expression_id = "i1" }, + { policy = "analysis.json", block_id = "dt1", expression_id = "i2" }, + { policy = "analysis.json", block_id = "dt1", expression_id = "o1" }, +] + +# Renaming entity "company" → "org" touches: +# - dm-company data model's name +# - dm-customer.companies relationship target ("company" → "org") +# - the alias c in ds1 refers to company only indirectly — no expression edit. +[[test]] +name = "rename company entity updates data model and relationship targets" +entity = "company" +field = "" +new_name = "org" +edits = [ + { policy = "analysis.json", block_id = "dm-company", target_kind = "entityName" }, + { policy = "analysis.json", block_id = "dm-customer", target_kind = "relationshipTarget" }, +] diff --git a/core/engine/tests/data/policy/rename_multi_policy.toml b/core/engine/tests/data/policy/rename_multi_policy.toml new file mode 100644 index 00000000..773d5fa4 --- /dev/null +++ b/core/engine/tests/data/policy/rename_multi_policy.toml @@ -0,0 +1,33 @@ +# Rename across multiple policies + +policies = ["multi_main.json", "multi_shared.json"] + +# customer.tier: expression read in main/s1 value, written by shared/tree1 match key +[[test]] +name = "rename customer.tier finds expression refs and write keys across policies" +entity = "customer" +field = "tier" +new_name = "level" +edits = [ + { policy = "multi_main.json", block_id = "s1", expression_id = "s1" }, + { policy = "multi_shared.json", block_id = "tree1" }, +] + +# customer.age IS referenced in shared policy arm condition "customer.age > 50" +# and defined in multi_main's dm-customer data model. +[[test]] +name = "rename customer.age finds reference in shared policy condition" +entity = "customer" +field = "age" +new_name = "years" +edits = [ + { policy = "multi_shared.json", block_id = "tree1", expression_id = "b1" }, + { policy = "multi_main.json", block_id = "dm-customer", target_kind = "fieldName" }, +] + +[[test]] +name = "rename nonexistent field across policies → no edits" +entity = "customer" +field = "nonexistent" +new_name = "other" +edits = [] diff --git a/core/engine/tests/engine.rs b/core/engine/tests/engine.rs index 77bd6f28..98f5bf80 100644 --- a/core/engine/tests/engine.rs +++ b/core/engine/tests/engine.rs @@ -8,7 +8,7 @@ use std::path::Path; use std::sync::Arc; use tokio::runtime::Builder; use zen_engine::loader::{LoaderError, MemoryLoader}; -use zen_engine::model::{DecisionContent, DecisionNode, DecisionNodeKind, FunctionNodeContent}; +use zen_engine::model::{DecisionNode, DecisionNodeKind, FunctionNodeContent, GraphContent}; use zen_engine::Variable; use zen_engine::{DecisionEngine, EvaluationError, EvaluationOptions}; @@ -37,6 +37,24 @@ async fn engine_memory_loader() { assert_eq!(not_found.unwrap_err().to_string(), "Loader error"); } +#[tokio::test] +#[cfg_attr(miri, ignore)] +async fn engine_precompiles_graphs() { + let memory_loader = Arc::new(MemoryLoader::default()); + memory_loader.add("table", load_test_data("table.json")); + + let engine = DecisionEngine::default().with_loader(memory_loader.clone()); + let failures = engine.compile(); + assert!(failures.is_empty()); + + memory_loader.remove("table"); + let table = engine + .evaluate("table", json!({ "input": 12 }).into()) + .await; + + assert_eq!(table.unwrap().result, json!({"output": 10}).into()); +} + #[tokio::test] #[cfg_attr(miri, ignore)] async fn engine_filesystem_loader() { @@ -59,8 +77,8 @@ async fn engine_filesystem_loader() { async fn engine_closure_loader() { let engine = DecisionEngine::default().with_closure_loader(|key| async { match key.as_str() { - "function" => Ok(Arc::new(load_test_data("function.json"))), - "table" => Ok(Arc::new(load_test_data("table.json"))), + "function" => Ok(Arc::new(load_test_data("function.json").into())), + "table" => Ok(Arc::new(load_test_data("table.json").into())), _ => Err(LoaderError::NotFound(key).into()), } }); @@ -93,14 +111,18 @@ fn engine_get_decision() { let rt = Builder::new_current_thread().build().unwrap(); let engine = DecisionEngine::default().with_loader(Arc::new(create_fs_loader())); - assert!(rt.block_on(engine.get_decision("table.json")).is_ok()); + assert!(rt + .block_on(engine.get_decision("table.json")) + .is_ok_and(|inner| inner.is_ok())); assert!(rt.block_on(engine.get_decision("any.json")).is_err()); } #[test] fn engine_create_decision() { let engine = DecisionEngine::default(); - engine.create_decision(load_test_data("table.json").into()); + let _ = engine + .create_decision(Arc::new(load_test_data("table.json").into())) + .unwrap(); } #[tokio::test] @@ -154,7 +176,7 @@ fn engine_with_trace() { assert!(table.trace.is_none()); assert!(table_opt.trace.is_some()); - let trace = table_opt.trace.unwrap(); + let trace = table_opt.trace.unwrap().into_graph().unwrap(); assert_eq!(trace.len(), 3); // trace for each node } @@ -187,12 +209,14 @@ async fn engine_function_imports() { }) .collect::>(); - let function_content = DecisionContent { + let function_content = GraphContent { edges: function_content.edges, nodes: new_nodes, compiled_cache: None, }; - let decision = DecisionEngine::default().create_decision(function_content.into()); + let decision = DecisionEngine::default() + .create_decision(Arc::new(function_content.into())) + .unwrap(); let response = decision.evaluate(json!({}).into()).await.unwrap(); #[derive(Deserialize, Debug)] @@ -241,7 +265,7 @@ async fn engine_graph_tests() { struct TestData { tests: Vec, #[serde(flatten)] - decision_content: DecisionContent, + decision_content: GraphContent, } let engine = DecisionEngine::default(); @@ -257,9 +281,13 @@ async fn engine_graph_tests() { let file_contents = fs::read_to_string(file.path()).expect("valid file data"); let test_data: TestData = serde_json::from_str(&file_contents).expect("Valid JSON"); - let decision = engine.create_decision(test_data.decision_content.clone().into()); + let decision = engine + .create_decision(Arc::new(test_data.decision_content.clone().into())) + .unwrap(); - let mut decision_compiled = engine.create_decision(test_data.decision_content.into()); + let mut decision_compiled = engine + .create_decision(Arc::new(test_data.decision_content.into())) + .unwrap(); decision_compiled.compile(); for test_case in test_data.tests { @@ -305,7 +333,7 @@ async fn engine_snapshot_tests() { struct TestData { tests: Vec, #[serde(flatten)] - decision_content: DecisionContent, + decision_content: GraphContent, } let engine = DecisionEngine::default(); @@ -326,9 +354,13 @@ async fn engine_snapshot_tests() { let file_contents = fs::read_to_string(file.path()).expect("valid file data"); let test_data: TestData = serde_json::from_str(&file_contents).expect("Valid JSON"); - let decision = engine.create_decision(test_data.decision_content.clone().into()); + let decision = engine + .create_decision(Arc::new(test_data.decision_content.clone().into())) + .unwrap(); - let mut decision_compiled = engine.create_decision(test_data.decision_content.into()); + let mut decision_compiled = engine + .create_decision(Arc::new(test_data.decision_content.into())) + .unwrap(); decision_compiled.compile(); for (index, test_case) in test_data.tests.iter().enumerate() { @@ -387,7 +419,7 @@ async fn engine_function_v2() { assert!(function_opt_r.is_ok(), "function v2 has errored"); let function_opt = function_opt_r.unwrap(); - let trace = function_opt.trace.unwrap(); + let trace = function_opt.trace.unwrap().into_graph().unwrap(); assert_eq!(trace.len(), 3); // trace for each node assert_eq!( diff --git a/core/engine/tests/model.rs b/core/engine/tests/model.rs index dd5fc32b..06386d58 100644 --- a/core/engine/tests/model.rs +++ b/core/engine/tests/model.rs @@ -21,3 +21,20 @@ fn jdm_serde() { assert!(serde_json::to_string(&serialized).is_ok()); } } + +#[test] +fn untagged_routing() { + let graph_json = r#"{"nodes":[],"edges":[]}"#; + let parsed: DecisionContent = serde_json::from_str(graph_json).unwrap(); + assert!( + matches!(parsed, DecisionContent::Graph(_)), + "graph JSON should match Graph variant" + ); + + let policy_json = r#"{"blocks":[]}"#; + let parsed: DecisionContent = serde_json::from_str(policy_json).unwrap(); + assert!( + matches!(parsed, DecisionContent::Policy(_)), + "policy JSON should match Policy variant" + ); +} diff --git a/core/engine/tests/policy.rs b/core/engine/tests/policy.rs new file mode 100644 index 00000000..4bb8dbe5 --- /dev/null +++ b/core/engine/tests/policy.rs @@ -0,0 +1,4871 @@ +use serde::Deserialize; +use serde_json::json; +use std::fs; +use std::path::Path; +use std::sync::Arc; +use zen_engine::policy::{ + Cursor, CursorTarget, EngineEdit, EvaluateRequest, EvaluationError, PolicyWorkspace, + ScopeRequest, Severity, +}; + +fn rewritten_block_json(edits: &[EngineEdit], block_id: &str) -> Option { + edits.iter().find_map(|e| match e { + EngineEdit::ReplaceBlock { + block_id: bid, + new_block, + .. + } if bid.as_ref() == block_id => Some(serde_json::to_string(new_block).unwrap()), + _ => None, + }) +} + +fn any_rewritten_contains(edits: &[EngineEdit], needle: &str) -> bool { + edits.iter().any(|e| match e { + EngineEdit::ReplaceBlock { new_block, .. } => { + serde_json::to_string(new_block).unwrap().contains(needle) + } + _ => false, + }) +} + +fn any_touches_block(edits: &[EngineEdit], block_id: &str) -> bool { + edits.iter().any(|e| match e { + EngineEdit::ReplaceBlock { block_id: bid, .. } + | EngineEdit::DeleteBlock { block_id: bid, .. } => bid.as_ref() == block_id, + EngineEdit::InsertBlock { .. } => false, + }) +} +use zen_expression::variable::{Variable, VariableType}; + +#[derive(Deserialize, Debug)] +struct DiagnosticsCase { + name: String, + #[serde(default)] + policies: Vec, + #[serde(default)] + content: Option, + #[serde(default)] + policy: Option, + #[serde(default)] + no_errors: bool, + #[serde(default)] + error_codes: Vec, + #[serde(default)] + warning_codes: Vec, + #[serde(default)] + hint_codes: Vec, + #[serde(default)] + error_count: Option, + #[serde(default)] + hint_count: Option, +} + +#[derive(Deserialize, Debug)] +struct DiagnosticsFile { + #[serde(rename = "test")] + tests: Vec, +} + +impl DiagnosticsCase { + fn policy_path(&self) -> &str { + self.policy + .as_deref() + .or_else(|| self.policies.first().map(String::as_str)) + .unwrap_or("p") + } + + fn run(&self, fixtures_dir: &Path) { + let mut ws = PolicyWorkspace::new(); + + if let Some(inline) = &self.content { + let doc: serde_json::Value = serde_json::from_str(inline) + .unwrap_or_else(|e| panic!("[{}] invalid inline policy JSON: {e}", self.name)); + let path = self.policy_path(); + ws.set_policy(path, serde_json::from_value(doc).unwrap()); + } else { + for fixture in &self.policies { + let path = fixtures_dir.join(fixture); + let raw = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("[{}] cannot read {:?}: {e}", self.name, path)); + let doc: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| { + panic!("[{}] invalid fixture JSON in {fixture}: {e}", self.name) + }); + ws.set_policy(fixture.as_str(), serde_json::from_value(doc).unwrap()); + } + } + + let target = self.policy_path(); + let diagnostics = ws.diagnostics(target); + let errors: Vec<_> = diagnostics + .iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + let warnings: Vec<_> = diagnostics + .iter() + .filter(|d| d.severity == Severity::Warning) + .collect(); + let hints: Vec<_> = diagnostics + .iter() + .filter(|d| d.severity == Severity::Hint) + .collect(); + + if self.no_errors { + assert!( + errors.is_empty(), + "[{}] expected no errors, got {errors:#?}", + self.name, + ); + } + if let Some(expected) = self.error_count { + assert_eq!( + errors.len(), + expected, + "[{}] expected exactly {expected} error(s), got {}: {errors:#?}", + self.name, + errors.len(), + ); + } + for code in &self.error_codes { + assert!( + errors.iter().any(|d| format!("{:?}", d.code) == *code), + "[{}] expected error `{code}` not found in {errors:#?}", + self.name, + ); + } + for code in &self.warning_codes { + assert!( + warnings.iter().any(|d| format!("{:?}", d.code) == *code), + "[{}] expected warning `{code}` not found in {warnings:#?}", + self.name, + ); + } + if let Some(expected) = self.hint_count { + assert_eq!( + hints.len(), + expected, + "[{}] expected exactly {expected} hint(s), got {}: {hints:#?}", + self.name, + hints.len(), + ); + } + for code in &self.hint_codes { + assert!( + hints.iter().any(|d| format!("{:?}", d.code) == *code), + "[{}] expected hint `{code}` not found in {hints:#?}", + self.name, + ); + } + } +} + +#[test] +fn diagnostics_toml_cases() { + let toml_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/policy/diagnostics.toml"); + let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/policy/fixtures"); + let raw = + fs::read_to_string(&toml_path).unwrap_or_else(|e| panic!("cannot read {toml_path:?}: {e}")); + let file: DiagnosticsFile = + toml::from_str(&raw).unwrap_or_else(|e| panic!("cannot parse {toml_path:?}: {e}")); + + let mut failures: Vec = Vec::new(); + for case in &file.tests { + if let Err(err) = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case.run(&fixtures_dir))) + { + let msg = err + .downcast_ref::() + .cloned() + .or_else(|| err.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| "".to_string()); + failures.push(format!("{}:\n{msg}", case.name)); + } + } + assert!( + failures.is_empty(), + "{} diagnostics case(s) failed:\n\n{}", + failures.len(), + failures.join("\n\n"), + ); +} + +#[derive(Deserialize, Debug)] +struct DependencyCase { + name: String, + #[serde(default)] + policies: Vec, + #[serde(default)] + content: Option, + #[serde(default)] + policy: Option, + target: String, + #[serde(default)] + computed: Vec, + #[serde(default)] + inputs: Vec, + #[serde(default)] + unresolved: Vec, +} + +#[derive(Deserialize, Debug)] +struct DependencyFile { + #[serde(rename = "test")] + tests: Vec, +} + +impl DependencyCase { + fn collect(node: &zen_engine::policy::DependencyNode, out: &mut Vec<(String, bool, bool)>) { + out.push(( + node.property.to_string(), + node.written_by.is_some(), + node.unresolved, + )); + for d in &node.deps { + Self::collect(d, out); + } + } + + fn run(&self, fixtures_dir: &Path) { + let mut ws = PolicyWorkspace::new(); + if let Some(inline) = &self.content { + let doc: serde_json::Value = serde_json::from_str(inline) + .unwrap_or_else(|e| panic!("[{}] invalid inline policy JSON: {e}", self.name)); + let path = self + .policy + .as_deref() + .or_else(|| self.policies.first().map(String::as_str)) + .unwrap_or("p"); + ws.set_policy(path, serde_json::from_value(doc).unwrap()); + } else { + for fixture in &self.policies { + let path = fixtures_dir.join(fixture); + let raw = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("[{}] cannot read {:?}: {e}", self.name, path)); + let doc: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| { + panic!("[{}] invalid fixture JSON in {fixture}: {e}", self.name) + }); + ws.set_policy(fixture.as_str(), serde_json::from_value(doc).unwrap()); + } + } + + let mut nodes: Vec<(String, bool, bool)> = Vec::new(); + Self::collect(&ws.dependencies(&self.target), &mut nodes); + let paths: Vec<&str> = nodes.iter().map(|(p, _, _)| p.as_str()).collect(); + let find = |path: &str| nodes.iter().find(|(p, _, _)| p == path); + + for c in &self.computed { + let node = find(c) + .unwrap_or_else(|| panic!("[{}] expected `{c}` in tree; got {paths:?}", self.name)); + assert!( + node.1, + "[{}] `{c}` expected computed (writer), got input/unresolved", + self.name + ); + } + for i in &self.inputs { + let node = find(i) + .unwrap_or_else(|| panic!("[{}] expected `{i}` in tree; got {paths:?}", self.name)); + assert!( + !node.1 && !node.2, + "[{}] `{i}` expected plain input, got writer={} unresolved={}", + self.name, + node.1, + node.2 + ); + } + for u in &self.unresolved { + let node = find(u) + .unwrap_or_else(|| panic!("[{}] expected `{u}` in tree; got {paths:?}", self.name)); + assert!( + node.2, + "[{}] `{u}` expected unresolved, got writer={}", + self.name, node.1 + ); + } + } +} + +#[test] +fn dependencies_toml_cases() { + let toml_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/policy/dependencies.toml"); + let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/policy/fixtures"); + let raw = + fs::read_to_string(&toml_path).unwrap_or_else(|e| panic!("cannot read {toml_path:?}: {e}")); + let file: DependencyFile = + toml::from_str(&raw).unwrap_or_else(|e| panic!("cannot parse {toml_path:?}: {e}")); + + let mut failures: Vec = Vec::new(); + for case in &file.tests { + if let Err(err) = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case.run(&fixtures_dir))) + { + let msg = err + .downcast_ref::() + .cloned() + .or_else(|| err.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| "".to_string()); + failures.push(format!("{}:\n{msg}", case.name)); + } + } + assert!( + failures.is_empty(), + "{} dependency case(s) failed:\n\n{}", + failures.len(), + failures.join("\n\n"), + ); +} + +fn data_model_document() -> serde_json::Value { + json!({ + "blocks": [ + { + "id": "dm-heading", + "type": "heading", + "props": {}, + "children": [] + }, + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "name", "type": "string", "array": false, "optional": false }, + { "id": "p2", "name": "age", "type": "number", "array": false, "optional": false }, + { "id": "p3", "name": "country", "type": "string", "array": false, "optional": false }, + { "id": "p4", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }, + { "id": "p5", "name": "creditReport", "type": "relationship", "target": "creditReport", "array": false, "optional": false }, + { "id": "p6", "name": "favoriteProduct", "type": "reference", "target": "product", "array": false, "optional": true } + ] + }) + } + }, + { + "id": "dm-company", + "type": "dataModel", + "props": { + "data": json!({ + "name": "company", + "properties": [ + { "id": "p7", "name": "id", "type": "string", "array": false, "optional": false }, + { "id": "p8", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + }) + } + }, + { + "id": "dm-credit-report", + "type": "dataModel", + "props": { + "data": json!({ + "name": "creditReport", + "properties": [ + { "id": "p11", "name": "score", "type": "number", "array": false, "optional": false } + ] + }) + } + }, + { + "id": "dm-product", + "type": "dataModel", + "props": { + "data": json!({ + "name": "product", + "properties": [ + { "id": "p14", "name": "id", "type": "string", "array": false, "optional": false }, + { "id": "p15", "name": "name", "type": "string", "array": false, "optional": false } + ] + }) + } + } + ] + }) +} + +#[test] +fn basic_entities_and_inputs() { + let mut ws = PolicyWorkspace::new(); + let doc = data_model_document(); + ws.set_policy("test", serde_json::from_value(doc).unwrap()); + + let diags = ws.diagnostics("test"); + let errors: Vec<_> = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "expected no errors, got {:?}", errors); + + let entities = ws.entities(&ScopeRequest::for_policy("test")); + let names: Vec<_> = entities.iter().map(|e| e.name.to_string()).collect(); + assert!(names.contains(&"customer".to_string())); + assert!(names.contains(&"company".to_string())); + assert!(names.contains(&"creditReport".to_string())); + assert!(names.contains(&"product".to_string())); + + let customer = entities + .iter() + .find(|e| e.name.as_ref() == "customer") + .unwrap(); + assert_eq!(customer.fields.len(), 6); + + let inputs = ws.inputs(&ScopeRequest::for_policy("test")); + let paths: Vec<_> = inputs.iter().map(|p| p.path.to_string()).collect(); + + let fav = inputs + .iter() + .find(|p| p.path.as_ref() == "customer.favoriteProduct"); + assert!( + fav.is_some(), + "customer.favoriteProduct missing; inputs={:?}", + paths + ); + assert!(matches!(fav.unwrap().resolved_type, VariableType::String)); + + let companies = inputs + .iter() + .find(|p| p.path.as_ref() == "customer.companies"); + assert!(companies.is_some()); + assert!(matches!( + companies.unwrap().resolved_type, + VariableType::Array(_) + )); + + let product = inputs.iter().find(|p| p.path.as_ref() == "product"); + assert!(product.is_some(), "product top-level ref target missing"); + assert!(matches!( + product.unwrap().resolved_type, + VariableType::Array(_) + )); + + assert!( + !paths.iter().any(|p| p.starts_with("company.")), + "company.* should not be top-level" + ); + assert!( + !paths.iter().any(|p| p == "company"), + "company should not appear as top-level" + ); +} + +#[test] +fn references_sorted_dataModel_then_reads_then_writes() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "policy", + serde_json::from_value(analysis_document()).unwrap(), + ); + + let refs = ws.references(&zen_engine::policy::RenameTarget::Field { + entity: Arc::from("customer"), + field: Arc::from("creditTier"), + }); + assert!(!refs.is_empty(), "expected at least one reference site"); + + let order: Vec = refs.iter().map(|r| r.kind.display_order()).collect(); + let mut sorted = order.clone(); + sorted.sort(); + assert_eq!( + order, sorted, + "references should be sorted by kind: dataModel → expressionRead → writeKey; got {refs:#?}", + ); +} + +#[test] +fn goal_evaluate_accepts_array_input_for_array_element_reads() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "policy", + serde_json::from_value(analysis_document()).unwrap(), + ); + + let skeleton = ws.input_skeleton(&ScopeRequest { + policy_path: Arc::from("policy"), + goals: vec![Arc::from("customer.totalRevenue")], + }); + let customer = skeleton + .as_object() + .and_then(|o| o.get("customer")) + .and_then(|v| v.as_object()) + .expect("customer in skeleton"); + assert!( + customer.contains_key("companies"), + "skeleton must include customer.companies for goal=totalRevenue; got {customer:#?}", + ); + + let req = EvaluateRequest { + policy_path: Arc::from("policy"), + input: Variable::from(skeleton), + goals: vec![Arc::from("customer.totalRevenue")], + trace: false, + }; + let result = ws.evaluate(&req); + assert!( + result.is_ok(), + "evaluate(goal=totalRevenue, input=skeleton) should succeed; got {:?}", + result.err(), + ); +} + +fn optional_input_document() -> serde_json::Value { + json!({ + "blocks": [ + { + "id": "dm", + "type": "dataModel", + "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }, + { "id": "p2", "name": "nickname", "type": "string", "array": false, "optional": true } + ] + }}, + "children": [] + }, + { + "id": "assert-adult", + "type": "assertion", + "props": { "data": { + "output": "customer.adult", + "conditions": [ + { "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 } + ] + }}, + "children": [] + }, + { + "id": "assert-greeted", + "type": "assertion", + "props": { "data": { + "output": "customer.greeted", + "conditions": [ + { "id": "c2", "expression": "customer.nickname != null", "operator": "and", "depth": 0 } + ] + }}, + "children": [] + } + ] + }) +} + +#[test] +fn optional_input_omission_does_not_flag_missing() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "policy", + serde_json::from_value(optional_input_document()).unwrap(), + ); + + let greeted = ws.evaluate(&EvaluateRequest { + policy_path: Arc::from("policy"), + input: Variable::from(json!({ "customer": {} })), + goals: vec![Arc::from("customer.greeted")], + trace: false, + }); + assert!( + greeted.is_ok(), + "omitting optional input customer.nickname must not raise MissingRequiredInputs; got {:?}", + greeted.err(), + ); + + let adult = ws.evaluate(&EvaluateRequest { + policy_path: Arc::from("policy"), + input: Variable::from(json!({ "customer": {} })), + goals: vec![Arc::from("customer.adult")], + trace: false, + }); + assert!( + matches!(adult, Err(EvaluationError::MissingRequiredInputs { .. })), + "omitting required input customer.age must raise MissingRequiredInputs; got {adult:?}", + ); +} + +#[test] +fn dependencies_distinguishes_per_write_in_multi_output_tree() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "policy", + serde_json::from_value(analysis_document()).unwrap(), + ); + + let total = ws.dependencies("customer.totalRevenue"); + let dep_paths: Vec = total.deps.iter().map(|d| d.property.to_string()).collect(); + assert!( + dep_paths.iter().any(|p| p.contains("customer.companies")), + "totalRevenue should depend on customer.companies; got {dep_paths:?}", + ); + assert!( + !dep_paths.iter().any(|p| p == "customer.creditReport.score"), + "totalRevenue must NOT depend on customer.creditReport.score (per-write granularity); got {dep_paths:?}", + ); + + let tier = ws.dependencies("customer.creditTier"); + let tier_paths: Vec = tier.deps.iter().map(|d| d.property.to_string()).collect(); + assert!( + tier_paths + .iter() + .any(|p| p == "customer.creditReport.score"), + "creditTier should depend on customer.creditReport.score; got {tier_paths:?}", + ); + + let discount = ws.dependencies("customer.discount"); + let discount_first: Vec = discount + .deps + .iter() + .map(|d| d.property.to_string()) + .collect(); + assert!( + discount_first.iter().any(|p| p == "customer.creditTier"), + "discount should directly depend on creditTier; got {discount_first:?}", + ); + assert!( + discount_first.iter().any(|p| p == "customer.totalRevenue"), + "discount should directly depend on totalRevenue; got {discount_first:?}", + ); + let credit_node = discount + .deps + .iter() + .find(|d| d.property.as_ref() == "customer.creditTier") + .expect("creditTier child"); + let credit_grandchildren: Vec = credit_node + .deps + .iter() + .map(|d| d.property.to_string()) + .collect(); + assert!( + credit_grandchildren + .iter() + .any(|p| p == "customer.creditReport.score"), + "discount → creditTier → customer.creditReport.score should appear transitively; got {credit_grandchildren:?}", + ); +} + +#[test] +fn input_skeleton_omits_back_references_and_terminates() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "test", + serde_json::from_value(data_model_document()).unwrap(), + ); + + let skeleton = ws.input_skeleton(&ScopeRequest::for_policy("test")); + + let root = skeleton.as_object().expect("skeleton is an object"); + let customer = root + .get("customer") + .and_then(|v| v.as_object()) + .expect("customer top-level missing"); + + assert_eq!(customer.get("name"), Some(&serde_json::json!(""))); + assert_eq!(customer.get("age"), Some(&serde_json::json!(0))); + + assert_eq!( + customer.get("companies"), + Some(&serde_json::json!([{ "id": "", "revenue": 0 }])), + ); + + assert_eq!( + customer.get("favoriteProduct"), + Some(&serde_json::json!("")) + ); + + let credit = customer + .get("creditReport") + .and_then(|v| v.as_object()) + .expect("creditReport defaults missing"); + assert_eq!(credit.get("score"), Some(&serde_json::json!(0))); + assert!( + !credit.contains_key("customer"), + "creditReport must not carry a back-reference to customer in the skeleton; got {credit:#?}", + ); + + assert_eq!( + root.get("product"), + Some(&serde_json::json!([{ "id": "", "name": "" }])), + ); +} + +fn analysis_document() -> serde_json::Value { + let mut dm = data_model_document(); + let blocks = dm["blocks"].as_array_mut().unwrap(); + + blocks.push(json!({ + "id": "ds1", + "type": "expression", + "props": { + "data": json!({ + "key": "customer.totalRevenue", + "value": "sum(map(customer.companies as c, c.revenue))" + }) + } + })); + + blocks.push(json!({ + "id": "f1", + "type": "match", + "props": { + "data": json!({ + "key": "customer.creditTier", + "arms": [ + { "id": "db1", "condition": "customer.creditReport.score >= 750", "value": "\"excellent\"" }, + { "id": "db2", "condition": "", "value": "\"good\"" } + ] + }) + } + })); + + blocks.push(json!({ + "id": "dt1", + "type": "decisionTable", + "props": { + "data": json!({ + "hitPolicy": "first", + "inputs": [ + { "id": "i1", "name": "", "field": "customer.creditTier" }, + { "id": "i2", "name": "", "field": "customer.totalRevenue" } + ], + "outputs": [ + { "id": "o1", "name": "", "field": "customer.discount" } + ], + "rules": [ + { "i1": "\"excellent\"", "i2": ">= 500000", "o1": "0.2" }, + { "i1": "\"excellent\"", "i2": "", "o1": "0.15" }, + { "i1": "", "i2": "", "o1": "0.05" } + ] + }) + } + })); + + dm +} + +#[test] +fn analysis_no_errors_and_outputs_present() { + let mut ws = PolicyWorkspace::new(); + let doc = analysis_document(); + ws.set_policy("policy", serde_json::from_value(doc).unwrap()); + + let diags = ws.diagnostics("policy"); + let errors: Vec<_> = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + for e in &errors { + eprintln!(" [{:?}] {} @ {:?}", e.code, e.message, e.location); + } + assert!( + errors.is_empty(), + "expected no errors, got {}", + errors.len() + ); + + let outputs = ws.outputs(&ScopeRequest::for_policy("policy")); + let discount = outputs + .iter() + .find(|p| p.path.as_ref() == "customer.discount"); + assert!(discount.is_some(), "customer.discount missing from outputs"); + assert!(matches!( + discount.unwrap().resolved_type, + VariableType::Number + )); + + let credit_tier_refs = ws.references(&zen_engine::policy::RenameTarget::Field { + entity: Arc::from("customer"), + field: Arc::from("creditTier"), + }); + assert!( + credit_tier_refs + .iter() + .any(|s| s.block_id.as_ref() == "dt1"), + "customer.creditTier should be referenced inside dt1; got {credit_tier_refs:#?}", + ); + let total_revenue_refs = ws.references(&zen_engine::policy::RenameTarget::Field { + entity: Arc::from("customer"), + field: Arc::from("totalRevenue"), + }); + assert!( + total_revenue_refs + .iter() + .any(|s| s.block_id.as_ref() == "dt1"), + "customer.totalRevenue should be referenced inside dt1; got {total_revenue_refs:#?}", + ); +} + +#[test] +fn columns_kind_shape_is_accepted() { + let doc = json!({ + "blocks": [ + { + "id": "dm", + "type": "dataModel", + "props": { + "data": json!({ + "name": "customer", + "properties": [ + { "id": "a", "name": "age", "type": "number", "array": false, "optional": false } + ] + }) + } + }, + { + "id": "dt", + "type": "decisionTable", + "props": { + "data": json!({ + "hitPolicy": "first", + "columns": [ + { "id": "i1", "field": "customer.age", "kind": "input" }, + { "id": "o1", "field": "customer.basePrice", "kind": "output" } + ], + "rules": [ + { "i1": ">= 65", "o1": "80" }, + { "i1": ">= 18", "o1": "100" }, + { "i1": "", "o1": "50" } + ] + }) + } + } + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("pricing", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("pricing") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "expected no errors, got {:?}", errors); + + let outputs = ws.outputs(&ScopeRequest::for_policy("pricing")); + let base_price = outputs + .iter() + .find(|p| p.path.as_ref() == "customer.basePrice") + .expect("customer.basePrice should be registered"); + assert!(matches!(base_price.resolved_type, VariableType::Number)); + + let req = EvaluateRequest { + policy_path: Arc::from("pricing"), + input: Variable::from(json!({ "customer": { "age": 35 } })), + goals: Vec::new(), + trace: true, + }; + let result = ws.evaluate(&req).expect("evaluate should succeed"); + let serialized: serde_json::Value = result.output.into(); + assert_eq!( + serialized + .get("customer") + .and_then(|c| c.get("basePrice")) + .and_then(|v| v.as_i64()), + Some(100), + "customer.basePrice should be 100 for age 35; got {:?}", + serialized + ); + let trace = result.trace.expect("trace should be populated"); + assert_eq!(trace.executions.len(), 1); +} + +#[test] +fn invalid_input_cell_errors() { + let mut dm = data_model_document(); + let blocks = dm["blocks"].as_array_mut().unwrap(); + blocks.push(json!({ + "id": "dt-bad", + "type": "decisionTable", + "props": { + "data": json!({ + "hitPolicy": "first", + "inputs": [ { "id": "i1", "name": "", "field": "customer.age" } ], + "outputs": [ { "id": "o1", "name": "", "field": "customer.discount" } ], + "rules": [ + { "i1": "tru", "o1": "0.1" }, + { "i1": "", "o1": "0.05" } + ] + }) + } + })); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("test", serde_json::from_value(dm).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("test") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(!errors.is_empty(), "expected errors for invalid cell 'tru'"); +} + +#[test] +fn multi_policy_with_import() { + let shared_schema = json!({ + "name": "customer", + "properties": [ + { "id": "a", "name": "age", "type": "number", "array": false, "optional": false } + ] + }); + + let pricing = json!({ + "blocks": [ + { "id": "dm-pricing", "type": "dataModel", "props": { "data": shared_schema.clone() } }, + { + "id": "dt-pricing", + "type": "decisionTable", + "props": { + "data": json!({ + "hitPolicy": "first", + "columns": [ + { "id": "i1", "field": "customer.age", "kind": "input" }, + { "id": "o1", "field": "customer.basePrice", "kind": "output" } + ], + "rules": [ + { "i1": ">= 65", "o1": "80" }, + { "i1": ">= 18", "o1": "100" }, + { "i1": "", "o1": "50" } + ] + }) + } + } + ] + }); + + let order = json!({ + "imports": ["pricing"], + "blocks": [ + { "id": "dm-order", "type": "dataModel", "props": { "data": shared_schema.clone() } }, + { + "id": "a-order", + "type": "assertion", + "props": { + "data": json!({ + "output": "customer.approved", + "conditions": [ + { "id": "c1", "expression": "customer.basePrice > 0", "operator": "and", "depth": 0 } + ] + }) + } + } + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("pricing", serde_json::from_value(pricing).unwrap()); + ws.set_policy("order", serde_json::from_value(order).unwrap()); + + for path in ["pricing", "order"] { + let errors: Vec<_> = ws + .diagnostics(path) + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "[{path}] expected no errors, got {:?}", + errors + ); + } + + let outputs = ws.outputs(&ScopeRequest::for_policy("order")); + let bp = outputs + .iter() + .find(|p| p.path.as_ref() == "customer.basePrice") + .expect("customer.basePrice should be visible via import"); + let writer = bp.written_by.as_ref().expect("writer present"); + assert_eq!(writer.policy_path.as_ref(), "pricing"); + assert_eq!(writer.block_id.as_ref(), "dt-pricing"); + + let req = EvaluateRequest { + policy_path: Arc::from("order"), + input: Variable::from(json!({ "customer": { "age": 35 } })), + goals: Vec::new(), + trace: false, + }; + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let out: serde_json::Value = result.output.into(); + assert_eq!( + out.get("customer") + .and_then(|c| c.get("basePrice")) + .and_then(|v| v.as_i64()), + Some(100) + ); + assert_eq!( + out.get("customer") + .and_then(|c| c.get("approved")) + .and_then(|v| v.as_bool()), + Some(true) + ); +} + +#[test] +fn missing_import_diagnostic_and_evaluate_error() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "main", + serde_json::from_value(json!({ + "imports": ["does-not-exist"], + "blocks": [ expr("e1", "x", "1 + 1") ] + })) + .unwrap(), + ); + + let errs = errors(&ws, "main"); + assert!( + errs.iter().any(|e| e.contains("ImportNotFound")), + "expected ImportNotFound diagnostic, got {errs:?}" + ); + + let req = EvaluateRequest { + policy_path: Arc::from("main"), + input: Variable::from(json!({})), + goals: Vec::new(), + trace: false, + }; + let err = ws + .evaluate(&req) + .expect_err("evaluate must fail when an import is missing"); + assert!( + matches!( + &err, + EvaluationError::ImportNotFound { policy_path, import } + if policy_path.as_ref() == "main" && import.as_ref() == "does-not-exist" + ), + "expected ImportNotFound, got {err:?}" + ); + + let err = ws + .enhance_trace(&req) + .expect_err("enhance_trace must fail when an import is missing"); + assert!(matches!(&err, EvaluationError::ImportNotFound { .. })); +} + +#[test] +fn missing_transitive_import_fails_evaluate() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "mid", + serde_json::from_value(json!({ + "imports": ["does-not-exist"], + "blocks": [ expr("e1", "y", "2") ] + })) + .unwrap(), + ); + ws.set_policy( + "main", + serde_json::from_value(json!({ + "imports": ["mid"], + "blocks": [ expr("e2", "x", "1") ] + })) + .unwrap(), + ); + + let req = EvaluateRequest { + policy_path: Arc::from("main"), + input: Variable::from(json!({})), + goals: Vec::new(), + trace: false, + }; + let err = ws + .evaluate(&req) + .expect_err("evaluate must fail when a transitive import is missing"); + assert!( + matches!( + &err, + EvaluationError::ImportNotFound { policy_path, import } + if policy_path.as_ref() == "mid" && import.as_ref() == "does-not-exist" + ), + "expected ImportNotFound on 'mid', got {err:?}" + ); +} + +fn closure_doc() -> serde_json::Value { + json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-company", "type": "dataModel", "props": { "data": json!({ + "name": "company", + "properties": [ + { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "tree", "type": "expression", "props": { "data": json!({ + "key": "customer.totalRevenue", + "value": "sum(map(customer.companies as c, c.revenue))" + }) }} + ] + }) +} + +#[test] +fn rename_follows_both_direct_refs_and_closure_aliases() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(closure_doc()).unwrap()); + + let edits = ws.rename( + &zen_engine::policy::RenameTarget::Entity { + name: Arc::from("customer"), + }, + "buyer", + ); + assert!( + any_rewritten_contains(&edits, "buyer.companies"), + "entity rename should rewrite `customer.companies` → `buyer.companies` in the closure", + ); + assert!( + any_rewritten_contains(&edits, "buyer.totalRevenue"), + "entity rename should rewrite the write key `customer.totalRevenue` → `buyer.totalRevenue`", + ); + + let edits = ws.rename( + &zen_engine::policy::RenameTarget::Field { + entity: Arc::from("company"), + field: Arc::from("revenue"), + }, + "income", + ); + assert!( + any_touches_block(&edits, "dm-company"), + "DataModel declaration of company.revenue should be renamed", + ); + let tree_json = rewritten_block_json(&edits, "tree") + .expect("rename should rewrite the tree block holding the closure body"); + assert!( + tree_json.contains("c.income"), + "aliased access `c.revenue` should become `c.income`; got {tree_json}", + ); +} + +#[test] +fn nested_iteration_uses_owner_back_reference() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-company", "type": "dataModel", "props": { "data": json!({ + "name": "company", + "properties": [ + { "id": "p2", "name": "id", "type": "string", "array": false, "optional": false }, + { "id": "p3", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "tree", "type": "expression", "props": { "data": json!({ + "key": "company.isHighestRevenue", + "value": "max(map(company.customer.companies as c, c.revenue)) == company.revenue" + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "expected no errors, got {errors:#?}",); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ + "customer": { + "companies": [ + { "id": "c1", "revenue": 500 }, + { "id": "c2", "revenue": 1000 } + ] + } + })), + goals: Vec::new(), + trace: false, + }; + let result = ws.evaluate(&req).expect("evaluate should succeed"); + let serialized: serde_json::Value = result.output.into(); + let companies = serialized + .get("customer") + .and_then(|c| c.get("companies")) + .and_then(|v| v.as_array()) + .expect("customer.companies present"); + + let flags: Vec = companies + .iter() + .map(|c| { + c.get("isHighestRevenue") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + }) + .collect(); + assert_eq!(flags, vec![false, true], "c2 has the highest revenue"); +} + +#[test] +fn completions_follow_cyclic_owner_back_reference_deeply() { + let expr = "company.customer.companies[0].customer.companies[0]."; + let cursor_pos = expr.len() as u32; + + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-company", "type": "dataModel", "props": { "data": json!({ + "name": "company", + "properties": [ + { "id": "p2", "name": "id", "type": "string", "array": false, "optional": false }, + { "id": "p3", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "tree", "type": "expression", "props": { "data": json!({ + "key": "company.tag", "value": expr + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let completions = ws.completions(&Cursor { + policy_path: Arc::from("p"), + block_id: Arc::from("tree"), + pos: cursor_pos, + target: CursorTarget::Expression { + id: Arc::from("s1"), + }, + }); + let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect(); + assert!( + !labels.is_empty(), + "expected non-empty completions at deep back-ref" + ); + for expected in ["id", "revenue", "customer"] { + assert!( + labels.iter().any(|l| *l == expected), + "expected completion '{expected}' in {labels:?}", + ); + } +} + +#[test] +fn rename_entity_from_decision_table_head() { + let doc = json!({ + "blocks": [ + { "id": "dm1", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "dt1", "type": "decisionTable", "props": { "data": json!({ + "hitPolicy": "first", + "inputs": [ { "id": "i1", "name": "", "field": "customer.age" } ], + "outputs": [ { "id": "o1", "name": "", "field": "customer.price" } ], + "rules": [ { "i1": ">= 18", "o1": "100" } ] + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let cursor = Cursor { + policy_path: Arc::from("p"), + block_id: Arc::from("dt1"), + pos: 0, + target: CursorTarget::DecisionTableHead { + col: Arc::from("i1"), + }, + }; + + let prep = ws.prepare_rename(&cursor).expect("prepare_rename"); + let edits = ws.rename(&prep.target, "buyer"); + assert!(!edits.is_empty(), "rename produced no edits"); +} + +#[test] +fn entities_includes_imported_policy_fields() { + let kyc = json!({ + "blocks": [ + { "id": "dm-cust", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }, + { "id": "p2", "name": "country", "type": "string", "array": false, "optional": false }, + { "id": "p3", "name": "kycVerified", "type": "boolean", "array": false, "optional": false } + ] + }) }} + ] + }); + let order = json!({ + "imports": ["underwriting/kyc"], + "blocks": [ + { "id": "ord-a", "type": "assertion", "props": { "data": json!({ + "output": "customer.orderApproved", + "conditions": [ + { "id": "c1", "expression": "customer.kycVerified == true", "operator": "and", "depth": 0 } + ] + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("underwriting/kyc", serde_json::from_value(kyc).unwrap()); + ws.set_policy("risk/order", serde_json::from_value(order).unwrap()); + + let diagnostics = ws.diagnostics("risk/order"); + let errors: Vec<_> = diagnostics + .iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "expected no errors compiling risk/order; got {errors:#?}" + ); + + let entities = ws.entities(&ScopeRequest::for_policy("risk/order")); + let customer = entities + .iter() + .find(|e| e.name.as_ref() == "customer") + .unwrap_or_else(|| panic!("customer entity missing from entities(); got {entities:#?}")); + + let field_names: Vec<&str> = customer.fields.iter().map(|f| f.name.as_ref()).collect(); + eprintln!("entities()['customer'] fields: {field_names:?}"); + for required in ["age", "country", "kycVerified", "orderApproved"] { + assert!( + field_names.iter().any(|n| *n == required), + "expected field `{required}` in customer entity; got {field_names:?}" + ); + } +} + +#[test] +fn policy_merge_evaluates_correctly() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "id", "type": "string", "array": false, "optional": false } + ] + }) }}, + { "id": "tree", "type": "expression", "props": { "data": json!({ + "key": "customer.merged", + "value": "merge([{a: 1, b: 2}, {b: 99, c: 3}])" + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "id": "x" } })), + goals: Vec::new(), + trace: false, + }; + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let out: serde_json::Value = result.output.into(); + let merged = out + .get("customer") + .and_then(|c| c.get("merged")) + .expect("customer.merged missing"); + assert_eq!(merged.get("a").and_then(|v| v.as_i64()), Some(1)); + assert_eq!(merged.get("b").and_then(|v| v.as_i64()), Some(99)); + assert_eq!(merged.get("c").and_then(|v| v.as_i64()), Some(3)); +} + +#[test] +fn rename_field_on_array_index_without_back_ref() { + let expr = "customer.companies[0].revenue"; + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-company", "type": "dataModel", "props": { "data": json!({ + "name": "company", + "properties": [ + { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "tree", "type": "expression", "props": { "data": json!({ + "key": "customer.topRevenue", "value": expr + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let edits = ws.rename( + &zen_engine::policy::RenameTarget::Field { + entity: Arc::from("company"), + field: Arc::from("revenue"), + }, + "income", + ); + + let tree_json = rewritten_block_json(&edits, "tree") + .unwrap_or_else(|| panic!("rename should rewrite the tree block; got {edits:#?}")); + assert!( + tree_json.contains("customer.companies[0].income"), + "rename should rewrite trailing .revenue → .income; got {tree_json}", + ); + assert!( + !tree_json.contains("customer.companies[0].revenue"), + "old `.revenue` should be gone; got {tree_json}", + ); +} + +#[test] +fn rename_field_through_indexed_back_reference_chain() { + let expr = "company.customer.companies[0].customer.creditTier"; + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }, + { "id": "p2", "name": "creditTier", "type": "string", "array": false, "optional": false } + ] + }) }}, + { "id": "dm-company", "type": "dataModel", "props": { "data": json!({ + "name": "company", + "properties": [ + { "id": "p3", "name": "id", "type": "string", "array": false, "optional": false } + ] + }) }}, + { "id": "tree", "type": "expression", "props": { "data": json!({ + "key": "company.hello", "value": expr + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "expected no errors, got {errors:#?}"); + + let edits = ws.rename( + &zen_engine::policy::RenameTarget::Field { + entity: Arc::from("customer"), + field: Arc::from("creditTier"), + }, + "creditTiera", + ); + + assert!( + any_touches_block(&edits, "dm-customer"), + "rename should update customer.creditTier in dm-customer; got edits: {edits:#?}", + ); + + let tree_json = rewritten_block_json(&edits, "tree") + .unwrap_or_else(|| panic!("rename should rewrite the tree block; got edits: {edits:#?}")); + let expected = format!("company.customer.companies[0].customer.{}", "creditTiera"); + assert!( + tree_json.contains(&expected), + "expected rewritten `{expected}` in tree block; got {tree_json}", + ); +} + +#[test] +fn global_scalar_appears_at_json_top_level_and_evaluates() { + let doc = json!({ + "blocks": [ + { "id": "dm-globals", "type": "dataModel", "props": { "data": json!({ + "name": "platform", + "scope": "global", + "properties": [ + { "id": "g1", "name": "taxRate", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "subtotal", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "assert", "type": "assertion", "props": { "data": json!({ + "output": "customer.taxed", + "conditions": [ + { "id": "c1", "expression": "customer.subtotal * (1 + taxRate) > 0", "operator": "and", "depth": 0 } + ] + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "expected no errors, got {errors:#?}"); + + let inputs = ws.inputs(&ScopeRequest::for_policy("p")); + let tax = inputs + .iter() + .find(|p| p.path.as_ref() == "taxRate") + .expect("taxRate should be a top-level input"); + assert!(matches!(tax.resolved_type, VariableType::Number)); + + let entities = ws.entities(&ScopeRequest::for_policy("p")); + assert!( + entities.iter().all(|e| e.name.as_ref() != "platform"), + "platform (global DM name) must not surface as an entity", + ); + + let skeleton = ws.input_skeleton(&ScopeRequest::for_policy("p")); + assert_eq!( + skeleton.as_object().and_then(|o| o.get("taxRate")), + Some(&serde_json::json!(0)), + ); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ + "taxRate": 0.2, + "customer": { "subtotal": 100 } + })), + goals: Vec::new(), + trace: false, + }; + let result = ws.evaluate(&req).expect("evaluate should succeed"); + let out: serde_json::Value = result.output.into(); + assert_eq!( + out.get("customer") + .and_then(|c| c.get("taxed")) + .and_then(|v| v.as_bool()), + Some(true), + "customer.taxed should compute from global taxRate; got {out:?}", + ); +} + +#[test] +fn global_property_name_collides_with_entity_name() { + let doc = json!({ + "blocks": [ + { "id": "dm-entity", "type": "dataModel", "props": { "data": json!({ + "name": "tenant", + "properties": [ + { "id": "p1", "name": "id", "type": "string", "array": false, "optional": false } + ] + }) }}, + { "id": "dm-global", "type": "dataModel", "props": { "data": json!({ + "name": "settings", + "scope": "global", + "properties": [ + { "id": "g1", "name": "tenant", "type": "string", "array": false, "optional": false } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors + .iter() + .any(|d| format!("{:?}", d.code) == "DataModelCollision"), + "expected DataModelCollision; got {errors:#?}", + ); +} + +#[test] +fn block_writing_entity_and_global_is_mixed_scope() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "dm-globals", "type": "dataModel", "props": { "data": json!({ + "name": "settings", + "scope": "global", + "properties": [ + { "id": "g1", "name": "lastSeenAge", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "tree", "type": "decisionTable", "props": { "data": json!({ + "hitPolicy": "first", + "inputs": [], + "outputs": [ + { "id": "s1", "name": "", "field": "customer.adult" }, + { "id": "s2", "name": "", "field": "lastSeenAge" } + ], + "rules": [ + { "_id": "r1", "s1": "customer.age >= 18", "s2": "customer.age" } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors + .iter() + .any(|d| format!("{:?}", d.code) == "MixedScope"), + "expected MixedScope when block writes both entity and global; got {errors:#?}", + ); +} + +#[test] +fn global_relationship_iterates_top_level_array() { + let doc = json!({ + "blocks": [ + { "id": "dm-globals", "type": "dataModel", "props": { "data": json!({ + "name": "platform", + "scope": "global", + "properties": [ + { "id": "g1", "name": "tenants", "type": "relationship", "target": "tenant", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-tenant", "type": "dataModel", "props": { "data": json!({ + "name": "tenant", + "properties": [ + { "id": "p1", "name": "id", "type": "string", "array": false, "optional": false }, + { "id": "p2", "name": "seats", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "tree", "type": "expression", "props": { "data": json!({ + "key": "tenant.large", "value": "tenant.seats >= 100" + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "expected no errors, got {errors:#?}"); + + let inputs = ws.inputs(&ScopeRequest::for_policy("p")); + let tenants = inputs + .iter() + .find(|p| p.path.as_ref() == "tenants") + .expect("tenants should be a top-level array input"); + assert!(matches!(tenants.resolved_type, VariableType::Array(_))); + assert!( + !inputs.iter().any(|p| p.path.as_ref() == "tenant"), + "tenant pool should not surface separately; got inputs {:?}", + inputs.iter().map(|i| i.path.as_ref()).collect::>(), + ); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ + "tenants": [ + { "id": "t1", "seats": 50 }, + { "id": "t2", "seats": 250 } + ] + })), + goals: Vec::new(), + trace: false, + }; + let result = ws.evaluate(&req).expect("evaluate should succeed"); + let out: serde_json::Value = result.output.into(); + let tenants_out = out + .get("tenants") + .and_then(|v| v.as_array()) + .expect("tenants present"); + let flags: Vec = tenants_out + .iter() + .map(|t| t.get("large").and_then(|v| v.as_bool()).unwrap_or(false)) + .collect(); + assert_eq!(flags, vec![false, true], "second tenant has >=100 seats"); +} + +#[test] +fn writing_to_a_global_input_is_rejected() { + let doc = json!({ + "blocks": [ + { "id": "dm-globals", "type": "dataModel", "props": { "data": json!({ + "name": "platform", + "scope": "global", + "properties": [ + { "id": "g1", "name": "taxRate", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "assert", "type": "assertion", "props": { "data": json!({ + "output": "taxRate", + "conditions": [ + { "id": "c1", "expression": "true", "operator": "and", "depth": 0 } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors + .iter() + .any(|d| format!("{:?}", d.code) == "InputOverride"), + "expected InputOverride for write to global input; got {errors:#?}", + ); +} + +#[test] +fn cross_policy_global_collision_with_mismatched_shape() { + let policy_a = json!({ + "blocks": [ + { "id": "dm-a", "type": "dataModel", "props": { "data": json!({ + "name": "settings", + "scope": "global", + "properties": [ + { "id": "g1", "name": "region", "type": "string", "array": false, "optional": false } + ] + }) }} + ] + }); + let policy_b = json!({ + "imports": ["a"], + "blocks": [ + { "id": "dm-b", "type": "dataModel", "props": { "data": json!({ + "name": "settings", + "scope": "global", + "properties": [ + { "id": "g1", "name": "region", "type": "number", "array": false, "optional": false } + ] + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("a", serde_json::from_value(policy_a).unwrap()); + ws.set_policy("b", serde_json::from_value(policy_b).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("b") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors + .iter() + .any(|d| format!("{:?}", d.code) == "DataModelCollision"), + "expected DataModelCollision for mismatched global shapes; got {errors:#?}", + ); +} + +#[test] +fn rename_global_scalar_rewrites_reads_writes_and_declaration() { + let doc = json!({ + "blocks": [ + { "id": "dm-globals", "type": "dataModel", "props": { "data": json!({ + "name": "platform", + "scope": "global", + "properties": [ + { "id": "g1", "name": "taxRate", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "subtotal", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "tree", "type": "expression", "props": { "data": json!({ + "key": "customer.taxed", + "value": "customer.subtotal * (1 + taxRate)" + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let edits = ws.rename( + &zen_engine::policy::RenameTarget::Global { + name: Arc::from("taxRate"), + }, + "vatRate", + ); + + assert!( + any_touches_block(&edits, "dm-globals"), + "rename must touch the global DM declaration; got {edits:#?}", + ); + + let tree_json = rewritten_block_json(&edits, "tree") + .unwrap_or_else(|| panic!("rename must rewrite the tree block; got {edits:#?}")); + assert!( + tree_json.contains("vatRate"), + "tree should contain the rewritten identifier; got {tree_json}", + ); + assert!( + !tree_json.contains("taxRate"), + "old identifier should be gone from tree; got {tree_json}", + ); +} + +#[test] +fn rename_global_relationship_chains_into_target_entity() { + let doc = json!({ + "blocks": [ + { "id": "dm-globals", "type": "dataModel", "props": { "data": json!({ + "name": "platform", + "scope": "global", + "properties": [ + { "id": "g1", "name": "tenants", "type": "relationship", "target": "tenant", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-tenant", "type": "dataModel", "props": { "data": json!({ + "name": "tenant", + "properties": [ + { "id": "p1", "name": "id", "type": "string", "array": false, "optional": false }, + { "id": "p2", "name": "seats", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "tree", "type": "expression", "props": { "data": json!({ + "key": "tenant.large", + "value": "tenant.seats >= 100" + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let edits = ws.rename( + &zen_engine::policy::RenameTarget::Entity { + name: Arc::from("tenant"), + }, + "client", + ); + + let globals_json = rewritten_block_json(&edits, "dm-globals") + .unwrap_or_else(|| panic!("rename must rewrite the global DM; got {edits:#?}")); + assert!( + globals_json.contains("\"client\""), + "global Relationship target should be rewritten to `client`; got {globals_json}", + ); + + let tree_json = rewritten_block_json(&edits, "tree") + .unwrap_or_else(|| panic!("rename must rewrite the tree block; got {edits:#?}")); + assert!( + tree_json.contains("client.seats"), + "expression read should be rewritten; got {tree_json}", + ); + assert!( + tree_json.contains("client.large"), + "write key should be rewritten; got {tree_json}", + ); +} + +#[test] +fn enhance_trace_populates_dt_input_pass_bitmask() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }, + { "id": "p2", "name": "country", "type": "string", "array": false, "optional": false } + ] + }) }}, + { "id": "dt", "type": "decisionTable", "props": { "data": json!({ + "hitPolicy": "collect", + "inputs": [ + { "id": "i1", "name": "", "field": "customer.age" }, + { "id": "i2", "name": "", "field": "customer.country" } + ], + "outputs": [ { "id": "o1", "name": "", "field": "customer.tier" } ], + "rules": [ + { "i1": ">= 18", "i2": "\"US\"", "o1": "\"adult-us\"" }, + { "i1": ">= 18", "i2": "\"CA\"", "o1": "\"adult-ca\"" }, + { "i1": "< 18", "i2": "", "o1": "\"minor\"" } + ] + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "age": 25, "country": "US" } })), + goals: Vec::new(), + trace: true, + }; + let result = ws.enhance_trace(&req).expect("enhance succeeded"); + let trace = result.trace.expect("trace populated"); + assert_eq!(trace.engine_version.as_ref(), env!("CARGO_PKG_VERSION")); + + let dt_exec = trace + .executions + .iter() + .find(|e| e.block_id.as_ref() == "dt") + .expect("dt execution present"); + + let serde_json::Value::Object(trace_obj) = serde_json::to_value(&dt_exec.trace).unwrap() else { + panic!("dt trace not an object"); + }; + let extras = trace_obj + .get("extras") + .and_then(|v| v.as_object()) + .expect("extras present under enhance_trace"); + let input_pass_b64 = extras + .get("inputPass") + .and_then(|v| v.as_str()) + .expect("inputPass present"); + + let bytes = base64_decode(input_pass_b64); + let cols = 2usize; + let bytes_per_row = cols.div_ceil(8); + let bit = |row: usize, col: usize| -> bool { + let byte = row * bytes_per_row + col / 8; + (bytes[byte] >> (col % 8)) & 1 == 1 + }; + assert!(bit(0, 0), "row 0 col 0 (age >= 18) should pass for age=25"); + assert!(bit(0, 1), "row 0 col 1 (\"US\") should pass for US"); + assert!(bit(1, 0), "row 1 col 0 (age >= 18) should pass for age=25"); + assert!(!bit(1, 1), "row 1 col 1 (\"CA\") should fail for US"); + assert!(!bit(2, 0), "row 2 col 0 (age < 18) should fail for age=25"); + assert!(bit(2, 1), "row 2 col 1 (wildcard) passes"); +} + +fn base64_decode(s: &str) -> Vec { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.decode(s).unwrap() +} + +#[test] +fn enhance_trace_operand_values_are_flat_path_keyed() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "totalRevenue", "type": "number", "array": false, "optional": false }, + { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false }, + { "id": "p3", "name": "threshold", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "dt", "type": "decisionTable", "props": { "data": json!({ + "hitPolicy": "first", + "inputs": [ + { "id": "i1", "name": "", "field": "customer.totalRevenue" }, + { "id": "i2", "name": "", "field": "" } + ], + "outputs": [ { "id": "o1", "name": "", "field": "customer.discount" } ], + "rules": [ + { "_id": "r1", "i1": "$ > customer.revenue", "i2": "customer.threshold > customer.revenue", "o1": "0.1" } + ] + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from( + json!({ "customer": { "totalRevenue": 700000, "revenue": 500000, "threshold": 100 } }), + ), + goals: Vec::new(), + trace: true, + }; + let result = ws.enhance_trace(&req).expect("enhance succeeded"); + let dt = result + .trace + .expect("trace populated") + .executions + .into_iter() + .find(|e| e.block_id.as_ref() == "dt") + .expect("dt execution present"); + + let ops: std::collections::BTreeMap = dt + .operand_values + .iter() + .map(|(k, v)| (k.to_string(), v.clone().into())) + .collect(); + + let expected: std::collections::BTreeMap = [ + ("customer.totalRevenue".to_string(), json!(700000)), + ("customer.revenue".to_string(), json!(500000)), + ("customer.threshold".to_string(), json!(100)), + ] + .into_iter() + .collect(); + assert_eq!( + ops, expected, + "operand values must be a flat property-path → value map (header read + in-cell reads, one entry per path, no expression-id buckets or spans)", + ); + + let json = serde_json::to_value(&dt).unwrap(); + let serialized = serde_json::to_string(&json).unwrap(); + assert!( + json["operandValues"]["customer.revenue"] == json!(500000), + "serialized operandValues must be keyed by property path; got {serialized}", + ); + assert!( + !serialized.contains("\"span\""), + "operand values must no longer carry spans; got {serialized}", + ); + assert!( + !serialized.contains("\"i1\"") + && !serialized.contains("\"i2\"") + && !serialized.contains("\"o1\""), + "operand values must not be keyed by expression/column id; got {serialized}", + ); +} + +#[test] +fn enhance_trace_operand_values_per_instance() { + let doc: serde_json::Value = serde_json::from_str( + &fs::read_to_string("tests/data/policy/fixtures/per_instance.json").unwrap(), + ) + .unwrap(); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "name": "Alice", "companies": [ + { "name": "Acme", "revenue": 300000 }, + { "name": "Smol", "revenue": 50000 } + ]}})), + goals: Vec::new(), + trace: true, + }; + let result = ws.enhance_trace(&req).expect("enhance succeeded"); + let execs: Vec<_> = result + .trace + .expect("trace populated") + .executions + .into_iter() + .filter(|e| e.block_id.as_ref() == "company-risk") + .collect(); + assert_eq!(execs.len(), 2, "one execution per company instance"); + + let value_for = |instance: &str| -> serde_json::Value { + execs + .iter() + .find(|e| e.instance_path.as_deref() == Some(instance)) + .and_then(|e| e.operand_values.get("company.revenue").cloned()) + .map(Into::into) + .unwrap_or(serde_json::Value::Null) + }; + assert_eq!( + value_for("customer.companies.0"), + json!(300000), + "operand value resolves against the per-instance scope", + ); + assert_eq!(value_for("customer.companies.1"), json!(50000)); +} + +#[test] +fn enhance_trace_populates_match_arm_results() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "score", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "tree", "type": "match", "props": { "data": json!({ + "key": "customer.tier", + "arms": [ + { "id": "b1", "condition": "customer.score >= 750", "value": "\"gold\"" }, + { "id": "b2", "condition": "customer.score >= 500", "value": "\"silver\"" }, + { "id": "b3", "condition": "", "value": "\"bronze\"" } + ] + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "score": 600 } })), + goals: Vec::new(), + trace: true, + }; + let result = ws.enhance_trace(&req).expect("enhance succeeded"); + let trace = result.trace.expect("trace populated"); + + let tree_exec = trace + .executions + .iter() + .find(|e| e.block_id.as_ref() == "tree") + .expect("match execution present"); + let trace_obj = serde_json::to_value(&tree_exec.trace).unwrap(); + let arms = trace_obj + .get("arms") + .and_then(|v| v.as_array()) + .expect("arms present"); + let result_for = |id: &str| { + arms.iter() + .find(|a| a.get("id").and_then(|v| v.as_str()) == Some(id)) + .and_then(|a| a.get("result").and_then(|v| v.as_bool())) + }; + + assert_eq!( + trace_obj.get("matchedArm").and_then(|v| v.as_str()), + Some("b2") + ); + assert_eq!(result_for("b1"), Some(false)); + assert_eq!(result_for("b2"), Some(true)); + assert_eq!( + result_for("b3"), + Some(true), + "b3 (default arm) must be evaluated under extras so the UI can color it; got {arms:?}", + ); +} + +#[test] +fn default_trace_omits_extras() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "dt", "type": "decisionTable", "props": { "data": json!({ + "hitPolicy": "first", + "inputs": [ { "id": "i1", "name": "", "field": "customer.age" } ], + "outputs": [ { "id": "o1", "name": "", "field": "customer.tier" } ], + "rules": [ { "i1": ">= 18", "o1": "\"adult\"" } ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "age": 25 } })), + goals: Vec::new(), + trace: true, + }; + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let trace = result.trace.expect("trace populated"); + assert_eq!(trace.engine_version.as_ref(), env!("CARGO_PKG_VERSION")); + let dt_exec = trace + .executions + .iter() + .find(|e| e.block_id.as_ref() == "dt") + .unwrap(); + let trace_json = serde_json::to_string(&dt_exec.trace).unwrap(); + assert!( + !trace_json.contains("extras"), + "default trace should omit extras; got {trace_json}", + ); +} + +#[test] +fn trace_block_execution_tags_imported_policy_only() { + let shared_schema = json!({ + "name": "customer", + "properties": [ + { "id": "a", "name": "age", "type": "number", "array": false, "optional": false } + ] + }); + let pricing = json!({ + "blocks": [ + { "id": "dm-pricing", "type": "dataModel", "props": { "data": shared_schema.clone() } }, + { + "id": "dt-pricing", + "type": "decisionTable", + "props": { + "data": json!({ + "hitPolicy": "first", + "columns": [ + { "id": "i1", "field": "customer.age", "kind": "input" }, + { "id": "o1", "field": "customer.basePrice", "kind": "output" } + ], + "rules": [ + { "i1": ">= 18", "o1": "100" }, + { "i1": "", "o1": "50" } + ] + }) + } + } + ] + }); + let order = json!({ + "imports": ["pricing"], + "blocks": [ + { "id": "dm-order", "type": "dataModel", "props": { "data": shared_schema.clone() } }, + { + "id": "a-order", + "type": "assertion", + "props": { + "data": json!({ + "output": "customer.approved", + "conditions": [ + { "id": "c1", "expression": "customer.basePrice > 0", "operator": "and", "depth": 0 } + ] + }) + } + } + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("pricing", serde_json::from_value(pricing).unwrap()); + ws.set_policy("order", serde_json::from_value(order).unwrap()); + + let req = EvaluateRequest { + policy_path: Arc::from("order"), + input: Variable::from(json!({ "customer": { "age": 35 } })), + goals: Vec::new(), + trace: true, + }; + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let trace = result.trace.expect("trace populated"); + + let pricing_exec = trace + .executions + .iter() + .find(|e| e.block_id.as_ref() == "dt-pricing") + .expect("dt-pricing execution present"); + assert_eq!( + pricing_exec.policy_path.as_deref().map(|p| p.as_ref()), + Some("pricing"), + "imported block must carry its owning policy_path", + ); + + let order_exec = trace + .executions + .iter() + .find(|e| e.block_id.as_ref() == "a-order") + .expect("a-order execution present"); + assert!( + order_exec.policy_path.is_none(), + "local block must omit policy_path (saves wire bytes); got {:?}", + order_exec.policy_path, + ); + + let order_json = serde_json::to_string(order_exec).unwrap(); + assert!( + !order_json.contains("policyPath"), + "local execution must not serialize policyPath; got {order_json}", + ); + let pricing_json = serde_json::to_string(pricing_exec).unwrap(); + assert!( + pricing_json.contains("\"policyPath\":\"pricing\""), + "imported execution must serialize policyPath; got {pricing_json}", + ); +} + +#[test] +fn unreachable_read_of_iterated_entity_from_singleton_block() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-company", "type": "dataModel", "props": { "data": json!({ + "name": "company", + "properties": [ + { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "assert", "type": "assertion", "props": { "data": json!({ + "output": "customer.flagged", + "conditions": [ + { "id": "c1", "expression": "company.revenue > 0", "operator": "and", "depth": 0 } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors + .iter() + .any(|d| format!("{:?}", d.code) == "UnreachableEntityRead"), + "expected UnreachableEntityRead for direct iterated read; got {errors:#?}", + ); +} + +#[test] +fn unreachable_read_of_iterated_entity_from_global_block() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-company", "type": "dataModel", "props": { "data": json!({ + "name": "company", + "properties": [ + { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "assert", "type": "assertion", "props": { "data": json!({ + "output": "globalFlag", + "conditions": [ + { "id": "c1", "expression": "company.revenue > 0", "operator": "and", "depth": 0 } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors + .iter() + .any(|d| format!("{:?}", d.code) == "UnreachableEntityRead"), + "expected UnreachableEntityRead from globals-writing block; got {errors:#?}", + ); +} + +#[test] +fn iterated_read_via_closure_aggregation_is_reachable() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-company", "type": "dataModel", "props": { "data": json!({ + "name": "company", + "properties": [ + { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "tree", "type": "expression", "props": { "data": json!({ + "key": "customer.totalRevenue", + "value": "sum(map(customer.companies as c, c.revenue))" + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "aggregation should be reachable; got {errors:#?}" + ); +} + +#[test] +fn iterated_block_reads_own_entity_fields() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-company", "type": "dataModel", "props": { "data": json!({ + "name": "company", + "properties": [ + { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "assert", "type": "assertion", "props": { "data": json!({ + "output": "company.large", + "conditions": [ + { "id": "c1", "expression": "company.revenue >= 1000", "operator": "and", "depth": 0 } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "iterated block reading its own entity must be reachable; got {errors:#?}", + ); +} + +#[test] +fn closure_alias_sees_computed_field_on_iterated_entity() { + let doc = json!({ + "blocks": [ + { "id": "dm-item", "type": "dataModel", "props": { "data": json!({ + "name": "item", + "properties": [ + { "id": "p1", "name": "price", "type": "number", "array": false, "optional": false }, + { "id": "p2", "name": "quantity", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "dm-order", "type": "dataModel", "props": { "data": json!({ + "name": "order", + "properties": [ + { "id": "p3", "name": "items", "type": "relationship", "target": "item", "array": true, "optional": false } + ] + }) }}, + { "id": "per-item", "type": "expression", "props": { "data": json!({ + "key": "item.subtotal", + "value": "item.price * item.quantity" + }) }}, + { "id": "per-order", "type": "expression", "props": { "data": json!({ + "key": "order.total", + "value": "sum(map(order.items as i, i.subtotal))" + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "closure alias on iterated entity must see computed fields; got {errors:#?}", + ); +} + +#[test] +fn enum_field_type_checks_and_validates() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "status", "type": "string", "enum": ["active", "inactive", "pending"], "array": false, "optional": false } + ] + }) }}, + { "id": "assert", "type": "assertion", "props": { "data": json!({ + "output": "customer.live", + "conditions": [ + { "id": "c1", "expression": "customer.status == \"active\"", "operator": "and", "depth": 0 } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "enum field should type-check; got {errors:#?}" + ); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "status": "active" } })), + goals: Vec::new(), + trace: false, + }; + let ok = ws.evaluate(&req).expect("valid enum value should evaluate"); + let out: serde_json::Value = ok.output.into(); + assert_eq!( + out.get("customer") + .and_then(|c| c.get("live")) + .and_then(|v| v.as_bool()), + Some(true) + ); + + let bad_req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "status": "unknown" } })), + goals: Vec::new(), + trace: false, + }; + let err = ws + .evaluate(&bad_req) + .expect_err("unknown enum value should be rejected"); + let msg = format!("{err:?}"); + assert!( + msg.contains("status") && msg.contains("active"), + "error should reference field + allowed values; got {msg}" + ); +} + +#[test] +fn enum_with_duplicate_values_raises_diagnostic() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "status", "type": "string", "enum": ["active", "inactive", "active"], "array": false, "optional": false } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + let dup = errors + .iter() + .find(|d| format!("{:?}", d.code) == "DuplicateEnumValue") + .expect("expected DuplicateEnumValue diagnostic"); + assert!( + dup.message.contains("'active'"), + "diagnostic should name the duplicate value; got {dup:?}", + ); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "status": "active" } })), + goals: Vec::new(), + trace: false, + }; + ws.evaluate(&req) + .expect("dedup'd enum still validates the surviving values"); +} + +#[test] +fn enum_field_unknown_value_in_expression_is_type_mismatch() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "status", "type": "string", "enum": ["active", "inactive"], "array": false, "optional": false } + ] + }) }}, + { "id": "assert", "type": "assertion", "props": { "data": json!({ + "output": "customer.live", + "conditions": [ + { "id": "c1", "expression": "customer.status == \"unknown\"", "operator": "and", "depth": 0 } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + !errors.is_empty(), + "comparing an enum field to a literal not in the enum should produce a type-check error", + ); +} + +#[test] +fn globals_query_returns_schema_and_computed_entries() { + let doc = json!({ + "blocks": [ + { "id": "dm-globals", "type": "dataModel", "props": { "data": json!({ + "name": "platform", + "scope": "global", + "properties": [ + { "id": "g1", "name": "taxRate", "type": "number", "array": false, "optional": false }, + { "id": "g2", "name": "currency", "type": "string", "array": false, "optional": false } + ] + }) }}, + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "assert", "type": "assertion", "props": { "data": json!({ + "output": "isAdult", + "conditions": [ + { "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "expected no errors, got {errors:#?}"); + + let globals = ws.globals(&ScopeRequest::for_policy("p")); + let names: Vec<&str> = globals.iter().map(|g| g.name.as_ref()).collect(); + assert_eq!(names, vec!["currency", "isAdult", "taxRate"]); + + let tax = globals + .iter() + .find(|g| g.name.as_ref() == "taxRate") + .unwrap(); + assert!(matches!(tax.resolved_type, VariableType::Number)); + assert!(matches!( + tax.origin, + zen_engine::policy::FieldOrigin::Schema { .. } + )); + + let is_adult = globals + .iter() + .find(|g| g.name.as_ref() == "isAdult") + .unwrap(); + assert!(matches!(is_adult.resolved_type, VariableType::Bool)); + assert!(matches!( + is_adult.origin, + zen_engine::policy::FieldOrigin::Computed { .. } + )); + + let entities = ws.entities(&ScopeRequest::for_policy("p")); + assert!( + entities.iter().all(|e| e.name.as_ref() != "platform"), + "platform (global DM name) must not appear in entities()", + ); + assert!( + entities.iter().all(|e| e.name.as_ref() != "isAdult"), + "computed top-level path must not appear in entities()", + ); +} + +#[test] +fn global_data_model_name_can_be_empty() { + let doc = json!({ + "blocks": [ + { "id": "dm-globals", "type": "dataModel", "props": { "data": json!({ + "name": "", + "scope": "global", + "properties": [ + { "id": "g1", "name": "taxRate", "type": "number", "array": false, "optional": false } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "global DM with empty name should be valid; got {errors:#?}", + ); + + let inputs = ws.inputs(&ScopeRequest::for_policy("p")); + assert!( + inputs.iter().any(|p| p.path.as_ref() == "taxRate"), + "taxRate should still surface as a top-level input; got {inputs:?}", + ); +} + +#[test] +fn assertion_writes_to_undeclared_global_top_level_path() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "assert", "type": "assertion", "props": { "data": json!({ + "output": "isAdult", + "conditions": [ + { "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "expected no errors, got {errors:#?}"); + + let outputs = ws.outputs(&ScopeRequest::for_policy("p")); + let is_adult = outputs + .iter() + .find(|p| p.path.as_ref() == "isAdult") + .expect("computed top-level path should surface in outputs()"); + assert!(matches!(is_adult.resolved_type, VariableType::Bool)); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "age": 25 } })), + goals: Vec::new(), + trace: false, + }; + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let out: serde_json::Value = result.output.into(); + assert_eq!( + out.get("isAdult").and_then(|v| v.as_bool()), + Some(true), + "isAdult must land at the JSON root; got {out:?}", + ); +} + +#[test] +fn global_relationship_to_unknown_entity_raises_diagnostic() { + let doc = json!({ + "blocks": [ + { "id": "dm-globals", "type": "dataModel", "props": { "data": json!({ + "name": "platform", + "scope": "global", + "properties": [ + { "id": "g1", "name": "tenants", "type": "relationship", "target": "tenant", "array": true, "optional": false } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors + .iter() + .any(|d| format!("{:?}", d.code) == "UnknownDataModelTarget"), + "expected UnknownDataModelTarget from a global with no declared entity; got {errors:#?}", + ); +} + +#[test] +fn input_validation_rejects_wrong_type_for_global() { + let doc = json!({ + "blocks": [ + { "id": "dm-globals", "type": "dataModel", "props": { "data": json!({ + "name": "platform", + "scope": "global", + "properties": [ + { "id": "g1", "name": "taxRate", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "id", "type": "string", "array": false, "optional": false } + ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ + "taxRate": "not a number", + "customer": { "id": "c1" } + })), + goals: Vec::new(), + trace: false, + }; + let err = ws + .evaluate(&req) + .expect_err("validator should reject string for numeric global"); + assert!( + format!("{err:?}").contains("taxRate"), + "error should reference taxRate; got {err:?}", + ); +} + +#[test] +fn expression_diagnostics_distinguish_key_from_value() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "p", + serde_json::from_value(json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "s1", "type": "expression", "props": { "data": { "key": "customer.bad name", "value": "customer.age" } } }, + { "id": "s2", "type": "expression", "props": { "data": { "key": "customer.result", "value": "nonexistentVar + 1" } } } + ] + })) + .unwrap(), + ); + + let diags = ws.diagnostics("p"); + + let key_diag = diags + .iter() + .find(|d| format!("{:?}", d.code) == "InvalidWritePath") + .expect("expected an InvalidWritePath diagnostic on the statement key"); + assert_eq!(key_diag.location.expression_id.as_deref(), Some("s1")); + assert!( + matches!(&key_diag.location.target, Some(CursorTarget::ExpressionKey)), + "key diagnostic must target the expression key, got {:?}", + key_diag.location.target + ); + + let value_diag = diags + .iter() + .find(|d| { + d.location.expression_id.as_deref() == Some("s2") + && format!("{:?}", d.code) == "UndefinedVariable" + }) + .expect("expected an UndefinedVariable diagnostic on the statement value"); + assert!( + value_diag.location.target.is_none(), + "value diagnostic must carry no target (defaults to the value expression), got {:?}", + value_diag.location.target + ); +} + +#[test] +fn undefined_member_emits_single_diagnostic_not_duplicate() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "p", + serde_json::from_value(json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "tree", "type": "expression", "props": { "data": { "key": "customer.result", "value": "customer.agee + 1" } } } + ] + })) + .unwrap(), + ); + + let undefined: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| format!("{:?}", d.code) == "UndefinedVariable") + .collect(); + assert_eq!( + undefined.len(), + 1, + "a single undefined member must not be reported twice, got {undefined:#?}" + ); +} + +#[test] +fn bare_root_read_is_not_an_unknown_property() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "p", + serde_json::from_value(json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "e1", "type": "expression", "props": { "data": { "key": "customer.rootKeys", "value": "len(keys($root))" } } } + ] + })) + .unwrap(), + ); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "bare $root must not be flagged: {errors:#?}" + ); +} + +#[test] +fn undefined_bare_identifier_still_reported_by_read_check() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "p", + serde_json::from_value(json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "tree", "type": "expression", "props": { "data": { "key": "customer.result", "value": "nonexistentVar + 1" } } } + ] + })) + .unwrap(), + ); + + let undefined: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| format!("{:?}", d.code) == "UndefinedVariable") + .collect(); + assert_eq!( + undefined.len(), + 1, + "a bare undefined identifier the type-checker can't see must still be flagged, got {undefined:#?}" + ); + assert!(undefined[0].message.contains("nonexistentVar")); +} + +#[test] +fn input_override_on_expression_key_carries_key_target() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "p", + serde_json::from_value(json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "s1", "type": "expression", "props": { "data": { "key": "customer.age", "value": "5" } } } + ] + })) + .unwrap(), + ); + let d = ws + .diagnostics("p") + .into_iter() + .find(|d| format!("{:?}", d.code) == "InputOverride") + .expect("expected InputOverride"); + assert!( + matches!(&d.location.target, Some(CursorTarget::ExpressionKey)), + "InputOverride must target the expression key, got {:?}", + d.location.target + ); +} + +#[test] +fn incompatible_key_type_carries_key_target_and_span() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "p", + serde_json::from_value(json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "tree", "type": "match", "props": { "data": { "key": "customer.tag", "arms": [ + { "id": "b1", "condition": "customer.age > 18", "value": "\"adult\"" }, + { "id": "b2", "condition": "", "value": "1" } + ]}}} + ] + })) + .unwrap(), + ); + let d = ws + .diagnostics("p") + .into_iter() + .find(|d| format!("{:?}", d.code) == "TypeMismatch") + .expect("expected TypeMismatch on incompatible key types"); + assert!( + matches!(&d.location.target, Some(CursorTarget::MatchTarget)), + "type-mismatch must target the match key, got {:?}", + d.location.target + ); + assert_eq!( + d.location.span, + Some((0, "customer.tag".chars().count() as u32)), + "type-mismatch must span the key field" + ); +} + +#[test] +fn unrelated_policies_do_not_cross_flag_writes() { + let mk = || { + json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ { "id": "p1", "name": "income", "type": "number", "array": false, "optional": false } ] + })}}, + { "id": "a", "type": "assertion", "props": { "data": json!({ + "output": "customer.score", + "conditions": [ { "id": "c1", "expression": "customer.income > 0", "operator": "and", "depth": 0 } ] + })}} + ] + }) + }; + let mut ws = PolicyWorkspace::new(); + ws.set_policy("alpha", serde_json::from_value(mk()).unwrap()); + ws.set_policy("beta", serde_json::from_value(mk()).unwrap()); + + for path in ["alpha", "beta"] { + let errors: Vec<_> = ws + .diagnostics(path) + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "[{path}] unrelated policy writing the same path must not be flagged; got {errors:#?}", + ); + } +} + +#[test] +fn disjoint_nested_writes_across_blocks_merge_at_runtime() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "globals", "scope": "global", + "properties": [ { "id": "g1", "name": "principal", "type": "number", "array": false, "optional": false } ] + })}}, + { "id": "current", "type": "expression", "props": { "data": json!({ + "key": "portfolio.byBucket.CURRENT.balance", "value": "principal * 0.7" + })}}, + { "id": "late", "type": "expression", "props": { "data": json!({ + "key": "portfolio.byBucket.LATE.balance", "value": "principal * 0.3" + })}} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "disjoint assembly must be clean; got {errors:#?}" + ); + + let result = ws + .evaluate(&EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "principal": 1000 })), + goals: Vec::new(), + trace: false, + }) + .expect("evaluate should succeed"); + let out: serde_json::Value = result.output.into(); + let bucket = out + .get("portfolio") + .and_then(|p| p.get("byBucket")) + .and_then(|b| b.as_object()) + .expect("portfolio.byBucket assembled"); + assert_eq!( + bucket + .get("CURRENT") + .and_then(|c| c.get("balance")) + .and_then(|v| v.as_f64()), + Some(700.0), + "both blocks' disjoint nested writes must survive the merge; got {out}", + ); + assert_eq!( + bucket + .get("LATE") + .and_then(|c| c.get("balance")) + .and_then(|v| v.as_f64()), + Some(300.0), + "both blocks' disjoint nested writes must survive the merge; got {out}", + ); +} + +#[test] +fn reading_assembled_object_is_self_reference_not_cycle() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "globals", "scope": "global", + "properties": [ { "id": "g1", "name": "principal", "type": "number", "array": false, "optional": false } ] + })}}, + { "id": "s1", "type": "expression", "props": { "data": json!({ "key": "totals.base", "value": "principal" })}}, + { "id": "s2", "type": "expression", "props": { "data": json!({ "key": "totals.count", "value": "len(keys(totals))" })}} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let codes: Vec = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .map(|d| format!("{:?}", d.code)) + .collect(); + assert!( + codes.iter().any(|c| c == "SelfReferencingWrite"), + "reading the object being assembled should surface as SelfReferencingWrite; got {codes:?}", + ); + assert!( + !codes.iter().any(|c| c == "CyclicDependency"), + "it must not surface as a cryptic CyclicDependency; got {codes:?}", + ); +} + +#[test] +fn unrelated_policies_do_not_leak_computed_global_types() { + let writer = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "scope": "global", "name": "g", + "properties": [ { "id": "p1", "name": "score", "type": "number", "array": false, "optional": false } ] + }) }}, + { "id": "t", "type": "decisionTable", "props": { "data": json!({ + "hitPolicy": "first", + "inputs": [ { "id": "i1", "name": "", "field": "score" } ], + "outputs": [ { "id": "o1", "name": "", "field": "tier" } ], + "rules": [ + { "_id": "r1", "i1": ">= 100", "o1": "\"gold\"" }, + { "_id": "r2", "i1": ">= 50", "o1": "\"silver\"" }, + { "_id": "r3", "i1": "", "o1": "\"bronze\"" } + ] + }) }} + ] + }); + let reader = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "scope": "global", "name": "g", + "properties": [ { "id": "p1", "name": "tier", "type": "string", "array": false, "optional": false } ] + }) }}, + { "id": "t", "type": "decisionTable", "props": { "data": json!({ + "hitPolicy": "first", + "inputs": [ { "id": "i1", "name": "", "field": "tier" } ], + "outputs": [ { "id": "o1", "name": "", "field": "discount" } ], + "rules": [ + { "_id": "r1", "i1": "\"platinum\"", "o1": "0.2" }, + { "_id": "r2", "i1": "\"gold\"", "o1": "0.1" } + ] + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("tier-writer", serde_json::from_value(writer).unwrap()); + ws.set_policy("tier-reader", serde_json::from_value(reader).unwrap()); + + let diags = ws.diagnostics("tier-reader"); + assert!( + diags.is_empty(), + "tier-reader declares its own global tier:string and never imports tier-writer; \ + tier-writer's computed gold|silver|bronze enum must not narrow it. Got: {:?}", + diags + .iter() + .map(|d| (&d.code, &d.message)) + .collect::>(), + ); +} + +#[test] +fn assertion_nested_or_of_and_group() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "pa", "name": "a", "type": "boolean", "array": false, "optional": false }, + { "id": "pb", "name": "b", "type": "boolean", "array": false, "optional": false }, + { "id": "pc", "name": "c", "type": "boolean", "array": false, "optional": false } + ] + }) }}, + { "id": "assert", "type": "assertion", "props": { "data": json!({ + "output": "customer.result", + "conditions": [ + { "id": "ca", "expression": "customer.a", "operator": "or", "depth": 0 }, + { "id": "cb", "expression": "customer.b", "operator": "and", "depth": 1 }, + { "id": "cc", "expression": "customer.c", "operator": "and", "depth": 1 } + ] + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let eval = |a: bool, b: bool, c: bool| -> bool { + let result = ws + .evaluate(&EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "a": a, "b": b, "c": c } })), + goals: Vec::new(), + trace: false, + }) + .expect("evaluate succeeded"); + result + .output + .dot("customer.result") + .and_then(|v| v.as_bool()) + .expect("customer.result is a bool") + }; + + assert!(eval(true, false, false), "true OR (false AND false) = true"); + assert!(eval(false, true, true), "false OR (true AND true) = true"); + assert!( + !eval(false, true, false), + "false OR (true AND false) = false" + ); + assert!( + !eval(false, false, false), + "false OR (false AND false) = false" + ); + assert!(eval(true, true, true), "true OR (true AND true) = true"); +} + +fn expr(id: &str, key: &str, value: &str) -> serde_json::Value { + json!({ "id": id, "type": "expression", "props": { "data": { "key": key, "value": value } } }) +} + +fn match_block(id: &str, key: &str, arms: serde_json::Value) -> serde_json::Value { + json!({ "id": id, "type": "match", "props": { "data": { "key": key, "arms": arms } } }) +} + +fn arm(id: &str, condition: &str, value: &str) -> serde_json::Value { + json!({ "id": id, "condition": condition, "value": value }) +} + +fn customer_dm() -> serde_json::Value { + json!({ + "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "a", "name": "age", "type": "number" }, + { "id": "i", "name": "income", "type": "number" } + ] + }} + }) +} + +fn errors(ws: &PolicyWorkspace, path: &str) -> Vec { + ws.diagnostics(path) + .into_iter() + .filter(|d| d.severity == Severity::Error) + .map(|d| format!("{:?}: {}", d.code, d.message)) + .collect() +} + +fn set(ws: &mut PolicyWorkspace, path: &str, blocks: serde_json::Value) { + ws.set_policy( + path, + serde_json::from_value(json!({ "blocks": blocks })).unwrap(), + ); +} + +fn eval(ws: &PolicyWorkspace, path: &str, input: serde_json::Value) -> serde_json::Value { + let req = EvaluateRequest { + policy_path: Arc::from(path), + input: Variable::from(input), + goals: Vec::new(), + trace: false, + }; + ws.evaluate(&req).expect("evaluate ok").output.into() +} + +#[test] +fn expression_block_writes_and_types() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + customer_dm(), + expr("e1", "customer.bonus", "customer.income * 0.1") + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + + let outputs = ws.outputs(&ScopeRequest::for_policy("p")); + let bonus = outputs + .iter() + .find(|o| o.path.as_ref() == "customer.bonus") + .expect("bonus output"); + assert_eq!(format!("{}", bonus.resolved_type), "number"); + + let out = eval( + &ws, + "p", + json!({ "customer": { "age": 40, "income": 1000 } }), + ); + assert_eq!(out["customer"]["bonus"].as_f64(), Some(100.0)); +} + +#[test] +fn separate_expression_blocks_order_independently_no_false_cycle() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + customer_dm(), + expr("b", "customer.b", "customer.a + 1"), + expr("c", "customer.c", "customer.income + 5"), + expr("a", "customer.a", "customer.c + 1"), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + + let out = eval(&ws, "p", json!({ "customer": { "age": 1, "income": 10 } })); + assert_eq!(out["customer"]["c"].as_f64(), Some(15.0)); + assert_eq!(out["customer"]["a"].as_f64(), Some(16.0)); + assert_eq!(out["customer"]["b"].as_f64(), Some(17.0)); +} + +#[test] +fn genuine_cycle_is_reported() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + customer_dm(), + expr("a", "customer.a", "customer.b + 1"), + expr("b", "customer.b", "customer.a + 1"), + ]), + ); + let errs = errors(&ws, "p"); + assert!( + errs.iter().any(|e| e.contains("Cyclic")), + "expected cycle, got {errs:?}" + ); +} + +#[test] +fn duplicate_writer_across_expression_blocks() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + customer_dm(), + expr("e1", "customer.tier", "\"a\""), + expr("e2", "customer.tier", "\"b\""), + ]), + ); + let errs = errors(&ws, "p"); + assert!( + errs.iter().any(|e| e.contains("DuplicateWriter")), + "{errs:?}" + ); +} + +#[test] +fn match_with_default_is_total_and_merges_types() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + customer_dm(), + match_block( + "m", + "customer.tier", + json!([ + arm("a1", "customer.age >= 65", "\"senior\""), + arm("a2", "customer.age >= 18", "\"adult\""), + arm("a3", "", "\"minor\""), + ]) + ), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + + let outputs = ws.outputs(&ScopeRequest::for_policy("p")); + let tier = outputs + .iter() + .find(|o| o.path.as_ref() == "customer.tier") + .expect("tier output"); + let ty = format!("{}", tier.resolved_type); + assert!( + ty.contains("senior") && ty.contains("adult") && ty.contains("minor"), + "{ty}" + ); + assert!(!ty.contains('?'), "total match must not be nullable: {ty}"); + + assert_eq!( + eval(&ws, "p", json!({ "customer": { "age": 70 } }))["customer"]["tier"], + json!("senior") + ); + assert_eq!( + eval(&ws, "p", json!({ "customer": { "age": 30 } }))["customer"]["tier"], + json!("adult") + ); + assert_eq!( + eval(&ws, "p", json!({ "customer": { "age": 5 } }))["customer"]["tier"], + json!("minor") + ); +} + +#[test] +fn match_without_default_errors_and_returns_null_at_runtime() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + customer_dm(), + match_block( + "m", + "customer.tier", + json!([arm("a1", "customer.age >= 65", "\"senior\""),]) + ), + ]), + ); + let errs = errors(&ws, "p"); + assert!( + errs.iter().any(|e| e.contains("MissingDefaultBranch")), + "non-exhaustive match must require a default arm: {errs:?}" + ); + + let out = eval(&ws, "p", json!({ "customer": { "age": 5 } })); + assert_eq!(out["customer"]["tier"], json!(null)); +} + +#[test] +fn match_non_boolean_condition_errors() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + customer_dm(), + match_block( + "m", + "customer.tier", + json!([arm("a1", "customer.age", "\"x\""), arm("a2", "", "\"y\""),]) + ), + ]), + ); + let errs = errors(&ws, "p"); + assert!( + errs.iter() + .any(|e| e.contains("TypeMismatch") && e.contains("boolean")), + "{errs:?}" + ); +} + +#[test] +fn match_feeds_downstream_expression() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + customer_dm(), + expr("e", "customer.discounted", "customer.rate < 0.1"), + match_block( + "m", + "customer.rate", + json!([ + arm("a1", "customer.age >= 65", "0.05"), + arm("a2", "", "0.2"), + ]) + ), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + let out = eval(&ws, "p", json!({ "customer": { "age": 70 } })); + assert_eq!(out["customer"]["rate"].as_f64(), Some(0.05)); + assert_eq!(out["customer"]["discounted"], json!(true)); +} + +#[test] +fn cross_block_deep_read_of_whole_object_write() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + customer_dm(), + expr( + "e1", + "customer.profile", + "{ age: customer.age, vip: customer.income > 1000 }" + ), + expr("e2", "customer.adult", "customer.profile.age >= 18"), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + let out = eval( + &ws, + "p", + json!({ "customer": { "age": 25, "income": 500 } }), + ); + assert_eq!(out["customer"]["adult"], json!(true)); +} + +#[test] +fn cross_block_condition_sharing_via_intermediate() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + customer_dm(), + expr( + "d", + "customer.isPremium", + "customer.age >= 65 and customer.income > 1000" + ), + match_block( + "m1", + "customer.tier", + json!([ + arm("a1", "customer.isPremium", "\"gold\""), + arm("a2", "", "\"std\"") + ]) + ), + match_block( + "m2", + "customer.discount", + json!([arm("b1", "customer.isPremium", "0.2"), arm("b2", "", "0.0")]) + ), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + let out = eval( + &ws, + "p", + json!({ "customer": { "age": 70, "income": 5000 } }), + ); + assert_eq!(out["customer"]["tier"], json!("gold")); + assert_eq!(out["customer"]["discount"].as_f64(), Some(0.2)); +} + +fn disc_dm() -> serde_json::Value { + json!({ + "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "g", "name": "segment", "type": "string", "enum": ["retail", "corporate", "other"] }, + { "id": "ac", "name": "active", "type": "boolean" }, + { "id": "a", "name": "age", "type": "number" }, + { "id": "r", "name": "region", "type": "string" } + ] + }} + }) +} + +fn tier_type(ws: &PolicyWorkspace) -> String { + let outputs = ws.outputs(&ScopeRequest::for_policy("p")); + let tier = outputs + .iter() + .find(|o| o.path.as_ref() == "customer.tier") + .expect("tier output"); + format!("{}", tier.resolved_type) +} + +#[test] +fn match_enum_full_cover_is_total_without_default() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + disc_dm(), + match_block( + "m", + "customer.tier", + json!([ + arm("a1", "customer.segment == \"retail\"", "\"gold\""), + arm("a2", "customer.segment == \"corporate\"", "\"silver\""), + arm("a3", "customer.segment == \"other\"", "\"bronze\""), + ]) + ), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + let ty = tier_type(&ws); + assert!(!ty.contains('?'), "full enum cover must be total: {ty}"); + assert_eq!( + eval(&ws, "p", json!({ "customer": { "segment": "corporate" } }))["customer"]["tier"], + json!("silver") + ); +} + +#[test] +fn match_enum_partial_cover_errors_missing_default() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + disc_dm(), + match_block( + "m", + "customer.tier", + json!([ + arm("a1", "customer.segment == \"retail\"", "\"gold\""), + arm("a2", "customer.segment == \"corporate\"", "\"silver\""), + ]) + ), + ]), + ); + let errs = errors(&ws, "p"); + assert!( + errs.iter().any(|e| e.contains("MissingDefaultBranch")), + "partial enum cover must require a default arm: {errs:?}" + ); +} + +#[test] +fn match_in_set_covers_enum() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + disc_dm(), + match_block( + "m", + "customer.tier", + json!([arm( + "a1", + "customer.segment in [\"retail\", \"corporate\", \"other\"]", + "\"any\"" + ),]) + ), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + assert!( + !tier_type(&ws).contains('?'), + "in-set covering the enum must be total" + ); +} + +#[test] +fn match_bool_both_arms_is_total() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + disc_dm(), + match_block( + "m", + "customer.tier", + json!([ + arm("a1", "customer.active == true", "\"on\""), + arm("a2", "customer.active == false", "\"off\""), + ]) + ), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + assert!( + !tier_type(&ws).contains('?'), + "bool true+false must be total" + ); +} + +#[test] +fn match_bool_one_arm_errors_missing_default() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + disc_dm(), + match_block( + "m", + "customer.tier", + json!([arm("a1", "customer.active == true", "\"on\""),]) + ), + ]), + ); + let errs = errors(&ws, "p"); + assert!( + errs.iter().any(|e| e.contains("MissingDefaultBranch")), + "single bool arm must require a default arm: {errs:?}" + ); +} + +#[test] +fn match_number_tiling_no_gap_is_total() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + disc_dm(), + match_block( + "m", + "customer.tier", + json!([ + arm("a1", "customer.age < 18", "\"minor\""), + arm("a2", "customer.age >= 18", "\"adult\""), + ]) + ), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + assert!( + !tier_type(&ws).contains('?'), + "gap-free number tiling must be total" + ); +} + +#[test] +fn match_number_tiling_with_gap_errors_missing_default() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + disc_dm(), + match_block( + "m", + "customer.tier", + json!([ + arm("a1", "customer.age < 18", "\"minor\""), + arm("a2", "customer.age > 18", "\"adult\""), + ]) + ), + ]), + ); + let errs = errors(&ws, "p"); + assert!( + errs.iter().any(|e| e.contains("MissingDefaultBranch")), + "number tiling with a gap must require a default arm: {errs:?}" + ); +} + +#[test] +fn match_guard_does_not_count_errors_missing_default() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + disc_dm(), + match_block( + "m", + "customer.tier", + json!([ + arm( + "a1", + "customer.segment == \"retail\" and customer.region == \"EU\"", + "\"gold\"" + ), + arm("a2", "customer.segment == \"corporate\"", "\"silver\""), + arm("a3", "customer.segment == \"other\"", "\"bronze\""), + ]) + ), + ]), + ); + let errs = errors(&ws, "p"); + assert!( + errs.iter().any(|e| e.contains("MissingDefaultBranch")), + "a guarded arm is not a pure discriminant test, so a default arm is required: {errs:?}" + ); +} + +#[test] +fn match_different_property_errors_missing_default() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + disc_dm(), + match_block( + "m", + "customer.tier", + json!([ + arm("a1", "customer.segment == \"retail\"", "\"gold\""), + arm("a2", "customer.active == false", "\"silver\""), + ]) + ), + ]), + ); + let errs = errors(&ws, "p"); + assert!( + errs.iter().any(|e| e.contains("MissingDefaultBranch")), + "arms on different properties have no single discriminant, so a default arm is required: {errs:?}" + ); +} + +#[test] +fn lazy_prunes_dead_arm_intermediate() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + disc_dm(), + expr("retail", "customer.retailValue", "customer.age * 2"), + expr("corporate", "customer.corporateValue", "customer.age * 3"), + match_block( + "m", + "customer.tier", + json!([ + arm( + "a1", + "customer.segment == \"retail\"", + "customer.retailValue" + ), + arm( + "a2", + "customer.segment == \"corporate\"", + "customer.corporateValue" + ), + arm("a3", "", "0"), + ]) + ), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + + let out = eval( + &ws, + "p", + json!({ "customer": { "segment": "corporate", "age": 10 } }), + ); + assert_eq!(out["customer"]["tier"].as_f64(), Some(30.0)); + assert_eq!(out["customer"]["corporateValue"].as_f64(), Some(30.0)); + assert!( + out["customer"].get("retailValue").is_none(), + "dead-arm intermediate must be pruned; got {out}" + ); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "segment": "corporate", "age": 10 } })), + goals: vec![Arc::from("customer.retailValue")], + trace: false, + }; + let goal_out: serde_json::Value = ws.evaluate(&req).expect("goal eval ok").output.into(); + assert_eq!( + goal_out["customer"]["retailValue"].as_f64(), + Some(20.0), + "a property promoted to a goal is forced even when only a dead arm reads it" + ); +} + +#[test] +fn lazy_equals_eager_for_branch_free_policy() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + disc_dm(), + expr("a", "customer.doubled", "customer.age * 2"), + expr("b", "customer.tier", "customer.doubled + 1"), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + let out = eval( + &ws, + "p", + json!({ "customer": { "segment": "retail", "age": 5 } }), + ); + assert_eq!(out["customer"]["doubled"].as_f64(), Some(10.0)); + assert_eq!(out["customer"]["tier"].as_f64(), Some(11.0)); +} + +#[test] +fn lazy_iterated_match_picks_per_instance_arm() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-company", "type": "dataModel", "props": { "data": json!({ + "name": "company", + "properties": [ + { "id": "p2", "name": "region", "type": "string", "enum": ["us", "eu"], "array": false, "optional": false }, + { "id": "p3", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "us", "type": "expression", "props": { "data": json!({ "key": "company.usBonus", "value": "company.revenue * 2" }) }}, + { "id": "eu", "type": "expression", "props": { "data": json!({ "key": "company.euBonus", "value": "company.revenue * 3" }) }}, + { "id": "m", "type": "match", "props": { "data": json!({ + "key": "company.bonus", + "arms": [ + { "id": "a1", "condition": "company.region == \"us\"", "value": "company.usBonus" }, + { "id": "a2", "condition": "company.region == \"eu\"", "value": "company.euBonus" }, + { "id": "a3", "condition": "", "value": "0" } + ] + }) }} + ] + }); + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + let errs: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errs.is_empty(), "expected no errors, got {errs:#?}"); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ + "customer": { "companies": [ + { "region": "us", "revenue": 100 }, + { "region": "eu", "revenue": 100 } + ] } + })), + goals: Vec::new(), + trace: false, + }; + let serialized: serde_json::Value = ws.evaluate(&req).expect("evaluate ok").output.into(); + let companies = serialized + .pointer("/customer/companies") + .and_then(|v| v.as_array()) + .expect("companies present"); + assert_eq!( + companies[0]["bonus"].as_f64(), + Some(200.0), + "us company resolves the us arm" + ); + assert_eq!( + companies[1]["bonus"].as_f64(), + Some(300.0), + "eu company resolves the eu arm" + ); +} + +fn group_paths(group: &serde_json::Value, key: &str) -> Vec { + group[key] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .filter_map(|p| p["path"].as_str().map(String::from)) + .collect() +} + +#[test] +fn conditional_schema_clean_enum_discriminant_union() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + json!({ "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "g", "name": "segment", "type": "string", "enum": ["retail", "corporate"] }, + { "id": "sd", "name": "retailData", "type": "number" }, + { "id": "ad", "name": "corporateData", "type": "number" } + ] + }}}), + expr("scalc", "customer.retailCalc", "customer.retailData * 2"), + expr( + "acalc", + "customer.corporateCalc", + "customer.corporateData * 2" + ), + match_block( + "m", + "customer.tier", + json!([ + arm( + "a1", + "customer.segment == \"retail\"", + "customer.retailCalc" + ), + arm( + "a2", + "customer.segment == \"corporate\"", + "customer.corporateCalc" + ), + ]) + ), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + + let schema = + serde_json::to_value(ws.conditional_schema(&ScopeRequest::for_policy("p"))).unwrap(); + assert_eq!(schema["kind"], "union"); + assert_eq!(schema["union"]["property"], "customer.segment"); + + let common_inputs = group_paths(&schema["common"], "inputs"); + let common_outputs = group_paths(&schema["common"], "outputs"); + assert!( + common_inputs.contains(&"customer.segment".to_string()), + "{common_inputs:?}" + ); + assert!( + common_outputs.contains(&"customer.tier".to_string()), + "{common_outputs:?}" + ); + + let variants = schema["union"]["variants"].as_array().unwrap(); + let retail = variants + .iter() + .find(|v| v["value"] == "retail") + .expect("retail variant"); + let sag_inputs = group_paths(&retail["group"], "inputs"); + let sag_outputs = group_paths(&retail["group"], "outputs"); + assert!( + sag_inputs.contains(&"customer.retailData".to_string()), + "{sag_inputs:?}" + ); + assert!( + !sag_inputs.contains(&"customer.corporateData".to_string()), + "retail variant must not carry the corporate input: {sag_inputs:?}" + ); + assert!( + sag_outputs.contains(&"customer.retailCalc".to_string()), + "{sag_outputs:?}" + ); + + let corporate = variants + .iter() + .find(|v| v["value"] == "corporate") + .expect("corporate variant"); + assert!( + group_paths(&corporate["group"], "inputs").contains(&"customer.corporateData".to_string()) + ); + + let all_inputs: std::collections::HashSet = ws + .inputs(&ScopeRequest::for_policy("p")) + .iter() + .map(|i| i.path.to_string()) + .collect(); + let mut partitioned: std::collections::HashSet = common_inputs.into_iter().collect(); + for v in variants { + partitioned.extend(group_paths(&v["group"], "inputs")); + } + assert_eq!( + partitioned, all_inputs, + "common + variants must cover the flat input superset" + ); +} + +#[test] +fn conditional_schema_compound_condition_is_flat_with_guard() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + json!({ "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "g", "name": "segment", "type": "string", "enum": ["retail", "corporate"] }, + { "id": "r", "name": "region", "type": "string" }, + { "id": "sd", "name": "retailData", "type": "number" } + ] + }}}), + expr("scalc", "customer.retailCalc", "customer.retailData * 2"), + match_block( + "m", + "customer.tier", + json!([ + arm( + "a1", + "customer.segment == \"retail\" and customer.region == \"EU\"", + "customer.retailCalc" + ), + arm("a2", "", "0"), + ]) + ), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + let schema = + serde_json::to_value(ws.conditional_schema(&ScopeRequest::for_policy("p"))).unwrap(); + assert_eq!( + schema["kind"], "flat", + "compound guard has no single clean discriminant" + ); + + let cond_inputs = schema["conditional"]["inputs"].as_array().unwrap(); + let retail = cond_inputs + .iter() + .find(|p| p["path"] == "customer.retailData") + .expect("retailData is conditional"); + assert!( + retail["requiredWhen"] + .as_str() + .unwrap_or_default() + .contains("region"), + "guard should carry the compound condition: {retail}" + ); +} + +#[test] +fn conditional_schema_branch_free_is_flat_with_empty_conditional() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + customer_dm(), + expr("a", "customer.doubled", "customer.age * 2"), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + let schema = + serde_json::to_value(ws.conditional_schema(&ScopeRequest::for_policy("p"))).unwrap(); + assert_eq!(schema["kind"], "flat"); + assert!(schema["conditional"]["inputs"] + .as_array() + .unwrap() + .is_empty()); + assert!(schema["conditional"]["outputs"] + .as_array() + .unwrap() + .is_empty()); +} + +#[test] +fn global_aggregation_of_fanout_field() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-company", "type": "dataModel", "props": { "data": json!({ + "name": "company", + "properties": [ + { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "fee", "type": "expression", "props": { "data": json!({ "key": "company.fee", "value": "company.revenue * 2" }) }}, + { "id": "total", "type": "expression", "props": { "data": json!({ "key": "totalFee", "value": "sum(map(customer.companies as c, c.fee))" }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + let errs: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errs.is_empty(), "expected no errors, got {errs:#?}"); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from( + json!({ "customer": { "companies": [ { "revenue": 10 }, { "revenue": 20 } ] } }), + ), + goals: Vec::new(), + trace: false, + }; + let out: serde_json::Value = ws.evaluate(&req).expect("evaluate ok").output.into(); + assert_eq!( + out["totalFee"].as_f64(), + Some(60.0), + "global aggregation must demand the per-entity fan-out write; got {out}" + ); +} + +#[test] +fn trace_on_error_carries_partial_trace() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "p", + json!([ + json!({ "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "a", "name": "age", "type": "number" }, + { "id": "n", "name": "name", "type": "string" } + ] + }}}), + expr("ok", "okValue", "42"), + expr( + "bad", + "customer.bad", + "okValue > 0 ? sum([customer.age, customer.name]) : 0" + ), + ]), + ); + assert!(errors(&ws, "p").is_empty(), "{:?}", errors(&ws, "p")); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "age": 10, "name": "Alice" } })), + goals: Vec::new(), + trace: true, + }; + match ws + .evaluate(&req) + .expect_err("summing a non-numeric array must fail at runtime") + { + EvaluationError::ExpressionFailed { + block_id, + partial_trace, + .. + } => { + assert_eq!(block_id.as_ref(), "bad", "error names the failing block"); + let trace = partial_trace.expect("partial trace attached on error"); + assert!( + trace.executions.iter().any(|e| e.block_id.as_ref() == "ok"), + "partial trace must include blocks that ran before the failure; got {:?}", + trace + .executions + .iter() + .map(|e| e.block_id.clone()) + .collect::>() + ); + } + other => panic!("expected ExpressionFailed, got {other:?}"), + } +} + +#[test] +fn goal_validation_resolves_fanout_entity_input() { + let doc = json!({ + "blocks": [ + { "id": "dm-app", "type": "dataModel", "props": { "data": json!({ + "name": "application", + "properties": [ + { "id": "d", "name": "drivers", "type": "relationship", "target": "driver", "array": true, "optional": false } + ] + }) }}, + { "id": "dm-driver", "type": "dataModel", "props": { "data": json!({ + "name": "driver", + "properties": [ + { "id": "dob", "name": "dob", "type": "number", "array": false, "optional": false } + ] + }) }}, + { "id": "age", "type": "expression", "props": { "data": json!({ "key": "driver.age", "value": "2025 - driver.dob" }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + let errs: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errs.is_empty(), "expected no errors, got {errs:#?}"); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "application": { "drivers": [ { "dob": 1990 } ] } })), + goals: vec![Arc::from("driver.age")], + trace: false, + }; + let result = ws.evaluate(&req); + assert!( + result.is_ok(), + "fan-out entity field input must satisfy the goal's required input; got {result:?}" + ); + let out: serde_json::Value = result.unwrap().output.into(); + assert_eq!( + out.pointer("/application/drivers/0/age") + .and_then(|v| v.as_f64()), + Some(35.0) + ); +} + +#[test] +fn cross_component_duplicate_writer_detected() { + let mut ws = PolicyWorkspace::new(); + set( + &mut ws, + "a", + json!([customer_dm(), expr("e", "customer.tier", "\"gold\"")]), + ); + set( + &mut ws, + "b", + json!([customer_dm(), expr("e", "customer.tier", "\"silver\"")]), + ); + + let conflicts = ws.cross_component_write_conflicts(); + let tier = conflicts + .iter() + .find(|c| c.path.as_ref() == "customer.tier") + .expect("cross-component conflict on customer.tier"); + assert!( + tier.policies.iter().any(|p| p.as_ref() == "a"), + "{:?}", + tier.policies + ); + assert!( + tier.policies.iter().any(|p| p.as_ref() == "b"), + "{:?}", + tier.policies + ); +} + +#[test] +fn cross_component_no_conflict_when_imported() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "base", + serde_json::from_value( + json!({ "blocks": [ customer_dm(), expr("e", "customer.x", "1") ] }), + ) + .unwrap(), + ); + ws.set_policy("mid", serde_json::from_value(json!({ "imports": ["base"], "blocks": [ expr("e2", "customer.y", "customer.x + 1") ] })).unwrap()); + assert!( + ws.cross_component_write_conflicts().is_empty(), + "imported policies share a component, no conflict" + ); +} + +#[test] +fn component_members_lists_merge_set() { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + "base", + serde_json::from_value( + json!({ "blocks": [ customer_dm(), expr("e", "customer.x", "1") ] }), + ) + .unwrap(), + ); + ws.set_policy("mid", serde_json::from_value(json!({ "imports": ["base"], "blocks": [ expr("e2", "customer.y", "customer.x + 1") ] })).unwrap()); + ws.set_policy( + "solo", + serde_json::from_value(json!({ "blocks": [ customer_dm() ] })).unwrap(), + ); + + let members: Vec = ws + .component_members("mid") + .iter() + .map(|m| m.to_string()) + .collect(); + assert_eq!( + members, + vec!["base".to_string(), "mid".to_string()], + "mid merges with base" + ); + assert_eq!( + ws.component_members("solo") + .iter() + .map(|m| m.to_string()) + .collect::>(), + vec!["solo".to_string()], + "unrelated policy is its own component" + ); +} + +#[test] +fn reference_pool_field_read_terminates_and_evaluates() { + let doc = json!({ + "blocks": [ + { "id": "dm-order", "type": "dataModel", "props": { "data": json!({ + "name": "order", + "properties": [ + { "id": "p1", "name": "productId", "type": "reference", "target": "product" }, + { "id": "p2", "name": "qty", "type": "number" } + ] + }) }}, + { "id": "dm-product", "type": "dataModel", "props": { "data": json!({ + "name": "product", + "properties": [ + { "id": "p3", "name": "id", "type": "string" }, + { "id": "p4", "name": "price", "type": "number" } + ] + }) }}, + { "id": "e-total", "type": "expression", "props": { "data": json!({ + "key": "order.total", + "value": "(product[0].price ?? 0) * order.qty" + }) }}, + { "id": "e-premium", "type": "expression", "props": { "data": json!({ + "key": "product.premium", + "value": "product.price > 100" + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let _ = ws.all_diagnostics(); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ + "order": { "productId": "p1", "qty": 2 }, + "product": [ { "id": "p1", "price": 10 }, { "id": "p2", "price": 200 } ] + })), + goals: Vec::new(), + trace: false, + }; + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + assert_eq!(output.pointer("/order/total"), Some(&json!(20))); + assert_eq!(output.pointer("/product/0/premium"), Some(&json!(false))); + assert_eq!(output.pointer("/product/1/premium"), Some(&json!(true))); +} + +#[test] +fn nested_relationship_chain_writes_are_flagged() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true } + ] + }) }}, + { "id": "dm-company", "type": "dataModel", "props": { "data": json!({ + "name": "company", + "properties": [ + { "id": "p2", "name": "branches", "type": "relationship", "target": "branch", "array": true } + ] + }) }}, + { "id": "dm-branch", "type": "dataModel", "props": { "data": json!({ + "name": "branch", + "properties": [ + { "id": "p3", "name": "headcount", "type": "number" } + ] + }) }}, + { "id": "e-score", "type": "expression", "props": { "data": json!({ + "key": "branch.score", + "value": "branch.headcount * 2" + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let nested: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| { + d.code == zen_engine::policy::DiagnosticCode::UnsupportedNestedIteration + && d.severity == Severity::Error + }) + .collect(); + assert_eq!( + nested.len(), + 1, + "writes behind a two-level relationship chain must be rejected, got {nested:#?}", + ); +} + +#[test] +fn empty_match_arm_value_types_as_nullable() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "flag", "type": "boolean" } + ] + }) }}, + { "id": "m", "type": "match", "props": { "data": json!({ + "key": "customer.tier", + "arms": [ + { "id": "a1", "condition": "customer.flag", "value": "1" }, + { "id": "a2", "condition": "", "value": "" } + ] + }) }}, + { "id": "e-next", "type": "expression", "props": { "data": json!({ + "key": "customer.next", + "value": "customer.tier + 1" + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + !errors.is_empty(), + "customer.tier can be null at runtime, so `customer.tier + 1` must be a type error", + ); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "flag": false } })), + goals: vec![Arc::from("customer.tier")], + trace: false, + }; + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + assert_eq!(output.pointer("/customer/tier"), Some(&json!(null))); +} + +#[test] +fn invalid_write_paths_are_not_registered_as_writers() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number" } + ] + }) }}, + { "id": "e-bad", "type": "expression", "props": { "data": json!({ + "key": "total amount", + "value": "customer.age * 2" + }) }}, + { "id": "m-bad", "type": "match", "props": { "data": json!({ + "key": "a..b", + "arms": [ { "id": "a1", "condition": "", "value": "1" } ] + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let invalid_paths = ws + .diagnostics("p") + .into_iter() + .filter(|d| { + d.code == zen_engine::policy::DiagnosticCode::InvalidWritePath + && d.severity == Severity::Error + }) + .count(); + assert_eq!(invalid_paths, 2); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "age": 30 } })), + goals: Vec::new(), + trace: false, + }; + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + assert!( + output.get("total amount").is_none(), + "malformed expression key must not be written: {output:#?}", + ); + assert!( + output.get("a").is_none(), + "malformed match key must not be written: {output:#?}", + ); +} + +#[test] +fn rename_field_rewrites_nested_write_keys() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "base", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-total", "type": "expression", "props": { "data": { + "key": "customer.scores.total", + "value": "customer.base * 2" + } } }, + { "id": "e-flat", "type": "expression", "props": { "data": { + "key": "customer.scoresTotal", + "value": "customer.base * 3" + } } }, + { "id": "e-read", "type": "expression", "props": { "data": { + "key": "customer.final", + "value": "customer.scores.total + 1" + } } } + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let edits = ws.rename( + &zen_engine::policy::RenameTarget::Field { + entity: Arc::from("customer"), + field: Arc::from("scores"), + }, + "metrics", + ); + let total_json = rewritten_block_json(&edits, "e-total") + .unwrap_or_else(|| panic!("rename must rewrite the nested write key; got {edits:#?}")); + assert!( + total_json.contains("customer.metrics.total"), + "write key `customer.scores.total` must become `customer.metrics.total`; got {total_json}", + ); + let read_json = rewritten_block_json(&edits, "e-read") + .unwrap_or_else(|| panic!("rename must rewrite the reader; got {edits:#?}")); + assert!( + read_json.contains("customer.metrics.total"), + "read of `customer.scores.total` must follow the write key; got {read_json}", + ); + assert!( + !any_touches_block(&edits, "e-flat"), + "write key `customer.scoresTotal` must not be treated as a prefix match; got {edits:#?}", + ); +} + +#[test] +fn dependencies_resolves_nested_field_target_across_components() { + let decoy = json!({ + "blocks": [ + { "id": "dm-other", "type": "dataModel", "props": { "data": { + "name": "other", + "properties": [ + { "id": "p1", "name": "x", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-decoy", "type": "expression", "props": { "data": { + "key": "other.y", + "value": "other.x * 2" + } } } + ] + }); + let writer = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p2", "name": "base", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-out", "type": "expression", "props": { "data": { + "key": "customer.out", + "value": "{ sub: customer.base * 2 }" + } } } + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("a-decoy", serde_json::from_value(decoy).unwrap()); + ws.set_policy("z-writer", serde_json::from_value(writer).unwrap()); + + let node = ws.dependencies("customer.out.sub"); + let written_by = node + .written_by + .as_ref() + .unwrap_or_else(|| panic!("customer.out.sub must resolve to its writer; got {node:#?}")); + assert_eq!(written_by.policy_path.as_ref(), "z-writer"); + assert_eq!(written_by.block_id.as_ref(), "e-out"); + let dep_paths: Vec = node.deps.iter().map(|d| d.property.to_string()).collect(); + assert!( + dep_paths.iter().any(|p| p == "customer.base"), + "nested target must decompose into the value expression's reads; got {dep_paths:?}", + ); +} + +#[test] +fn null_typed_computed_field_reads_are_not_unknown_properties() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "base", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-null", "type": "expression", "props": { "data": { + "key": "customer.flag", + "value": "null" + } } }, + { "id": "e-read", "type": "expression", "props": { "data": { + "key": "customer.out", + "value": "customer.flag ?? 'none'" + } } } + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let errors: Vec<_> = ws + .diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "reading a null-typed computed field must not raise errors: {errors:#?}", + ); + + let req = EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "base": 1 } })), + goals: Vec::new(), + trace: false, + }; + let result = ws.evaluate(&req).expect("policy must evaluate"); + let output: serde_json::Value = result.output.into(); + assert_eq!(output.pointer("/customer/out"), Some(&json!("none"))); +} + +fn bool_assertion_eval(conditions: serde_json::Value) -> impl Fn(bool, bool, bool) -> bool { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": json!({ + "name": "customer", + "properties": [ + { "id": "pa", "name": "a", "type": "boolean", "array": false, "optional": false }, + { "id": "pb", "name": "b", "type": "boolean", "array": false, "optional": false }, + { "id": "pc", "name": "c", "type": "boolean", "array": false, "optional": false } + ] + }) }}, + { "id": "assert", "type": "assertion", "props": { "data": json!({ + "output": "customer.result", + "conditions": conditions + }) }} + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + move |a: bool, b: bool, c: bool| -> bool { + let result = ws + .evaluate(&EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(json!({ "customer": { "a": a, "b": b, "c": c } })), + goals: Vec::new(), + trace: false, + }) + .expect("evaluate succeeded"); + result + .output + .dot("customer.result") + .and_then(|v| v.as_bool()) + .expect("customer.result is a bool") + } +} + +#[test] +fn assertion_group_then_sibling_keeps_or_operator() { + let eval = bool_assertion_eval(json!([ + { "id": "ca", "expression": "customer.a", "operator": "and", "depth": 1 }, + { "id": "cb", "expression": "customer.b", "operator": "or", "depth": 1 }, + { "id": "cc", "expression": "customer.c", "operator": "and", "depth": 0 } + ])); + + assert!(eval(false, false, true), "(false AND false) OR true = true"); + assert!(eval(true, true, false), "(true AND true) OR false = true"); + assert!( + !eval(false, true, false), + "(false AND true) OR false = false" + ); + assert!(eval(true, true, true), "(true AND true) OR true = true"); +} + +#[test] +fn assertion_group_then_sibling_keeps_and_operator() { + let eval = bool_assertion_eval(json!([ + { "id": "ca", "expression": "customer.a", "operator": "or", "depth": 1 }, + { "id": "cb", "expression": "customer.b", "operator": "and", "depth": 1 }, + { "id": "cc", "expression": "customer.c", "operator": "and", "depth": 0 } + ])); + + assert!( + !eval(true, false, false), + "(true OR false) AND false = false" + ); + assert!(eval(true, false, true), "(true OR false) AND true = true"); + assert!( + !eval(false, false, true), + "(false OR false) AND true = false" + ); +} + +#[test] +fn assertion_with_only_empty_conditions_still_writes_false() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "base", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "assert", "type": "assertion", "props": { "data": { + "output": "customer.ok", + "conditions": [ + { "id": "c1", "expression": "", "operator": "and", "depth": 0 } + ] + } } }, + { "id": "e-read", "type": "expression", "props": { "data": { + "key": "customer.okText", + "value": "customer.ok ? 'yes' : 'no'" + } } } + ] + }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let output = eval(&ws, "p", json!({ "customer": { "base": 1 } })); + assert_eq!( + output.pointer("/customer/ok"), + Some(&json!(false)), + "an assertion with no effective conditions must still write false: {output:#?}", + ); + assert_eq!(output.pointer("/customer/okText"), Some(&json!("no"))); +} diff --git a/core/engine/tests/policy_cache.rs b/core/engine/tests/policy_cache.rs new file mode 100644 index 00000000..9cf289d6 --- /dev/null +++ b/core/engine/tests/policy_cache.rs @@ -0,0 +1,297 @@ +use std::sync::Arc; + +use serde_json::{json, Value}; +use zen_engine::loader::MemoryLoader; +use zen_engine::model::DecisionContent; +use zen_engine::{DecisionEngine, EvaluationError}; +use zen_expression::variable::Variable; + +fn policy_content() -> DecisionContent { + policy_content_with_threshold(100) +} + +fn policy_content_with_threshold(threshold: i64) -> DecisionContent { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "platform", + "scope": "global", + "properties": [ + { "id": "g1", "name": "amount", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "assert", "type": "assertion", "props": { "data": { + "output": "approved", + "conditions": [ + { "id": "c1", "expression": format!("amount >= {threshold}"), "operator": "and", "depth": 0 } + ] + }}} + ] + }); + serde_json::from_value(doc).unwrap() +} + +fn engine_with_policy(precompile: bool) -> DecisionEngine { + let loader = MemoryLoader::default(); + loader.add("policy", policy_content()); + let engine = DecisionEngine::default().with_loader(Arc::new(loader)); + if precompile { + engine.compile(); + } + engine +} + +fn input(amount: i64) -> Variable { + Variable::from(json!({ "amount": amount })) +} + +fn approved(result: &Variable) -> Value { + serde_json::to_value(result).unwrap()["approved"].clone() +} + +#[tokio::test] +async fn precompiled_matches_lazy() { + let lazy = engine_with_policy(false) + .evaluate("policy", input(150)) + .await + .unwrap(); + let eager = engine_with_policy(true) + .evaluate("policy", input(150)) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(&lazy.result).unwrap(), + serde_json::to_value(&eager.result).unwrap() + ); + assert_eq!(approved(&eager.result), json!(true)); +} + +#[tokio::test] +async fn compiled_resolves_cross_policy_imports() { + let loader = MemoryLoader::default(); + loader.add( + "base.json", + serde_json::from_value::(json!({ + "imports": [], + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "platform", "scope": "global", + "properties": [ + { "id": "g1", "name": "amount", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "qual", "type": "assertion", "props": { "data": { + "output": "qualified", + "conditions": [ + { "id": "c1", "expression": "amount >= 100", "operator": "and", "depth": 0 } + ] + }}} + ] + })) + .unwrap(), + ); + loader.add( + "main.json", + serde_json::from_value::(json!({ + "imports": ["base.json"], + "blocks": [ + { "id": "appr", "type": "assertion", "props": { "data": { + "output": "approved", + "conditions": [ + { "id": "c1", "expression": "qualified", "operator": "and", "depth": 0 } + ] + }}} + ] + })) + .unwrap(), + ); + + let engine = DecisionEngine::default().with_loader(Arc::new(loader)); + engine.compile(); + + let result = engine.evaluate("main.json", input(150)).await.unwrap(); + let json = serde_json::to_value(&result.result).unwrap(); + assert_eq!(json["qualified"], serde_json::json!(true)); + assert_eq!(json["approved"], serde_json::json!(true)); +} + +fn parse_error_policy() -> DecisionContent { + serde_json::from_value(json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "platform", "scope": "global", + "properties": [ + { "id": "g1", "name": "amount", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "a", "type": "assertion", "props": { "data": { + "output": "x", + "conditions": [ + { "id": "c1", "expression": "if (( invalid )) {{", "operator": "and", "depth": 0 } + ] + }}} + ] + })) + .unwrap() +} + +fn invalid_graph() -> DecisionContent { + serde_json::from_value(json!({ + "nodes": [ + { "id": "in", "type": "inputNode", "name": "Input", "position": { "x": 0, "y": 0 } } + ], + "edges": [ + { "id": "e1", "type": "edge", "sourceId": "in", "targetId": "ghost" } + ] + })) + .unwrap() +} + +#[test] +fn compile_reports_every_failure() { + let loader = MemoryLoader::default(); + loader.add("good.json", policy_content()); + loader.add("bad-1.json", parse_error_policy()); + loader.add("bad-2.json", parse_error_policy()); + let engine = DecisionEngine::default().with_loader(Arc::new(loader)); + + let failures = engine.compile(); + let keys: Vec<&str> = failures.iter().map(|f| f.key.as_ref()).collect(); + assert!( + keys.contains(&"bad-1.json"), + "every failure must be listed; got: {keys:?}" + ); + assert!( + keys.contains(&"bad-2.json"), + "every failure must be listed; got: {keys:?}" + ); +} + +#[tokio::test] +async fn compile_evicts_bad_keeps_good() { + let loader = MemoryLoader::default(); + loader.add("good.json", policy_content()); + loader.add("bad-policy.json", parse_error_policy()); + loader.add("bad-graph.json", invalid_graph()); + let engine = DecisionEngine::default().with_loader(Arc::new(loader)); + + let failures = engine.compile(); + assert_eq!(failures.len(), 2); + assert!(failures + .iter() + .any(|f| f.kind == "policy" && f.key.as_ref() == "bad-policy.json")); + assert!(failures + .iter() + .any(|f| f.kind == "graph" && f.key.as_ref() == "bad-graph.json")); + assert_eq!(engine.compile_failures().len(), 2); + + let result = engine.evaluate("good.json", input(150)).await.unwrap(); + assert_eq!(approved(&result.result), json!(true)); +} + +#[test] +fn compile_surfaces_bundle_errors() { + let loader = MemoryLoader::default(); + let broken = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "platform", + "scope": "global", + "properties": [ + { "id": "g1", "name": "amount", "type": "number", "array": false, "optional": false } + ] + }}}, + { "id": "assert", "type": "assertion", "props": { "data": { + "output": "approved", + "conditions": [ + { "id": "c1", "expression": "if (( invalid )) {{", "operator": "and", "depth": 0 } + ] + }}} + ] + }); + loader.add( + "policy", + serde_json::from_value::(broken).unwrap(), + ); + let engine = DecisionEngine::default().with_loader(Arc::new(loader)); + + assert!(!engine.compile().is_empty()); +} + +#[tokio::test] +async fn clone_with_loader_does_not_share_compiled_set() { + let loader_a = MemoryLoader::default(); + loader_a.add("policy", policy_content_with_threshold(100)); + let engine_a = DecisionEngine::default().with_loader(Arc::new(loader_a)); + engine_a.compile(); + + let loader_b = MemoryLoader::default(); + loader_b.add("policy", policy_content_with_threshold(1000)); + let engine_b = engine_a.clone().with_loader(Arc::new(loader_b)); + engine_b.compile(); + + let from_a = engine_a.evaluate("policy", input(150)).await.unwrap(); + assert_eq!(approved(&from_a.result), json!(true)); + + let from_b = engine_b.evaluate("policy", input(150)).await.unwrap(); + assert_eq!(approved(&from_b.result), json!(false)); +} + +#[tokio::test] +async fn compile_reports_policies_with_broken_imports() { + let loader = MemoryLoader::default(); + loader.add("base.json", parse_error_policy()); + loader.add( + "main.json", + serde_json::from_value::(json!({ + "imports": ["base.json"], + "blocks": [ + { "id": "appr", "type": "assertion", "props": { "data": { + "output": "approved", + "conditions": [ + { "id": "c1", "expression": "amount >= 100", "operator": "and", "depth": 0 } + ] + }}} + ] + })) + .unwrap(), + ); + let engine = DecisionEngine::default().with_loader(Arc::new(loader)); + + let failures = engine.compile(); + assert!(failures + .iter() + .any(|f| f.kind == "policy" && f.key.as_ref() == "base.json")); + let main_failure = failures + .iter() + .find(|f| f.key.as_ref() == "main.json") + .expect("main.json must fail compilation when its import is broken"); + assert_eq!(main_failure.kind, "policy"); + assert!(!main_failure.diagnostics.is_empty()); + + let err = engine + .evaluate("main.json", input(150)) + .await + .expect_err("main.json must not evaluate through a broken fast path"); + assert!( + matches!( + *err, + EvaluationError::Policy(zen_engine::policy::EvaluationError::CompilationErrors { .. }) + ), + "expected CompilationErrors, got: {err:?}" + ); +} + +#[tokio::test] +async fn reload_recompiles_from_loader() { + let engine = engine_with_policy(true); + + let denied = engine.evaluate("policy", input(50)).await.unwrap(); + assert_eq!(approved(&denied.result), json!(false)); + + engine.compile(); + + let allowed = engine.evaluate("policy", input(150)).await.unwrap(); + assert_eq!(approved(&allowed.result), json!(true)); +} diff --git a/core/engine/tests/policy_driver.rs b/core/engine/tests/policy_driver.rs new file mode 100644 index 00000000..d71cd650 --- /dev/null +++ b/core/engine/tests/policy_driver.rs @@ -0,0 +1,957 @@ +use serde_json::json; +use std::sync::Arc; +use zen_engine::policy::{EvaluateRequest, PolicyWorkspace}; +use zen_expression::variable::Variable; + +fn workspace_with(doc: serde_json::Value) -> PolicyWorkspace { + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + ws +} + +fn request(input: serde_json::Value, goals: Vec<&str>, trace: bool) -> EvaluateRequest { + EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(input), + goals: goals.into_iter().map(Arc::from).collect(), + trace, + } +} + +fn executed_block_ids(ws: &PolicyWorkspace, req: &EvaluateRequest) -> Vec { + let result = ws.evaluate(req).expect("evaluate succeeded"); + result + .trace + .expect("trace populated") + .executions + .iter() + .map(|e| e.block_id.to_string()) + .collect() +} + +fn match_laziness_doc() -> serde_json::Value { + json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "score", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-a", "type": "expression", "props": { "data": { + "key": "customer.aVal", + "value": "customer.score * 2" + } } }, + { "id": "e-b", "type": "expression", "props": { "data": { + "key": "customer.bVal", + "value": "customer.score * 3" + } } }, + { "id": "m", "type": "match", "props": { "data": { + "key": "customer.out", + "arms": [ + { "id": "a1", "condition": "customer.score >= 10", "value": "customer.aVal" }, + { "id": "a2", "condition": "", "value": "customer.bVal" } + ] + } } } + ] + }) +} + +#[test] +fn unmatched_match_arm_dependencies_do_not_execute() { + let ws = workspace_with(match_laziness_doc()); + let req = request(json!({ "customer": { "score": 20 } }), vec![], true); + let executed = executed_block_ids(&ws, &req); + + assert!( + executed.contains(&"e-a".to_string()), + "matched arm dependency must run: {executed:?}" + ); + assert!(executed.contains(&"m".to_string())); + assert!( + !executed.contains(&"e-b".to_string()), + "unmatched arm dependency must stay lazy: {executed:?}", + ); + + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + assert_eq!(output.pointer("/customer/out"), Some(&json!(40))); + assert_eq!(output.pointer("/customer/bVal"), None); +} + +#[test] +fn default_match_arm_dependencies_execute_when_selected() { + let ws = workspace_with(match_laziness_doc()); + let req = request(json!({ "customer": { "score": 5 } }), vec![], true); + let executed = executed_block_ids(&ws, &req); + + assert!( + executed.contains(&"e-b".to_string()), + "default arm dependency must run: {executed:?}" + ); + assert!( + !executed.contains(&"e-a".to_string()), + "non-selected arm dependency must stay lazy: {executed:?}", + ); +} + +#[test] +fn dependencies_execute_before_dependents() { + let ws = workspace_with(match_laziness_doc()); + let req = request(json!({ "customer": { "score": 20 } }), vec![], true); + let executed = executed_block_ids(&ws, &req); + + let pos = |id: &str| { + executed + .iter() + .position(|e| e == id) + .unwrap_or_else(|| panic!("{id} missing from {executed:?}")) + }; + assert!( + pos("e-a") < pos("m"), + "dependency must commit before dependent: {executed:?}" + ); +} + +#[test] +fn goals_prune_unrelated_blocks() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "score", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-x", "type": "expression", "props": { "data": { + "key": "customer.x", + "value": "customer.score * 2" + } } }, + { "id": "e-y", "type": "expression", "props": { "data": { + "key": "customer.y", + "value": "customer.score * 3" + } } } + ] + }); + let ws = workspace_with(doc); + let req = request( + json!({ "customer": { "score": 10 } }), + vec!["customer.x"], + true, + ); + let executed = executed_block_ids(&ws, &req); + + assert!(executed.contains(&"e-x".to_string())); + assert!( + !executed.contains(&"e-y".to_string()), + "goal pruning must skip e-y: {executed:?}" + ); + + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + assert_eq!(output.pointer("/customer/x"), Some(&json!(20))); + assert_eq!(output.pointer("/customer/y"), None); +} + +#[test] +fn decision_table_unmatched_row_dependencies_do_not_execute() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-adult", "type": "expression", "props": { "data": { + "key": "customer.adultMsg", + "value": "\"adult\"" + } } }, + { "id": "e-child", "type": "expression", "props": { "data": { + "key": "customer.childMsg", + "value": "\"child\"" + } } }, + { "id": "dt", "type": "decisionTable", "props": { "data": { + "hitPolicy": "first", + "inputs": [ { "id": "i1", "name": "", "field": "customer.age" } ], + "outputs": [ { "id": "o1", "name": "", "field": "customer.msg" } ], + "rules": [ + { "i1": ">= 18", "o1": "customer.adultMsg" }, + { "i1": "", "o1": "customer.childMsg" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + let req = request(json!({ "customer": { "age": 30 } }), vec![], true); + let executed = executed_block_ids(&ws, &req); + + assert!( + executed.contains(&"e-adult".to_string()), + "matched row dependency must run: {executed:?}" + ); + assert!( + !executed.contains(&"e-child".to_string()), + "unmatched row dependency must stay lazy: {executed:?}", + ); + + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + assert_eq!(output.pointer("/customer/msg"), Some(&json!("adult"))); +} + +fn iterated_doc() -> serde_json::Value { + json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "name", "type": "string", "array": false, "optional": false }, + { "id": "p2", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + } } }, + { "id": "dm-company", "type": "dataModel", "props": { "data": { + "name": "company", + "properties": [ + { "id": "p3", "name": "name", "type": "string", "array": false, "optional": false }, + { "id": "p4", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-top", "type": "expression", "props": { "data": { + "key": "company.isTop", + "value": "max(map(company.customer.companies as c, c.revenue)) == company.revenue" + } } } + ] + }) +} + +#[test] +fn iterated_owner_binding_does_not_leak_into_output() { + let ws = workspace_with(iterated_doc()); + let input = json!({ "customer": { "name": "Alice", "companies": [ + { "name": "Acme", "revenue": 500 }, + { "name": "Mega", "revenue": 1000 } + ] } }); + + for trace in [false, true] { + let req = request(input.clone(), vec![], trace); + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + let companies = output + .pointer("/customer/companies") + .and_then(|v| v.as_array()) + .expect("companies present"); + assert_eq!(companies.len(), 2); + assert_eq!(companies[0].get("isTop"), Some(&json!(false))); + assert_eq!(companies[1].get("isTop"), Some(&json!(true))); + for company in companies { + assert!( + company.get("customer").is_none(), + "synthetic owner binding leaked into output (trace={trace}): {company:#?}", + ); + } + } +} + +#[test] +fn iterated_match_unselected_arm_dependencies_do_not_execute() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + } } }, + { "id": "dm-company", "type": "dataModel", "props": { "data": { + "name": "company", + "properties": [ + { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-big", "type": "expression", "props": { "data": { + "key": "customer.bigRate", + "value": "2" + } } }, + { "id": "e-small", "type": "expression", "props": { "data": { + "key": "customer.smallRate", + "value": "3" + } } }, + { "id": "m", "type": "match", "props": { "data": { + "key": "company.bonus", + "arms": [ + { "id": "hi", "condition": "company.revenue >= 100", "value": "company.revenue * customer.bigRate" }, + { "id": "lo", "condition": "", "value": "company.revenue * customer.smallRate" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + let req = request( + json!({ "customer": { "companies": [ { "revenue": 200 }, { "revenue": 300 } ] } }), + vec![], + true, + ); + let executed = executed_block_ids(&ws, &req); + + assert!( + executed.contains(&"e-big".to_string()), + "selected arm dependency must run: {executed:?}" + ); + assert!( + !executed.contains(&"e-small".to_string()), + "arm unselected by every instance must stay lazy: {executed:?}", + ); + assert_eq!( + executed.iter().filter(|id| id.as_str() == "m").count(), + 2, + "one execution per instance: {executed:?}", + ); + + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + assert_eq!( + output.pointer("/customer/companies/0/bonus"), + Some(&json!(400)) + ); + assert_eq!( + output.pointer("/customer/companies/1/bonus"), + Some(&json!(600)) + ); +} + +#[test] +fn iterated_failure_does_not_leak_owner_binding_into_partial_trace() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + } } }, + { "id": "dm-company", "type": "dataModel", "props": { "data": { + "name": "company", + "properties": [ + { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-fail", "type": "expression", "props": { "data": { + "key": "company.score", + "value": "company.customer.missing.deep + company.revenue" + } } } + ] + }); + let ws = workspace_with(doc); + let input = json!({ "customer": { "companies": [ { "revenue": 200 } ] } }); + let req = request(input.clone(), vec![], true); + + let _ = ws.evaluate(&req); + + let probe = workspace_with(iterated_doc()); + let _ = probe; + let input_after: serde_json::Value = req.input.into(); + let company = input_after + .pointer("/customer/companies/0") + .expect("instance present"); + assert!( + company.get("customer").is_none(), + "synthetic owner binding leaked into caller input after failure: {company:#?}", + ); +} + +#[test] +fn enhance_trace_tolerates_runtime_error_in_post_selection_match_arm() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "flag", "type": "string", "array": false, "optional": false } + ] + } } }, + { "id": "m", "type": "match", "props": { "data": { + "key": "customer.out", + "arms": [ + { "id": "a1", "condition": "customer.flag == ''", "value": "\"empty\"" }, + { "id": "a2", "condition": "number(customer.flag) > 2", "value": "\"big\"" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + let req = request(json!({ "customer": { "flag": "" } }), vec![], true); + + let plain = ws.evaluate(&req).expect("evaluate must succeed"); + let output: serde_json::Value = plain.output.into(); + assert_eq!(output.pointer("/customer/out"), Some(&json!("empty"))); + + let enhanced = ws + .enhance_trace(&req) + .expect("enhance_trace must tolerate a post-selection arm error"); + let trace = enhanced.trace.expect("trace populated"); + let m = trace + .executions + .iter() + .find(|e| e.block_id.as_ref() == "m") + .expect("m missing from trace"); + let trace_json = serde_json::to_value(&m.trace).unwrap(); + assert_eq!(trace_json.pointer("/matchedArm"), Some(&json!("a1"))); + assert_eq!( + trace_json.pointer("/arms"), + Some(&json!([ + { "id": "a1", "result": true }, + { "id": "a2", "result": false } + ])) + ); +} + +#[test] +fn enhance_trace_propagates_runtime_error_before_match_selection() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "flag", "type": "string", "array": false, "optional": false } + ] + } } }, + { "id": "m", "type": "match", "props": { "data": { + "key": "customer.out", + "arms": [ + { "id": "a1", "condition": "number(customer.flag) > 2", "value": "\"big\"" }, + { "id": "a2", "condition": "", "value": "\"fallback\"" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + let req = request(json!({ "customer": { "flag": "" } }), vec![], true); + + assert!(ws.evaluate(&req).is_err()); + assert!( + ws.enhance_trace(&req).is_err(), + "pre-selection arm error must still propagate in extras mode" + ); +} + +#[test] +fn enhance_trace_tolerates_runtime_error_in_post_selection_table_row() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "flag", "type": "string", "array": false, "optional": false } + ] + } } }, + { "id": "dt", "type": "decisionTable", "props": { "data": { + "hitPolicy": "first", + "inputs": [ { "id": "i1", "name": "", "field": "customer.flag" } ], + "outputs": [ { "id": "o1", "name": "", "field": "customer.out" } ], + "rules": [ + { "i1": "== ''", "o1": "\"empty\"" }, + { "i1": "number($) > 2", "o1": "\"big\"" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + let req = request(json!({ "customer": { "flag": "" } }), vec![], true); + + let plain = ws.evaluate(&req).expect("evaluate must succeed"); + let output: serde_json::Value = plain.output.into(); + assert_eq!(output.pointer("/customer/out"), Some(&json!("empty"))); + + let enhanced = ws + .enhance_trace(&req) + .expect("enhance_trace must tolerate a post-selection row error"); + let trace = enhanced.trace.expect("trace populated"); + let dt = trace + .executions + .iter() + .find(|e| e.block_id.as_ref() == "dt") + .expect("dt missing from trace"); + let trace_json = serde_json::to_value(&dt.trace).unwrap(); + assert_eq!(trace_json.pointer("/matchedRows"), Some(&json!([0]))); +} + +#[test] +fn enhance_trace_tolerates_error_after_failed_column_in_unmatched_row() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "flag", "type": "string", "array": false, "optional": false } + ] + } } }, + { "id": "dt", "type": "decisionTable", "props": { "data": { + "hitPolicy": "first", + "inputs": [ + { "id": "i1", "name": "", "field": "customer.flag" }, + { "id": "i2", "name": "", "field": "customer.flag" } + ], + "outputs": [ { "id": "o1", "name": "", "field": "customer.out" } ], + "rules": [ + { "i1": "== 'x'", "i2": "number($) > 2", "o1": "\"first\"" }, + { "i1": "== ''", "o1": "\"second\"" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + let req = request(json!({ "customer": { "flag": "" } }), vec![], true); + + let plain = ws.evaluate(&req).expect("evaluate must succeed"); + let output: serde_json::Value = plain.output.into(); + assert_eq!(output.pointer("/customer/out"), Some(&json!("second"))); + + let enhanced = ws.enhance_trace(&req).expect( + "enhance_trace must tolerate an error in a column evaluate would short-circuit past", + ); + let trace = enhanced.trace.expect("trace populated"); + let dt = trace + .executions + .iter() + .find(|e| e.block_id.as_ref() == "dt") + .expect("dt missing from trace"); + let trace_json = serde_json::to_value(&dt.trace).unwrap(); + assert_eq!(trace_json.pointer("/matchedRows"), Some(&json!([1]))); +} + +#[test] +fn enhance_trace_tolerates_error_after_failed_column_in_collect_table() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "flag", "type": "string", "array": false, "optional": false } + ] + } } }, + { "id": "dt", "type": "decisionTable", "props": { "data": { + "hitPolicy": "collect", + "inputs": [ + { "id": "i1", "name": "", "field": "customer.flag" }, + { "id": "i2", "name": "", "field": "customer.flag" } + ], + "outputs": [ { "id": "o1", "name": "", "field": "customer.out" } ], + "rules": [ + { "i1": "== 'x'", "i2": "number($) > 2", "o1": "\"first\"" }, + { "i1": "== ''", "o1": "\"second\"" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + let req = request(json!({ "customer": { "flag": "" } }), vec![], true); + + let plain = ws.evaluate(&req).expect("evaluate must succeed"); + let output: serde_json::Value = plain.output.into(); + assert_eq!(output.pointer("/customer/out"), Some(&json!(["second"]))); + + let enhanced = ws + .enhance_trace(&req) + .expect("enhance_trace must tolerate post-short-circuit errors in collect tables"); + let trace = enhanced.trace.expect("trace populated"); + let dt = trace + .executions + .iter() + .find(|e| e.block_id.as_ref() == "dt") + .expect("dt missing from trace"); + let trace_json = serde_json::to_value(&dt.trace).unwrap(); + assert_eq!(trace_json.pointer("/matchedRows"), Some(&json!([1]))); +} + +#[test] +fn iterated_write_named_like_owner_entity_survives_write_back() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + } } }, + { "id": "dm-company", "type": "dataModel", "props": { "data": { + "name": "company", + "properties": [ + { "id": "p2", "name": "name", "type": "string", "array": false, "optional": false } + ] + } } }, + { "id": "e-owner-name", "type": "expression", "props": { "data": { + "key": "company.customer", + "value": "company.name + '!'" + } } } + ] + }); + let ws = workspace_with(doc); + let req = request( + json!({ "customer": { "companies": [ { "name": "Acme" }, { "name": "Mega" } ] } }), + vec![], + false, + ); + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + assert_eq!( + output.pointer("/customer/companies/0/customer"), + Some(&json!("Acme!")), + "write named like the owner entity must survive write_back: {output:#?}", + ); + assert_eq!( + output.pointer("/customer/companies/1/customer"), + Some(&json!("Mega!")) + ); +} + +#[test] +fn cyclic_property_demand_terminates() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "seed", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-a", "type": "expression", "props": { "data": { + "key": "customer.a", + "value": "customer.b + 1" + } } }, + { "id": "e-b", "type": "expression", "props": { "data": { + "key": "customer.b", + "value": "customer.a + 1" + } } } + ] + }); + let ws = workspace_with(doc); + let req = request(json!({ "customer": { "seed": 1 } }), vec![], false); + let _ = ws.evaluate(&req); +} + +fn writes_reads_doc() -> serde_json::Value { + json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "score", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-base", "type": "expression", "props": { "data": { + "key": "customer.base", + "value": "customer.score * 2" + } } }, + { "id": "a-rich", "type": "assertion", "props": { "data": { + "output": "customer.rich", + "conditions": [ + { "id": "c1", "expression": "customer.base >= 30", "operator": "and", "depth": 0 } + ] + } } }, + { "id": "m-tier", "type": "match", "props": { "data": { + "key": "customer.tier", + "arms": [ + { "id": "hi", "condition": "customer.rich", "value": "\"gold\"" }, + { "id": "lo", "condition": "", "value": "\"basic\"" } + ] + } } }, + { "id": "dt", "type": "decisionTable", "props": { "data": { + "hitPolicy": "first", + "inputs": [ + { "id": "i1", "name": "", "field": "customer.tier" } + ], + "outputs": [ + { "id": "o1", "name": "", "field": "customer.discount" } + ], + "rules": [ + { "i1": "\"gold\"", "o1": "customer.base * 0.01" }, + { "i1": "", "o1": "0" } + ] + } } } + ] + }) +} + +fn writes_of(ex: &zen_engine::policy::BlockExecution) -> Vec<(String, serde_json::Value)> { + ex.writes + .iter() + .map(|w| (w.path.to_string(), w.value.clone().into())) + .collect() +} + +fn reads_of(ex: &zen_engine::policy::BlockExecution) -> Vec { + ex.reads.iter().map(|r| r.to_string()).collect() +} + +#[test] +fn trace_records_writes_and_reads_per_execution() { + let ws = workspace_with(writes_reads_doc()); + let req = request(json!({ "customer": { "score": 20 } }), vec![], true); + let result = ws.enhance_trace(&req).expect("evaluate succeeded"); + let trace = result.trace.expect("trace populated"); + + let by_id = |id: &str| { + trace + .executions + .iter() + .find(|e| e.block_id.as_ref() == id) + .unwrap_or_else(|| panic!("{id} missing from trace")) + }; + + let expression = by_id("e-base"); + assert_eq!( + writes_of(expression), + vec![("customer.base".to_string(), json!(40))] + ); + assert_eq!(reads_of(expression), vec!["customer.score"]); + + let assertion = by_id("a-rich"); + assert_eq!( + writes_of(assertion), + vec![("customer.rich".to_string(), json!(true))] + ); + assert_eq!(reads_of(assertion), vec!["customer.base"]); + + let match_block = by_id("m-tier"); + assert_eq!( + writes_of(match_block), + vec![("customer.tier".to_string(), json!("gold"))] + ); + assert_eq!(reads_of(match_block), vec!["customer.rich"]); + + let table = by_id("dt"); + assert_eq!( + writes_of(table), + vec![("customer.discount".to_string(), json!(0.4))] + ); + assert_eq!(reads_of(table), vec!["customer.base", "customer.tier"]); +} + +#[test] +fn collect_table_trace_records_final_array_write() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "score", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "dt-collect", "type": "decisionTable", "props": { "data": { + "hitPolicy": "collect", + "inputs": [ + { "id": "i1", "name": "", "field": "customer.score" } + ], + "outputs": [ + { "id": "o1", "name": "", "field": "customer.flags" } + ], + "rules": [ + { "i1": ">= 10", "o1": "\"big\"" }, + { "i1": ">= 0", "o1": "\"pos\"" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + let req = request(json!({ "customer": { "score": 20 } }), vec![], true); + let result = ws.enhance_trace(&req).expect("evaluate succeeded"); + let trace = result.trace.expect("trace populated"); + + let table = trace + .executions + .iter() + .find(|e| e.block_id.as_ref() == "dt-collect") + .expect("dt-collect missing from trace"); + assert_eq!( + writes_of(table), + vec![("customer.flags".to_string(), json!(["big", "pos"]))] + ); +} + +#[test] +fn iterated_trace_records_writes_and_reads_per_instance() { + let doc = json!({ + "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + } } }, + { "id": "dm-company", "type": "dataModel", "props": { "data": { + "name": "company", + "properties": [ + { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-rate", "type": "expression", "props": { "data": { + "key": "customer.rate", + "value": "2" + } } }, + { "id": "m-bonus", "type": "match", "props": { "data": { + "key": "company.bonus", + "arms": [ + { "id": "hi", "condition": "company.revenue >= 100", "value": "company.revenue * customer.rate" }, + { "id": "lo", "condition": "", "value": "0" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + let req = request( + json!({ "customer": { "companies": [ { "revenue": 200 }, { "revenue": 50 } ] } }), + vec![], + true, + ); + let result = ws.enhance_trace(&req).expect("evaluate succeeded"); + let trace = result.trace.expect("trace populated"); + + let instances: Vec<_> = trace + .executions + .iter() + .filter(|e| e.block_id.as_ref() == "m-bonus") + .collect(); + assert_eq!(instances.len(), 2); + + assert_eq!( + instances[0].instance_path.as_deref(), + Some("customer.companies.0") + ); + assert_eq!( + writes_of(instances[0]), + vec![("company.bonus".to_string(), json!(400))] + ); + assert_eq!( + reads_of(instances[0]), + vec!["company.revenue", "customer.rate"] + ); + + assert_eq!( + instances[1].instance_path.as_deref(), + Some("customer.companies.1") + ); + assert_eq!( + writes_of(instances[1]), + vec![("company.bonus".to_string(), json!(0))] + ); + assert_eq!(reads_of(instances[1]), vec!["company.revenue"]); +} + +fn nested_totals_doc() -> serde_json::Value { + json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "base", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-ta", "type": "expression", "props": { "data": { + "key": "totals.a", + "value": "customer.base * 2" + } } }, + { "id": "e-tb", "type": "expression", "props": { "data": { + "key": "totals.b", + "value": "customer.base * 3" + } } } + ] + }) +} + +#[test] +fn sibling_writers_under_shared_nested_root_all_execute() { + let ws = workspace_with(nested_totals_doc()); + let req = request(json!({ "customer": { "base": 10 } }), vec![], true); + let executed = executed_block_ids(&ws, &req); + + assert!( + executed.contains(&"e-ta".to_string()), + "first sibling writer must run: {executed:?}" + ); + assert!( + executed.contains(&"e-tb".to_string()), + "second sibling writer must run: {executed:?}" + ); + + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + assert_eq!(output.pointer("/totals/a"), Some(&json!(20))); + assert_eq!(output.pointer("/totals/b"), Some(&json!(30))); +} + +#[test] +fn goal_on_shared_nested_root_runs_all_sibling_writers() { + let ws = workspace_with(nested_totals_doc()); + let req = request(json!({ "customer": { "base": 10 } }), vec!["totals"], true); + let executed = executed_block_ids(&ws, &req); + + assert!(executed.contains(&"e-ta".to_string())); + assert!( + executed.contains(&"e-tb".to_string()), + "ancestor goal must fan out to every descendant writer: {executed:?}" + ); + + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + assert_eq!(output.pointer("/totals/a"), Some(&json!(20))); + assert_eq!(output.pointer("/totals/b"), Some(&json!(30))); +} + +#[test] +fn entity_rooted_sibling_writers_under_shared_nested_root_all_execute() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "base", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e-ta", "type": "expression", "props": { "data": { + "key": "customer.totals.a", + "value": "customer.base * 2" + } } }, + { "id": "e-tb", "type": "expression", "props": { "data": { + "key": "customer.totals.b", + "value": "customer.base * 3" + } } } + ] + }); + let ws = workspace_with(doc); + let req = request(json!({ "customer": { "base": 10 } }), vec![], true); + let executed = executed_block_ids(&ws, &req); + + assert!(executed.contains(&"e-ta".to_string())); + assert!( + executed.contains(&"e-tb".to_string()), + "entity-rooted sibling writer must run: {executed:?}" + ); + + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + assert_eq!(output.pointer("/customer/totals/a"), Some(&json!(20))); + assert_eq!(output.pointer("/customer/totals/b"), Some(&json!(30))); +} + +#[test] +fn goal_on_nested_leaf_prunes_sibling_writer() { + let ws = workspace_with(nested_totals_doc()); + let req = request( + json!({ "customer": { "base": 10 } }), + vec!["totals.a"], + true, + ); + let executed = executed_block_ids(&ws, &req); + + assert!(executed.contains(&"e-ta".to_string())); + assert!( + !executed.contains(&"e-tb".to_string()), + "leaf goal must stay lazy and skip the sibling writer: {executed:?}" + ); + + let result = ws.evaluate(&req).expect("evaluate succeeded"); + let output: serde_json::Value = result.output.into(); + assert_eq!(output.pointer("/totals/a"), Some(&json!(20))); + assert_eq!(output.pointer("/totals/b"), None); +} diff --git a/core/engine/tests/policy_instance_of.rs b/core/engine/tests/policy_instance_of.rs new file mode 100644 index 00000000..6e9e1f33 --- /dev/null +++ b/core/engine/tests/policy_instance_of.rs @@ -0,0 +1,229 @@ +use serde_json::json; +use std::sync::Arc; +use zen_engine::policy::{PolicyWorkspace, ReferenceKind, RenameTarget, ScopeRequest}; + +fn workspace_with(doc: serde_json::Value) -> PolicyWorkspace { + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + ws +} + +fn customer_company_dm() -> Vec { + vec![ + json!({ "id": "dm-customer", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "name", "type": "string", "array": false, "optional": false }, + { "id": "p2", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } + ] + } } }), + json!({ "id": "dm-company", "type": "dataModel", "props": { "data": { + "name": "company", + "properties": [ + { "id": "p1", "name": "name", "type": "string", "array": false, "optional": false }, + { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + } } }), + ] +} + +fn instance_of(ws: &PolicyWorkspace, entity: &str, field: &str) -> Option { + let entities = ws.entities(&ScopeRequest::for_policy("p")); + let entity_obj = entities.iter().find(|e| e.name.as_ref() == entity)?; + let field_obj = entity_obj + .fields + .iter() + .find(|f| f.name.as_ref() == field)?; + let origin = serde_json::to_value(&field_obj.origin).unwrap(); + origin.get("instanceOf").cloned() +} + +#[test] +fn chained_computed_identity_resolves_transitively() { + let mut blocks = customer_company_dm(); + blocks.push( + json!({ "id": "e1", "type": "expression", "props": { "data": { + "key": "customer.profitable", + "value": "filter(customer.companies, $.revenue > 0)" + } } }), + ); + blocks.push( + json!({ "id": "e2", "type": "expression", "props": { "data": { + "key": "customer.topThree", + "value": "customer.profitable[0:3]" + } } }), + ); + blocks.push( + json!({ "id": "e3", "type": "expression", "props": { "data": { + "key": "customer.best", + "value": "customer.topThree[0]" + } } }), + ); + let ws = workspace_with(json!({ "blocks": blocks })); + + assert_eq!( + instance_of(&ws, "customer", "profitable"), + Some(json!({ "target": "company", "array": true })) + ); + assert_eq!( + instance_of(&ws, "customer", "topThree"), + Some(json!({ "target": "company", "array": true })) + ); + assert_eq!( + instance_of(&ws, "customer", "best"), + Some(json!({ "target": "company", "array": false })) + ); +} + +#[test] +fn match_arms_with_agreeing_identity() { + let mut blocks = customer_company_dm(); + blocks.push(json!({ "id": "m1", "type": "match", "props": { "data": { + "key": "customer.picked", + "arms": [ + { "id": "a1", "condition": "customer.name == \"vip\"", "value": "customer.companies[0]" }, + { "id": "a2", "condition": "", "value": "customer.companies[1]" } + ] + } } })); + let ws = workspace_with(json!({ "blocks": blocks })); + assert_eq!( + instance_of(&ws, "customer", "picked"), + Some(json!({ "target": "company", "array": false })) + ); +} + +#[test] +fn match_arms_with_disagreeing_identity_erase() { + let mut blocks = customer_company_dm(); + blocks.push(json!({ "id": "m1", "type": "match", "props": { "data": { + "key": "customer.picked", + "arms": [ + { "id": "a1", "condition": "customer.name == \"vip\"", "value": "customer.companies[0]" }, + { "id": "a2", "condition": "", "value": "customer.name" } + ] + } } })); + let ws = workspace_with(json!({ "blocks": blocks })); + assert_eq!(instance_of(&ws, "customer", "picked"), None); +} + +#[test] +fn pool_root_filter_keeps_array() { + let doc = json!({ "blocks": [ + { "id": "dm-customer", "type": "dataModel", "props": { "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "favorite", "type": "reference", "target": "company", "array": false, "optional": false } + ] + } } }, + { "id": "dm-company", "type": "dataModel", "props": { "data": { + "name": "company", + "properties": [ + { "id": "p1", "name": "revenue", "type": "number", "array": false, "optional": false } + ] + } } }, + { "id": "e1", "type": "expression", "props": { "data": { + "key": "customer.richPool", + "value": "filter(company, $.revenue > 100)" + } } } + ] }); + let ws = workspace_with(doc); + assert_eq!( + instance_of(&ws, "customer", "richPool"), + Some(json!({ "target": "company", "array": true })) + ); +} + +#[test] +fn outputs_carry_instance_of() { + let mut blocks = customer_company_dm(); + blocks.push( + json!({ "id": "e1", "type": "expression", "props": { "data": { + "key": "customer.profitable", + "value": "filter(customer.companies, $.revenue > 0)" + } } }), + ); + let ws = workspace_with(json!({ "blocks": blocks })); + let outputs = ws.outputs(&ScopeRequest::for_policy("p")); + let profitable = outputs + .iter() + .find(|o| o.path.as_ref() == "customer.profitable") + .expect("output registered"); + let serialized = serde_json::to_value(profitable).unwrap(); + assert_eq!( + serialized["instanceOf"], + json!({ "target": "company", "array": true }) + ); +} + +#[test] +fn rename_finds_reads_through_computed_collection_and_pointer() { + let mut blocks = customer_company_dm(); + blocks.push( + json!({ "id": "e1", "type": "expression", "props": { "data": { + "key": "customer.profitable", + "value": "filter(customer.companies, $.revenue > 0)" + } } }), + ); + blocks.push( + json!({ "id": "e2", "type": "expression", "props": { "data": { + "key": "customer.total", + "value": "sum(map(customer.profitable as c, c.revenue))" + } } }), + ); + let ws = workspace_with(json!({ "blocks": blocks })); + + let sites = ws.references(&RenameTarget::Field { + entity: Arc::from("company"), + field: Arc::from("revenue"), + }); + let expression_sites: Vec<&str> = sites + .iter() + .filter(|s| s.kind == ReferenceKind::ExpressionRead) + .map(|s| s.block_id.as_ref()) + .collect(); + assert!( + expression_sites.contains(&"e1"), + "$ read over declared relationship must be found: {sites:#?}" + ); + assert!( + expression_sites.contains(&"e2"), + "alias read over computed collection must be found: {sites:#?}" + ); + + let edits = ws.rename( + &RenameTarget::Field { + entity: Arc::from("company"), + field: Arc::from("revenue"), + }, + "income", + ); + let serialized = serde_json::to_string(&edits).unwrap(); + assert!( + serialized.contains("$.income > 0"), + "pointer read must be rewritten: {serialized}" + ); + assert!( + serialized.contains("c.income"), + "alias read must be rewritten: {serialized}" + ); +} + +#[test] +fn map_and_arithmetic_erase_identity() { + let mut blocks = customer_company_dm(); + blocks.push( + json!({ "id": "e1", "type": "expression", "props": { "data": { + "key": "customer.summaries", + "value": "map(customer.companies as c, { label: c.name })" + } } }), + ); + blocks.push( + json!({ "id": "e2", "type": "expression", "props": { "data": { + "key": "customer.count", + "value": "len(customer.companies)" + } } }), + ); + let ws = workspace_with(json!({ "blocks": blocks })); + assert_eq!(instance_of(&ws, "customer", "summaries"), None); + assert_eq!(instance_of(&ws, "customer", "count"), None); +} diff --git a/core/engine/tests/policy_runtime.rs b/core/engine/tests/policy_runtime.rs new file mode 100644 index 00000000..bc743cf2 --- /dev/null +++ b/core/engine/tests/policy_runtime.rs @@ -0,0 +1,354 @@ +use serde_json::json; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use zen_engine::loader::{LoaderError, MemoryLoader}; +use zen_engine::model::{DecisionContent, PolicyContent}; +use zen_engine::{ + DecisionEngine, EvaluationError, EvaluationOptions, EvaluationSerializedOptions, + EvaluationTraceKind, +}; + +mod support; +use support::load_test_data; + +fn simple_policy_json() -> serde_json::Value { + json!({ + "blocks": [ + { + "id": "dm-customer", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] + } + }, + "children": [] + }, + { + "id": "assert1", + "type": "assertion", + "props": { + "data": { + "output": "customer.isAdult", + "conditions": [ + { "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 } + ] + } + }, + "children": [] + } + ] + }) +} + +fn importing_policy_json(import_path: &str, output: &str) -> serde_json::Value { + json!({ + "imports": [import_path], + "blocks": [ + { + "id": format!("assert-{output}"), + "type": "assertion", + "props": { + "data": { + "output": format!("customer.{output}"), + "conditions": [ + { "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 } + ] + } + }, + "children": [] + } + ] + }) +} + +fn make_policy_content(json: serde_json::Value) -> DecisionContent { + let policy: zen_engine::policy::PolicyDocument = + serde_json::from_value(json).expect("valid policy fixture"); + DecisionContent::Policy(PolicyContent(Arc::new(policy))) +} + +fn engine_with(loader: Arc) -> DecisionEngine { + DecisionEngine::default().with_loader(loader) +} + +#[tokio::test] +async fn evaluate_single_policy() { + let loader = Arc::new(MemoryLoader::default()); + loader.add("policy", make_policy_content(simple_policy_json())); + let engine = engine_with(loader); + + let result = engine + .evaluate("policy", json!({ "customer": { "age": 30 } }).into()) + .await + .expect("evaluate ok"); + + let result_json: serde_json::Value = result.result.into(); + assert_eq!( + result_json.pointer("/customer/isAdult"), + Some(&json!(true)), + "expected customer.isAdult=true, got {result_json:#?}", + ); +} + +#[tokio::test] +async fn evaluate_with_single_import() { + let loader = Arc::new(MemoryLoader::default()); + loader.add("base", make_policy_content(simple_policy_json())); + loader.add( + "entry", + make_policy_content(importing_policy_json("base", "fromEntry")), + ); + let engine = engine_with(loader); + + let result = engine + .evaluate("entry", json!({ "customer": { "age": 70 } }).into()) + .await + .expect("evaluate ok"); + + let result_json: serde_json::Value = result.result.into(); + assert_eq!(result_json.pointer("/customer/isAdult"), Some(&json!(true))); + assert_eq!( + result_json.pointer("/customer/fromEntry"), + Some(&json!(true)) + ); +} + +#[tokio::test] +async fn evaluate_chained_imports() { + let loader = Arc::new(MemoryLoader::default()); + loader.add("base", make_policy_content(simple_policy_json())); + loader.add( + "middle", + make_policy_content(importing_policy_json("base", "fromMiddle")), + ); + loader.add( + "entry", + make_policy_content(importing_policy_json("middle", "fromEntry")), + ); + let engine = engine_with(loader); + + let result = engine + .evaluate("entry", json!({ "customer": { "age": 80 } }).into()) + .await + .expect("evaluate ok"); + + let result_json: serde_json::Value = result.result.into(); + assert_eq!(result_json.pointer("/customer/isAdult"), Some(&json!(true))); + assert_eq!( + result_json.pointer("/customer/fromMiddle"), + Some(&json!(true)) + ); + assert_eq!( + result_json.pointer("/customer/fromEntry"), + Some(&json!(true)) + ); +} + +#[tokio::test] +async fn cycle_terminates() { + let loader = Arc::new(MemoryLoader::default()); + loader.add("a", make_policy_content(importing_policy_json("b", "aOut"))); + loader.add("b", make_policy_content(importing_policy_json("a", "bOut"))); + let engine = engine_with(loader); + + let result = engine + .evaluate("a", json!({ "customer": { "age": 25 } }).into()) + .await; + let _ = result; +} + +#[tokio::test] +async fn self_import_terminates() { + let loader = Arc::new(MemoryLoader::default()); + loader.add( + "a", + make_policy_content(importing_policy_json("a", "selfOut")), + ); + let engine = engine_with(loader); + + let result = engine + .evaluate("a", json!({ "customer": { "age": 25 } }).into()) + .await; + let _ = result; +} + +#[tokio::test] +async fn policy_importing_graph_fails() { + let loader = Arc::new(MemoryLoader::default()); + loader.add( + "entry", + make_policy_content(importing_policy_json("g", "fromEntry")), + ); + loader.add("g", load_test_data("table.json")); + let engine = engine_with(loader); + + let err = engine + .evaluate("entry", json!({ "customer": { "age": 25 } }).into()) + .await + .expect_err("policy importing a graph should fail"); + + match *err { + EvaluationError::ContentKindMismatch { expected, got, .. } => { + assert_eq!(expected, "policy"); + assert_eq!(got, "graph"); + } + other => panic!("expected ContentKindMismatch, got: {other:?}"), + } +} + +#[tokio::test] +async fn missing_import_is_loader_error() { + let loader = Arc::new(MemoryLoader::default()); + loader.add( + "entry", + make_policy_content(importing_policy_json("does-not-exist", "fromEntry")), + ); + let engine = engine_with(loader); + + let err = engine + .evaluate("entry", json!({ "customer": { "age": 25 } }).into()) + .await + .expect_err("missing import should surface loader error"); + + assert!( + matches!(*err, EvaluationError::LoaderError(_)), + "expected LoaderError, got: {err:?}" + ); +} + +fn assert_string_trace(value: serde_json::Value) { + let trace = value.pointer("/trace").expect("trace key present"); + let trace_str = trace.as_str().expect("trace must serialize as a string"); + let parsed: serde_json::Value = serde_json::from_str(trace_str).expect("trace string is JSON"); + assert!(parsed.pointer("/properties").is_some()); + assert!(parsed.pointer("/executions").is_some()); +} + +#[tokio::test] +async fn serialized_string_trace_for_policies() { + let loader = Arc::new(MemoryLoader::default()); + loader.add("policy", make_policy_content(simple_policy_json())); + let engine = engine_with(loader); + + let options = EvaluationSerializedOptions { + trace: EvaluationTraceKind::String, + ..Default::default() + }; + let lazy = engine + .evaluate_serialized( + "policy", + json!({ "customer": { "age": 30 } }).into(), + options, + ) + .await + .expect("evaluate ok"); + assert_string_trace(lazy); + + engine.compile(); + let precompiled = engine + .evaluate_serialized( + "policy", + json!({ "customer": { "age": 30 } }).into(), + options, + ) + .await + .expect("evaluate ok"); + assert_string_trace(precompiled); +} + +#[tokio::test] +async fn diamond_imports_load_each_key_once() { + let mut contents: HashMap> = HashMap::new(); + contents.insert( + "entry".into(), + Arc::new(make_policy_content(json!({ + "imports": ["a", "b"], + "blocks": [ + { + "id": "assert-entry", + "type": "assertion", + "props": { + "data": { + "output": "customer.fromEntry", + "conditions": [ + { "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 } + ] + } + } + } + ] + }))), + ); + contents.insert( + "a".into(), + Arc::new(make_policy_content(importing_policy_json("base", "fromA"))), + ); + contents.insert( + "b".into(), + Arc::new(make_policy_content(importing_policy_json("base", "fromB"))), + ); + contents.insert( + "base".into(), + Arc::new(make_policy_content(simple_policy_json())), + ); + + let counts: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let counts_in_loader = counts.clone(); + let engine = DecisionEngine::default().with_closure_loader(move |key| { + let counts = counts_in_loader.clone(); + let content = contents.get(&key).cloned(); + async move { + *counts.lock().unwrap().entry(key.clone()).or_insert(0) += 1; + content.ok_or_else(|| LoaderError::NotFound(key)) + } + }); + + engine + .evaluate("entry", json!({ "customer": { "age": 30 } }).into()) + .await + .expect("evaluate ok"); + + let counts = counts.lock().unwrap(); + for key in ["entry", "a", "b", "base"] { + assert_eq!( + counts.get(key), + Some(&1), + "key {key} should load exactly once, got {counts:?}" + ); + } +} + +#[tokio::test] +async fn trace_serializes_for_policies() { + let loader = Arc::new(MemoryLoader::default()); + loader.add("policy", make_policy_content(simple_policy_json())); + let engine = engine_with(loader); + + let result = engine + .evaluate_with_opts( + "policy", + json!({ "customer": { "age": 30 } }).into(), + EvaluationOptions { + trace: true, + ..Default::default() + }, + ) + .await + .expect("evaluate ok"); + + assert!(result.trace.is_some(), "trace requested but None returned"); + let serialized = serde_json::to_value(&result).expect("serialize ok"); + let trace = serialized.pointer("/trace").expect("trace key present"); + assert!( + trace.pointer("/properties").is_some(), + "policy trace should serialize with `properties` field" + ); + assert!( + trace.pointer("/executions").is_some(), + "policy trace should serialize with `executions` field" + ); +} diff --git a/core/engine/tests/policy_table.rs b/core/engine/tests/policy_table.rs new file mode 100644 index 00000000..93d8d940 --- /dev/null +++ b/core/engine/tests/policy_table.rs @@ -0,0 +1,592 @@ +use serde_json::json; +use std::sync::Arc; +use zen_engine::policy::{ + EngineEdit, EvaluateRequest, PolicyWorkspace, RenameTarget, ScopeRequest, Severity, +}; +use zen_expression::variable::{Variable, VariableType}; + +fn workspace_with(doc: serde_json::Value) -> PolicyWorkspace { + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + ws +} + +fn request(input: serde_json::Value, trace: bool) -> EvaluateRequest { + EvaluateRequest { + policy_path: Arc::from("p"), + input: Variable::from(input), + goals: Vec::new(), + trace, + } +} + +fn evaluate_output(ws: &PolicyWorkspace, input: serde_json::Value) -> serde_json::Value { + let result = ws.evaluate(&request(input, false)).expect("evaluate"); + result.output.into() +} + +fn output_type(ws: &PolicyWorkspace, path: &str) -> VariableType { + ws.outputs(&ScopeRequest::for_policy("p")) + .into_iter() + .find(|o| o.path.as_ref() == path) + .unwrap_or_else(|| panic!("output {path} not registered")) + .resolved_type +} + +fn error_messages(ws: &PolicyWorkspace) -> Vec { + ws.diagnostics("p") + .into_iter() + .filter(|d| d.severity == Severity::Error) + .map(|d| d.message.to_string()) + .collect() +} + +fn order_dm() -> serde_json::Value { + json!({ "id": "dm", "type": "dataModel", "props": { "data": { + "name": "order", + "properties": [ + { "id": "p1", "name": "amount", "type": "number", "array": false, "optional": false }, + { "id": "p2", "name": "region", "type": "string", "enum": ["US", "EU"], "array": false, "optional": false }, + { "id": "p3", "name": "express", "type": "boolean", "array": false, "optional": false } + ] + } } }) +} + +fn strict_table_doc() -> serde_json::Value { + json!({ + "blocks": [ + order_dm(), + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ + { "id": "i1", "name": "", "field": "order.amount" }, + { "id": "i2", "name": "", "field": "order.region" } + ], + "outputs": [ { "id": "o1", "name": "", "field": "order.shippingCost" } ], + "rules": [ + { "i1": ">= 100", "i2": "\"US\"", "o1": "0" }, + { "i1": ">= 100", "i2": "\"EU\"", "o1": "5" } + ] + } } } + ] + }) +} + +#[test] +fn no_match_emits_null_for_scalar_output() { + let ws = workspace_with(strict_table_doc()); + let output = evaluate_output(&ws, json!({ "order": { "amount": 50, "region": "US" } })); + assert_eq!( + output.pointer("/order/shippingCost"), + Some(&serde_json::Value::Null), + "no-match scalar column must write an explicit null; got {output:?}" + ); +} + +#[test] +fn no_match_trace_has_no_matched_rows() { + let ws = workspace_with(strict_table_doc()); + let result = ws + .evaluate(&request( + json!({ "order": { "amount": 50, "region": "US" } }), + true, + )) + .expect("evaluate"); + let trace = result.trace.expect("trace"); + let dt = trace + .executions + .iter() + .find(|e| e.block_id.as_ref() == "dt") + .expect("dt execution"); + let trace_json = serde_json::to_value(&dt.trace).unwrap(); + assert_eq!(trace_json["matchedRows"], json!([])); +} + +#[test] +fn uncovered_scalar_output_is_nullable() { + let ws = workspace_with(strict_table_doc()); + assert!(error_messages(&ws).is_empty()); + assert!( + matches!( + output_type(&ws, "order.shippingCost"), + VariableType::Nullable(_) + ), + "no catch-all and partial coverage must produce a nullable output" + ); +} + +#[test] +fn catch_all_row_makes_scalar_output_non_nullable() { + let mut doc = strict_table_doc(); + doc["blocks"][1]["props"]["data"]["rules"] + .as_array_mut() + .unwrap() + .push(json!({ "i1": "", "i2": "", "o1": "20" })); + let ws = workspace_with(doc); + assert!(error_messages(&ws).is_empty()); + assert!( + matches!(output_type(&ws, "order.shippingCost"), VariableType::Number), + "catch-all row must prove coverage" + ); +} + +#[test] +fn enum_union_across_rows_covers() { + let doc = json!({ + "blocks": [ + order_dm(), + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ { "id": "i1", "name": "", "field": "order.region" } ], + "outputs": [ { "id": "o1", "name": "", "field": "order.zone" } ], + "rules": [ + { "i1": "\"US\"", "o1": "1" }, + { "i1": "\"EU\"", "o1": "2" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + assert!(error_messages(&ws).is_empty()); + assert!(matches!( + output_type(&ws, "order.zone"), + VariableType::Number + )); +} + +#[test] +fn partial_enum_union_stays_nullable() { + let doc = json!({ + "blocks": [ + order_dm(), + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ { "id": "i1", "name": "", "field": "order.region" } ], + "outputs": [ { "id": "o1", "name": "", "field": "order.zone" } ], + "rules": [ { "i1": "\"US\"", "o1": "1" } ] + } } } + ] + }); + let ws = workspace_with(doc); + assert!(matches!( + output_type(&ws, "order.zone"), + VariableType::Nullable(_) + )); +} + +#[test] +fn number_tiling_covers_and_gap_does_not() { + let tiled = json!({ + "blocks": [ + order_dm(), + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], + "outputs": [ { "id": "o1", "name": "", "field": "order.band" } ], + "rules": [ + { "i1": "< 100", "o1": "\"low\"" }, + { "i1": "[100..500]", "o1": "\"mid\"" }, + { "i1": "> 500", "o1": "\"high\"" } + ] + } } } + ] + }); + let ws = workspace_with(tiled); + assert!( + !matches!(output_type(&ws, "order.band"), VariableType::Nullable(_)), + "tiled number coverage must be non-nullable" + ); + + let gapped = json!({ + "blocks": [ + order_dm(), + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], + "outputs": [ { "id": "o1", "name": "", "field": "order.band" } ], + "rules": [ + { "i1": "< 100", "o1": "\"low\"" }, + { "i1": "> 500", "o1": "\"high\"" } + ] + } } } + ] + }); + let ws = workspace_with(gapped); + assert!(matches!( + output_type(&ws, "order.band"), + VariableType::Nullable(_) + )); +} + +#[test] +fn bool_both_values_cover() { + let doc = json!({ + "blocks": [ + order_dm(), + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ { "id": "i1", "name": "", "field": "order.express" } ], + "outputs": [ { "id": "o1", "name": "", "field": "order.fee" } ], + "rules": [ + { "i1": "true", "o1": "10" }, + { "i1": "false", "o1": "0" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + assert!(matches!( + output_type(&ws, "order.fee"), + VariableType::Number + )); +} + +#[test] +fn multi_column_rows_do_not_prove_coverage() { + let doc = json!({ + "blocks": [ + order_dm(), + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ + { "id": "i1", "name": "", "field": "order.region" }, + { "id": "i2", "name": "", "field": "order.amount" } + ], + "outputs": [ { "id": "o1", "name": "", "field": "order.zone" } ], + "rules": [ + { "i1": "\"US\"", "i2": "> 0", "o1": "1" }, + { "i1": "\"EU\"", "i2": "> 0", "o1": "2" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + assert!(matches!( + output_type(&ws, "order.zone"), + VariableType::Nullable(_) + )); +} + +#[test] +fn optional_input_field_is_not_covered_without_catch_all() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "order", + "properties": [ + { "id": "p2", "name": "region", "type": "string", "enum": ["US", "EU"], "array": false, "optional": true } + ] + } } }, + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ { "id": "i1", "name": "", "field": "order.region" } ], + "outputs": [ { "id": "o1", "name": "", "field": "order.zone" } ], + "rules": [ + { "i1": "\"US\"", "o1": "1" }, + { "i1": "\"EU\"", "o1": "2" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + assert!(matches!( + output_type(&ws, "order.zone"), + VariableType::Nullable(_) + )); +} + +fn mixed_table_doc() -> serde_json::Value { + json!({ + "blocks": [ + order_dm(), + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], + "outputs": [ + { "id": "o1", "name": "", "field": "order.tags[]" }, + { "id": "o2", "name": "", "field": "order.tier" } + ], + "rules": [ + { "i1": "> 1000", "o1": "\"vip\"", "o2": "" }, + { "i1": "> 100", "o1": "\"bulk\"", "o2": "\"gold\"" }, + { "i1": "> 10", "o1": "", "o2": "\"silver\"" }, + { "i1": "", "o1": "\"standard\"", "o2": "\"bronze\"" } + ] + } } } + ] + }) +} + +#[test] +fn mixed_table_collects_tags_and_first_matches_tier() { + let ws = workspace_with(mixed_table_doc()); + assert!(error_messages(&ws).is_empty(), "{:?}", error_messages(&ws)); + + let output = evaluate_output( + &ws, + json!({ "order": { "amount": 5000, "region": "US", "express": false } }), + ); + assert_eq!( + output.pointer("/order/tags"), + Some(&json!(["vip", "bulk", "standard"])) + ); + assert_eq!(output.pointer("/order/tier"), Some(&json!("gold"))); + + let output = evaluate_output( + &ws, + json!({ "order": { "amount": 50, "region": "US", "express": false } }), + ); + assert_eq!(output.pointer("/order/tags"), Some(&json!(["standard"]))); + assert_eq!(output.pointer("/order/tier"), Some(&json!("silver"))); +} + +#[test] +fn scalar_falls_through_rows_with_empty_cell() { + let ws = workspace_with(mixed_table_doc()); + let output = evaluate_output( + &ws, + json!({ "order": { "amount": 5000, "region": "US", "express": false } }), + ); + assert_eq!( + output.pointer("/order/tier"), + Some(&json!("gold")), + "row 0 matches with an empty tier cell; tier must fall through to row 1" + ); +} + +#[test] +fn mixed_table_types_are_array_and_covered_scalar() { + let ws = workspace_with(mixed_table_doc()); + let tags = output_type(&ws, "order.tags"); + assert!( + matches!(&tags, VariableType::Array(inner) if !matches!(inner.as_ref(), VariableType::Nullable(_))), + "collect column must type as a clean array, got {tags}" + ); + let tier = output_type(&ws, "order.tier"); + assert!( + !matches!(tier, VariableType::Nullable(_)), + "catch-all row provides tier coverage, got {tier}" + ); + assert_eq!(format!("{tier}"), "\"gold\" | \"silver\" | \"bronze\""); +} + +#[test] +fn no_match_collect_emits_empty_array() { + let doc = json!({ + "blocks": [ + order_dm(), + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], + "outputs": [ { "id": "o1", "name": "", "field": "order.tags[]" } ], + "rules": [ { "i1": "> 1000", "o1": "\"vip\"" } ] + } } } + ] + }); + let ws = workspace_with(doc); + let output = evaluate_output( + &ws, + json!({ "order": { "amount": 5, "region": "US", "express": false } }), + ); + assert_eq!(output.pointer("/order/tags"), Some(&json!([]))); +} + +#[test] +fn legacy_collect_hit_policy_marks_all_columns() { + let doc = json!({ + "blocks": [ + order_dm(), + { "id": "dt", "type": "decisionTable", "props": { "data": { + "hitPolicy": "collect", + "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], + "outputs": [ { "id": "o1", "name": "", "field": "order.tags" } ], + "rules": [ + { "i1": "> 10", "o1": "\"a\"" }, + { "i1": "> 100", "o1": "\"b\"" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + assert!(matches!( + output_type(&ws, "order.tags"), + VariableType::Array(_) + )); + let output = evaluate_output( + &ws, + json!({ "order": { "amount": 500, "region": "US", "express": false } }), + ); + assert_eq!(output.pointer("/order/tags"), Some(&json!(["a", "b"]))); +} + +#[test] +fn collect_marker_mid_path_errors() { + let doc = json!({ + "blocks": [ + order_dm(), + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], + "outputs": [ { "id": "o1", "name": "", "field": "order.tags[].x" } ], + "rules": [ { "i1": "", "o1": "\"a\"" } ] + } } } + ] + }); + let ws = workspace_with(doc); + let errors = error_messages(&ws); + assert!( + errors.iter().any(|m| m.contains("[]")), + "mid-path [] must raise InvalidWritePath, got {errors:?}" + ); +} + +#[test] +fn bare_collect_marker_errors() { + let doc = json!({ + "blocks": [ + order_dm(), + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], + "outputs": [ { "id": "o1", "name": "", "field": "[]" } ], + "rules": [ { "i1": "", "o1": "\"a\"" } ] + } } } + ] + }); + let ws = workspace_with(doc); + let errors = error_messages(&ws); + assert!( + !errors.is_empty(), + "bare [] field must raise InvalidWritePath" + ); +} + +#[test] +fn rename_rewrites_collect_field_keeping_marker() { + let ws = workspace_with(mixed_table_doc()); + let edits = ws.rename( + &RenameTarget::Field { + entity: Arc::from("order"), + field: Arc::from("tags"), + }, + "labels", + ); + let rewritten = edits + .iter() + .find_map(|e| match e { + EngineEdit::ReplaceBlock { new_block, .. } => { + Some(serde_json::to_string(new_block).unwrap()) + } + _ => None, + }) + .expect("rename must rewrite the table block"); + assert!( + rewritten.contains("order.labels[]"), + "collect marker must survive rename, got {rewritten}" + ); +} + +#[test] +fn unused_scalar_cells_stay_lazy() { + let doc = json!({ + "blocks": [ + order_dm(), + { "id": "e-gold", "type": "expression", "props": { "data": { + "key": "order.goldLabel", + "value": "\"gold\"" + } } }, + { "id": "e-silver", "type": "expression", "props": { "data": { + "key": "order.silverLabel", + "value": "\"silver\"" + } } }, + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ { "id": "i1", "name": "", "field": "order.amount" } ], + "outputs": [ + { "id": "o1", "name": "", "field": "order.tags[]" }, + { "id": "o2", "name": "", "field": "order.tier" } + ], + "rules": [ + { "i1": "> 100", "o1": "\"bulk\"", "o2": "order.goldLabel" }, + { "i1": "", "o1": "\"standard\"", "o2": "order.silverLabel" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + let result = ws + .evaluate(&request( + json!({ "order": { "amount": 500, "region": "US", "express": false } }), + true, + )) + .expect("evaluate"); + let executed: Vec = result + .trace + .expect("trace") + .executions + .iter() + .map(|e| e.block_id.to_string()) + .collect(); + assert!( + executed.contains(&"e-gold".to_string()), + "resolving row's tier dependency must run: {executed:?}" + ); + assert!( + !executed.contains(&"e-silver".to_string()), + "non-resolving row's tier dependency must stay lazy: {executed:?}" + ); + + let output: serde_json::Value = result.output.into(); + assert_eq!(output.pointer("/order/tier"), Some(&json!("gold"))); + assert_eq!( + output.pointer("/order/tags"), + Some(&json!(["bulk", "standard"])) + ); +} + +#[test] +fn equality_index_matches_linear_semantics() { + let doc = json!({ + "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { + "name": "order", + "properties": [ + { "id": "p1", "name": "amount", "type": "number", "array": false, "optional": false }, + { "id": "p2", "name": "region", "type": "string", "array": false, "optional": false }, + { "id": "p3", "name": "express", "type": "boolean", "array": false, "optional": false } + ] + } } }, + { "id": "dt", "type": "decisionTable", "props": { "data": { + "inputs": [ + { "id": "i1", "name": "", "field": "order.region" }, + { "id": "i2", "name": "", "field": "order.amount" }, + { "id": "i3", "name": "", "field": "order.express" } + ], + "outputs": [ { "id": "o1", "name": "", "field": "order.bucket" } ], + "rules": [ + { "i1": "\"US\"", "i2": "1", "i3": "true", "o1": "\"r0\"" }, + { "i1": "\"US\", \"EU\"", "i2": "2", "i3": "", "o1": "\"r1\"" }, + { "i1": "in [\"EU\"]", "i2": "in [3, 4]", "i3": "false", "o1": "\"r2\"" }, + { "i1": "(\"US\")", "i2": "[10..20]", "i3": "", "o1": "\"r3\"" }, + { "i1": "", "i2": "100", "i3": "", "o1": "\"r4\"" }, + { "i1": "\"AP\"", "i2": "", "i3": "", "o1": "\"r5\"" }, + { "i1": "startsWith($, \"E\")", "i2": "777", "i3": "", "o1": "\"r6\"" }, + { "i1": "\"US\"", "i2": "> 1000", "i3": "", "o1": "\"r7\"" }, + { "i1": "\"EU\"", "i2": "2.50", "i3": "", "o1": "\"r8\"" }, + { "i1": "", "i2": "", "i3": "", "o1": "\"r9\"" } + ] + } } } + ] + }); + let ws = workspace_with(doc); + let bucket_for = + |region: &str, amount: serde_json::Value, express: bool| -> serde_json::Value { + evaluate_output( + &ws, + json!({ "order": { "region": region, "amount": amount, "express": express } }), + ) + .pointer("/order/bucket") + .cloned() + .unwrap_or(serde_json::Value::Null) + }; + + assert_eq!(bucket_for("US", json!(1), true), json!("r0")); + assert_eq!(bucket_for("US", json!(1), false), json!("r9")); + assert_eq!(bucket_for("EU", json!(2), false), json!("r1")); + assert_eq!(bucket_for("EU", json!(3), false), json!("r2")); + assert_eq!(bucket_for("EU", json!(4), true), json!("r9")); + assert_eq!(bucket_for("US", json!(15), false), json!("r3")); + assert_eq!(bucket_for("AP", json!(100), false), json!("r4")); + assert_eq!(bucket_for("AP", json!(7), false), json!("r5")); + assert_eq!(bucket_for("EU", json!(777), false), json!("r6")); + assert_eq!(bucket_for("US", json!(5000), false), json!("r7")); + assert_eq!(bucket_for("EU", json!(2.5), false), json!("r8")); + assert_eq!(bucket_for("US", json!(999), false), json!("r9")); +} diff --git a/core/engine/tests/policy_toml.rs b/core/engine/tests/policy_toml.rs new file mode 100644 index 00000000..c91d194d --- /dev/null +++ b/core/engine/tests/policy_toml.rs @@ -0,0 +1,610 @@ +use serde::Deserialize; +use std::collections::BTreeMap; +use std::sync::Arc; +use zen_engine::policy::{ + Cursor, CursorTarget, EvaluateRequest, PolicyWorkspace, ReferenceKind, RenameTarget, + ScopeRequest, +}; +use zen_expression::variable::Variable; + +const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/policy/fixtures/"); + +fn build_workspace(policies: &[String]) -> PolicyWorkspace { + let mut ws = PolicyWorkspace::new(); + for path in policies { + let raw = std::fs::read_to_string(format!("{FIXTURES_DIR}{path}")) + .unwrap_or_else(|e| panic!("cannot read fixture {path}: {e}")); + let doc = serde_json::from_str(&raw) + .unwrap_or_else(|e| panic!("cannot deserialize fixture {path}: {e}")); + ws.set_policy(path.as_str(), doc); + } + ws +} + +fn toml_to_json(value: &toml::Value) -> serde_json::Value { + serde_json::to_value(value).expect("toml converts to json") +} + +fn numbers_equal(a: &serde_json::Number, b: &serde_json::Number) -> bool { + match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() <= f64::EPSILON * x.abs().max(y.abs()).max(1.0), + _ => a == b, + } +} + +fn assert_subset(expected: &serde_json::Value, actual: Option<&serde_json::Value>, ctx: &str) { + use serde_json::Value; + let Some(actual) = actual else { + panic!("{ctx}: expected {expected}, but key is absent"); + }; + match (expected, actual) { + (Value::Object(exp), Value::Object(act)) => { + for (key, val) in exp { + assert_subset(val, act.get(key), &format!("{ctx}.{key}")); + } + } + (Value::Array(exp), Value::Array(act)) => { + assert_eq!( + exp.len(), + act.len(), + "{ctx}: expected {} elements, got {}: {actual}", + exp.len(), + act.len() + ); + for (idx, val) in exp.iter().enumerate() { + assert_subset(val, act.get(idx), &format!("{ctx}[{idx}]")); + } + } + (Value::Number(exp), Value::Number(act)) => { + assert!( + numbers_equal(exp, act), + "{ctx}: expected {expected}, got {actual}" + ); + } + _ => assert_eq!(expected, actual, "{ctx}: expected {expected}, got {actual}"), + } +} + +fn subset_matches(expected: &serde_json::Value, actual: Option<&serde_json::Value>) -> bool { + use serde_json::Value; + let Some(actual) = actual else { return false }; + match (expected, actual) { + (Value::Object(exp), Value::Object(act)) => exp + .iter() + .all(|(key, val)| subset_matches(val, act.get(key))), + (Value::Array(exp), Value::Array(act)) => { + exp.len() == act.len() && exp.iter().zip(act).all(|(e, a)| subset_matches(e, Some(a))) + } + (Value::Number(exp), Value::Number(act)) => numbers_equal(exp, act), + _ => expected == actual, + } +} + +#[derive(Debug, Deserialize)] +struct EvaluationFile { + test: Vec, +} + +#[derive(Debug, Deserialize)] +struct EvaluationCase { + name: String, + policies: Vec, + input: toml::Value, + output: toml::Value, + #[serde(default)] + nulls: Vec, + trace: Option, +} + +#[derive(Debug, Deserialize)] +struct TraceExpectation { + blocks: BTreeMap, +} + +fn normalize_block_expectation(value: &toml::Value) -> serde_json::Value { + let mut json = toml_to_json(value); + if let Some(obj) = json.as_object_mut() { + if let Some(rows) = obj.remove("matched_rows") { + obj.insert("matchedRows".into(), rows); + } + } + json +} + +fn run_evaluation(file_name: &str, toml_data: &str) { + let file: EvaluationFile = + toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}")); + + for test in &file.test { + let ctx = format!("[{file_name}:{}]", test.name); + let ws = build_workspace(&test.policies); + let req = EvaluateRequest { + policy_path: Arc::from(test.policies[0].as_str()), + input: Variable::from(toml_to_json(&test.input)), + goals: Vec::new(), + trace: test.trace.is_some(), + }; + let result = ws + .evaluate(&req) + .unwrap_or_else(|e| panic!("{ctx} evaluation failed: {e:?}")); + + let output_json: serde_json::Value = result.output.into(); + assert_subset(&toml_to_json(&test.output), Some(&output_json), &ctx); + for path in &test.nulls { + let pointer = format!("/{}", path.replace('.', "/")); + assert_eq!( + output_json.pointer(&pointer), + Some(&serde_json::Value::Null), + "{ctx} expected explicit null at '{path}'; got {output_json}" + ); + } + + let Some(expected_trace) = &test.trace else { + continue; + }; + let trace = result + .trace + .unwrap_or_else(|| panic!("{ctx} expected trace but got none")); + for (block_id, expectation) in &expected_trace.blocks { + let expected = normalize_block_expectation(expectation); + let candidates: Vec = trace + .executions + .iter() + .filter(|e| e.block_id.as_ref() == block_id.as_str()) + .map(|e| serde_json::to_value(&e.trace).expect("trace serializes")) + .collect(); + assert!( + !candidates.is_empty(), + "{ctx} block '{block_id}' not found in trace; executed: {:?}", + trace + .executions + .iter() + .map(|e| e.block_id.as_ref()) + .collect::>() + ); + assert!( + candidates + .iter() + .any(|actual| subset_matches(&expected, Some(actual))), + "{ctx} block '{block_id}': no execution matches {expected}\n got: {candidates:#?}" + ); + } + } +} + +#[derive(Debug, Deserialize)] +struct RenameFile { + policies: Vec, + test: Vec, +} + +#[derive(Debug, Deserialize)] +struct RenameCase { + name: String, + entity: String, + field: String, + new_name: String, + edits: Vec, +} + +#[derive(Debug, Deserialize)] +struct EditExpectation { + policy: String, + block_id: String, + expression_id: Option, + target_kind: Option, +} + +fn rename_target(entity: &str, field: &str) -> RenameTarget { + if field.is_empty() { + RenameTarget::Entity { + name: Arc::from(entity), + } + } else { + RenameTarget::Field { + entity: Arc::from(entity), + field: Arc::from(field), + } + } +} + +fn run_rename(file_name: &str, toml_data: &str) { + let file: RenameFile = + toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}")); + let ws = build_workspace(&file.policies); + + for test in &file.test { + let ctx = format!("[{file_name}:{}]", test.name); + let target = rename_target(&test.entity, &test.field); + + let sites = ws.references(&target); + let mut unmatched: Vec = (0..sites.len()).collect(); + for expected in &test.edits { + let pos = unmatched.iter().position(|&i| { + let site = &sites[i]; + if site.policy_path.as_ref() != expected.policy + || site.block_id.as_ref() != expected.block_id + { + return false; + } + match (&expected.expression_id, &expected.target_kind) { + (Some(id), _) => { + site.expression_id.as_deref() == Some(id.as_str()) + && site.kind != ReferenceKind::DataModel + } + (None, Some(_)) => site.kind == ReferenceKind::DataModel, + (None, None) => { + site.expression_id.is_none() && site.kind != ReferenceKind::DataModel + } + } + }); + match pos { + Some(idx_pos) => { + unmatched.remove(idx_pos); + } + None => panic!( + "{ctx} expected site {expected:?} not found.\n got: {:#?}", + sites + ), + } + } + assert!( + unmatched.is_empty(), + "{ctx} unexpected extra sites: {:#?}", + unmatched.iter().map(|&i| &sites[i]).collect::>() + ); + + let edits = ws.rename(&target, &test.new_name); + if test.edits.is_empty() { + assert!(edits.is_empty(), "{ctx} expected no edits, got {edits:?}"); + } else { + let serialized = serde_json::to_string(&edits).expect("edits serialize"); + assert!( + serialized.contains(&test.new_name), + "{ctx} rename edits must contain '{}': {serialized}", + test.new_name + ); + } + } +} + +#[derive(Debug, Deserialize)] +struct PrepareRenameFile { + policies: Vec, + test: Vec, +} + +#[derive(Debug, Deserialize)] +struct PrepareRenameCase { + name: String, + policy: String, + block_id: String, + expression_id: String, + pos: u32, + expected_entity: Option, + expected_field: Option, +} + +fn run_prepare_rename(file_name: &str, toml_data: &str) { + let file: PrepareRenameFile = + toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}")); + let ws = build_workspace(&file.policies); + + for test in &file.test { + let ctx = format!("[{file_name}:{}]", test.name); + let result = ws.prepare_rename(&Cursor { + policy_path: Arc::from(test.policy.as_str()), + block_id: Arc::from(test.block_id.as_str()), + pos: test.pos, + target: CursorTarget::Expression { + id: Arc::from(test.expression_id.as_str()), + }, + }); + + match (&test.expected_entity, result) { + (None, None) => {} + (None, Some(r)) => panic!("{ctx} expected None, got {:?}", r.target), + (Some(_), None) => panic!("{ctx} expected Some, got None"), + (Some(entity), Some(r)) => { + let expected_field = test.expected_field.as_deref().unwrap_or(""); + let expected = rename_target(entity, expected_field); + assert_eq!(r.target, expected, "{ctx} target mismatch"); + } + } + } +} + +#[derive(Debug, Deserialize)] +struct CompletionsFile { + policies: Vec, + test: Vec, +} + +#[derive(Debug, Deserialize)] +struct CompletionsCase { + name: String, + policy: String, + block_id: String, + expression_id: String, + pos: u32, + #[serde(default)] + head: bool, + row: Option, + #[serde(default)] + includes: Vec, + #[serde(default)] + excludes: Vec, +} + +fn run_completions(file_name: &str, toml_data: &str) { + let file: CompletionsFile = + toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}")); + let ws = build_workspace(&file.policies); + + for test in &file.test { + let ctx = format!("[{file_name}:{}]", test.name); + let target = if test.head { + CursorTarget::DecisionTableHead { + col: Arc::from(test.expression_id.as_str()), + } + } else if let Some(row) = &test.row { + CursorTarget::DecisionTableCell { + row: Arc::from(row.as_str()), + col: Arc::from(test.expression_id.as_str()), + } + } else { + CursorTarget::Expression { + id: Arc::from(test.expression_id.as_str()), + } + }; + let completions = ws.completions(&Cursor { + policy_path: Arc::from(test.policy.as_str()), + block_id: Arc::from(test.block_id.as_str()), + pos: test.pos, + target, + }); + let labels: Vec<&str> = completions.iter().map(|c| c.label.as_ref()).collect(); + + for inc in &test.includes { + assert!( + labels.contains(&inc.as_str()), + "{ctx} expected completion '{inc}' missing.\n got: {labels:?}" + ); + } + for exc in &test.excludes { + assert!( + !labels.contains(&exc.as_str()), + "{ctx} unexpected completion '{exc}'.\n got: {labels:?}" + ); + } + } +} + +#[derive(Debug, Deserialize)] +struct EntitiesFile { + policies: Vec, + test: Vec, +} + +#[derive(Debug, Deserialize)] +struct EntitiesCase { + name: String, + policy: String, + entity_count: Option, + entity: Option, + field: Option, + field_kind: Option, + instance_of: Option, + #[serde(default)] + no_instance_of: bool, + property_kind: Option, + source: Option, + source_is_local: Option, + #[serde(default)] + no_duplicate_fields: bool, + #[serde(default)] + absent_fields: Vec, +} + +fn run_entities(file_name: &str, toml_data: &str) { + let file: EntitiesFile = + toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}")); + let ws = build_workspace(&file.policies); + + for test in &file.test { + let ctx = format!("[{file_name}:{}]", test.name); + let entities = ws.entities(&ScopeRequest::for_policy(test.policy.as_str())); + + if let Some(count) = test.entity_count { + assert_eq!( + entities.len(), + count, + "{ctx} entity count mismatch; got {:?}", + entities.iter().map(|e| e.name.as_ref()).collect::>() + ); + } + let Some(entity) = &test.entity else { + continue; + }; + let entity_obj = entities + .iter() + .find(|e| e.name.as_ref() == entity.as_str()) + .unwrap_or_else(|| panic!("{ctx} entity '{entity}' not found")); + + if test.no_duplicate_fields { + let mut names: Vec<&str> = entity_obj.fields.iter().map(|f| f.name.as_ref()).collect(); + let total = names.len(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), total, "{ctx} duplicate fields present"); + } + for absent in &test.absent_fields { + assert!( + !entity_obj + .fields + .iter() + .any(|f| f.name.as_ref() == absent.as_str()), + "{ctx} field '{absent}' should be absent" + ); + } + let Some(field) = &test.field else { + continue; + }; + let field_obj = entity_obj + .fields + .iter() + .find(|f| f.name.as_ref() == field.as_str()) + .unwrap_or_else(|| { + panic!( + "{ctx} field '{field}' not found; got {:?}", + entity_obj + .fields + .iter() + .map(|f| f.name.as_ref()) + .collect::>() + ) + }); + + let origin_json = serde_json::to_value(&field_obj.origin).expect("origin serializes"); + if let Some(kind) = &test.property_kind { + let actual = match origin_json["origin"].as_str() { + Some("schema") => "input", + Some("computed") => "computed", + other => panic!("{ctx} unexpected origin {other:?}"), + }; + assert_eq!(actual, kind, "{ctx} property_kind mismatch: {origin_json}"); + } + if let Some(expected_kind) = &test.field_kind { + assert_eq!( + origin_json["origin"].as_str(), + Some("schema"), + "{ctx} field_kind asserts require a schema-origin field: {origin_json}" + ); + assert_subset( + &toml_to_json(expected_kind), + Some(&origin_json["fieldKind"]), + &format!("{ctx} field_kind"), + ); + } + if let Some(expected) = &test.instance_of { + assert_eq!( + origin_json["origin"].as_str(), + Some("computed"), + "{ctx} instance_of asserts require a computed field: {origin_json}" + ); + assert_subset( + &toml_to_json(expected), + Some(&origin_json["instanceOf"]), + &format!("{ctx} instance_of"), + ); + } + if test.no_instance_of { + assert!( + origin_json.get("instanceOf").is_none(), + "{ctx} expected no instanceOf: {origin_json}" + ); + } + + let source_policy = match origin_json["origin"].as_str() { + Some("schema") => origin_json["source"].as_str(), + Some("computed") => origin_json["writtenBy"]["policyPath"].as_str(), + _ => None, + }; + if let Some(expected) = &test.source { + assert_eq!( + source_policy, + Some(expected.as_str()), + "{ctx} source mismatch: {origin_json}" + ); + } + if let Some(local) = test.source_is_local { + assert_eq!( + source_policy == Some(test.policy.as_str()), + local, + "{ctx} source_is_local mismatch: {origin_json}" + ); + } + } +} + +#[test] +fn evaluation_toml_cases() { + run_evaluation( + "evaluation.toml", + include_str!("data/policy/evaluation.toml"), + ); +} + +#[test] +fn rename_toml_cases() { + run_rename("rename.toml", include_str!("data/policy/rename.toml")); +} + +#[test] +fn rename_multi_policy_toml_cases() { + run_rename( + "rename_multi_policy.toml", + include_str!("data/policy/rename_multi_policy.toml"), + ); +} + +#[test] +fn prepare_rename_toml_cases() { + run_prepare_rename( + "prepare_rename.toml", + include_str!("data/policy/prepare_rename.toml"), + ); +} + +#[test] +fn completions_toml_cases() { + run_completions( + "completions.toml", + include_str!("data/policy/completions.toml"), + ); +} + +#[test] +fn completions_scoping_toml_cases() { + run_completions( + "completions_scoping.toml", + include_str!("data/policy/completions_scoping.toml"), + ); +} + +#[test] +fn completions_multi_entity_toml_cases() { + run_completions( + "completions_multi_entity.toml", + include_str!("data/policy/completions_multi_entity.toml"), + ); +} + +#[test] +fn entities_toml_cases() { + run_entities("entities.toml", include_str!("data/policy/entities.toml")); +} + +#[test] +fn entities_filter_toml_cases() { + run_entities( + "entities_filter.toml", + include_str!("data/policy/entities_filter.toml"), + ); +} + +#[test] +fn entities_merge_toml_cases() { + run_entities( + "entities_merge.toml", + include_str!("data/policy/entities_merge.toml"), + ); +} + +#[test] +fn entities_merge_multi_policy_toml_cases() { + run_entities( + "entities_merge_multi_policy.toml", + include_str!("data/policy/entities_merge_multi_policy.toml"), + ); +} diff --git a/core/engine/tests/support/mod.rs b/core/engine/tests/support/mod.rs index 8e0ecb05..85a6a552 100644 --- a/core/engine/tests/support/mod.rs +++ b/core/engine/tests/support/mod.rs @@ -2,7 +2,7 @@ use std::fs::File; use std::io::BufReader; use std::path::Path; use zen_engine::loader::{FilesystemLoader, FilesystemLoaderOptions}; -use zen_engine::model::DecisionContent; +use zen_engine::model::{DecisionContent, GraphContent}; #[allow(dead_code)] pub fn test_data_root() -> String { @@ -23,14 +23,19 @@ pub fn load_raw_test_data(key: &str) -> BufReader { } #[allow(dead_code)] -pub fn load_test_data(key: &str) -> DecisionContent { - serde_json::from_reader(load_raw_test_data(key)).unwrap() +pub fn load_test_data(key: &str) -> GraphContent { + let content: DecisionContent = serde_json::from_reader(load_raw_test_data(key)).unwrap(); + match content { + DecisionContent::Graph(g) => g, + DecisionContent::Policy(_) => { + panic!("expected graph test fixture, got policy: {key}") + } + } } #[allow(dead_code)] pub fn create_fs_loader() -> FilesystemLoader { FilesystemLoader::new(FilesystemLoaderOptions { - keep_in_memory: false, root: test_data_root(), }) }