From fe8e6ed52ad51ba71cd5be41d7bc941b109306cd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:01:21 +0100 Subject: [PATCH 1/9] chore(deps): pin @exadev/eslint-config to 2.12.1 across the workspace Bumps the shared ESLint config from the ^2.10.2 range to an exact 2.12.1 in the root package.json and in trilean, trilean-regex, and trilean-sql, each pinned independently. 2.12.1 brings JSDoc/TSDoc comment-quality validation, a ban on Stryker-suppression comments, and an 800-real-line-per-file cap, none of which change any existing runtime behaviour on their own. --- package.json | 2 +- packages/trilean-regex/package.json | 2 +- packages/trilean-sql/package.json | 2 +- packages/trilean/package.json | 2 +- pnpm-lock.yaml | 323 +++++++++++++++++++++++++++- 5 files changed, 316 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 12a3052..bfe30da 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", "@eslint/js": "^10.0.1", - "@exadev/eslint-config": "^2.10.2", + "@exadev/eslint-config": "2.12.1", "@exadev/semantic-release-workspace": "^1.2.1", "@semantic-release/changelog": "^7.0.0", "@semantic-release/commit-analyzer": "^13.0.1", diff --git a/packages/trilean-regex/package.json b/packages/trilean-regex/package.json index bf47290..50cd287 100644 --- a/packages/trilean-regex/package.json +++ b/packages/trilean-regex/package.json @@ -74,7 +74,7 @@ ], "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", - "@exadev/eslint-config": "^2.10.2", + "@exadev/eslint-config": "2.12.1", "@types/node": "^26.4.0", "@vitest/coverage-v8": "^4.1.11", "eslint": "^10.9.1", diff --git a/packages/trilean-sql/package.json b/packages/trilean-sql/package.json index bcf210d..91ef2be 100644 --- a/packages/trilean-sql/package.json +++ b/packages/trilean-sql/package.json @@ -82,7 +82,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@electric-sql/pglite": "^0.5.8", - "@exadev/eslint-config": "^2.10.2", + "@exadev/eslint-config": "2.12.1", "@testcontainers/postgresql": "^12.1.0", "@types/better-sqlite3": "^9.6.0", "@types/node": "^26.4.0", diff --git a/packages/trilean/package.json b/packages/trilean/package.json index 69656e1..7a2b332 100644 --- a/packages/trilean/package.json +++ b/packages/trilean/package.json @@ -87,7 +87,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@cloudflare/vitest-pool-workers": "^0.22.0", - "@exadev/eslint-config": "^2.10.2", + "@exadev/eslint-config": "2.12.1", "@types/node": "^26.4.0", "@vitest/coverage-v8": "^4.1.11", "canonicalize": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f77ef7d..f2781b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.9.1(jiti@2.6.1)) '@exadev/eslint-config': - specifier: ^2.10.2 - version: 2.10.4(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3) + specifier: 2.12.1 + version: 2.12.1(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3) '@exadev/semantic-release-workspace': specifier: ^1.2.1 version: 1.2.1(@semantic-release/changelog@7.0.0(semantic-release@25.0.9(typescript@6.0.3)))(@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.9(typescript@6.0.3)))(@semantic-release/git@11.0.1(semantic-release@25.0.9(typescript@6.0.3)))(@semantic-release/github@12.0.9(semantic-release@25.0.9(typescript@6.0.3)))(@semantic-release/npm@13.1.5(semantic-release@25.0.9(typescript@6.0.3)))(@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.9(typescript@6.0.3)))(semantic-release@25.0.9(typescript@6.0.3))(typescript@6.0.3) @@ -100,8 +100,8 @@ importers: specifier: ^0.22.0 version: 0.22.0(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11) '@exadev/eslint-config': - specifier: ^2.10.2 - version: 2.10.4(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3) + specifier: 2.12.1 + version: 2.12.1(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3) '@types/node': specifier: ^26.4.0 version: 26.4.1 @@ -154,8 +154,8 @@ importers: specifier: ^0.18.5 version: 0.18.5 '@exadev/eslint-config': - specifier: ^2.10.2 - version: 2.10.4(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3) + specifier: 2.12.1 + version: 2.12.1(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3) '@types/node': specifier: ^26.4.0 version: 26.4.1 @@ -212,8 +212,8 @@ importers: specifier: ^0.5.8 version: 0.5.8 '@exadev/eslint-config': - specifier: ^2.10.2 - version: 2.10.4(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3) + specifier: 2.12.1 + version: 2.12.1(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3) '@testcontainers/postgresql': specifier: ^12.1.0 version: 12.1.0 @@ -475,6 +475,14 @@ packages: '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@es-joy/jsdoccomment@0.97.0': + resolution: {integrity: sha512-EP8uoFfh6+GsdGCduYtmWAW0h7AO+Ayik9Vh5YbA2r/3N6lmJKkCNZX+q3QBXC1K6ixjQ/9igF2b7WVvLm063g==} + engines: {node: ^22.22.2 || >=24.15.0} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -826,8 +834,8 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@exadev/eslint-config@2.10.4': - resolution: {integrity: sha512-E+YLfg3RbQ5DVfWMiRdpJo9sjYfwWzuRef6JidWbvoQxHMkEp7IWEPedfIvBvBPQPjK8hDVUMdnKPojdhxEpYA==} + '@exadev/eslint-config@2.12.1': + resolution: {integrity: sha512-b5JKCX7l5onZy0i0kHdIeuiocxDoiuEwnrG6+j+EV8ZWaLpi18BDzNi1iT3tc647DFRUYJtHWDiJrkWT6E2JjQ==} engines: {node: '>=20'} peerDependencies: '@next/eslint-plugin-next': ^16.3.2 @@ -1082,6 +1090,12 @@ packages: '@loaderkit/resolve@1.0.6': resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@microsoft/tsdoc-config@0.18.1': + resolution: {integrity: sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -1362,6 +1376,10 @@ packages: resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} engines: {node: '>=22'} + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -1473,16 +1491,32 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.69.0': resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.69.0': resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/tsconfig-utils@8.69.0': resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1496,16 +1530,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.69.0': resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/typescript-estree@8.69.0': resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.69.0': resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1513,6 +1564,10 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.69.0': resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1715,6 +1770,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -1757,6 +1815,10 @@ packages: resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} engines: {node: '>= 14'} + are-docs-informative@0.1.1: + resolution: {integrity: sha512-sqRsNQBwbKLRX0jV5Cu5uzmtflf892n4Vukz7T659ebL4pz3mpOqCMU7lxMoBTFwnp10E3YB5ZcyHM41W5bcDA==} + engines: {node: '>=18'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1979,6 +2041,10 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} + comment-parser@1.4.8: + resolution: {integrity: sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==} + engines: {node: '>= 12.0.0'} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -2184,6 +2250,10 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -2222,6 +2292,12 @@ packages: peerDependencies: eslint: '>=7.0.0' + eslint-plugin-jsdoc@64.3.6: + resolution: {integrity: sha512-lo7IXmgUUNy88SxW7KnJmmD2iPQBIRMomfCFPHidW1M3zNNVuzxlB2uoNrNyE1g4v2/L+RtYmiROPwQhSNzL8Q==} + engines: {node: ^22.22.2 || >=24.15.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + eslint-plugin-prettier@5.5.6: resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2236,6 +2312,9 @@ packages: eslint-config-prettier: optional: true + eslint-plugin-tsdoc@0.5.2: + resolution: {integrity: sha512-BlvqjWZdBJDIPO/YU3zcPCF23CvjYT3gyu63yo6b609NNV3D1b6zceAREy2xnweuBoDpZcLNuPyAUq9cvx6bbQ==} + eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -2400,6 +2479,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + function-timeout@1.0.2: resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} engines: {node: '>=18'} @@ -2474,6 +2556,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -2492,6 +2578,9 @@ packages: resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} engines: {node: ^20.17.0 || >=22.9.0} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2567,6 +2656,10 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2640,6 +2733,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -2650,6 +2746,10 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jsdoc-type-pratt-parser@9.2.1: + resolution: {integrity: sha512-V4Ww4EHnTcTLSOMoB0FsF72JhQvcAsriCm/LWnxJeGWoxIjEL2l9na11abQok5SYShq8m0Gl02el/xAbTCulvQ==} + engines: {node: ^22.22.2 || >=24.15.0} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -3039,6 +3139,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-deep-merge@2.0.1: + resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -3108,6 +3211,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + parse-json@4.0.0: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} engines: {node: '>=4'} @@ -3124,6 +3230,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -3149,6 +3258,9 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -3350,6 +3462,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3361,6 +3477,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -3472,6 +3593,9 @@ packages: spdx-expression-parse@3.0.1: resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + spdx-expression-parse@5.0.0: + resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} + spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} @@ -3574,6 +3698,10 @@ packages: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + synckit@0.11.13: resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3650,6 +3778,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + traverse@0.6.8: resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} engines: {node: '>= 0.4'} @@ -4297,6 +4429,16 @@ snapshots: tslib: 2.8.1 optional: true + '@es-joy/jsdoccomment@0.97.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.69.0 + comment-parser: 1.4.8 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 9.2.1 + + '@es-joy/resolve.exports@1.2.0': {} + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -4487,11 +4629,13 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exadev/eslint-config@2.10.4(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3)': + '@exadev/eslint-config@2.12.1(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@eslint/js': 10.0.1(eslint@10.9.1(jiti@2.6.1)) '@typescript-eslint/utils': 8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) eslint: 10.9.1(jiti@2.6.1) + eslint-plugin-jsdoc: 64.3.6(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + eslint-plugin-tsdoc: 0.5.2(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 typescript-eslint: 8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) @@ -4691,6 +4835,15 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 + '@microsoft/tsdoc-config@0.18.1': + dependencies: + '@microsoft/tsdoc': 0.16.0 + ajv: 8.18.0 + jju: 1.4.0 + resolve: 1.22.12 + + '@microsoft/tsdoc@0.16.0': {} + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.8': @@ -4973,6 +5126,8 @@ snapshots: '@simple-libs/stream-utils@2.0.0': {} + '@sindresorhus/base62@1.0.0': {} + '@sindresorhus/is@4.6.0': {} '@sindresorhus/is@7.2.0': {} @@ -5095,6 +5250,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.56.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) + '@typescript-eslint/types': 8.69.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.69.0(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) @@ -5104,11 +5268,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager@8.69.0': dependencies: '@typescript-eslint/types': 8.69.0 '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/tsconfig-utils@8.69.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 @@ -5125,8 +5298,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.69.0': {} + '@typescript-eslint/typescript-estree@8.56.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/typescript-estree@8.69.0(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.69.0(typescript@6.0.3) @@ -5142,6 +5332,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.56.1(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) + eslint: 10.9.1(jiti@2.6.1) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.6.1)) @@ -5153,6 +5354,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.69.0': dependencies: '@typescript-eslint/types': 8.69.0 @@ -5311,6 +5517,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.7 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -5364,6 +5577,8 @@ snapshots: - bare-buffer - react-native-b4a + are-docs-informative@0.1.1: {} + argparse@2.0.1: {} argue-cli@3.2.0: {} @@ -5561,6 +5776,8 @@ snapshots: commander@15.0.0: {} + comment-parser@1.4.8: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -5755,6 +5972,8 @@ snapshots: error-stack-parser-es@1.0.5: {} + es-errors@1.3.0: {} + es-module-lexer@2.3.2: {} es-toolkit@1.52.0: {} @@ -5829,6 +6048,28 @@ snapshots: dependencies: eslint: 10.9.1(jiti@2.6.1) + eslint-plugin-jsdoc@64.3.6(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3): + dependencies: + '@es-joy/jsdoccomment': 0.97.0 + '@es-joy/resolve.exports': 1.2.0 + '@typescript-eslint/utils': 8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + are-docs-informative: 0.1.1 + comment-parser: 1.4.8 + debug: 4.4.3 + escape-string-regexp: 5.0.0 + eslint: 10.9.1(jiti@2.6.1) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.1 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 5.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + - typescript + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.9.1(jiti@2.6.1)))(eslint@10.9.1(jiti@2.6.1))(prettier@3.9.6): dependencies: eslint: 10.9.1(jiti@2.6.1) @@ -5838,6 +6079,16 @@ snapshots: optionalDependencies: eslint-config-prettier: 10.1.8(eslint@10.9.1(jiti@2.6.1)) + eslint-plugin-tsdoc@0.5.2(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3): + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.1 + '@typescript-eslint/utils': 8.56.1(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + transitivePeerDependencies: + - eslint + - supports-color + - typescript + eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 @@ -6039,6 +6290,8 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + function-timeout@1.0.2: {} get-caller-file@2.0.5: {} @@ -6107,6 +6360,10 @@ snapshots: has-flag@4.0.0: {} + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + highlight.js@10.7.3: {} hook-std@4.0.0: {} @@ -6121,6 +6378,8 @@ snapshots: dependencies: lru-cache: 11.5.2 + html-entities@2.6.0: {} + html-escaper@2.0.2: {} http-proxy-agent@9.1.0: @@ -6183,6 +6442,10 @@ snapshots: is-arrayish@0.2.1: {} + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -6240,6 +6503,8 @@ snapshots: jiti@2.6.1: {} + jju@1.4.0: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -6248,6 +6513,11 @@ snapshots: dependencies: argparse: 2.0.1 + jsdoc-type-pratt-parser@9.2.1: + dependencies: + '@types/estree': 1.0.9 + '@types/node': 26.4.1 + json-buffer@3.0.1: {} json-parse-better-errors@1.0.2: {} @@ -6536,6 +6806,8 @@ snapshots: object-assign@4.1.1: {} + object-deep-merge@2.0.1: {} + obug@2.1.4: {} once@1.4.0: @@ -6597,6 +6869,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + parse-json@4.0.0: dependencies: error-ex: 1.3.4 @@ -6617,6 +6893,8 @@ snapshots: parse-ms@4.0.0: {} + parse-statements@1.0.11: {} + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -6633,6 +6911,8 @@ snapshots: path-key@4.0.0: {} + path-parse@1.0.7: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -6857,12 +7137,21 @@ snapshots: require-from-string@2.0.2: {} + reserved-identifiers@1.2.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + retry@0.12.0: {} rolldown-plugin-dts@0.27.14(rolldown@1.2.7)(typescript@6.0.3): @@ -7029,6 +7318,11 @@ snapshots: spdx-exceptions: 2.5.0 spdx-license-ids: 3.0.23 + spdx-expression-parse@5.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + spdx-license-ids@3.0.23: {} split-ca@1.0.1: {} @@ -7140,6 +7434,8 @@ snapshots: has-flag: 4.0.0 supports-color: 7.2.0 + supports-preserve-symlinks-flag@1.0.0: {} + synckit@0.11.13: dependencies: '@pkgr/core': 0.3.6 @@ -7263,6 +7559,11 @@ snapshots: dependencies: is-number: 7.0.0 + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + traverse@0.6.8: {} tree-kill@1.2.2: {} From c5171c406fb44b6b0345d817a4a6434e78be37b0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:02:01 +0100 Subject: [PATCH 2/9] chore: default pnpm adds to exact versions Sets saveExact: true in pnpm-workspace.yaml, the settings location pnpm 11+ actually reads (.npmrc is registry/auth-only there), so every future pnpm add lands as an exact version by default rather than a caret range -- matching the exact pin @exadev/eslint-config and @exadev/semantic-release-workspace already carry. Adds the equivalent save-exact/minimum-release-age-exclude lines to .npmrc as a fallback for a pnpm 10.x release older than 10.17, which predates pnpm-workspace.yaml's own settings block. --- .npmrc | 6 ++++++ pnpm-workspace.yaml | 3 +++ 2 files changed, 9 insertions(+) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b8e149f --- /dev/null +++ b/.npmrc @@ -0,0 +1,6 @@ +# Fallback for pnpm 10.x releases older than 10.17, which predate pnpm-workspace.yaml's +# own settings block -- pnpm-workspace.yaml (saveExact, minimumReleaseAgeExclude) is the +# real source of truth on pnpm 10.17+ and pnpm 11+, where .npmrc is registry/auth-only and +# these two lines are simply ignored. +save-exact=true +minimum-release-age-exclude[]=@exadev/eslint-config diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1e78a5a..f49d744 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,3 +32,6 @@ overrides: # # This override is what lets the changelog use the conventionalcommits preset at all. The single-package release config this replaces worked around the same bug by generating notes with the angular preset instead, which loses the per-type section headings. "@semantic-release/release-notes-generator>conventional-changelog-writer": 9.2.1 + +# Pin every future `pnpm add` to the resolved version instead of a caret range, matching the exact pins already used for @exadev/eslint-config and @exadev/semantic-release-workspace above. +saveExact: true From 4be98837ddadc8e491d966e58b166d837555e960 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:02:13 +0100 Subject: [PATCH 3/9] fix: wrap scoped-package names in backticks in release-workspace doc comment TSDoc's parser treats a bare @word as an attempted block tag even mid-sentence, so "@exadev/semantic-release-workspace" and "@semantic-release/git" in this comment's prose were each read as a malformed tag rather than plain text. Wrapping both in backticks marks them as code spans, which TSDoc skips over during tag parsing. --- release-workspace.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/release-workspace.config.ts b/release-workspace.config.ts index 7a81cf6..58b2a56 100644 --- a/release-workspace.config.ts +++ b/release-workspace.config.ts @@ -28,13 +28,13 @@ export const commitTypes: readonly CommitType[] = [ ]; /** - * Runs on `main`, once per push, through @exadev/semantic-release-workspace rather than semantic-release directly. + * Runs on `main`, once per push, through `@exadev/semantic-release-workspace` rather than semantic-release directly. * * The orchestrator discovers every package from pnpm-workspace.yaml, orders them so a package releases only after each workspace sibling it depends on has, and runs semantic-release per package with the commit list path-filtered to that package's own directory and its tags in `name@version` form. So each package's version tracks its own history: `packages/trilean` continues from the version its own `trilean@x.y.z` tags record, and a package added later starts its own sequence without disturbing it. * * That tag format is what carries a package's version across from before the workspace existed, and it is not the format a single-package release used: `v1.3.0` then, `trilean@1.3.0` now. A package brought in from a repository of its own therefore needs a tag in the new format created at the commit its last old-format tag names -- semantic-release derives the previous version from the last tag matching the format it is configured with, and finds nothing at all without one, so the package would restart at 1.0.0 and its first publish would collide with a version the registry already holds. `trilean@1.3.0` exists for exactly that reason and points at the same commit as `v1.3.0`. * - * `commitStrategy: "single"` produces one commit for the whole run -- every version bump, changelog write, and dependency-range rewrite together -- instead of one commit per released package plus one per bump. @semantic-release/git is deliberately absent from the plugin list because of it: that plugin's own prepare step would make exactly the per-package commit this mode exists to replace, and the orchestrator rejects the combination outright rather than producing both. + * `commitStrategy: "single"` produces one commit for the whole run -- every version bump, changelog write, and dependency-range rewrite together -- instead of one commit per released package plus one per bump. `@semantic-release/git` is deliberately absent from the plugin list because of it: that plugin's own prepare step would make exactly the per-package commit this mode exists to replace, and the orchestrator rejects the combination outright rather than producing both. */ const config: Pick< ReleaseWorkspaceOptions, From a6a36366b9340da9254c46d269f5075405c7d6e6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:02:40 +0100 Subject: [PATCH 4/9] fix: use backticks instead of JSDoc-style braces on @throws tags TSDoc has no bracketed-type syntax for @throws -- writing `@throws {RegexParseError} ...` makes the parser expect an inline tag starting with "{@" and fail on the bare "{". Backticking the type name instead reads as an ordinary code span, which is what these comments already do for every other inline type reference. --- packages/trilean-regex/src/matcher.ts | 6 ++---- packages/trilean-regex/src/parser.ts | 3 +-- packages/trilean-sql/src/compile.ts | 7 +++---- packages/trilean-sql/src/guard.ts | 3 +-- packages/trilean-sql/src/portable-pattern.ts | 6 ++---- 5 files changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/trilean-regex/src/matcher.ts b/packages/trilean-regex/src/matcher.ts index 8d8c7b3..41c29af 100644 --- a/packages/trilean-regex/src/matcher.ts +++ b/packages/trilean-regex/src/matcher.ts @@ -88,8 +88,7 @@ export interface CompiledPattern { * Parses `pattern` (this package's grammar; see README.md) and compiles it to a reference matcher. * * Prefer this over `testPattern` when the same pattern will be tested against more than one input: it parses and compiles exactly once, and the returned `CompiledPattern.test` is then only the O(length x states) simulation. - * - * @throws {RegexParseError} if `pattern` is not a valid pattern in this grammar. + * @throws `RegexParseError` if `pattern` is not a valid pattern in this grammar. */ export function compilePattern(pattern: string): CompiledPattern { const nfa = compileToNfa(parseRegex(pattern)); @@ -98,8 +97,7 @@ export function compilePattern(pattern: string): CompiledPattern { /** * Parses `pattern` and tests it against `input` in one call. Equivalent to `compilePattern(pattern).test(input)`, and exactly as cheap for a single test -- prefer `compilePattern` instead when the same pattern will be reused, so the parse and compile steps happen once rather than once per input. - * - * @throws {RegexParseError} if `pattern` is not a valid pattern in this grammar. + * @throws `RegexParseError` if `pattern` is not a valid pattern in this grammar. */ export function testPattern(pattern: string, input: string): boolean { return compilePattern(pattern).test(input); diff --git a/packages/trilean-regex/src/parser.ts b/packages/trilean-regex/src/parser.ts index 9a4b3b7..4f87181 100644 --- a/packages/trilean-regex/src/parser.ts +++ b/packages/trilean-regex/src/parser.ts @@ -475,8 +475,7 @@ class Parser { * Parses a pattern in this package's grammar (see README.md) into a `RegexNode` AST. * * Never returns a partial result: a pattern either parses completely, with nothing left over, or this throws `RegexParseError` naming exactly where parsing could not proceed. - * - * @throws {RegexParseError} if `pattern` is not a complete, valid pattern in this grammar. + * @throws `RegexParseError` if `pattern` is not a complete, valid pattern in this grammar. */ export function parseRegex(pattern: string): RegexNode { const parser = new Parser(pattern); diff --git a/packages/trilean-sql/src/compile.ts b/packages/trilean-sql/src/compile.ts index ce44bd1..91c7a57 100644 --- a/packages/trilean-sql/src/compile.ts +++ b/packages/trilean-sql/src/compile.ts @@ -254,10 +254,9 @@ function compilePredicate( * Three-valued logic is not reimplemented on top of SQL; it is delegated to it. SQL's `AND`, `OR` and `NOT` over `TRUE`/`FALSE`/`NULL` are Kleene's strong three-valued tables, which are the same tables trilean's own `combineAnd`, `combineOr` and `not` implement, and a comparison against a NULL column yields `NULL` exactly where the evaluator would have returned `indeterminate` from an unresolved reference. A row excluded by `WHERE` because its condition was unknown is therefore excluded for the same reason, and by the same rule, as a subject the evaluator declines to judge. No indeterminacy column, sentinel value or `CASE` scaffolding is emitted, because none is needed. * * Every caller-supplied literal becomes a bind parameter. Nothing but structure, operators, and quoted column identifiers is ever written into the returned `sql`. - * - * @throws {UnknownDialectError} if `options.dialect` names a dialect this version does not implement. - * @throws {UnsupportedNodeError} if any node in the tree is one this compiler will not translate -- see `findUnpushableNodeKind`, which this runs first and which a caller can run itself to choose between pushdown and in-process evaluation without provoking an exception. - * @throws {InvalidColumnError} if `columnFor` returns a column that cannot be rendered as an identifier. + * @throws `UnknownDialectError` if `options.dialect` names a dialect this version does not implement. + * @throws `UnsupportedNodeError` if any node in the tree is one this compiler will not translate -- see `findUnpushableNodeKind`, which this runs first and which a caller can run itself to choose between pushdown and in-process evaluation without provoking an exception. + * @throws `InvalidColumnError` if `columnFor` returns a column that cannot be rendered as an identifier. */ export function compilePredicateNode( node: PredicateNode, diff --git a/packages/trilean-sql/src/guard.ts b/packages/trilean-sql/src/guard.ts index f634e90..2d406e9 100644 --- a/packages/trilean-sql/src/guard.ts +++ b/packages/trilean-sql/src/guard.ts @@ -467,8 +467,7 @@ function findUnpushablePredicate( * Passing `options` widens the check: without them the walk is purely structural, and with them it also applies the operand-kind rules that depend on each mapped column's declared `paramType` (and therefore calls `columnFor`). * * Which nodes are refused does not depend on the dialect -- every divergence refused here is real in both -- so a structural walk given no `options` refuses exactly what a walk given them would. What `options` also settles is which engine each `reason` describes; with none to read a dialect from, the reasons describe PostgreSQL, the dialect these refusals were first derived against. - * - * @throws {UnknownDialectError} if `options` names a dialect this version does not implement. Reporting a tree as pushable is a promise that `compilePredicateNode` will compile it, and under a dialect that does not exist it cannot -- so this is refused here rather than left to surface from the compiler, which is the one caller this function's answer is for. + * @throws `UnknownDialectError` if `options` names a dialect this version does not implement. Reporting a tree as pushable is a promise that `compilePredicateNode` will compile it, and under a dialect that does not exist it cannot -- so this is refused here rather than left to surface from the compiler, which is the one caller this function's answer is for. */ export function findUnpushableNodeKind( node: PredicateNode, diff --git a/packages/trilean-sql/src/portable-pattern.ts b/packages/trilean-sql/src/portable-pattern.ts index b39486c..8625ad3 100644 --- a/packages/trilean-sql/src/portable-pattern.ts +++ b/packages/trilean-sql/src/portable-pattern.ts @@ -85,8 +85,7 @@ function renderPostgresSubcomponent(node: RegexNode): string { * Translates a `trilean-regex` AST into PostgreSQL's own Advanced Regular Expression syntax, for binding as the pattern argument to `~`/`!~`. * * Every construct this grammar accepts translates structurally, `.` included: measured directly against a real PostgreSQL 17 server (see the "PostgreSQL's own engine treats '.' as matching a newline" case in `test/integration/postgres.test.ts`), PostgreSQL's `.` matches a newline by default in the mode this compiler's `~`/`!~` operators run under -- the same as this grammar's own `.` (see trilean-regex's README) -- so no rewriting is needed here. (PostgreSQL's own prose documentation reads, in isolation, as though newline-sensitive matching were the default; it is not the one this operator uses, and the fact was confirmed against the real server rather than trusted from the prose alone -- see the "Verify this assumption" principle this compiler otherwise applies to every other construct too.) - * - * @throws {PortablePatternUnsupportedError} if a bounded repetition exceeds PostgreSQL's own 0-255 bound limit. + * @throws `PortablePatternUnsupportedError` if a bounded repetition exceeds PostgreSQL's own 0-255 bound limit. */ export function renderPostgresPattern(node: RegexNode): string { switch (node.kind) { @@ -243,8 +242,7 @@ function planGlobBody(node: RegexNode): GlobPlan { /** * Translates a `trilean-regex` AST into a `GLOB` pattern, for binding as `GLOB`/`NOT GLOB`'s right-hand argument. See the "reachable subset" doc comment above for exactly what this does and does not translate. - * - * @throws {PortablePatternUnsupportedError} if `node` uses any construct outside the reachable subset. + * @throws `PortablePatternUnsupportedError` if `node` uses any construct outside the reachable subset. */ export function renderSqliteGlobPattern(node: RegexNode): string { const plan = planGlobBody(node); From a3fd274eceacdcb7df5c6f0a04ee6f32c6ddc611 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:03:26 +0100 Subject: [PATCH 5/9] fix: escape arrow notation's > in doc comments so TSDoc doesn't read it as HTML A bare > after a hyphen (an ASCII "->" arrow) reads to TSDoc as the start of an HTML closing tag, which it flags as needing escape. Backslash-escaping each > lets these comments keep using "->" as a mapping/traversal arrow without tripping the parser. --- packages/trilean/src/computed-value.ts | 2 +- packages/trilean/test/integration/schema-pipeline.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/trilean/src/computed-value.ts b/packages/trilean/src/computed-value.ts index 90c213d..72ecab3 100644 --- a/packages/trilean/src/computed-value.ts +++ b/packages/trilean/src/computed-value.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -/** Dimension symbol -> exponent, e.g. `{ m: 1, s: -1 }` for metres per second. A bare symbol like `"kg"` is shorthand for `{ kg: 1 }`. */ +/** Dimension symbol -\> exponent, e.g. `{ m: 1, s: -1 }` for metres per second. A bare symbol like `"kg"` is shorthand for `{ kg: 1 }`. */ export const UnitSchema = z.record(z.string(), z.number()); export type Unit = z.infer; diff --git a/packages/trilean/test/integration/schema-pipeline.test.ts b/packages/trilean/test/integration/schema-pipeline.test.ts index 82ccd66..a7ee116 100644 --- a/packages/trilean/test/integration/schema-pipeline.test.ts +++ b/packages/trilean/test/integration/schema-pipeline.test.ts @@ -108,7 +108,7 @@ describe("an expression tree round-trips through untrusted JSON text before eval /** * The two trees are mutually recursive, and src/tree.ts implements that recursion with getter-backed `z.object` shapes specifically so a schema may forward-reference one declared later in the module (see that file's own comment on the TDZ). Every other test of the schema layer parses shallowly: src/tree.test.ts's samples are at most one level deep and never leave the tree they start in, and the round-trip cases above bottom out in a literal after two nodes. Nothing else, anywhere, drives `safeParse` down a tree that crosses the predicate/expression boundary repeatedly -- so nothing else would notice if a getter stopped being lazily invoked partway down, or if one tree's recursion back into the other resolved to the wrong schema. * - * The tree below is authored as JSON text (as a stored rule genuinely arrives) and descends allOf -> compare -> conditional -> some -> textCompare -> reference: predicate to expression to predicate and back again, twice in each direction, with a fold/accumulator aggregation on the conditional's own branch. Validating it and then evaluating it proves the same object graph survives both layers, which is the composition this tier exists to check -- neither layer alone can fail this test without the other's result changing. + * The tree below is authored as JSON text (as a stored rule genuinely arrives) and descends allOf -\> compare -\> conditional -\> some -\> textCompare -\> reference: predicate to expression to predicate and back again, twice in each direction, with a fold/accumulator aggregation on the conditional's own branch. Validating it and then evaluating it proves the same object graph survives both layers, which is the composition this tier exists to check -- neither layer alone can fail this test without the other's result changing. */ describe("a deep, mutually recursive tree crosses the predicate/expression boundary in both directions", () => { const ruleJson = `{ From 5fc4c8d53994137b963f537818fda08094787ad9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:14:38 +0100 Subject: [PATCH 6/9] test(evaluator): split evaluator.test.ts into topic-scoped files under the 800-line cap evaluator.test.ts had grown to 3152 lines of real code, well over the new max-lines cap. Splits it by tested concern into nine files (literal/reference values, temporal arithmetic, real-number arithmetic, complex-number arithmetic, compare/textCompare, memberOf/exists/quantifiers, lookup/fold/accumulator, conditional/delegate/treeReference/call, and the resource-limit and golden-example integration checks), each comfortably under the cap. Extracts the shared default resolver, expectDefinite/expectIndeterminate assertion helpers, and the isPlainRecord/isUnknownArray type guards -- previously duplicated near-verbatim in evaluator.indeterminacy.test.ts -- into a new evaluator-test-helpers.ts every split file imports from. --- .../trilean/src/evaluator-test-helpers.ts | 44 + .../trilean/src/evaluator.arithmetic.test.ts | 326 ++ .../trilean/src/evaluator.collections.test.ts | 288 ++ .../trilean/src/evaluator.comparison.test.ts | 512 +++ .../src/evaluator.complex-arithmetic.test.ts | 341 ++ .../src/evaluator.control-flow.test.ts | 761 ++++ .../trilean/src/evaluator.integration.test.ts | 248 ++ .../trilean/src/evaluator.membership.test.ts | 512 +++ .../trilean/src/evaluator.temporal.test.ts | 221 ++ packages/trilean/src/evaluator.test.ts | 3398 ----------------- packages/trilean/src/evaluator.values.test.ts | 226 ++ 11 files changed, 3479 insertions(+), 3398 deletions(-) create mode 100644 packages/trilean/src/evaluator-test-helpers.ts create mode 100644 packages/trilean/src/evaluator.arithmetic.test.ts create mode 100644 packages/trilean/src/evaluator.collections.test.ts create mode 100644 packages/trilean/src/evaluator.comparison.test.ts create mode 100644 packages/trilean/src/evaluator.complex-arithmetic.test.ts create mode 100644 packages/trilean/src/evaluator.control-flow.test.ts create mode 100644 packages/trilean/src/evaluator.integration.test.ts create mode 100644 packages/trilean/src/evaluator.membership.test.ts create mode 100644 packages/trilean/src/evaluator.temporal.test.ts delete mode 100644 packages/trilean/src/evaluator.test.ts create mode 100644 packages/trilean/src/evaluator.values.test.ts diff --git a/packages/trilean/src/evaluator-test-helpers.ts b/packages/trilean/src/evaluator-test-helpers.ts new file mode 100644 index 0000000..b17f964 --- /dev/null +++ b/packages/trilean/src/evaluator-test-helpers.ts @@ -0,0 +1,44 @@ +import { expect } from "vitest"; +import type { Evaluation } from "./evaluation"; +import type { Resolvers } from "./resolvers"; + +/** A resolver reporting a fixed `{ kind: "number", value: 10, unit: { m: 1 } }` for key `"present"`, and not-found for anything else -- `resolveLookup`/`resolveCollection` are never exercised by expression-level nodes and are stubbed only to satisfy `Resolvers`. Shared across the split `evaluator.*.test.ts` files below; a test needing genuinely different resolver behaviour spreads over it (`{ ...resolvers, resolveValue: ... }`) locally rather than mutating this shared instance. */ +export const resolvers: Resolvers = { + resolveValue: async (key) => + Promise.resolve( + key === "present" + ? { found: true, value: { kind: "number", value: 10, unit: { m: 1 } } } + : { found: false }, + ), + resolveLookup: async () => Promise.resolve({ found: false }), + resolveCollection: async () => Promise.resolve([]), +}; + +export function expectDefinite( + evaluation: Evaluation, + expected: T, +): void { + expect(evaluation).toEqual({ status: "definite", value: expected }); +} + +export function expectIndeterminate( + evaluation: Evaluation, + code: "not-found" | "wrong-type" | "domain-error", +): void { + expect(evaluation.status).toBe("indeterminate"); + if (evaluation.status === "indeterminate") { + expect(evaluation.reason.code).toBe(code); + } +} + +/** A narrowing type guard for a resolver context treated as a plain record, following this repo's `object -> Record` narrowing convention rather than an `as Record` assertion. */ +export function isPlainRecord( + value: unknown, +): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** `Array.isArray`'s own lib.es5.d.ts type predicate narrows to `any[]`, not `unknown[]` -- this re-typed wrapper is what lets a `resolveCollection` stub return a properly `unknown[]`-typed result instead of an implicit `any[]`. */ +export function isUnknownArray(value: unknown): value is unknown[] { + return Array.isArray(value); +} diff --git a/packages/trilean/src/evaluator.arithmetic.test.ts b/packages/trilean/src/evaluator.arithmetic.test.ts new file mode 100644 index 0000000..ae29edc --- /dev/null +++ b/packages/trilean/src/evaluator.arithmetic.test.ts @@ -0,0 +1,326 @@ +import { describe, it } from "vitest"; +import { evaluateValue } from "./evaluator"; +import type { ExpressionNode } from "./tree"; +import { + expectDefinite, + expectIndeterminate, + resolvers, +} from "./evaluator-test-helpers"; + +/** `arithmetic`'s own indeterminate-propagation table -- contrast this against `and`/`or`'s absorption (see truth-tables.test.ts and `combineAnd`/`combineOr` in evaluator.ts): arithmetic has no absorbing value at all, so a definite operand on one side never rescues an indeterminate operand on the other, unlike OR's absorbing `true` or AND's absorbing `false`. */ +describe("arithmetic indeterminate propagation (no absorbing value)", () => { + const definiteOperand: ExpressionNode = { kind: "numberLiteral", value: 1 }; + const indeterminateOperand: ExpressionNode = { + kind: "reference", + key: "missing", + }; + + it("definite + definite => definite", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: definiteOperand, + right: definiteOperand, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "number", value: 2 }); + }); + + it("indeterminate + definite => indeterminate (left's reason, unlike OR's absorbing true)", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: indeterminateOperand, + right: definiteOperand, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); + + it("definite + indeterminate => indeterminate (right's reason)", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: definiteOperand, + right: indeterminateOperand, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); + + it("indeterminate + indeterminate => indeterminate, tie-broken left before right", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: indeterminateOperand, + right: { kind: "reference", key: "also-missing" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); +}); + +describe("arithmetic on numbers -- units", () => { + it("add requires identical unit maps", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "numberLiteral", value: 3, unit: { m: 1 } }, + right: { kind: "numberLiteral", value: 4, unit: { m: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "number", value: 7, unit: { m: 1 } }); + }); + + it("subtract is wrong-type on mismatched units", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "subtract", + left: { kind: "numberLiteral", value: 3, unit: { m: 1 } }, + right: { kind: "numberLiteral", value: 4, unit: { s: 1 } }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("multiply combines units dimensionally", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "multiply", + left: { kind: "numberLiteral", value: 3, unit: { m: 1 } }, + right: { kind: "numberLiteral", value: 4, unit: { s: -1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "number", + value: 12, + unit: { m: 1, s: -1 }, + }); + }); + + it("divide combines units dimensionally", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "divide", + left: { kind: "numberLiteral", value: 10, unit: { m: 1 } }, + right: { kind: "numberLiteral", value: 2, unit: { s: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "number", + value: 5, + unit: { m: 1, s: -1 }, + }); + }); + + it("dividing a unit by itself normalises to dimensionless", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "divide", + left: { kind: "numberLiteral", value: 10, unit: { m: 1 } }, + right: { kind: "numberLiteral", value: 2, unit: { m: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "number", value: 5, unit: {} }); + }); +}); + +describe("arithmetic domain errors", () => { + it("division by zero is domain-error", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "divide", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + + it("modulo by zero is domain-error", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "modulo", + left: { kind: "numberLiteral", value: 7 }, + right: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + + it("a negative base raised to a non-integer power is domain-error", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "numberLiteral", value: -1 }, + right: { kind: "numberLiteral", value: 0.5 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + + it("a zero base raised to a negative power is domain-error, not a definite infinity", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "numberLiteral", value: 0 }, + right: { kind: "numberLiteral", value: -1 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + + it("power and modulo are otherwise ordinary numeric operations", async () => { + const power = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "numberLiteral", value: 2 }, + right: { kind: "numberLiteral", value: 3 }, + }, + undefined, + resolvers, + ); + expectDefinite(power, { kind: "number", value: 8 }); + + const modulo = await evaluateValue( + { + kind: "arithmetic", + op: "modulo", + left: { kind: "numberLiteral", value: 7 }, + right: { kind: "numberLiteral", value: 3 }, + }, + undefined, + resolvers, + ); + expectDefinite(modulo, { kind: "number", value: 1 }); + }); +}); + +describe("arithmetic on non-numeric, non-temporal operands", () => { + it("is wrong-type when an operand is text", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "textLiteral", value: "active" }, + right: { kind: "numberLiteral", value: 1 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); +}); + +describe("negate", () => { + it("flips a number's sign, preserving its unit", async () => { + const result = await evaluateValue( + { + kind: "negate", + operand: { kind: "numberLiteral", value: 5, unit: { m: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "number", value: -5, unit: { m: 1 } }); + }); + + it("reverses a duration's magnitude, preserving its unit -- not sugar for zero minus the value", async () => { + const result = await evaluateValue( + { + kind: "negate", + operand: { kind: "durationLiteral", value: 5, unit: "min" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "duration", value: -5, unit: "min" }); + }); + + it("flips both components of a complex value, preserving its unit", async () => { + const result = await evaluateValue( + { + kind: "negate", + operand: { kind: "complexLiteral", re: 3, im: -4, unit: { V: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "complex", + re: -3, + im: 4, + unit: { V: 1 }, + }); + }); + + it("is wrong-type on an instant", async () => { + const result = await evaluateValue( + { + kind: "negate", + operand: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type on a boolean -- negate has no logical-NOT meaning here", async () => { + const result = await evaluateValue( + { kind: "negate", operand: { kind: "booleanLiteral", value: true } }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("propagates an indeterminate operand", async () => { + const result = await evaluateValue( + { kind: "negate", operand: { kind: "reference", key: "missing" } }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); +}); diff --git a/packages/trilean/src/evaluator.collections.test.ts b/packages/trilean/src/evaluator.collections.test.ts new file mode 100644 index 0000000..3b86b9b --- /dev/null +++ b/packages/trilean/src/evaluator.collections.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from "vitest"; +import { evaluateValue } from "./evaluator"; +import type { Resolvers } from "./resolvers"; +import { + expectDefinite, + expectIndeterminate, + isPlainRecord, + resolvers, +} from "./evaluator-test-helpers"; + +describe("lookup", () => { + const lookupResolvers: Resolvers = { + resolveValue: async (key) => { + if (key === "region") { + return Promise.resolve({ + found: true, + value: { kind: "text", value: "north" }, + }); + } + if (key === "tier") { + return Promise.resolve({ + found: true, + value: { kind: "number", value: 2 }, + }); + } + return Promise.resolve({ found: false }); + }, + resolveLookup: async (table) => + Promise.resolve( + table === "pricing" + ? { found: true, value: { kind: "number", value: 42 } } + : { found: false }, + ), + resolveCollection: async () => Promise.resolve([]), + }; + + it("evaluates every key and passes its resolved value to resolveLookup, in declared order", async () => { + const receivedKeys: unknown[] = []; + const trackingResolvers: Resolvers = { + ...lookupResolvers, + resolveLookup: async (table, keys, context) => { + receivedKeys.push(...keys); + return lookupResolvers.resolveLookup(table, keys, context); + }, + }; + const result = await evaluateValue( + { + kind: "lookup", + table: "pricing", + keys: [ + { kind: "reference", key: "region" }, + { kind: "reference", key: "tier" }, + ], + }, + undefined, + trackingResolvers, + ); + expect(receivedKeys).toEqual([ + { kind: "text", value: "north" }, + { kind: "number", value: 2 }, + ]); + expectDefinite(result, { kind: "number", value: 42 }); + }); + + it("is not-found when the resolver reports no match", async () => { + const result = await evaluateValue( + { + kind: "lookup", + table: "unknown-table", + keys: [{ kind: "reference", key: "region" }], + }, + undefined, + lookupResolvers, + ); + expectIndeterminate(result, "not-found"); + }); + + it("resolves a boolean value with no schema change beyond the ComputedValue union itself -- lookup carries no kind restriction of its own", async () => { + const booleanLookupResolvers: Resolvers = { + ...lookupResolvers, + resolveLookup: async (table, keys, context) => + table === "eligibility" + ? Promise.resolve({ + found: true, + value: { kind: "boolean", value: true }, + }) + : lookupResolvers.resolveLookup(table, keys, context), + }; + const result = await evaluateValue( + { + kind: "lookup", + table: "eligibility", + keys: [{ kind: "reference", key: "region" }], + }, + undefined, + booleanLookupResolvers, + ); + expectDefinite(result, { kind: "boolean", value: true }); + }); +}); + +describe("fold", () => { + const foldResolvers: Resolvers = { + resolveValue: async (key, context) => { + if ( + typeof key !== "string" || + !isPlainRecord(context) || + !(key in context) + ) { + return Promise.resolve({ found: false }); + } + const value = context[key]; + if (typeof value === "number") { + return Promise.resolve({ + found: true, + value: { kind: "number", value }, + }); + } + if (typeof value === "string") { + return Promise.resolve({ found: true, value: { kind: "text", value } }); + } + return Promise.resolve({ found: false }); + }, + resolveLookup: async () => Promise.resolve({ found: false }), + resolveCollection: async (collection) => { + if (collection === "amounts") { + return Promise.resolve([{ amount: 8 }, { amount: 12 }, { amount: 1 }]); + } + if (collection === "grouped") { + return Promise.resolve([ + { group: "keep", amount: 5 }, + { group: "skip", amount: 999 }, + ]); + } + if (collection === "empty") return Promise.resolve([]); + return Promise.resolve([]); + }, + }; + + describe("reduce", () => { + it("sums participating items, threading the running total through accumulator", async () => { + const result = await evaluateValue( + { + kind: "fold", + collection: "amounts", + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 0 }, + combine: { + kind: "arithmetic", + op: "add", + left: { kind: "accumulator" }, + right: { kind: "reference", key: "amount" }, + }, + }, + }, + undefined, + foldResolvers, + ); + expectDefinite(result, { kind: "number", value: 21 }); + }); + + it("filter narrows which items participate, exactly like some/every's own filter", async () => { + const result = await evaluateValue( + { + kind: "fold", + collection: "grouped", + filter: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "group" }, + right: { kind: "textLiteral", value: "keep" }, + }, + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 0 }, + combine: { + kind: "arithmetic", + op: "add", + left: { kind: "accumulator" }, + right: { kind: "reference", key: "amount" }, + }, + }, + }, + undefined, + foldResolvers, + ); + // The excluded item's amount (999) never participates; only the kept item's 5 does. + expectDefinite(result, { kind: "number", value: 5 }); + }); + + it("over an empty (post-filter) collection evaluates to initial directly, without touching combine", async () => { + const result = await evaluateValue( + { + kind: "fold", + collection: "empty", + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 7 }, + combine: { kind: "numberLiteral", value: 999 }, + }, + }, + undefined, + foldResolvers, + ); + expectDefinite(result, { kind: "number", value: 7 }); + }); + }); + + describe("max, min", () => { + it("max keeps the largest projected value seen", async () => { + const result = await evaluateValue( + { + kind: "fold", + collection: "amounts", + combiner: { mode: "max", item: { kind: "reference", key: "amount" } }, + }, + undefined, + foldResolvers, + ); + expectDefinite(result, { kind: "number", value: 12 }); + }); + + it("min keeps the smallest projected value seen", async () => { + const result = await evaluateValue( + { + kind: "fold", + collection: "amounts", + combiner: { mode: "min", item: { kind: "reference", key: "amount" } }, + }, + undefined, + foldResolvers, + ); + expectDefinite(result, { kind: "number", value: 1 }); + }); + + it("is domain-error over an empty (post-filter) collection -- there is no first item to seed from", async () => { + const result = await evaluateValue( + { + kind: "fold", + collection: "empty", + combiner: { mode: "max", item: { kind: "reference", key: "amount" } }, + }, + undefined, + foldResolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + }); +}); + +describe("accumulator", () => { + it("is wrong-type when evaluated outside any fold's combine expression", async () => { + const result = await evaluateValue( + { kind: "accumulator" }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("resolves to the running value inside a reduce fold's own combine expression", async () => { + // The collection has to genuinely resolve to items: over an empty one `combine` is never evaluated at all and the fold returns `initial` untouched, so an assertion against `initial`'s own value would hold whether or not `accumulator` resolved correctly. Stepping the accumulator once per item is what makes the expected value depend on it. + const twoItemResolvers: Resolvers = { + ...resolvers, + resolveCollection: async () => Promise.resolve([{}, {}]), + }; + const result = await evaluateValue( + { + kind: "fold", + collection: "items", + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 5 }, + combine: { + kind: "arithmetic", + op: "add", + left: { kind: "accumulator" }, + right: { kind: "numberLiteral", value: 1 }, + }, + }, + }, + undefined, + twoItemResolvers, + ); + expectDefinite(result, { kind: "number", value: 7 }); + }); +}); diff --git a/packages/trilean/src/evaluator.comparison.test.ts b/packages/trilean/src/evaluator.comparison.test.ts new file mode 100644 index 0000000..eba97cf --- /dev/null +++ b/packages/trilean/src/evaluator.comparison.test.ts @@ -0,0 +1,512 @@ +import { describe, it } from "vitest"; +import { evaluatePredicate } from "./evaluator"; +import { + expectDefinite, + expectIndeterminate, + resolvers, +} from "./evaluator-test-helpers"; + +describe("compare", () => { + it.each([ + { op: "gt", left: 5, right: 3, expected: true }, + { op: "gt", left: 3, right: 5, expected: false }, + { op: "gte", left: 5, right: 5, expected: true }, + { op: "gte", left: 3, right: 5, expected: false }, + { op: "lt", left: 3, right: 5, expected: true }, + { op: "lt", left: 5, right: 3, expected: false }, + { op: "lte", left: 5, right: 5, expected: true }, + { op: "lte", left: 5, right: 3, expected: false }, + { op: "eq", left: 5, right: 5, expected: true }, + { op: "eq", left: 5, right: 3, expected: false }, + { op: "neq", left: 5, right: 3, expected: true }, + { op: "neq", left: 5, right: 5, expected: false }, + ] as const)( + "$op($left, $right) => $expected", + async ({ op, left, right, expected }) => { + const result = await evaluatePredicate( + { + kind: "compare", + op, + left: { kind: "numberLiteral", value: left }, + right: { kind: "numberLiteral", value: right }, + }, + undefined, + resolvers, + ); + expectDefinite(result, expected); + }, + ); + + it.each([ + { op: "eq", left: true, right: true, expected: true }, + { op: "eq", left: true, right: false, expected: false }, + { op: "neq", left: true, right: false, expected: true }, + { op: "neq", left: true, right: true, expected: false }, + ] as const)( + "boolean $op($left, $right) => $expected", + async ({ op, left, right, expected }) => { + const result = await evaluatePredicate( + { + kind: "compare", + op, + left: { kind: "booleanLiteral", value: left }, + right: { kind: "booleanLiteral", value: right }, + }, + undefined, + resolvers, + ); + expectDefinite(result, expected); + }, + ); + + it.each(["gt", "gte", "lt", "lte"] as const)( + "%s is wrong-type for boolean operands -- no natural ordering", + async (op) => { + const result = await evaluatePredicate( + { + kind: "compare", + op, + left: { kind: "booleanLiteral", value: true }, + right: { kind: "booleanLiteral", value: false }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }, + ); + + it("compares instants by parsed timestamp ordering", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "lt", + left: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + right: { kind: "instantLiteral", value: "2026-01-02T00:00:00.000Z" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("compares durations by magnitude, normalising different units to the same base", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "durationLiteral", value: 1, unit: "min" }, + right: { kind: "durationLiteral", value: 60, unit: "s" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it.each([ + { op: "eq", right: { re: 3, im: -4 }, expected: true }, + { op: "eq", right: { re: 3, im: 4 }, expected: false }, + { op: "eq", right: { re: -3, im: -4 }, expected: false }, + { op: "neq", right: { re: 3, im: 4 }, expected: true }, + { op: "neq", right: { re: 3, im: -4 }, expected: false }, + ] as const)( + "$op against a complex value is exact equality across both components => $expected", + async ({ op, right, expected }) => { + const result = await evaluatePredicate( + { + kind: "compare", + op, + left: { kind: "complexLiteral", re: 3, im: -4 }, + right: { kind: "complexLiteral", ...right }, + }, + undefined, + resolvers, + ); + expectDefinite(result, expected); + }, + ); + + it.each(["gt", "gte", "lt", "lte"] as const)( + "%s is wrong-type on complex operands -- the complex plane carries no total order", + async (op) => { + const result = await evaluatePredicate( + { + kind: "compare", + op, + left: { kind: "complexLiteral", re: 3, im: -4 }, + right: { kind: "complexLiteral", re: 1, im: 1 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }, + ); + + it("is wrong-type when two complex values have incompatible units", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "complexLiteral", re: 3, im: -4, unit: { V: 1 } }, + right: { kind: "complexLiteral", re: 3, im: -4, unit: { A: 1 } }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type when comparing a complex value against a real one, unlike arithmetic's promotion", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "complexLiteral", re: 3, im: 0 }, + right: { kind: "numberLiteral", value: 3 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type when comparing across different computed-value kinds", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "textLiteral", value: "1" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type when comparing a boolean against a number", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "booleanLiteral", value: true }, + right: { kind: "numberLiteral", value: 1 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type when comparing an instant against a duration", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + right: { kind: "durationLiteral", value: 1, unit: "min" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type when two numbers have incompatible units", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "gt", + left: { kind: "numberLiteral", value: 5, unit: { m: 1 } }, + right: { kind: "numberLiteral", value: 3, unit: { s: 1 } }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("propagates an indeterminate left operand", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "missing" }, + right: { kind: "numberLiteral", value: 1 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); + + it("propagates an indeterminate right operand", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "reference", key: "missing" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); + + it("tie-breaks two indeterminate operands to the left's reason, per the declared-operand-order rule", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "missing" }, + right: { kind: "reference", key: "also-missing" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); +}); + +describe("textCompare", () => { + it("equals is exact string equality", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "equals", + left: { kind: "textLiteral", value: "active" }, + right: { kind: "textLiteral", value: "active" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("equals is false for differing strings", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "equals", + left: { kind: "textLiteral", value: "active" }, + right: { kind: "textLiteral", value: "inactive" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, false); + }); + + it("notEquals is the negation of equals", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "notEquals", + left: { kind: "textLiteral", value: "active" }, + right: { kind: "textLiteral", value: "inactive" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("matches tests right's text as a regular expression against left's text", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "matches", + left: { kind: "textLiteral", value: "active-123" }, + right: { kind: "textLiteral", value: "^active-\\d+$" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("matches is false when the pattern does not match", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "matches", + left: { kind: "textLiteral", value: "inactive" }, + right: { kind: "textLiteral", value: "^active$" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, false); + }); + + it("notMatches is the negation of matches", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "notMatches", + left: { kind: "textLiteral", value: "inactive" }, + right: { kind: "textLiteral", value: "^active$" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("portableMatches tests right's text as a trilean-regex pattern against left's text", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "portableMatches", + left: { kind: "textLiteral", value: "active-123" }, + right: { kind: "textLiteral", value: "^active-\\d+$" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("portableMatches is false when the pattern does not match", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "portableMatches", + left: { kind: "textLiteral", value: "inactive" }, + right: { kind: "textLiteral", value: "^active$" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, false); + }); + + it("portableNotMatches is the negation of portableMatches", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "portableNotMatches", + left: { kind: "textLiteral", value: "inactive" }, + right: { kind: "textLiteral", value: "^active$" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("an invalid trilean-regex pattern is wrong-type, not a thrown exception", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "portableMatches", + left: { kind: "textLiteral", value: "anything" }, + right: { kind: "textLiteral", value: "(a)" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("portableMatches accepts only its own restricted grammar, rejecting an ECMAScript-only construct matches would accept", async () => { + // A lookahead is valid ECMAScript but not a construct trilean-regex's grammar defines -- see packages/trilean-regex/README.md's "Grammar" table. This is the load-bearing case distinguishing the two operator pairs: the same pattern text is accepted by 'matches' and rejected by 'portableMatches'. + const lookahead = { kind: "textLiteral" as const, value: "^(?=a)a$" }; + const viaMatches = await evaluatePredicate( + { + kind: "textCompare", + op: "matches", + left: { kind: "textLiteral", value: "a" }, + right: lookahead, + }, + undefined, + resolvers, + ); + expectDefinite(viaMatches, true); + + const viaPortableMatches = await evaluatePredicate( + { + kind: "textCompare", + op: "portableMatches", + left: { kind: "textLiteral", value: "a" }, + right: lookahead, + }, + undefined, + resolvers, + ); + expectIndeterminate(viaPortableMatches, "wrong-type"); + }); + + it("is wrong-type when the left operand is not text", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "equals", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "textLiteral", value: "1" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type when the right operand is not text", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "equals", + left: { kind: "textLiteral", value: "1" }, + right: { kind: "numberLiteral", value: 1 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("an invalid regular expression pattern is wrong-type, not a thrown exception", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "matches", + left: { kind: "textLiteral", value: "anything" }, + right: { kind: "textLiteral", value: "(" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("propagates an indeterminate left operand", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "missing" }, + right: { kind: "textLiteral", value: "active" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); + + it("propagates an indeterminate right operand", async () => { + const result = await evaluatePredicate( + { + kind: "textCompare", + op: "equals", + left: { kind: "textLiteral", value: "active" }, + right: { kind: "reference", key: "missing" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); +}); diff --git a/packages/trilean/src/evaluator.complex-arithmetic.test.ts b/packages/trilean/src/evaluator.complex-arithmetic.test.ts new file mode 100644 index 0000000..e106db2 --- /dev/null +++ b/packages/trilean/src/evaluator.complex-arithmetic.test.ts @@ -0,0 +1,341 @@ +import { describe, it } from "vitest"; +import { evaluateValue } from "./evaluator"; +import { + expectDefinite, + expectIndeterminate, + resolvers, +} from "./evaluator-test-helpers"; + +describe("arithmetic on complex values", () => { + it("add combines component-wise, preserving the shared unit", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "complexLiteral", re: 3, im: 4, unit: { V: 1 } }, + right: { kind: "complexLiteral", re: 1, im: -6, unit: { V: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "complex", + re: 4, + im: -2, + unit: { V: 1 }, + }); + }); + + it("subtract combines component-wise", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "subtract", + left: { kind: "complexLiteral", re: 3, im: 4 }, + right: { kind: "complexLiteral", re: 1, im: -6 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 2, im: 10 }); + }); + + it("add is wrong-type on mismatched units, exactly as it is for real numbers", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "complexLiteral", re: 3, im: 4, unit: { V: 1 } }, + right: { kind: "complexLiteral", re: 1, im: -6, unit: { A: 1 } }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("subtract is wrong-type on mismatched units", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "subtract", + left: { kind: "complexLiteral", re: 3, im: 4, unit: { V: 1 } }, + right: { kind: "complexLiteral", re: 1, im: -6 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("multiply is real complex multiplication, not component-wise, and combines units dimensionally", async () => { + // (3 + 4i)(1 - 2i) = (3 + 8) + (4 - 6)i = 11 - 2i -- a component-wise product would have given 3 - 8i instead. + const result = await evaluateValue( + { + kind: "arithmetic", + op: "multiply", + left: { kind: "complexLiteral", re: 3, im: 4, unit: { A: 1 } }, + right: { kind: "complexLiteral", re: 1, im: -2, unit: { V: 1, A: -1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "complex", + re: 11, + im: -2, + unit: { V: 1 }, + }); + }); + + it("divide is real complex division, and combines units dimensionally", async () => { + // (11 - 2i) / (1 - 2i) = ((11 + 4) + (-2 + 22)i) / 5 = 3 + 4i -- the exact inverse of the multiply above, chosen so the expected components are exactly representable. + const result = await evaluateValue( + { + kind: "arithmetic", + op: "divide", + left: { kind: "complexLiteral", re: 11, im: -2, unit: { V: 1 } }, + right: { kind: "complexLiteral", re: 1, im: -2, unit: { V: 1, A: -1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 3, im: 4, unit: { A: 1 } }); + }); + + it("divide by complex zero is domain-error, the same category as real division by zero", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "divide", + left: { kind: "complexLiteral", re: 3, im: 4 }, + right: { kind: "complexLiteral", re: 0, im: 0 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + + it("divide by a purely imaginary value is an ordinary quotient, not a division by zero", async () => { + // A zero real part alone must not be mistaken for a zero divisor: (4 + 0i) / (0 + 2i) = -2i. + const result = await evaluateValue( + { + kind: "arithmetic", + op: "divide", + left: { kind: "complexLiteral", re: 4, im: 0 }, + right: { kind: "complexLiteral", re: 0, im: 2 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 0, im: -2, unit: {} }); + }); + + it("modulo is domain-error -- there is no remainder on the complex plane", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "modulo", + left: { kind: "complexLiteral", re: 7, im: 1 }, + right: { kind: "complexLiteral", re: 3, im: 1 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); +}); + +describe("complex power -- a real integer exponent only", () => { + it("raises to a positive integer exponent by repeated multiplication", async () => { + // (1 + i)^4 = ((1 + i)^2)^2 = (2i)^2 = -4. + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 1, im: 1 }, + right: { kind: "numberLiteral", value: 4 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: -4, im: 0, unit: {} }); + }); + + it("raises to a negative integer exponent as the reciprocal of the positive power", async () => { + // (1 + i)^-2 = 1 / (1 + i)^2 = 1 / 2i = -0.5i. + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 1, im: 1 }, + right: { kind: "numberLiteral", value: -2 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 0, im: -0.5, unit: {} }); + }); + + it("raises to a zero exponent as the complex unit, exactly as a real base does", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 3, im: 4 }, + right: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 1, im: 0, unit: {} }); + }); + + it("is domain-error for a zero base with a negative exponent, the same as a real base", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 0, im: 0 }, + right: { kind: "numberLiteral", value: -1 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + + it("is wrong-type for a non-integer exponent -- an answer this design does not define, not one that does not exist", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 1, im: 1 }, + right: { kind: "numberLiteral", value: 0.5 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type for a complex exponent, whatever the base", async () => { + const complexExponent = { kind: "complexLiteral", re: 1, im: 2 } as const; + const complexBase = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 1, im: 1 }, + right: complexExponent, + }, + undefined, + resolvers, + ); + expectIndeterminate(complexBase, "wrong-type"); + + const realBase = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "numberLiteral", value: 2 }, + right: complexExponent, + }, + undefined, + resolvers, + ); + expectIndeterminate(realBase, "wrong-type"); + }); + + it("requires dimensionless operands, exactly as a real power does", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 1, im: 1, unit: { V: 1 } }, + right: { kind: "numberLiteral", value: 2 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); +}); + +describe("arithmetic mixing real and complex operands", () => { + it("promotes a real operand to the complex plane rather than rejecting the pair", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "numberLiteral", value: 5, unit: { V: 1 } }, + right: { kind: "complexLiteral", re: 1, im: -6, unit: { V: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 6, im: -6, unit: { V: 1 } }); + }); + + it("scales a complex value by a real one, combining units dimensionally", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "multiply", + left: { kind: "complexLiteral", re: 3, im: 4, unit: { A: 1 } }, + right: { kind: "numberLiteral", value: 2, unit: { V: 1, A: -1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "complex", + re: 6, + im: 8, + unit: { V: 1 }, + }); + }); + + it("stays complex even when the result's imaginary part is zero", async () => { + // The result kind follows the operand kinds, never the runtime values -- a tree's shape would otherwise depend on the data flowing through it. + const result = await evaluateValue( + { + kind: "arithmetic", + op: "subtract", + left: { kind: "complexLiteral", re: 3, im: 4 }, + right: { kind: "complexLiteral", re: 1, im: 4 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 2, im: 0 }); + }); + + it("is wrong-type when a complex operand meets a temporal one", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "complexLiteral", re: 3, im: 4 }, + right: { kind: "durationLiteral", value: 5, unit: "min" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type when a complex operand meets a text one", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "complexLiteral", re: 3, im: 4 }, + right: { kind: "textLiteral", value: "active" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); +}); diff --git a/packages/trilean/src/evaluator.control-flow.test.ts b/packages/trilean/src/evaluator.control-flow.test.ts new file mode 100644 index 0000000..c728d75 --- /dev/null +++ b/packages/trilean/src/evaluator.control-flow.test.ts @@ -0,0 +1,761 @@ +import { describe, expect, it } from "vitest"; +import { createEvaluator, evaluatePredicate, evaluateValue } from "./evaluator"; +import type { FunctionRegistry } from "./functions"; +import type { Resolvers } from "./resolvers"; +import type { PredicateNode } from "./tree"; +import { + expectDefinite, + expectIndeterminate, + isPlainRecord, + resolvers, +} from "./evaluator-test-helpers"; + +describe("conditional", () => { + const whenEquals = (left: number, right: number): PredicateNode => ({ + kind: "compare", + op: "eq", + left: { kind: "numberLiteral", value: left }, + right: { kind: "numberLiteral", value: right }, + }); + /** `@typescript-eslint/no-magic-numbers` only exempts -1/0/1/2 -- a third distinctly-numbered case below needs a named constant instead of a bare `3`. */ + const thirdCaseIdentifier = 3; + + it("evaluates the then of the first case whose when is definitely true, skipping earlier false cases", async () => { + const result = await evaluateValue( + { + kind: "conditional", + cases: [ + { + when: whenEquals(1, 2), + then: { kind: "numberLiteral", value: 100 }, + }, + { + when: whenEquals(1, 1), + then: { kind: "numberLiteral", value: 200 }, + }, + { + when: whenEquals(1, 1), + then: { kind: "numberLiteral", value: 300 }, + }, + ], + fallback: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "number", value: 200 }); + }); + + it("evaluates fallback when no case matches", async () => { + const result = await evaluateValue( + { + kind: "conditional", + cases: [ + { + when: whenEquals(1, 2), + then: { kind: "numberLiteral", value: 100 }, + }, + ], + fallback: { kind: "numberLiteral", value: 999 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "number", value: 999 }); + }); + + it("an empty cases list always evaluates to fallback", async () => { + const result = await evaluateValue( + { + kind: "conditional", + cases: [], + fallback: { kind: "numberLiteral", value: 999 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "number", value: 999 }); + }); + + it("never evaluates a later case's then once an earlier case has already matched", async () => { + let laterThenEvaluations = 0; + const trackingResolvers: Resolvers = { + ...resolvers, + resolveValue: async (key, context) => { + if (key === "later-then-marker") laterThenEvaluations += 1; + return resolvers.resolveValue(key, context); + }, + }; + const result = await evaluateValue( + { + kind: "conditional", + cases: [ + { + when: whenEquals(1, 1), + then: { kind: "numberLiteral", value: 200 }, + }, + ], + fallback: { kind: "reference", key: "later-then-marker" }, + }, + undefined, + trackingResolvers, + ); + expectDefinite(result, { kind: "number", value: 200 }); + expect(laterThenEvaluations).toBe(0); + }); + + // A `when` predicate that is always indeterminate (not-found), used across the hitPolicy tests below to distinguish "this case definitely doesn't match" from "this case's match status is unknown". + const whenIndeterminate: PredicateNode = { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "missing" }, + right: { kind: "numberLiteral", value: 0 }, + }; + + describe("hitPolicy: 'unique'", () => { + it("exactly one match: returns that case's then, and never evaluates any other case's then", async () => { + let secondThenEvaluations = 0; + const trackingResolvers: Resolvers = { + ...resolvers, + resolveValue: async (key, context) => { + if (key === "second-then-marker") secondThenEvaluations += 1; + return resolvers.resolveValue(key, context); + }, + }; + const result = await evaluateValue( + { + kind: "conditional", + hitPolicy: "unique", + cases: [ + { + when: whenEquals(1, 2), + then: { kind: "reference", key: "second-then-marker" }, + }, + { + when: whenEquals(1, 1), + then: { kind: "numberLiteral", value: 200 }, + }, + ], + fallback: { kind: "numberLiteral", value: 0 }, + }, + undefined, + trackingResolvers, + ); + expectDefinite(result, { kind: "number", value: 200 }); + expect(secondThenEvaluations).toBe(0); + }); + + it("zero matches: returns fallback, same as 'first'", async () => { + const result = await evaluateValue( + { + kind: "conditional", + hitPolicy: "unique", + cases: [ + { + when: whenEquals(1, 2), + then: { kind: "numberLiteral", value: 100 }, + }, + ], + fallback: { kind: "numberLiteral", value: 999 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "number", value: 999 }); + }); + + it("two matches: domain-error, regardless of case order", async () => { + const result = await evaluateValue( + { + kind: "conditional", + hitPolicy: "unique", + cases: [ + { + when: whenEquals(1, 1), + then: { kind: "numberLiteral", value: 100 }, + }, + { + when: whenEquals(2, 2), + then: { kind: "numberLiteral", value: 200 }, + }, + ], + fallback: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + if (result.status === "indeterminate") { + expect(result.reason.message).toBe( + "more than one case matched under the 'unique' hit policy", + ); + } + }); + + it("three matches: the same domain-error, not a distinct message", async () => { + const result = await evaluateValue( + { + kind: "conditional", + hitPolicy: "unique", + cases: [ + { + when: whenEquals(1, 1), + then: { kind: "numberLiteral", value: 1 }, + }, + { + when: whenEquals(2, 2), + then: { kind: "numberLiteral", value: 2 }, + }, + { + when: whenEquals(thirdCaseIdentifier, thirdCaseIdentifier), + then: { kind: "numberLiteral", value: thirdCaseIdentifier }, + }, + ], + fallback: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + if (result.status === "indeterminate") { + expect(result.reason.message).toBe( + "more than one case matched under the 'unique' hit policy", + ); + } + }); + + it("one match plus one unrelated indeterminate case: the whole node is indeterminate, NOT the match's then -- a single confirmed match does not absorb a remaining unresolved sibling under 'unique'", async () => { + const result = await evaluateValue( + { + kind: "conditional", + hitPolicy: "unique", + cases: [ + { + when: whenEquals(1, 1), + then: { kind: "numberLiteral", value: 100 }, + }, + { + when: whenIndeterminate, + then: { kind: "numberLiteral", value: 200 }, + }, + ], + fallback: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); + + it("two matches plus a third indeterminate case: still the domain-error -- 2+ matches absorbs even a genuine indeterminate elsewhere", async () => { + const result = await evaluateValue( + { + kind: "conditional", + hitPolicy: "unique", + cases: [ + { + when: whenEquals(1, 1), + then: { kind: "numberLiteral", value: 1 }, + }, + { + when: whenEquals(2, 2), + then: { kind: "numberLiteral", value: 2 }, + }, + { + when: whenIndeterminate, + then: { kind: "numberLiteral", value: 3 }, + }, + ], + fallback: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + + it("zero matches plus one indeterminate case: indeterminate", async () => { + const result = await evaluateValue( + { + kind: "conditional", + hitPolicy: "unique", + cases: [ + { + when: whenEquals(1, 2), + then: { kind: "numberLiteral", value: 1 }, + }, + { + when: whenIndeterminate, + then: { kind: "numberLiteral", value: 2 }, + }, + ], + fallback: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); + + it("absent hitPolicy behaves identically to explicit 'first'", async () => { + const cases = [ + { + when: whenEquals(1, 2), + then: { kind: "numberLiteral" as const, value: 100 }, + }, + { + when: whenEquals(1, 1), + then: { kind: "numberLiteral" as const, value: 200 }, + }, + ]; + const withoutHitPolicy = await evaluateValue( + { + kind: "conditional", + cases, + fallback: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + const withExplicitFirst = await evaluateValue( + { + kind: "conditional", + hitPolicy: "first", + cases, + fallback: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + expect(withoutHitPolicy).toEqual(withExplicitFirst); + expectDefinite(withoutHitPolicy, { kind: "number", value: 200 }); + }); + + it("evaluates every case's when concurrently, not short-circuited on the first match", async () => { + const caseCount = 3; + let whenEvaluationCount = 0; + const trackingResolvers: Resolvers = { + ...resolvers, + resolveValue: async (key, context) => { + if (key === "when-marker") whenEvaluationCount += 1; + return resolvers.resolveValue(key, context); + }, + }; + const whenMarker: PredicateNode = { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "when-marker" }, + right: { kind: "numberLiteral", value: 0 }, + }; + await evaluateValue( + { + kind: "conditional", + hitPolicy: "unique", + cases: [ + { when: whenMarker, then: { kind: "numberLiteral", value: 1 } }, + { when: whenMarker, then: { kind: "numberLiteral", value: 2 } }, + { when: whenMarker, then: { kind: "numberLiteral", value: 3 } }, + ], + fallback: { kind: "numberLiteral", value: 0 }, + }, + undefined, + trackingResolvers, + ); + expect(whenEvaluationCount).toBe(caseCount); + }); + }); +}); + +describe("delegate", () => { + it("is wrong-type when no delegate handler is registered", async () => { + const result = await evaluateValue( + { kind: "delegate", system: "tax-engine", payload: { rate: 0.2 } }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("resolves via the registered handler when one is provided", async () => { + const delegateResolvers: Resolvers = { + ...resolvers, + resolveDelegate: async (system) => + Promise.resolve( + system === "tax-engine" + ? { found: true, value: { kind: "number", value: 42 } } + : { found: false }, + ), + }; + const result = await evaluateValue( + { kind: "delegate", system: "tax-engine", payload: null }, + undefined, + delegateResolvers, + ); + expectDefinite(result, { kind: "number", value: 42 }); + }); + + it("is not-found when the registered handler itself reports absence", async () => { + const delegateResolvers: Resolvers = { + ...resolvers, + resolveDelegate: async () => Promise.resolve({ found: false }), + }; + const result = await evaluateValue( + { kind: "delegate", system: "tax-engine", payload: null }, + undefined, + delegateResolvers, + ); + expectIndeterminate(result, "not-found"); + }); +}); + +describe("treeReference", () => { + it("evaluatePredicate: is wrong-type when no tree resolver is registered", async () => { + const result = await evaluatePredicate( + { kind: "treeReference", key: "eligibility" }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("evaluateValue: is wrong-type when no tree resolver is registered", async () => { + const result = await evaluateValue( + { kind: "treeReference", key: "pricing" }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("evaluatePredicate: is not-found when the resolver reports absence", async () => { + const treeResolvers: Resolvers = { + ...resolvers, + resolveTree: async () => Promise.resolve({ found: false }), + }; + const result = await evaluatePredicate( + { kind: "treeReference", key: "eligibility" }, + undefined, + treeResolvers, + ); + expectIndeterminate(result, "not-found"); + }); + + it("evaluatePredicate: resolves and evaluates the referenced predicate tree, with context passed through unchanged", async () => { + const treeResolvers: Resolvers = { + ...resolvers, + resolveTree: async (key) => + Promise.resolve( + key === "eligibility" + ? { + found: true, + node: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 21 }, + }, + } + : { found: false }, + ), + resolveValue: async (key, context) => + Promise.resolve( + key === "age" && isPlainRecord(context) && "age" in context + ? { + found: true, + value: { kind: "number", value: context.age as number }, + } + : { found: false }, + ), + }; + const result = await evaluatePredicate( + { kind: "treeReference", key: "eligibility" }, + { age: 21 }, + treeResolvers, + ); + expectDefinite(result, true); + }); + + it("evaluateValue: resolves and evaluates the referenced expression tree, with the enclosing fold's accumulator passed through unchanged", async () => { + const treeResolvers: Resolvers = { + ...resolvers, + resolveTree: async (key) => + Promise.resolve( + key === "double-accumulator" + ? { + found: true, + node: { + kind: "arithmetic", + op: "multiply", + left: { kind: "accumulator" }, + right: { kind: "numberLiteral", value: 2 }, + }, + } + : { found: false }, + ), + resolveCollection: async (collection) => + Promise.resolve(collection === "items" ? [{}] : []), + }; + const result = await evaluateValue( + { + kind: "fold", + collection: "items", + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 5 }, + combine: { kind: "treeReference", key: "double-accumulator" }, + }, + }, + undefined, + treeResolvers, + ); + expectDefinite(result, { kind: "number", value: 10, unit: {} }); + }); + + it("evaluatePredicate: is wrong-type when the resolved node fails to parse as a PredicateNode", async () => { + const treeResolvers: Resolvers = { + ...resolvers, + resolveTree: async () => + Promise.resolve({ found: true, node: { kind: "not-a-real-kind" } }), + }; + const result = await evaluatePredicate( + { kind: "treeReference", key: "malformed" }, + undefined, + treeResolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("evaluateValue: is wrong-type when the resolved node fails to parse as an ExpressionNode", async () => { + const treeResolvers: Resolvers = { + ...resolvers, + resolveTree: async () => + Promise.resolve({ found: true, node: "just a string, not a node" }), + }; + const result = await evaluateValue( + { kind: "treeReference", key: "malformed" }, + undefined, + treeResolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("direct self-reference is a domain-error: circular treeReference detected", async () => { + const treeResolvers: Resolvers = { + ...resolvers, + resolveTree: async () => + Promise.resolve({ + found: true, + node: { kind: "treeReference", key: "self" }, + }), + }; + const result = await evaluatePredicate( + { kind: "treeReference", key: "self" }, + undefined, + treeResolvers, + ); + expectIndeterminate(result, "domain-error"); + if (result.status === "indeterminate") { + expect(result.reason.message).toBe("circular treeReference detected"); + } + }); + + it("indirect A -> B -> A cycle is a domain-error", async () => { + const treeResolvers: Resolvers = { + ...resolvers, + resolveTree: async (key) => + Promise.resolve({ + found: true, + node: { + kind: "treeReference", + key: key === "ruleA" ? "ruleB" : "ruleA", + }, + }), + }; + const result = await evaluatePredicate( + { kind: "treeReference", key: "ruleA" }, + undefined, + treeResolvers, + ); + expectIndeterminate(result, "domain-error"); + if (result.status === "indeterminate") { + expect(result.reason.message).toBe("circular treeReference detected"); + } + }); + + it("a long acyclic chain past the maximum depth is a domain-error, not a cycle", async () => { + const treeResolvers: Resolvers = { + ...resolvers, + resolveTree: async (key) => + Promise.resolve({ + found: true, + node: { + kind: "treeReference", + key: `${typeof key === "string" ? key : "chain"}-next`, + }, + }), + }; + const result = await evaluatePredicate( + { kind: "treeReference", key: "chain-0" }, + undefined, + treeResolvers, + ); + expectIndeterminate(result, "domain-error"); + if (result.status === "indeterminate") { + expect(result.reason.message).toBe( + "treeReference chain exceeds the maximum depth of 100", + ); + } + }); + + it("a treeReference nested inside a some/every filter still participates in cycle detection correctly", async () => { + const treeResolvers: Resolvers = { + ...resolvers, + resolveCollection: async (collection) => + Promise.resolve(collection === "items" ? [{}] : []), + resolveTree: async () => + Promise.resolve({ + found: true, + node: { kind: "treeReference", key: "self-in-filter" }, + }), + }; + await expect( + evaluatePredicate( + { + kind: "some", + collection: "items", + item: { + kind: "exists", + operand: { kind: "numberLiteral", value: 1 }, + }, + filter: { kind: "treeReference", key: "self-in-filter" }, + }, + undefined, + treeResolvers, + ), + ).resolves.toMatchObject({ + status: "indeterminate", + reason: { + code: "domain-error", + message: "circular treeReference detected", + }, + }); + }); + + it("a treeReference nested inside a conditional's when still participates in cycle detection correctly", async () => { + const treeResolvers: Resolvers = { + ...resolvers, + resolveTree: async () => + Promise.resolve({ + found: true, + node: { kind: "treeReference", key: "self-in-when" }, + }), + }; + await expect( + evaluateValue( + { + kind: "conditional", + cases: [ + { + when: { kind: "treeReference", key: "self-in-when" }, + then: { kind: "numberLiteral", value: 1 }, + }, + ], + fallback: { kind: "numberLiteral", value: 0 }, + }, + undefined, + treeResolvers, + ), + ).resolves.toMatchObject({ + status: "indeterminate", + reason: { + code: "domain-error", + message: "circular treeReference detected", + }, + }); + }); +}); + +describe("call", () => { + const doubleFn: FunctionRegistry = { + double: (args) => { + const [arg] = args; + if (arg?.kind !== "number") { + return { domainError: "double requires a single number argument" }; + } + return { kind: "number", value: arg.value + arg.value }; + }, + }; + + it("invokes the registered function with the resolved arguments", async () => { + const { evaluateValue: evaluate } = createEvaluator({ + functions: doubleFn, + }); + const result = await evaluate( + { + kind: "call", + fn: "double", + args: [{ kind: "numberLiteral", value: 21 }], + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "number", value: 42 }); + }); + + it("is wrong-type for an unregistered function name", async () => { + const result = await evaluateValue( + { kind: "call", fn: "doesNotExist", args: [] }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + /** A `FunctionRegistry` is an ordinary object, so `Object.prototype`'s own members answer a bare index lookup on it. A tree naming one of those is naming a function nobody registered, and must be reported as such rather than reaching a prototype method -- which would either throw or produce a "definite" result that is not a `ComputedValue` at all. */ + it.each(["toString", "valueOf", "constructor", "hasOwnProperty"])( + "treats the inherited Object.prototype member '%s' as an unregistered function name", + async (fn) => { + const result = await evaluateValue( + { kind: "call", fn, args: [] }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }, + ); + + it("is domain-error when the registered function reports one", async () => { + const { evaluateValue: evaluate } = createEvaluator({ + functions: doubleFn, + }); + const result = await evaluate( + { + kind: "call", + fn: "double", + args: [{ kind: "textLiteral", value: "x" }], + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + + it("evaluates every argument concurrently, tie-breaking an indeterminate outcome to the first in declared order", async () => { + const { evaluateValue: evaluate } = createEvaluator({ + functions: doubleFn, + }); + const result = await evaluate( + { + kind: "call", + fn: "double", + args: [ + { kind: "reference", key: "missing" }, + { kind: "reference", key: "also-missing" }, + ], + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); +}); diff --git a/packages/trilean/src/evaluator.integration.test.ts b/packages/trilean/src/evaluator.integration.test.ts new file mode 100644 index 0000000..69d702d --- /dev/null +++ b/packages/trilean/src/evaluator.integration.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from "vitest"; +import { createEvaluator, evaluatePredicate, evaluateValue } from "./evaluator"; +import type { Resolvers } from "./resolvers"; +import type { ExpressionNode, PredicateNode } from "./tree"; +import { + expectDefinite, + expectIndeterminate, + isPlainRecord, + isUnknownArray, + resolvers, +} from "./evaluator-test-helpers"; + +describe("evaluation resource limits", () => { + /** A `depth`-deep chain of `not` wrapping a single leaf `exists` node, built iteratively (never recursively) so constructing the fixture itself never taxes the test runner's own call stack. No `treeReference` node appears anywhere in this tree, so `MAX_TREE_REFERENCE_DEPTH`'s chain-depth counter never advances while evaluating it -- this is deliberately the shape that guard cannot see, since it only checks depth across `treeReference` hops between separately-stored trees, never ordinary recursive descent within one self-contained tree. */ + function deeplyNestedNot(depth: number): PredicateNode { + let node: PredicateNode = { + kind: "exists", + operand: { kind: "numberLiteral", value: 1 }, + }; + for (let level = 0; level < depth; level += 1) { + node = { kind: "not", operand: node }; + } + return node; + } + + /** A wide-but-shallow `allOf` of `count` trivial `exists` leaves -- every leaf contributes to the total node-visit count while adding only two levels of nesting depth regardless of `count`, isolating the node-count cap from the nesting-depth cap below. */ + function wideAllOf(count: number): PredicateNode { + return { + kind: "allOf", + operands: Array.from({ length: count }, () => ({ + kind: "exists" as const, + operand: { kind: "numberLiteral" as const, value: 1 }, + })), + }; + } + + /** The explicit `maxNodes` configured for the node-count-cap tests below, chosen only to be comfortably smaller than `wideOperandCountExceedingNodeCap`'s resulting node count -- its exact value carries no other significance. */ + const explicitNodeCap = 50; + + /** The explicit `maxNestingDepth` configured for the nesting-depth-cap tests below, chosen only to be comfortably smaller than `notDepthExceedingNestingCap` -- its exact value carries no other significance. */ + const explicitNestingDepthCap = 10; + + /** `allOf` operand count for the node-count-cap test: the allOf node itself plus this many two-node (`exists` + `numberLiteral`) operands comfortably exceeds `explicitNodeCap`, while the tree stays only two levels deep -- nowhere near any nesting-depth cap, isolating that assertion to the node-count cap alone. */ + const wideOperandCountExceedingNodeCap = 60; + + /** `not`-chain depth for the nesting-depth-cap test: comfortably exceeds `explicitNestingDepthCap`, while the resulting total node count stays nowhere near any default or explicit node-count cap, isolating that assertion to the nesting-depth cap alone. */ + const notDepthExceedingNestingCap = 50; + + /** `not`-chain depth for the "well within both caps" test: an odd number of negations flips a definitely-true `exists` leaf to false, comfortably inside both `explicitNodeCap` and `explicitNestingDepthCap`. */ + const notDepthWithinBothCaps = 5; + + /** `not`-chain depth used to prove `createEvaluator({})`'s own defaults catch an oversized tree: comfortably past `DEFAULT_MAX_NESTING_DEPTH` while staying well short of the depth at which this shape has been observed to exhaust the real call stack (see the issue this addresses for how that was measured). */ + const notDepthExceedingDefaultNestingCap = 1000; + + it("a node-count cap configured via createEvaluator rejects a wide-but-shallow tree that exceeds it, as indeterminate", async () => { + const { evaluatePredicate: evaluate } = createEvaluator({ + maxNodes: explicitNodeCap, + }); + const result = await evaluate( + wideAllOf(wideOperandCountExceedingNodeCap), + undefined, + resolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("domain-error"); + expect(result.reason.message).toMatch(/node/i); + } + }); + + it("a nesting-depth cap configured via createEvaluator rejects a narrow-but-deep tree that exceeds it, as indeterminate", async () => { + const { evaluatePredicate: evaluate } = createEvaluator({ + maxNestingDepth: explicitNestingDepthCap, + }); + const result = await evaluate( + deeplyNestedNot(notDepthExceedingNestingCap), + undefined, + resolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("domain-error"); + expect(result.reason.message).toMatch(/depth/i); + } + }); + + it("a tree well within both caps evaluates normally and correctly", async () => { + const { evaluatePredicate: evaluate } = createEvaluator({ + maxNodes: explicitNodeCap, + maxNestingDepth: explicitNestingDepthCap, + }); + const result = await evaluate( + deeplyNestedNot(notDepthWithinBothCaps), + undefined, + resolvers, + ); + expectDefinite(result, false); + }); + + it("MAX_TREE_REFERENCE_DEPTH's own chain-depth guard is unaffected by the nesting-depth cap", async () => { + const treeResolvers: Resolvers = { + ...resolvers, + resolveTree: async (key) => + Promise.resolve({ + found: true, + node: { + kind: "treeReference", + key: `${typeof key === "string" ? key : "chain"}-next`, + }, + }), + }; + const result = await evaluatePredicate( + { kind: "treeReference", key: "chain-0" }, + undefined, + treeResolvers, + ); + expectIndeterminate(result, "domain-error"); + if (result.status === "indeterminate") { + expect(result.reason.message).toBe( + "treeReference chain exceeds the maximum depth of 100", + ); + } + }); + + it("createEvaluator({})'s defaults catch a real oversized tree without any explicit cap configuration", async () => { + // A correct default catches this deterministically as indeterminate; the pre-fix evaluator instead resolved it to a (wrong, but not crashing) definite value at this particular depth. + const result = await evaluatePredicate( + deeplyNestedNot(notDepthExceedingDefaultNestingCap), + undefined, + resolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("domain-error"); + expect(result.reason.message).toMatch(/depth/i); + } + }); + + it("createEvaluator({})'s defaults catch a real oversized tree via evaluateValue too, not only evaluatePredicate", async () => { + let node: ExpressionNode = { kind: "numberLiteral", value: 1 }; + for ( + let level = 0; + level < notDepthExceedingDefaultNestingCap; + level += 1 + ) { + node = { kind: "negate", operand: node }; + } + const result = await evaluateValue(node, undefined, resolvers); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("domain-error"); + expect(result.reason.message).toMatch(/depth/i); + } + }); +}); + +/** README.md's own "Worked example" (§ Worked example), verbatim: `isActive equals 1` AND `sum(items.amount) > x + y`, where the sum is exactly the `fold` tree the `sum` derived-aggregate builder assembles. Both variations from the README are included, exercising the propagation rules the worked example is there to demonstrate -- absorption via `and`'s definitely-false right operand, versus a missing reference surfacing all the way to the top because `true` is not absorbing for `and`. */ +describe("golden example (README Worked example)", () => { + const goldenExampleResolvers: Resolvers = { + resolveValue: async (key, context) => { + if ( + typeof key !== "string" || + !isPlainRecord(context) || + !(key in context) + ) { + return Promise.resolve({ found: false }); + } + const value = context[key]; + return Promise.resolve( + typeof value === "number" + ? { found: true, value: { kind: "number", value } } + : { found: false }, + ); + }, + resolveLookup: async () => Promise.resolve({ found: false }), + resolveCollection: async (collection, context) => { + if (collection !== "items" || !isPlainRecord(context)) { + return Promise.resolve([]); + } + const items = context.items; + return Promise.resolve(isUnknownArray(items) ? items : []); + }, + }; + + const node: PredicateNode = { + kind: "and", + left: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "isActive" }, + right: { kind: "numberLiteral", value: 1 }, + }, + right: { + kind: "compare", + op: "gt", + left: { + kind: "fold", + collection: "items", + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 0 }, + combine: { + kind: "arithmetic", + op: "add", + left: { kind: "accumulator" }, + right: { kind: "reference", key: "amount" }, + }, + }, + }, + right: { + kind: "arithmetic", + op: "add", + left: { kind: "reference", key: "x" }, + right: { kind: "reference", key: "y" }, + }, + }, + }; + + it("base case: isActive=1, sum(items.amount)=21 > x+y=15 => definitely true", async () => { + const data = { + isActive: 1, + x: 10, + y: 5, + items: [{ amount: 8 }, { amount: 12 }, { amount: 1 }], + }; + const result = await evaluatePredicate(node, data, goldenExampleResolvers); + expect(result).toEqual({ status: "definite", value: true }); + }); + + it("empty items: the sum-over-empty identity (0) is not > 15, and false absorbs regardless of the left branch", async () => { + const data = { isActive: 1, x: 10, y: 5, items: [] as unknown[] }; + const result = await evaluatePredicate(node, data, goldenExampleResolvers); + expect(result).toEqual({ status: "definite", value: false }); + }); + + it("missing x: not-found surfaces to the top, since true is not absorbing for and", async () => { + const data = { + isActive: 1, + y: 5, + items: [{ amount: 8 }, { amount: 12 }, { amount: 1 }], + }; + const result = await evaluatePredicate(node, data, goldenExampleResolvers); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("not-found"); + } + }); +}); diff --git a/packages/trilean/src/evaluator.membership.test.ts b/packages/trilean/src/evaluator.membership.test.ts new file mode 100644 index 0000000..44b2c90 --- /dev/null +++ b/packages/trilean/src/evaluator.membership.test.ts @@ -0,0 +1,512 @@ +import { describe, expect, it } from "vitest"; +import { evaluatePredicate } from "./evaluator"; +import type { Resolvers } from "./resolvers"; +import type { ExpressionNode } from "./tree"; +import { + expectDefinite, + expectIndeterminate, + isPlainRecord, + resolvers, +} from "./evaluator-test-helpers"; + +describe("memberOf", () => { + it("in: a definite match makes the leaf definitely true", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "numberLiteral", value: 5 }, + candidates: [ + { kind: "numberLiteral", value: 3 }, + { kind: "numberLiteral", value: 5 }, + ], + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("notIn: a definite match makes the leaf definitely false", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "notIn", + operand: { kind: "numberLiteral", value: 5 }, + candidates: [{ kind: "numberLiteral", value: 5 }], + }, + undefined, + resolvers, + ); + expectDefinite(result, false); + }); + + it("boolean operands participate under the same kind-agnostic equality rule as every other kind", async () => { + const matchResult = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "booleanLiteral", value: true }, + candidates: [ + { kind: "booleanLiteral", value: false }, + { kind: "booleanLiteral", value: true }, + ], + }, + undefined, + resolvers, + ); + expectDefinite(matchResult, true); + + const nonMatchResult = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "booleanLiteral", value: true }, + candidates: [{ kind: "booleanLiteral", value: false }], + }, + undefined, + resolvers, + ); + expectDefinite(nonMatchResult, false); + }); + + it("is wrong-type when a boolean operand is compared against a non-boolean candidate for membership", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "booleanLiteral", value: true }, + candidates: [{ kind: "numberLiteral", value: 1 }], + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("matches a complex candidate on both components at once", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "complexLiteral", re: 3, im: -4 }, + candidates: [ + { kind: "complexLiteral", re: 3, im: 4 }, + { kind: "complexLiteral", re: 3, im: -4 }, + ], + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("is wrong-type when a complex operand meets a candidate of another kind", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "complexLiteral", re: 3, im: 0 }, + candidates: [{ kind: "numberLiteral", value: 3 }], + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type when a complex candidate carries an incompatible unit", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "complexLiteral", re: 3, im: -4, unit: { V: 1 } }, + candidates: [{ kind: "complexLiteral", re: 3, im: -4, unit: { A: 1 } }], + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("a definite match short-circuits the result past a later indeterminate candidate", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "numberLiteral", value: 5 }, + candidates: [ + { kind: "numberLiteral", value: 5 }, + { kind: "reference", key: "missing" }, + ], + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("an empty candidates list is never scanned: in is definitely false, notIn is definitely true", async () => { + const inResult = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "numberLiteral", value: 5 }, + candidates: [], + }, + undefined, + resolvers, + ); + expectDefinite(inResult, false); + + const notInResult = await evaluatePredicate( + { + kind: "memberOf", + op: "notIn", + operand: { kind: "numberLiteral", value: 5 }, + candidates: [], + }, + undefined, + resolvers, + ); + expectDefinite(notInResult, true); + }); + + it("no match among definite, comparable candidates: in is false, notIn is true", async () => { + const candidates: ExpressionNode[] = [ + { kind: "numberLiteral", value: 1 }, + { kind: "numberLiteral", value: 2 }, + ]; + + const inResult = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "numberLiteral", value: 5 }, + candidates, + }, + undefined, + resolvers, + ); + expectDefinite(inResult, false); + + const notInResult = await evaluatePredicate( + { + kind: "memberOf", + op: "notIn", + operand: { kind: "numberLiteral", value: 5 }, + candidates, + }, + undefined, + resolvers, + ); + expectDefinite(notInResult, true); + }); + + it("tie-breaks several indeterminate/incompatible candidates to the first's reason, per declared order", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "numberLiteral", value: 5 }, + candidates: [ + { kind: "textLiteral", value: "5" }, // incompatible kind -> wrong-type + { kind: "reference", key: "missing" }, // not-found + ], + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("an indeterminate operand makes the whole leaf indeterminate without ever scanning a candidate", async () => { + let candidateEvaluations = 0; + const trackingResolvers: Resolvers = { + ...resolvers, + resolveValue: async (key, context) => { + if (key === "candidate-marker") candidateEvaluations += 1; + return resolvers.resolveValue(key, context); + }, + }; + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "missing" }, + candidates: [{ kind: "reference", key: "candidate-marker" }], + }, + undefined, + trackingResolvers, + ); + expectIndeterminate(result, "not-found"); + expect(candidateEvaluations).toBe(0); + }); + + it("respects units for numeric candidates -- an incompatible unit is wrong-type, never a plain non-match", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "numberLiteral", value: 5, unit: { m: 1 } }, + candidates: [{ kind: "numberLiteral", value: 5, unit: { s: 1 } }], + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); +}); + +describe("exists", () => { + it("a definite operand makes exists definitely true", async () => { + const result = await evaluatePredicate( + { kind: "exists", operand: { kind: "numberLiteral", value: 1 } }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("not-found converts to definitely false", async () => { + const result = await evaluatePredicate( + { kind: "exists", operand: { kind: "reference", key: "missing" } }, + undefined, + resolvers, + ); + expectDefinite(result, false); + }); + + it("wrong-type converts to definitely true -- the data point resolved, it just wasn't usable", async () => { + const result = await evaluatePredicate( + { + kind: "exists", + operand: { kind: "reference", key: "present", unit: { s: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("domain-error converts to definitely true -- the data point resolved, it just wasn't usable", async () => { + const result = await evaluatePredicate( + { + kind: "exists", + operand: { + kind: "arithmetic", + op: "divide", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "numberLiteral", value: 0 }, + }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); +}); + +describe("some, every", () => { + /** A resolver whose `resolveCollection` maps a small set of named collection references onto plain-object item arrays, and whose `resolveValue` reads a field straight off whatever item context it's given -- mirroring `resolveCollection`/`resolveValue`'s real relationship: descending into a quantifier replaces the evaluation context with the resolved item itself (see README.md's "Evaluation context" section), so a `reference` inside `item`/`filter` resolves against that one item, not the outer context. */ + const quantifierResolvers: Resolvers = { + resolveValue: async (key, context) => { + if ( + typeof key !== "string" || + !isPlainRecord(context) || + !(key in context) + ) { + return Promise.resolve({ found: false }); + } + const value = context[key]; + if (typeof value === "number") { + return Promise.resolve({ + found: true, + value: { kind: "number", value }, + }); + } + if (typeof value === "string") { + return Promise.resolve({ found: true, value: { kind: "text", value } }); + } + return Promise.resolve({ found: false }); + }, + resolveLookup: async () => Promise.resolve({ found: false }), + resolveCollection: async (collection) => { + if (collection === "amounts") { + return Promise.resolve([{ amount: 1 }, { amount: 2 }, { amount: 30 }]); + } + if (collection === "grouped") { + return Promise.resolve([ + { group: "keep", amount: 5 }, + { group: "skip", amount: 999 }, + ]); + } + if (collection === "flagged") { + return Promise.resolve([ + { flag: 1, amount: 100 }, + { amount: 5 }, // no `flag` field -- the filter itself is indeterminate for this item + ]); + } + return Promise.resolve([]); + }, + }; + + it("some is definitely true when at least one participating item's item predicate matches", async () => { + const result = await evaluatePredicate( + { + kind: "some", + collection: "amounts", + item: { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "amount" }, + right: { kind: "numberLiteral", value: 10 }, + }, + }, + undefined, + quantifierResolvers, + ); + expectDefinite(result, true); + }); + + it("some is definitely false when no participating item's item predicate matches", async () => { + const result = await evaluatePredicate( + { + kind: "some", + collection: "amounts", + item: { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "amount" }, + right: { kind: "numberLiteral", value: 1000 }, + }, + }, + undefined, + quantifierResolvers, + ); + expectDefinite(result, false); + }); + + it("every is definitely true when every participating item's item predicate matches", async () => { + const result = await evaluatePredicate( + { + kind: "every", + collection: "amounts", + item: { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "amount" }, + right: { kind: "numberLiteral", value: 0 }, + }, + }, + undefined, + quantifierResolvers, + ); + expectDefinite(result, true); + }); + + it("every is definitely false when at least one participating item's item predicate fails to match", async () => { + const result = await evaluatePredicate( + { + kind: "every", + collection: "amounts", + item: { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "amount" }, + right: { kind: "numberLiteral", value: 10 }, + }, + }, + undefined, + quantifierResolvers, + ); + expectDefinite(result, false); + }); + + it("filter excludes an item entirely, as if it had never been in the collection", async () => { + const result = await evaluatePredicate( + { + kind: "some", + collection: "grouped", + filter: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "group" }, + right: { kind: "textLiteral", value: "keep" }, + }, + item: { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "amount" }, + right: { kind: "numberLiteral", value: 500 }, + }, + }, + undefined, + quantifierResolvers, + ); + // The only participating item ("keep") has amount 5, well under 500; the excluded item's amount 999 would satisfy `item`, but it never gets to participate. + expectDefinite(result, false); + }); + + it("a filter-indeterminate item's own vote is absorbed by a different item's clean match", async () => { + const result = await evaluatePredicate( + { + kind: "some", + collection: "flagged", + filter: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "flag" }, + right: { kind: "numberLiteral", value: 1 }, + }, + item: { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "amount" }, + right: { kind: "numberLiteral", value: 10 }, + }, + }, + undefined, + quantifierResolvers, + ); + // The second item has no `flag` field, so its filter is indeterminate and its vote is indeterminate too -- but the first item's clean match (flag 1, amount 100 > 10) still absorbs it to a definite true, exactly as OR's own absorption already requires. + expectDefinite(result, true); + }); + + // Deliberate, settled per README.md: "every over an empty collection is definitely true" -- vacuous truth, the standard convention for universal quantification over an empty set. This must never be "fixed" to false. + it("every over an empty collection is definitely true", async () => { + const result = await evaluatePredicate( + { + kind: "every", + collection: "nothing-here", + item: { + kind: "compare", + op: "eq", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "numberLiteral", value: 1 }, + }, + }, + undefined, + quantifierResolvers, + ); + expectDefinite(result, true); + }); + + it("some over an empty collection is definitely false", async () => { + const result = await evaluatePredicate( + { + kind: "some", + collection: "nothing-here", + item: { + kind: "compare", + op: "eq", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "numberLiteral", value: 1 }, + }, + }, + undefined, + quantifierResolvers, + ); + expectDefinite(result, false); + }); +}); diff --git a/packages/trilean/src/evaluator.temporal.test.ts b/packages/trilean/src/evaluator.temporal.test.ts new file mode 100644 index 0000000..1cfea0c --- /dev/null +++ b/packages/trilean/src/evaluator.temporal.test.ts @@ -0,0 +1,221 @@ +import { describe, it } from "vitest"; +import { evaluatePredicate, evaluateValue } from "./evaluator"; +import type { ExpressionNode } from "./tree"; +import { + expectDefinite, + expectIndeterminate, + resolvers, +} from "./evaluator-test-helpers"; + +describe("temporal arithmetic -- the four well-defined combination rules", () => { + it("instant - instant => duration, in milliseconds", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "subtract", + left: { kind: "instantLiteral", value: "2026-01-01T00:01:00.000Z" }, + right: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "duration", value: 60_000, unit: "ms" }); + }); + + it("instant + duration => instant", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + right: { kind: "durationLiteral", value: 1, unit: "min" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "instant", + value: "2026-01-01T00:01:00.000Z", + }); + }); + + it("duration + instant => instant", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "durationLiteral", value: 1, unit: "min" }, + right: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "instant", + value: "2026-01-01T00:01:00.000Z", + }); + }); + + it("duration + duration => duration, normalising different units to milliseconds", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "durationLiteral", value: 1, unit: "min" }, + right: { kind: "durationLiteral", value: 30, unit: "s" }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "duration", value: 90_000, unit: "ms" }); + }); +}); + +describe("temporal arithmetic -- wrong-type violations", () => { + it("adding two instants is wrong-type (only instant - instant is defined)", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + right: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("subtracting a duration from an instant is wrong-type (not one of the defined combinations)", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "subtract", + left: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + right: { kind: "durationLiteral", value: 1, unit: "min" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("comparing an instant against a plain number is wrong-type", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + right: { kind: "numberLiteral", value: 1 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + /** An `instant`'s value is an opaque string until something parses it: nothing in the schema layer checks that it really is an ISO-8601 timestamp, and a resolver can return whatever its backing data holds. Every operation that has to parse one must therefore keep an unparseable value inside the three-outcome model rather than letting `Date.parse`'s `NaN` leak onward -- silently, as a definite result computed from `NaN`, or loudly, as a thrown `RangeError` from `toISOString`. */ + describe("an unparseable instant is wrong-type, never a throw or a NaN-derived definite result", () => { + const unparseable: ExpressionNode = { + kind: "instantLiteral", + value: "not-a-timestamp", + }; + const parseable: ExpressionNode = { + kind: "instantLiteral", + value: "2026-01-01T00:00:00.000Z", + }; + const oneHour: ExpressionNode = { + kind: "durationLiteral", + value: 1, + unit: "h", + }; + + it("instant - instant", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "subtract", + left: unparseable, + right: parseable, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("instant + duration", async () => { + const result = await evaluateValue( + { kind: "arithmetic", op: "add", left: unparseable, right: oneHour }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("duration + instant", async () => { + const result = await evaluateValue( + { kind: "arithmetic", op: "add", left: oneHour, right: unparseable }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("compare -- neq between two unparseable instants is not definitely true", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "neq", + left: unparseable, + right: unparseable, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("memberOf -- an unparseable candidate is not a definite non-match", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: parseable, + candidates: [unparseable], + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("shifting a parseable instant beyond the representable timestamp range is domain-error", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: parseable, + right: { kind: "durationLiteral", value: 1e18, unit: "d" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + }); + + it("multiplying two durations is wrong-type (no representable result unit)", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "multiply", + left: { kind: "durationLiteral", value: 1, unit: "min" }, + right: { kind: "durationLiteral", value: 2, unit: "s" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); +}); diff --git a/packages/trilean/src/evaluator.test.ts b/packages/trilean/src/evaluator.test.ts deleted file mode 100644 index a66558b..0000000 --- a/packages/trilean/src/evaluator.test.ts +++ /dev/null @@ -1,3398 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createEvaluator, evaluatePredicate, evaluateValue } from "./evaluator"; -import type { Evaluation } from "./evaluation"; -import type { FunctionRegistry } from "./functions"; -import type { ExpressionNode, PredicateNode } from "./tree"; -import type { Resolvers } from "./resolvers"; - -/** A resolver reporting a fixed `{ kind: "number", value: 10, unit: { m: 1 } }` for key `"present"`, and not-found for anything else -- `resolveLookup`/`resolveCollection` are never exercised by expression-level nodes and are stubbed only to satisfy `Resolvers`. */ -const resolvers: Resolvers = { - resolveValue: async (key) => - Promise.resolve( - key === "present" - ? { found: true, value: { kind: "number", value: 10, unit: { m: 1 } } } - : { found: false }, - ), - resolveLookup: async () => Promise.resolve({ found: false }), - resolveCollection: async () => Promise.resolve([]), -}; - -function expectDefinite(evaluation: Evaluation, expected: T): void { - expect(evaluation).toEqual({ status: "definite", value: expected }); -} - -function expectIndeterminate( - evaluation: Evaluation, - code: "not-found" | "wrong-type" | "domain-error", -): void { - expect(evaluation.status).toBe("indeterminate"); - if (evaluation.status === "indeterminate") { - expect(evaluation.reason.code).toBe(code); - } -} - -/** A narrowing type guard for the golden-example checkpoint's resolver context below, following this repo's `object -> Record` narrowing convention rather than an `as Record` assertion. */ -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** `Array.isArray`'s own lib.es5.d.ts type predicate narrows to `any[]`, not `unknown[]` -- this re-typed wrapper is what lets `resolveCollection` below return a properly `unknown[]`-typed result instead of an implicit `any[]`. */ -function isUnknownArray(value: unknown): value is unknown[] { - return Array.isArray(value); -} - -describe("literals", () => { - it("numberLiteral is always definite", async () => { - const result = await evaluateValue( - { kind: "numberLiteral", value: 42, unit: { kg: 1 } }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "number", value: 42, unit: { kg: 1 } }); - }); - - it("textLiteral is always definite", async () => { - const result = await evaluateValue( - { kind: "textLiteral", value: "active" }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "text", value: "active" }); - }); - - it("booleanLiteral is always definite", async () => { - const result = await evaluateValue( - { kind: "booleanLiteral", value: true }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "boolean", value: true }); - }); - - it("instantLiteral is always definite", async () => { - const result = await evaluateValue( - { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - undefined, - resolvers, - ); - expectDefinite(result, { - kind: "instant", - value: "2026-01-01T00:00:00.000Z", - }); - }); - - it("durationLiteral is always definite", async () => { - const result = await evaluateValue( - { kind: "durationLiteral", value: 5, unit: "min" }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "duration", value: 5, unit: "min" }); - }); -}); - -describe("complexLiteral", () => { - it("is always definite, preserving both components and the optional unit", async () => { - const result = await evaluateValue( - { kind: "complexLiteral", re: 3, im: -4, unit: { V: 1, A: -1 } }, - undefined, - resolvers, - ); - expectDefinite(result, { - kind: "complex", - re: 3, - im: -4, - unit: { V: 1, A: -1 }, - }); - }); - - it("normalises a polar literal to rectangular on evaluation, preserving the optional unit", async () => { - const result = await evaluateValue( - { - kind: "complexLiteral", - magnitude: 1, - phase: Math.PI / 2, - unit: { V: 1 }, - }, - undefined, - resolvers, - ); - expect(result.status).toBe("definite"); - if (result.status !== "definite") return; - expect(result.value.kind).toBe("complex"); - if (result.value.kind !== "complex") return; - expect(result.value.re).toBeCloseTo(0); - expect(result.value.im).toBeCloseTo(1); - expect(result.value.unit).toEqual({ V: 1 }); - }); - - it("eq: a rectangular literal and a polar literal representing the same underlying complex number compare as equal", async () => { - // A deliberately non-trivial phase (not a multiple of pi/2), with the rectangular side computed from the raw trig formula independently of the evaluator/complexFromPolar under test -- a sin/cos swap, or a bug that ignored `phase` entirely, would each produce a rectangular value that differs from this one, unlike phase 0's trivial identity. - const arbitraryPhase = 0.7; - const magnitude = 2; - const result = await evaluatePredicate( - { - kind: "compare", - op: "eq", - left: { - kind: "complexLiteral", - re: magnitude * Math.cos(arbitraryPhase), - im: magnitude * Math.sin(arbitraryPhase), - }, - right: { kind: "complexLiteral", magnitude, phase: arbitraryPhase }, - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("memberOf: a rectangular operand matches a candidate list containing a polar literal representing the same number", async () => { - // Same non-trivial-phase, independently-computed-rectangular reasoning as the `eq` test above applies here. - const arbitraryPhase = 0.7; - const magnitude = 1; - const result = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { - kind: "complexLiteral", - re: magnitude * Math.cos(arbitraryPhase), - im: magnitude * Math.sin(arbitraryPhase), - }, - candidates: [ - { kind: "complexLiteral", re: 0, im: 1 }, - { kind: "complexLiteral", magnitude, phase: arbitraryPhase }, - ], - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); -}); - -describe("reference", () => { - it("resolves a found value with no expected unit", async () => { - const result = await evaluateValue( - { kind: "reference", key: "present" }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "number", value: 10, unit: { m: 1 } }); - }); - - it("resolves a found value whose unit matches the expected unit", async () => { - const result = await evaluateValue( - { kind: "reference", key: "present", unit: { m: 1 } }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "number", value: 10, unit: { m: 1 } }); - }); - - it("is not-found for an unresolved key", async () => { - const result = await evaluateValue( - { kind: "reference", key: "missing" }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); - - it("is wrong-type when the expected unit does not match the resolved unit", async () => { - const result = await evaluateValue( - { kind: "reference", key: "present", unit: { s: 1 } }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("resolves a boolean value with no schema change beyond the ComputedValue union itself -- reference carries no kind restriction of its own", async () => { - const booleanResolvers: Resolvers = { - ...resolvers, - resolveValue: async (key, context) => - key === "active" - ? Promise.resolve({ - found: true, - value: { kind: "boolean", value: true }, - }) - : resolvers.resolveValue(key, context), - }; - const result = await evaluateValue( - { kind: "reference", key: "active" }, - undefined, - booleanResolvers, - ); - expectDefinite(result, { kind: "boolean", value: true }); - }); - - it("checks an expected unit against a complex resolution too, since a complex value carries one as well", async () => { - const complexResolvers: Resolvers = { - ...resolvers, - resolveValue: async () => - Promise.resolve({ - found: true, - value: { kind: "complex", re: 3, im: -4, unit: { V: 1, A: -1 } }, - }), - }; - - const matching = await evaluateValue( - { kind: "reference", key: "impedance", unit: { V: 1, A: -1 } }, - undefined, - complexResolvers, - ); - expectDefinite(matching, { - kind: "complex", - re: 3, - im: -4, - unit: { V: 1, A: -1 }, - }); - - const mismatched = await evaluateValue( - { kind: "reference", key: "impedance", unit: { V: 1 } }, - undefined, - complexResolvers, - ); - expectIndeterminate(mismatched, "wrong-type"); - }); -}); - -/** `arithmetic`'s own indeterminate-propagation table -- contrast this against `and`/`or`'s absorption (see truth-tables.test.ts and `combineAnd`/`combineOr` in evaluator.ts): arithmetic has no absorbing value at all, so a definite operand on one side never rescues an indeterminate operand on the other, unlike OR's absorbing `true` or AND's absorbing `false`. */ -describe("arithmetic indeterminate propagation (no absorbing value)", () => { - const definiteOperand: ExpressionNode = { kind: "numberLiteral", value: 1 }; - const indeterminateOperand: ExpressionNode = { - kind: "reference", - key: "missing", - }; - - it("definite + definite => definite", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: definiteOperand, - right: definiteOperand, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "number", value: 2 }); - }); - - it("indeterminate + definite => indeterminate (left's reason, unlike OR's absorbing true)", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: indeterminateOperand, - right: definiteOperand, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); - - it("definite + indeterminate => indeterminate (right's reason)", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: definiteOperand, - right: indeterminateOperand, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); - - it("indeterminate + indeterminate => indeterminate, tie-broken left before right", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: indeterminateOperand, - right: { kind: "reference", key: "also-missing" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); -}); - -describe("arithmetic on numbers -- units", () => { - it("add requires identical unit maps", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: { kind: "numberLiteral", value: 3, unit: { m: 1 } }, - right: { kind: "numberLiteral", value: 4, unit: { m: 1 } }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "number", value: 7, unit: { m: 1 } }); - }); - - it("subtract is wrong-type on mismatched units", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "subtract", - left: { kind: "numberLiteral", value: 3, unit: { m: 1 } }, - right: { kind: "numberLiteral", value: 4, unit: { s: 1 } }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("multiply combines units dimensionally", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "multiply", - left: { kind: "numberLiteral", value: 3, unit: { m: 1 } }, - right: { kind: "numberLiteral", value: 4, unit: { s: -1 } }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { - kind: "number", - value: 12, - unit: { m: 1, s: -1 }, - }); - }); - - it("divide combines units dimensionally", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "divide", - left: { kind: "numberLiteral", value: 10, unit: { m: 1 } }, - right: { kind: "numberLiteral", value: 2, unit: { s: 1 } }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { - kind: "number", - value: 5, - unit: { m: 1, s: -1 }, - }); - }); - - it("dividing a unit by itself normalises to dimensionless", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "divide", - left: { kind: "numberLiteral", value: 10, unit: { m: 1 } }, - right: { kind: "numberLiteral", value: 2, unit: { m: 1 } }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "number", value: 5, unit: {} }); - }); -}); - -describe("arithmetic domain errors", () => { - it("division by zero is domain-error", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "divide", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "numberLiteral", value: 0 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "domain-error"); - }); - - it("modulo by zero is domain-error", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "modulo", - left: { kind: "numberLiteral", value: 7 }, - right: { kind: "numberLiteral", value: 0 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "domain-error"); - }); - - it("a negative base raised to a non-integer power is domain-error", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "power", - left: { kind: "numberLiteral", value: -1 }, - right: { kind: "numberLiteral", value: 0.5 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "domain-error"); - }); - - it("a zero base raised to a negative power is domain-error, not a definite infinity", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "power", - left: { kind: "numberLiteral", value: 0 }, - right: { kind: "numberLiteral", value: -1 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "domain-error"); - }); - - it("power and modulo are otherwise ordinary numeric operations", async () => { - const power = await evaluateValue( - { - kind: "arithmetic", - op: "power", - left: { kind: "numberLiteral", value: 2 }, - right: { kind: "numberLiteral", value: 3 }, - }, - undefined, - resolvers, - ); - expectDefinite(power, { kind: "number", value: 8 }); - - const modulo = await evaluateValue( - { - kind: "arithmetic", - op: "modulo", - left: { kind: "numberLiteral", value: 7 }, - right: { kind: "numberLiteral", value: 3 }, - }, - undefined, - resolvers, - ); - expectDefinite(modulo, { kind: "number", value: 1 }); - }); -}); - -describe("arithmetic on complex values", () => { - it("add combines component-wise, preserving the shared unit", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: { kind: "complexLiteral", re: 3, im: 4, unit: { V: 1 } }, - right: { kind: "complexLiteral", re: 1, im: -6, unit: { V: 1 } }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { - kind: "complex", - re: 4, - im: -2, - unit: { V: 1 }, - }); - }); - - it("subtract combines component-wise", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "subtract", - left: { kind: "complexLiteral", re: 3, im: 4 }, - right: { kind: "complexLiteral", re: 1, im: -6 }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "complex", re: 2, im: 10 }); - }); - - it("add is wrong-type on mismatched units, exactly as it is for real numbers", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: { kind: "complexLiteral", re: 3, im: 4, unit: { V: 1 } }, - right: { kind: "complexLiteral", re: 1, im: -6, unit: { A: 1 } }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("subtract is wrong-type on mismatched units", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "subtract", - left: { kind: "complexLiteral", re: 3, im: 4, unit: { V: 1 } }, - right: { kind: "complexLiteral", re: 1, im: -6 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("multiply is real complex multiplication, not component-wise, and combines units dimensionally", async () => { - // (3 + 4i)(1 - 2i) = (3 + 8) + (4 - 6)i = 11 - 2i -- a component-wise product would have given 3 - 8i instead. - const result = await evaluateValue( - { - kind: "arithmetic", - op: "multiply", - left: { kind: "complexLiteral", re: 3, im: 4, unit: { A: 1 } }, - right: { kind: "complexLiteral", re: 1, im: -2, unit: { V: 1, A: -1 } }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { - kind: "complex", - re: 11, - im: -2, - unit: { V: 1 }, - }); - }); - - it("divide is real complex division, and combines units dimensionally", async () => { - // (11 - 2i) / (1 - 2i) = ((11 + 4) + (-2 + 22)i) / 5 = 3 + 4i -- the exact inverse of the multiply above, chosen so the expected components are exactly representable. - const result = await evaluateValue( - { - kind: "arithmetic", - op: "divide", - left: { kind: "complexLiteral", re: 11, im: -2, unit: { V: 1 } }, - right: { kind: "complexLiteral", re: 1, im: -2, unit: { V: 1, A: -1 } }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "complex", re: 3, im: 4, unit: { A: 1 } }); - }); - - it("divide by complex zero is domain-error, the same category as real division by zero", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "divide", - left: { kind: "complexLiteral", re: 3, im: 4 }, - right: { kind: "complexLiteral", re: 0, im: 0 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "domain-error"); - }); - - it("divide by a purely imaginary value is an ordinary quotient, not a division by zero", async () => { - // A zero real part alone must not be mistaken for a zero divisor: (4 + 0i) / (0 + 2i) = -2i. - const result = await evaluateValue( - { - kind: "arithmetic", - op: "divide", - left: { kind: "complexLiteral", re: 4, im: 0 }, - right: { kind: "complexLiteral", re: 0, im: 2 }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "complex", re: 0, im: -2, unit: {} }); - }); - - it("modulo is domain-error -- there is no remainder on the complex plane", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "modulo", - left: { kind: "complexLiteral", re: 7, im: 1 }, - right: { kind: "complexLiteral", re: 3, im: 1 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "domain-error"); - }); -}); - -describe("complex power -- a real integer exponent only", () => { - it("raises to a positive integer exponent by repeated multiplication", async () => { - // (1 + i)^4 = ((1 + i)^2)^2 = (2i)^2 = -4. - const result = await evaluateValue( - { - kind: "arithmetic", - op: "power", - left: { kind: "complexLiteral", re: 1, im: 1 }, - right: { kind: "numberLiteral", value: 4 }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "complex", re: -4, im: 0, unit: {} }); - }); - - it("raises to a negative integer exponent as the reciprocal of the positive power", async () => { - // (1 + i)^-2 = 1 / (1 + i)^2 = 1 / 2i = -0.5i. - const result = await evaluateValue( - { - kind: "arithmetic", - op: "power", - left: { kind: "complexLiteral", re: 1, im: 1 }, - right: { kind: "numberLiteral", value: -2 }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "complex", re: 0, im: -0.5, unit: {} }); - }); - - it("raises to a zero exponent as the complex unit, exactly as a real base does", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "power", - left: { kind: "complexLiteral", re: 3, im: 4 }, - right: { kind: "numberLiteral", value: 0 }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "complex", re: 1, im: 0, unit: {} }); - }); - - it("is domain-error for a zero base with a negative exponent, the same as a real base", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "power", - left: { kind: "complexLiteral", re: 0, im: 0 }, - right: { kind: "numberLiteral", value: -1 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "domain-error"); - }); - - it("is wrong-type for a non-integer exponent -- an answer this design does not define, not one that does not exist", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "power", - left: { kind: "complexLiteral", re: 1, im: 1 }, - right: { kind: "numberLiteral", value: 0.5 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("is wrong-type for a complex exponent, whatever the base", async () => { - const complexExponent = { kind: "complexLiteral", re: 1, im: 2 } as const; - const complexBase = await evaluateValue( - { - kind: "arithmetic", - op: "power", - left: { kind: "complexLiteral", re: 1, im: 1 }, - right: complexExponent, - }, - undefined, - resolvers, - ); - expectIndeterminate(complexBase, "wrong-type"); - - const realBase = await evaluateValue( - { - kind: "arithmetic", - op: "power", - left: { kind: "numberLiteral", value: 2 }, - right: complexExponent, - }, - undefined, - resolvers, - ); - expectIndeterminate(realBase, "wrong-type"); - }); - - it("requires dimensionless operands, exactly as a real power does", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "power", - left: { kind: "complexLiteral", re: 1, im: 1, unit: { V: 1 } }, - right: { kind: "numberLiteral", value: 2 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); -}); - -describe("arithmetic mixing real and complex operands", () => { - it("promotes a real operand to the complex plane rather than rejecting the pair", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: { kind: "numberLiteral", value: 5, unit: { V: 1 } }, - right: { kind: "complexLiteral", re: 1, im: -6, unit: { V: 1 } }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "complex", re: 6, im: -6, unit: { V: 1 } }); - }); - - it("scales a complex value by a real one, combining units dimensionally", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "multiply", - left: { kind: "complexLiteral", re: 3, im: 4, unit: { A: 1 } }, - right: { kind: "numberLiteral", value: 2, unit: { V: 1, A: -1 } }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { - kind: "complex", - re: 6, - im: 8, - unit: { V: 1 }, - }); - }); - - it("stays complex even when the result's imaginary part is zero", async () => { - // The result kind follows the operand kinds, never the runtime values -- a tree's shape would otherwise depend on the data flowing through it. - const result = await evaluateValue( - { - kind: "arithmetic", - op: "subtract", - left: { kind: "complexLiteral", re: 3, im: 4 }, - right: { kind: "complexLiteral", re: 1, im: 4 }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "complex", re: 2, im: 0 }); - }); - - it("is wrong-type when a complex operand meets a temporal one", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: { kind: "complexLiteral", re: 3, im: 4 }, - right: { kind: "durationLiteral", value: 5, unit: "min" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("is wrong-type when a complex operand meets a text one", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: { kind: "complexLiteral", re: 3, im: 4 }, - right: { kind: "textLiteral", value: "active" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); -}); - -describe("arithmetic on non-numeric, non-temporal operands", () => { - it("is wrong-type when an operand is text", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: { kind: "textLiteral", value: "active" }, - right: { kind: "numberLiteral", value: 1 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); -}); - -describe("negate", () => { - it("flips a number's sign, preserving its unit", async () => { - const result = await evaluateValue( - { - kind: "negate", - operand: { kind: "numberLiteral", value: 5, unit: { m: 1 } }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "number", value: -5, unit: { m: 1 } }); - }); - - it("reverses a duration's magnitude, preserving its unit -- not sugar for zero minus the value", async () => { - const result = await evaluateValue( - { - kind: "negate", - operand: { kind: "durationLiteral", value: 5, unit: "min" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "duration", value: -5, unit: "min" }); - }); - - it("flips both components of a complex value, preserving its unit", async () => { - const result = await evaluateValue( - { - kind: "negate", - operand: { kind: "complexLiteral", re: 3, im: -4, unit: { V: 1 } }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { - kind: "complex", - re: -3, - im: 4, - unit: { V: 1 }, - }); - }); - - it("is wrong-type on an instant", async () => { - const result = await evaluateValue( - { - kind: "negate", - operand: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("is wrong-type on a boolean -- negate has no logical-NOT meaning here", async () => { - const result = await evaluateValue( - { kind: "negate", operand: { kind: "booleanLiteral", value: true } }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("propagates an indeterminate operand", async () => { - const result = await evaluateValue( - { kind: "negate", operand: { kind: "reference", key: "missing" } }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); -}); - -describe("temporal arithmetic -- the four well-defined combination rules", () => { - it("instant - instant => duration, in milliseconds", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "subtract", - left: { kind: "instantLiteral", value: "2026-01-01T00:01:00.000Z" }, - right: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "duration", value: 60_000, unit: "ms" }); - }); - - it("instant + duration => instant", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - right: { kind: "durationLiteral", value: 1, unit: "min" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { - kind: "instant", - value: "2026-01-01T00:01:00.000Z", - }); - }); - - it("duration + instant => instant", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: { kind: "durationLiteral", value: 1, unit: "min" }, - right: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { - kind: "instant", - value: "2026-01-01T00:01:00.000Z", - }); - }); - - it("duration + duration => duration, normalising different units to milliseconds", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: { kind: "durationLiteral", value: 1, unit: "min" }, - right: { kind: "durationLiteral", value: 30, unit: "s" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "duration", value: 90_000, unit: "ms" }); - }); -}); - -describe("temporal arithmetic -- wrong-type violations", () => { - it("adding two instants is wrong-type (only instant - instant is defined)", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - right: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("subtracting a duration from an instant is wrong-type (not one of the defined combinations)", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "subtract", - left: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - right: { kind: "durationLiteral", value: 1, unit: "min" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("comparing an instant against a plain number is wrong-type", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - right: { kind: "numberLiteral", value: 1 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - /** An `instant`'s value is an opaque string until something parses it: nothing in the schema layer checks that it really is an ISO-8601 timestamp, and a resolver can return whatever its backing data holds. Every operation that has to parse one must therefore keep an unparseable value inside the three-outcome model rather than letting `Date.parse`'s `NaN` leak onward -- silently, as a definite result computed from `NaN`, or loudly, as a thrown `RangeError` from `toISOString`. */ - describe("an unparseable instant is wrong-type, never a throw or a NaN-derived definite result", () => { - const unparseable: ExpressionNode = { - kind: "instantLiteral", - value: "not-a-timestamp", - }; - const parseable: ExpressionNode = { - kind: "instantLiteral", - value: "2026-01-01T00:00:00.000Z", - }; - const oneHour: ExpressionNode = { - kind: "durationLiteral", - value: 1, - unit: "h", - }; - - it("instant - instant", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "subtract", - left: unparseable, - right: parseable, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("instant + duration", async () => { - const result = await evaluateValue( - { kind: "arithmetic", op: "add", left: unparseable, right: oneHour }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("duration + instant", async () => { - const result = await evaluateValue( - { kind: "arithmetic", op: "add", left: oneHour, right: unparseable }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("compare -- neq between two unparseable instants is not definitely true", async () => { - const result = await evaluatePredicate( - { - kind: "compare", - op: "neq", - left: unparseable, - right: unparseable, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("memberOf -- an unparseable candidate is not a definite non-match", async () => { - const result = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: parseable, - candidates: [unparseable], - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("shifting a parseable instant beyond the representable timestamp range is domain-error", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "add", - left: parseable, - right: { kind: "durationLiteral", value: 1e18, unit: "d" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "domain-error"); - }); - }); - - it("multiplying two durations is wrong-type (no representable result unit)", async () => { - const result = await evaluateValue( - { - kind: "arithmetic", - op: "multiply", - left: { kind: "durationLiteral", value: 1, unit: "min" }, - right: { kind: "durationLiteral", value: 2, unit: "s" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); -}); - -describe("compare", () => { - it.each([ - { op: "gt", left: 5, right: 3, expected: true }, - { op: "gt", left: 3, right: 5, expected: false }, - { op: "gte", left: 5, right: 5, expected: true }, - { op: "gte", left: 3, right: 5, expected: false }, - { op: "lt", left: 3, right: 5, expected: true }, - { op: "lt", left: 5, right: 3, expected: false }, - { op: "lte", left: 5, right: 5, expected: true }, - { op: "lte", left: 5, right: 3, expected: false }, - { op: "eq", left: 5, right: 5, expected: true }, - { op: "eq", left: 5, right: 3, expected: false }, - { op: "neq", left: 5, right: 3, expected: true }, - { op: "neq", left: 5, right: 5, expected: false }, - ] as const)( - "$op($left, $right) => $expected", - async ({ op, left, right, expected }) => { - const result = await evaluatePredicate( - { - kind: "compare", - op, - left: { kind: "numberLiteral", value: left }, - right: { kind: "numberLiteral", value: right }, - }, - undefined, - resolvers, - ); - expectDefinite(result, expected); - }, - ); - - it.each([ - { op: "eq", left: true, right: true, expected: true }, - { op: "eq", left: true, right: false, expected: false }, - { op: "neq", left: true, right: false, expected: true }, - { op: "neq", left: true, right: true, expected: false }, - ] as const)( - "boolean $op($left, $right) => $expected", - async ({ op, left, right, expected }) => { - const result = await evaluatePredicate( - { - kind: "compare", - op, - left: { kind: "booleanLiteral", value: left }, - right: { kind: "booleanLiteral", value: right }, - }, - undefined, - resolvers, - ); - expectDefinite(result, expected); - }, - ); - - it.each(["gt", "gte", "lt", "lte"] as const)( - "%s is wrong-type for boolean operands -- no natural ordering", - async (op) => { - const result = await evaluatePredicate( - { - kind: "compare", - op, - left: { kind: "booleanLiteral", value: true }, - right: { kind: "booleanLiteral", value: false }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }, - ); - - it("compares instants by parsed timestamp ordering", async () => { - const result = await evaluatePredicate( - { - kind: "compare", - op: "lt", - left: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - right: { kind: "instantLiteral", value: "2026-01-02T00:00:00.000Z" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("compares durations by magnitude, normalising different units to the same base", async () => { - const result = await evaluatePredicate( - { - kind: "compare", - op: "eq", - left: { kind: "durationLiteral", value: 1, unit: "min" }, - right: { kind: "durationLiteral", value: 60, unit: "s" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it.each([ - { op: "eq", right: { re: 3, im: -4 }, expected: true }, - { op: "eq", right: { re: 3, im: 4 }, expected: false }, - { op: "eq", right: { re: -3, im: -4 }, expected: false }, - { op: "neq", right: { re: 3, im: 4 }, expected: true }, - { op: "neq", right: { re: 3, im: -4 }, expected: false }, - ] as const)( - "$op against a complex value is exact equality across both components => $expected", - async ({ op, right, expected }) => { - const result = await evaluatePredicate( - { - kind: "compare", - op, - left: { kind: "complexLiteral", re: 3, im: -4 }, - right: { kind: "complexLiteral", ...right }, - }, - undefined, - resolvers, - ); - expectDefinite(result, expected); - }, - ); - - it.each(["gt", "gte", "lt", "lte"] as const)( - "%s is wrong-type on complex operands -- the complex plane carries no total order", - async (op) => { - const result = await evaluatePredicate( - { - kind: "compare", - op, - left: { kind: "complexLiteral", re: 3, im: -4 }, - right: { kind: "complexLiteral", re: 1, im: 1 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }, - ); - - it("is wrong-type when two complex values have incompatible units", async () => { - const result = await evaluatePredicate( - { - kind: "compare", - op: "eq", - left: { kind: "complexLiteral", re: 3, im: -4, unit: { V: 1 } }, - right: { kind: "complexLiteral", re: 3, im: -4, unit: { A: 1 } }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("is wrong-type when comparing a complex value against a real one, unlike arithmetic's promotion", async () => { - const result = await evaluatePredicate( - { - kind: "compare", - op: "eq", - left: { kind: "complexLiteral", re: 3, im: 0 }, - right: { kind: "numberLiteral", value: 3 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("is wrong-type when comparing across different computed-value kinds", async () => { - const result = await evaluatePredicate( - { - kind: "compare", - op: "eq", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "textLiteral", value: "1" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("is wrong-type when comparing a boolean against a number", async () => { - const result = await evaluatePredicate( - { - kind: "compare", - op: "eq", - left: { kind: "booleanLiteral", value: true }, - right: { kind: "numberLiteral", value: 1 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("is wrong-type when comparing an instant against a duration", async () => { - const result = await evaluatePredicate( - { - kind: "compare", - op: "eq", - left: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - right: { kind: "durationLiteral", value: 1, unit: "min" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("is wrong-type when two numbers have incompatible units", async () => { - const result = await evaluatePredicate( - { - kind: "compare", - op: "gt", - left: { kind: "numberLiteral", value: 5, unit: { m: 1 } }, - right: { kind: "numberLiteral", value: 3, unit: { s: 1 } }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("propagates an indeterminate left operand", async () => { - const result = await evaluatePredicate( - { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "missing" }, - right: { kind: "numberLiteral", value: 1 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); - - it("propagates an indeterminate right operand", async () => { - const result = await evaluatePredicate( - { - kind: "compare", - op: "eq", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "reference", key: "missing" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); - - it("tie-breaks two indeterminate operands to the left's reason, per the declared-operand-order rule", async () => { - const result = await evaluatePredicate( - { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "missing" }, - right: { kind: "reference", key: "also-missing" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); -}); - -describe("textCompare", () => { - it("equals is exact string equality", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "equals", - left: { kind: "textLiteral", value: "active" }, - right: { kind: "textLiteral", value: "active" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("equals is false for differing strings", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "equals", - left: { kind: "textLiteral", value: "active" }, - right: { kind: "textLiteral", value: "inactive" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, false); - }); - - it("notEquals is the negation of equals", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "notEquals", - left: { kind: "textLiteral", value: "active" }, - right: { kind: "textLiteral", value: "inactive" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("matches tests right's text as a regular expression against left's text", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "matches", - left: { kind: "textLiteral", value: "active-123" }, - right: { kind: "textLiteral", value: "^active-\\d+$" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("matches is false when the pattern does not match", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "matches", - left: { kind: "textLiteral", value: "inactive" }, - right: { kind: "textLiteral", value: "^active$" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, false); - }); - - it("notMatches is the negation of matches", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "notMatches", - left: { kind: "textLiteral", value: "inactive" }, - right: { kind: "textLiteral", value: "^active$" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("portableMatches tests right's text as a trilean-regex pattern against left's text", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "portableMatches", - left: { kind: "textLiteral", value: "active-123" }, - right: { kind: "textLiteral", value: "^active-\\d+$" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("portableMatches is false when the pattern does not match", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "portableMatches", - left: { kind: "textLiteral", value: "inactive" }, - right: { kind: "textLiteral", value: "^active$" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, false); - }); - - it("portableNotMatches is the negation of portableMatches", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "portableNotMatches", - left: { kind: "textLiteral", value: "inactive" }, - right: { kind: "textLiteral", value: "^active$" }, - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("an invalid trilean-regex pattern is wrong-type, not a thrown exception", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "portableMatches", - left: { kind: "textLiteral", value: "anything" }, - right: { kind: "textLiteral", value: "(a)" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("portableMatches accepts only its own restricted grammar, rejecting an ECMAScript-only construct matches would accept", async () => { - // A lookahead is valid ECMAScript but not a construct trilean-regex's grammar defines -- see packages/trilean-regex/README.md's "Grammar" table. This is the load-bearing case distinguishing the two operator pairs: the same pattern text is accepted by 'matches' and rejected by 'portableMatches'. - const lookahead = { kind: "textLiteral" as const, value: "^(?=a)a$" }; - const viaMatches = await evaluatePredicate( - { - kind: "textCompare", - op: "matches", - left: { kind: "textLiteral", value: "a" }, - right: lookahead, - }, - undefined, - resolvers, - ); - expectDefinite(viaMatches, true); - - const viaPortableMatches = await evaluatePredicate( - { - kind: "textCompare", - op: "portableMatches", - left: { kind: "textLiteral", value: "a" }, - right: lookahead, - }, - undefined, - resolvers, - ); - expectIndeterminate(viaPortableMatches, "wrong-type"); - }); - - it("is wrong-type when the left operand is not text", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "equals", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "textLiteral", value: "1" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("is wrong-type when the right operand is not text", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "equals", - left: { kind: "textLiteral", value: "1" }, - right: { kind: "numberLiteral", value: 1 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("an invalid regular expression pattern is wrong-type, not a thrown exception", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "matches", - left: { kind: "textLiteral", value: "anything" }, - right: { kind: "textLiteral", value: "(" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("propagates an indeterminate left operand", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "missing" }, - right: { kind: "textLiteral", value: "active" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); - - it("propagates an indeterminate right operand", async () => { - const result = await evaluatePredicate( - { - kind: "textCompare", - op: "equals", - left: { kind: "textLiteral", value: "active" }, - right: { kind: "reference", key: "missing" }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); -}); - -describe("memberOf", () => { - it("in: a definite match makes the leaf definitely true", async () => { - const result = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "numberLiteral", value: 5 }, - candidates: [ - { kind: "numberLiteral", value: 3 }, - { kind: "numberLiteral", value: 5 }, - ], - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("notIn: a definite match makes the leaf definitely false", async () => { - const result = await evaluatePredicate( - { - kind: "memberOf", - op: "notIn", - operand: { kind: "numberLiteral", value: 5 }, - candidates: [{ kind: "numberLiteral", value: 5 }], - }, - undefined, - resolvers, - ); - expectDefinite(result, false); - }); - - it("boolean operands participate under the same kind-agnostic equality rule as every other kind", async () => { - const matchResult = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "booleanLiteral", value: true }, - candidates: [ - { kind: "booleanLiteral", value: false }, - { kind: "booleanLiteral", value: true }, - ], - }, - undefined, - resolvers, - ); - expectDefinite(matchResult, true); - - const nonMatchResult = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "booleanLiteral", value: true }, - candidates: [{ kind: "booleanLiteral", value: false }], - }, - undefined, - resolvers, - ); - expectDefinite(nonMatchResult, false); - }); - - it("is wrong-type when a boolean operand is compared against a non-boolean candidate for membership", async () => { - const result = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "booleanLiteral", value: true }, - candidates: [{ kind: "numberLiteral", value: 1 }], - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("matches a complex candidate on both components at once", async () => { - const result = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "complexLiteral", re: 3, im: -4 }, - candidates: [ - { kind: "complexLiteral", re: 3, im: 4 }, - { kind: "complexLiteral", re: 3, im: -4 }, - ], - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("is wrong-type when a complex operand meets a candidate of another kind", async () => { - const result = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "complexLiteral", re: 3, im: 0 }, - candidates: [{ kind: "numberLiteral", value: 3 }], - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("is wrong-type when a complex candidate carries an incompatible unit", async () => { - const result = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "complexLiteral", re: 3, im: -4, unit: { V: 1 } }, - candidates: [{ kind: "complexLiteral", re: 3, im: -4, unit: { A: 1 } }], - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("a definite match short-circuits the result past a later indeterminate candidate", async () => { - const result = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "numberLiteral", value: 5 }, - candidates: [ - { kind: "numberLiteral", value: 5 }, - { kind: "reference", key: "missing" }, - ], - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("an empty candidates list is never scanned: in is definitely false, notIn is definitely true", async () => { - const inResult = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "numberLiteral", value: 5 }, - candidates: [], - }, - undefined, - resolvers, - ); - expectDefinite(inResult, false); - - const notInResult = await evaluatePredicate( - { - kind: "memberOf", - op: "notIn", - operand: { kind: "numberLiteral", value: 5 }, - candidates: [], - }, - undefined, - resolvers, - ); - expectDefinite(notInResult, true); - }); - - it("no match among definite, comparable candidates: in is false, notIn is true", async () => { - const candidates: ExpressionNode[] = [ - { kind: "numberLiteral", value: 1 }, - { kind: "numberLiteral", value: 2 }, - ]; - - const inResult = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "numberLiteral", value: 5 }, - candidates, - }, - undefined, - resolvers, - ); - expectDefinite(inResult, false); - - const notInResult = await evaluatePredicate( - { - kind: "memberOf", - op: "notIn", - operand: { kind: "numberLiteral", value: 5 }, - candidates, - }, - undefined, - resolvers, - ); - expectDefinite(notInResult, true); - }); - - it("tie-breaks several indeterminate/incompatible candidates to the first's reason, per declared order", async () => { - const result = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "numberLiteral", value: 5 }, - candidates: [ - { kind: "textLiteral", value: "5" }, // incompatible kind -> wrong-type - { kind: "reference", key: "missing" }, // not-found - ], - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("an indeterminate operand makes the whole leaf indeterminate without ever scanning a candidate", async () => { - let candidateEvaluations = 0; - const trackingResolvers: Resolvers = { - ...resolvers, - resolveValue: async (key, context) => { - if (key === "candidate-marker") candidateEvaluations += 1; - return resolvers.resolveValue(key, context); - }, - }; - const result = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "missing" }, - candidates: [{ kind: "reference", key: "candidate-marker" }], - }, - undefined, - trackingResolvers, - ); - expectIndeterminate(result, "not-found"); - expect(candidateEvaluations).toBe(0); - }); - - it("respects units for numeric candidates -- an incompatible unit is wrong-type, never a plain non-match", async () => { - const result = await evaluatePredicate( - { - kind: "memberOf", - op: "in", - operand: { kind: "numberLiteral", value: 5, unit: { m: 1 } }, - candidates: [{ kind: "numberLiteral", value: 5, unit: { s: 1 } }], - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); -}); - -describe("exists", () => { - it("a definite operand makes exists definitely true", async () => { - const result = await evaluatePredicate( - { kind: "exists", operand: { kind: "numberLiteral", value: 1 } }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("not-found converts to definitely false", async () => { - const result = await evaluatePredicate( - { kind: "exists", operand: { kind: "reference", key: "missing" } }, - undefined, - resolvers, - ); - expectDefinite(result, false); - }); - - it("wrong-type converts to definitely true -- the data point resolved, it just wasn't usable", async () => { - const result = await evaluatePredicate( - { - kind: "exists", - operand: { kind: "reference", key: "present", unit: { s: 1 } }, - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); - - it("domain-error converts to definitely true -- the data point resolved, it just wasn't usable", async () => { - const result = await evaluatePredicate( - { - kind: "exists", - operand: { - kind: "arithmetic", - op: "divide", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "numberLiteral", value: 0 }, - }, - }, - undefined, - resolvers, - ); - expectDefinite(result, true); - }); -}); - -describe("some, every", () => { - /** A resolver whose `resolveCollection` maps a small set of named collection references onto plain-object item arrays, and whose `resolveValue` reads a field straight off whatever item context it's given -- mirroring `resolveCollection`/`resolveValue`'s real relationship: descending into a quantifier replaces the evaluation context with the resolved item itself (see README.md's "Evaluation context" section), so a `reference` inside `item`/`filter` resolves against that one item, not the outer context. */ - const quantifierResolvers: Resolvers = { - resolveValue: async (key, context) => { - if ( - typeof key !== "string" || - !isPlainRecord(context) || - !(key in context) - ) { - return Promise.resolve({ found: false }); - } - const value = context[key]; - if (typeof value === "number") { - return Promise.resolve({ - found: true, - value: { kind: "number", value }, - }); - } - if (typeof value === "string") { - return Promise.resolve({ found: true, value: { kind: "text", value } }); - } - return Promise.resolve({ found: false }); - }, - resolveLookup: async () => Promise.resolve({ found: false }), - resolveCollection: async (collection) => { - if (collection === "amounts") { - return Promise.resolve([{ amount: 1 }, { amount: 2 }, { amount: 30 }]); - } - if (collection === "grouped") { - return Promise.resolve([ - { group: "keep", amount: 5 }, - { group: "skip", amount: 999 }, - ]); - } - if (collection === "flagged") { - return Promise.resolve([ - { flag: 1, amount: 100 }, - { amount: 5 }, // no `flag` field -- the filter itself is indeterminate for this item - ]); - } - return Promise.resolve([]); - }, - }; - - it("some is definitely true when at least one participating item's item predicate matches", async () => { - const result = await evaluatePredicate( - { - kind: "some", - collection: "amounts", - item: { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "amount" }, - right: { kind: "numberLiteral", value: 10 }, - }, - }, - undefined, - quantifierResolvers, - ); - expectDefinite(result, true); - }); - - it("some is definitely false when no participating item's item predicate matches", async () => { - const result = await evaluatePredicate( - { - kind: "some", - collection: "amounts", - item: { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "amount" }, - right: { kind: "numberLiteral", value: 1000 }, - }, - }, - undefined, - quantifierResolvers, - ); - expectDefinite(result, false); - }); - - it("every is definitely true when every participating item's item predicate matches", async () => { - const result = await evaluatePredicate( - { - kind: "every", - collection: "amounts", - item: { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "amount" }, - right: { kind: "numberLiteral", value: 0 }, - }, - }, - undefined, - quantifierResolvers, - ); - expectDefinite(result, true); - }); - - it("every is definitely false when at least one participating item's item predicate fails to match", async () => { - const result = await evaluatePredicate( - { - kind: "every", - collection: "amounts", - item: { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "amount" }, - right: { kind: "numberLiteral", value: 10 }, - }, - }, - undefined, - quantifierResolvers, - ); - expectDefinite(result, false); - }); - - it("filter excludes an item entirely, as if it had never been in the collection", async () => { - const result = await evaluatePredicate( - { - kind: "some", - collection: "grouped", - filter: { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "group" }, - right: { kind: "textLiteral", value: "keep" }, - }, - item: { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "amount" }, - right: { kind: "numberLiteral", value: 500 }, - }, - }, - undefined, - quantifierResolvers, - ); - // The only participating item ("keep") has amount 5, well under 500; the excluded item's amount 999 would satisfy `item`, but it never gets to participate. - expectDefinite(result, false); - }); - - it("a filter-indeterminate item's own vote is absorbed by a different item's clean match", async () => { - const result = await evaluatePredicate( - { - kind: "some", - collection: "flagged", - filter: { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "flag" }, - right: { kind: "numberLiteral", value: 1 }, - }, - item: { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "amount" }, - right: { kind: "numberLiteral", value: 10 }, - }, - }, - undefined, - quantifierResolvers, - ); - // The second item has no `flag` field, so its filter is indeterminate and its vote is indeterminate too -- but the first item's clean match (flag 1, amount 100 > 10) still absorbs it to a definite true, exactly as OR's own absorption already requires. - expectDefinite(result, true); - }); - - // Deliberate, settled per README.md: "every over an empty collection is definitely true" -- vacuous truth, the standard convention for universal quantification over an empty set. This must never be "fixed" to false. - it("every over an empty collection is definitely true", async () => { - const result = await evaluatePredicate( - { - kind: "every", - collection: "nothing-here", - item: { - kind: "compare", - op: "eq", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "numberLiteral", value: 1 }, - }, - }, - undefined, - quantifierResolvers, - ); - expectDefinite(result, true); - }); - - it("some over an empty collection is definitely false", async () => { - const result = await evaluatePredicate( - { - kind: "some", - collection: "nothing-here", - item: { - kind: "compare", - op: "eq", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "numberLiteral", value: 1 }, - }, - }, - undefined, - quantifierResolvers, - ); - expectDefinite(result, false); - }); -}); - -describe("lookup", () => { - const lookupResolvers: Resolvers = { - resolveValue: async (key) => { - if (key === "region") { - return Promise.resolve({ - found: true, - value: { kind: "text", value: "north" }, - }); - } - if (key === "tier") { - return Promise.resolve({ - found: true, - value: { kind: "number", value: 2 }, - }); - } - return Promise.resolve({ found: false }); - }, - resolveLookup: async (table) => - Promise.resolve( - table === "pricing" - ? { found: true, value: { kind: "number", value: 42 } } - : { found: false }, - ), - resolveCollection: async () => Promise.resolve([]), - }; - - it("evaluates every key and passes its resolved value to resolveLookup, in declared order", async () => { - const receivedKeys: unknown[] = []; - const trackingResolvers: Resolvers = { - ...lookupResolvers, - resolveLookup: async (table, keys, context) => { - receivedKeys.push(...keys); - return lookupResolvers.resolveLookup(table, keys, context); - }, - }; - const result = await evaluateValue( - { - kind: "lookup", - table: "pricing", - keys: [ - { kind: "reference", key: "region" }, - { kind: "reference", key: "tier" }, - ], - }, - undefined, - trackingResolvers, - ); - expect(receivedKeys).toEqual([ - { kind: "text", value: "north" }, - { kind: "number", value: 2 }, - ]); - expectDefinite(result, { kind: "number", value: 42 }); - }); - - it("is not-found when the resolver reports no match", async () => { - const result = await evaluateValue( - { - kind: "lookup", - table: "unknown-table", - keys: [{ kind: "reference", key: "region" }], - }, - undefined, - lookupResolvers, - ); - expectIndeterminate(result, "not-found"); - }); - - it("resolves a boolean value with no schema change beyond the ComputedValue union itself -- lookup carries no kind restriction of its own", async () => { - const booleanLookupResolvers: Resolvers = { - ...lookupResolvers, - resolveLookup: async (table, keys, context) => - table === "eligibility" - ? Promise.resolve({ - found: true, - value: { kind: "boolean", value: true }, - }) - : lookupResolvers.resolveLookup(table, keys, context), - }; - const result = await evaluateValue( - { - kind: "lookup", - table: "eligibility", - keys: [{ kind: "reference", key: "region" }], - }, - undefined, - booleanLookupResolvers, - ); - expectDefinite(result, { kind: "boolean", value: true }); - }); -}); - -describe("conditional", () => { - const whenEquals = (left: number, right: number): PredicateNode => ({ - kind: "compare", - op: "eq", - left: { kind: "numberLiteral", value: left }, - right: { kind: "numberLiteral", value: right }, - }); - /** `@typescript-eslint/no-magic-numbers` only exempts -1/0/1/2 -- a third distinctly-numbered case below needs a named constant instead of a bare `3`. */ - const thirdCaseIdentifier = 3; - - it("evaluates the then of the first case whose when is definitely true, skipping earlier false cases", async () => { - const result = await evaluateValue( - { - kind: "conditional", - cases: [ - { - when: whenEquals(1, 2), - then: { kind: "numberLiteral", value: 100 }, - }, - { - when: whenEquals(1, 1), - then: { kind: "numberLiteral", value: 200 }, - }, - { - when: whenEquals(1, 1), - then: { kind: "numberLiteral", value: 300 }, - }, - ], - fallback: { kind: "numberLiteral", value: 0 }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "number", value: 200 }); - }); - - it("evaluates fallback when no case matches", async () => { - const result = await evaluateValue( - { - kind: "conditional", - cases: [ - { - when: whenEquals(1, 2), - then: { kind: "numberLiteral", value: 100 }, - }, - ], - fallback: { kind: "numberLiteral", value: 999 }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "number", value: 999 }); - }); - - it("an empty cases list always evaluates to fallback", async () => { - const result = await evaluateValue( - { - kind: "conditional", - cases: [], - fallback: { kind: "numberLiteral", value: 999 }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "number", value: 999 }); - }); - - it("never evaluates a later case's then once an earlier case has already matched", async () => { - let laterThenEvaluations = 0; - const trackingResolvers: Resolvers = { - ...resolvers, - resolveValue: async (key, context) => { - if (key === "later-then-marker") laterThenEvaluations += 1; - return resolvers.resolveValue(key, context); - }, - }; - const result = await evaluateValue( - { - kind: "conditional", - cases: [ - { - when: whenEquals(1, 1), - then: { kind: "numberLiteral", value: 200 }, - }, - ], - fallback: { kind: "reference", key: "later-then-marker" }, - }, - undefined, - trackingResolvers, - ); - expectDefinite(result, { kind: "number", value: 200 }); - expect(laterThenEvaluations).toBe(0); - }); - - // A `when` predicate that is always indeterminate (not-found), used across the hitPolicy tests below to distinguish "this case definitely doesn't match" from "this case's match status is unknown". - const whenIndeterminate: PredicateNode = { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "missing" }, - right: { kind: "numberLiteral", value: 0 }, - }; - - describe("hitPolicy: 'unique'", () => { - it("exactly one match: returns that case's then, and never evaluates any other case's then", async () => { - let secondThenEvaluations = 0; - const trackingResolvers: Resolvers = { - ...resolvers, - resolveValue: async (key, context) => { - if (key === "second-then-marker") secondThenEvaluations += 1; - return resolvers.resolveValue(key, context); - }, - }; - const result = await evaluateValue( - { - kind: "conditional", - hitPolicy: "unique", - cases: [ - { - when: whenEquals(1, 2), - then: { kind: "reference", key: "second-then-marker" }, - }, - { - when: whenEquals(1, 1), - then: { kind: "numberLiteral", value: 200 }, - }, - ], - fallback: { kind: "numberLiteral", value: 0 }, - }, - undefined, - trackingResolvers, - ); - expectDefinite(result, { kind: "number", value: 200 }); - expect(secondThenEvaluations).toBe(0); - }); - - it("zero matches: returns fallback, same as 'first'", async () => { - const result = await evaluateValue( - { - kind: "conditional", - hitPolicy: "unique", - cases: [ - { - when: whenEquals(1, 2), - then: { kind: "numberLiteral", value: 100 }, - }, - ], - fallback: { kind: "numberLiteral", value: 999 }, - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "number", value: 999 }); - }); - - it("two matches: domain-error, regardless of case order", async () => { - const result = await evaluateValue( - { - kind: "conditional", - hitPolicy: "unique", - cases: [ - { - when: whenEquals(1, 1), - then: { kind: "numberLiteral", value: 100 }, - }, - { - when: whenEquals(2, 2), - then: { kind: "numberLiteral", value: 200 }, - }, - ], - fallback: { kind: "numberLiteral", value: 0 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "domain-error"); - if (result.status === "indeterminate") { - expect(result.reason.message).toBe( - "more than one case matched under the 'unique' hit policy", - ); - } - }); - - it("three matches: the same domain-error, not a distinct message", async () => { - const result = await evaluateValue( - { - kind: "conditional", - hitPolicy: "unique", - cases: [ - { - when: whenEquals(1, 1), - then: { kind: "numberLiteral", value: 1 }, - }, - { - when: whenEquals(2, 2), - then: { kind: "numberLiteral", value: 2 }, - }, - { - when: whenEquals(thirdCaseIdentifier, thirdCaseIdentifier), - then: { kind: "numberLiteral", value: thirdCaseIdentifier }, - }, - ], - fallback: { kind: "numberLiteral", value: 0 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "domain-error"); - if (result.status === "indeterminate") { - expect(result.reason.message).toBe( - "more than one case matched under the 'unique' hit policy", - ); - } - }); - - it("one match plus one unrelated indeterminate case: the whole node is indeterminate, NOT the match's then -- a single confirmed match does not absorb a remaining unresolved sibling under 'unique'", async () => { - const result = await evaluateValue( - { - kind: "conditional", - hitPolicy: "unique", - cases: [ - { - when: whenEquals(1, 1), - then: { kind: "numberLiteral", value: 100 }, - }, - { - when: whenIndeterminate, - then: { kind: "numberLiteral", value: 200 }, - }, - ], - fallback: { kind: "numberLiteral", value: 0 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); - - it("two matches plus a third indeterminate case: still the domain-error -- 2+ matches absorbs even a genuine indeterminate elsewhere", async () => { - const result = await evaluateValue( - { - kind: "conditional", - hitPolicy: "unique", - cases: [ - { - when: whenEquals(1, 1), - then: { kind: "numberLiteral", value: 1 }, - }, - { - when: whenEquals(2, 2), - then: { kind: "numberLiteral", value: 2 }, - }, - { - when: whenIndeterminate, - then: { kind: "numberLiteral", value: 3 }, - }, - ], - fallback: { kind: "numberLiteral", value: 0 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "domain-error"); - }); - - it("zero matches plus one indeterminate case: indeterminate", async () => { - const result = await evaluateValue( - { - kind: "conditional", - hitPolicy: "unique", - cases: [ - { - when: whenEquals(1, 2), - then: { kind: "numberLiteral", value: 1 }, - }, - { - when: whenIndeterminate, - then: { kind: "numberLiteral", value: 2 }, - }, - ], - fallback: { kind: "numberLiteral", value: 0 }, - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); - - it("absent hitPolicy behaves identically to explicit 'first'", async () => { - const cases = [ - { - when: whenEquals(1, 2), - then: { kind: "numberLiteral" as const, value: 100 }, - }, - { - when: whenEquals(1, 1), - then: { kind: "numberLiteral" as const, value: 200 }, - }, - ]; - const withoutHitPolicy = await evaluateValue( - { - kind: "conditional", - cases, - fallback: { kind: "numberLiteral", value: 0 }, - }, - undefined, - resolvers, - ); - const withExplicitFirst = await evaluateValue( - { - kind: "conditional", - hitPolicy: "first", - cases, - fallback: { kind: "numberLiteral", value: 0 }, - }, - undefined, - resolvers, - ); - expect(withoutHitPolicy).toEqual(withExplicitFirst); - expectDefinite(withoutHitPolicy, { kind: "number", value: 200 }); - }); - - it("evaluates every case's when concurrently, not short-circuited on the first match", async () => { - const caseCount = 3; - let whenEvaluationCount = 0; - const trackingResolvers: Resolvers = { - ...resolvers, - resolveValue: async (key, context) => { - if (key === "when-marker") whenEvaluationCount += 1; - return resolvers.resolveValue(key, context); - }, - }; - const whenMarker: PredicateNode = { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "when-marker" }, - right: { kind: "numberLiteral", value: 0 }, - }; - await evaluateValue( - { - kind: "conditional", - hitPolicy: "unique", - cases: [ - { when: whenMarker, then: { kind: "numberLiteral", value: 1 } }, - { when: whenMarker, then: { kind: "numberLiteral", value: 2 } }, - { when: whenMarker, then: { kind: "numberLiteral", value: 3 } }, - ], - fallback: { kind: "numberLiteral", value: 0 }, - }, - undefined, - trackingResolvers, - ); - expect(whenEvaluationCount).toBe(caseCount); - }); - }); -}); - -describe("fold", () => { - const foldResolvers: Resolvers = { - resolveValue: async (key, context) => { - if ( - typeof key !== "string" || - !isPlainRecord(context) || - !(key in context) - ) { - return Promise.resolve({ found: false }); - } - const value = context[key]; - if (typeof value === "number") { - return Promise.resolve({ - found: true, - value: { kind: "number", value }, - }); - } - if (typeof value === "string") { - return Promise.resolve({ found: true, value: { kind: "text", value } }); - } - return Promise.resolve({ found: false }); - }, - resolveLookup: async () => Promise.resolve({ found: false }), - resolveCollection: async (collection) => { - if (collection === "amounts") { - return Promise.resolve([{ amount: 8 }, { amount: 12 }, { amount: 1 }]); - } - if (collection === "grouped") { - return Promise.resolve([ - { group: "keep", amount: 5 }, - { group: "skip", amount: 999 }, - ]); - } - if (collection === "empty") return Promise.resolve([]); - return Promise.resolve([]); - }, - }; - - describe("reduce", () => { - it("sums participating items, threading the running total through accumulator", async () => { - const result = await evaluateValue( - { - kind: "fold", - collection: "amounts", - combiner: { - mode: "reduce", - initial: { kind: "numberLiteral", value: 0 }, - combine: { - kind: "arithmetic", - op: "add", - left: { kind: "accumulator" }, - right: { kind: "reference", key: "amount" }, - }, - }, - }, - undefined, - foldResolvers, - ); - expectDefinite(result, { kind: "number", value: 21 }); - }); - - it("filter narrows which items participate, exactly like some/every's own filter", async () => { - const result = await evaluateValue( - { - kind: "fold", - collection: "grouped", - filter: { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "group" }, - right: { kind: "textLiteral", value: "keep" }, - }, - combiner: { - mode: "reduce", - initial: { kind: "numberLiteral", value: 0 }, - combine: { - kind: "arithmetic", - op: "add", - left: { kind: "accumulator" }, - right: { kind: "reference", key: "amount" }, - }, - }, - }, - undefined, - foldResolvers, - ); - // The excluded item's amount (999) never participates; only the kept item's 5 does. - expectDefinite(result, { kind: "number", value: 5 }); - }); - - it("over an empty (post-filter) collection evaluates to initial directly, without touching combine", async () => { - const result = await evaluateValue( - { - kind: "fold", - collection: "empty", - combiner: { - mode: "reduce", - initial: { kind: "numberLiteral", value: 7 }, - combine: { kind: "numberLiteral", value: 999 }, - }, - }, - undefined, - foldResolvers, - ); - expectDefinite(result, { kind: "number", value: 7 }); - }); - }); - - describe("max, min", () => { - it("max keeps the largest projected value seen", async () => { - const result = await evaluateValue( - { - kind: "fold", - collection: "amounts", - combiner: { mode: "max", item: { kind: "reference", key: "amount" } }, - }, - undefined, - foldResolvers, - ); - expectDefinite(result, { kind: "number", value: 12 }); - }); - - it("min keeps the smallest projected value seen", async () => { - const result = await evaluateValue( - { - kind: "fold", - collection: "amounts", - combiner: { mode: "min", item: { kind: "reference", key: "amount" } }, - }, - undefined, - foldResolvers, - ); - expectDefinite(result, { kind: "number", value: 1 }); - }); - - it("is domain-error over an empty (post-filter) collection -- there is no first item to seed from", async () => { - const result = await evaluateValue( - { - kind: "fold", - collection: "empty", - combiner: { mode: "max", item: { kind: "reference", key: "amount" } }, - }, - undefined, - foldResolvers, - ); - expectIndeterminate(result, "domain-error"); - }); - }); -}); - -describe("accumulator", () => { - it("is wrong-type when evaluated outside any fold's combine expression", async () => { - const result = await evaluateValue( - { kind: "accumulator" }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("resolves to the running value inside a reduce fold's own combine expression", async () => { - // The collection has to genuinely resolve to items: over an empty one `combine` is never evaluated at all and the fold returns `initial` untouched, so an assertion against `initial`'s own value would hold whether or not `accumulator` resolved correctly. Stepping the accumulator once per item is what makes the expected value depend on it. - const twoItemResolvers: Resolvers = { - ...resolvers, - resolveCollection: async () => Promise.resolve([{}, {}]), - }; - const result = await evaluateValue( - { - kind: "fold", - collection: "items", - combiner: { - mode: "reduce", - initial: { kind: "numberLiteral", value: 5 }, - combine: { - kind: "arithmetic", - op: "add", - left: { kind: "accumulator" }, - right: { kind: "numberLiteral", value: 1 }, - }, - }, - }, - undefined, - twoItemResolvers, - ); - expectDefinite(result, { kind: "number", value: 7 }); - }); -}); - -describe("delegate", () => { - it("is wrong-type when no delegate handler is registered", async () => { - const result = await evaluateValue( - { kind: "delegate", system: "tax-engine", payload: { rate: 0.2 } }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("resolves via the registered handler when one is provided", async () => { - const delegateResolvers: Resolvers = { - ...resolvers, - resolveDelegate: async (system) => - Promise.resolve( - system === "tax-engine" - ? { found: true, value: { kind: "number", value: 42 } } - : { found: false }, - ), - }; - const result = await evaluateValue( - { kind: "delegate", system: "tax-engine", payload: null }, - undefined, - delegateResolvers, - ); - expectDefinite(result, { kind: "number", value: 42 }); - }); - - it("is not-found when the registered handler itself reports absence", async () => { - const delegateResolvers: Resolvers = { - ...resolvers, - resolveDelegate: async () => Promise.resolve({ found: false }), - }; - const result = await evaluateValue( - { kind: "delegate", system: "tax-engine", payload: null }, - undefined, - delegateResolvers, - ); - expectIndeterminate(result, "not-found"); - }); -}); - -describe("treeReference", () => { - it("evaluatePredicate: is wrong-type when no tree resolver is registered", async () => { - const result = await evaluatePredicate( - { kind: "treeReference", key: "eligibility" }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("evaluateValue: is wrong-type when no tree resolver is registered", async () => { - const result = await evaluateValue( - { kind: "treeReference", key: "pricing" }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("evaluatePredicate: is not-found when the resolver reports absence", async () => { - const treeResolvers: Resolvers = { - ...resolvers, - resolveTree: async () => Promise.resolve({ found: false }), - }; - const result = await evaluatePredicate( - { kind: "treeReference", key: "eligibility" }, - undefined, - treeResolvers, - ); - expectIndeterminate(result, "not-found"); - }); - - it("evaluatePredicate: resolves and evaluates the referenced predicate tree, with context passed through unchanged", async () => { - const treeResolvers: Resolvers = { - ...resolvers, - resolveTree: async (key) => - Promise.resolve( - key === "eligibility" - ? { - found: true, - node: { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 21 }, - }, - } - : { found: false }, - ), - resolveValue: async (key, context) => - Promise.resolve( - key === "age" && isPlainRecord(context) && "age" in context - ? { - found: true, - value: { kind: "number", value: context.age as number }, - } - : { found: false }, - ), - }; - const result = await evaluatePredicate( - { kind: "treeReference", key: "eligibility" }, - { age: 21 }, - treeResolvers, - ); - expectDefinite(result, true); - }); - - it("evaluateValue: resolves and evaluates the referenced expression tree, with the enclosing fold's accumulator passed through unchanged", async () => { - const treeResolvers: Resolvers = { - ...resolvers, - resolveTree: async (key) => - Promise.resolve( - key === "double-accumulator" - ? { - found: true, - node: { - kind: "arithmetic", - op: "multiply", - left: { kind: "accumulator" }, - right: { kind: "numberLiteral", value: 2 }, - }, - } - : { found: false }, - ), - resolveCollection: async (collection) => - Promise.resolve(collection === "items" ? [{}] : []), - }; - const result = await evaluateValue( - { - kind: "fold", - collection: "items", - combiner: { - mode: "reduce", - initial: { kind: "numberLiteral", value: 5 }, - combine: { kind: "treeReference", key: "double-accumulator" }, - }, - }, - undefined, - treeResolvers, - ); - expectDefinite(result, { kind: "number", value: 10, unit: {} }); - }); - - it("evaluatePredicate: is wrong-type when the resolved node fails to parse as a PredicateNode", async () => { - const treeResolvers: Resolvers = { - ...resolvers, - resolveTree: async () => - Promise.resolve({ found: true, node: { kind: "not-a-real-kind" } }), - }; - const result = await evaluatePredicate( - { kind: "treeReference", key: "malformed" }, - undefined, - treeResolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("evaluateValue: is wrong-type when the resolved node fails to parse as an ExpressionNode", async () => { - const treeResolvers: Resolvers = { - ...resolvers, - resolveTree: async () => - Promise.resolve({ found: true, node: "just a string, not a node" }), - }; - const result = await evaluateValue( - { kind: "treeReference", key: "malformed" }, - undefined, - treeResolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - it("direct self-reference is a domain-error: circular treeReference detected", async () => { - const treeResolvers: Resolvers = { - ...resolvers, - resolveTree: async () => - Promise.resolve({ - found: true, - node: { kind: "treeReference", key: "self" }, - }), - }; - const result = await evaluatePredicate( - { kind: "treeReference", key: "self" }, - undefined, - treeResolvers, - ); - expectIndeterminate(result, "domain-error"); - if (result.status === "indeterminate") { - expect(result.reason.message).toBe("circular treeReference detected"); - } - }); - - it("indirect A -> B -> A cycle is a domain-error", async () => { - const treeResolvers: Resolvers = { - ...resolvers, - resolveTree: async (key) => - Promise.resolve({ - found: true, - node: { - kind: "treeReference", - key: key === "ruleA" ? "ruleB" : "ruleA", - }, - }), - }; - const result = await evaluatePredicate( - { kind: "treeReference", key: "ruleA" }, - undefined, - treeResolvers, - ); - expectIndeterminate(result, "domain-error"); - if (result.status === "indeterminate") { - expect(result.reason.message).toBe("circular treeReference detected"); - } - }); - - it("a long acyclic chain past the maximum depth is a domain-error, not a cycle", async () => { - const treeResolvers: Resolvers = { - ...resolvers, - resolveTree: async (key) => - Promise.resolve({ - found: true, - node: { - kind: "treeReference", - key: `${typeof key === "string" ? key : "chain"}-next`, - }, - }), - }; - const result = await evaluatePredicate( - { kind: "treeReference", key: "chain-0" }, - undefined, - treeResolvers, - ); - expectIndeterminate(result, "domain-error"); - if (result.status === "indeterminate") { - expect(result.reason.message).toBe( - "treeReference chain exceeds the maximum depth of 100", - ); - } - }); - - it("a treeReference nested inside a some/every filter still participates in cycle detection correctly", async () => { - const treeResolvers: Resolvers = { - ...resolvers, - resolveCollection: async (collection) => - Promise.resolve(collection === "items" ? [{}] : []), - resolveTree: async () => - Promise.resolve({ - found: true, - node: { kind: "treeReference", key: "self-in-filter" }, - }), - }; - await expect( - evaluatePredicate( - { - kind: "some", - collection: "items", - item: { - kind: "exists", - operand: { kind: "numberLiteral", value: 1 }, - }, - filter: { kind: "treeReference", key: "self-in-filter" }, - }, - undefined, - treeResolvers, - ), - ).resolves.toMatchObject({ - status: "indeterminate", - reason: { - code: "domain-error", - message: "circular treeReference detected", - }, - }); - }); - - it("a treeReference nested inside a conditional's when still participates in cycle detection correctly", async () => { - const treeResolvers: Resolvers = { - ...resolvers, - resolveTree: async () => - Promise.resolve({ - found: true, - node: { kind: "treeReference", key: "self-in-when" }, - }), - }; - await expect( - evaluateValue( - { - kind: "conditional", - cases: [ - { - when: { kind: "treeReference", key: "self-in-when" }, - then: { kind: "numberLiteral", value: 1 }, - }, - ], - fallback: { kind: "numberLiteral", value: 0 }, - }, - undefined, - treeResolvers, - ), - ).resolves.toMatchObject({ - status: "indeterminate", - reason: { - code: "domain-error", - message: "circular treeReference detected", - }, - }); - }); -}); - -describe("call", () => { - const doubleFn: FunctionRegistry = { - double: (args) => { - const [arg] = args; - if (arg?.kind !== "number") { - return { domainError: "double requires a single number argument" }; - } - return { kind: "number", value: arg.value + arg.value }; - }, - }; - - it("invokes the registered function with the resolved arguments", async () => { - const { evaluateValue: evaluate } = createEvaluator({ - functions: doubleFn, - }); - const result = await evaluate( - { - kind: "call", - fn: "double", - args: [{ kind: "numberLiteral", value: 21 }], - }, - undefined, - resolvers, - ); - expectDefinite(result, { kind: "number", value: 42 }); - }); - - it("is wrong-type for an unregistered function name", async () => { - const result = await evaluateValue( - { kind: "call", fn: "doesNotExist", args: [] }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }); - - /** A `FunctionRegistry` is an ordinary object, so `Object.prototype`'s own members answer a bare index lookup on it. A tree naming one of those is naming a function nobody registered, and must be reported as such rather than reaching a prototype method -- which would either throw or produce a "definite" result that is not a `ComputedValue` at all. */ - it.each(["toString", "valueOf", "constructor", "hasOwnProperty"])( - "treats the inherited Object.prototype member '%s' as an unregistered function name", - async (fn) => { - const result = await evaluateValue( - { kind: "call", fn, args: [] }, - undefined, - resolvers, - ); - expectIndeterminate(result, "wrong-type"); - }, - ); - - it("is domain-error when the registered function reports one", async () => { - const { evaluateValue: evaluate } = createEvaluator({ - functions: doubleFn, - }); - const result = await evaluate( - { - kind: "call", - fn: "double", - args: [{ kind: "textLiteral", value: "x" }], - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "domain-error"); - }); - - it("evaluates every argument concurrently, tie-breaking an indeterminate outcome to the first in declared order", async () => { - const { evaluateValue: evaluate } = createEvaluator({ - functions: doubleFn, - }); - const result = await evaluate( - { - kind: "call", - fn: "double", - args: [ - { kind: "reference", key: "missing" }, - { kind: "reference", key: "also-missing" }, - ], - }, - undefined, - resolvers, - ); - expectIndeterminate(result, "not-found"); - }); -}); - -describe("evaluation resource limits", () => { - /** A `depth`-deep chain of `not` wrapping a single leaf `exists` node, built iteratively (never recursively) so constructing the fixture itself never taxes the test runner's own call stack. No `treeReference` node appears anywhere in this tree, so `MAX_TREE_REFERENCE_DEPTH`'s chain-depth counter never advances while evaluating it -- this is deliberately the shape that guard cannot see, since it only checks depth across `treeReference` hops between separately-stored trees, never ordinary recursive descent within one self-contained tree. */ - function deeplyNestedNot(depth: number): PredicateNode { - let node: PredicateNode = { - kind: "exists", - operand: { kind: "numberLiteral", value: 1 }, - }; - for (let level = 0; level < depth; level += 1) { - node = { kind: "not", operand: node }; - } - return node; - } - - /** A wide-but-shallow `allOf` of `count` trivial `exists` leaves -- every leaf contributes to the total node-visit count while adding only two levels of nesting depth regardless of `count`, isolating the node-count cap from the nesting-depth cap below. */ - function wideAllOf(count: number): PredicateNode { - return { - kind: "allOf", - operands: Array.from({ length: count }, () => ({ - kind: "exists" as const, - operand: { kind: "numberLiteral" as const, value: 1 }, - })), - }; - } - - /** The explicit `maxNodes` configured for the node-count-cap tests below, chosen only to be comfortably smaller than `wideOperandCountExceedingNodeCap`'s resulting node count -- its exact value carries no other significance. */ - const explicitNodeCap = 50; - - /** The explicit `maxNestingDepth` configured for the nesting-depth-cap tests below, chosen only to be comfortably smaller than `notDepthExceedingNestingCap` -- its exact value carries no other significance. */ - const explicitNestingDepthCap = 10; - - /** `allOf` operand count for the node-count-cap test: the allOf node itself plus this many two-node (`exists` + `numberLiteral`) operands comfortably exceeds `explicitNodeCap`, while the tree stays only two levels deep -- nowhere near any nesting-depth cap, isolating that assertion to the node-count cap alone. */ - const wideOperandCountExceedingNodeCap = 60; - - /** `not`-chain depth for the nesting-depth-cap test: comfortably exceeds `explicitNestingDepthCap`, while the resulting total node count stays nowhere near any default or explicit node-count cap, isolating that assertion to the nesting-depth cap alone. */ - const notDepthExceedingNestingCap = 50; - - /** `not`-chain depth for the "well within both caps" test: an odd number of negations flips a definitely-true `exists` leaf to false, comfortably inside both `explicitNodeCap` and `explicitNestingDepthCap`. */ - const notDepthWithinBothCaps = 5; - - /** `not`-chain depth used to prove `createEvaluator({})`'s own defaults catch an oversized tree: comfortably past `DEFAULT_MAX_NESTING_DEPTH` while staying well short of the depth at which this shape has been observed to exhaust the real call stack (see the issue this addresses for how that was measured). */ - const notDepthExceedingDefaultNestingCap = 1000; - - it("a node-count cap configured via createEvaluator rejects a wide-but-shallow tree that exceeds it, as indeterminate", async () => { - const { evaluatePredicate: evaluate } = createEvaluator({ - maxNodes: explicitNodeCap, - }); - const result = await evaluate( - wideAllOf(wideOperandCountExceedingNodeCap), - undefined, - resolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("domain-error"); - expect(result.reason.message).toMatch(/node/i); - } - }); - - it("a nesting-depth cap configured via createEvaluator rejects a narrow-but-deep tree that exceeds it, as indeterminate", async () => { - const { evaluatePredicate: evaluate } = createEvaluator({ - maxNestingDepth: explicitNestingDepthCap, - }); - const result = await evaluate( - deeplyNestedNot(notDepthExceedingNestingCap), - undefined, - resolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("domain-error"); - expect(result.reason.message).toMatch(/depth/i); - } - }); - - it("a tree well within both caps evaluates normally and correctly", async () => { - const { evaluatePredicate: evaluate } = createEvaluator({ - maxNodes: explicitNodeCap, - maxNestingDepth: explicitNestingDepthCap, - }); - const result = await evaluate( - deeplyNestedNot(notDepthWithinBothCaps), - undefined, - resolvers, - ); - expectDefinite(result, false); - }); - - it("MAX_TREE_REFERENCE_DEPTH's own chain-depth guard is unaffected by the nesting-depth cap", async () => { - const treeResolvers: Resolvers = { - ...resolvers, - resolveTree: async (key) => - Promise.resolve({ - found: true, - node: { - kind: "treeReference", - key: `${typeof key === "string" ? key : "chain"}-next`, - }, - }), - }; - const result = await evaluatePredicate( - { kind: "treeReference", key: "chain-0" }, - undefined, - treeResolvers, - ); - expectIndeterminate(result, "domain-error"); - if (result.status === "indeterminate") { - expect(result.reason.message).toBe( - "treeReference chain exceeds the maximum depth of 100", - ); - } - }); - - it("createEvaluator({})'s defaults catch a real oversized tree without any explicit cap configuration", async () => { - // A correct default catches this deterministically as indeterminate; the pre-fix evaluator instead resolved it to a (wrong, but not crashing) definite value at this particular depth. - const result = await evaluatePredicate( - deeplyNestedNot(notDepthExceedingDefaultNestingCap), - undefined, - resolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("domain-error"); - expect(result.reason.message).toMatch(/depth/i); - } - }); - - it("createEvaluator({})'s defaults catch a real oversized tree via evaluateValue too, not only evaluatePredicate", async () => { - let node: ExpressionNode = { kind: "numberLiteral", value: 1 }; - for ( - let level = 0; - level < notDepthExceedingDefaultNestingCap; - level += 1 - ) { - node = { kind: "negate", operand: node }; - } - const result = await evaluateValue(node, undefined, resolvers); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("domain-error"); - expect(result.reason.message).toMatch(/depth/i); - } - }); -}); - -/** README.md's own "Worked example" (§ Worked example), verbatim: `isActive equals 1` AND `sum(items.amount) > x + y`, where the sum is exactly the `fold` tree the `sum` derived-aggregate builder assembles. Both variations from the README are included, exercising the propagation rules the worked example is there to demonstrate -- absorption via `and`'s definitely-false right operand, versus a missing reference surfacing all the way to the top because `true` is not absorbing for `and`. */ -describe("golden example (README Worked example)", () => { - const goldenExampleResolvers: Resolvers = { - resolveValue: async (key, context) => { - if ( - typeof key !== "string" || - !isPlainRecord(context) || - !(key in context) - ) { - return Promise.resolve({ found: false }); - } - const value = context[key]; - return Promise.resolve( - typeof value === "number" - ? { found: true, value: { kind: "number", value } } - : { found: false }, - ); - }, - resolveLookup: async () => Promise.resolve({ found: false }), - resolveCollection: async (collection, context) => { - if (collection !== "items" || !isPlainRecord(context)) { - return Promise.resolve([]); - } - const items = context.items; - return Promise.resolve(isUnknownArray(items) ? items : []); - }, - }; - - const node: PredicateNode = { - kind: "and", - left: { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "isActive" }, - right: { kind: "numberLiteral", value: 1 }, - }, - right: { - kind: "compare", - op: "gt", - left: { - kind: "fold", - collection: "items", - combiner: { - mode: "reduce", - initial: { kind: "numberLiteral", value: 0 }, - combine: { - kind: "arithmetic", - op: "add", - left: { kind: "accumulator" }, - right: { kind: "reference", key: "amount" }, - }, - }, - }, - right: { - kind: "arithmetic", - op: "add", - left: { kind: "reference", key: "x" }, - right: { kind: "reference", key: "y" }, - }, - }, - }; - - it("base case: isActive=1, sum(items.amount)=21 > x+y=15 => definitely true", async () => { - const data = { - isActive: 1, - x: 10, - y: 5, - items: [{ amount: 8 }, { amount: 12 }, { amount: 1 }], - }; - const result = await evaluatePredicate(node, data, goldenExampleResolvers); - expect(result).toEqual({ status: "definite", value: true }); - }); - - it("empty items: the sum-over-empty identity (0) is not > 15, and false absorbs regardless of the left branch", async () => { - const data = { isActive: 1, x: 10, y: 5, items: [] as unknown[] }; - const result = await evaluatePredicate(node, data, goldenExampleResolvers); - expect(result).toEqual({ status: "definite", value: false }); - }); - - it("missing x: not-found surfaces to the top, since true is not absorbing for and", async () => { - const data = { - isActive: 1, - y: 5, - items: [{ amount: 8 }, { amount: 12 }, { amount: 1 }], - }; - const result = await evaluatePredicate(node, data, goldenExampleResolvers); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("not-found"); - } - }); -}); diff --git a/packages/trilean/src/evaluator.values.test.ts b/packages/trilean/src/evaluator.values.test.ts new file mode 100644 index 0000000..6ea7be6 --- /dev/null +++ b/packages/trilean/src/evaluator.values.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; +import { evaluatePredicate, evaluateValue } from "./evaluator"; +import type { Resolvers } from "./resolvers"; +import { + expectDefinite, + expectIndeterminate, + resolvers, +} from "./evaluator-test-helpers"; + +describe("literals", () => { + it("numberLiteral is always definite", async () => { + const result = await evaluateValue( + { kind: "numberLiteral", value: 42, unit: { kg: 1 } }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "number", value: 42, unit: { kg: 1 } }); + }); + + it("textLiteral is always definite", async () => { + const result = await evaluateValue( + { kind: "textLiteral", value: "active" }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "text", value: "active" }); + }); + + it("booleanLiteral is always definite", async () => { + const result = await evaluateValue( + { kind: "booleanLiteral", value: true }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "boolean", value: true }); + }); + + it("instantLiteral is always definite", async () => { + const result = await evaluateValue( + { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "instant", + value: "2026-01-01T00:00:00.000Z", + }); + }); + + it("durationLiteral is always definite", async () => { + const result = await evaluateValue( + { kind: "durationLiteral", value: 5, unit: "min" }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "duration", value: 5, unit: "min" }); + }); +}); + +describe("complexLiteral", () => { + it("is always definite, preserving both components and the optional unit", async () => { + const result = await evaluateValue( + { kind: "complexLiteral", re: 3, im: -4, unit: { V: 1, A: -1 } }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "complex", + re: 3, + im: -4, + unit: { V: 1, A: -1 }, + }); + }); + + it("normalises a polar literal to rectangular on evaluation, preserving the optional unit", async () => { + const result = await evaluateValue( + { + kind: "complexLiteral", + magnitude: 1, + phase: Math.PI / 2, + unit: { V: 1 }, + }, + undefined, + resolvers, + ); + expect(result.status).toBe("definite"); + if (result.status !== "definite") return; + expect(result.value.kind).toBe("complex"); + if (result.value.kind !== "complex") return; + expect(result.value.re).toBeCloseTo(0); + expect(result.value.im).toBeCloseTo(1); + expect(result.value.unit).toEqual({ V: 1 }); + }); + + it("eq: a rectangular literal and a polar literal representing the same underlying complex number compare as equal", async () => { + // A deliberately non-trivial phase (not a multiple of pi/2), with the rectangular side computed from the raw trig formula independently of the evaluator/complexFromPolar under test -- a sin/cos swap, or a bug that ignored `phase` entirely, would each produce a rectangular value that differs from this one, unlike phase 0's trivial identity. + const arbitraryPhase = 0.7; + const magnitude = 2; + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { + kind: "complexLiteral", + re: magnitude * Math.cos(arbitraryPhase), + im: magnitude * Math.sin(arbitraryPhase), + }, + right: { kind: "complexLiteral", magnitude, phase: arbitraryPhase }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("memberOf: a rectangular operand matches a candidate list containing a polar literal representing the same number", async () => { + // Same non-trivial-phase, independently-computed-rectangular reasoning as the `eq` test above applies here. + const arbitraryPhase = 0.7; + const magnitude = 1; + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { + kind: "complexLiteral", + re: magnitude * Math.cos(arbitraryPhase), + im: magnitude * Math.sin(arbitraryPhase), + }, + candidates: [ + { kind: "complexLiteral", re: 0, im: 1 }, + { kind: "complexLiteral", magnitude, phase: arbitraryPhase }, + ], + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); +}); + +describe("reference", () => { + it("resolves a found value with no expected unit", async () => { + const result = await evaluateValue( + { kind: "reference", key: "present" }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "number", value: 10, unit: { m: 1 } }); + }); + + it("resolves a found value whose unit matches the expected unit", async () => { + const result = await evaluateValue( + { kind: "reference", key: "present", unit: { m: 1 } }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "number", value: 10, unit: { m: 1 } }); + }); + + it("is not-found for an unresolved key", async () => { + const result = await evaluateValue( + { kind: "reference", key: "missing" }, + undefined, + resolvers, + ); + expectIndeterminate(result, "not-found"); + }); + + it("is wrong-type when the expected unit does not match the resolved unit", async () => { + const result = await evaluateValue( + { kind: "reference", key: "present", unit: { s: 1 } }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("resolves a boolean value with no schema change beyond the ComputedValue union itself -- reference carries no kind restriction of its own", async () => { + const booleanResolvers: Resolvers = { + ...resolvers, + resolveValue: async (key, context) => + key === "active" + ? Promise.resolve({ + found: true, + value: { kind: "boolean", value: true }, + }) + : resolvers.resolveValue(key, context), + }; + const result = await evaluateValue( + { kind: "reference", key: "active" }, + undefined, + booleanResolvers, + ); + expectDefinite(result, { kind: "boolean", value: true }); + }); + + it("checks an expected unit against a complex resolution too, since a complex value carries one as well", async () => { + const complexResolvers: Resolvers = { + ...resolvers, + resolveValue: async () => + Promise.resolve({ + found: true, + value: { kind: "complex", re: 3, im: -4, unit: { V: 1, A: -1 } }, + }), + }; + + const matching = await evaluateValue( + { kind: "reference", key: "impedance", unit: { V: 1, A: -1 } }, + undefined, + complexResolvers, + ); + expectDefinite(matching, { + kind: "complex", + re: 3, + im: -4, + unit: { V: 1, A: -1 }, + }); + + const mismatched = await evaluateValue( + { kind: "reference", key: "impedance", unit: { V: 1 } }, + undefined, + complexResolvers, + ); + expectIndeterminate(mismatched, "wrong-type"); + }); +}); From 735061d5c9b04e11bc8c6599290f6ca71d6df262 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:20:28 +0100 Subject: [PATCH 7/9] test(evaluator): split evaluator.indeterminacy.test.ts under the 800-line cap evaluator.indeterminacy.test.ts had grown to 1138 lines of real code, over the new max-lines cap. Its own structure already separated cleanly into a fixture-driven reference table (one row per node-kind x reason-code combination) and a set of explicitly called-out edge cases beyond that table, so it splits along that same seam into evaluator.indeterminacy-table.test.ts and evaluator.indeterminacy-cases.test.ts. Extracts the fixture machinery both files share (baseResolvers, the shared node fragments, the pred/expr/runFixture helpers) into evaluator-indeterminacy-helpers.ts. Also fixes the one real new-rule violation this uncovered: runFixture narrowed an indeterminate result's reason via `result as { reason: IndeterminateReason }`, banned outright by this project's own no-assertions rule (allowed in *.test.ts files specifically, which this shared helper module no longer is). Typing Fixture.run's result as a proper `FixtureRunResult` discriminated union, rather than the looser `{ status: string }` it replaces, lets runFixture narrow result.reason directly with no assertion needed. --- .../src/evaluator-indeterminacy-helpers.ts | 219 +++ .../src/evaluator.indeterminacy-cases.test.ts | 382 ++++++ .../src/evaluator.indeterminacy-table.test.ts | 674 +++++++++ .../src/evaluator.indeterminacy.test.ts | 1220 ----------------- 4 files changed, 1275 insertions(+), 1220 deletions(-) create mode 100644 packages/trilean/src/evaluator-indeterminacy-helpers.ts create mode 100644 packages/trilean/src/evaluator.indeterminacy-cases.test.ts create mode 100644 packages/trilean/src/evaluator.indeterminacy-table.test.ts delete mode 100644 packages/trilean/src/evaluator.indeterminacy.test.ts diff --git a/packages/trilean/src/evaluator-indeterminacy-helpers.ts b/packages/trilean/src/evaluator-indeterminacy-helpers.ts new file mode 100644 index 0000000..e06e3ea --- /dev/null +++ b/packages/trilean/src/evaluator-indeterminacy-helpers.ts @@ -0,0 +1,219 @@ +import { expect } from "vitest"; +import { createEvaluator, evaluatePredicate, evaluateValue } from "./evaluator"; +import type { IndeterminateReason } from "./evaluation"; +import type { FunctionRegistry } from "./functions"; +import type { EvaluationContext, Resolvers } from "./resolvers"; +import type { ExpressionNode, PredicateNode } from "./tree"; + +/** + * Shared fixture machinery for the two split indeterminacy test files (evaluator.indeterminacy-table.test.ts, evaluator.indeterminacy-cases.test.ts): systematic, table-driven coverage of README.md's "Indeterminacy reference" section, one fixture per node-kind x reason-code combination that section documents, for every `PredicateNode`/`ExpressionNode` kind implemented. This complements, rather than replaces, the ad hoc behavioural tests already spread across the evaluator.*.test.ts files and truth-tables.test.ts. + */ + +export function isPlainRecord( + value: unknown, +): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isUnknownArray(value: unknown): value is unknown[] { + return Array.isArray(value); +} + +/** A single resolver pool shared by every fixture below, covering every named key/table/collection any fixture needs. Fixtures that need genuinely different resolver behaviour (the delegate/call rows) supply their own. */ +export const baseResolvers: Resolvers = { + resolveValue: async (key, context) => { + if (key === "present") { + return Promise.resolve({ + found: true, + value: { kind: "number", value: 10, unit: { m: 1 } }, + }); + } + if (key === "presentText") { + return Promise.resolve({ + found: true, + value: { kind: "text", value: "hello" }, + }); + } + if (typeof key === "string" && isPlainRecord(context) && key in context) { + const value = context[key]; + if (typeof value === "number") { + return Promise.resolve({ + found: true, + value: { kind: "number", value }, + }); + } + if (typeof value === "string") { + return Promise.resolve({ + found: true, + value: { kind: "text", value }, + }); + } + } + return Promise.resolve({ found: false }); + }, + resolveLookup: async (table) => { + if (table === "table1") { + return Promise.resolve({ + found: true, + value: { kind: "number", value: 100 }, + }); + } + return Promise.resolve({ found: false }); + }, + resolveCollection: async (collection, context) => { + if (collection === "single-notfound") return Promise.resolve([{}]); + if (collection === "single-wrongtype") { + return Promise.resolve([{ value: "x" }]); + } + if (collection === "single-domainerror") return Promise.resolve([{}]); + if (collection === "empty") return Promise.resolve([]); + if (collection === "one-item") return Promise.resolve([{}]); + if (collection === "nested-outer") { + return Promise.resolve([ + { values: [{ amount: 1 }, { amount: 2 }] }, + { values: [{ amount: 10 }] }, + ]); + } + if (collection === "values" && isPlainRecord(context)) { + const values = context.values; + return Promise.resolve(isUnknownArray(values) ? values : []); + } + return Promise.resolve([]); + }, +}; + +// --- Shared node fragments --- + +export const numberLiteral = (value: number): ExpressionNode => ({ + kind: "numberLiteral", + value, +}); +export const missingRef: ExpressionNode = { + kind: "reference", + key: "missing", +}; +export const divideByZero: ExpressionNode = { + kind: "arithmetic", + op: "divide", + left: numberLiteral(1), + right: numberLiteral(0), +}; +export const wrongTypeArithmetic: ExpressionNode = { + kind: "arithmetic", + op: "add", + left: { kind: "textLiteral", value: "x" }, + right: numberLiteral(1), +}; +export const trueGuard: PredicateNode = { + kind: "compare", + op: "eq", + left: numberLiteral(1), + right: numberLiteral(1), +}; +export const falseGuard: PredicateNode = { + kind: "compare", + op: "eq", + left: numberLiteral(1), + right: numberLiteral(2), +}; +export const missingGuard: PredicateNode = { + kind: "compare", + op: "eq", + left: missingRef, + right: numberLiteral(1), +}; + +/** Arbitrary non-`{-1,0,1,2}` magnitudes used below, named individually so each numeric literal only appears once (`@typescript-eslint/no-magic-numbers` only exempts -1/0/1/2 and object-literal property values, not values passed as plain call arguments). */ +export const memberOfProbeValue = 5; +export const reduceInitialSeed = 5; +export const laterConditionalCaseValue = 3; +export const untouchedReduceInitial = 42; +export const uniqueHitPolicyThirdCaseValue = 3; + +// --- Fixture machinery --- + +export type Expected = + | { readonly status: "definite" } + | { + readonly status: "indeterminate"; + readonly code: IndeterminateReason["code"]; + }; + +export const isDefinite: Expected = { status: "definite" }; +export const isNotFound: Expected = { + status: "indeterminate", + code: "not-found", +}; +export const isWrongType: Expected = { + status: "indeterminate", + code: "wrong-type", +}; +export const isDomainError: Expected = { + status: "indeterminate", + code: "domain-error", +}; + +/** The minimal shape `runFixture` needs from either `evaluatePredicate`'s or `evaluateValue`'s result -- both `Evaluation` and `Evaluation` are structurally assignable here since only the indeterminate branch's `reason` is ever inspected, never the definite branch's `value`. Typing `Fixture.run`'s result as this proper discriminated union, rather than the looser `{ status: string }` it replaces, is what lets `runFixture` below narrow `result.reason` directly instead of needing a type assertion. */ +type FixtureRunResult = + | { readonly status: "definite" } + | { + readonly status: "indeterminate"; + readonly reason: IndeterminateReason; + }; + +export interface Fixture { + readonly description: string; + readonly expected: Expected; + readonly run: () => Promise; +} + +export interface FixtureOverrides { + readonly context?: EvaluationContext; + readonly resolvers?: Resolvers; + readonly functions?: FunctionRegistry; +} + +export function pred( + description: string, + node: PredicateNode, + expected: Expected, + overrides: FixtureOverrides = {}, +): Fixture { + const { context, resolvers = baseResolvers, functions } = overrides; + const { evaluatePredicate: evaluate } = functions + ? createEvaluator({ functions }) + : { evaluatePredicate }; + return { + description, + expected, + run: async () => evaluate(node, context, resolvers), + }; +} + +export function expr( + description: string, + node: ExpressionNode, + expected: Expected, + overrides: FixtureOverrides = {}, +): Fixture { + const { context, resolvers = baseResolvers, functions } = overrides; + const { evaluateValue: evaluate } = functions + ? createEvaluator({ functions }) + : { evaluateValue }; + return { + description, + expected, + run: async () => evaluate(node, context, resolvers), + }; +} + +export async function runFixture({ run, expected }: Fixture): Promise { + const result = await run(); + expect(result.status).toBe(expected.status); + if ( + expected.status === "indeterminate" && + result.status === "indeterminate" + ) { + expect(result.reason.code).toBe(expected.code); + } +} diff --git a/packages/trilean/src/evaluator.indeterminacy-cases.test.ts b/packages/trilean/src/evaluator.indeterminacy-cases.test.ts new file mode 100644 index 0000000..f042ccd --- /dev/null +++ b/packages/trilean/src/evaluator.indeterminacy-cases.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, it } from "vitest"; +import { createEvaluator, evaluatePredicate, evaluateValue } from "./evaluator"; +import type { Resolvers } from "./resolvers"; +import type { PredicateNode } from "./tree"; +import { + baseResolvers, + falseGuard, + laterConditionalCaseValue, + missingGuard, + missingRef, + numberLiteral, + trueGuard, + uniqueHitPolicyThirdCaseValue, + untouchedReduceInitial, +} from "./evaluator-indeterminacy-helpers"; + +// --- Explicitly called-out cases beyond the row-per-cell table above --- + +describe("lookup: any-indeterminate-key short-circuit", () => { + it("never calls resolveLookup when a key is indeterminate", async () => { + let resolveLookupCalls = 0; + const trackingResolvers: Resolvers = { + ...baseResolvers, + resolveLookup: async (table, keys, context) => { + resolveLookupCalls += 1; + return baseResolvers.resolveLookup(table, keys, context); + }, + }; + const result = await evaluateValue( + { kind: "lookup", table: "table1", keys: [missingRef, numberLiteral(1)] }, + undefined, + trackingResolvers, + ); + expect(result.status).toBe("indeterminate"); + expect(resolveLookupCalls).toBe(0); + }); +}); + +describe("conditional: must not skip an unresolved guard", () => { + it("never evaluates a later case once an earlier unmatched guard is indeterminate", async () => { + let laterCaseEvaluations = 0; + const trackingResolvers: Resolvers = { + ...baseResolvers, + resolveValue: async (key, context) => { + if (key === "later-case-marker") laterCaseEvaluations += 1; + return baseResolvers.resolveValue(key, context); + }, + }; + const result = await evaluateValue( + { + kind: "conditional", + cases: [ + { when: falseGuard, then: numberLiteral(1) }, + { when: missingGuard, then: numberLiteral(2) }, + { + when: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "later-case-marker" }, + right: numberLiteral(1), + }, + then: numberLiteral(laterConditionalCaseValue), + }, + ], + fallback: numberLiteral(0), + }, + undefined, + trackingResolvers, + ); + expect(result.status).toBe("indeterminate"); + expect(laterCaseEvaluations).toBe(0); + }); +}); + +describe("conditional: 'unique' hit policy", () => { + it("2+ definite matches absorbs -- domain-error, even with a third case that is itself indeterminate", async () => { + const result = await evaluateValue( + { + kind: "conditional", + hitPolicy: "unique", + cases: [ + { when: trueGuard, then: numberLiteral(1) }, + { when: trueGuard, then: numberLiteral(2) }, + { + when: missingGuard, + then: numberLiteral(uniqueHitPolicyThirdCaseValue), + }, + ], + fallback: numberLiteral(0), + }, + undefined, + baseResolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("domain-error"); + } + }); + + it("exactly one definite match plus one indeterminate case does NOT absorb -- the whole node is indeterminate, not the match's then", async () => { + const result = await evaluateValue( + { + kind: "conditional", + hitPolicy: "unique", + cases: [ + { when: trueGuard, then: numberLiteral(1) }, + { when: missingGuard, then: numberLiteral(2) }, + ], + fallback: numberLiteral(0), + }, + undefined, + baseResolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("not-found"); + } + }); + + it("a when that is itself wrong-type propagates that reason, not domain-error", async () => { + const wrongTypeGuard: PredicateNode = { + kind: "compare", + op: "eq", + left: { kind: "textLiteral", value: "x" }, + right: numberLiteral(1), + }; + const result = await evaluateValue( + { + kind: "conditional", + hitPolicy: "unique", + cases: [{ when: wrongTypeGuard, then: numberLiteral(1) }], + fallback: numberLiteral(0), + }, + undefined, + baseResolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("wrong-type"); + } + }); +}); + +describe("fold: reduce vs max/min over an empty collection", () => { + it("reduce evaluates to initial directly, without ever evaluating combine", async () => { + let combineEvaluations = 0; + const trackingResolvers: Resolvers = { + ...baseResolvers, + resolveValue: async (key, context) => { + if (key === "combine-marker") combineEvaluations += 1; + return baseResolvers.resolveValue(key, context); + }, + }; + const result = await evaluateValue( + { + kind: "fold", + collection: "empty", + combiner: { + mode: "reduce", + initial: numberLiteral(untouchedReduceInitial), + combine: { + kind: "arithmetic", + op: "add", + left: { kind: "accumulator" }, + right: { kind: "reference", key: "combine-marker" }, + }, + }, + }, + undefined, + trackingResolvers, + ); + expect(result).toEqual({ + status: "definite", + value: { kind: "number", value: 42 }, + }); + expect(combineEvaluations).toBe(0); + }); + + it("max/min is domain-error, not initial-untouched -- it has no seed value at all", async () => { + const result = await evaluateValue( + { + kind: "fold", + collection: "empty", + combiner: { mode: "max", item: numberLiteral(0) }, + }, + undefined, + baseResolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") + expect(result.reason.code).toBe("domain-error"); + }); +}); + +describe("accumulator: nested-fold shadowing", () => { + it("an inner fold's own combine expression refers to the inner running value, not the outer's", async () => { + // Outer fold sums a per-item nested "values" collection via an inner fold, seeded fresh at 0 on every outer item -- if the inner accumulator wrongly resolved to the outer's *current* running total instead of its own, the second outer item (processed once the outer total is already 3) would double-count it. + const result = await evaluateValue( + { + kind: "fold", + collection: "nested-outer", + combiner: { + mode: "reduce", + initial: numberLiteral(0), + combine: { + kind: "arithmetic", + op: "add", + left: { kind: "accumulator" }, + right: { + kind: "fold", + collection: "values", + combiner: { + mode: "reduce", + initial: numberLiteral(0), + combine: { + kind: "arithmetic", + op: "add", + left: { kind: "accumulator" }, + right: { kind: "reference", key: "amount" }, + }, + }, + }, + }, + }, + }, + undefined, + baseResolvers, + ); + expect(result).toEqual({ + status: "definite", + value: { kind: "number", value: 13 }, + }); + }); +}); + +describe("delegate: missing-handler message", () => { + it("includes the external system's own name", async () => { + const result = await evaluateValue( + { kind: "delegate", system: "external-pricing-engine", payload: null }, + undefined, + baseResolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("wrong-type"); + expect(result.reason.message).toContain("external-pricing-engine"); + } + }); +}); + +describe("treeReference: reason-code coverage", () => { + it("wrong-type: no resolveTree resolver registered", async () => { + const result = await evaluatePredicate( + { kind: "treeReference", key: "any" }, + undefined, + baseResolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("wrong-type"); + } + }); + + it("not-found: the registered resolver reports absence", async () => { + const treeResolvers: Resolvers = { + ...baseResolvers, + resolveTree: async () => Promise.resolve({ found: false }), + }; + const result = await evaluatePredicate( + { kind: "treeReference", key: "any" }, + undefined, + treeResolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("not-found"); + } + }); + + it("wrong-type: the resolved value fails to parse against the expected schema", async () => { + const treeResolvers: Resolvers = { + ...baseResolvers, + resolveTree: async () => + Promise.resolve({ found: true, node: { kind: "nonsense" } }), + }; + const result = await evaluatePredicate( + { kind: "treeReference", key: "any" }, + undefined, + treeResolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("wrong-type"); + } + }); + + it("domain-error: a key already on the current reference chain (cycle)", async () => { + const treeResolvers: Resolvers = { + ...baseResolvers, + resolveTree: async () => + Promise.resolve({ + found: true, + node: { kind: "treeReference", key: "self" }, + }), + }; + const result = await evaluatePredicate( + { kind: "treeReference", key: "self" }, + undefined, + treeResolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("domain-error"); + expect(result.reason.message).toBe("circular treeReference detected"); + } + }); + + it("domain-error: the chain already reached the maximum depth", async () => { + const treeResolvers: Resolvers = { + ...baseResolvers, + resolveTree: async (key) => + Promise.resolve({ + found: true, + node: { + kind: "treeReference", + key: `${typeof key === "string" ? key : "chain"}-next`, + }, + }), + }; + const result = await evaluatePredicate( + { kind: "treeReference", key: "chain-0" }, + undefined, + treeResolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("domain-error"); + expect(result.reason.message).toContain("maximum depth"); + } + }); +}); + +describe("call: unregistered-name vs out-of-domain-argument distinction", () => { + it("an unregistered function name is wrong-type", async () => { + const result = await evaluateValue( + { kind: "call", fn: "doesNotExist", args: [] }, + undefined, + baseResolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("wrong-type"); + expect(result.reason.message).toContain("doesNotExist"); + } + }); + + it("a registered function's out-of-domain argument is domain-error, not wrong-type", async () => { + const { evaluateValue: evaluate } = createEvaluator({ + functions: { + reciprocal: (args) => { + const [arg] = args; + if (arg?.kind !== "number") { + return { + domainError: "reciprocal requires a single number argument", + }; + } + if (arg.value === 0) + return { domainError: "reciprocal of zero is undefined" }; + return { kind: "number", value: 1 / arg.value }; + }, + }, + }); + const result = await evaluate( + { kind: "call", fn: "reciprocal", args: [numberLiteral(0)] }, + undefined, + baseResolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") + expect(result.reason.code).toBe("domain-error"); + }); +}); diff --git a/packages/trilean/src/evaluator.indeterminacy-table.test.ts b/packages/trilean/src/evaluator.indeterminacy-table.test.ts new file mode 100644 index 0000000..4e56af0 --- /dev/null +++ b/packages/trilean/src/evaluator.indeterminacy-table.test.ts @@ -0,0 +1,674 @@ +import { describe, it } from "vitest"; +import { + type Fixture, + baseResolvers, + divideByZero, + expr, + falseGuard, + isDefinite, + isDomainError, + isNotFound, + isWrongType, + memberOfProbeValue, + missingGuard, + missingRef, + numberLiteral, + pred, + reduceInitialSeed, + runFixture, + trueGuard, + wrongTypeArithmetic, +} from "./evaluator-indeterminacy-helpers"; + +// --- The reference table itself, one row per node-kind x reason-code combination --- + +const fixtures: readonly Fixture[] = [ + // not + pred( + "not: not-found propagates from operand", + { + kind: "not", + operand: { + kind: "compare", + op: "eq", + left: missingRef, + right: numberLiteral(1), + }, + }, + isNotFound, + ), + pred( + "not: wrong-type propagates from operand", + { + kind: "not", + operand: { + kind: "compare", + op: "eq", + left: numberLiteral(1), + right: { kind: "textLiteral", value: "1" }, + }, + }, + isWrongType, + ), + pred( + "not: domain-error propagates from operand", + { + kind: "not", + operand: { + kind: "compare", + op: "eq", + left: divideByZero, + right: numberLiteral(1), + }, + }, + isDomainError, + ), + + // and + pred( + "and: not-found propagates when the other operand is not definitely false", + { kind: "and", left: trueGuard, right: missingGuard }, + isNotFound, + ), + pred( + "and: false absorbs an indeterminate other operand regardless of its reason", + { + kind: "and", + left: falseGuard, + right: { + kind: "compare", + op: "eq", + left: divideByZero, + right: numberLiteral(1), + }, + }, + isDefinite, + ), + + // or + pred( + "or: not-found propagates when the other operand is not definitely true", + { kind: "or", left: falseGuard, right: missingGuard }, + isNotFound, + ), + pred( + "or: true absorbs an indeterminate other operand regardless of its reason", + { + kind: "or", + left: trueGuard, + right: { + kind: "compare", + op: "eq", + left: divideByZero, + right: numberLiteral(1), + }, + }, + isDefinite, + ), + + // allOf / anyOf + pred( + "allOf: an indeterminate operand propagates when nothing in the list is definitely false", + { kind: "allOf", operands: [trueGuard, missingGuard, trueGuard] }, + isNotFound, + ), + pred( + "allOf: a definitely-false operand absorbs an indeterminate sibling", + { kind: "allOf", operands: [falseGuard, missingGuard] }, + isDefinite, + ), + pred( + "anyOf: an indeterminate operand propagates when nothing in the list is definitely true", + { kind: "anyOf", operands: [falseGuard, missingGuard, falseGuard] }, + isNotFound, + ), + pred( + "anyOf: a definitely-true operand absorbs an indeterminate sibling", + { kind: "anyOf", operands: [trueGuard, missingGuard] }, + isDefinite, + ), + + // compare + pred( + "compare: not-found propagates from either operand", + { kind: "compare", op: "eq", left: missingRef, right: numberLiteral(1) }, + isNotFound, + ), + pred( + "compare: wrong-type when the operand kinds differ", + { + kind: "compare", + op: "eq", + left: numberLiteral(1), + right: { kind: "textLiteral", value: "1" }, + }, + isWrongType, + ), + pred( + "compare: wrong-type when both operands are a kind compare does not support (text)", + { + kind: "compare", + op: "eq", + left: { kind: "textLiteral", value: "a" }, + right: { kind: "textLiteral", value: "b" }, + }, + isWrongType, + ), + pred( + "compare: wrong-type when two numbers have incompatible units", + { + kind: "compare", + op: "eq", + left: { kind: "numberLiteral", value: 1, unit: { m: 1 } }, + right: { kind: "numberLiteral", value: 1, unit: { s: 1 } }, + }, + isWrongType, + ), + pred( + "compare: never generates its own domain-error, but still carries one through from an operand", + { kind: "compare", op: "eq", left: divideByZero, right: numberLiteral(1) }, + isDomainError, + ), + + // textCompare + pred( + "textCompare: not-found propagates from either operand", + { + kind: "textCompare", + op: "equals", + left: missingRef, + right: { kind: "textLiteral", value: "x" }, + }, + isNotFound, + ), + pred( + "textCompare: wrong-type when an operand is not text", + { + kind: "textCompare", + op: "equals", + left: numberLiteral(1), + right: { kind: "textLiteral", value: "1" }, + }, + isWrongType, + ), + pred( + "textCompare: never generates its own domain-error, but still carries one through from an operand", + { + kind: "textCompare", + op: "equals", + left: divideByZero, + right: { kind: "textLiteral", value: "x" }, + }, + isDomainError, + ), + + // memberOf + pred( + "memberOf: not-found when operand is not found", + { + kind: "memberOf", + op: "in", + operand: missingRef, + candidates: [numberLiteral(1)], + }, + isNotFound, + ), + pred( + "memberOf: wrong-type when operand/candidate kinds are incompatible and nothing else matches", + { + kind: "memberOf", + op: "in", + operand: numberLiteral(memberOfProbeValue), + candidates: [{ kind: "textLiteral", value: "5" }], + }, + isWrongType, + ), + pred( + "memberOf: never generates its own domain-error, but still carries one through from the operand", + { + kind: "memberOf", + op: "in", + operand: divideByZero, + candidates: [numberLiteral(1)], + }, + isDomainError, + ), + + // exists -- never indeterminate, by design + pred( + "exists: never not-found -- converts operand not-found to definitely false", + { kind: "exists", operand: missingRef }, + isDefinite, + ), + pred( + "exists: never wrong-type -- converts operand wrong-type to definitely true", + { kind: "exists", operand: wrongTypeArithmetic }, + isDefinite, + ), + pred( + "exists: never domain-error -- converts operand domain-error to definitely true", + { kind: "exists", operand: divideByZero }, + isDefinite, + ), + + // some / every + pred( + "some: not-found from a participating item's item sub-node, not absorbed by any other item", + { + kind: "some", + collection: "single-notfound", + item: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "value" }, + right: numberLiteral(1), + }, + }, + isNotFound, + ), + pred( + "every: wrong-type from a participating item's item sub-node, not absorbed by any other item", + { + kind: "every", + collection: "single-wrongtype", + item: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "value" }, + right: numberLiteral(1), + }, + }, + isWrongType, + ), + pred( + "some: domain-error from a participating item's item sub-node, not absorbed by any other item", + { + kind: "some", + collection: "single-domainerror", + item: { + kind: "compare", + op: "eq", + left: divideByZero, + right: numberLiteral(1), + }, + }, + isDomainError, + ), + + // literals -- never indeterminate, by construction + expr("numberLiteral: never indeterminate", numberLiteral(1), isDefinite), + expr( + "textLiteral: never indeterminate", + { kind: "textLiteral", value: "x" }, + isDefinite, + ), + expr( + "instantLiteral: never indeterminate", + { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + isDefinite, + ), + expr( + "durationLiteral: never indeterminate", + { kind: "durationLiteral", value: 1, unit: "min" }, + isDefinite, + ), + expr( + "complexLiteral: never indeterminate", + { kind: "complexLiteral", re: 1, im: -1 }, + isDefinite, + ), + + // reference + expr( + "reference: not-found when the resolver reports absence", + missingRef, + isNotFound, + ), + expr( + "reference: wrong-type when the expected unit does not match the resolved value's unit", + { kind: "reference", key: "present", unit: { s: 1 } }, + isWrongType, + ), + + // arithmetic + expr( + "arithmetic: not-found propagates from either operand", + { + kind: "arithmetic", + op: "add", + left: missingRef, + right: numberLiteral(1), + }, + isNotFound, + ), + expr( + "arithmetic: wrong-type when an operand is non-numeric", + wrongTypeArithmetic, + isWrongType, + ), + expr( + "arithmetic: domain-error on division by zero", + divideByZero, + isDomainError, + ), + + // negate + expr( + "negate: not-found propagates from the operand", + { kind: "negate", operand: missingRef }, + isNotFound, + ), + expr( + "negate: wrong-type when the operand is not number/duration", + { + kind: "negate", + operand: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, + }, + isWrongType, + ), + expr( + "negate: never generates its own domain-error, but still carries one through from the operand", + { kind: "negate", operand: divideByZero }, + isDomainError, + ), + + // call + expr( + "call: not-found propagates from any argument, before the function is ever invoked", + { kind: "call", fn: "identity", args: [missingRef] }, + isNotFound, + { + functions: { + identity: () => { + throw new Error( + "unreachable: an indeterminate argument must short-circuit before the function is invoked", + ); + }, + }, + }, + ), + expr( + "call: wrong-type for an unregistered function name", + { kind: "call", fn: "doesNotExist", args: [] }, + isWrongType, + ), + expr( + "call: domain-error when a registered function's argument is out of its valid domain", + { kind: "call", fn: "reciprocal", args: [numberLiteral(0)] }, + isDomainError, + { + functions: { + reciprocal: (args) => { + const [arg] = args; + if (arg?.kind !== "number") { + return { + domainError: "reciprocal requires a single number argument", + }; + } + if (arg.value === 0) + return { domainError: "reciprocal of zero is undefined" }; + return { kind: "number", value: 1 / arg.value }; + }, + }, + }, + ), + + // lookup + expr( + "lookup: not-found when a key expression is itself not-found", + { kind: "lookup", table: "table1", keys: [missingRef] }, + isNotFound, + ), + expr( + "lookup: not-found when the resolver reports no match", + { kind: "lookup", table: "unknown-table", keys: [numberLiteral(1)] }, + isNotFound, + ), + expr( + "lookup: wrong-type propagates from a key expression's own wrong-type evaluation", + { + kind: "lookup", + table: "table1", + keys: [{ kind: "reference", key: "present", unit: { s: 1 } }], + }, + isWrongType, + ), + + // conditional + expr( + "conditional: not-found from an unmatched guard evaluated before any earlier case matched", + { + kind: "conditional", + cases: [ + { when: falseGuard, then: numberLiteral(1) }, + { when: missingGuard, then: numberLiteral(2) }, + ], + fallback: numberLiteral(0), + }, + isNotFound, + ), + expr( + "conditional: wrong-type from the chosen branch's own result", + { + kind: "conditional", + cases: [{ when: trueGuard, then: wrongTypeArithmetic }], + fallback: numberLiteral(0), + }, + isWrongType, + ), + expr( + "conditional: domain-error from the chosen branch's own result", + { + kind: "conditional", + cases: [{ when: trueGuard, then: divideByZero }], + fallback: numberLiteral(0), + }, + isDomainError, + ), + + // fold + expr( + "fold (reduce): not-found from a participating item's combine step", + { + kind: "fold", + collection: "single-notfound", + combiner: { + mode: "reduce", + initial: numberLiteral(0), + combine: { + kind: "arithmetic", + op: "add", + left: { kind: "accumulator" }, + right: { kind: "reference", key: "value" }, + }, + }, + }, + isNotFound, + ), + expr( + "fold (reduce): wrong-type from the initial expression", + { + kind: "fold", + collection: "empty", + combiner: { + mode: "reduce", + initial: wrongTypeArithmetic, + combine: numberLiteral(0), + }, + }, + isWrongType, + ), + expr( + "fold (reduce): domain-error from a participating item's combine step", + { + kind: "fold", + collection: "single-domainerror", + combiner: { + mode: "reduce", + initial: numberLiteral(0), + combine: { + kind: "arithmetic", + op: "add", + left: { kind: "accumulator" }, + right: divideByZero, + }, + }, + }, + isDomainError, + ), + expr( + "fold: an indeterminate filter makes the whole fold indeterminate outright -- no absorption, unlike some/every", + { + kind: "fold", + collection: "single-notfound", + filter: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "value" }, + right: numberLiteral(1), + }, + combiner: { + mode: "reduce", + initial: numberLiteral(0), + combine: numberLiteral(1), + }, + }, + isNotFound, + ), + expr( + "fold (max): domain-error over an empty (post-filter) collection -- no first item to seed from", + { + kind: "fold", + collection: "empty", + combiner: { mode: "max", item: numberLiteral(0) }, + }, + isDomainError, + ), + expr( + "fold (min): domain-error over an empty (post-filter) collection -- no first item to seed from", + { + kind: "fold", + collection: "empty", + combiner: { mode: "min", item: numberLiteral(0) }, + }, + isDomainError, + ), + expr( + "fold (max): not-found from a participating item's own item expression", + { + kind: "fold", + collection: "single-notfound", + combiner: { mode: "max", item: { kind: "reference", key: "value" } }, + }, + isNotFound, + ), + + // accumulator + expr( + "accumulator: wrong-type when used outside any fold's combine expression", + { kind: "accumulator" }, + isWrongType, + ), + expr( + "accumulator: definite inside a reduce fold's own combine expression", + { + kind: "fold", + collection: "one-item", + combiner: { + mode: "reduce", + initial: numberLiteral(reduceInitialSeed), + combine: { kind: "accumulator" }, + }, + }, + isDefinite, + ), + expr( + "accumulator: wrong-type inside a fold's filter (reset to undefined, even though a fold's own combine has one in scope)", + { + kind: "fold", + collection: "one-item", + filter: { + kind: "compare", + op: "eq", + left: { kind: "accumulator" }, + right: numberLiteral(0), + }, + combiner: { + mode: "reduce", + initial: numberLiteral(0), + combine: numberLiteral(1), + }, + }, + isWrongType, + ), + expr( + "accumulator: wrong-type inside a max/min combiner's item (reset to undefined)", + { + kind: "fold", + collection: "one-item", + combiner: { mode: "max", item: { kind: "accumulator" } }, + }, + isWrongType, + ), + expr( + "accumulator: wrong-type inside a reduce combiner's own initial, even nested inside an outer fold's combine where an accumulator IS in scope", + { + kind: "fold", + collection: "one-item", + combiner: { + mode: "reduce", + initial: numberLiteral(0), + combine: { + kind: "fold", + collection: "empty", + combiner: { + mode: "reduce", + initial: { kind: "accumulator" }, + combine: numberLiteral(0), + }, + }, + }, + }, + isWrongType, + ), + + // delegate + expr( + "delegate: wrong-type when no handler is registered for the named system", + { + kind: "delegate", + system: "external-pricing-engine", + payload: { foo: "bar" }, + }, + isWrongType, + ), + expr( + "delegate: never domain-error, and not-found only when the handler itself reports absence", + { kind: "delegate", system: "known", payload: null }, + isNotFound, + { + resolvers: { + ...baseResolvers, + resolveDelegate: async () => Promise.resolve({ found: false }), + }, + }, + ), + expr( + "delegate: definite when the registered handler resolves a value", + { kind: "delegate", system: "known", payload: null }, + isDefinite, + { + resolvers: { + ...baseResolvers, + resolveDelegate: async () => + Promise.resolve({ + found: true, + value: { kind: "number", value: 1 }, + }), + }, + }, + ), +]; +describe("indeterminacy reference table", () => { + it.each(fixtures)("$description", runFixture); +}); diff --git a/packages/trilean/src/evaluator.indeterminacy.test.ts b/packages/trilean/src/evaluator.indeterminacy.test.ts deleted file mode 100644 index 7de28dd..0000000 --- a/packages/trilean/src/evaluator.indeterminacy.test.ts +++ /dev/null @@ -1,1220 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createEvaluator, evaluatePredicate, evaluateValue } from "./evaluator"; -import type { IndeterminateReason } from "./evaluation"; -import type { FunctionRegistry } from "./functions"; -import type { EvaluationContext, Resolvers } from "./resolvers"; -import type { ExpressionNode, PredicateNode } from "./tree"; - -/** - * Systematic, table-driven coverage of README.md's "Indeterminacy reference" section: one fixture per node-kind x reason-code combination that section documents, for every `PredicateNode`/`ExpressionNode` kind implemented across all six phases so far. This complements, rather than replaces, the ad hoc behavioural tests already in evaluator.test.ts/truth-tables.test.ts -- this file exists specifically to walk the reference table itself, row by row. - */ - -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isUnknownArray(value: unknown): value is unknown[] { - return Array.isArray(value); -} - -/** A single resolver pool shared by every fixture below, covering every named key/table/collection any fixture needs. Fixtures that need genuinely different resolver behaviour (the delegate/call rows) supply their own. */ -const baseResolvers: Resolvers = { - resolveValue: async (key, context) => { - if (key === "present") { - return Promise.resolve({ - found: true, - value: { kind: "number", value: 10, unit: { m: 1 } }, - }); - } - if (key === "presentText") { - return Promise.resolve({ - found: true, - value: { kind: "text", value: "hello" }, - }); - } - if (typeof key === "string" && isPlainRecord(context) && key in context) { - const value = context[key]; - if (typeof value === "number") { - return Promise.resolve({ - found: true, - value: { kind: "number", value }, - }); - } - if (typeof value === "string") { - return Promise.resolve({ - found: true, - value: { kind: "text", value }, - }); - } - } - return Promise.resolve({ found: false }); - }, - resolveLookup: async (table) => { - if (table === "table1") { - return Promise.resolve({ - found: true, - value: { kind: "number", value: 100 }, - }); - } - return Promise.resolve({ found: false }); - }, - resolveCollection: async (collection, context) => { - if (collection === "single-notfound") return Promise.resolve([{}]); - if (collection === "single-wrongtype") { - return Promise.resolve([{ value: "x" }]); - } - if (collection === "single-domainerror") return Promise.resolve([{}]); - if (collection === "empty") return Promise.resolve([]); - if (collection === "one-item") return Promise.resolve([{}]); - if (collection === "nested-outer") { - return Promise.resolve([ - { values: [{ amount: 1 }, { amount: 2 }] }, - { values: [{ amount: 10 }] }, - ]); - } - if (collection === "values" && isPlainRecord(context)) { - const values = context.values; - return Promise.resolve(isUnknownArray(values) ? values : []); - } - return Promise.resolve([]); - }, -}; - -// --- Shared node fragments --- - -const numberLiteral = (value: number): ExpressionNode => ({ - kind: "numberLiteral", - value, -}); -const missingRef: ExpressionNode = { kind: "reference", key: "missing" }; -const divideByZero: ExpressionNode = { - kind: "arithmetic", - op: "divide", - left: numberLiteral(1), - right: numberLiteral(0), -}; -const wrongTypeArithmetic: ExpressionNode = { - kind: "arithmetic", - op: "add", - left: { kind: "textLiteral", value: "x" }, - right: numberLiteral(1), -}; -const trueGuard: PredicateNode = { - kind: "compare", - op: "eq", - left: numberLiteral(1), - right: numberLiteral(1), -}; -const falseGuard: PredicateNode = { - kind: "compare", - op: "eq", - left: numberLiteral(1), - right: numberLiteral(2), -}; -const missingGuard: PredicateNode = { - kind: "compare", - op: "eq", - left: missingRef, - right: numberLiteral(1), -}; - -/** Arbitrary non-{-1,0,1,2} magnitudes used below, named individually so each numeric literal only appears once (`@typescript-eslint/no-magic-numbers` only exempts -1/0/1/2 and object-literal property values, not values passed as plain call arguments). */ -const memberOfProbeValue = 5; -const reduceInitialSeed = 5; -const laterConditionalCaseValue = 3; -const untouchedReduceInitial = 42; -const uniqueHitPolicyThirdCaseValue = 3; - -// --- Fixture machinery --- - -type Expected = - | { readonly status: "definite" } - | { - readonly status: "indeterminate"; - readonly code: IndeterminateReason["code"]; - }; - -const isDefinite: Expected = { status: "definite" }; -const isNotFound: Expected = { status: "indeterminate", code: "not-found" }; -const isWrongType: Expected = { status: "indeterminate", code: "wrong-type" }; -const isDomainError: Expected = { - status: "indeterminate", - code: "domain-error", -}; - -interface Fixture { - readonly description: string; - readonly expected: Expected; - readonly run: () => Promise<{ status: string }>; -} - -interface FixtureOverrides { - readonly context?: EvaluationContext; - readonly resolvers?: Resolvers; - readonly functions?: FunctionRegistry; -} - -function pred( - description: string, - node: PredicateNode, - expected: Expected, - overrides: FixtureOverrides = {}, -): Fixture { - const { context, resolvers = baseResolvers, functions } = overrides; - const { evaluatePredicate: evaluate } = functions - ? createEvaluator({ functions }) - : { evaluatePredicate }; - return { - description, - expected, - run: async () => evaluate(node, context, resolvers), - }; -} - -function expr( - description: string, - node: ExpressionNode, - expected: Expected, - overrides: FixtureOverrides = {}, -): Fixture { - const { context, resolvers = baseResolvers, functions } = overrides; - const { evaluateValue: evaluate } = functions - ? createEvaluator({ functions }) - : { evaluateValue }; - return { - description, - expected, - run: async () => evaluate(node, context, resolvers), - }; -} - -async function runFixture({ run, expected }: Fixture): Promise { - const result = await run(); - expect(result.status).toBe(expected.status); - if (expected.status === "indeterminate" && "reason" in result) { - expect((result as { reason: IndeterminateReason }).reason.code).toBe( - expected.code, - ); - } -} - -// --- The reference table itself, one row per node-kind x reason-code combination --- - -const fixtures: readonly Fixture[] = [ - // not - pred( - "not: not-found propagates from operand", - { - kind: "not", - operand: { - kind: "compare", - op: "eq", - left: missingRef, - right: numberLiteral(1), - }, - }, - isNotFound, - ), - pred( - "not: wrong-type propagates from operand", - { - kind: "not", - operand: { - kind: "compare", - op: "eq", - left: numberLiteral(1), - right: { kind: "textLiteral", value: "1" }, - }, - }, - isWrongType, - ), - pred( - "not: domain-error propagates from operand", - { - kind: "not", - operand: { - kind: "compare", - op: "eq", - left: divideByZero, - right: numberLiteral(1), - }, - }, - isDomainError, - ), - - // and - pred( - "and: not-found propagates when the other operand is not definitely false", - { kind: "and", left: trueGuard, right: missingGuard }, - isNotFound, - ), - pred( - "and: false absorbs an indeterminate other operand regardless of its reason", - { - kind: "and", - left: falseGuard, - right: { - kind: "compare", - op: "eq", - left: divideByZero, - right: numberLiteral(1), - }, - }, - isDefinite, - ), - - // or - pred( - "or: not-found propagates when the other operand is not definitely true", - { kind: "or", left: falseGuard, right: missingGuard }, - isNotFound, - ), - pred( - "or: true absorbs an indeterminate other operand regardless of its reason", - { - kind: "or", - left: trueGuard, - right: { - kind: "compare", - op: "eq", - left: divideByZero, - right: numberLiteral(1), - }, - }, - isDefinite, - ), - - // allOf / anyOf - pred( - "allOf: an indeterminate operand propagates when nothing in the list is definitely false", - { kind: "allOf", operands: [trueGuard, missingGuard, trueGuard] }, - isNotFound, - ), - pred( - "allOf: a definitely-false operand absorbs an indeterminate sibling", - { kind: "allOf", operands: [falseGuard, missingGuard] }, - isDefinite, - ), - pred( - "anyOf: an indeterminate operand propagates when nothing in the list is definitely true", - { kind: "anyOf", operands: [falseGuard, missingGuard, falseGuard] }, - isNotFound, - ), - pred( - "anyOf: a definitely-true operand absorbs an indeterminate sibling", - { kind: "anyOf", operands: [trueGuard, missingGuard] }, - isDefinite, - ), - - // compare - pred( - "compare: not-found propagates from either operand", - { kind: "compare", op: "eq", left: missingRef, right: numberLiteral(1) }, - isNotFound, - ), - pred( - "compare: wrong-type when the operand kinds differ", - { - kind: "compare", - op: "eq", - left: numberLiteral(1), - right: { kind: "textLiteral", value: "1" }, - }, - isWrongType, - ), - pred( - "compare: wrong-type when both operands are a kind compare does not support (text)", - { - kind: "compare", - op: "eq", - left: { kind: "textLiteral", value: "a" }, - right: { kind: "textLiteral", value: "b" }, - }, - isWrongType, - ), - pred( - "compare: wrong-type when two numbers have incompatible units", - { - kind: "compare", - op: "eq", - left: { kind: "numberLiteral", value: 1, unit: { m: 1 } }, - right: { kind: "numberLiteral", value: 1, unit: { s: 1 } }, - }, - isWrongType, - ), - pred( - "compare: never generates its own domain-error, but still carries one through from an operand", - { kind: "compare", op: "eq", left: divideByZero, right: numberLiteral(1) }, - isDomainError, - ), - - // textCompare - pred( - "textCompare: not-found propagates from either operand", - { - kind: "textCompare", - op: "equals", - left: missingRef, - right: { kind: "textLiteral", value: "x" }, - }, - isNotFound, - ), - pred( - "textCompare: wrong-type when an operand is not text", - { - kind: "textCompare", - op: "equals", - left: numberLiteral(1), - right: { kind: "textLiteral", value: "1" }, - }, - isWrongType, - ), - pred( - "textCompare: never generates its own domain-error, but still carries one through from an operand", - { - kind: "textCompare", - op: "equals", - left: divideByZero, - right: { kind: "textLiteral", value: "x" }, - }, - isDomainError, - ), - - // memberOf - pred( - "memberOf: not-found when operand is not found", - { - kind: "memberOf", - op: "in", - operand: missingRef, - candidates: [numberLiteral(1)], - }, - isNotFound, - ), - pred( - "memberOf: wrong-type when operand/candidate kinds are incompatible and nothing else matches", - { - kind: "memberOf", - op: "in", - operand: numberLiteral(memberOfProbeValue), - candidates: [{ kind: "textLiteral", value: "5" }], - }, - isWrongType, - ), - pred( - "memberOf: never generates its own domain-error, but still carries one through from the operand", - { - kind: "memberOf", - op: "in", - operand: divideByZero, - candidates: [numberLiteral(1)], - }, - isDomainError, - ), - - // exists -- never indeterminate, by design - pred( - "exists: never not-found -- converts operand not-found to definitely false", - { kind: "exists", operand: missingRef }, - isDefinite, - ), - pred( - "exists: never wrong-type -- converts operand wrong-type to definitely true", - { kind: "exists", operand: wrongTypeArithmetic }, - isDefinite, - ), - pred( - "exists: never domain-error -- converts operand domain-error to definitely true", - { kind: "exists", operand: divideByZero }, - isDefinite, - ), - - // some / every - pred( - "some: not-found from a participating item's item sub-node, not absorbed by any other item", - { - kind: "some", - collection: "single-notfound", - item: { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "value" }, - right: numberLiteral(1), - }, - }, - isNotFound, - ), - pred( - "every: wrong-type from a participating item's item sub-node, not absorbed by any other item", - { - kind: "every", - collection: "single-wrongtype", - item: { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "value" }, - right: numberLiteral(1), - }, - }, - isWrongType, - ), - pred( - "some: domain-error from a participating item's item sub-node, not absorbed by any other item", - { - kind: "some", - collection: "single-domainerror", - item: { - kind: "compare", - op: "eq", - left: divideByZero, - right: numberLiteral(1), - }, - }, - isDomainError, - ), - - // literals -- never indeterminate, by construction - expr("numberLiteral: never indeterminate", numberLiteral(1), isDefinite), - expr( - "textLiteral: never indeterminate", - { kind: "textLiteral", value: "x" }, - isDefinite, - ), - expr( - "instantLiteral: never indeterminate", - { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - isDefinite, - ), - expr( - "durationLiteral: never indeterminate", - { kind: "durationLiteral", value: 1, unit: "min" }, - isDefinite, - ), - expr( - "complexLiteral: never indeterminate", - { kind: "complexLiteral", re: 1, im: -1 }, - isDefinite, - ), - - // reference - expr( - "reference: not-found when the resolver reports absence", - missingRef, - isNotFound, - ), - expr( - "reference: wrong-type when the expected unit does not match the resolved value's unit", - { kind: "reference", key: "present", unit: { s: 1 } }, - isWrongType, - ), - - // arithmetic - expr( - "arithmetic: not-found propagates from either operand", - { - kind: "arithmetic", - op: "add", - left: missingRef, - right: numberLiteral(1), - }, - isNotFound, - ), - expr( - "arithmetic: wrong-type when an operand is non-numeric", - wrongTypeArithmetic, - isWrongType, - ), - expr( - "arithmetic: domain-error on division by zero", - divideByZero, - isDomainError, - ), - - // negate - expr( - "negate: not-found propagates from the operand", - { kind: "negate", operand: missingRef }, - isNotFound, - ), - expr( - "negate: wrong-type when the operand is not number/duration", - { - kind: "negate", - operand: { kind: "instantLiteral", value: "2026-01-01T00:00:00.000Z" }, - }, - isWrongType, - ), - expr( - "negate: never generates its own domain-error, but still carries one through from the operand", - { kind: "negate", operand: divideByZero }, - isDomainError, - ), - - // call - expr( - "call: not-found propagates from any argument, before the function is ever invoked", - { kind: "call", fn: "identity", args: [missingRef] }, - isNotFound, - { - functions: { - identity: () => { - throw new Error( - "unreachable: an indeterminate argument must short-circuit before the function is invoked", - ); - }, - }, - }, - ), - expr( - "call: wrong-type for an unregistered function name", - { kind: "call", fn: "doesNotExist", args: [] }, - isWrongType, - ), - expr( - "call: domain-error when a registered function's argument is out of its valid domain", - { kind: "call", fn: "reciprocal", args: [numberLiteral(0)] }, - isDomainError, - { - functions: { - reciprocal: (args) => { - const [arg] = args; - if (arg?.kind !== "number") { - return { - domainError: "reciprocal requires a single number argument", - }; - } - if (arg.value === 0) - return { domainError: "reciprocal of zero is undefined" }; - return { kind: "number", value: 1 / arg.value }; - }, - }, - }, - ), - - // lookup - expr( - "lookup: not-found when a key expression is itself not-found", - { kind: "lookup", table: "table1", keys: [missingRef] }, - isNotFound, - ), - expr( - "lookup: not-found when the resolver reports no match", - { kind: "lookup", table: "unknown-table", keys: [numberLiteral(1)] }, - isNotFound, - ), - expr( - "lookup: wrong-type propagates from a key expression's own wrong-type evaluation", - { - kind: "lookup", - table: "table1", - keys: [{ kind: "reference", key: "present", unit: { s: 1 } }], - }, - isWrongType, - ), - - // conditional - expr( - "conditional: not-found from an unmatched guard evaluated before any earlier case matched", - { - kind: "conditional", - cases: [ - { when: falseGuard, then: numberLiteral(1) }, - { when: missingGuard, then: numberLiteral(2) }, - ], - fallback: numberLiteral(0), - }, - isNotFound, - ), - expr( - "conditional: wrong-type from the chosen branch's own result", - { - kind: "conditional", - cases: [{ when: trueGuard, then: wrongTypeArithmetic }], - fallback: numberLiteral(0), - }, - isWrongType, - ), - expr( - "conditional: domain-error from the chosen branch's own result", - { - kind: "conditional", - cases: [{ when: trueGuard, then: divideByZero }], - fallback: numberLiteral(0), - }, - isDomainError, - ), - - // fold - expr( - "fold (reduce): not-found from a participating item's combine step", - { - kind: "fold", - collection: "single-notfound", - combiner: { - mode: "reduce", - initial: numberLiteral(0), - combine: { - kind: "arithmetic", - op: "add", - left: { kind: "accumulator" }, - right: { kind: "reference", key: "value" }, - }, - }, - }, - isNotFound, - ), - expr( - "fold (reduce): wrong-type from the initial expression", - { - kind: "fold", - collection: "empty", - combiner: { - mode: "reduce", - initial: wrongTypeArithmetic, - combine: numberLiteral(0), - }, - }, - isWrongType, - ), - expr( - "fold (reduce): domain-error from a participating item's combine step", - { - kind: "fold", - collection: "single-domainerror", - combiner: { - mode: "reduce", - initial: numberLiteral(0), - combine: { - kind: "arithmetic", - op: "add", - left: { kind: "accumulator" }, - right: divideByZero, - }, - }, - }, - isDomainError, - ), - expr( - "fold: an indeterminate filter makes the whole fold indeterminate outright -- no absorption, unlike some/every", - { - kind: "fold", - collection: "single-notfound", - filter: { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "value" }, - right: numberLiteral(1), - }, - combiner: { - mode: "reduce", - initial: numberLiteral(0), - combine: numberLiteral(1), - }, - }, - isNotFound, - ), - expr( - "fold (max): domain-error over an empty (post-filter) collection -- no first item to seed from", - { - kind: "fold", - collection: "empty", - combiner: { mode: "max", item: numberLiteral(0) }, - }, - isDomainError, - ), - expr( - "fold (min): domain-error over an empty (post-filter) collection -- no first item to seed from", - { - kind: "fold", - collection: "empty", - combiner: { mode: "min", item: numberLiteral(0) }, - }, - isDomainError, - ), - expr( - "fold (max): not-found from a participating item's own item expression", - { - kind: "fold", - collection: "single-notfound", - combiner: { mode: "max", item: { kind: "reference", key: "value" } }, - }, - isNotFound, - ), - - // accumulator - expr( - "accumulator: wrong-type when used outside any fold's combine expression", - { kind: "accumulator" }, - isWrongType, - ), - expr( - "accumulator: definite inside a reduce fold's own combine expression", - { - kind: "fold", - collection: "one-item", - combiner: { - mode: "reduce", - initial: numberLiteral(reduceInitialSeed), - combine: { kind: "accumulator" }, - }, - }, - isDefinite, - ), - expr( - "accumulator: wrong-type inside a fold's filter (reset to undefined, even though a fold's own combine has one in scope)", - { - kind: "fold", - collection: "one-item", - filter: { - kind: "compare", - op: "eq", - left: { kind: "accumulator" }, - right: numberLiteral(0), - }, - combiner: { - mode: "reduce", - initial: numberLiteral(0), - combine: numberLiteral(1), - }, - }, - isWrongType, - ), - expr( - "accumulator: wrong-type inside a max/min combiner's item (reset to undefined)", - { - kind: "fold", - collection: "one-item", - combiner: { mode: "max", item: { kind: "accumulator" } }, - }, - isWrongType, - ), - expr( - "accumulator: wrong-type inside a reduce combiner's own initial, even nested inside an outer fold's combine where an accumulator IS in scope", - { - kind: "fold", - collection: "one-item", - combiner: { - mode: "reduce", - initial: numberLiteral(0), - combine: { - kind: "fold", - collection: "empty", - combiner: { - mode: "reduce", - initial: { kind: "accumulator" }, - combine: numberLiteral(0), - }, - }, - }, - }, - isWrongType, - ), - - // delegate - expr( - "delegate: wrong-type when no handler is registered for the named system", - { - kind: "delegate", - system: "external-pricing-engine", - payload: { foo: "bar" }, - }, - isWrongType, - ), - expr( - "delegate: never domain-error, and not-found only when the handler itself reports absence", - { kind: "delegate", system: "known", payload: null }, - isNotFound, - { - resolvers: { - ...baseResolvers, - resolveDelegate: async () => Promise.resolve({ found: false }), - }, - }, - ), - expr( - "delegate: definite when the registered handler resolves a value", - { kind: "delegate", system: "known", payload: null }, - isDefinite, - { - resolvers: { - ...baseResolvers, - resolveDelegate: async () => - Promise.resolve({ - found: true, - value: { kind: "number", value: 1 }, - }), - }, - }, - ), -]; - -describe("indeterminacy reference table", () => { - it.each(fixtures)("$description", runFixture); -}); - -// --- Explicitly called-out cases beyond the row-per-cell table above --- - -describe("lookup: any-indeterminate-key short-circuit", () => { - it("never calls resolveLookup when a key is indeterminate", async () => { - let resolveLookupCalls = 0; - const trackingResolvers: Resolvers = { - ...baseResolvers, - resolveLookup: async (table, keys, context) => { - resolveLookupCalls += 1; - return baseResolvers.resolveLookup(table, keys, context); - }, - }; - const result = await evaluateValue( - { kind: "lookup", table: "table1", keys: [missingRef, numberLiteral(1)] }, - undefined, - trackingResolvers, - ); - expect(result.status).toBe("indeterminate"); - expect(resolveLookupCalls).toBe(0); - }); -}); - -describe("conditional: must not skip an unresolved guard", () => { - it("never evaluates a later case once an earlier unmatched guard is indeterminate", async () => { - let laterCaseEvaluations = 0; - const trackingResolvers: Resolvers = { - ...baseResolvers, - resolveValue: async (key, context) => { - if (key === "later-case-marker") laterCaseEvaluations += 1; - return baseResolvers.resolveValue(key, context); - }, - }; - const result = await evaluateValue( - { - kind: "conditional", - cases: [ - { when: falseGuard, then: numberLiteral(1) }, - { when: missingGuard, then: numberLiteral(2) }, - { - when: { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "later-case-marker" }, - right: numberLiteral(1), - }, - then: numberLiteral(laterConditionalCaseValue), - }, - ], - fallback: numberLiteral(0), - }, - undefined, - trackingResolvers, - ); - expect(result.status).toBe("indeterminate"); - expect(laterCaseEvaluations).toBe(0); - }); -}); - -describe("conditional: 'unique' hit policy", () => { - it("2+ definite matches absorbs -- domain-error, even with a third case that is itself indeterminate", async () => { - const result = await evaluateValue( - { - kind: "conditional", - hitPolicy: "unique", - cases: [ - { when: trueGuard, then: numberLiteral(1) }, - { when: trueGuard, then: numberLiteral(2) }, - { - when: missingGuard, - then: numberLiteral(uniqueHitPolicyThirdCaseValue), - }, - ], - fallback: numberLiteral(0), - }, - undefined, - baseResolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("domain-error"); - } - }); - - it("exactly one definite match plus one indeterminate case does NOT absorb -- the whole node is indeterminate, not the match's then", async () => { - const result = await evaluateValue( - { - kind: "conditional", - hitPolicy: "unique", - cases: [ - { when: trueGuard, then: numberLiteral(1) }, - { when: missingGuard, then: numberLiteral(2) }, - ], - fallback: numberLiteral(0), - }, - undefined, - baseResolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("not-found"); - } - }); - - it("a when that is itself wrong-type propagates that reason, not domain-error", async () => { - const wrongTypeGuard: PredicateNode = { - kind: "compare", - op: "eq", - left: { kind: "textLiteral", value: "x" }, - right: numberLiteral(1), - }; - const result = await evaluateValue( - { - kind: "conditional", - hitPolicy: "unique", - cases: [{ when: wrongTypeGuard, then: numberLiteral(1) }], - fallback: numberLiteral(0), - }, - undefined, - baseResolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("wrong-type"); - } - }); -}); - -describe("fold: reduce vs max/min over an empty collection", () => { - it("reduce evaluates to initial directly, without ever evaluating combine", async () => { - let combineEvaluations = 0; - const trackingResolvers: Resolvers = { - ...baseResolvers, - resolveValue: async (key, context) => { - if (key === "combine-marker") combineEvaluations += 1; - return baseResolvers.resolveValue(key, context); - }, - }; - const result = await evaluateValue( - { - kind: "fold", - collection: "empty", - combiner: { - mode: "reduce", - initial: numberLiteral(untouchedReduceInitial), - combine: { - kind: "arithmetic", - op: "add", - left: { kind: "accumulator" }, - right: { kind: "reference", key: "combine-marker" }, - }, - }, - }, - undefined, - trackingResolvers, - ); - expect(result).toEqual({ - status: "definite", - value: { kind: "number", value: 42 }, - }); - expect(combineEvaluations).toBe(0); - }); - - it("max/min is domain-error, not initial-untouched -- it has no seed value at all", async () => { - const result = await evaluateValue( - { - kind: "fold", - collection: "empty", - combiner: { mode: "max", item: numberLiteral(0) }, - }, - undefined, - baseResolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") - expect(result.reason.code).toBe("domain-error"); - }); -}); - -describe("accumulator: nested-fold shadowing", () => { - it("an inner fold's own combine expression refers to the inner running value, not the outer's", async () => { - // Outer fold sums a per-item nested "values" collection via an inner fold, seeded fresh at 0 on every outer item -- if the inner accumulator wrongly resolved to the outer's *current* running total instead of its own, the second outer item (processed once the outer total is already 3) would double-count it. - const result = await evaluateValue( - { - kind: "fold", - collection: "nested-outer", - combiner: { - mode: "reduce", - initial: numberLiteral(0), - combine: { - kind: "arithmetic", - op: "add", - left: { kind: "accumulator" }, - right: { - kind: "fold", - collection: "values", - combiner: { - mode: "reduce", - initial: numberLiteral(0), - combine: { - kind: "arithmetic", - op: "add", - left: { kind: "accumulator" }, - right: { kind: "reference", key: "amount" }, - }, - }, - }, - }, - }, - }, - undefined, - baseResolvers, - ); - expect(result).toEqual({ - status: "definite", - value: { kind: "number", value: 13 }, - }); - }); -}); - -describe("delegate: missing-handler message", () => { - it("includes the external system's own name", async () => { - const result = await evaluateValue( - { kind: "delegate", system: "external-pricing-engine", payload: null }, - undefined, - baseResolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("wrong-type"); - expect(result.reason.message).toContain("external-pricing-engine"); - } - }); -}); - -describe("treeReference: reason-code coverage", () => { - it("wrong-type: no resolveTree resolver registered", async () => { - const result = await evaluatePredicate( - { kind: "treeReference", key: "any" }, - undefined, - baseResolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("wrong-type"); - } - }); - - it("not-found: the registered resolver reports absence", async () => { - const treeResolvers: Resolvers = { - ...baseResolvers, - resolveTree: async () => Promise.resolve({ found: false }), - }; - const result = await evaluatePredicate( - { kind: "treeReference", key: "any" }, - undefined, - treeResolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("not-found"); - } - }); - - it("wrong-type: the resolved value fails to parse against the expected schema", async () => { - const treeResolvers: Resolvers = { - ...baseResolvers, - resolveTree: async () => - Promise.resolve({ found: true, node: { kind: "nonsense" } }), - }; - const result = await evaluatePredicate( - { kind: "treeReference", key: "any" }, - undefined, - treeResolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("wrong-type"); - } - }); - - it("domain-error: a key already on the current reference chain (cycle)", async () => { - const treeResolvers: Resolvers = { - ...baseResolvers, - resolveTree: async () => - Promise.resolve({ - found: true, - node: { kind: "treeReference", key: "self" }, - }), - }; - const result = await evaluatePredicate( - { kind: "treeReference", key: "self" }, - undefined, - treeResolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("domain-error"); - expect(result.reason.message).toBe("circular treeReference detected"); - } - }); - - it("domain-error: the chain already reached the maximum depth", async () => { - const treeResolvers: Resolvers = { - ...baseResolvers, - resolveTree: async (key) => - Promise.resolve({ - found: true, - node: { - kind: "treeReference", - key: `${typeof key === "string" ? key : "chain"}-next`, - }, - }), - }; - const result = await evaluatePredicate( - { kind: "treeReference", key: "chain-0" }, - undefined, - treeResolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("domain-error"); - expect(result.reason.message).toContain("maximum depth"); - } - }); -}); - -describe("call: unregistered-name vs out-of-domain-argument distinction", () => { - it("an unregistered function name is wrong-type", async () => { - const result = await evaluateValue( - { kind: "call", fn: "doesNotExist", args: [] }, - undefined, - baseResolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") { - expect(result.reason.code).toBe("wrong-type"); - expect(result.reason.message).toContain("doesNotExist"); - } - }); - - it("a registered function's out-of-domain argument is domain-error, not wrong-type", async () => { - const { evaluateValue: evaluate } = createEvaluator({ - functions: { - reciprocal: (args) => { - const [arg] = args; - if (arg?.kind !== "number") { - return { - domainError: "reciprocal requires a single number argument", - }; - } - if (arg.value === 0) - return { domainError: "reciprocal of zero is undefined" }; - return { kind: "number", value: 1 / arg.value }; - }, - }, - }); - const result = await evaluate( - { kind: "call", fn: "reciprocal", args: [numberLiteral(0)] }, - undefined, - baseResolvers, - ); - expect(result.status).toBe("indeterminate"); - if (result.status === "indeterminate") - expect(result.reason.code).toBe("domain-error"); - }); -}); From dc3c36f524141c940d18e9f7e3e534e2e1cb38e3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:22:40 +0100 Subject: [PATCH 8/9] refactor(evaluator): split arithmetic, budget, collection, and factory logic into dedicated modules evaluator.ts had grown to 1564 lines of real code, over the new 800-line cap. Splits it into four cohesive pieces along its own existing seams: evaluator-operations.ts holds the pure, non-recursive three-valued combination/comparison/arithmetic helpers (combineAnd, compareValues, applyArithmetic and their siblings); evaluator-budget.ts holds the per-call resource-limit infrastructure (EvaluationBudget and its constants); evaluator-collection.ts holds some/every/fold's shared collection-resolution logic, decoupled from evaluatePredicateInternal via an injected EvaluatePredicate callback rather than a direct import, which is what lets it live in its own module without recreating the circular-import hazard evaluator.ts's own top comment already documents for its two mutually-recursive core functions; evaluator-factory.ts holds createEvaluator and the default evaluatePredicate/evaluateValue pair, since that factory only ever calls into evaluator.ts and never the reverse. evaluator.ts itself now holds only evaluatePredicateInternal and evaluateValueInternal, the one part of the original file that genuinely cannot be split further without breaking their mutual recursion across a module boundary. Every caller of createEvaluator/evaluatePredicate/evaluateValue now imports from evaluator-factory.ts instead of evaluator.ts. --- packages/trilean/src/complex.test.ts | 2 +- .../trilean/src/derived-aggregates.test.ts | 2 +- .../trilean/src/derived-connectives.test.ts | 2 +- packages/trilean/src/derived-patterns.test.ts | 2 +- packages/trilean/src/derived-values.test.ts | 2 +- packages/trilean/src/evaluator-budget.ts | 49 + packages/trilean/src/evaluator-collection.ts | 78 ++ packages/trilean/src/evaluator-factory.ts | 67 ++ .../src/evaluator-indeterminacy-helpers.ts | 6 +- packages/trilean/src/evaluator-operations.ts | 736 ++++++++++++++ .../trilean/src/evaluator.arithmetic.test.ts | 4 +- .../trilean/src/evaluator.collections.test.ts | 2 +- .../trilean/src/evaluator.comparison.test.ts | 2 +- .../src/evaluator.complex-arithmetic.test.ts | 2 +- .../src/evaluator.control-flow.test.ts | 6 +- .../src/evaluator.indeterminacy-cases.test.ts | 6 +- .../trilean/src/evaluator.integration.test.ts | 6 +- .../trilean/src/evaluator.membership.test.ts | 2 +- .../trilean/src/evaluator.temporal.test.ts | 2 +- packages/trilean/src/evaluator.ts | 910 +----------------- packages/trilean/src/evaluator.values.test.ts | 2 +- packages/trilean/src/functions.ts | 2 +- packages/trilean/src/golden-examples.test.ts | 2 +- packages/trilean/src/index.ts | 6 +- packages/trilean/src/truth-tables.test.ts | 2 +- packages/trilean/test/workers/trilean.test.ts | 2 +- 26 files changed, 1001 insertions(+), 903 deletions(-) create mode 100644 packages/trilean/src/evaluator-budget.ts create mode 100644 packages/trilean/src/evaluator-collection.ts create mode 100644 packages/trilean/src/evaluator-factory.ts create mode 100644 packages/trilean/src/evaluator-operations.ts diff --git a/packages/trilean/src/complex.test.ts b/packages/trilean/src/complex.test.ts index e06474f..cc603c4 100644 --- a/packages/trilean/src/complex.test.ts +++ b/packages/trilean/src/complex.test.ts @@ -5,7 +5,7 @@ import { complexMagnitude, complexPhase, } from "./complex"; -import { evaluateValue } from "./evaluator"; +import { evaluateValue } from "./evaluator-factory"; import type { Resolvers } from "./resolvers"; import { ExpressionNodeSchema } from "./tree"; diff --git a/packages/trilean/src/derived-aggregates.test.ts b/packages/trilean/src/derived-aggregates.test.ts index a19838e..daf340d 100644 --- a/packages/trilean/src/derived-aggregates.test.ts +++ b/packages/trilean/src/derived-aggregates.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { average, count, sum } from "./derived-aggregates"; -import { evaluateValue } from "./evaluator"; +import { evaluateValue } from "./evaluator-factory"; import type { Evaluation } from "./evaluation"; import type { ComputedValue } from "./computed-value"; import type { ExpressionNode, PredicateNode } from "./tree"; diff --git a/packages/trilean/src/derived-connectives.test.ts b/packages/trilean/src/derived-connectives.test.ts index c7716c0..11baf90 100644 --- a/packages/trilean/src/derived-connectives.test.ts +++ b/packages/trilean/src/derived-connectives.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { evaluatePredicate } from "./evaluator"; +import { evaluatePredicate } from "./evaluator-factory"; import { and, iff, diff --git a/packages/trilean/src/derived-patterns.test.ts b/packages/trilean/src/derived-patterns.test.ts index f214160..3e1d578 100644 --- a/packages/trilean/src/derived-patterns.test.ts +++ b/packages/trilean/src/derived-patterns.test.ts @@ -4,7 +4,7 @@ import { prefixPattern, wildcardPattern, } from "./derived-patterns"; -import { createEvaluator, evaluatePredicate } from "./evaluator"; +import { createEvaluator, evaluatePredicate } from "./evaluator-factory"; import type { PredicateNode } from "./tree"; import type { Resolvers } from "./resolvers"; diff --git a/packages/trilean/src/derived-values.test.ts b/packages/trilean/src/derived-values.test.ts index 9f27add..3e9b250 100644 --- a/packages/trilean/src/derived-values.test.ts +++ b/packages/trilean/src/derived-values.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { coalesce } from "./derived-values"; -import { evaluateValue } from "./evaluator"; +import { evaluateValue } from "./evaluator-factory"; import type { Evaluation } from "./evaluation"; import type { ComputedValue } from "./computed-value"; import type { ExpressionNode } from "./tree"; diff --git a/packages/trilean/src/evaluator-budget.ts b/packages/trilean/src/evaluator-budget.ts new file mode 100644 index 0000000..dd3578c --- /dev/null +++ b/packages/trilean/src/evaluator-budget.ts @@ -0,0 +1,49 @@ +import type { Evaluation } from "./evaluation"; +import { indeterminate } from "./evaluation"; + +/** A defense-in-depth guard against a long acyclic `treeReference` chain exhausting the call stack -- distinct from, and layered on top of, the cycle detector in evaluator.ts (`visitedTreeKeys`), which catches an actual repeat immediately and more precisely. */ +export const MAX_TREE_REFERENCE_DEPTH = 100; + +/** Default cap on the total number of predicate/expression nodes a single `evaluatePredicate`/`evaluateValue` call may visit -- see `createEvaluator`'s `maxNodes` option, threaded through to `createEvaluationBudget` below. Chosen generously above any realistic hand-authored business-rule, eligibility, or formula tree (see README.md's own consumer use cases), while still bounding an untrusted, adversarially-authored tree's total evaluation cost to a fixed, small amount of work regardless of how it is shaped. */ +export const DEFAULT_MAX_EVALUATION_NODES = 10_000; + +/** Default cap on ordinary recursive-descent nesting depth -- see `createEvaluator`'s `maxNestingDepth` option, threaded through to `createEvaluationBudget` below. Set comfortably below the depth at which this evaluator's own recursive descent has been observed to exhaust the host call stack (a chain of plain `not` nodes wrapped around a single leaf, evaluated directly against this file under Node.js, failed consistently somewhere in the low thousands of nesting levels, with some run-to-run variance from whatever else already occupied the stack), while remaining far deeper than any legitimate hand-authored tree is ever likely to nest. Kept well clear of that measured failure point because the exact threshold varies by host runtime (a Cloudflare Workers isolate's own stack is smaller than Node's) and by how much of the stack the rest of the call chain has already used. */ +export const DEFAULT_MAX_NESTING_DEPTH = 500; + +/** + * A single `evaluatePredicate`/`evaluateValue` call's resource limits, independent of and layered underneath `MAX_TREE_REFERENCE_DEPTH`'s own cross-tree chain guard above: that guard only advances at an actual `treeReference` resolution and says nothing about a plain, self-contained tree built from ordinary `and`/`or`/`fold`/`conditional`/quantifier nesting, with no `treeReference` node anywhere in it. This closes that gap for a consumer evaluating a tree it did not author and cannot fully trust before evaluation (e.g. a tree embedded in a signed but otherwise attacker-controlled payload). + * + * Exposed as a single `checkNode` method rather than a raw mutable counter, following the same all-callback shape `Resolvers` and `FunctionRegistry` use in evaluator.ts: every recursive call site threads this object through as `Readonly`, and that wrapper genuinely prevents tampering because the running node count lives in `createEvaluationBudget`'s own closure, never as an assignable property on the object itself. + */ +export interface EvaluationBudget { + /** Charges one node visit and checks both caps, returning the `Evaluation` to return immediately if either is now exceeded, or `undefined` if evaluation of this node may proceed. Called as the very first action inside `evaluatePredicateInternal`/`evaluateValueInternal`, before any of that node's own work or further recursion, so an exceeded budget is discovered before it can be spent on additional descent. */ + checkNode: (nestingDepth: number) => Evaluation | undefined; +} + +/** Constructs a fresh `EvaluationBudget` for one top-level `evaluatePredicate`/`evaluateValue` call -- see `createEvaluator`'s `maxNodes`/`maxNestingDepth` options, which supply `maxNodes`/`maxNestingDepth` here. A fresh closure per call is what keeps `nodesVisited` from leaking between unrelated evaluations. */ +export function createEvaluationBudget( + maxNodes: number, + maxNestingDepth: number, +): EvaluationBudget { + /** Total predicate/expression nodes visited so far across the whole call -- shared by every branch of an `and`/`or`/`allOf`/`anyOf`/fold/quantifier through the closure below, so it accumulates across the whole traversal rather than resetting per branch. */ + let nodesVisited = 0; + return { + checkNode(nestingDepth) { + nodesVisited += 1; + if (nodesVisited > maxNodes) { + return indeterminate( + "domain-error", + `evaluation exceeded the maximum of ${maxNodes.toString()} nodes visited in a single call (resource exhausted)`, + ); + } + // Distinct from `MAX_TREE_REFERENCE_DEPTH`'s own chain-depth counter: `nestingDepth` advances on every recursive descent into a child predicate/expression node, not only at `treeReference` resolution. + if (nestingDepth >= maxNestingDepth) { + return indeterminate( + "domain-error", + `evaluation nesting depth exceeds the maximum of ${maxNestingDepth.toString()} (resource exhausted)`, + ); + } + return undefined; + }, + }; +} diff --git a/packages/trilean/src/evaluator-collection.ts b/packages/trilean/src/evaluator-collection.ts new file mode 100644 index 0000000..02e011b --- /dev/null +++ b/packages/trilean/src/evaluator-collection.ts @@ -0,0 +1,78 @@ +import type { ComputedValue } from "./computed-value"; +import type { Evaluation, IndeterminateReason } from "./evaluation"; +import type { EvaluationBudget } from "./evaluator-budget"; +import type { FunctionRegistry } from "./functions"; +import type { JsonValue } from "./json-value"; +import type { EvaluationContext, Resolvers } from "./resolvers"; +import type { PredicateNode } from "./tree"; + +/** `resolveParticipatingItems`'s own view of `evaluatePredicateInternal` -- the exact signature that function has in evaluator.ts, injected here rather than imported directly so this module has no dependency back on evaluator.ts. Evaluating a `filter` genuinely does need the full recursive predicate evaluator (a filter can itself be arbitrarily nested), but taking it as a parameter rather than an import means this collection-resolution logic and evaluator.ts's mutually-recursive `evaluatePredicateInternal`/`evaluateValueInternal` pair never import each other, so there is no circular-import hazard between the two files. */ +export type EvaluatePredicate = ( + node: PredicateNode, + context: EvaluationContext, + resolvers: Readonly, + accumulator: ComputedValue | undefined, + functions: Readonly, + visitedTreeKeys: ReadonlySet, + treeReferenceDepth: number, + budget: Readonly, + nestingDepth: number, +) => Promise>; + +/** A collection candidate paired with its own pre-filter outcome: `"include"`/`"exclude"` when `filter` resolved definitely, or the filter's own indeterminate `Evaluation` when it did not (there is no third, definite-but-neither branch -- see `resolveParticipatingItems` below). */ +export interface ResolvedCollectionItem { + readonly item: unknown; + readonly filterOutcome: "include" | "exclude" | Evaluation; +} + +/** + * Collection resolution shared by the quantifiers (`some`/`every`) and by `fold` in evaluator.ts: resolves the opaque `collection` reference to its concrete candidate list via `resolvers.resolveCollection`, then evaluates each candidate's optional `filter` (via the injected `evaluatePredicate`, see `EvaluatePredicate`'s own doc comment) with that candidate as its own evaluation context and the accumulator reset to `undefined` -- see the README's "Evaluation context" and "Pre-filtering which items participate" sections. Deliberately stops short of deciding how an indeterminate filter combines with the rest of the surrounding node: `some`/`every` fold a filter-indeterminate item in as its own vote via the surrounding OR/AND absorption, while `fold` has no absorbing value at all and goes indeterminate outright on the same condition -- the two callers need genuinely different combination logic over these same per-item outcomes, so this helper only produces the outcomes and leaves combining them to the caller. + */ +export async function resolveParticipatingItems( + evaluatePredicate: EvaluatePredicate, + collection: JsonValue, + filter: PredicateNode | undefined, + context: EvaluationContext, + resolvers: Readonly, + functions: Readonly, + visitedTreeKeys: ReadonlySet, + treeReferenceDepth: number, + budget: Readonly, + nestingDepth: number, +): Promise { + const candidates = await resolvers.resolveCollection(collection, context); + return Promise.all( + candidates.map(async (item): Promise => { + if (filter === undefined) return { item, filterOutcome: "include" }; + const filterResult = await evaluatePredicate( + filter, + item, + resolvers, + undefined, + functions, + visitedTreeKeys, + treeReferenceDepth, + budget, + nestingDepth + 1, + ); + if (filterResult.status === "indeterminate") { + return { item, filterOutcome: filterResult }; + } + return { + item, + filterOutcome: filterResult.value ? "include" : "exclude", + }; + }), + ); +} + +/** The first participating item (in declared collection order) whose `filter` evaluation was itself indeterminate, or `undefined` if every participating item's filter resolved definitely -- a filter-excluded item's own `"exclude"` outcome never counts here. Used only by `fold`, which -- unlike `some`/`every`'s OR/AND absorption -- has no absorbing value at all: any participating item's indeterminate filter makes the whole fold indeterminate outright, with no other item's outcome able to override it. */ +export function firstFilterIndeterminate( + participating: readonly ResolvedCollectionItem[], +): IndeterminateReason | undefined { + for (const { filterOutcome } of participating) { + if (filterOutcome === "include" || filterOutcome === "exclude") continue; + if (filterOutcome.status === "indeterminate") return filterOutcome.reason; + } + return undefined; +} diff --git a/packages/trilean/src/evaluator-factory.ts b/packages/trilean/src/evaluator-factory.ts new file mode 100644 index 0000000..45e5743 --- /dev/null +++ b/packages/trilean/src/evaluator-factory.ts @@ -0,0 +1,67 @@ +import type { ComputedValue } from "./computed-value"; +import type { Evaluation } from "./evaluation"; +import { + DEFAULT_MAX_EVALUATION_NODES, + DEFAULT_MAX_NESTING_DEPTH, + createEvaluationBudget, +} from "./evaluator-budget"; +import { evaluatePredicateInternal, evaluateValueInternal } from "./evaluator"; +import type { FunctionRegistry } from "./functions"; +import { emptyFunctionRegistry } from "./functions"; +import type { EvaluationContext, Resolvers } from "./resolvers"; +import type { ExpressionNode, PredicateNode } from "./tree"; + +/** + * Builds a bound `{ evaluatePredicate, evaluateValue }` pair over a caller-supplied function registry for `call` nodes -- the registry is bound once, at construction time, unlike `resolvers`, which are supplied fresh to every call. `maxNodes` and `maxNestingDepth` configure that same pair's own `EvaluationBudget` (see its doc comment in evaluator-budget.ts), created fresh for every top-level `evaluatePredicate`/`evaluateValue` invocation so budgets never leak between unrelated calls. The bare module-level `evaluatePredicate`/`evaluateValue` exports below are `createEvaluator({})`'s output, so both caps default to `DEFAULT_MAX_EVALUATION_NODES`/`DEFAULT_MAX_NESTING_DEPTH` for every caller that does not explicitly configure them. + */ +export function createEvaluator({ + functions = emptyFunctionRegistry, + maxNodes = DEFAULT_MAX_EVALUATION_NODES, + maxNestingDepth = DEFAULT_MAX_NESTING_DEPTH, +}: { + functions?: FunctionRegistry; + /** Caps the total number of predicate/expression nodes a single `evaluatePredicate`/`evaluateValue` call may visit -- see `createEvaluationBudget`. Defaults to `DEFAULT_MAX_EVALUATION_NODES`. */ + maxNodes?: number; + /** Caps ordinary recursive-descent nesting depth, independent of `MAX_TREE_REFERENCE_DEPTH`'s own `treeReference` chain-depth cap -- see `createEvaluationBudget`. Defaults to `DEFAULT_MAX_NESTING_DEPTH`. */ + maxNestingDepth?: number; +}): { + evaluatePredicate: ( + node: PredicateNode, + context: EvaluationContext, + resolvers: Readonly, + ) => Promise>; + evaluateValue: ( + node: ExpressionNode, + context: EvaluationContext, + resolvers: Readonly, + ) => Promise>; +} { + return { + evaluatePredicate: async (node, context, resolvers) => + evaluatePredicateInternal( + node, + context, + resolvers, + undefined, + functions, + new Set(), + 0, + createEvaluationBudget(maxNodes, maxNestingDepth), + 0, + ), + evaluateValue: async (node, context, resolvers) => + evaluateValueInternal( + node, + context, + resolvers, + undefined, + functions, + new Set(), + 0, + createEvaluationBudget(maxNodes, maxNestingDepth), + 0, + ), + }; +} + +export const { evaluatePredicate, evaluateValue } = createEvaluator({}); diff --git a/packages/trilean/src/evaluator-indeterminacy-helpers.ts b/packages/trilean/src/evaluator-indeterminacy-helpers.ts index e06e3ea..0f24378 100644 --- a/packages/trilean/src/evaluator-indeterminacy-helpers.ts +++ b/packages/trilean/src/evaluator-indeterminacy-helpers.ts @@ -1,5 +1,9 @@ import { expect } from "vitest"; -import { createEvaluator, evaluatePredicate, evaluateValue } from "./evaluator"; +import { + createEvaluator, + evaluatePredicate, + evaluateValue, +} from "./evaluator-factory"; import type { IndeterminateReason } from "./evaluation"; import type { FunctionRegistry } from "./functions"; import type { EvaluationContext, Resolvers } from "./resolvers"; diff --git a/packages/trilean/src/evaluator-operations.ts b/packages/trilean/src/evaluator-operations.ts new file mode 100644 index 0000000..23d981c --- /dev/null +++ b/packages/trilean/src/evaluator-operations.ts @@ -0,0 +1,736 @@ +import type { CompiledPattern } from "trilean-regex"; +import { RegexParseError, compilePattern } from "trilean-regex"; +import type { ComputedValue, DurationUnit, Unit } from "./computed-value"; +import { + combineUnitsForDivide, + combineUnitsForMultiply, + unitsEqual, +} from "./computed-value"; +import { + type Evaluation, + definite, + firstIndeterminate, + indeterminate, +} from "./evaluation"; +import type { FunctionRegistry } from "./functions"; +import type { + ArithmeticOperator, + ComparisonOperator, + TextComparisonOperator, +} from "./tree"; + +/** The three-valued AND table: `false` is absorbing regardless of the other operand's indeterminacy; otherwise both-definite folds to a boolean AND; otherwise indeterminate, with the tie-break rule (declared operand order, left before right) applied via `firstIndeterminate`. */ +export function combineAnd( + left: Evaluation, + right: Evaluation, +): Evaluation { + if (left.status === "definite" && !left.value) return definite(false); + if (right.status === "definite" && !right.value) return definite(false); + const reason = firstIndeterminate(left, right); + if (reason !== undefined) return { status: "indeterminate", reason }; + return definite(true); +} + +/** The three-valued OR table: mirror image of `combineAnd`, with `true` absorbing. */ +export function combineOr( + left: Evaluation, + right: Evaluation, +): Evaluation { + if (left.status === "definite" && left.value) return definite(true); + if (right.status === "definite" && right.value) return definite(true); + const reason = firstIndeterminate(left, right); + if (reason !== undefined) return { status: "indeterminate", reason }; + return definite(false); +} + +function applyComparisonOperator( + op: ComparisonOperator, + left: number, + right: number, +): boolean { + switch (op) { + case "gt": + return left > right; + case "gte": + return left >= right; + case "lt": + return left < right; + case "lte": + return left <= right; + case "eq": + return left === right; + case "neq": + return left !== right; + default: + throw new Error("unreachable comparison operator"); + } +} + +const millisecondsPerDurationUnit: Record = { + ms: 1, + s: 1000, + min: 60_000, + h: 3_600_000, + d: 86_400_000, +}; + +/** Normalises a `duration`'s magnitude to milliseconds, the common base unit for combining or comparing two `duration`s (or an `instant` and a `duration`) of potentially different `DurationUnit`s -- shared by `compareValues`'s own `duration` branch below and by the temporal arithmetic further down. */ +function toMilliseconds(value: number, unit: DurationUnit): number { + return value * millisecondsPerDurationUnit[unit]; +} + +/** `Date.parse` reports an unparseable timestamp as `NaN`, and every downstream use of that `NaN` then fails silently rather than loudly: every comparison against it is `false` (so `neq` between two unparseable instants would come back definitely `true`), a subtraction yields a `NaN`-magnitude duration, and `new Date(NaN).toISOString()` throws a `RangeError` outright -- a thrown exception for a data-quality problem, which this design never does (see `Evaluation`'s own doc comment in evaluation.ts). An `instant` whose string will not parse is a value the operation cannot use, so every caller below reports it as `wrong-type`, exactly like any other unusable operand. */ +function toEpochMilliseconds(value: string): number | undefined { + const epochMilliseconds = Date.parse(value); + return Number.isNaN(epochMilliseconds) ? undefined : epochMilliseconds; +} + +function unparseableInstant(value: string): Evaluation { + return indeterminate( + "wrong-type", + `'${value}' is not a parseable ISO-8601 timestamp`, + ); +} + +/** The representable timestamp range is finite, so a valid instant shifted by a large enough duration lands outside it, where `new Date(...).toISOString()` throws a `RangeError`. Checking the shifted date against the platform's own notion of a valid time value keeps that inside the three-outcome model as a `domain-error` -- an operation pushed outside its valid domain, the same category as division by zero -- and avoids hardcoding the range bound. */ +function instantFromEpochMilliseconds( + epochMilliseconds: number, +): Evaluation { + const shifted = new Date(epochMilliseconds); + if (Number.isNaN(shifted.getTime())) { + return indeterminate( + "domain-error", + "the resulting instant falls outside the representable timestamp range", + ); + } + return definite({ kind: "instant", value: shifted.toISOString() }); +} + +/** `compare`'s two operands: valid kinds are `number` (matching units required), `instant` (ordered by parsed epoch millisecond), or `duration` (ordered by magnitude normalised to milliseconds) -- `text` is never valid here (see `textCompare`), and a kind mismatch between the two operands is `wrong-type`. Narrowing `right` against a literal `left.kind` case (rather than asserting it) is what lets both sides stay properly typed with no `as`. */ +export function compareValues( + op: ComparisonOperator, + left: ComputedValue, + right: ComputedValue, +): Evaluation { + switch (left.kind) { + case "number": + if (right.kind !== "number") { + return indeterminate( + "wrong-type", + `cannot compare a 'number' value with a '${right.kind}' value`, + ); + } + if (!unitsEqual(left.unit, right.unit)) { + return indeterminate( + "wrong-type", + "cannot compare numbers with incompatible units", + ); + } + return definite(applyComparisonOperator(op, left.value, right.value)); + case "text": + return indeterminate( + "wrong-type", + "text values are not comparable via 'compare'; use 'textCompare'", + ); + case "boolean": + if (right.kind !== "boolean") { + return indeterminate( + "wrong-type", + `cannot compare a 'boolean' value with a '${right.kind}' value`, + ); + } + if (op !== "eq" && op !== "neq") { + return indeterminate( + "wrong-type", + `booleans have no natural ordering; '${op}' is only defined for 'eq'/'neq'`, + ); + } + return definite( + op === "eq" ? left.value === right.value : left.value !== right.value, + ); + case "instant": { + if (right.kind !== "instant") { + return indeterminate( + "wrong-type", + `cannot compare an 'instant' value with a '${right.kind}' value`, + ); + } + const leftEpoch = toEpochMilliseconds(left.value); + if (leftEpoch === undefined) return unparseableInstant(left.value); + const rightEpoch = toEpochMilliseconds(right.value); + if (rightEpoch === undefined) return unparseableInstant(right.value); + return definite(applyComparisonOperator(op, leftEpoch, rightEpoch)); + } + case "duration": + if (right.kind !== "duration") { + return indeterminate( + "wrong-type", + `cannot compare a 'duration' value with a '${right.kind}' value`, + ); + } + return definite( + applyComparisonOperator( + op, + toMilliseconds(left.value, left.unit), + toMilliseconds(right.value, right.unit), + ), + ); + // Complex operands stay kind-strict here, deliberately unlike `arithmetic`'s promotion of a real operand: arithmetic produces a value, so promoting loses nothing, whereas a comparison consumes two and this design already treats a kind difference between them as a modelling error worth surfacing (the same reason an `instant` is never compared against a plain `number`). + case "complex": + if (op !== "eq" && op !== "neq") { + return indeterminate( + "wrong-type", + `ordering operator '${op}' is not defined for complex values; the complex plane has no total order`, + ); + } + if (right.kind !== "complex") { + return indeterminate( + "wrong-type", + `cannot compare a 'complex' value with a '${right.kind}' value`, + ); + } + if (!unitsEqual(left.unit, right.unit)) { + return indeterminate( + "wrong-type", + "cannot compare complex values with incompatible units", + ); + } + return definite( + op === "eq" + ? left.re === right.re && left.im === right.im + : left.re !== right.re || left.im !== right.im, + ); + default: + throw new Error("unreachable computed-value kind"); + } +} + +/** `textCompare`'s two operands must both resolve to the `text` computed-value kind -- any other kind, on either operand, is `wrong-type` (see the `textCompare` section of README.md). `equals`/`notEquals` are exact string equality; `matches`/`notMatches` interpret `right`'s text as an ECMAScript regular expression tested against `left`'s text; `portableMatches`/`portableNotMatches` interpret it as a pattern in `trilean-regex`'s own restricted grammar instead, matched by that package's finite-automaton reference matcher rather than the host's native `RegExp` engine -- see the `textCompare` section of README.md for when to reach for the portable pair instead of the plain one. An invalid pattern is `wrong-type` rather than a thrown exception either way -- every data-quality problem stays inside the `Evaluation` result, per `Evaluation`'s own doc comment in evaluation.ts. */ +export function compareText( + op: TextComparisonOperator, + left: ComputedValue, + right: ComputedValue, +): Evaluation { + if (left.kind !== "text") { + return indeterminate( + "wrong-type", + `'textCompare' requires 'text' operands; the left operand is '${left.kind}'`, + ); + } + if (right.kind !== "text") { + return indeterminate( + "wrong-type", + `'textCompare' requires 'text' operands; the right operand is '${right.kind}'`, + ); + } + switch (op) { + case "equals": + return definite(left.value === right.value); + case "notEquals": + return definite(left.value !== right.value); + case "matches": + case "notMatches": { + let pattern: RegExp; + try { + pattern = new RegExp(right.value); + } catch { + return indeterminate( + "wrong-type", + `'${right.value}' is not a valid regular expression pattern`, + ); + } + const isMatch = pattern.test(left.value); + return definite(op === "matches" ? isMatch : !isMatch); + } + case "portableMatches": + case "portableNotMatches": { + let compiled: CompiledPattern; + try { + compiled = compilePattern(right.value); + } catch (error) { + const reason = + error instanceof RegexParseError ? error.message : String(error); + return indeterminate( + "wrong-type", + `'${right.value}' is not a valid trilean-regex pattern: ${reason}`, + ); + } + const isMatch = compiled.test(left.value); + return definite(op === "portableMatches" ? isMatch : !isMatch); + } + default: + throw new Error("unreachable text comparison operator"); + } +} + +/** `memberOf`'s own value-equality test between two computed values -- kind-agnostic across `number`/`text`/`instant`/`duration` (see the `memberOf` section of README.md, and its "Derived aggregates" note that this equality is kind-agnostic across every computed-value kind), unlike `compare`'s own `eq`, which rejects a `text` operand outright and directs callers to `textCompare` instead. A kind mismatch, or a `number` pair with an incompatible unit, is `wrong-type` -- never simply "not equal". Narrowing `candidate` against a literal `operand.kind` case (rather than asserting it) is the same technique `compareValues` uses above. */ +export function computeMembershipMatch( + operand: ComputedValue, + candidate: ComputedValue, +): Evaluation { + switch (operand.kind) { + case "number": + if (candidate.kind !== "number") { + return indeterminate( + "wrong-type", + `cannot compare a 'number' value with a '${candidate.kind}' value for membership`, + ); + } + if (!unitsEqual(operand.unit, candidate.unit)) { + return indeterminate( + "wrong-type", + "cannot compare numbers with incompatible units for membership", + ); + } + return definite(operand.value === candidate.value); + case "text": + if (candidate.kind !== "text") { + return indeterminate( + "wrong-type", + `cannot compare a 'text' value with a '${candidate.kind}' value for membership`, + ); + } + return definite(operand.value === candidate.value); + case "boolean": + if (candidate.kind !== "boolean") { + return indeterminate( + "wrong-type", + `cannot compare a 'boolean' value with a '${candidate.kind}' value for membership`, + ); + } + return definite(operand.value === candidate.value); + case "instant": { + if (candidate.kind !== "instant") { + return indeterminate( + "wrong-type", + `cannot compare an 'instant' value with a '${candidate.kind}' value for membership`, + ); + } + const operandEpoch = toEpochMilliseconds(operand.value); + if (operandEpoch === undefined) return unparseableInstant(operand.value); + const candidateEpoch = toEpochMilliseconds(candidate.value); + if (candidateEpoch === undefined) { + return unparseableInstant(candidate.value); + } + return definite(operandEpoch === candidateEpoch); + } + case "duration": + if (candidate.kind !== "duration") { + return indeterminate( + "wrong-type", + `cannot compare a 'duration' value with a '${candidate.kind}' value for membership`, + ); + } + return definite( + toMilliseconds(operand.value, operand.unit) === + toMilliseconds(candidate.value, candidate.unit), + ); + case "complex": + if (candidate.kind !== "complex") { + return indeterminate( + "wrong-type", + `cannot compare a 'complex' value with a '${candidate.kind}' value for membership`, + ); + } + if (!unitsEqual(operand.unit, candidate.unit)) { + return indeterminate( + "wrong-type", + "cannot compare complex values with incompatible units for membership", + ); + } + return definite( + operand.re === candidate.re && operand.im === candidate.im, + ); + default: + throw new Error("unreachable computed-value kind"); + } +} + +/** `power`/`modulo` have no defined unit-combination rule in this design (unlike `add`/`subtract`'s "identical units" requirement or `multiply`/`divide`'s dimensional-exponent combination) -- scoping them to dimensionless operands avoids inventing an unspecified unit-scaling semantics for a fractional or runtime-determined exponent. */ +function isDimensionless(unit: Unit | undefined): boolean { + return unitsEqual(unit, undefined); +} + +/** `negate` is never sugar for "zero minus the value" (see the `arithmetic`/`negate` section of README.md) -- a `duration`'s magnitude is negated directly, in its own original unit, with no subtraction or millisecond normalisation involved. */ +export function applyNegate(operand: ComputedValue): Evaluation { + switch (operand.kind) { + case "number": + return definite({ + kind: "number", + value: -operand.value, + unit: operand.unit, + }); + case "duration": + return definite({ + kind: "duration", + value: -operand.value, + unit: operand.unit, + }); + case "complex": + return definite({ + kind: "complex", + re: -operand.re, + im: -operand.im, + unit: operand.unit, + }); + case "text": + case "instant": + case "boolean": + return indeterminate( + "wrong-type", + `cannot negate a '${operand.kind}' value`, + ); + default: + throw new Error("unreachable computed-value kind"); + } +} + +function applyArithmeticOnNumbers( + op: ArithmeticOperator, + left: Extract, + right: Extract, +): Evaluation { + switch (op) { + case "add": + if (!unitsEqual(left.unit, right.unit)) { + return indeterminate( + "wrong-type", + "cannot add numbers with incompatible units", + ); + } + return definite({ + kind: "number", + value: left.value + right.value, + unit: left.unit, + }); + case "subtract": + if (!unitsEqual(left.unit, right.unit)) { + return indeterminate( + "wrong-type", + "cannot subtract numbers with incompatible units", + ); + } + return definite({ + kind: "number", + value: left.value - right.value, + unit: left.unit, + }); + case "multiply": + return definite({ + kind: "number", + value: left.value * right.value, + unit: combineUnitsForMultiply(left.unit, right.unit), + }); + case "divide": + if (right.value === 0) { + return indeterminate("domain-error", "division by zero"); + } + return definite({ + kind: "number", + value: left.value / right.value, + unit: combineUnitsForDivide(left.unit, right.unit), + }); + case "power": + if (!isDimensionless(left.unit) || !isDimensionless(right.unit)) { + return indeterminate( + "wrong-type", + "'power' requires dimensionless operands", + ); + } + if (left.value < 0 && !Number.isInteger(right.value)) { + return indeterminate( + "domain-error", + "a negative base raised to a non-integer power is not a real number", + ); + } + // A zero base with a negative exponent is a division by zero written the other way round, so it belongs in the same domain-error category rather than escaping as a definite (infinite) result. + if (left.value === 0 && right.value < 0) { + return indeterminate( + "domain-error", + "zero raised to a negative power is a division by zero", + ); + } + return definite({ kind: "number", value: left.value ** right.value }); + case "modulo": + if (!isDimensionless(left.unit) || !isDimensionless(right.unit)) { + return indeterminate( + "wrong-type", + "'modulo' requires dimensionless operands", + ); + } + if (right.value === 0) { + return indeterminate("domain-error", "modulo by zero"); + } + return definite({ kind: "number", value: left.value % right.value }); + default: + throw new Error("unreachable arithmetic operator"); + } +} + +/** The only same-kind, non-`number` arithmetic this design defines: two `duration`s combine by normalising both to milliseconds first (see `toMilliseconds`), reporting the result in milliseconds -- `multiply`/`divide`/`power`/`modulo` have no representable result unit for a `duration` squared or a dimensionless ratio, so they are `wrong-type` rather than invented. */ +function applyArithmeticOnDurations( + op: ArithmeticOperator, + left: Readonly>, + right: Readonly>, +): Evaluation { + switch (op) { + case "add": + return definite({ + kind: "duration", + value: + toMilliseconds(left.value, left.unit) + + toMilliseconds(right.value, right.unit), + unit: "ms", + }); + case "subtract": + return definite({ + kind: "duration", + value: + toMilliseconds(left.value, left.unit) - + toMilliseconds(right.value, right.unit), + unit: "ms", + }); + case "multiply": + case "divide": + case "power": + case "modulo": + return indeterminate( + "wrong-type", + `arithmetic operator '${op}' is not defined between two 'duration' values`, + ); + default: + throw new Error("unreachable arithmetic operator"); + } +} + +/** Every real number is a complex number with a zero imaginary part, so promoting one is exact and total -- unlike the temporal cross-kind combinations above, which each had to be enumerated because no such embedding exists between an `instant` and a `duration`. This is what lets a tree mix real and complex terms freely instead of forcing every real literal to be written as a complex one. */ +function toComplexValue( + value: Readonly>, +): Extract { + if (value.kind === "complex") return value; + return { kind: "complex", re: value.value, im: 0, unit: value.unit }; +} + +/** The complex product, as its own component-level helper rather than only a branch of the operator switch below, because `power`'s repeated multiplication needs exactly this and must not re-derive it. */ +function multiplyComplexValues( + left: Readonly>, + right: Readonly>, +): Extract { + return { + kind: "complex", + re: left.re * right.re - left.im * right.im, + im: left.re * right.im + left.im * right.re, + unit: combineUnitsForMultiply(left.unit, right.unit), + }; +} + +/** Complex arithmetic, over the one canonical rectangular representation the `complex` kind stores (see the "Complex values" section of README.md). `add`/`subtract` are component-wise and carry the same identical-units requirement real numbers already have. `power` is deliberately excluded from this operator set rather than handled and rejected here: its exponent must stay an un-promoted real number, so the dispatcher routes it to `applyComplexPower` first, and excluding it from the type is what makes the compiler enforce that routing instead of leaving a dead branch behind. */ +function applyArithmeticOnComplex( + op: Exclude, + left: Readonly>, + right: Readonly>, +): Evaluation { + switch (op) { + case "add": + if (!unitsEqual(left.unit, right.unit)) { + return indeterminate( + "wrong-type", + "cannot add complex values with incompatible units", + ); + } + return definite({ + kind: "complex", + re: left.re + right.re, + im: left.im + right.im, + unit: left.unit, + }); + case "subtract": + if (!unitsEqual(left.unit, right.unit)) { + return indeterminate( + "wrong-type", + "cannot subtract complex values with incompatible units", + ); + } + return definite({ + kind: "complex", + re: left.re - right.re, + im: left.im - right.im, + unit: left.unit, + }); + case "multiply": + return definite(multiplyComplexValues(left, right)); + case "divide": { + // Multiplying both sides by the divisor's conjugate makes the denominator the real |divisor|^2, which is what turns a complex quotient into two ordinary real divisions. + const divisorSquaredMagnitude = right.re * right.re + right.im * right.im; + // Zero is the one complex value with no reciprocal, and it is zero in *both* components -- a divisor with only a zero real part (a purely imaginary one) divides perfectly well. + if (divisorSquaredMagnitude === 0) { + return indeterminate("domain-error", "division by zero"); + } + return definite({ + kind: "complex", + re: (left.re * right.re + left.im * right.im) / divisorSquaredMagnitude, + im: (left.im * right.re - left.re * right.im) / divisorSquaredMagnitude, + unit: combineUnitsForDivide(left.unit, right.unit), + }); + } + // Unlike the `wrong-type` cases elsewhere in this design, which mean "an answer exists but this operator does not accept this operand", modulo has no answer to accept: a remainder needs a canonical notion of "how many whole divisors fit", and the complex plane has no ordering to provide one. That is a genuine domain violation, the same category as division by zero. + case "modulo": + return indeterminate( + "domain-error", + "'modulo' is undefined for complex values", + ); + default: + throw new Error("unreachable arithmetic operator"); + } +} + +/** `power` with a complex operand on either side, defined for exactly one case: a real integer exponent, evaluated as the repeated multiplication that integer exponentiation *is* (see the "Complex values" section of README.md for why an arbitrary complex exponent stays out of scope). */ +function applyComplexPower( + base: Readonly>, + exponent: Readonly>, +): Evaluation { + if (exponent.kind !== "number" || !Number.isInteger(exponent.value)) { + return indeterminate( + "wrong-type", + "'power' with a complex operand requires a real integer exponent", + ); + } + if (!isDimensionless(base.unit) || !isDimensionless(exponent.unit)) { + return indeterminate( + "wrong-type", + "'power' requires dimensionless operands", + ); + } + const complexUnit: Extract = { + kind: "complex", + re: 1, + im: 0, + unit: {}, + }; + const complexBase = toComplexValue(base); + let repeatedProduct = complexUnit; + for (let applied = 0; applied < Math.abs(exponent.value); applied += 1) { + repeatedProduct = multiplyComplexValues(repeatedProduct, complexBase); + } + if (exponent.value >= 0) return definite(repeatedProduct); + // A negative exponent is the reciprocal of the positive one by definition, so it reuses the division above rather than re-deriving it -- which also means a zero base inherits that operator's own division-by-zero domain-error instead of needing its own check. + return applyArithmeticOnComplex("divide", complexUnit, repeatedProduct); +} + +/** + * Dispatches `arithmetic` by operand kind. The three cross-kind temporal combinations this design defines (`instant - instant`, `instant + duration`, `duration + instant`) are checked explicitly first, in that order, against the exact operator each requires; any other combination touching an `instant` is `wrong-type` (see "Temporal values" in README.md -- e.g. adding two instants, or subtracting a `duration` from an `instant`, are deliberately *not* defined). Same-kind `duration`/`duration` combinations are delegated to `applyArithmeticOnDurations`; a `duration` paired with anything other than an `instant` or another `duration` is `wrong-type`. Everything remaining requires two `number` operands. + */ +export function applyArithmetic( + op: ArithmeticOperator, + left: ComputedValue, + right: ComputedValue, +): Evaluation { + if ( + left.kind === "instant" && + right.kind === "instant" && + op === "subtract" + ) { + const leftEpoch = toEpochMilliseconds(left.value); + if (leftEpoch === undefined) return unparseableInstant(left.value); + const rightEpoch = toEpochMilliseconds(right.value); + if (rightEpoch === undefined) return unparseableInstant(right.value); + return definite({ + kind: "duration", + value: leftEpoch - rightEpoch, + unit: "ms", + }); + } + if (left.kind === "instant" && right.kind === "duration" && op === "add") { + const leftEpoch = toEpochMilliseconds(left.value); + if (leftEpoch === undefined) return unparseableInstant(left.value); + return instantFromEpochMilliseconds( + leftEpoch + toMilliseconds(right.value, right.unit), + ); + } + if (left.kind === "duration" && right.kind === "instant" && op === "add") { + const rightEpoch = toEpochMilliseconds(right.value); + if (rightEpoch === undefined) return unparseableInstant(right.value); + return instantFromEpochMilliseconds( + rightEpoch + toMilliseconds(left.value, left.unit), + ); + } + if (left.kind === "instant" || right.kind === "instant") { + return indeterminate( + "wrong-type", + `arithmetic operator '${op}' is not defined between a '${left.kind}' and a '${right.kind}' value`, + ); + } + if (left.kind === "duration" && right.kind === "duration") { + return applyArithmeticOnDurations(op, left, right); + } + if (left.kind === "duration" || right.kind === "duration") { + return indeterminate( + "wrong-type", + `arithmetic operator '${op}' is not defined between a '${left.kind}' and a '${right.kind}' value`, + ); + } + if (left.kind !== "number" && left.kind !== "complex") { + return indeterminate( + "wrong-type", + `arithmetic requires numeric operands; got a '${left.kind}' value`, + ); + } + if (right.kind !== "number" && right.kind !== "complex") { + return indeterminate( + "wrong-type", + `arithmetic requires numeric operands; got a '${right.kind}' value`, + ); + } + if (left.kind === "complex" || right.kind === "complex") { + if (op === "power") return applyComplexPower(left, right); + // Both operands are `number` or `complex` by this point, so promoting the real side is always possible; the result is complex whenever either operand is, regardless of what the components turn out to be. + return applyArithmeticOnComplex( + op, + toComplexValue(left), + toComplexValue(right), + ); + } + return applyArithmeticOnNumbers(op, left, right); +} + +/** `reference`'s optional expected-unit check: when `expectedUnit` is declared (from `node.unit`), the resolved value must be numeric (`number` or `complex`) and its own unit must match. Returns the `wrong-type` `Evaluation` to return immediately if the check fails, or `undefined` if the reference may resolve as-is. */ +export function checkReferenceUnit( + expectedUnit: Unit | undefined, + resolvedValue: ComputedValue, +): Evaluation | undefined { + if (expectedUnit === undefined) return undefined; + // `complex` counts as numeric here alongside `number`: it carries a `unit` of its own for exactly the same dimensional-analysis reason, so a reference to a complex-valued quantity can declare what it expects like any other. + if (resolvedValue.kind !== "number" && resolvedValue.kind !== "complex") { + return indeterminate( + "wrong-type", + "a unit was expected on a reference that resolved to a non-numeric value", + ); + } + if (!unitsEqual(expectedUnit, resolvedValue.unit)) { + return indeterminate( + "wrong-type", + "the resolved value's unit does not match the reference's expected unit", + ); + } + return undefined; +} + +/** `call`'s own function-registry dispatch, given the `fn` name off the tree and its already-evaluated `args`. `node.fn` comes off the serialised tree, which this design treats as data that may have been authored anywhere (see README.md's opening section), while `FunctionRegistry` is an ordinary object with `Object.prototype` on its chain. A bare index lookup would therefore resolve `toString`, `valueOf`, `constructor` and friends as though a consumer had registered them; only the registry's own keys count as registered function names. */ +export function invokeRegisteredFunction( + functions: Readonly, + fnName: string, + args: readonly ComputedValue[], +): Evaluation { + const fn = Object.hasOwn(functions, fnName) ? functions[fnName] : undefined; + if (fn === undefined) { + return indeterminate( + "wrong-type", + `no function registered under the name '${fnName}'`, + ); + } + const outcome = fn(args); + if ("domainError" in outcome) { + return indeterminate("domain-error", outcome.domainError); + } + return definite(outcome); +} diff --git a/packages/trilean/src/evaluator.arithmetic.test.ts b/packages/trilean/src/evaluator.arithmetic.test.ts index ae29edc..4c7aff5 100644 --- a/packages/trilean/src/evaluator.arithmetic.test.ts +++ b/packages/trilean/src/evaluator.arithmetic.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "vitest"; -import { evaluateValue } from "./evaluator"; +import { evaluateValue } from "./evaluator-factory"; import type { ExpressionNode } from "./tree"; import { expectDefinite, @@ -7,7 +7,7 @@ import { resolvers, } from "./evaluator-test-helpers"; -/** `arithmetic`'s own indeterminate-propagation table -- contrast this against `and`/`or`'s absorption (see truth-tables.test.ts and `combineAnd`/`combineOr` in evaluator.ts): arithmetic has no absorbing value at all, so a definite operand on one side never rescues an indeterminate operand on the other, unlike OR's absorbing `true` or AND's absorbing `false`. */ +/** `arithmetic`'s own indeterminate-propagation table -- contrast this against `and`/`or`'s absorption (see truth-tables.test.ts and `combineAnd`/`combineOr` in evaluator-operations.ts): arithmetic has no absorbing value at all, so a definite operand on one side never rescues an indeterminate operand on the other, unlike OR's absorbing `true` or AND's absorbing `false`. */ describe("arithmetic indeterminate propagation (no absorbing value)", () => { const definiteOperand: ExpressionNode = { kind: "numberLiteral", value: 1 }; const indeterminateOperand: ExpressionNode = { diff --git a/packages/trilean/src/evaluator.collections.test.ts b/packages/trilean/src/evaluator.collections.test.ts index 3b86b9b..b2d4fee 100644 --- a/packages/trilean/src/evaluator.collections.test.ts +++ b/packages/trilean/src/evaluator.collections.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { evaluateValue } from "./evaluator"; +import { evaluateValue } from "./evaluator-factory"; import type { Resolvers } from "./resolvers"; import { expectDefinite, diff --git a/packages/trilean/src/evaluator.comparison.test.ts b/packages/trilean/src/evaluator.comparison.test.ts index eba97cf..0da5ce2 100644 --- a/packages/trilean/src/evaluator.comparison.test.ts +++ b/packages/trilean/src/evaluator.comparison.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "vitest"; -import { evaluatePredicate } from "./evaluator"; +import { evaluatePredicate } from "./evaluator-factory"; import { expectDefinite, expectIndeterminate, diff --git a/packages/trilean/src/evaluator.complex-arithmetic.test.ts b/packages/trilean/src/evaluator.complex-arithmetic.test.ts index e106db2..18449cc 100644 --- a/packages/trilean/src/evaluator.complex-arithmetic.test.ts +++ b/packages/trilean/src/evaluator.complex-arithmetic.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "vitest"; -import { evaluateValue } from "./evaluator"; +import { evaluateValue } from "./evaluator-factory"; import { expectDefinite, expectIndeterminate, diff --git a/packages/trilean/src/evaluator.control-flow.test.ts b/packages/trilean/src/evaluator.control-flow.test.ts index c728d75..3a0178e 100644 --- a/packages/trilean/src/evaluator.control-flow.test.ts +++ b/packages/trilean/src/evaluator.control-flow.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createEvaluator, evaluatePredicate, evaluateValue } from "./evaluator"; +import { + createEvaluator, + evaluatePredicate, + evaluateValue, +} from "./evaluator-factory"; import type { FunctionRegistry } from "./functions"; import type { Resolvers } from "./resolvers"; import type { PredicateNode } from "./tree"; diff --git a/packages/trilean/src/evaluator.indeterminacy-cases.test.ts b/packages/trilean/src/evaluator.indeterminacy-cases.test.ts index f042ccd..ebd4b95 100644 --- a/packages/trilean/src/evaluator.indeterminacy-cases.test.ts +++ b/packages/trilean/src/evaluator.indeterminacy-cases.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createEvaluator, evaluatePredicate, evaluateValue } from "./evaluator"; +import { + createEvaluator, + evaluatePredicate, + evaluateValue, +} from "./evaluator-factory"; import type { Resolvers } from "./resolvers"; import type { PredicateNode } from "./tree"; import { diff --git a/packages/trilean/src/evaluator.integration.test.ts b/packages/trilean/src/evaluator.integration.test.ts index 69d702d..56d18f5 100644 --- a/packages/trilean/src/evaluator.integration.test.ts +++ b/packages/trilean/src/evaluator.integration.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createEvaluator, evaluatePredicate, evaluateValue } from "./evaluator"; +import { + createEvaluator, + evaluatePredicate, + evaluateValue, +} from "./evaluator-factory"; import type { Resolvers } from "./resolvers"; import type { ExpressionNode, PredicateNode } from "./tree"; import { diff --git a/packages/trilean/src/evaluator.membership.test.ts b/packages/trilean/src/evaluator.membership.test.ts index 44b2c90..fedf758 100644 --- a/packages/trilean/src/evaluator.membership.test.ts +++ b/packages/trilean/src/evaluator.membership.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { evaluatePredicate } from "./evaluator"; +import { evaluatePredicate } from "./evaluator-factory"; import type { Resolvers } from "./resolvers"; import type { ExpressionNode } from "./tree"; import { diff --git a/packages/trilean/src/evaluator.temporal.test.ts b/packages/trilean/src/evaluator.temporal.test.ts index 1cfea0c..52b10dd 100644 --- a/packages/trilean/src/evaluator.temporal.test.ts +++ b/packages/trilean/src/evaluator.temporal.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "vitest"; -import { evaluatePredicate, evaluateValue } from "./evaluator"; +import { evaluatePredicate, evaluateValue } from "./evaluator-factory"; import type { ExpressionNode } from "./tree"; import { expectDefinite, diff --git a/packages/trilean/src/evaluator.ts b/packages/trilean/src/evaluator.ts index 8d5ed3d..f2e460c 100644 --- a/packages/trilean/src/evaluator.ts +++ b/packages/trilean/src/evaluator.ts @@ -1,816 +1,45 @@ -import type { CompiledPattern } from "trilean-regex"; -import { RegexParseError, compilePattern } from "trilean-regex"; import { complexFromPolar } from "./complex"; -import type { ComputedValue, DurationUnit, Unit } from "./computed-value"; -import { - combineUnitsForDivide, - combineUnitsForMultiply, - unitsEqual, -} from "./computed-value"; +import type { ComputedValue } from "./computed-value"; import { type Evaluation, - type IndeterminateReason, definite, firstIndeterminate, indeterminate, } from "./evaluation"; +import { + type EvaluationBudget, + MAX_TREE_REFERENCE_DEPTH, +} from "./evaluator-budget"; +import { + firstFilterIndeterminate, + resolveParticipatingItems, +} from "./evaluator-collection"; +import { + applyArithmetic, + applyNegate, + checkReferenceUnit, + combineAnd, + combineOr, + compareText, + compareValues, + computeMembershipMatch, + invokeRegisteredFunction, +} from "./evaluator-operations"; import type { FunctionRegistry } from "./functions"; -import { emptyFunctionRegistry } from "./functions"; -import type { JsonValue } from "./json-value"; import type { EvaluationContext, Resolvers } from "./resolvers"; import { - type ArithmeticOperator, type ComparisonOperator, type ExpressionNode, type PredicateNode, - type TextComparisonOperator, ExpressionNodeSchema, PredicateNodeSchema, } from "./tree"; /** - * `evaluatePredicateInternal` and `evaluateValueInternal` are co-located in this one file, rather than split across `predicate-evaluator.ts`/`value-evaluator.ts`, because they are mutually recursive: a predicate leaf (`compare`, `textCompare`, `memberOf`, `exists`) holds `ExpressionNode` operands, and an expression node (`conditional`'s `when`, a fold's `filter`) holds `PredicateNode` operands. Splitting them across modules would make each half import the other, and whichever module finished loading second would see the other's export as `undefined` at its own module-evaluation time -- a genuine circular-import TDZ hazard, not merely a style preference. + * `evaluatePredicateInternal` and `evaluateValueInternal` below are co-located in this one file, rather than split across `predicate-evaluator.ts`/`value-evaluator.ts`, because they are mutually recursive: a predicate leaf (`compare`, `textCompare`, `memberOf`, `exists`) holds `ExpressionNode` operands, and an expression node (`conditional`'s `when`, a fold's `filter`) holds `PredicateNode` operands. Splitting them across modules would make each half import the other, and whichever module finished loading second would see the other's export as `undefined` at its own module-evaluation time -- a genuine circular-import TDZ hazard, not merely a style preference. `resolveParticipatingItems` (in `evaluator-collection.ts`) would share that same hazard if it imported `evaluatePredicateInternal` directly, since it calls back into it to evaluate each candidate's filter -- so it instead takes the evaluator as an injected `EvaluatePredicate` parameter (see that type's own doc comment), which is what lets it live in its own file with no circular dependency on this one. The pure, non-recursive value-combination logic (`combineAnd`/`compareValues`/`applyArithmetic` and their siblings) carries no such constraint either and lives in `evaluator-operations.ts`; the resource-limit infrastructure (`EvaluationBudget` and its constants), likewise self-contained, lives in `evaluator-budget.ts`. */ -/** The three-valued AND table: `false` is absorbing regardless of the other operand's indeterminacy; otherwise both-definite folds to a boolean AND; otherwise indeterminate, with the tie-break rule (declared operand order, left before right) applied via `firstIndeterminate`. */ -function combineAnd( - left: Evaluation, - right: Evaluation, -): Evaluation { - if (left.status === "definite" && !left.value) return definite(false); - if (right.status === "definite" && !right.value) return definite(false); - const reason = firstIndeterminate(left, right); - if (reason !== undefined) return { status: "indeterminate", reason }; - return definite(true); -} - -/** The three-valued OR table: mirror image of `combineAnd`, with `true` absorbing. */ -function combineOr( - left: Evaluation, - right: Evaluation, -): Evaluation { - if (left.status === "definite" && left.value) return definite(true); - if (right.status === "definite" && right.value) return definite(true); - const reason = firstIndeterminate(left, right); - if (reason !== undefined) return { status: "indeterminate", reason }; - return definite(false); -} - -function applyComparisonOperator( - op: ComparisonOperator, - left: number, - right: number, -): boolean { - switch (op) { - case "gt": - return left > right; - case "gte": - return left >= right; - case "lt": - return left < right; - case "lte": - return left <= right; - case "eq": - return left === right; - case "neq": - return left !== right; - default: - throw new Error("unreachable comparison operator"); - } -} - -const millisecondsPerDurationUnit: Record = { - ms: 1, - s: 1000, - min: 60_000, - h: 3_600_000, - d: 86_400_000, -}; - -/** Normalises a `duration`'s magnitude to milliseconds, the common base unit for combining or comparing two `duration`s (or an `instant` and a `duration`) of potentially different `DurationUnit`s -- shared by `compareValues`'s own `duration` branch below and by the temporal arithmetic further down. */ -function toMilliseconds(value: number, unit: DurationUnit): number { - return value * millisecondsPerDurationUnit[unit]; -} - -/** `Date.parse` reports an unparseable timestamp as `NaN`, and every downstream use of that `NaN` then fails silently rather than loudly: every comparison against it is `false` (so `neq` between two unparseable instants would come back definitely `true`), a subtraction yields a `NaN`-magnitude duration, and `new Date(NaN).toISOString()` throws a `RangeError` outright -- a thrown exception for a data-quality problem, which this design never does (see `Evaluation`'s own doc comment in evaluation.ts). An `instant` whose string will not parse is a value the operation cannot use, so every caller below reports it as `wrong-type`, exactly like any other unusable operand. */ -function toEpochMilliseconds(value: string): number | undefined { - const epochMilliseconds = Date.parse(value); - return Number.isNaN(epochMilliseconds) ? undefined : epochMilliseconds; -} - -function unparseableInstant(value: string): Evaluation { - return indeterminate( - "wrong-type", - `'${value}' is not a parseable ISO-8601 timestamp`, - ); -} - -/** The representable timestamp range is finite, so a valid instant shifted by a large enough duration lands outside it, where `new Date(...).toISOString()` throws a `RangeError`. Checking the shifted date against the platform's own notion of a valid time value keeps that inside the three-outcome model as a `domain-error` -- an operation pushed outside its valid domain, the same category as division by zero -- and avoids hardcoding the range bound. */ -function instantFromEpochMilliseconds( - epochMilliseconds: number, -): Evaluation { - const shifted = new Date(epochMilliseconds); - if (Number.isNaN(shifted.getTime())) { - return indeterminate( - "domain-error", - "the resulting instant falls outside the representable timestamp range", - ); - } - return definite({ kind: "instant", value: shifted.toISOString() }); -} - -/** `compare`'s two operands: valid kinds are `number` (matching units required), `instant` (ordered by parsed epoch millisecond), or `duration` (ordered by magnitude normalised to milliseconds) -- `text` is never valid here (see `textCompare`), and a kind mismatch between the two operands is `wrong-type`. Narrowing `right` against a literal `left.kind` case (rather than asserting it) is what lets both sides stay properly typed with no `as`. */ -function compareValues( - op: ComparisonOperator, - left: ComputedValue, - right: ComputedValue, -): Evaluation { - switch (left.kind) { - case "number": - if (right.kind !== "number") { - return indeterminate( - "wrong-type", - `cannot compare a 'number' value with a '${right.kind}' value`, - ); - } - if (!unitsEqual(left.unit, right.unit)) { - return indeterminate( - "wrong-type", - "cannot compare numbers with incompatible units", - ); - } - return definite(applyComparisonOperator(op, left.value, right.value)); - case "text": - return indeterminate( - "wrong-type", - "text values are not comparable via 'compare'; use 'textCompare'", - ); - case "boolean": - if (right.kind !== "boolean") { - return indeterminate( - "wrong-type", - `cannot compare a 'boolean' value with a '${right.kind}' value`, - ); - } - if (op !== "eq" && op !== "neq") { - return indeterminate( - "wrong-type", - `booleans have no natural ordering; '${op}' is only defined for 'eq'/'neq'`, - ); - } - return definite( - op === "eq" ? left.value === right.value : left.value !== right.value, - ); - case "instant": { - if (right.kind !== "instant") { - return indeterminate( - "wrong-type", - `cannot compare an 'instant' value with a '${right.kind}' value`, - ); - } - const leftEpoch = toEpochMilliseconds(left.value); - if (leftEpoch === undefined) return unparseableInstant(left.value); - const rightEpoch = toEpochMilliseconds(right.value); - if (rightEpoch === undefined) return unparseableInstant(right.value); - return definite(applyComparisonOperator(op, leftEpoch, rightEpoch)); - } - case "duration": - if (right.kind !== "duration") { - return indeterminate( - "wrong-type", - `cannot compare a 'duration' value with a '${right.kind}' value`, - ); - } - return definite( - applyComparisonOperator( - op, - toMilliseconds(left.value, left.unit), - toMilliseconds(right.value, right.unit), - ), - ); - // Complex operands stay kind-strict here, deliberately unlike `arithmetic`'s promotion of a real operand: arithmetic produces a value, so promoting loses nothing, whereas a comparison consumes two and this design already treats a kind difference between them as a modelling error worth surfacing (the same reason an `instant` is never compared against a plain `number`). - case "complex": - if (op !== "eq" && op !== "neq") { - return indeterminate( - "wrong-type", - `ordering operator '${op}' is not defined for complex values; the complex plane has no total order`, - ); - } - if (right.kind !== "complex") { - return indeterminate( - "wrong-type", - `cannot compare a 'complex' value with a '${right.kind}' value`, - ); - } - if (!unitsEqual(left.unit, right.unit)) { - return indeterminate( - "wrong-type", - "cannot compare complex values with incompatible units", - ); - } - return definite( - op === "eq" - ? left.re === right.re && left.im === right.im - : left.re !== right.re || left.im !== right.im, - ); - default: - throw new Error("unreachable computed-value kind"); - } -} - -/** `textCompare`'s two operands must both resolve to the `text` computed-value kind -- any other kind, on either operand, is `wrong-type` (see the `textCompare` section of README.md). `equals`/`notEquals` are exact string equality; `matches`/`notMatches` interpret `right`'s text as an ECMAScript regular expression tested against `left`'s text; `portableMatches`/`portableNotMatches` interpret it as a pattern in `trilean-regex`'s own restricted grammar instead, matched by that package's finite-automaton reference matcher rather than the host's native `RegExp` engine -- see the `textCompare` section of README.md for when to reach for the portable pair instead of the plain one. An invalid pattern is `wrong-type` rather than a thrown exception either way -- every data-quality problem stays inside the `Evaluation` result, per `Evaluation`'s own doc comment in evaluation.ts. */ -function compareText( - op: TextComparisonOperator, - left: ComputedValue, - right: ComputedValue, -): Evaluation { - if (left.kind !== "text") { - return indeterminate( - "wrong-type", - `'textCompare' requires 'text' operands; the left operand is '${left.kind}'`, - ); - } - if (right.kind !== "text") { - return indeterminate( - "wrong-type", - `'textCompare' requires 'text' operands; the right operand is '${right.kind}'`, - ); - } - switch (op) { - case "equals": - return definite(left.value === right.value); - case "notEquals": - return definite(left.value !== right.value); - case "matches": - case "notMatches": { - let pattern: RegExp; - try { - pattern = new RegExp(right.value); - } catch { - return indeterminate( - "wrong-type", - `'${right.value}' is not a valid regular expression pattern`, - ); - } - const isMatch = pattern.test(left.value); - return definite(op === "matches" ? isMatch : !isMatch); - } - case "portableMatches": - case "portableNotMatches": { - let compiled: CompiledPattern; - try { - compiled = compilePattern(right.value); - } catch (error) { - const reason = - error instanceof RegexParseError ? error.message : String(error); - return indeterminate( - "wrong-type", - `'${right.value}' is not a valid trilean-regex pattern: ${reason}`, - ); - } - const isMatch = compiled.test(left.value); - return definite(op === "portableMatches" ? isMatch : !isMatch); - } - default: - throw new Error("unreachable text comparison operator"); - } -} - -/** `memberOf`'s own value-equality test between two computed values -- kind-agnostic across `number`/`text`/`instant`/`duration` (see the `memberOf` section of README.md, and its "Derived aggregates" note that this equality is kind-agnostic across every computed-value kind), unlike `compare`'s own `eq`, which rejects a `text` operand outright and directs callers to `textCompare` instead. A kind mismatch, or a `number` pair with an incompatible unit, is `wrong-type` -- never simply "not equal". Narrowing `candidate` against a literal `operand.kind` case (rather than asserting it) is the same technique `compareValues` uses above. */ -function computeMembershipMatch( - operand: ComputedValue, - candidate: ComputedValue, -): Evaluation { - switch (operand.kind) { - case "number": - if (candidate.kind !== "number") { - return indeterminate( - "wrong-type", - `cannot compare a 'number' value with a '${candidate.kind}' value for membership`, - ); - } - if (!unitsEqual(operand.unit, candidate.unit)) { - return indeterminate( - "wrong-type", - "cannot compare numbers with incompatible units for membership", - ); - } - return definite(operand.value === candidate.value); - case "text": - if (candidate.kind !== "text") { - return indeterminate( - "wrong-type", - `cannot compare a 'text' value with a '${candidate.kind}' value for membership`, - ); - } - return definite(operand.value === candidate.value); - case "boolean": - if (candidate.kind !== "boolean") { - return indeterminate( - "wrong-type", - `cannot compare a 'boolean' value with a '${candidate.kind}' value for membership`, - ); - } - return definite(operand.value === candidate.value); - case "instant": { - if (candidate.kind !== "instant") { - return indeterminate( - "wrong-type", - `cannot compare an 'instant' value with a '${candidate.kind}' value for membership`, - ); - } - const operandEpoch = toEpochMilliseconds(operand.value); - if (operandEpoch === undefined) return unparseableInstant(operand.value); - const candidateEpoch = toEpochMilliseconds(candidate.value); - if (candidateEpoch === undefined) { - return unparseableInstant(candidate.value); - } - return definite(operandEpoch === candidateEpoch); - } - case "duration": - if (candidate.kind !== "duration") { - return indeterminate( - "wrong-type", - `cannot compare a 'duration' value with a '${candidate.kind}' value for membership`, - ); - } - return definite( - toMilliseconds(operand.value, operand.unit) === - toMilliseconds(candidate.value, candidate.unit), - ); - case "complex": - if (candidate.kind !== "complex") { - return indeterminate( - "wrong-type", - `cannot compare a 'complex' value with a '${candidate.kind}' value for membership`, - ); - } - if (!unitsEqual(operand.unit, candidate.unit)) { - return indeterminate( - "wrong-type", - "cannot compare complex values with incompatible units for membership", - ); - } - return definite( - operand.re === candidate.re && operand.im === candidate.im, - ); - default: - throw new Error("unreachable computed-value kind"); - } -} - -/** `power`/`modulo` have no defined unit-combination rule in this design (unlike `add`/`subtract`'s "identical units" requirement or `multiply`/`divide`'s dimensional-exponent combination) -- scoping them to dimensionless operands avoids inventing an unspecified unit-scaling semantics for a fractional or runtime-determined exponent. */ -function isDimensionless(unit: Unit | undefined): boolean { - return unitsEqual(unit, undefined); -} - -/** `negate` is never sugar for "zero minus the value" (see the `arithmetic`/`negate` section of README.md) -- a `duration`'s magnitude is negated directly, in its own original unit, with no subtraction or millisecond normalisation involved. */ -function applyNegate(operand: ComputedValue): Evaluation { - switch (operand.kind) { - case "number": - return definite({ - kind: "number", - value: -operand.value, - unit: operand.unit, - }); - case "duration": - return definite({ - kind: "duration", - value: -operand.value, - unit: operand.unit, - }); - case "complex": - return definite({ - kind: "complex", - re: -operand.re, - im: -operand.im, - unit: operand.unit, - }); - case "text": - case "instant": - case "boolean": - return indeterminate( - "wrong-type", - `cannot negate a '${operand.kind}' value`, - ); - default: - throw new Error("unreachable computed-value kind"); - } -} - -function applyArithmeticOnNumbers( - op: ArithmeticOperator, - left: Extract, - right: Extract, -): Evaluation { - switch (op) { - case "add": - if (!unitsEqual(left.unit, right.unit)) { - return indeterminate( - "wrong-type", - "cannot add numbers with incompatible units", - ); - } - return definite({ - kind: "number", - value: left.value + right.value, - unit: left.unit, - }); - case "subtract": - if (!unitsEqual(left.unit, right.unit)) { - return indeterminate( - "wrong-type", - "cannot subtract numbers with incompatible units", - ); - } - return definite({ - kind: "number", - value: left.value - right.value, - unit: left.unit, - }); - case "multiply": - return definite({ - kind: "number", - value: left.value * right.value, - unit: combineUnitsForMultiply(left.unit, right.unit), - }); - case "divide": - if (right.value === 0) { - return indeterminate("domain-error", "division by zero"); - } - return definite({ - kind: "number", - value: left.value / right.value, - unit: combineUnitsForDivide(left.unit, right.unit), - }); - case "power": - if (!isDimensionless(left.unit) || !isDimensionless(right.unit)) { - return indeterminate( - "wrong-type", - "'power' requires dimensionless operands", - ); - } - if (left.value < 0 && !Number.isInteger(right.value)) { - return indeterminate( - "domain-error", - "a negative base raised to a non-integer power is not a real number", - ); - } - // A zero base with a negative exponent is a division by zero written the other way round, so it belongs in the same domain-error category rather than escaping as a definite (infinite) result. - if (left.value === 0 && right.value < 0) { - return indeterminate( - "domain-error", - "zero raised to a negative power is a division by zero", - ); - } - return definite({ kind: "number", value: left.value ** right.value }); - case "modulo": - if (!isDimensionless(left.unit) || !isDimensionless(right.unit)) { - return indeterminate( - "wrong-type", - "'modulo' requires dimensionless operands", - ); - } - if (right.value === 0) { - return indeterminate("domain-error", "modulo by zero"); - } - return definite({ kind: "number", value: left.value % right.value }); - default: - throw new Error("unreachable arithmetic operator"); - } -} - -/** The only same-kind, non-`number` arithmetic this design defines: two `duration`s combine by normalising both to milliseconds first (see `toMilliseconds`), reporting the result in milliseconds -- `multiply`/`divide`/`power`/`modulo` have no representable result unit for a `duration` squared or a dimensionless ratio, so they are `wrong-type` rather than invented. */ -function applyArithmeticOnDurations( - op: ArithmeticOperator, - left: Readonly>, - right: Readonly>, -): Evaluation { - switch (op) { - case "add": - return definite({ - kind: "duration", - value: - toMilliseconds(left.value, left.unit) + - toMilliseconds(right.value, right.unit), - unit: "ms", - }); - case "subtract": - return definite({ - kind: "duration", - value: - toMilliseconds(left.value, left.unit) - - toMilliseconds(right.value, right.unit), - unit: "ms", - }); - case "multiply": - case "divide": - case "power": - case "modulo": - return indeterminate( - "wrong-type", - `arithmetic operator '${op}' is not defined between two 'duration' values`, - ); - default: - throw new Error("unreachable arithmetic operator"); - } -} - -/** Every real number is a complex number with a zero imaginary part, so promoting one is exact and total -- unlike the temporal cross-kind combinations above, which each had to be enumerated because no such embedding exists between an `instant` and a `duration`. This is what lets a tree mix real and complex terms freely instead of forcing every real literal to be written as a complex one. */ -function toComplexValue( - value: Readonly>, -): Extract { - if (value.kind === "complex") return value; - return { kind: "complex", re: value.value, im: 0, unit: value.unit }; -} - -/** The complex product, as its own component-level helper rather than only a branch of the operator switch below, because `power`'s repeated multiplication needs exactly this and must not re-derive it. */ -function multiplyComplexValues( - left: Readonly>, - right: Readonly>, -): Extract { - return { - kind: "complex", - re: left.re * right.re - left.im * right.im, - im: left.re * right.im + left.im * right.re, - unit: combineUnitsForMultiply(left.unit, right.unit), - }; -} - -/** Complex arithmetic, over the one canonical rectangular representation the `complex` kind stores (see the "Complex values" section of README.md). `add`/`subtract` are component-wise and carry the same identical-units requirement real numbers already have. `power` is deliberately excluded from this operator set rather than handled and rejected here: its exponent must stay an un-promoted real number, so the dispatcher routes it to `applyComplexPower` first, and excluding it from the type is what makes the compiler enforce that routing instead of leaving a dead branch behind. */ -function applyArithmeticOnComplex( - op: Exclude, - left: Readonly>, - right: Readonly>, -): Evaluation { - switch (op) { - case "add": - if (!unitsEqual(left.unit, right.unit)) { - return indeterminate( - "wrong-type", - "cannot add complex values with incompatible units", - ); - } - return definite({ - kind: "complex", - re: left.re + right.re, - im: left.im + right.im, - unit: left.unit, - }); - case "subtract": - if (!unitsEqual(left.unit, right.unit)) { - return indeterminate( - "wrong-type", - "cannot subtract complex values with incompatible units", - ); - } - return definite({ - kind: "complex", - re: left.re - right.re, - im: left.im - right.im, - unit: left.unit, - }); - case "multiply": - return definite(multiplyComplexValues(left, right)); - case "divide": { - // Multiplying both sides by the divisor's conjugate makes the denominator the real |divisor|^2, which is what turns a complex quotient into two ordinary real divisions. - const divisorSquaredMagnitude = right.re * right.re + right.im * right.im; - // Zero is the one complex value with no reciprocal, and it is zero in *both* components -- a divisor with only a zero real part (a purely imaginary one) divides perfectly well. - if (divisorSquaredMagnitude === 0) { - return indeterminate("domain-error", "division by zero"); - } - return definite({ - kind: "complex", - re: (left.re * right.re + left.im * right.im) / divisorSquaredMagnitude, - im: (left.im * right.re - left.re * right.im) / divisorSquaredMagnitude, - unit: combineUnitsForDivide(left.unit, right.unit), - }); - } - // Unlike the `wrong-type` cases elsewhere in this design, which mean "an answer exists but this operator does not accept this operand", modulo has no answer to accept: a remainder needs a canonical notion of "how many whole divisors fit", and the complex plane has no ordering to provide one. That is a genuine domain violation, the same category as division by zero. - case "modulo": - return indeterminate( - "domain-error", - "'modulo' is undefined for complex values", - ); - default: - throw new Error("unreachable arithmetic operator"); - } -} - -/** `power` with a complex operand on either side, defined for exactly one case: a real integer exponent, evaluated as the repeated multiplication that integer exponentiation *is* (see the "Complex values" section of README.md for why an arbitrary complex exponent stays out of scope). */ -function applyComplexPower( - base: Readonly>, - exponent: Readonly>, -): Evaluation { - if (exponent.kind !== "number" || !Number.isInteger(exponent.value)) { - return indeterminate( - "wrong-type", - "'power' with a complex operand requires a real integer exponent", - ); - } - if (!isDimensionless(base.unit) || !isDimensionless(exponent.unit)) { - return indeterminate( - "wrong-type", - "'power' requires dimensionless operands", - ); - } - const complexUnit: Extract = { - kind: "complex", - re: 1, - im: 0, - unit: {}, - }; - const complexBase = toComplexValue(base); - let repeatedProduct = complexUnit; - for (let applied = 0; applied < Math.abs(exponent.value); applied += 1) { - repeatedProduct = multiplyComplexValues(repeatedProduct, complexBase); - } - if (exponent.value >= 0) return definite(repeatedProduct); - // A negative exponent is the reciprocal of the positive one by definition, so it reuses the division above rather than re-deriving it -- which also means a zero base inherits that operator's own division-by-zero domain-error instead of needing its own check. - return applyArithmeticOnComplex("divide", complexUnit, repeatedProduct); -} - -/** - * Dispatches `arithmetic` by operand kind. The three cross-kind temporal combinations this design defines (`instant - instant`, `instant + duration`, `duration + instant`) are checked explicitly first, in that order, against the exact operator each requires; any other combination touching an `instant` is `wrong-type` (see "Temporal values" in README.md -- e.g. adding two instants, or subtracting a `duration` from an `instant`, are deliberately *not* defined). Same-kind `duration`/`duration` combinations are delegated to `applyArithmeticOnDurations`; a `duration` paired with anything other than an `instant` or another `duration` is `wrong-type`. Everything remaining requires two `number` operands. - */ -function applyArithmetic( - op: ArithmeticOperator, - left: ComputedValue, - right: ComputedValue, -): Evaluation { - if ( - left.kind === "instant" && - right.kind === "instant" && - op === "subtract" - ) { - const leftEpoch = toEpochMilliseconds(left.value); - if (leftEpoch === undefined) return unparseableInstant(left.value); - const rightEpoch = toEpochMilliseconds(right.value); - if (rightEpoch === undefined) return unparseableInstant(right.value); - return definite({ - kind: "duration", - value: leftEpoch - rightEpoch, - unit: "ms", - }); - } - if (left.kind === "instant" && right.kind === "duration" && op === "add") { - const leftEpoch = toEpochMilliseconds(left.value); - if (leftEpoch === undefined) return unparseableInstant(left.value); - return instantFromEpochMilliseconds( - leftEpoch + toMilliseconds(right.value, right.unit), - ); - } - if (left.kind === "duration" && right.kind === "instant" && op === "add") { - const rightEpoch = toEpochMilliseconds(right.value); - if (rightEpoch === undefined) return unparseableInstant(right.value); - return instantFromEpochMilliseconds( - rightEpoch + toMilliseconds(left.value, left.unit), - ); - } - if (left.kind === "instant" || right.kind === "instant") { - return indeterminate( - "wrong-type", - `arithmetic operator '${op}' is not defined between a '${left.kind}' and a '${right.kind}' value`, - ); - } - if (left.kind === "duration" && right.kind === "duration") { - return applyArithmeticOnDurations(op, left, right); - } - if (left.kind === "duration" || right.kind === "duration") { - return indeterminate( - "wrong-type", - `arithmetic operator '${op}' is not defined between a '${left.kind}' and a '${right.kind}' value`, - ); - } - if (left.kind !== "number" && left.kind !== "complex") { - return indeterminate( - "wrong-type", - `arithmetic requires numeric operands; got a '${left.kind}' value`, - ); - } - if (right.kind !== "number" && right.kind !== "complex") { - return indeterminate( - "wrong-type", - `arithmetic requires numeric operands; got a '${right.kind}' value`, - ); - } - if (left.kind === "complex" || right.kind === "complex") { - if (op === "power") return applyComplexPower(left, right); - // Both operands are `number` or `complex` by this point, so promoting the real side is always possible; the result is complex whenever either operand is, regardless of what the components turn out to be. - return applyArithmeticOnComplex( - op, - toComplexValue(left), - toComplexValue(right), - ); - } - return applyArithmeticOnNumbers(op, left, right); -} - -/** A defense-in-depth guard against a long acyclic `treeReference` chain exhausting the call stack -- distinct from, and layered on top of, the cycle detector below (`visitedTreeKeys`), which catches an actual repeat immediately and more precisely. */ -const MAX_TREE_REFERENCE_DEPTH = 100; - -/** Default cap on the total number of predicate/expression nodes a single `evaluatePredicate`/`evaluateValue` call may visit -- see `createEvaluator`'s `maxNodes` option, threaded through to `createEvaluationBudget` below. Chosen generously above any realistic hand-authored business-rule, eligibility, or formula tree (see README.md's own consumer use cases), while still bounding an untrusted, adversarially-authored tree's total evaluation cost to a fixed, small amount of work regardless of how it is shaped. */ -const DEFAULT_MAX_EVALUATION_NODES = 10_000; - -/** Default cap on ordinary recursive-descent nesting depth -- see `createEvaluator`'s `maxNestingDepth` option, threaded through to `createEvaluationBudget` below. Set comfortably below the depth at which this evaluator's own recursive descent has been observed to exhaust the host call stack (a chain of plain `not` nodes wrapped around a single leaf, evaluated directly against this file under Node.js, failed consistently somewhere in the low thousands of nesting levels, with some run-to-run variance from whatever else already occupied the stack), while remaining far deeper than any legitimate hand-authored tree is ever likely to nest. Kept well clear of that measured failure point because the exact threshold varies by host runtime (a Cloudflare Workers isolate's own stack is smaller than Node's) and by how much of the stack the rest of the call chain has already used. */ -const DEFAULT_MAX_NESTING_DEPTH = 500; - -/** - * A single `evaluatePredicate`/`evaluateValue` call's resource limits, independent of and layered underneath `MAX_TREE_REFERENCE_DEPTH`'s own cross-tree chain guard above: that guard only advances at an actual `treeReference` resolution and says nothing about a plain, self-contained tree built from ordinary `and`/`or`/`fold`/`conditional`/quantifier nesting, with no `treeReference` node anywhere in it. This closes that gap for a consumer evaluating a tree it did not author and cannot fully trust before evaluation (e.g. a tree embedded in a signed but otherwise attacker-controlled payload). - * - * Exposed as a single `checkNode` method rather than a raw mutable counter, following the same all-callback shape `Resolvers` and `FunctionRegistry` already use elsewhere in this file: every recursive call site threads this object through as `Readonly`, and that wrapper genuinely prevents tampering because the running node count lives in `createEvaluationBudget`'s own closure, never as an assignable property on the object itself. - */ -interface EvaluationBudget { - /** Charges one node visit and checks both caps, returning the `Evaluation` to return immediately if either is now exceeded, or `undefined` if evaluation of this node may proceed. Called as the very first action inside `evaluatePredicateInternal`/`evaluateValueInternal`, before any of that node's own work or further recursion, so an exceeded budget is discovered before it can be spent on additional descent. */ - checkNode: (nestingDepth: number) => Evaluation | undefined; -} - -/** Constructs a fresh `EvaluationBudget` for one top-level `evaluatePredicate`/`evaluateValue` call -- see `createEvaluator`'s `maxNodes`/`maxNestingDepth` options, which supply `maxNodes`/`maxNestingDepth` here. A fresh closure per call is what keeps `nodesVisited` from leaking between unrelated evaluations. */ -function createEvaluationBudget( - maxNodes: number, - maxNestingDepth: number, -): EvaluationBudget { - /** Total predicate/expression nodes visited so far across the whole call -- shared by every branch of an `and`/`or`/`allOf`/`anyOf`/fold/quantifier through the closure below, so it accumulates across the whole traversal rather than resetting per branch. */ - let nodesVisited = 0; - return { - checkNode(nestingDepth) { - nodesVisited += 1; - if (nodesVisited > maxNodes) { - return indeterminate( - "domain-error", - `evaluation exceeded the maximum of ${maxNodes.toString()} nodes visited in a single call (resource exhausted)`, - ); - } - // Distinct from `MAX_TREE_REFERENCE_DEPTH`'s own chain-depth counter: `nestingDepth` advances on every recursive descent into a child predicate/expression node, not only at `treeReference` resolution. - if (nestingDepth >= maxNestingDepth) { - return indeterminate( - "domain-error", - `evaluation nesting depth exceeds the maximum of ${maxNestingDepth.toString()} (resource exhausted)`, - ); - } - return undefined; - }, - }; -} - -/** A collection candidate paired with its own pre-filter outcome: `"include"`/`"exclude"` when `filter` resolved definitely, or the filter's own indeterminate `Evaluation` when it did not (there is no third, definite-but-neither branch -- see `resolveParticipatingItems` below). */ -interface ResolvedCollectionItem { - readonly item: unknown; - readonly filterOutcome: "include" | "exclude" | Evaluation; -} - -/** - * Collection resolution shared by the quantifiers (`some`/`every`, below) and by `fold` (a later phase): resolves the opaque `collection` reference to its concrete candidate list via `resolvers.resolveCollection`, then evaluates each candidate's optional `filter` with that candidate as its own evaluation context and the accumulator reset to `undefined` -- see the README's "Evaluation context" and "Pre-filtering which items participate" sections. Deliberately stops short of deciding how an indeterminate filter combines with the rest of the surrounding node: `some`/`every` fold a filter-indeterminate item in as its own vote via the surrounding OR/AND absorption, while `fold` has no absorbing value at all and goes indeterminate outright on the same condition -- the two callers need genuinely different combination logic over these same per-item outcomes, so this helper only produces the outcomes and leaves combining them to the caller. - */ -async function resolveParticipatingItems( - collection: JsonValue, - filter: PredicateNode | undefined, - context: EvaluationContext, - resolvers: Readonly, - functions: Readonly, - visitedTreeKeys: ReadonlySet, - treeReferenceDepth: number, - budget: Readonly, - nestingDepth: number, -): Promise { - const candidates = await resolvers.resolveCollection(collection, context); - return Promise.all( - candidates.map(async (item): Promise => { - if (filter === undefined) return { item, filterOutcome: "include" }; - const filterResult = await evaluatePredicateInternal( - filter, - item, - resolvers, - undefined, - functions, - visitedTreeKeys, - treeReferenceDepth, - budget, - nestingDepth + 1, - ); - if (filterResult.status === "indeterminate") { - return { item, filterOutcome: filterResult }; - } - return { - item, - filterOutcome: filterResult.value ? "include" : "exclude", - }; - }), - ); -} - -/** The first participating item (in declared collection order) whose `filter` evaluation was itself indeterminate, or `undefined` if every participating item's filter resolved definitely -- a filter-excluded item's own `"exclude"` outcome never counts here. Used only by `fold`, which -- unlike `some`/`every`'s OR/AND absorption -- has no absorbing value at all: any participating item's indeterminate filter makes the whole fold indeterminate outright, with no other item's outcome able to override it. */ -function firstFilterIndeterminate( - participating: readonly ResolvedCollectionItem[], -): IndeterminateReason | undefined { - for (const { filterOutcome } of participating) { - if (filterOutcome === "include" || filterOutcome === "exclude") continue; - if (filterOutcome.status === "indeterminate") return filterOutcome.reason; - } - return undefined; -} - -async function evaluatePredicateInternal( +export async function evaluatePredicateInternal( node: PredicateNode, context: EvaluationContext, resolvers: Readonly, @@ -1065,6 +294,7 @@ async function evaluatePredicateInternal( case "some": case "every": { const participating = await resolveParticipatingItems( + evaluatePredicateInternal, node.collection, node.filter, context, @@ -1153,7 +383,7 @@ async function evaluatePredicateInternal( } } -async function evaluateValueInternal( +export async function evaluateValueInternal( node: ExpressionNode, context: EvaluationContext, resolvers: Readonly, @@ -1175,24 +405,8 @@ async function evaluateValueInternal( `no value found for reference key ${JSON.stringify(node.key)}`, ); } - if (node.unit !== undefined) { - // `complex` counts as numeric here alongside `number`: it carries a `unit` of its own for exactly the same dimensional-analysis reason, so a reference to a complex-valued quantity can declare what it expects like any other. - if ( - resolution.value.kind !== "number" && - resolution.value.kind !== "complex" - ) { - return indeterminate( - "wrong-type", - "a unit was expected on a reference that resolved to a non-numeric value", - ); - } - if (!unitsEqual(node.unit, resolution.value.unit)) { - return indeterminate( - "wrong-type", - "the resolved value's unit does not match the reference's expected unit", - ); - } - } + const unitError = checkReferenceUnit(node.unit, resolution.value); + if (unitError !== undefined) return unitError; return definite(resolution.value); } case "accumulator": { @@ -1225,21 +439,7 @@ async function evaluateValueInternal( if (result.status === "indeterminate") return result; args.push(result.value); } - // `node.fn` comes off the serialised tree, which this design treats as data that may have been authored anywhere (see README.md's opening section), while `FunctionRegistry` is an ordinary object with `Object.prototype` on its chain. A bare index lookup would therefore resolve `toString`, `valueOf`, `constructor` and friends as though a consumer had registered them; only the registry's own keys count as registered function names. - const fn = Object.hasOwn(functions, node.fn) - ? functions[node.fn] - : undefined; - if (fn === undefined) { - return indeterminate( - "wrong-type", - `no function registered under the name '${node.fn}'`, - ); - } - const outcome = fn(args); - if ("domainError" in outcome) { - return indeterminate("domain-error", outcome.domainError); - } - return definite(outcome); + return invokeRegisteredFunction(functions, node.fn, args); } case "numberLiteral": return definite({ kind: "number", value: node.value, unit: node.unit }); @@ -1449,6 +649,7 @@ async function evaluateValueInternal( } case "fold": { const participating = await resolveParticipatingItems( + evaluatePredicateInternal, node.collection, node.filter, context, @@ -1610,57 +811,4 @@ async function evaluateValueInternal( } } -/** - * Builds a bound `{ evaluatePredicate, evaluateValue }` pair over a caller-supplied function registry for `call` nodes -- the registry is bound once, at construction time, unlike `resolvers`, which are supplied fresh to every call. `maxNodes` and `maxNestingDepth` configure that same pair's own `EvaluationBudget` (see its doc comment above), created fresh for every top-level `evaluatePredicate`/`evaluateValue` invocation so budgets never leak between unrelated calls. The bare module-level `evaluatePredicate`/`evaluateValue` exports below are `createEvaluator({})`'s output, so both caps default to `DEFAULT_MAX_EVALUATION_NODES`/`DEFAULT_MAX_NESTING_DEPTH` for every caller that does not explicitly configure them. - */ -export function createEvaluator({ - functions = emptyFunctionRegistry, - maxNodes = DEFAULT_MAX_EVALUATION_NODES, - maxNestingDepth = DEFAULT_MAX_NESTING_DEPTH, -}: { - functions?: FunctionRegistry; - /** Caps the total number of predicate/expression nodes a single `evaluatePredicate`/`evaluateValue` call may visit -- see `createEvaluationBudget`. Defaults to `DEFAULT_MAX_EVALUATION_NODES`. */ - maxNodes?: number; - /** Caps ordinary recursive-descent nesting depth, independent of `MAX_TREE_REFERENCE_DEPTH`'s own `treeReference` chain-depth cap -- see `createEvaluationBudget`. Defaults to `DEFAULT_MAX_NESTING_DEPTH`. */ - maxNestingDepth?: number; -}): { - evaluatePredicate: ( - node: PredicateNode, - context: EvaluationContext, - resolvers: Readonly, - ) => Promise>; - evaluateValue: ( - node: ExpressionNode, - context: EvaluationContext, - resolvers: Readonly, - ) => Promise>; -} { - return { - evaluatePredicate: async (node, context, resolvers) => - evaluatePredicateInternal( - node, - context, - resolvers, - undefined, - functions, - new Set(), - 0, - createEvaluationBudget(maxNodes, maxNestingDepth), - 0, - ), - evaluateValue: async (node, context, resolvers) => - evaluateValueInternal( - node, - context, - resolvers, - undefined, - functions, - new Set(), - 0, - createEvaluationBudget(maxNodes, maxNestingDepth), - 0, - ), - }; -} - -export const { evaluatePredicate, evaluateValue } = createEvaluator({}); +// `createEvaluator` (and the bare module-level `evaluatePredicate`/`evaluateValue` it produces for a default, empty function registry) lives in `evaluator-factory.ts`, a thin caller-facing wrapper over `evaluatePredicateInternal`/`evaluateValueInternal` above -- it only ever calls into this module, never the reverse, so keeping it separate carries none of this file's own circular-import constraint. diff --git a/packages/trilean/src/evaluator.values.test.ts b/packages/trilean/src/evaluator.values.test.ts index 6ea7be6..4471a40 100644 --- a/packages/trilean/src/evaluator.values.test.ts +++ b/packages/trilean/src/evaluator.values.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { evaluatePredicate, evaluateValue } from "./evaluator"; +import { evaluatePredicate, evaluateValue } from "./evaluator-factory"; import type { Resolvers } from "./resolvers"; import { expectDefinite, diff --git a/packages/trilean/src/functions.ts b/packages/trilean/src/functions.ts index 3bd50ff..1310646 100644 --- a/packages/trilean/src/functions.ts +++ b/packages/trilean/src/functions.ts @@ -1,7 +1,7 @@ import type { ComputedValue } from "./computed-value"; /** - * Named functions available to a `call` expression node, supplied once at evaluator construction time (see `createEvaluator` in evaluator.ts) rather than per evaluation call. A function reports a domain violation (e.g. `squareRoot` given a negative number) by returning a `domainError` rather than throwing -- this keeps `call` inside the same three-outcome model as every other node kind. + * Named functions available to a `call` expression node, supplied once at evaluator construction time (see `createEvaluator` in evaluator-factory.ts) rather than per evaluation call. A function reports a domain violation (e.g. `squareRoot` given a negative number) by returning a `domainError` rather than throwing -- this keeps `call` inside the same three-outcome model as every other node kind. */ export type FunctionRegistry = Record< string, diff --git a/packages/trilean/src/golden-examples.test.ts b/packages/trilean/src/golden-examples.test.ts index 38899f7..86a4f20 100644 --- a/packages/trilean/src/golden-examples.test.ts +++ b/packages/trilean/src/golden-examples.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { evaluatePredicate } from "./evaluator"; +import { evaluatePredicate } from "./evaluator-factory"; import { goldenExampleData, goldenExampleDataEmptyItems, diff --git a/packages/trilean/src/index.ts b/packages/trilean/src/index.ts index 33890b7..a50623d 100644 --- a/packages/trilean/src/index.ts +++ b/packages/trilean/src/index.ts @@ -29,7 +29,11 @@ export type { export type { FunctionRegistry } from "./functions"; export { emptyFunctionRegistry } from "./functions"; -export { createEvaluator, evaluatePredicate, evaluateValue } from "./evaluator"; +export { + createEvaluator, + evaluatePredicate, + evaluateValue, +} from "./evaluator-factory"; export { and, diff --git a/packages/trilean/src/truth-tables.test.ts b/packages/trilean/src/truth-tables.test.ts index bfe848f..7cb5c17 100644 --- a/packages/trilean/src/truth-tables.test.ts +++ b/packages/trilean/src/truth-tables.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { evaluatePredicate } from "./evaluator"; +import { evaluatePredicate } from "./evaluator-factory"; import type { Evaluation } from "./evaluation"; import type { PredicateNode } from "./tree"; import type { Resolvers } from "./resolvers"; diff --git a/packages/trilean/test/workers/trilean.test.ts b/packages/trilean/test/workers/trilean.test.ts index 07cf552..47c72d1 100644 --- a/packages/trilean/test/workers/trilean.test.ts +++ b/packages/trilean/test/workers/trilean.test.ts @@ -5,7 +5,7 @@ import { prefixPattern, wildcardPattern, } from "../../src/derived-patterns"; -import { evaluatePredicate, evaluateValue } from "../../src/evaluator"; +import { evaluatePredicate, evaluateValue } from "../../src/evaluator-factory"; import { goldenExampleData, goldenExampleDataEmptyItems, From 14446a061dfb7319e6afe2512f085458c947c4c7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:25:49 +0100 Subject: [PATCH 9/9] chore: bump the pinned package manager from pnpm 11.6.0 to 12.4.1 Runs corepack use pnpm@12.4.1 to update the packageManager field and regenerate the lockfile's packageManagerDependencies entries for the new engine. lint, typecheck, test (unit/integration/smoke/workers), and build all still pass unchanged under the new version. --- package.json | 2 +- pnpm-lock.yaml | 158 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index bfe30da..3f811d7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "engines": { "node": ">=20" }, - "packageManager": "pnpm@11.6.0", + "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", "scripts": { "build": "turbo run _build", "lint": "turbo run _lint", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2781b3..4a6f8c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.4.1 + version: 12.4.1 + +packages: + + '@pnpm/exe.android-arm64@12.4.1': + resolution: {integrity: sha512-/HwsqXMSmlOfgtV9+O0ratzjV6Vd/8n1hh4rGHpCGmDURvt52MwZxdbhyxP4K03ZfvgSDvotFPr8O+RzE5Eu8A==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.4.1': + resolution: {integrity: sha512-+l74Qb4c2YjOzNKHXJLg+1wr8xHM1ckkUhnU5KRUK9TJqiiczt6yeTZqqzHIlJ8i6pAoj5V0OJevDRwnQKLgrQ==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.4.1': + resolution: {integrity: sha512-6rkZkT3iGfaxknUdGHraqSWFvTa6N0ajAHluv9Ax0GRWs0sIcGNiFhDopv6xSZCsJZmG483aNS/b6UEDy3blfw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.4.1': + resolution: {integrity: sha512-Vb1CHlR88HghC1qUxjxjs82zQSXnXacPD+btG2CmG8Q/hBU7Q0/b2YWJKSQqZXicunV5khFtfTlAwJddV9RkYA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.4.1': + resolution: {integrity: sha512-iT3iHz3Nl0Sxxj7UPOtZ/aQ81AFsQhjoeWMAlPkSRO04gsgDGAXv3UYxOFaesMWsfNxaGn0A+CITbuCHFF66FA==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.4.1': + resolution: {integrity: sha512-aBooZfNXM5f+OGUgCAMFWpE/kAhsWfvmqIyMtHy6zl3aNxyInWrcY/Saln/UElzo7lZWMC0Yktroxd/2J26lNQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.4.1': + resolution: {integrity: sha512-TlOdacTTP09BgcMvwWBFRsu8VAjfwqwnslBj+XSq1JFM3ck4f3k+1O/747EuctxZxs3/o785b6Q3s7Pd92Ptsg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.4.1': + resolution: {integrity: sha512-r/ab/MlIBo75oizUP5ITiziCCrnXz4SJwfErLQ+603AshB+Yq7xTMCoMtzTSCAMu6aaymIKkAFtZvd2J75Wq0w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.4.1': + resolution: {integrity: sha512-C/D1QWdKMiB8+wv/spl1rGITFUuqC+aI/fb6h8NB5sqxsOaGXeYc/g891oCuzggHn6SODk9p+I09hI76x/cMNw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.4.1': + resolution: {integrity: sha512-nxz5zD4yXt94uzbStDk0QTPKW+aE92hH1b5tFXK9ctB1HE9Xcq1vjTiA2LsZMFC6p4gdu5OwQeFqYNAVGa6QlA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.4.1': + resolution: {integrity: sha512-5AwgFdGhVUg2kIweYGfxzSLEHiIG77PQhZAkXC3TwofQHsu1Wr+TrV5/rNX2PopFnHRzuE581zoB8F6Wle32yg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.4.1': + resolution: {integrity: sha512-FJOZuuuQMhp0oLzBtcKkLXknBI92hfkmSnlKc47vfin4HrmfID5khY2lGekL9tCzk1cpR+HShEw/meFl+nHtzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.4.1': + resolution: {integrity: sha512-OO7eKBL9S+xk5hRy+JUUZSNJknGuGe3GEUffsrlC2LbqKF02g+VX1anGIzSbJDvkkdnpJhSlxF5AUz2JzJu2Jw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.4.1': + resolution: {integrity: sha512-x7gJHZgHo6hp354xCYA2NvoFzYJkHwovt2kVsUBK5EmXULLcP51JGMq09CX+5FRFJUZFKoXjLu3mZWmfP7o6PQ==} + cpu: [x64] + os: [win32] + + pnpm@12.4.1: + resolution: {integrity: sha512-LoHjmdc/6DkNqyXgaqeIq3pZCCSNL1o3D4K0gRR6ano2e/gEj5pv22Rg8hpm8FQt7bi5TKLIcjWWdBkgsWVtTA==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.4.1': + optional: true + + '@pnpm/exe.android-x64@12.4.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.4.1': + optional: true + + '@pnpm/exe.darwin-x64@12.4.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.4.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.4.1': + optional: true + + '@pnpm/exe.linux-arm64@12.4.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.4.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.4.1': + optional: true + + '@pnpm/exe.linux-s390x@12.4.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.4.1': + optional: true + + '@pnpm/exe.linux-x64@12.4.1': + optional: true + + '@pnpm/exe.win32-arm64@12.4.1': + optional: true + + '@pnpm/exe.win32-x64@12.4.1': + optional: true + + pnpm@12.4.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.4.1 + '@pnpm/exe.android-x64': 12.4.1 + '@pnpm/exe.darwin-arm64': 12.4.1 + '@pnpm/exe.darwin-x64': 12.4.1 + '@pnpm/exe.freebsd-x64': 12.4.1 + '@pnpm/exe.linux-arm64': 12.4.1 + '@pnpm/exe.linux-arm64-musl': 12.4.1 + '@pnpm/exe.linux-ppc64': 12.4.1 + '@pnpm/exe.linux-riscv64': 12.4.1 + '@pnpm/exe.linux-s390x': 12.4.1 + '@pnpm/exe.linux-x64': 12.4.1 + '@pnpm/exe.linux-x64-musl': 12.4.1 + '@pnpm/exe.win32-arm64': 12.4.1 + '@pnpm/exe.win32-x64': 12.4.1 + +--- lockfileVersion: '9.0' settings: