diff --git a/.github/actions/setup-workspace/action.yml b/.github/actions/setup-workspace/action.yml new file mode 100644 index 0000000..ca24c5e --- /dev/null +++ b/.github/actions/setup-workspace/action.yml @@ -0,0 +1,45 @@ +name: Setup workspace +description: Set up pnpm + Node, install the workspace from the frozen lockfile, restore turbo's local cache, then run a command. Requires checkout before use. + +inputs: + task: + description: Task identifier, used for the step name and as part of the turbo cache key (lint, typecheck, test, build, release). + required: true + command: + description: Shell command to execute after install. + required: true + github-token: + description: GitHub token for commands that need it (e.g. the release orchestrator). + required: false + default: "" + +runs: + using: composite + steps: + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v7 + with: + # A single source of truth for the whole workspace, rather than a version literal repeated in every job. registry-url is deliberately absent everywhere: setting it makes setup-node write an .npmrc containing an _authToken line, and that line wins over the OIDC token exchange -- so the setting that looks like it configures the registry is exactly the one that would stop trusted publishing working. + node-version-file: .tool-versions + cache: pnpm + + - run: pnpm install --frozen-lockfile + shell: bash + + # turbo's local filesystem cache, keyed per task. A package whose inputs (per turbo.json) are unchanged since a prior run on this lockfile/pipeline generation replays that task's recorded result -- including its declared outputs (e.g. dist/**, .eslintcache) -- instead of re-running tsdown/tsc/eslint/vitest. + # + # hashFiles(pnpm-lock.yaml, turbo.json) puts the dependency set and the task graph itself into the key, so either changing starts a fresh cache lineage rather than inheriting a stale one; github.run_id keeps every run's own save key unique. actions/cache treats a key as immutable -- a save under a key that already exists is silently skipped -- so without run_id, only the very first run for a given lockfile/turbo.json pair would ever actually save anything, and no later run could repair or extend a cache left behind by a partially failed one. The two-level restore-keys then prefers a cache from the same lockfile/turbo.json generation and only falls back to the newest cache for this task when that generation has no entry yet. + - uses: actions/cache@v6 + with: + path: .turbo + key: turbo-${{ inputs.task }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}-${{ github.run_id }} + restore-keys: | + turbo-${{ inputs.task }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}- + turbo-${{ inputs.task }}-${{ runner.os }}- + + - name: Run ${{ inputs.task }} + run: ${{ inputs.command }} + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 09ae9f5..489346f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,11 @@ version: 2 + updates: - package-ecosystem: "npm" - directory: "/" + # `directories` (plural, glob-capable) covers the workspace root's own manifest plus every package's, in one entry rather than one per manifest -- a `directory: "/"` entry alone would leave every dependency declared inside packages/* unwatched. + directories: + - "/" + - "/packages/*" schedule: interval: "daily" cooldown: @@ -9,3 +13,22 @@ updates: commit-message: prefix: "build" include: "scope" + + # The npm entry above never covers the actions themselves, so every `uses:` in the workflows -- checkout, setup-node, pnpm/action-setup, cache, attest -- would be pinned to a major and then left to drift, including the ones that hold `id-token: write` and sign release attestations. `/` covers the workflow files; `/.github/actions/*` covers the composite action's own `uses:` steps, which live in a separate manifest Dependabot does not reach from the root. + - package-ecosystem: "github-actions" + directories: + - "/" + - "/.github/actions/*" + schedule: + # Weekly rather than the npm entry's daily: action releases are far less frequent, and a daily poll would only add noise. + interval: "weekly" + cooldown: + default-days: 7 + commit-message: + # `ci` rather than the npm entry's `build`: these updates change the CI definition, and commitlint accepts both types. The scope Dependabot appends makes it `ci(deps)`. + prefix: "ci" + include: "scope" + groups: + # One catch-all group, majors included. Unlike an npm major, an action major bump is a one-line change that either passes CI or does not, so there is nothing gained by isolating it into its own pull request. + actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c04ca87..1793dd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,26 @@ name: CI +# One workflow for the whole workspace. Every check runs its task across the workspace through turbo from the repository root, rather than against a single package directory, so adding a package needs no edit here. + on: push: branches: [main] pull_request: workflow_dispatch: +# A new push to a pull request supersedes that PR's in-flight run. A push to main never cancels: the release job publishes to npm and pushes tags mid-run, and cancelling it partway leaves real work half-done. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read +env: + # On a pull request, restrict every turbo task to the packages the branch actually changed (and their dependents); on main, run the whole workspace so the caches the next run restores from are complete and the release gate covers everything. --affected compares against the base branch, so the checkouts below use fetch-depth: 0. + TURBO_FLAGS: ${{ github.event_name == 'pull_request' && '--affected' || '' }} + TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} + jobs: commitlint: name: Commitlint @@ -21,7 +33,7 @@ jobs: - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: - node-version: '22' + node-version-file: .tool-versions cache: pnpm - run: pnpm install --frozen-lockfile - name: Validate the last commit with commitlint @@ -34,79 +46,51 @@ jobs: lint: name: Lint runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 with: - node-version: '22' - cache: pnpm - - run: pnpm install --frozen-lockfile - # turbo's local filesystem cache, keyed per task/job. A job whose inputs (per turbo.json) are unchanged since a prior run on this lockfile/pipeline generation replays that task's recorded result -- including its declared outputs (e.g. dist/**, .eslintcache) -- instead of re-running eslint/tsc/tsdown/vitest. - # - # hashFiles(pnpm-lock.yaml, turbo.json) puts the dependency set and the task graph itself into the key, so either changing starts a fresh cache lineage rather than inheriting a stale one; github.run_id keeps every run's own save key unique. actions/cache treats a key as immutable -- a save under a key that already exists is silently skipped -- so without run_id, only the very first run for a given lockfile/turbo.json pair would ever actually save anything, and no later run could repair or extend a cache left behind by a partially failed one. The two-level restore-keys then prefers a cache from the same lockfile/turbo.json generation and only falls back to the newest cache for this task when that generation has no entry yet. - - uses: actions/cache@v6 + fetch-depth: 0 + - uses: ./.github/actions/setup-workspace with: - path: .turbo - key: turbo-lint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}-${{ github.run_id }} - restore-keys: | - turbo-lint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}- - turbo-lint-${{ runner.os }}- - - run: pnpm lint + task: lint + command: pnpm lint $TURBO_FLAGS typecheck: name: Typecheck runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 with: - node-version: '22' - cache: pnpm - - run: pnpm install --frozen-lockfile - - uses: actions/cache@v6 + fetch-depth: 0 + - uses: ./.github/actions/setup-workspace with: - path: .turbo - key: turbo-typecheck-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}-${{ github.run_id }} - restore-keys: | - turbo-typecheck-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}- - turbo-typecheck-${{ runner.os }}- - # turbo's _typecheck task depends on _build, so this already builds dist/ before type-checking. attw (are-the-types-wrong) needs that same dist/, which is why it runs straight after with no separate build step. - - run: pnpm typecheck - - run: pnpm exec attw --pack + task: typecheck + # _typecheck:attw (attw --pack per published package, checking each package's declared types resolve under every module resolution mode) is one of the tasks `pnpm typecheck` runs through turbo, depending on that package's own _build -- no separate build step or bare `pnpm exec attw --pack` needed to give it a dist/ to inspect. + command: pnpm typecheck $TURBO_FLAGS test: name: Test runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 permissions: contents: read code-quality: write # to upload the cobertura coverage report below steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 with: - node-version: '22' - cache: pnpm - - run: pnpm install --frozen-lockfile - - uses: actions/cache@v6 + fetch-depth: 0 + - uses: ./.github/actions/setup-workspace with: - path: .turbo - key: turbo-test-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}-${{ github.run_id }} - restore-keys: | - turbo-test-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}- - turbo-test-${{ runner.os }}- - - run: pnpm test:coverage + task: test + command: pnpm test:coverage $TURBO_FLAGS - name: Upload coverage report # Code Quality requires the org on GitHub Team/Enterprise Cloud, which ExaDev is not yet on, so the upload call itself will fail until that changes -- fail-on-error: false keeps that failure a log annotation instead of gating the release job below on a feature we can't turn on yet. Also guarded against fork PRs, which never hold the code-quality: write permission to upload. if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository uses: actions/upload-code-coverage@v1 with: - file: coverage/cobertura-coverage.xml + file: packages/trilean/coverage/cobertura-coverage.xml language: typescript label: unit fail-on-error: false @@ -114,83 +98,73 @@ jobs: test-integration: name: Integration test runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 with: - node-version: '22' - cache: pnpm - - run: pnpm install --frozen-lockfile - - uses: actions/cache@v6 + fetch-depth: 0 + - uses: ./.github/actions/setup-workspace with: - path: .turbo - key: turbo-test-integration-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}-${{ github.run_id }} - restore-keys: | - turbo-test-integration-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}- - turbo-test-integration-${{ runner.os }}- - - run: pnpm test:integration + task: test-integration + command: pnpm test:integration $TURBO_FLAGS test-smoke: name: Smoke test runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 with: - node-version: '22' - cache: pnpm - - run: pnpm install --frozen-lockfile - - uses: actions/cache@v6 + fetch-depth: 0 + - uses: ./.github/actions/setup-workspace with: - path: .turbo - key: turbo-test-smoke-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}-${{ github.run_id }} - restore-keys: | - turbo-test-smoke-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}- - turbo-test-smoke-${{ runner.os }}- - - run: pnpm test:smoke + task: test-smoke + command: pnpm test:smoke $TURBO_FLAGS test-workers: name: Workers runtime test runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 with: - node-version: '22' - cache: pnpm - # pnpm install builds the workerd binary (allowBuilds in pnpm-workspace.yaml), so this runs the evaluator inside a real Cloudflare Workers isolate -- enforcing zero Node-only API usage at runtime rather than by assertion. - - run: pnpm install --frozen-lockfile - - uses: actions/cache@v6 + fetch-depth: 0 + # The install inside the composite action builds the workerd binary (allowBuilds in pnpm-workspace.yaml), so this runs the evaluator inside a real Cloudflare Workers isolate -- enforcing zero Node-only API usage at runtime rather than by assertion. + - uses: ./.github/actions/setup-workspace with: - path: .turbo - key: turbo-test-workers-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}-${{ github.run_id }} - restore-keys: | - turbo-test-workers-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}- - turbo-test-workers-${{ runner.os }}- - - run: pnpm test:workers + task: test-workers + command: pnpm test:workers $TURBO_FLAGS release: name: Release - needs: [commitlint, lint, typecheck, test, test-integration, test-smoke, test-workers] + needs: + [ + commitlint, + lint, + typecheck, + test, + test-integration, + test-smoke, + test-workers, + ] if: github.ref == 'refs/heads/main' && github.event_name == 'push' + # Two pushes to main close together must never run this job at the same time: the orchestrator pushes release commits, tags and dependency bumps to main mid-run, and semantic-release's own stale-checkout guard refuses to publish the moment it sees a commit on main this checkout does not have. Queue the second run behind the first rather than cancelling either -- cancelling mid-release would leave a published package with no committed version bump. + concurrency: + group: release-workspace + cancel-in-progress: false runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 permissions: - contents: write # to push the release commit/tag and create the GitHub Release + contents: write # to push the release commit and tags, and create GitHub Releases issues: write # to comment on released issues pull-requests: write # to comment on released pull requests id-token: write # OIDC identity for npm trusted publishing (no NPM_TOKEN) outputs: - published: ${{ steps.before.outputs.version != steps.after.outputs.version }} - version: ${{ steps.after.outputs.version }} + released: ${{ steps.released.outputs.released }} + version: ${{ steps.released.outputs.version }} + tag: ${{ steps.released.outputs.tag }} steps: - # main's ruleset requires every change to land via a pull request, and the default GITHUB_TOKEN has no bypass for that -- @semantic-release/git's release-commit push is a direct push to main, so it needs a token from an actor the ruleset explicitly allows through instead. The org-wide "exadev" GitHub App (installed with access to every ExaDev repo, unlike the documents.js-family repos' own narrower, selected-repository app) is that actor, added as an Integration bypass_actor on this repo's ruleset. + # main's ruleset requires every change to land via a pull request, and the default GITHUB_TOKEN has no bypass for that -- the orchestrator's release commit is a direct push to main, so it needs a token from an actor the ruleset explicitly allows through instead. The org-wide "exadev" GitHub App is that actor, added as an Integration bypass_actor on this repo's ruleset. - name: Generate a token for the release push id: app-token uses: actions/create-github-app-token@v2 @@ -199,44 +173,53 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - uses: actions/checkout@v7 with: - # semantic-release analyses the full commit history since the last release. + # ref: main, not the bare event SHA. The orchestrator pushes to the current branch by name, so a detached HEAD stops the run with a WorkspaceStateError rather than pushing HEAD:HEAD; naming the branch is what makes checkout attach HEAD to it. fetch-depth: 0 because each package's release range is analysed from its own last matching tag. + ref: main fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: - node-version: '22' + node-version-file: .tool-versions cache: pnpm # registry-url is deliberately absent. Setting it makes setup-node write an .npmrc containing an _authToken line, and that line wins over the OIDC token exchange -- so the setting that looks like it configures the registry is exactly the one that would stop trusted publishing working. - run: pnpm install --frozen-lockfile - - uses: actions/cache@v6 - with: - path: .turbo - key: turbo-release-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}-${{ github.run_id }} - restore-keys: | - turbo-release-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}- - turbo-release-${{ runner.os }}- - - name: Read pre-release version - id: before - run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" - name: Upgrade npm for OIDC trusted publishing (needs npm CLI >=11.5.1) run: npm install -g npm@latest - - name: Release - # HUSKY=0 so the commit-msg hook never fires against the automated release commit. - run: HUSKY=0 pnpm exec semantic-release + - name: Record the tags present before the orchestrator runs + # The orchestrator creates each package's `name@version` tag in this same checkout as it releases, so diffing the remote's tags across the release step is the exact record of what this run released. Written to RUNNER_TEMP, not the working tree: the single-commit strategy discovers what it touched via `git status` and requires a clean tree to start from, so a scratch file inside the checkout would fail that check every run. + run: git ls-remote --tags origin | sed 's|.*refs/tags/||' | grep -v '\^{}' | sort > "$RUNNER_TEMP/release-tags-before.txt" + - name: Release every package that changed, in dependency order + # The orchestrator discovers the workspace from pnpm-workspace.yaml, orders packages topologically, and runs semantic-release per package with commits path-filtered to that package's own directory and tags in `name@version` form. HUSKY=0 so local git hooks never fire against the automated commit. + run: HUSKY=0 pnpm release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # npm publish provenance for every package the run publishes, matching each package.json's own publishConfig.provenance. + NPM_CONFIG_PROVENANCE: "true" # Blanked, not omitted -- an inherited NPM_TOKEN/NODE_AUTH_TOKEN from a workflow-level env block, reusable workflow, or composite action would otherwise be used in preference to the OIDC exchange. - NPM_TOKEN: '' - NODE_AUTH_TOKEN: '' - - name: Read post-release version - id: after - run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + NPM_TOKEN: "" + NODE_AUTH_TOKEN: "" + - name: Collect what this run released + id: released + # The post-release jobs below mirror and attest the one published package, so they need its tag and version rather than a matrix. A tag that appeared during the release step and names this package is that record; nothing new means nothing released, and every downstream job skips. + run: | + git ls-remote --tags origin | sed 's|.*refs/tags/||' | grep -v '\^{}' | sort > "$RUNNER_TEMP/release-tags-after.txt" + TAG=$(comm -13 "$RUNNER_TEMP/release-tags-before.txt" "$RUNNER_TEMP/release-tags-after.txt" | grep '^trilean@' || true) + if [ -z "$TAG" ]; then + echo "released=false" >> "$GITHUB_OUTPUT" + echo "::notice::No new trilean tag; nothing was released by this run." + exit 0 + fi + { + echo "released=true" + echo "tag=$TAG" + echo "version=${TAG##*@}" + } >> "$GITHUB_OUTPUT" notify-hive: name: Notify novus-power/hive needs: release - if: needs.release.outputs.published == 'true' + if: needs.release.outputs.released == 'true' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -264,9 +247,9 @@ jobs: publish-github-packages: name: Publish mirror to GitHub Packages needs: release - if: needs.release.outputs.published == 'true' + if: needs.release.outputs.released == 'true' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 permissions: contents: read packages: write @@ -276,41 +259,39 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: main # the release commit semantic-release just pushed + # The tag, not main: a queued release run could have pushed further commits and tags to main between this run's release job finishing and this job starting, and the tag points at the exact release commit whose packages/trilean is the released state. + ref: ${{ needs.release.outputs.tag }} - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: - node-version: '22' + node-version-file: .tool-versions cache: pnpm - # Deliberately NOT setup-node's own registry-url/scope inputs: those write an @exadev:registry=https://npm.pkg.github.com/ *install-time* scope-to-registry mapping into .npmrc, which would redirect this package's own @exadev-scoped devDependency installs (e.g. @exadev/eslint-config, published only to the default registry) through GitHub Packages too, breaking `pnpm install` below. publishConfig.registry (set explicitly below) already fully determines pnpm publish's *target* registry on its own; only the host-scoped auth token below is genuinely needed, added after install so install never sees any @exadev scope mapping at all. + # Deliberately NOT setup-node's own registry-url/scope inputs: those write an @exadev:registry=https://npm.pkg.github.com/ *install-time* scope-to-registry mapping into .npmrc, which would redirect this workspace's own @exadev-scoped devDependency installs (@exadev/eslint-config, @exadev/semantic-release-workspace, published only to the default registry) through GitHub Packages too, breaking `pnpm install` below. publishConfig.registry (set explicitly below) already fully determines pnpm publish's *target* registry on its own; only the host-scoped auth token below is genuinely needed, added after install so install never sees any @exadev scope mapping at all. - run: pnpm install --frozen-lockfile - - uses: actions/cache@v6 - with: - path: .turbo - key: turbo-publish-github-packages-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}-${{ github.run_id }} - restore-keys: | - turbo-publish-github-packages-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}- - turbo-publish-github-packages-${{ runner.os }}- - run: pnpm build - name: Rewrite the package name for the GitHub Packages mirror - # trilean itself stays unscoped on npmjs.org (see package.json's own "name"), but GitHub Packages' npm registry structurally requires every package it hosts to be scoped to the owning org -- an unscoped `trilean` publish there is rejected outright. This job's own package.json rewrite (never committed -- it runs against the checkout's working tree only) is what lets the same build ship under both names without the primary npm publish above ever carrying the @exadev scope. + # trilean itself stays unscoped on npmjs.org (see the package's own "name"), but GitHub Packages' npm registry structurally requires every package it hosts to be scoped to the owning org -- an unscoped `trilean` publish there is rejected outright. This job's own package.json rewrite (never committed -- it runs against the checkout's working tree only) is what lets the same build ship under both names without the primary npm publish ever carrying the @exadev scope. + working-directory: packages/trilean run: npm pkg set name="@exadev/trilean" - name: Rewrite the registry for the GitHub Packages mirror # publishConfig.registry has to be overridden explicitly: without it, pnpm publish would target registry.npmjs.org -- the registry the primary, unscoped npm publish already used in the release job above -- instead of GitHub Packages. + working-directory: packages/trilean run: npm pkg set publishConfig.registry="https://npm.pkg.github.com" - name: Drop provenance for the GitHub Packages mirror # npm honours publishConfig.provenance against whatever registry it is publishing to, and signing a provenance statement needs an OIDC token this job deliberately holds no permission to mint -- so leaving the field set fails the publish outright with 'Provenance generation in GitHub Actions requires "write" access to the "id-token" permission', before a single byte is uploaded. Granting that permission is not the fix: sigstore provenance is an npmjs.org feature GitHub Packages does not host, so the mirror would be signing an attestation none of its consumers could ever resolve. The primary npm publish in the release job above keeps provenance, which is where it means something. + working-directory: packages/trilean run: npm pkg delete publishConfig.provenance - name: Configure the GitHub Packages auth token for publish only run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc - - run: pnpm publish --access public --no-git-checks + - working-directory: packages/trilean + run: pnpm publish --access public --no-git-checks attest-npm: name: Attest SBOM and build provenance (npm) needs: release - if: needs.release.outputs.published == 'true' + if: needs.release.outputs.released == 'true' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 permissions: contents: read id-token: write @@ -318,40 +299,35 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: main # the release commit semantic-release just pushed + ref: ${{ needs.release.outputs.tag }} # the release commit the orchestrator tagged, not whatever main has moved on to by now - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: - node-version: '22' + node-version-file: .tool-versions cache: pnpm - run: pnpm install --frozen-lockfile - - uses: actions/cache@v6 - with: - path: .turbo - key: turbo-attest-npm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}-${{ github.run_id }} - restore-keys: | - turbo-attest-npm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}- - turbo-attest-npm-${{ runner.os }}- - run: pnpm build # Pack into a directory of its own, separate from dist/ (tsdown's raw build output). The attestation subject has to be the artefact that actually ships -- attesting dist/ itself would mix in files that never leave the repo, producing digests that match nothing a consumer can download. - - run: pnpm pack --pack-destination release-artifact - - run: pnpm sbom --sbom-format spdx --prod > release-artifact/sbom.spdx.json + - working-directory: packages/trilean + run: pnpm pack --pack-destination release-artifact + - working-directory: packages/trilean + run: pnpm sbom --sbom-format spdx --prod > release-artifact/sbom.spdx.json - name: Attest SBOM uses: actions/attest@v4 with: - subject-path: release-artifact/*.tgz - sbom-path: release-artifact/sbom.spdx.json + subject-path: packages/trilean/release-artifact/*.tgz + sbom-path: packages/trilean/release-artifact/sbom.spdx.json - name: Attest build provenance uses: actions/attest@v4 with: - subject-path: release-artifact/*.tgz + subject-path: packages/trilean/release-artifact/*.tgz attest-github-packages: name: Attest SBOM and build provenance (GitHub Packages) needs: release - if: needs.release.outputs.published == 'true' + if: needs.release.outputs.released == 'true' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 permissions: contents: read id-token: write @@ -359,32 +335,28 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: main # the release commit semantic-release just pushed + ref: ${{ needs.release.outputs.tag }} # the release commit the orchestrator tagged, not whatever main has moved on to by now - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: - node-version: '22' + node-version-file: .tool-versions cache: pnpm - run: pnpm install --frozen-lockfile - - uses: actions/cache@v6 - with: - path: .turbo - key: turbo-attest-github-packages-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}-${{ github.run_id }} - restore-keys: | - turbo-attest-github-packages-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}- - turbo-attest-github-packages-${{ runner.os }}- - run: pnpm build # The GitHub Packages mirror ships under a different name (see publish-github-packages) and is therefore a genuinely different artefact from the one attest-npm covers above -- attesting only the unscoped tarball would leave the scoped mirror with no provenance a consumer could verify. This job's own attest@v4 attestations land in this repository's own attestation store regardless of which registry the tarball is later published to, so no id-token/OIDC conflict with publish-github-packages' own npm-registry auth exists here. - name: Rewrite the package name to match the GitHub Packages mirror + working-directory: packages/trilean run: npm pkg set name="@exadev/trilean" - - run: pnpm pack --pack-destination release-artifact - - run: pnpm sbom --sbom-format spdx --prod > release-artifact/sbom.spdx.json + - working-directory: packages/trilean + run: pnpm pack --pack-destination release-artifact + - working-directory: packages/trilean + run: pnpm sbom --sbom-format spdx --prod > release-artifact/sbom.spdx.json - name: Attest SBOM uses: actions/attest@v4 with: - subject-path: release-artifact/*.tgz - sbom-path: release-artifact/sbom.spdx.json + subject-path: packages/trilean/release-artifact/*.tgz + sbom-path: packages/trilean/release-artifact/sbom.spdx.json - name: Attest build provenance uses: actions/attest@v4 with: - subject-path: release-artifact/*.tgz + subject-path: packages/trilean/release-artifact/*.tgz diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..8febcc3 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 22 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b122c0c..77d4c4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,19 +3,21 @@ `pnpm install` sets up git hooks automatically (`prepare: husky`). From then on: - **Pre-commit** lints and auto-fixes staged files (`lint-staged`). -- **commit-msg** validates the message against [Conventional Commits](https://www.conventionalcommits.org/), enforced by commitlint. The allowed types and the release level each one triggers are defined once, in `release.config.ts`'s `commitTypes` (commitlint imports the same list, so a type can never pass one check and fail the other). As a rule: `feat` releases minor, a breaking-change footer/`!` releases major, everything else (`fix`, `refactor`, `docs`, `test`, `chore`, …) releases patch. +- **commit-msg** validates the message against [Conventional Commits](https://www.conventionalcommits.org/), enforced by commitlint. The allowed types, the release level each one triggers, and its changelog heading are defined once, in `release-workspace.config.ts`'s `commitTypes` (commitlint imports the same list, so a type can never pass one check and fail the other). As a rule: `feat` releases minor, a breaking-change footer/`!` releases major, everything else (`fix`, `refactor`, `docs`, `test`, `chore`, …) releases patch. - **Pre-push** runs the `unit` project (fast) to catch obvious breakage before it reaches CI, which runs the full matrix — `integration`, `smoke`, and `workers` included. ## Before opening a PR -`pnpm run prepublishOnly` runs the same gate CI enforces: lint, typecheck, `unit` + `integration` tests, build, `smoke` test, `publint`, and `attw --pack`. It does not run the `workers` project — run `pnpm test:workers` separately for any change touching `src/` (evaluator, schemas, resolvers), since that's what actually proves the isomorphism constraint below rather than merely asserting it. +`pnpm --dir packages/trilean run prepublishOnly` runs the same gate CI enforces for that package: lint, typecheck, `unit` + `integration` tests, build, `smoke` test, `publint`, and `attw --pack`. It does not run the `workers` project — run `pnpm test:workers` separately for any change touching `src/` (evaluator, schemas, resolvers), since that's what actually proves the isomorphism constraint below rather than merely asserting it. ## Constraints a change must preserve -- **Isomorphism.** `src/**/*.ts` must never import a `node:*` module or reference `Buffer` — enforced by `eslint.config.ts`'s isomorphism guard and runtime-checked by the `workers` project running the evaluator inside a real Cloudflare Workers isolate (`test/workers/`, `wrangler.jsonc`). A change that needs a Node API belongs in a script or test file, never in `src/`. -- **[Design principles](README.md#design-principles).** No assumptions about consumer data, three evaluation outcomes never two, derived constructs built as compositions rather than new logic, one schema with mechanically derived artefacts. These hold across the whole design; an implementation change that would violate one needs the principle itself revisited first, not a quiet exception. -- **Generated files are never hand-edited.** `schemas/trilean.schema.json` is produced by `scripts/generate-json-schema.ts` as part of `pnpm build` and is gitignored — if the shipped schema looks wrong, fix the Zod schema or the generator script, not the output. +- **Isomorphism.** `packages/trilean/src/**/*.ts` must never import a `node:*` module or reference `Buffer` — enforced by that package's own `eslint.config.ts` isomorphism guard and runtime-checked by the `workers` project running the evaluator inside a real Cloudflare Workers isolate (`packages/trilean/test/workers/`, `packages/trilean/wrangler.jsonc`). A change that needs a Node API belongs in a script or test file, never in `src/`. +- **[Design principles](packages/trilean/README.md#design-principles).** No assumptions about consumer data, three evaluation outcomes never two, derived constructs built as compositions rather than new logic, one schema with mechanically derived artefacts. These hold across the whole design; an implementation change that would violate one needs the principle itself revisited first, not a quiet exception. +- **Generated files are never hand-edited.** `packages/trilean/schemas/trilean.schema.json` is produced by `packages/trilean/scripts/generate-json-schema.ts` as part of `pnpm build` and is gitignored — if the shipped schema looks wrong, fix the Zod schema or the generator script, not the output. ## Releases -Merging to `main` runs `semantic-release` in CI: it decides the version bump from the commit types since the last release, publishes to npm, tags, and writes `CHANGELOG.md`. Never hand-bump `package.json`'s version or edit `CHANGELOG.md` directly — both are overwritten by the next release. +Merging to `main` runs [`@exadev/semantic-release-workspace`](https://www.npmjs.com/package/@exadev/semantic-release-workspace) in CI, which runs semantic-release once per package. Each package is analysed against only the commits that touched its own directory, so its version tracks its own history and a change to one package never bumps another. A release publishes to npm, tags as `@`, and writes that package's own `CHANGELOG.md`. + +Never hand-bump a `package.json` version or edit a `CHANGELOG.md` directly — both are overwritten by the next release. A commit's *scope* is free text and does not route it anywhere; what decides which package releases is which files the commit changed. diff --git a/README.md b/README.md index d15d21f..e88865d 100644 --- a/README.md +++ b/README.md @@ -1,969 +1,53 @@ -# trilean +# trilean workspace [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/trilean) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/trilean) [![Release](https://img.shields.io/github/v/release/ExaDev/trilean)](https://github.com/ExaDev/trilean/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/trilean/ci.yml?branch=main)](https://github.com/ExaDev/trilean/actions) -> /ˈtraɪ.li.ən/ (TRY-lee-ən) — rhymes with "boolean". -> -> "Tri-" for [three-valued logic](https://en.wikipedia.org/wiki/Three-valued_logic) — the three possible outcomes of an evaluation (definitely true, definitely false, or indeterminate — see [The evaluation model](#the-evaluation-model)) — "-lean" echoing "boolean" itself, George Boole's own two-valued logic. +The pnpm workspace holding the trilean packages. This file describes the repository; for the library itself — what it is, how the evaluation model works, and the full API — see [`packages/trilean/README.md`](packages/trilean/README.md). -A serialisable (JSON) representation of two related tree structures — a **predicate tree** (truth-valued) and an **expression tree** (value-valued) — together with an evaluator for both. The package is deliberately domain-agnostic: the schema layer never assumes anything about where data actually comes from. Every point of contact with a consumer's real data is an injected, opaque resolver function supplied by whoever embeds the package. +## Packages -Typical use: representing business rules, eligibility conditions, formulas, or validation logic as data (JSON) that can be stored, transmitted, edited by non-developers via a UI, and evaluated identically wherever it lands — a browser, a server, a batch job — without recompiling anything. +| Package | Directory | Published as | +| --- | --- | --- | +| [trilean](packages/trilean/README.md) | `packages/trilean` | [`trilean`](https://www.npmjs.com/package/trilean) on npm, [`@exadev/trilean`](https://github.com/ExaDev/trilean/pkgs/npm/trilean) on GitHub Packages | -## Getting started +Each package is versioned, released, and published independently of every other, from its own commit history. There is no lockstep version shared across the workspace. -```sh -npm install trilean -# or -pnpm add trilean -``` - -The package ships as dual ESM and CJS builds, is isomorphic (no assumptions about a Node, browser, or Workers runtime — see [Design principles](#design-principles)), and has zero runtime dependencies beyond [Zod](https://zod.dev). - -```ts -import { evaluatePredicate, type PredicateNode, type Resolvers } from "trilean"; - -const node: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 18 }, -}; - -const resolvers: Resolvers = { - async resolveValue(key, context) { - const record = context as Record; - return key === "age" && "age" in record - ? { found: true, value: { kind: "number", value: record.age as number } } - : { found: false }; - }, - async resolveLookup() { - return { found: false }; - }, - async resolveCollection() { - return []; - }, -}; - -await evaluatePredicate(node, { age: 21 }, resolvers); -// => { status: "definite", value: true } -``` - -See [Evaluator entry points](#evaluator-entry-points) and [Resolvers](#resolvers) for the full contract, and the [Worked example](#worked-example) for a larger tree combining boolean logic, a formula, and an aggregation. - -### A nested filter for a REST API search endpoint - -A search endpoint's filter criteria are exactly the kind of thing this package is for: nested boolean logic, stored as JSON, that a client can construct, a non-developer can edit via a UI, and a server evaluates per record without ever hardcoding the filter or redeploying when it changes. There is no query-string DSL to parse and no ORM query-builder to translate into — the request body already is the tree: - -```http -POST /orders/search HTTP/1.1 -Content-Type: application/json - -{ - "filter": { - "kind": "and", - "left": { - "kind": "textCompare", - "op": "equals", - "left": { "kind": "reference", "key": "status" }, - "right": { "kind": "textLiteral", "value": "active" } - }, - "right": { - "kind": "or", - "left": { - "kind": "compare", - "op": "gt", - "left": { "kind": "reference", "key": "orderTotal" }, - "right": { "kind": "numberLiteral", "value": 100 } - }, - "right": { - "kind": "memberOf", - "op": "in", - "operand": { "kind": "reference", "key": "category" }, - "candidates": [ - { "kind": "textLiteral", "value": "electronics" }, - { "kind": "textLiteral", "value": "books" } - ] - } - } - } -} -``` - -`status equals "active" AND (orderTotal > 100 OR category is a preferred one)` — two levels of nesting: an `or` inside the right branch of an `and`. The server parses that body's `filter` field as a `PredicateNode` and evaluates it, unmodified, against each candidate order: - -```ts -import { evaluatePredicate, type PredicateNode, type Resolvers } from "trilean"; +## Commands -interface Order { - status: string; - orderTotal: number; - category: string; -} - -// The parsed `filter` field from the request body above. -const filter: PredicateNode = { - kind: "and", - left: { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "status" }, - right: { kind: "textLiteral", value: "active" }, - }, - right: { - kind: "or", - left: { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "orderTotal" }, - right: { kind: "numberLiteral", value: 100 }, - }, - right: { - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "category" }, - candidates: [ - { kind: "textLiteral", value: "electronics" }, - { kind: "textLiteral", value: "books" }, - ], - }, - }, -}; - -const orderResolvers: Resolvers = { - async resolveValue(key, context) { - const order = context as Order; - switch (key) { - case "status": - return { found: true, value: { kind: "text", value: order.status } }; - case "orderTotal": - return { found: true, value: { kind: "number", value: order.orderTotal } }; - case "category": - return { found: true, value: { kind: "text", value: order.category } }; - default: - return { found: false }; - } - }, - async resolveLookup() { - return { found: false }; - }, - async resolveCollection() { - return []; - }, -}; - -const orders: Order[] = [ - { status: "active", orderTotal: 42, category: "electronics" }, - { status: "active", orderTotal: 150, category: "garden" }, - { status: "cancelled", orderTotal: 200, category: "electronics" }, -]; - -const results = await Promise.all( - orders.map((order) => evaluatePredicate(filter, order, orderResolvers)), -); -const matching = orders.filter((_, i) => results[i]?.status === "definite" && results[i]?.value === true); -// => the first two orders match; the cancelled one doesn't reach the "or" at all, since "and" absorbs on its left operand's definite false -``` - -See [`and`/`or`](#not-and-or), [`compare`](#compare), [`textCompare`](#textcompare), and [`memberOf`](#memberof) for the full node-kind reference. - -## Build, test, and lint +Every command runs from the repository root and fans out across the workspace through Turborepo, which caches each task against its declared inputs — an unchanged package replays its recorded result instead of re-running the work. ```sh -pnpm install -pnpm build # tsdown -> dist/, then generates schemas/trilean.schema.json -pnpm test # unit suite, against src/ -pnpm test:integration # multi-kind composition, schema-pipeline, and function-registry/delegate tests, against src/ -pnpm test:smoke # builds first, then checks dist/ in both ESM and CJS plus the generated JSON Schema -pnpm test:workers # runs the evaluator inside a real Cloudflare Workers isolate -pnpm lint -pnpm typecheck -``` - -See [CONTRIBUTING.md](CONTRIBUTING.md) for the git hooks, the release process, and the constraints an implementation change must preserve. - -## Design principles - -These hold across every part of the design below, and any implementation change must preserve them: - -- **No assumptions about consumer data.** The only places this package touches real data are three named resolver contracts (see [Resolvers](#resolvers)). The schema stores *what to pass* to a resolver, never any resolver logic itself, and never interprets the meaning of an opaque key, table identifier, or collection reference. -- **Three outcomes, never two.** Every evaluation produces a definite result or an indeterminate result carrying a reason — never a bare `boolean`/`number`, and never a thrown exception for a data-quality problem. See [The evaluation model](#the-evaluation-model). -- **Derived constructs are compositions, not new logic.** Anything describable as "some other primitive, wired together" is implemented that way, so its correctness is inherited rather than requiring separate proof. See [Derived connectives](#derived-connectives), [Derived aggregates](#derived-aggregates), [Derived values](#derived-values), [Pattern-matching builders](#pattern-matching-builders), and [Defining your own named presets](#defining-your-own-named-presets). -- **One schema, mechanically derived artefacts.** A single canonical type definition produces the runtime validator and the portable wire-format schema; they cannot drift apart because there is only one source. See [Schema strategy](#schema-strategy). -- **A numeric extension that stays closed-form is in scope; a different kind of computation is not.** When something the current numeric model does not cover comes up, the test is whether evaluating it is still closed-form numeric evaluation — no solving, no simplification, no code execution. If it is, it belongs here, however unlike the existing kinds it looks: [Complex values](#complex-values) were once listed under [Out of scope](#out-of-scope) on a sizing judgement that turned out to be wrong, since complex arithmetic is exactly the closed-form evaluation this evaluator already does for every other kind. What stays behind [`delegate`](#delegate) is a genuinely different *kind* of computation — symbolic algebra, arbitrary external computation — not merely a kind of number the model has not reached yet. -- **Generic examples only.** Every example in this document uses invented, placeholder field names (`temperature`, `orderTotal`, `isActive`, `x`, `y`, `amount`, `items`) with no resemblance to any particular company, product, or industry's real data model. - -## The evaluation model - -Every evaluation — of a predicate node or an expression node — produces exactly one of two outcomes: - -```ts -type Evaluation = - | { status: "definite"; value: T } - | { status: "indeterminate"; reason: IndeterminateReason }; - -interface IndeterminateReason { - /** Which of the three reason categories applies. */ - code: "not-found" | "wrong-type" | "domain-error"; - /** A human-readable explanation, for logging and debugging. */ - message: string; -} +pnpm install # install the workspace and set up the git hooks +pnpm build # tsdown build plus JSON-schema generation, per package +pnpm lint # eslint, zero warnings, per package and at the root +pnpm typecheck # tsc --noEmit plus attw --pack against each published package +pnpm test # the unit project +pnpm test:coverage # the unit project with coverage +pnpm test:integration # the integration project +pnpm test:smoke # rebuilds, then exercises the built dist/ through the exports map +pnpm test:workers # the workers project, inside the real workerd runtime ``` -The three reason codes are: - -| Code | Meaning | -|---|---| -| `not-found` | A value a node needed did not exist in the underlying data at all. | -| `wrong-type` | A value existed but was not of a kind the operation could use (e.g. non-numeric where a number was required). | -| `domain-error` | A mathematical operation was attempted outside its valid domain (division by zero, a function given an input outside its allowed range, an aggregation with nothing to aggregate). | - -`domain-error` is not a separate error type, exception, or crash — it uses exactly the same `Evaluation`/`IndeterminateReason` mechanism as the other two. This three-outcome model applies uniformly to every node kind in both trees: arithmetic, comparison, and boolean logic alike. It never collapses to a plain boolean or number at any intermediate point inside the tree; only the code that consumes the final top-level `Evaluation` decides what to do with an indeterminate outcome (reject, default, surface to a user, etc.) — that decision is deliberately outside this package's scope. - -**Infrastructure failures are a different concern.** If a resolver itself throws (a network error, a database outage), that propagates as an ordinary rejected promise from `evaluatePredicate`/`evaluateValue`, exactly like any other function call failure. The three-outcome model exists to describe *data-quality* states inside the domain being modelled — it does not, and should not, attempt to also model transport-level failure. - -### Where an indeterminate outcome can carry more than one candidate reason - -Some nodes combine several sub-evaluations that could each independently be indeterminate for a different reason (e.g. an `and` node whose both operands are indeterminate, one `not-found` and one `wrong-type`). This design resolves ties with a single, consistently-applied rule: **take the first indeterminate reason encountered in the node's own declared operand order** (left before right; list order for N-ary/collection operands). This is an implementation decision this document makes explicitly, once, so every node kind's evaluator can apply the same rule without re-deriving it. - -## Three-valued propagation rules - -Let **U** denote "indeterminate" for the purposes of these tables — the specific reason is preserved and reported per the tie-break rule above, but propagation logic itself only cares that an operand is not a definite value. **T** = true, **F** = false. - -**Any arithmetic operation or relational comparison with at least one indeterminate operand always produces an indeterminate result.** There is no operand value that can rescue an arithmetic or single relational comparison once one side is indeterminate — arithmetic and single relational comparisons have no absorbing value and no short-circuit. - -Logical AND, OR, and NOT behave differently: they have absorbing values, and this absorption must be preserved exactly as specified below. **A design in which any indeterminate operand automatically makes the whole boolean result indeterminate, with no absorption, is a specification defect** — it would silently discard cases where the answer was already determined regardless of the indeterminate side. - -**AND** — `false` is absorbing/dominant: - -| AND | T | F | U | -|---|---|---|---| -| **T** | T | F | U | -| **F** | F | F | F | -| **U** | U | F | U | - -**OR** — `true` is absorbing/dominant (mirror image of AND): - -| OR | T | F | U | -|---|---|---|---| -| **T** | T | T | T | -| **F** | T | F | U | -| **U** | T | U | U | - -**NOT** — negates a definite result; leaves indeterminate as indeterminate, reason unchanged: - -| NOT | result | -|---|---| -| T | F | -| F | T | -| U | U | +The `pnpm ` scripts are thin wrappers over `turbo run _`; the underscore-prefixed name is the one that carries the real command in each package's own manifest. Running a task inside a single package works the same way (`pnpm --dir packages/trilean test`), reaching the same cached pipeline. -**Identity elements for the N-ary and collection forms.** AND is a fold over `true` (the identity for AND), OR is a fold over `false` (the identity for OR) — this is a structural property of the operation, not a separate design choice, so it applies consistently everywhere an AND/OR is taken across a list: an empty `allOf` is definitely `true`; an empty `anyOf` is definitely `false`; a "some" quantifier over an empty collection is definitely `false` (no item can satisfy it). +## Layout -> **Deliberate, settled: `every` over an empty collection is definitely `true`.** This is vacuous truth — the standard convention for universal quantification over an empty set, and exactly the same identity-element reasoning already used for `allOf` above (an empty `allOf`'s `true` and an empty `every`'s `true` are the same fact, stated twice because `every` is a quantifier over resolved items rather than a literal list of sub-nodes). This is worth stating explicitly and prominently, rather than leaving it as something an implementer might reasonably second-guess, because at least one other real, existing tool in this space gets exactly this case wrong — its own "all" operator returns `false` for an empty collection, which is simply an incorrect implementation of universal quantification, not an equally valid alternative convention. Nothing about a genuinely empty collection can violate "every item satisfies X", so `true` is the only value consistent with what the quantifier claims to mean; this document's `every` must not be "fixed" to match that other tool's behaviour. - -## Derived connectives - -Exclusive-or, NAND, NOR, implication, and the biconditional are never implemented as independently-evaluated node kinds. Each is defined purely as a fixed composition of unary NOT and binary AND/OR, expressed as ordinary builder functions that construct a tree of primitive nodes: - -```ts -const not = (a: PredicateNode): PredicateNode => ({ kind: "not", operand: a }); -const and = (a: PredicateNode, b: PredicateNode): PredicateNode => ({ kind: "and", left: a, right: b }); -const or = (a: PredicateNode, b: PredicateNode): PredicateNode => ({ kind: "or", left: a, right: b }); - -const xor = (a: PredicateNode, b: PredicateNode): PredicateNode => or(and(a, not(b)), and(not(a), b)); -const nand = (a: PredicateNode, b: PredicateNode): PredicateNode => not(and(a, b)); -const nor = (a: PredicateNode, b: PredicateNode): PredicateNode => not(or(a, b)); -const implies = (a: PredicateNode, b: PredicateNode): PredicateNode => or(not(a), b); -const iff = (a: PredicateNode, b: PredicateNode): PredicateNode => not(xor(a, b)); - -const none = (collection: JsonValue, item: PredicateNode, filter?: PredicateNode): PredicateNode => - not({ kind: "some", collection, item, filter }); ``` - -None of `xor`/`nand`/`nor`/`implies`/`iff` ever appears as a `kind` discriminant on the wire — a serialised tree containing an XOR is indistinguishable from one written out by hand using `or`/`and`/`not`. Three-valued correctness for all five is therefore inherited automatically from the already-verified AND/OR/NOT tables above, never requiring a separate proof for each. - -The same treatment applies to a third quantifier, `none` ("no item satisfies") — defined purely as `not(some(...))`, never as its own independently-evaluated node kind, and so never appearing as its own `kind` discriminant either. Its three-valued correctness is inherited automatically from NOT and from `some`'s own already-established correctness (including its absorbing behaviour and its `filter` handling) — no new truth table or worked proof is needed, exactly as for the five connectives above. - -### Worked correctness check: exclusive-or - -Applying the AND/OR/NOT tables above to `xor(A, B) = or(and(A, not(B)), and(not(A), B))` across all nine combinations of `{T, F, U}` for `A` and `B`: - -| A | B | not B | A ∧ ¬B | not A | ¬A ∧ B | result (∨) | expected | -|---|---|---|---|---|---|---|---| -| T | T | F | F | F | F | F | F | -| T | F | T | T | F | F | T | T | -| T | U | U | U | F | F | U | U | -| F | T | F | F | T | T | T | T | -| F | F | T | F | T | F | F | F | -| F | U | U | F | T | U | U | U | -| U | T | F | F | U | U | U | U | -| U | F | T | U | U | F | U | U | -| U | U | U | U | U | U | U | U | - -Every fully-known input pair produces the correct classical XOR, and every combination with at least one `U` produces `U`. This is the correct three-valued extension specifically for XOR — unlike AND/OR, exclusive-or has no operand value that determines the result on its own (there is no value of `B` for which `xor(anything, B)` is fixed regardless of the other side), so it has no absorbing value and "any unknown input yields an unknown output" is exactly right here — even though the identical blanket rule would be *wrong* for AND/OR, where it would ignore real absorption. NAND, NOR, implication, and the biconditional each inherit correct behaviour the same way, purely from being built out of NOT/AND/OR — check any of them the same way, by writing out all nine input combinations and confirming the result matches intuition. As one further spot check: `implies(F, U) = or(not(F), U) = or(T, U) = T` — a false antecedent makes an implication vacuously true regardless of whether the consequent is even knowable, which is the absorbing behaviour correctly carried through from OR. - -## Schema strategy - -The canonical definition lives in one place: a [Zod](https://zod.dev) schema per node kind. The TypeScript type is inferred from the schema (`z.infer<...>`), and a portable wire-format schema for documentation or cross-language interoperability is mechanically derived from the same Zod schema via `z.toJSONSchema()`. There is exactly one hand-authored artefact; the runtime validator and the JSON Schema document cannot drift apart because the second is generated from the first, not maintained alongside it. - -```ts -import { z } from "zod"; - -// A JSON value with no further meaning imposed by this schema — used for every -// opaque payload (reference keys, table identifiers, collection references, -// delegation payloads). "Opaque" means "uninterpreted by this package", not -// "untyped" — every one of these must still be plain, serialisable JSON. -type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; -const JsonValueSchema: z.ZodType = z.lazy(() => - z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValueSchema), z.record(z.string(), JsonValueSchema)]) -); +. workspace root: tooling config, the task pipeline, release orchestration +├── packages/ +│ └── trilean/ the published library, with its own README, CHANGELOG, and configs +├── pnpm-workspace.yaml package globs and pnpm's install-time settings +├── turbo.json the task pipeline every package's tasks are ordered and cached by +├── tsconfig.base.json the compiler options every package extends +└── release-workspace.config.ts how each package is versioned, tagged, and published ``` -Node schemas are `z.discriminatedUnion("kind", [...])` over per-kind `z.object` shapes, following the concrete definitions below. A generated JSON Schema document (produced once, as a build step, via `z.toJSONSchema(PredicateNodeSchema)` / `z.toJSONSchema(ExpressionNodeSchema)`) is what a non-TypeScript consumer or an authoring UI would target. - -The generated document carries a version-pinned `$id` — a jsDelivr URL naming the exact published version, e.g. `https://cdn.jsdelivr.net/npm/trilean@1.2.3/schemas/trilean.schema.json` — so a consumer's own rule file can point its `$schema` at a fixed target rather than a moving one. The file's bytes are exactly its RFC 8785 (JSON Canonicalization Scheme) canonical form — keys sorted recursively, no whitespace between tokens, no trailing newline — so `canonicalize(JSON.parse(file)) === file` holds under any JCS implementation, and the same input always produces the same bytes. That makes the file's own SHA-256 re-derivable from its parsed content alone, which is what lets a downloaded copy be checked against this package's SBOM and build-provenance attestations (see the release workflow). - -### Performance - -A consumer that parses and evaluates many trees at high throughput can opt into Zod 4.5's compiled-schema fast path by importing `zod/compile` once, at their own application's entry point: - -```ts -import "zod/compile"; -``` - -This package deliberately does **not** import it itself — `zod/compile` has global side effects on the Zod runtime, which would contradict this package's own `sideEffects: false` declaration and could surprise a consumer who never asked for it. Opting in (or not) is left entirely to whoever embeds the package. - -## The predicate tree - -A `PredicateNode` evaluates to `Evaluation` — true, false, or indeterminate-with-reason. - -```ts -type ComparisonOperator = "gt" | "gte" | "lt" | "lte" | "eq" | "neq"; -type TextComparisonOperator = "equals" | "notEquals" | "matches" | "notMatches"; -type MembershipOperator = "in" | "notIn"; - -type PredicateNode = - | { kind: "not"; operand: PredicateNode } - | { kind: "and"; left: PredicateNode; right: PredicateNode } - | { kind: "or"; left: PredicateNode; right: PredicateNode } - | { kind: "allOf"; operands: PredicateNode[] } - | { kind: "anyOf"; operands: PredicateNode[] } - | { kind: "compare"; op: ComparisonOperator; left: ExpressionNode; right: ExpressionNode } - | { kind: "textCompare"; op: TextComparisonOperator; left: ExpressionNode; right: ExpressionNode } - | { kind: "memberOf"; op: MembershipOperator; operand: ExpressionNode; candidates: ExpressionNode[] } - | { kind: "exists"; operand: ExpressionNode } - | { kind: "some"; collection: JsonValue; item: PredicateNode; filter?: PredicateNode } - | { kind: "every"; collection: JsonValue; item: PredicateNode; filter?: PredicateNode } - | { kind: "treeReference"; key: JsonValue }; -``` - -### `not`, `and`, `or` - -The three primitives. `not` takes exactly one operand — it is never modelled as a two-operand node with an unused second slot. `and`/`or` each take exactly two named operands (`left`/`right`), evaluated per the truth tables above. - -### `allOf`, `anyOf` - -The N-ary forms of `and`/`or`: given an ordered list of operands (rather than exactly two), combine all of them with AND, or all of them with OR, respectively. Defined as repeated pairwise application of `and`/`or` — an implementation detail, not a new evaluation rule requiring separate verification. Because resolvers are asynchronous, a reference implementation is free to evaluate every operand concurrently and then apply the absorption rule when combining results, rather than evaluating strictly left-to-right; both strategies produce an identical final `Evaluation` because absorption is a property of the values, not of execution order. The empty-list identity values from [Three-valued propagation rules](#three-valued-propagation-rules) apply: `allOf([])` is definitely `true`; `anyOf([])` is definitely `false`. - -### `compare` - -A relational-comparison leaf: compares two computed values using `gt`/`gte`/`lt`/`lte`/`eq`/`neq`. **Both `left` and `right` are `ExpressionNode`** — either side may be a plain literal/reference or an arbitrary formula from the expression tree; the comparison is symmetric, and an implementation that only allows a formula on one side is incomplete. Valid operand kinds are `number` (matching units required — see [Units](#units)), `instant`, `duration`, or `boolean`, plus `complex` for `eq`/`neq` only (see [Complex values](#complex-values)); comparing across different computed-value kinds, or comparing two numbers with incompatible units, is `wrong-type`. `boolean` only supports `eq`/`neq` — there is no natural ordering for a truth value, so `gt`/`gte`/`lt`/`lte` are `wrong-type` for a `boolean` operand. - -### `textCompare` - -A text-matching leaf, symmetric in the same way as `compare`: both `left` and `right` are `ExpressionNode`, and either may be a literal or an arbitrary formula. `equals`/`notEquals` are exact string equality; `matches`/`notMatches` interpret `right` as a pattern (an ECMAScript-style regular expression) tested against `left`'s text. Both operands must resolve to the `text` computed-value kind; anything else is `wrong-type`. A "small fixed category" value (e.g. a status label) is simply a `text` computed value from this leaf's point of view — no separate category kind exists. - -### Pattern-matching builders - -`matches` already covers arbitrary pattern matching, but writing the regular expression by hand is where the common, narrower cases go wrong: getting the escape-then-convert ordering backwards either stops wildcards working or silently reinterprets a literal asterisk in real data as one. Three builder functions compile a pattern string into an ordinary `textCompare` node instead — never a new node kind, never an evaluator branch, exactly the same composition-not-new-logic treatment [Derived connectives](#derived-connectives), [Derived aggregates](#derived-aggregates), and [Derived values](#derived-values) already give `xor`/`sum`/`coalesce`: - -```ts -const command: ExpressionNode = { kind: "reference", key: "command" }; -const path: ExpressionNode = { kind: "reference", key: "path" }; - -// Matches "ls" and "ls -la", never "lsof". -prefixPattern(command, "ls"); -// Matches "git add file" and, by the trailing-wildcard convenience below, bare "git". -wildcardPattern(command, "git *"); -// Matches "workspace/report.txt", but not "workspace/archive/report.txt". -hierarchicalGlobPattern(path, "workspace/*"); -``` - -Each returns an ordinary predicate node — `prefixPattern(command, "ls")` is exactly `{ kind: "textCompare", op: "matches", left: command, right: { kind: "textLiteral", value: "^ls(?: [\\s\\S]*)?$" } }`. Compilation happens once, when the tree is built, so what is stored and serialised is a `textCompare` tree indistinguishable from one written out by hand — a consumer that never calls a builder loses nothing, and a serialised tree carries no dependency on the builder that produced it. - -The three are separate dialects, deliberately not one function with a mode argument, because they answer different questions and mixing them silently changes what a pattern means: - -| Builder | `*` | `**` | `?` | Escapes | Intended for | -|---|---|---|---|---|---| -| `prefixPattern` | literal | literal | literal | none — the prefix is a plain literal throughout | Command/label prefixes where `"ls"` must match `"ls"` and `"ls -la"` but never `"lsof"` | -| `wildcardPattern` | any characters | (two wildcards in a row) | literal | `\*` for a literal asterisk, `\\` for a literal backslash | Flat strings with no internal hierarchy | -| `hierarchicalGlobPattern` | any characters within one `/`-delimited segment | any characters across segments | one character within a segment | none — a backslash is a literal backslash | Path- or category-tree-shaped values | - -Two behaviours in `wildcardPattern` are worth stating rather than leaving to be inferred. Its pattern is trimmed before compiling. And a pattern whose **only** unescaped wildcard is a trailing `" *"` also matches the bare prefix, so `"git *"` matches `"git"` as well as `"git add file"` — the convenience does not apply to `"git * *"`, where both wildcards are still required. - -The compiled pattern is a fully anchored, flag-free string: "any character" is spelled `[\s\S]` rather than `.`, because the stored pattern carries no `s` flag and nothing downstream can add one, and the only characters escaped are ECMAScript's own `SyntaxCharacter` set plus `/`, which are exactly the escapes that stay valid under a `u`/`v`-flagged `RegExp` as well as an unflagged one. A compiled pattern is therefore portable in the strongest available sense — it means the same thing wherever it is compiled, including pasted verbatim into a `/.../` literal. - -Three-valued behaviour is inherited unchanged from [`textCompare`](#textcompare) and needs no separate proof: an unresolvable subject is indeterminate rather than a non-match, and a non-`text` subject is `wrong-type` — a compiled pattern never turns a data problem into a definite `false`. - -### `memberOf` - -A membership-test leaf, parallel to `compare` and `textCompare` rather than folded into either one's operator set: `operand` is the `ExpressionNode` being tested; `candidates` is a list of `ExpressionNode`s to test it against, every element of which may independently be an arbitrary formula, not only a literal — the same symmetry principle already applied to `compare` and `textCompare`. `op: "in"` asks whether `operand` equals any candidate; `op: "notIn"` asks whether it equals none of them. - -Membership is decided by value equality between computed values of the same kind, respecting units for numeric values exactly as `compare`'s own `eq` already does — a candidate of an incompatible kind, or a `number` candidate with an incompatible unit, can never be a match, and the comparison for that one element is `wrong-type`, not simply "not equal". - -Evaluate `operand` first; if it is indeterminate, the whole leaf is indeterminate with that reason. Otherwise, scan `candidates` in order: a candidate that is a **definite match** immediately settles the result — `in` is definitely `true`, `notIn` is definitely `false` — regardless of any not-yet-scanned or indeterminate candidates, mirroring the same absorbing-value discipline already established for OR and `some` elsewhere in this document (a confirmed match cannot be undone by an unrelated element's data problem). If scanning completes with no definite match: the leaf is indeterminate (first indeterminate candidate's reason, per the tie-break rule in [The evaluation model](#the-evaluation-model)) if at least one candidate was itself indeterminate or of an incompatible kind/unit; otherwise every candidate was a definite, comparable non-match, and `in` is definitely `false`, `notIn` is definitely `true`. An empty `candidates` list is never scanned and never indeterminate: `in` is definitely `false` and `notIn` is definitely `true` — the same non-vacuous facts an empty `anyOf`/`allOf` already establishes for OR/AND. - -### `exists` - -Evaluates `true` if the given `ExpressionNode` can be resolved to some value at all, `false` if it definitely cannot be resolved (the data point is genuinely absent), independent of whether that value would itself be usable in further computation. Concretely: evaluate the operand; if the result is definite, `exists` is `true`; if the result is indeterminate with reason `not-found`, `exists` is `false`; if the result is indeterminate with reason `wrong-type` or `domain-error`, `exists` is still `true` — the underlying data point *did* resolve to something, it merely wasn't usable for whatever computation was attempted around it, which is exactly why section [The evaluation model](#the-evaluation-model) distinguishes "did not exist" from "existed but unusable" in the first place. `exists` itself is never indeterminate — it always produces a definite boolean. - -### `some`, `every` - -Quantifiers over a collection, sharing the exact collection-resolution mechanism described in [Collections](#collections). `some` is semantically an OR of `item` evaluated once per participating item; `every` is semantically an AND of `item` evaluated once per participating item — both inherit the absorbing-value propagation from the AND/OR tables applied across the whole collection (e.g. `some` can be definitely `true` from one known-true item even if every other participating item is unresolvable). An optional `filter` narrows which resolved items participate at all before either quantifier runs over them — see [Collections](#collections) for exactly how a `filter` result feeds into this same absorption. The item's own evaluation context (for both `filter` and `item`) is the item itself — see [Collections](#collections). A third quantifier, "no item satisfies", is derived from `some` — see [Derived connectives](#derived-connectives). - -## The expression tree - -An `ExpressionNode` evaluates to `Evaluation`. - -```ts -type Unit = Record; // dimension symbol -> exponent, e.g. { m: 1, s: -1 } for metres per second -type DurationUnit = "ms" | "s" | "min" | "h" | "d"; - -type ComputedValue = - | { kind: "number"; value: number; unit?: Unit } - | { kind: "text"; value: string } - | { kind: "boolean"; value: boolean } - | { kind: "instant"; value: string } // ISO-8601 timestamp - | { kind: "duration"; value: number; unit: DurationUnit } - | { kind: "complex"; re: number; im: number; unit?: Unit }; - -type ArithmeticOperator = "add" | "subtract" | "multiply" | "divide" | "power" | "modulo"; - -type FoldCombiner = - | { mode: "max"; item: ExpressionNode } - | { mode: "min"; item: ExpressionNode } - | { mode: "reduce"; initial: ExpressionNode; combine: ExpressionNode }; - -type HitPolicy = "first" | "unique"; - -type ExpressionNode = - | { kind: "numberLiteral"; value: number; unit?: Unit } - | { kind: "textLiteral"; value: string } - | { kind: "booleanLiteral"; value: boolean } - | { kind: "instantLiteral"; value: string } - | { kind: "durationLiteral"; value: number; unit: DurationUnit } - | { kind: "complexLiteral"; re: number; im: number; unit?: Unit } // rectangular - | { kind: "complexLiteral"; magnitude: number; phase: number; unit?: Unit } // polar -- see Complex values - | { kind: "reference"; key: JsonValue; unit?: Unit } - | { kind: "arithmetic"; op: ArithmeticOperator; left: ExpressionNode; right: ExpressionNode } - | { kind: "negate"; operand: ExpressionNode } - | { kind: "call"; fn: string; args: ExpressionNode[] } - | { kind: "lookup"; table: JsonValue; keys: ExpressionNode[] } - | { kind: "conditional"; hitPolicy?: HitPolicy; cases: { when: PredicateNode; then: ExpressionNode }[]; fallback: ExpressionNode } - | { kind: "fold"; collection: JsonValue; filter?: PredicateNode; combiner: FoldCombiner } - | { kind: "accumulator" } - | { kind: "delegate"; system: string; payload: JsonValue } - | { kind: "treeReference"; key: JsonValue }; -``` - -A `textLiteral` kind is included even though it is not separately enumerated as its own top-level construct, because `textCompare`'s symmetry requirement (either side may be an arbitrary computed value, per the section above) is meaningless without a way to write a constant string or pattern — matching a field against the fixed text `"active"`, or against a fixed regular expression, needs a text constant on one side. This is a structural consequence of the symmetry already required for text matching, not an added feature. - -### Literals - -`numberLiteral`, `textLiteral`, `booleanLiteral`, `instantLiteral` (an ISO-8601 timestamp string), `durationLiteral` (a magnitude plus a `DurationUnit`), and `complexLiteral` (either a real and an imaginary component, or a magnitude and a phase, plus an optional `Unit` — see [Complex values](#complex-values)) are always definite by construction — a literal node never itself produces an indeterminate outcome. - -### `reference` - -A reference to a single external value, identified by an opaque `key` whose meaning is entirely up to the embedding consumer — the schema never interprets it (see [Resolvers](#resolvers), resolver 1). May optionally carry an expected `unit`, validated against whatever the resolver actually returns for a `number` result; a mismatch (or an expectation of a unit on a non-numeric result) is `wrong-type`. If the resolver reports absence, the result is `not-found`. - -### `arithmetic`, `negate` - -Binary arithmetic (`add`/`subtract`/`multiply`/`divide`/`power`/`modulo`) and unary negation, each over `number` computed values by default, with the temporal exceptions listed under [Temporal values](#temporal-values) and the complex ones under [Complex values](#complex-values) below. `negate` is an explicit node — never sugar for "zero minus the value" — because it also applies to `duration` values (negating a duration reverses its direction) where "zero minus" has no natural literal-zero counterpart; over a `complex` value it flips both components. Division by zero, or any operator given an operand outside its mathematical domain, is `domain-error`; a non-numeric, non-temporal operand where a number was required is `wrong-type`; any operand that is itself indeterminate makes the whole node indeterminate, with no rescuing value on the other side (see [Three-valued propagation rules](#three-valued-propagation-rules)). - -### `call` - -A named function applied to an ordered list of `ExpressionNode` arguments. The set of named functions is intentionally open-ended and resolved through a function registry supplied at evaluator construction time — `minimum`, `maximum`, `absoluteValue`, `round`, `squareRoot`, and `logarithm` are starting examples, not an exhaustive list; new functions are added to the registry as concrete need arises. Calling an unregistered function name is `wrong-type` ("no function registered under this name"); calling a registered function with an argument outside its domain (e.g. `squareRoot` given a negative number) is `domain-error`. - -### Units - -`numberLiteral`, `complexLiteral`, and `reference` may carry a `unit`, represented as a dimensional-exponent map (e.g. `{ m: 1, s: -1 }` for metres per second) rather than an opaque string, so that unit combination follows real dimensional analysis instead of string matching. A bare symbol like `"kg"` is shorthand for `{ kg: 1 }`. - -- `add`/`subtract` between two unit-tagged numbers require **identical** dimensional-exponent maps. A mismatch is `wrong-type` ("incompatible units") — units are never silently coerced or dropped. -- `multiply`/`divide` combine the two operands' unit maps by dimensional analysis: multiplying adds exponents per dimension, dividing subtracts them. An operand with no `unit` is treated as dimensionless (an empty map) for this purpose. - -### Temporal values - -`instant` (a point in time) and `duration` are computed-value kinds distinct from `number`, even though a duration ultimately carries a numeric magnitude — an instant is never treated as "a number that happens to represent a date". The only well-defined cross-kind arithmetic is: - -- `instant − instant → duration` -- `instant + duration → instant` (and `duration + instant → instant`) - -Any other arithmetic combination touching an `instant` or `duration` (adding two instants, multiplying a duration by an instant, comparing an instant against a plain number, and so on) is `wrong-type`. A reference implementation normalises `duration` values to a single base unit (milliseconds) internally before combining two durations of different `DurationUnit`s, then reports the result in whichever unit the node's own context calls for. - -### Complex values - -`complex` is a computed-value kind alongside `number`, for the domains — signal processing, control theory, anything phasor-shaped — where a formula naturally mixes real and complex terms in one expression. It stays inside this evaluator rather than behind [`delegate`](#delegate) because it is closed-form numeric evaluation, exactly what every other kind here already does; see [Design principles](#design-principles) for that scope test in general. - -**One canonical representation, rectangular.** A `complex` value is stored as `{ re, im }` and never as a magnitude and a phase, and there is deliberately no `form` discriminant offering both. Three reasons, in order of weight: - -1. **A second form would make equality ambiguous.** Polar coordinates do not encode a value uniquely — phase is only defined modulo a full turn, and a zero-magnitude value has no meaningful phase at all — so the same complex number would have unboundedly many polar encodings. `eq` and `memberOf` are exact equality throughout this design (see [`compare`](#compare)); making them work across two forms would mean either normalising on every comparison or introducing an approximate equality for this one kind, and neither belongs in a design where every other kind compares exactly. -2. **A discriminant would double the branching in every operator** — quadruple it for a binary one — for a choice that changes no value. Every operator would still convert to rectangular internally, because that is where the closed forms live, so the discriminant would buy nothing at evaluation time and cost at every boundary. -3. **Rectangular is what the operators actually need.** `add`/`subtract` are component-wise in it; `multiply`, `divide`, `negate`, and integer `power` all have standard closed forms in it. Polar's advantage — multiplication and division as one product of magnitudes and one sum of angles — does not extend to addition at all, which would have to convert back and forth. - -The magnitude-and-phase view stays reachable through four exported conversion helpers rather than a second encoding: `complexFromPolar(magnitude, phase, unit?)` and `complexLiteralFromPolar(magnitude, phase, unit?)` build a value or a literal node from polar terms, and `complexMagnitude(value)` and `complexPhase(value)` read them back out — the magnitude as a real number in the value's own unit, the phase as a dimensionless real number of radians. Conversions at the edges, one representation in the middle. - -**The wire-format literal accepts either authoring form, structurally discriminated.** `ComputedValue`'s own `complex` kind stays exactly the single rectangular shape described above — nothing about it changes. But the `complexLiteral` *node* is a plain union of two shapes, `{ kind: "complexLiteral", re, im, unit? }` and `{ kind: "complexLiteral", magnitude, phase, unit? }`, told apart by which fields are present rather than by a `form` tag, since both still share the one literal `kind`. This is not a second encoding of `ComputedValue` reappearing through the back door — it exists only at the authoring boundary, for whichever of the two forms is natural for a given domain to write directly into JSON rather than hand-computing a conversion before ever constructing the tree, and the evaluator normalises whichever form was used to the single rectangular `ComputedValue` immediately, before any arithmetic, comparison, or negation ever runs. A rectangular literal and a polar literal representing the same underlying number are therefore indistinguishable from that point on: they evaluate to the identical `ComputedValue` and compare `eq` to one another exactly as two rectangular literals with the same components would. - -**Arithmetic.** - -- `add`/`subtract` are component-wise, requiring **identical** dimensional-exponent maps exactly as real numbers do (see [Units](#units)). -- `multiply`/`divide` are real complex multiplication and division — `(a + bi)(c + di) = (ac − bd) + (ad + bc)i`, and the corresponding quotient — never component-wise. Units combine by the same dimensional analysis real numbers use. A zero divisor means both components zero; a divisor with only a zero real part divides perfectly well. -- `power` is defined for a **real integer exponent** and evaluated as the repeated multiplication that integer exponentiation is, with a negative exponent the reciprocal of the positive one. Like a real `power`, it requires dimensionless operands. An arbitrary complex exponent is a genuinely bigger question — it needs the complex logarithm, which is multivalued, so it needs a branch-cut convention this design has not chosen — and is deliberately out of scope for now: it is `wrong-type`, as is a non-integer real exponent, on the same reading of that code used throughout ("an answer exists, but this operator does not accept this operand" — compare `power`'s existing dimensionless-operands requirement, also `wrong-type`). -- `modulo` is `domain-error`, not `wrong-type`: a remainder needs a canonical notion of how many whole divisors fit, and the complex plane has no ordering to supply one. There is no answer to accept, which is the same category as division by zero. - -**A real operand is promoted, never rejected.** Mixing a `number` with a `complex` in one `arithmetic` node works: every real number *is* a complex number with a zero imaginary part, so the promotion is exact, total, and canonical — unlike the temporal cross-kind combinations above, which had to be enumerated one by one precisely because no such embedding exists between an `instant` and a `duration`. Scaling a complex value by a real one, or offsetting it by a real constant, is the common case, and forcing every real literal in such a formula to be rewritten as a complex one would defeat the point. The result is `complex` whenever either operand is, even when the imaginary part comes out zero: a node's result kind follows its operand kinds, never the values that happen to flow through it. - -**Comparison is kind-strict, deliberately unlike arithmetic.** `gt`/`gte`/`lt`/`lte` are `wrong-type` for a `complex` operand — the complex plane carries no total order — exactly as they already are for `text`. `eq`/`neq` work normally, as exact equality across both components under the same unit-compatibility rule numbers already have, and `memberOf` matches the same way. But a `complex` compared against a `number` is `wrong-type`, with no promotion: arithmetic *produces* a value, so promoting a real operand 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` despite being a count of milliseconds underneath. - -Ordering a complex quantity therefore goes through whichever real projection the formula actually means — most often its magnitude. This package ships no built-in function set (see [`call`](#call)), so that bridge is an ordinary registry entry, one line over the exported helper: - -```ts -const functions: FunctionRegistry = { - magnitude: (args) => - args[0]?.kind === "complex" - ? complexMagnitude(args[0]) - : { domainError: "expected a complex argument" }, -}; -``` - -which a tree then calls like any other function, putting a real number back on the left of an ordinary `compare`: - -```json -{ - "kind": "compare", - "op": "gt", - "left": { "kind": "call", "fn": "magnitude", "args": [{ "kind": "reference", "key": "x" }] }, - "right": { "kind": "numberLiteral", "value": 13 } -} -``` - -### `lookup` - -Resolves a single value from a named external table-like source, keyed by one or more `ExpressionNode` keys, via resolver 2 (see [Resolvers](#resolvers)). The schema never interprets what "table" or "key" mean to a given consumer; `table` and the resolved key values are passed through verbatim. If any key expression is itself indeterminate, the lookup is indeterminate with that reason (no key evaluation, no lookup attempt). If the resolver reports no match, the result is `not-found`. - -### `conditional` - -A piecewise/conditional-value node: an ordered, possibly-empty list of `{ when, then }` cases plus a required `fallback`. An optional `hitPolicy` field (`"first"` or `"unique"`) decides how cases are read; absent is treated as `"first"` — the exact, unchanged behaviour of every tree serialised before this field existed, not a masked-bug fallback. - -**`hitPolicy: "first"`** (the default). Evaluates to the `then` of the first case whose `when` predicate is definitely `true`; if no case matches, evaluates to `fallback`. If evaluating a `when` predicate produces an indeterminate outcome **before any earlier case has matched**, the whole `conditional` node's own result is that same indeterminate outcome (reason preserved) — evaluation does not skip past an unknown guard to try the next one, because doing so could silently pick a later branch that only looks correct because an earlier one couldn't actually be checked. - -**`hitPolicy: "unique"`** asserts that at most one case is expected to match, and treats two or more matches as a data error rather than silently taking the first. Every case's `when` is evaluated concurrently (there is no "earlier case" to short-circuit on), then resolved in this order: - -1. **Two or more cases are definitely `true`** — `domain-error` ("more than one case matched under the 'unique' hit policy"), regardless of any other case's own indeterminacy. This mirrors `memberOf`/`some`/`every`'s existing absorption: a confirmed outcome (here, "there is a genuine ambiguity") cannot be undone by an unrelated case's data problem. -2. Otherwise, **any case's `when` is indeterminate** — the whole node is indeterminate with that reason (first such candidate, in declared case order, per [The evaluation model](#the-evaluation-model)'s tie-break rule). This is deliberately **not** absorbed by a single already-confirmed match, unlike step 1 above and unlike `memberOf`/`some`/`every`'s own absorption: an unresolved case might still turn out to be a second match, so "exactly one match so far" cannot be trusted as final until every other case is known to not also match. -3. Otherwise, **exactly one case is definitely `true`** — evaluate and return that case's `then`. No other case's `then` is ever evaluated. -4. Otherwise (zero matches, and nothing indeterminate) — evaluate and return `fallback`, exactly as `"first"` already does. - -### `fold` - -An aggregation over a collection (see [Collections](#collections)): `collection` is the opaque collection reference; an optional `filter` narrows which resolved items participate (see [Collections](#collections)); `combiner` decides how the participating items' values become one result. There is exactly one general mechanism, `reduce`, and exactly two named forms, `max`/`min`, that cannot be expressed as an instance of it — see [Derived aggregates](#derived-aggregates) for why `sum`, `count`, and `average` need no combiner mode of their own at all. - -**`reduce`** is "fold with an accumulator": `initial` is evaluated once, in the fold node's own (outer) context, to seed the running result; then, for each participating item in turn, `combine` is evaluated with that item as its evaluation context to produce the new running result from the old one. `combine` reaches the running result through the dedicated [`accumulator`](#accumulator) leaf; the item's own fields are reached the ordinary way, through `reference`/`lookup` nodes resolved against the item context. Over an empty (post-filter) collection, a `reduce` fold evaluates to `initial` without ever touching `combine`. - -**`max`/`min`** each carry an `item`, evaluated once per participating item using that item as its evaluation context, and keep the largest/smallest projected value seen. These two are the only combining behaviours that stay as their own directly-specified forms, for a precise mathematical reason rather than an arbitrary exception: `reduce` needs a seed value that is also the identity for `combine` (as `0` is for addition), and there is no largest or smallest real number to seed a running maximum or minimum with — the JSON number model has no literal for an unbounded sentinel. `max`/`min` are still the same underlying mechanism, just its standard *unseeded* variant (sometimes called "reduce1" elsewhere): the running result starts as the first participating item's own projected value, and `combine` (the ordinary "keep the larger"/"keep the smaller" comparison) is applied to each item after that — not an independently-invented special case, only the one variant of the mechanism that a literal `initial` genuinely cannot express. Over an empty (post-filter) collection, both are `domain-error` (undefined over an empty set, the same category as division by zero, per [The evaluation model](#the-evaluation-model)'s explicit allowance for "any comparable domain violation for any function added later") — there is no first item to seed from. - -**Indeterminacy, both forms.** If any participating item's `filter` evaluation is indeterminate, the whole `fold` is indeterminate with that reason — `fold` has no absorbing value (see [Three-valued propagation rules](#three-valued-propagation-rules)), so unlike a quantifier's OR/AND there is no other item's outcome that can override this (see [Pre-filtering which items participate](#pre-filtering-which-items-participate)). The same is true of any participating item's `item`/`combine` evaluation, and of a `reduce`'s `initial`: if any is indeterminate, the whole `fold` is indeterminate with that reason (first such candidate, in resolved-list order, `initial` counting as evaluated before any item). - -### `accumulator` - -A zero-field leaf, meaningful only inside the `combine` expression of an enclosing `fold`'s `reduce` form (see [`fold`](#fold) above), where it evaluates to that step's running accumulated result. A nested `fold`'s own `combine` expression introduces its own, separate accumulator scope — `accumulator` always refers to the innermost enclosing reduce fold. Using `accumulator` anywhere else (a `max`/`min` fold's `item`, a `filter` predicate, a quantifier's `item`, or outside any fold at all) is `wrong-type` — there is no running accumulator in scope. - -### Derived aggregates - -`sum`, `count`, and `average` are never their own `FoldCombiner` mode — each is a builder function that assembles an ordinary `fold` (and, for `average`, one `arithmetic` division of two ordinary folds), exactly the same treatment [Derived connectives](#derived-connectives) already gives `xor`/`nand`/`nor`/`implies`/`iff`/`none`: correctness is inherited from the mechanism they're built from, rather than needing its own independent implementation that could silently drift from it. - -```ts -const sum = (collection: JsonValue, item: ExpressionNode, filter?: PredicateNode): ExpressionNode => ({ - kind: "fold", - collection, - filter, - combiner: { - mode: "reduce", - initial: { kind: "numberLiteral", value: 0 }, - combine: { kind: "arithmetic", op: "add", left: { kind: "accumulator" }, right: item }, - }, -}); - -const presenceOf = (probe: ExpressionNode): ExpressionNode => ({ - kind: "conditional", - cases: [ - { - when: { kind: "memberOf", op: "in", operand: probe, candidates: [probe] }, - then: { kind: "numberLiteral", value: 1 }, - }, - ], - fallback: { kind: "numberLiteral", value: 0 }, // unreachable: a definite probe is always a member of the single-element list containing only itself -}); - -const count = (collection: JsonValue, filter?: PredicateNode, probe?: ExpressionNode): ExpressionNode => ({ - kind: "fold", - collection, - filter, - combiner: { - mode: "reduce", - initial: { kind: "numberLiteral", value: 0 }, - combine: { - kind: "arithmetic", - op: "add", - left: { kind: "accumulator" }, - right: probe ? presenceOf(probe) : { kind: "numberLiteral", value: 1 }, - }, - }, -}); - -const average = (collection: JsonValue, item: ExpressionNode, filter?: PredicateNode): ExpressionNode => ({ - kind: "arithmetic", - op: "divide", - left: sum(collection, item, filter), - right: count(collection, filter), -}); -``` - -`sum` needs no per-item probe beyond `item` itself: it is a literal `reduce` seeded at `0`, adding each participating item's projected value to the running total, and it already goes indeterminate if `item` fails to resolve for any participating item — no separate mechanism needed, since `item`'s value is exactly what gets added. - -`count` takes an optional third argument, `probe`, and this is where it matters that `filter` and a probe are not the same thing. `filter` *excludes* an item from participating — a filtered-out item's absence is invisible in the final result, exactly as if it had never been in the collection at all. A `probe` does the opposite: it doesn't decide whether an item participates, it makes the *whole count* indeterminate if it fails to resolve for *any* participating item, surfacing "I cannot give you a trustworthy count" rather than silently reporting a smaller, technically-successful count for the same underlying data-quality problem — precisely the distinction the rest of this document's indeterminate-outcome model exists to preserve (see [The evaluation model](#the-evaluation-model)). `count(collection, filter)` with no `probe` is a plain `reduce` seeded at `0` that adds `1` per participating item, with no indeterminacy of its own beyond `filter`'s. `count(collection, filter, probe)` instead adds `presenceOf(probe)` per participating item — a small helper built entirely from already-established primitives, with no restriction on `probe`'s kind: it tests `probe` for membership in the single-element list `[probe]`, so a `memberOf` "in" test against itself is trivially true whenever `probe` resolves to a definite value of *any* kind (`memberOf`'s equality is already kind-agnostic across `number`/`text`/`boolean`/`instant`/`duration` — see [`memberOf`](#memberof)), and exactly `probe`'s own indeterminate outcome otherwise, per `memberOf`'s own "evaluate `operand` first" rule. A `conditional` then turns that boolean into the number `1`; its `fallback` is never reached, since a definite `probe` always equals itself. (A real implementation may memoise `probe`'s single evaluation rather than running the resolver twice for `operand` and its one `candidates` entry — resolvers are pure functions of their inputs throughout this design, so this is a performance choice, not a correctness one.) - -`average` is `sum` divided by `count` over the same `collection`/`filter`, with no `probe` — `sum`'s own `item` already forces every participating item's projected value to resolve, so `average`'s numerator is already indeterminate under exactly the condition a `count` probe exists to detect, with nothing left to duplicate. Nothing new to verify for the empty-collection case either: division's own already-established rule (zero divisor is `domain-error`) is *why* `average` over an empty collection is `domain-error`, since `count` over an empty collection is `0` and `sum(...)/0` already means exactly that. - -### Derived values - -`coalesce` is never its own evaluated node kind — it is a builder function that assembles an ordinary `conditional`, the same treatment [Derived connectives](#derived-connectives) and [Derived aggregates](#derived-aggregates) already give `xor`/`nand`/`nor`/`implies`/`iff`/`none`/`sum`/`count`/`average`: correctness is inherited from the mechanism it's built from, rather than needing its own independent implementation that could silently drift from it. - -```ts -const coalesce = ( - first: ExpressionNode, - second: ExpressionNode, - ...rest: ExpressionNode[] -): ExpressionNode => - [first, second, ...rest].reduceRight( - (fallback, candidate): ExpressionNode => ({ - kind: "conditional", - cases: [{ when: { kind: "exists", operand: candidate }, then: candidate }], - fallback, - }), - ); -``` - -Built right-to-left over the full candidate list via `reduceRight`, needing no seed value: `first`/`second` are required arguments (rather than accepting a single `ExpressionNode[]`), which guarantees the list always has at least two elements, so the no-initial-value overload of `reduceRight` never hits an empty array. - -**Worked correctness check.** `coalesce`'s only interesting behaviour — whether a given candidate is skipped past or propagated — reduces entirely to what its single `exists` probe reports, per [`exists`](#exists)'s and [`conditional`](#conditional)'s own already-established rules: - -| A candidate's own evaluation | `exists(candidate)` | The `conditional` case | `coalesce` evaluates to | -|---|---|---|---| -| A definite value | definite `true` | matches | The candidate's value (re-evaluated as `then`, same result) | -| Indeterminate, `not-found` | definite `false` | does not match | The next candidate (the enclosing `fallback`), evaluated fresh | -| Indeterminate, `wrong-type` | definite `true` | matches | The candidate's own `wrong-type` result (re-evaluated as `then`) | -| Indeterminate, `domain-error` | definite `true` | matches | The candidate's own `domain-error` result (re-evaluated as `then`) | - -Falling through to the next candidate therefore happens only on `exists`'s own `false` — a genuinely absent value (`not-found`) — never on a candidate that resolved to something merely unusable (`wrong-type`/`domain-error`): `exists` already draws exactly that line, and `coalesce` inherits it unmodified rather than re-deciding it. This is the one behaviour a naive reimplementation is likely to get backwards (treating *any* indeterminate candidate as "try the next one"), so it is worth stating explicitly rather than leaving it to be inferred from the composition alone. - -A real implementation may memoise a candidate's single evaluation rather than running its resolver twice — once for the `exists` probe, once again for `then` — exactly the same performance caveat [Derived aggregates](#derived-aggregates)'s `presenceOf` already documents for its own `memberOf` probe; resolvers are pure functions of their inputs throughout this design, so this is a performance choice, not a correctness one. - -### Defining your own named presets - -This is exactly the same composition-not-new-logic treatment already given to `xor`/`sum`/`coalesce` above — nothing stops application code from defining its own named builder functions the same way, for whatever domain-specific composed queries come up repeatedly in a given consumer's own rules. - -```ts -/** isRecentlyActive(30) reads as "the item's lastActiveAt instant is within the last 30 days" — a small, named composition over compare/arithmetic, exactly the same "assembles ordinary nodes" treatment sum/coalesce already get above. Built as `now + (-days)` rather than `now - days`: per "Temporal values" above, `instant - duration` is not a defined cross-kind combination, only `instant + duration` is, so negating the duration first is how this reaches the same "days ago" instant using only defined operators. */ -const isRecentlyActive = (days: number): PredicateNode => ({ - kind: "compare", - op: "gt", - left: { kind: "reference", key: "lastActiveAt" }, - right: { - kind: "arithmetic", - op: "add", - left: { kind: "reference", key: "now" }, - right: { kind: "negate", operand: { kind: "durationLiteral", value: days, unit: "d" } }, - }, -}); -``` - -A UI surfacing these to an end user treats each one as a named preset in a node-picker — a label ("Recently active") plus whatever parameters the builder function takes (`days`) — and splices the expanded tree in at the point the user picked it, exactly as if they'd hand-built that subtree themselves; nothing about the resulting PredicateNode/ExpressionNode distinguishes a preset-sourced subtree from a manually-authored one. - -This is the "bake in at authoring time" half of the picture: a preset's own definition lives in application code, expanded once, at the moment a user picks it, into an ordinary static subtree. [`treeReference`](#treereference) is the complementary half — a *live*, centrally-editable reference, resolved fresh on every evaluation rather than expanded once at authoring time. Choosing between them is exactly the choice between "this composition is fixed application logic" (a named preset, this section) and "this composition is itself data someone should be able to edit without a deploy" (a treeReference). - -### `delegate` - -An explicitly-named external system plus an arbitrary, unevaluated JSON payload, standing in for the whole node without this package attempting to evaluate it itself — see [Out of scope](#out-of-scope). Evaluating a `delegate` node is not part of this package's own evaluation semantics. The reference evaluator accepts an optional delegate handler per external system name; if none is registered for the named `system`, evaluating the node is indeterminate (`wrong-type`, "no delegate handler registered for external system ''"). Consumers who want a `delegate` node to actually resolve are expected either to register a handler, or to pre-process the tree — walk it, find delegation nodes, invoke the named external system out of band, and substitute the result as a literal — before the tree ever reaches this package's evaluator. - -### `treeReference` - -A reference to a whole other tree, identified by an opaque `key` (see [Resolvers](#resolvers)) — the only node kind valid from *both* a `PredicateNode` and an `ExpressionNode` position: the exact same schema is appended as the last member of each of the two discriminated unions above, not two separately-declared copies that happen to look alike. Unlike [`delegate`](#delegate), which hands the whole node off to an *external* system this package never evaluates, `treeReference` resolves to *another tree of this same schema* and evaluates it with this same evaluator — a sub-rule reference, not an escape hatch. - -`resolveTree` is optional on `Resolvers`, for the same reason `resolveDelegate` is: a well-defined indeterminate result on absence, not a masked bug, and an additive, non-breaking interface change for every consumer implemented before this node kind existed. If no `resolveTree` is registered, evaluating a `treeReference` node is `wrong-type` ("no tree resolver registered for treeReference nodes"). If the resolver reports no match, the result is `not-found`. - -A resolved tree is never merely trusted — it is re-validated with a fresh `PredicateNodeSchema`/`ExpressionNodeSchema` parse (`PredicateNodeSchema` from a predicate-position reference, `ExpressionNodeSchema` from an expression-position one) before evaluation proceeds, exactly the same discipline the top-level tree itself is subject to when it first arrives at this package. A resolver fetching "a named rule from storage" is very often surfacing the same non-developer-authored JSON the top-level tree already is, with no static guarantee it still matches the schema; a failed parse is `wrong-type`. - -`context` and the enclosing fold's `accumulator` both pass through a `treeReference` unchanged — the referenced tree shares the caller's evaluation scope, like a subroutine call, not a nested evaluation with its own fresh context. There is deliberately no mechanism to override the context at a `treeReference` boundary; a consumer wanting that already has [`delegate`](#delegate). - -**Cycle and depth protection.** A tree that references itself, directly or through a longer chain, is guarded by two independent, layered checks rather than one: a cycle detector tracks every `key` (by its `JSON.stringify`'d form) already on the current reference chain, and reports `domain-error` ("circular treeReference detected") the moment a repeat is seen; a fixed depth cap separately reports `domain-error` ("...exceeds the maximum depth") on a long *acyclic* chain the cycle detector alone would never catch. Neither check is a substitute for the other. - -**A known, accepted design consequence.** `resolveTree` has no built-in way to know whether a given `key` is being resolved from a predicate-position or an expression-position reference — the same is already true of `reference.key` and `lookup.table`, neither of which carries a type discriminator either. A consumer needing to disambiguate structures their own key accordingly (e.g. `{ kind: "predicate", id: "..." }` as the `JsonValue` itself) rather than this schema growing a bespoke field for it. - -## Collections - -Both `fold` and the two quantifier leaves (`some`/`every`, and transitively `none`) need "a collection of items" resolved from something the schema itself treats as opaque data. The schema's job is only to carry an opaque reference to what collection is meant, plus a sub-node (an `ExpressionNode` for `fold`, a `PredicateNode` for the quantifiers) to be evaluated once per resolved item, using that single item as its evaluation context, plus an optional per-item pre-filter — see [Pre-filtering which items participate](#pre-filtering-which-items-participate). - -How an opaque collection reference actually becomes a concrete list of items is entirely the resolver's responsibility, and is expected to vary enormously between consumers — one consumer's "collection" might be an array already sitting inside a single in-hand record (zero further lookups needed); a completely different consumer's "collection" might require actively traversing some larger connected structure outward from a starting point to discover which items even belong to it, with nothing available up front. The schema and evaluator support both extremes, and anything in between, equally well, purely by keeping the reference opaque and leaving all resolution logic behind the injected collection resolver — there is no assumption anywhere about how many steps are involved in turning a reference into a list. - -### Evaluation context - -```ts -type EvaluationContext = unknown; -``` - -Every evaluation call is threaded through an `EvaluationContext` — an opaque, purely in-process value supplied by the caller, never itself part of the serialised tree and never required to be JSON-serialisable (unlike every payload described above, which *does* travel inside the tree and must be plain JSON). `reference` and `lookup` resolution both receive the current context. Descending into a `fold` or a quantifier replaces the context for the sub-node's evaluation with the single resolved item — literally the item itself, not a wrapper around it — so that a `reference` inside `item`/case sub-trees resolves against that item rather than against whatever the outer context was. - -### Pre-filtering which items participate - -`fold`, `some`, and `every` each accept an optional `filter: PredicateNode`, evaluated once per candidate item using that item as its own evaluation context — exactly the same mechanism `fold`'s own per-item expression and the quantifiers' own `item` sub-node already use. An item for which `filter` is definitely `true` participates; one for which it is definitely `false` is excluded, exactly as if it had never been in the collection at all. Time-window narrowing (only include items whose own timestamp falls within given bounds) is simply one example use of this general mechanism — a `filter` predicate comparing the item's own timestamp field against bounds via `compare` — not a separate concept, and there is no dedicated time-scoping field alongside it. A resolver that already knows how to push a narrowing hint down into its own data access remains free to do so using whatever it can infer from the opaque `collection` reference and `context` it already receives — `filter` narrows the schema's own view of the result, it doesn't preclude a resolver-side optimisation underneath. - -An item whose `filter` is itself indeterminate is never silently included or excluded — silently picking either would hide a real data-quality problem behind an arbitrary default. What happens next depends on whether the surrounding node has an absorbing value: `fold` has none (see [Three-valued propagation rules](#three-valued-propagation-rules)), so an indeterminate `filter` on any candidate item unconditionally makes the whole `fold` indeterminate, exactly as an indeterminate `item`/`combine` evaluation already does. The quantifiers do have one: an indeterminate `filter` makes that one item's own contribution to the surrounding OR (`some`)/AND (`every`) indeterminate, and the quantifier's already-established absorption rule then decides the final result exactly as it already does for an indeterminate `item` evaluation — a `some` with one item whose `filter` can't be resolved still comes back definitely `true` if a different, cleanly-filtered item is a definite match. Treating an indeterminate filter as an automatic override of an already-decided quantifier result would reintroduce, for filtering specifically, exactly the "any indeterminate operand poisons everything, no absorption" defect this document already identifies as wrong for AND/OR in general. - -## Resolvers - -Three core, required points of extension, plus two further independent optional ones, each supplied separately by the embedding consumer, each treated by the schema as pure data to hand over — never as resolver logic living inside the schema itself: - -```ts -type Resolution = - | { found: true; value: ComputedValue } - | { found: false }; - -type TreeResolution = - | { found: true; node: JsonValue } - | { found: false }; - -interface Resolvers { - /** Resolver 1 — a single opaque key to a single value (IV.reference). */ - resolveValue(key: JsonValue, context: EvaluationContext): Promise; - - /** Resolver 2 — an opaque table identifier plus computed keys to a single value (IV.lookup). */ - resolveLookup(table: JsonValue, keys: ComputedValue[], context: EvaluationContext): Promise; - - /** Resolver 3 — an opaque collection reference to a concrete list of items (fold/some/every). */ - resolveCollection(collection: JsonValue, context: EvaluationContext): Promise; - - /** Optional, separate from the three core contracts — see the `delegate` node kind. */ - resolveDelegate?(system: string, payload: JsonValue, context: EvaluationContext): Promise; - - /** Optional, separate from the three core contracts — see the `treeReference` node kind. */ - resolveTree?(key: JsonValue, context: EvaluationContext): Promise; -} -``` - -`resolveCollection` takes no narrowing parameter of its own: it always returns the full candidate list for the given reference, and narrowing which of those candidates actually take part is handled uniformly, after resolution, by the `filter` mechanism described under [Pre-filtering which items participate](#pre-filtering-which-items-participate) — no resolver needs a bespoke narrowing argument for this. It also returns a plain array rather than a `Resolution` envelope: a collection's "nothing here" state is unambiguously an empty array, unlike a single value's absence, which needs an explicit flag to distinguish "there is genuinely nothing here" from any value the resolver might otherwise legitimately return. Each resolver may itself be asynchronous, independently of the others. None of the three core resolvers needs to know anything about the other two, or about either optional one; a consumer implementing all five is free to have them share underlying data-access logic, but the schema and evaluator never require or assume that they do. - -`resolveTree` returns a `TreeResolution`, deliberately shaped like `Resolution` but distinct from it: `node` carries opaque JSON — the referenced tree, re-validated by the evaluator rather than trusted (see [`treeReference`](#treereference)) — where `Resolution`'s `value` carries an already-typed `ComputedValue`. It is otherwise the same "found" envelope for the same reason: a `treeReference`'s absence needs to be distinguishable from any tree the resolver might otherwise legitimately return, exactly as a `reference`'s absence needs to be distinguishable from any value. `resolveDelegate` and `resolveTree` solve different problems and are never a substitute for one another: `resolveDelegate` hands a payload to an *external* system this package never evaluates; `resolveTree` hands back *more of this same schema*, for this same evaluator to keep evaluating. - -## Evaluator entry points - -```ts -function evaluatePredicate( - node: PredicateNode, - context: EvaluationContext, - resolvers: Resolvers, -): Promise>; - -function evaluateValue( - node: ExpressionNode, - context: EvaluationContext, - resolvers: Resolvers, -): Promise>; -``` - -Both are exported directly, bound to an empty function registry — under them, any [`call`](#call) node is `wrong-type`. The registry a `call` resolves against is fixed at evaluator construction time rather than passed per evaluation (unlike `resolvers`, which are supplied fresh on every call), so supplying one means building a bound pair: - -```ts -type FunctionRegistry = Record< - string, - (args: readonly ComputedValue[]) => ComputedValue | { domainError: string } ->; - -function createEvaluator(options: { functions?: FunctionRegistry }): { - evaluatePredicate: (node: PredicateNode, context: EvaluationContext, resolvers: Resolvers) => Promise>; - evaluateValue: (node: ExpressionNode, context: EvaluationContext, resolvers: Resolvers) => Promise>; -}; -``` - -A registered function signals an argument outside its domain by *returning* `{ domainError: message }` rather than throwing, which is what keeps `call` inside the same three-outcome model as every other node kind (see [The evaluation model](#the-evaluation-model)); only the registry's own keys count as registered names, so a tree naming an inherited `Object.prototype` member is `wrong-type` like any other unregistered name. - -```ts -const { evaluateValue } = createEvaluator({ - functions: { - squareRoot: (args) => { - const [arg] = args; - if (arg?.kind !== "number") return { domainError: "squareRoot requires one number argument" }; - if (arg.value < 0) return { domainError: "squareRoot of a negative number is not a real number" }; - return { kind: "number", value: Math.sqrt(arg.value) }; - }, - }, -}); -``` - -## Indeterminacy reference - -How each reason category can arise, per node kind. "Propagates" means: an indeterminate operand/sub-result, with no other rule overriding it, makes the whole node indeterminate with that same reason (subject to the tie-break rule in [The evaluation model](#the-evaluation-model) when more than one candidate reason is present, and to the absorbing-value exceptions called out explicitly below). - -| Node kind | `not-found` | `wrong-type` | `domain-error` | -|---|---|---|---| -| `not` | propagates from operand | propagates from operand | propagates from operand | -| `and` | propagates, **unless** the other operand is definitely `false` (absorbs) | as `not-found` | as `not-found` | -| `or` | propagates, **unless** the other operand is definitely `true` (absorbs) | as `not-found` | as `not-found` | -| `allOf` / `anyOf` | as `and`/`or`, extended pairwise across the list | as `and`/`or` | as `and`/`or` | -| `compare` | either operand not found | operand kinds differ, or units incompatible, or kind is not `number`/`instant`/`duration`/`complex`, or an ordering operator was given a `complex` operand (see [Complex values](#complex-values)) | never directly (comparison itself has no domain restriction) | -| `textCompare` | either operand not found | either operand is not `text` | never directly | -| `memberOf` | `operand` not found, or (with no definite match found) a scanned candidate not found | `operand`/a candidate resolves to an incompatible kind or unit, with no definite match found among the rest | never directly | -| `exists` | never — converts operand `not-found` to definite `false` | never — converts operand `wrong-type`/`domain-error` to definite `true` | never — see `wrong-type` column | -| `some` / `every` | an item's `filter` or `item` sub-node reports not-found, and it is not absorbed by an already-decided item | as `not-found` | as `not-found` | -| literals (`numberLiteral`, `textLiteral`, `instantLiteral`, `durationLiteral`, `complexLiteral`) | never | never | never | -| `reference` | resolver reports absence | resolver's value doesn't match an expected `unit`, or is used where an incompatible kind is required upstream | never directly | -| `arithmetic` | either operand not found | operand not numeric (or temporal-kind mismatch — see [Temporal values](#temporal-values)), or unit mismatch on add/subtract, or a `power` exponent that is not a real integer over a `complex` operand (see [Complex values](#complex-values)) | zero divisor, `modulo` over a `complex` operand, or any other documented domain violation for the operator | -| `negate` | operand not found | operand not `number`/`duration`/`complex` | never directly | -| `call` | any argument not found | unregistered function name, or an argument of the wrong kind for that function | argument outside the function's valid domain (e.g. negative input to `squareRoot`) | -| `lookup` | any key not found, or resolver reports no match | a key expression resolves to the wrong kind for that table | never directly | -| `conditional` | `"first"`: an unmatched guard's own evaluation is `not-found`, before any earlier guard matched.
`"unique"`: any case's `when` is `not-found`, unless 2+ cases already definitely matched (see `domain-error`, which then takes priority).
Both: also the chosen branch's (`then`/`fallback`) own result if it is `not-found`. | Same pattern as `not-found`, substituting `wrong-type` throughout (guard evaluation and chosen branch alike). | `"unique"` only: 2+ cases are definitely `true` — see [`conditional`](#conditional)'s absorption order.
Both: same pattern as `not-found`, substituting `domain-error` (guard evaluation and chosen branch alike). | -| `fold` | any participating item's `filter`, `item`, or `combine` evaluation is `not-found`; or a `reduce`'s `initial` is `not-found` | any participating item's `filter`, `item`, or `combine` evaluation is `wrong-type`; or a `reduce`'s `initial` is `wrong-type` | empty (post-filter) collection with `max`/`min` (no first item to seed from); or any participating item's `item`/`combine` evaluation is `domain-error`; or a `reduce`'s `initial` is `domain-error` | -| `accumulator` | never | used outside a reduce fold's `combine` expression | never | -| `delegate` | never (no resolution attempted without a handler) | no handler registered for the named `system` | never | -| `treeReference` | resolver reports no match | no `resolveTree` registered; or the resolved node fails schema validation | a circular reference is detected; or the reference chain exceeds the maximum depth | - -## Worked example - -A single condition combining a boolean tree, a comparison leaf whose value side is itself a formula, a fold/aggregation node, and all three resolver contracts in use — every name below is a generic placeholder. - -**Rule:** "`isActive` is true, and the sum of `amount` across the `items` collection is greater than `x + y`." `isActive` is a `boolean` computed value, compared for equality against the literal `true`. The `fold` below is exactly what the [`sum`](#derived-aggregates) builder produces — shown here as the literal tree it assembles, to keep the resolver trace below concrete. - -```json -{ - "kind": "and", - "left": { - "kind": "compare", - "op": "eq", - "left": { "kind": "reference", "key": "isActive" }, - "right": { "kind": "booleanLiteral", "value": true } - }, - "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" } - } - } -} -``` - -A minimal set of resolvers backing this against a plain in-memory record: - -```ts -const data = { - isActive: true, - x: 10, - y: 5, - items: [{ amount: 8 }, { amount: 12 }, { amount: 1 }], -}; - -const resolvers: Resolvers = { - async resolveValue(key, context) { - const record = context as Record; - if (typeof key !== "string" || !(key in record)) return { found: false }; - const value = record[key]; - if (typeof value === "boolean") return { found: true, value: { kind: "boolean", value } }; - return { found: true, value: { kind: "number", value: value as number } }; - }, - async resolveLookup() { - return { found: false }; // unused by this example - }, - async resolveCollection(collection, context) { - const record = context as Record; - return collection === "items" ? (record.items as unknown[]) : []; - }, -}; -``` - -Tracing the evaluation against `data` as the root `EvaluationContext`: - -1. `compare eq` (left branch): `resolveValue("isActive", data)` → `{ found: true, value: { kind: "boolean", value: true } }`; compared against `booleanLiteral true` → definite `true`. -2. `fold` (`reduce`, seeded at `0`): `resolveCollection("items", data)` → three items. The accumulator starts at `0`; for each item in turn, `combine` evaluates `accumulator + reference("amount")` with that single item as context — `resolveValue("amount", item)` → `8`, `12`, `1`, all definite — stepping the accumulator `0 → 8 → 20 → 21`. Final accumulator → `21`. -3. `arithmetic add`: `resolveValue("x", data)` → `10`; `resolveValue("y", data)` → `5`. Sum → `15`. -4. `compare gt` (right branch): `21 > 15` → definite `true`. -5. `and(true, true)` → definite `true`. - -Final result: `{ status: "definite", value: true }`. - -Two variations show the propagation rules in action without changing the tree at all. If `items` resolved to `[]`, step 2 would be `0` (the `sum`-over-empty identity), step 4 would be `0 > 15 → false`, and step 5 would be `and(true, false) → false` — still fully definite, because `false` absorbs regardless of how step 1 turned out. If instead `x` were missing from `data`, `resolveValue("x", data)` would report `{ found: false }`, making the `arithmetic add` indeterminate (`not-found`), the `compare gt` indeterminate for the same reason, and `and(true, indeterminate)` indeterminate too — `true` is not an absorbing value for AND, so the missing data surfaces all the way to the top-level result rather than being silently swallowed. - -## Out of scope - -This package is a representation-plus-evaluator for conditions and formulas over already-available (or resolver-obtained) data. It deliberately does not include: - -- **Symbolic algebra.** It cannot solve an expression for an unknown quantity, symbolically simplify an expression, or perform symbolic differentiation or integration. A consumer needing any of that is expected to translate the pure-arithmetic portion of an expression tree into the input format of existing, general-purpose symbolic-mathematics software — several mature, freely available options already exist — and let that external system do the symbolic work. This package's job stops at representing and numerically evaluating a tree, not manipulating it symbolically. -- **Batch unresolvable-reference reporting.** This design deliberately has no node kind for asking "which of these references, across a whole batch, are unresolvable" as a single evaluation — only the [`exists`](#exists) leaf's one-at-a-time true/false/false-on-absence check. A tool that wants to report a *list* of every missing reference (for an authoring UI validating a tree before it's saved, say) is expected to build that on top of `exists` — walk the references of interest and evaluate an `exists` leaf over each — at the authoring/tooling layer, rather than this package growing a bespoke aggregate-diagnostic node kind for it. This is a deliberate boundary, not an oversight: it keeps the evaluation tree itself limited to producing one `Evaluation` per node, and leaves "collect many such results and report on them together" to whatever sits above the evaluator, exactly like symbolic algebra above is left to whatever sits beside it. - -This package does not name or depend on any specific external tool for the delegation case above — it only defines the shape of the hand-off (an opaque payload plus a named destination system). - -Complex-number and phasor arithmetic used to be listed here too, delegated out on the reasoning that supporting them would be a far larger and more invasive change than adding one more named function. That sizing was wrong: unlike symbolic algebra, which is a genuinely different kind of system, complex arithmetic is closed-form numeric evaluation, exactly what this evaluator already does for every other computed-value kind. It is now part of the core numeric model — see [Complex values](#complex-values), and [Design principles](#design-principles) for the scope test that judgement is now written down as. - -## Prior art - -Twenty-three existing tools — JSON rule engines, expression languages, query-filter conventions, and three-valued-logic precedents — researched against seven properties trilean combines: a genuinely portable representation, injected async data access, a real three-outcome logic, vendor-agnostic scope, mixing logic and arithmetic in one tree, no code-execution surface for an untrusted author, and a formally published schema. None of the twenty-three combine all seven. Each verdict below was checked against the tool's own documentation, specification, or a security advisory, not assumed from category. +Configuration that is genuinely per-package — its ESLint config's file scoping and import bans, its tsconfigs, its vitest projects, its wrangler config — lives in the package. Configuration that is a property of the repository — commit-message rules, the pre-commit hook, formatting, the release pipeline — lives at the root, because only one copy of each can ever take effect. -The closest structural relative is [GoRules' Zen Engine](https://gorules.io) (`@gorules/zen-engine`), whose JDM format is a genuinely portable JSON decision graph with a real injected extension point — but its outcome model is value-level nulls, not a propagating three-valued logic, and its "Function" node type runs real JavaScript rather than staying within a bounded grammar. The closest semantic relative is [DMN](https://www.omg.org/spec/DMN)'s FEEL expression language, which implements the identical absorbing-AND/OR three-valued truth tables trilean does — but FEEL's canonical form is XML, not JSON. +## Contributing -Worth knowing: a Rust crate on crates.io is also named [`trilean`](https://crates.io/crates/trilean) and also implements Kleene's three-valued logic — a genuine name collision, different ecosystem, no npm conflict, unrelated project. +See [CONTRIBUTING.md](CONTRIBUTING.md) for the hooks, the gate a change has to pass, and the constraints the library's design depends on. Security reports go through [SECURITY.md](SECURITY.md). -The security research here surfaced findings worth knowing independent of the comparison: [JSONata](https://jsonata.org) has had multiple prototype-pollution CVEs reaching `Function`/`child_process`; [jexl](https://github.com/TomFrost/Jexl) has a documented, unfixed path to `Function.prototype` via `__proto__`; [expr-eval](https://github.com/silentmatt/expr-eval) has a prototype-pollution CVE (CVE-2026-12866); and MongoDB's `$where`/`$function` and JsonLogic's `method` operator are documented, acknowledged arbitrary-code escape hatches. [CEL](https://cel.dev), [filtrex](https://github.com/cshaa/filtrex), and [Rego](https://www.openpolicyagent.org/docs/policy-language) are the standouts, each explicitly designed and marketed as safe for untrusted input. +## Licence -| Tool | Category | Portable data | Async access | Missing ≠ false | Vendor-agnostic | Mixes logic & math | No code-exec risk | Published schema | -|---|---|---|---|---|---|---|---|---| -| [trilean](https://www.npmjs.com/package/trilean) | — | 🟢
JSON, Zod-validated, RFC 8785 canonical | 🟢
three typed resolver contracts | 🟢
definite/indeterminate, typed reason | 🟢
no assumptions about consumer data | 🟢
compare/textCompare/memberOf take formulas | 🟢
call's fn is a registry key, never code | 🟢
one Zod schema generates both | -| [JsonLogic](https://www.npmjs.com/package/json-logic-js) | Rule engine | 🟢 | 🔴
direct path lookup | 🔴
counts as false | 🟢 | 🟢
any operand can be another rule | 🟡
`method` op is an acknowledged escape hatch | 🔴
a JSON Schema request was never resolved | -| [json-rules-engine](https://www.npmjs.com/package/json-rules-engine) | Rule engine | 🟢 | 🟢
async fact handlers | 🔴
undocumented | 🟢 | 🔴
docs call inline formulas "a design smell" | 🟢
operators are name-based registry lookups | 🔴
a proposed schema was never merged | -| [json-rules-engine-simplified](https://www.npmjs.com/package/json-rules-engine-simplified) | Rule engine | 🟢 | 🔴
direct path lookup | 🔴
falls through to false | 🟢 | 🔴
no arithmetic/formula node exists | 🟢
no `eval()`, by the project's own claim | 🔴
README prose only | -| [Zen Engine](https://www.npmjs.com/package/@gorules/zen-engine) / [JDM](https://docs.gorules.io/developers/jdm/standard) | Rule engine | 🟢 | 🟢
injected custom-node callback | 🟡
null-coalescing only | 🟢 | 🟢
ZEN expressions nest arithmetic in comparisons | 🟡
Function nodes run real JS, sandboxed | 🟡
docs claim one, none found published | -| [nools](https://www.npmjs.com/package/nools) | Rule engine | 🔴
JS/DSL | 🔴 | 🔴 | 🟢 | 🟢
DSL nests arithmetic in comparisons | 🔴
the `then` block is literal JS | 🔴
DSL documented only in prose | -| [rools](https://www.npmjs.com/package/rools) | Rule engine | 🔴
rules are JS | 🔴 | 🔴 | 🟢 | 🟢
but only because it's unrestricted JS | 🔴
"rules are specified in pure JavaScript" | 🔴
plain JS, prose docs only | -| [node-rules](https://www.npmjs.com/package/node-rules) | Rule engine | 🔴
conditions are JS | 🔴 | 🔴 | 🟢 | 🟢
but only because it's unrestricted JS | 🔴
a condition is explicitly "a function" | 🔴
prose docs only | -| [JSONata](https://www.npmjs.com/package/jsonata) | Expression lang. | 🟡
undocumented shape | 🟢 | — | 🟢 | 🟢
arithmetic on both sides of any comparison | 🔴
multiple prototype-pollution CVEs to RCE | 🔴
hand-written parser, a grammar request was declined | -| [CEL](https://cel.dev) | Expression lang. | 🟢
as Protobuf, not JSON | 🔴
sync by design | — | 🟢 | 🟢
arithmetic feeds directly into comparison | 🟢
explicitly designed safe for untrusted code | 🟢
versioned .proto files, wire-compatible forever | -| [jexl](https://www.npmjs.com/package/jexl) | Expression lang. | 🔴
private, unexposed | 🟢
closest match | — | 🟢 | 🟢
arithmetic and logical ops nest freely | 🔴
documented unfixed `__proto__` access issue | 🔴
grammar lives in a JS source file | -| [filtrex](https://www.npmjs.com/package/filtrex) | Expression lang. | 🔴
AST retained | 🔴
sandboxed sync closure | — | 🟢 | 🟢
documented example nests a product in a condition | 🟢
markets itself explicitly as safe for end-users | 🔴
a real grammar file exists but ships unpublished | -| [expr-eval](https://www.npmjs.com/package/expr-eval) | Expression lang. | 🔴
(jsep-based siblings do) | 🔴 | — | 🟢 | 🟢
and/or plus comparisons alongside arithmetic | 🔴
CVE-2026-12866, prototype pollution to RCE | 🔴
prose README only | -| [mathjs](https://www.npmjs.com/package/mathjs) | Expression lang. | 🔴
round-trip unreliable | 🔴
sync `evaluate()` | — | 🟢 | 🟢
arithmetic binds tighter than and/or, by design | 🟡
`eval` removed, but real sandbox-escape CVEs existed | 🔴
documented only in prose | -| [MongoDB query operators](https://www.mongodb.com/docs/manual/reference/operator/query/) | Query/filter DSL | 🟢 | — | — | 🔴
MQL only | 🟡
only behind the `$expr` escape hatch | 🟡
`$where`/`$function` run arbitrary server-side JS | 🟡
an official grammar exists but is archived since 2021 | -| [Prisma `where`](https://www.prisma.io/docs/orm/prisma-client/queries/filtering-and-sorting) | Query/filter DSL | 🔴
never transmitted | — | — | 🔴
per-schema generated | 🔴
computed fields aren't usable for filtering at all | 🟢
a fixed, enumerated operator set only | 🔴
generated internal TypeScript types only | -| [OData `$filter`](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html) | Query/filter DSL | 🟢
as a query string | — | — | 🟢
OASIS standard | 🟢
arithmetic operators combine directly with comparisons | 🟢
fixed operator set, no code-reference mechanism | 🟢
a normative, versioned ABNF grammar document | -| [JSON:API `filter`](https://jsonapi.org/format/#fetching-filtering) | Query/filter DSL | 🔴
reservation only | — | — | 🟡
no grammar defined | — | — | — | -| [GraphQL (Hasura-style)](https://hasura.io/docs/2.0/queries/postgres/filters/boolean-operators/) | Query/filter DSL | 🟢 | — | — | 🟡
de facto convention | 🟡
operand can be a column, never a computed formula | 🟢
fixed comparison-operator vocabulary | 🟡
real schema, but generated per deployment | -| [SQL `NULL` / `UNKNOWN`](https://www.postgresql.org/docs/current/functions-comparison.html) | 3VL precedent | 🔴
language semantic | — | 🟢
same absorbing tables | 🟢 | 🟢
WHERE-clause operands are arbitrary expressions | — | — | -| [DMN's FEEL](https://www.omg.org/spec/DMN) | 3VL precedent | 🟢
as XML, not JSON | — | 🟢
absorbing | 🟢
OMG standard | 🟢
full FEEL mixes arithmetic and and/or freely | 🟡
a boxed function can invoke external Java/PMML by name | 🟡
DMN XML has an XSD, FEEL itself is prose BNF | -| [OPA / Rego](https://www.openpolicyagent.org/docs/policy-language) | 3VL precedent | 🟢
, as source text | — | 🟡
absence, not a value | 🟢 | 🟢
comparisons take arithmetic sub-expressions | 🟢
explicitly not Turing-complete, by design | 🔴
grammar is prose EBNF, no standalone file | -| [AWS IAM policy language](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_grammar.html) | 3VL precedent | 🟢 | — | 🟡
default, not propagating | 🔴
AWS-specific | 🔴
every condition value is a literal string | 🟢
bounded grammar, fixed operator set | 🔴
only a prose BNF-like description | -| [`trinary`](https://pypi.org/project/trinary/), [`tvl`](https://github.com/archanpatkar/tvl), [`3vl`](https://www.npmjs.com/package/3vl), Go [`ternary`](https://github.com/mithrandie/ternary) | 3VL precedent | 🔴
in-memory only | — | 🟢
Kleene K3 | 🟢 | — | — | — | +MIT — see [LICENSE](LICENSE). diff --git a/commitlint.config.ts b/commitlint.config.ts index c1fac9b..569891c 100644 --- a/commitlint.config.ts +++ b/commitlint.config.ts @@ -1,4 +1,4 @@ -import { commitTypes } from "./release.config"; +import { commitTypes } from "./release-workspace.config"; export default { extends: ["@commitlint/config-conventional"], diff --git a/eslint.config.ts b/eslint.config.ts index 3a779e7..cff2115 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,30 +1,27 @@ -import { builtinModules } from "node:module"; import { exadevConfig } from "@exadev/eslint-config"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; import globals from "globals"; -const nodeBuiltinBaseModules = [ - ...new Set( - builtinModules - .filter((name) => !name.startsWith("_") && !name.startsWith("node:")) - .map((name) => - name.includes("/") ? name.slice(0, name.indexOf("/")) : name, - ), - ), -].sort(); -const bareNodeBuiltinPattern = `^(${nodeBuiltinBaseModules.join("|")})(/.*)?$`; - -const runtimeSrcExemptions = ["src/**/*.test.ts", "src/test-support/**"]; - +// Lints the workspace root's own files -- its TypeScript tooling configs and its prose and data -- and nothing else. +// +// packages/** stays ignored here. The package keeps its own eslint.config.ts, because file scoping, tsconfig wiring, and the isomorphism import bans are genuinely per-package concerns; a root run that also walked the package would apply this program's tsconfig to source files it does not contain. export default exadevConfig( {}, { - ignores: ["dist", "coverage", "node_modules", ".turbo", "schemas"], + ignores: [ + "packages/**", + "node_modules", + ".turbo", + "coverage", + // Generated by semantic-release from commit messages; nothing about its formatting is a human decision, and the same goes for the lockfile. + "CHANGELOG.md", + "pnpm-lock.yaml", + ], }, { languageOptions: { parserOptions: { - project: ["./tsconfig.json", "./tsconfig.node.json"], + project: ["./tsconfig.json"], tsconfigRootDir: import.meta.dirname, }, globals: { ...globals.node }, @@ -36,51 +33,6 @@ export default exadevConfig( "error", { fixStyle: "inline-type-imports" }, ], - "exadev/barrel-policy": ["error", { mode: "single" }], - }, - }, - { - files: ["src/**/*.ts"], - ignores: runtimeSrcExemptions, - rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: ["node:*", "node:*/**"], - message: - "This is an isomorphic library: node:* imports are banned in runtime src.", - }, - { - regex: bareNodeBuiltinPattern, - message: - "This is an isomorphic library: bare Node builtin imports are banned in runtime src.", - }, - ], - }, - ], - "no-restricted-globals": [ - "error", - { - name: "Buffer", - message: "Buffer is Node-only; use Uint8Array/plain objects instead.", - }, - ], - }, - }, - { - // recommendedTypeChecked sets linterOptions.noInlineConfig, banning eslint-disable comments everywhere. src/tree.ts genuinely needs one (see the comment at its top) for its z.lazy() mutual-recursion pattern, so inline directives are permitted for this one file only. - files: ["src/tree.ts"], - linterOptions: { noInlineConfig: false }, - }, - { - files: ["**/*.test.ts"], - rules: { - "@typescript-eslint/no-empty-function": [ - "error", - { allow: ["arrowFunctions", "asyncFunctions"] }, - ], }, }, eslintPluginPrettierRecommended, diff --git a/lint-staged.config.ts b/lint-staged.config.ts index f571a4f..5dd9b87 100644 --- a/lint-staged.config.ts +++ b/lint-staged.config.ts @@ -1,7 +1,51 @@ +import { relative, sep } from "node:path"; import type { Configuration } from "lint-staged"; +// ESLint's flat config resolves from the working directory, not from each linted file's own directory, so running `eslint --fix` at the workspace root over staged files in packages// would apply the root config -- which ignores packages/** -- and report every one of them as ignored rather than linting it. Each package owns its own eslint.config.ts, so the fix has to run once per package, in that package's directory. Files outside packages/ are linted by the root config in one final invocation. + +const PACKAGES_DIR = "packages"; + +// packages / / . A path with fewer segments than that names the packages directory itself or a package's own root entry, neither of which is a file inside a package. +const SEGMENTS_IN_SHALLOWEST_PACKAGE_FILE_PATH = 3; + +function packageDirectoryOf(repoRelativePath: string): string | undefined { + const segments = repoRelativePath.split(sep); + const [first, second] = segments; + if ( + first !== PACKAGES_DIR || + second === undefined || + segments.length < SEGMENTS_IN_SHALLOWEST_PACKAGE_FILE_PATH + ) { + return undefined; + } + return `${PACKAGES_DIR}${sep}${second}`; +} + +function groupByDirectory( + absolutePaths: readonly string[], +): ReadonlyMap { + const grouped = new Map(); + for (const absolutePath of absolutePaths) { + const repoRelativePath = relative(process.cwd(), absolutePath); + const directory = packageDirectoryOf(repoRelativePath) ?? "."; + const existing = grouped.get(directory); + if (existing === undefined) { + grouped.set(directory, [repoRelativePath]); + } else { + existing.push(repoRelativePath); + } + } + return grouped; +} + const config: Configuration = { - "*.ts": "eslint --fix --cache", + "*.ts": (files) => + [...groupByDirectory(files)].map(([directory, paths]) => { + const pathsRelativeToDirectory = paths.map( + (path) => `"${directory === "." ? path : relative(directory, path)}"`, + ); + return `pnpm --dir ${directory} exec eslint --fix ${pathsRelativeToDirectory.join(" ")}`; + }), }; export default config; diff --git a/package.json b/package.json index 71c4b2e..12a3052 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { - "name": "trilean", - "version": "1.4.0", - "description": "Three-valued predicate and expression evaluation trees, stored as JSON and evaluated against injected resolvers, for domain logic — business rules, eligibility checks, formulas, search filters, and more — that needs to be data instead of code.", + "name": "trilean-monorepo", + "private": true, + "description": "Workspace root for the trilean ecosystem. Never published -- it exists to hold the workspace, the task pipeline, the shared tooling configuration, and the release orchestration.", "type": "module", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/ExaDev/trilean.git" @@ -11,86 +12,31 @@ "bugs": { "url": "https://github.com/ExaDev/trilean/issues" }, - "exports": { - ".": { - "types": { - "import": "./dist/index.d.ts", - "require": "./dist/index.d.cts" - }, - "import": "./dist/index.js", - "require": "./dist/index.cjs" - }, - "./*": { - "types": { - "import": "./dist/*.d.ts", - "require": "./dist/*.d.cts" - }, - "import": "./dist/*.js", - "require": "./dist/*.cjs" - }, - "./schemas/*.schema.json": "./schemas/*.schema.json" - }, - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist", - "schemas" - ], - "publishConfig": { - "access": "public", - "provenance": true, - "registry": "https://registry.npmjs.org/" - }, - "sideEffects": false, "engines": { "node": ">=20" }, - "license": "MIT", "packageManager": "pnpm@11.6.0", - "dependencies": { - "zod": "^4.5.4" - }, "scripts": { "build": "turbo run _build", - "_build": "tsdown && tsx scripts/generate-json-schema.ts", "lint": "turbo run _lint", "_lint": "eslint . --fix --cache --max-warnings 0", - "typecheck": "turbo run _typecheck", - "_typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json", + "typecheck": "turbo run _typecheck _typecheck:attw", + "_typecheck": "tsc -p tsconfig.json", "test": "turbo run _test", - "_test": "vitest run --project unit", "test:coverage": "turbo run _test:coverage", - "_test:coverage": "vitest run --project unit --coverage", "test:integration": "turbo run _test:integration", - "_test:integration": "vitest run --project integration", "test:smoke": "turbo run _test:smoke", - "_test:smoke": "vitest run --project smoke", "test:workers": "turbo run _test:workers", - "_test:workers": "vitest run --project workers", "prepush": "turbo run _prepush", - "_prepush": "true", - "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run test:integration && pnpm run build && pnpm run test:smoke && publint && attw --pack", - "prepare": "husky", - "release": "semantic-release" + "release": "semantic-release-workspace release --config release-workspace.config.ts", + "prepare": "husky" }, - "keywords": [ - "json", - "predicate", - "expression", - "evaluator", - "three-valued-logic", - "zod", - "rules-engine", - "isomorphic" - ], "devDependencies": { - "@arethetypeswrong/cli": "^0.18.5", - "@cloudflare/vitest-pool-workers": "^0.22.0", "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", "@eslint/js": "^10.0.1", "@exadev/eslint-config": "^2.10.2", + "@exadev/semantic-release-workspace": "^1.2.1", "@semantic-release/changelog": "^7.0.0", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^11.0.1", @@ -98,8 +44,7 @@ "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", "@types/node": "^26.4.0", - "@vitest/coverage-v8": "^4.1.11", - "canonicalize": "^4.0.0", + "conventional-changelog-conventionalcommits": "^10.4.0", "eslint": "^10.9.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", @@ -107,13 +52,9 @@ "husky": "^9.1.7", "lint-staged": "^17.4.1", "prettier": "^3.9.6", - "publint": "^0.3.24", "semantic-release": "^25.0.9", - "tsdown": "^0.22.14", - "tsx": "^4.23.13", "turbo": "^2.10.12", "typescript": "^6.0.3", - "typescript-eslint": "^8.68.0", - "vitest": "^4.1.11" + "typescript-eslint": "^8.68.0" } } diff --git a/packages/trilean/AGENTS.md b/packages/trilean/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/trilean/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/CHANGELOG.md b/packages/trilean/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to packages/trilean/CHANGELOG.md diff --git a/packages/trilean/CLAUDE.md b/packages/trilean/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/trilean/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/trilean/LICENSE b/packages/trilean/LICENSE new file mode 100644 index 0000000..e6cf1d1 --- /dev/null +++ b/packages/trilean/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Joseph Mearman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/trilean/README.md b/packages/trilean/README.md new file mode 100644 index 0000000..d15d21f --- /dev/null +++ b/packages/trilean/README.md @@ -0,0 +1,969 @@ +# trilean + +[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/trilean) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/trilean) [![Release](https://img.shields.io/github/v/release/ExaDev/trilean)](https://github.com/ExaDev/trilean/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/trilean/ci.yml?branch=main)](https://github.com/ExaDev/trilean/actions) + +> /ˈtraɪ.li.ən/ (TRY-lee-ən) — rhymes with "boolean". +> +> "Tri-" for [three-valued logic](https://en.wikipedia.org/wiki/Three-valued_logic) — the three possible outcomes of an evaluation (definitely true, definitely false, or indeterminate — see [The evaluation model](#the-evaluation-model)) — "-lean" echoing "boolean" itself, George Boole's own two-valued logic. + +A serialisable (JSON) representation of two related tree structures — a **predicate tree** (truth-valued) and an **expression tree** (value-valued) — together with an evaluator for both. The package is deliberately domain-agnostic: the schema layer never assumes anything about where data actually comes from. Every point of contact with a consumer's real data is an injected, opaque resolver function supplied by whoever embeds the package. + +Typical use: representing business rules, eligibility conditions, formulas, or validation logic as data (JSON) that can be stored, transmitted, edited by non-developers via a UI, and evaluated identically wherever it lands — a browser, a server, a batch job — without recompiling anything. + +## Getting started + +```sh +npm install trilean +# or +pnpm add trilean +``` + +The package ships as dual ESM and CJS builds, is isomorphic (no assumptions about a Node, browser, or Workers runtime — see [Design principles](#design-principles)), and has zero runtime dependencies beyond [Zod](https://zod.dev). + +```ts +import { evaluatePredicate, type PredicateNode, type Resolvers } from "trilean"; + +const node: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 18 }, +}; + +const resolvers: Resolvers = { + async resolveValue(key, context) { + const record = context as Record; + return key === "age" && "age" in record + ? { found: true, value: { kind: "number", value: record.age as number } } + : { found: false }; + }, + async resolveLookup() { + return { found: false }; + }, + async resolveCollection() { + return []; + }, +}; + +await evaluatePredicate(node, { age: 21 }, resolvers); +// => { status: "definite", value: true } +``` + +See [Evaluator entry points](#evaluator-entry-points) and [Resolvers](#resolvers) for the full contract, and the [Worked example](#worked-example) for a larger tree combining boolean logic, a formula, and an aggregation. + +### A nested filter for a REST API search endpoint + +A search endpoint's filter criteria are exactly the kind of thing this package is for: nested boolean logic, stored as JSON, that a client can construct, a non-developer can edit via a UI, and a server evaluates per record without ever hardcoding the filter or redeploying when it changes. There is no query-string DSL to parse and no ORM query-builder to translate into — the request body already is the tree: + +```http +POST /orders/search HTTP/1.1 +Content-Type: application/json + +{ + "filter": { + "kind": "and", + "left": { + "kind": "textCompare", + "op": "equals", + "left": { "kind": "reference", "key": "status" }, + "right": { "kind": "textLiteral", "value": "active" } + }, + "right": { + "kind": "or", + "left": { + "kind": "compare", + "op": "gt", + "left": { "kind": "reference", "key": "orderTotal" }, + "right": { "kind": "numberLiteral", "value": 100 } + }, + "right": { + "kind": "memberOf", + "op": "in", + "operand": { "kind": "reference", "key": "category" }, + "candidates": [ + { "kind": "textLiteral", "value": "electronics" }, + { "kind": "textLiteral", "value": "books" } + ] + } + } + } +} +``` + +`status equals "active" AND (orderTotal > 100 OR category is a preferred one)` — two levels of nesting: an `or` inside the right branch of an `and`. The server parses that body's `filter` field as a `PredicateNode` and evaluates it, unmodified, against each candidate order: + +```ts +import { evaluatePredicate, type PredicateNode, type Resolvers } from "trilean"; + +interface Order { + status: string; + orderTotal: number; + category: string; +} + +// The parsed `filter` field from the request body above. +const filter: PredicateNode = { + kind: "and", + left: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "status" }, + right: { kind: "textLiteral", value: "active" }, + }, + right: { + kind: "or", + left: { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "orderTotal" }, + right: { kind: "numberLiteral", value: 100 }, + }, + right: { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "category" }, + candidates: [ + { kind: "textLiteral", value: "electronics" }, + { kind: "textLiteral", value: "books" }, + ], + }, + }, +}; + +const orderResolvers: Resolvers = { + async resolveValue(key, context) { + const order = context as Order; + switch (key) { + case "status": + return { found: true, value: { kind: "text", value: order.status } }; + case "orderTotal": + return { found: true, value: { kind: "number", value: order.orderTotal } }; + case "category": + return { found: true, value: { kind: "text", value: order.category } }; + default: + return { found: false }; + } + }, + async resolveLookup() { + return { found: false }; + }, + async resolveCollection() { + return []; + }, +}; + +const orders: Order[] = [ + { status: "active", orderTotal: 42, category: "electronics" }, + { status: "active", orderTotal: 150, category: "garden" }, + { status: "cancelled", orderTotal: 200, category: "electronics" }, +]; + +const results = await Promise.all( + orders.map((order) => evaluatePredicate(filter, order, orderResolvers)), +); +const matching = orders.filter((_, i) => results[i]?.status === "definite" && results[i]?.value === true); +// => the first two orders match; the cancelled one doesn't reach the "or" at all, since "and" absorbs on its left operand's definite false +``` + +See [`and`/`or`](#not-and-or), [`compare`](#compare), [`textCompare`](#textcompare), and [`memberOf`](#memberof) for the full node-kind reference. + +## Build, test, and lint + +```sh +pnpm install +pnpm build # tsdown -> dist/, then generates schemas/trilean.schema.json +pnpm test # unit suite, against src/ +pnpm test:integration # multi-kind composition, schema-pipeline, and function-registry/delegate tests, against src/ +pnpm test:smoke # builds first, then checks dist/ in both ESM and CJS plus the generated JSON Schema +pnpm test:workers # runs the evaluator inside a real Cloudflare Workers isolate +pnpm lint +pnpm typecheck +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the git hooks, the release process, and the constraints an implementation change must preserve. + +## Design principles + +These hold across every part of the design below, and any implementation change must preserve them: + +- **No assumptions about consumer data.** The only places this package touches real data are three named resolver contracts (see [Resolvers](#resolvers)). The schema stores *what to pass* to a resolver, never any resolver logic itself, and never interprets the meaning of an opaque key, table identifier, or collection reference. +- **Three outcomes, never two.** Every evaluation produces a definite result or an indeterminate result carrying a reason — never a bare `boolean`/`number`, and never a thrown exception for a data-quality problem. See [The evaluation model](#the-evaluation-model). +- **Derived constructs are compositions, not new logic.** Anything describable as "some other primitive, wired together" is implemented that way, so its correctness is inherited rather than requiring separate proof. See [Derived connectives](#derived-connectives), [Derived aggregates](#derived-aggregates), [Derived values](#derived-values), [Pattern-matching builders](#pattern-matching-builders), and [Defining your own named presets](#defining-your-own-named-presets). +- **One schema, mechanically derived artefacts.** A single canonical type definition produces the runtime validator and the portable wire-format schema; they cannot drift apart because there is only one source. See [Schema strategy](#schema-strategy). +- **A numeric extension that stays closed-form is in scope; a different kind of computation is not.** When something the current numeric model does not cover comes up, the test is whether evaluating it is still closed-form numeric evaluation — no solving, no simplification, no code execution. If it is, it belongs here, however unlike the existing kinds it looks: [Complex values](#complex-values) were once listed under [Out of scope](#out-of-scope) on a sizing judgement that turned out to be wrong, since complex arithmetic is exactly the closed-form evaluation this evaluator already does for every other kind. What stays behind [`delegate`](#delegate) is a genuinely different *kind* of computation — symbolic algebra, arbitrary external computation — not merely a kind of number the model has not reached yet. +- **Generic examples only.** Every example in this document uses invented, placeholder field names (`temperature`, `orderTotal`, `isActive`, `x`, `y`, `amount`, `items`) with no resemblance to any particular company, product, or industry's real data model. + +## The evaluation model + +Every evaluation — of a predicate node or an expression node — produces exactly one of two outcomes: + +```ts +type Evaluation = + | { status: "definite"; value: T } + | { status: "indeterminate"; reason: IndeterminateReason }; + +interface IndeterminateReason { + /** Which of the three reason categories applies. */ + code: "not-found" | "wrong-type" | "domain-error"; + /** A human-readable explanation, for logging and debugging. */ + message: string; +} +``` + +The three reason codes are: + +| Code | Meaning | +|---|---| +| `not-found` | A value a node needed did not exist in the underlying data at all. | +| `wrong-type` | A value existed but was not of a kind the operation could use (e.g. non-numeric where a number was required). | +| `domain-error` | A mathematical operation was attempted outside its valid domain (division by zero, a function given an input outside its allowed range, an aggregation with nothing to aggregate). | + +`domain-error` is not a separate error type, exception, or crash — it uses exactly the same `Evaluation`/`IndeterminateReason` mechanism as the other two. This three-outcome model applies uniformly to every node kind in both trees: arithmetic, comparison, and boolean logic alike. It never collapses to a plain boolean or number at any intermediate point inside the tree; only the code that consumes the final top-level `Evaluation` decides what to do with an indeterminate outcome (reject, default, surface to a user, etc.) — that decision is deliberately outside this package's scope. + +**Infrastructure failures are a different concern.** If a resolver itself throws (a network error, a database outage), that propagates as an ordinary rejected promise from `evaluatePredicate`/`evaluateValue`, exactly like any other function call failure. The three-outcome model exists to describe *data-quality* states inside the domain being modelled — it does not, and should not, attempt to also model transport-level failure. + +### Where an indeterminate outcome can carry more than one candidate reason + +Some nodes combine several sub-evaluations that could each independently be indeterminate for a different reason (e.g. an `and` node whose both operands are indeterminate, one `not-found` and one `wrong-type`). This design resolves ties with a single, consistently-applied rule: **take the first indeterminate reason encountered in the node's own declared operand order** (left before right; list order for N-ary/collection operands). This is an implementation decision this document makes explicitly, once, so every node kind's evaluator can apply the same rule without re-deriving it. + +## Three-valued propagation rules + +Let **U** denote "indeterminate" for the purposes of these tables — the specific reason is preserved and reported per the tie-break rule above, but propagation logic itself only cares that an operand is not a definite value. **T** = true, **F** = false. + +**Any arithmetic operation or relational comparison with at least one indeterminate operand always produces an indeterminate result.** There is no operand value that can rescue an arithmetic or single relational comparison once one side is indeterminate — arithmetic and single relational comparisons have no absorbing value and no short-circuit. + +Logical AND, OR, and NOT behave differently: they have absorbing values, and this absorption must be preserved exactly as specified below. **A design in which any indeterminate operand automatically makes the whole boolean result indeterminate, with no absorption, is a specification defect** — it would silently discard cases where the answer was already determined regardless of the indeterminate side. + +**AND** — `false` is absorbing/dominant: + +| AND | T | F | U | +|---|---|---|---| +| **T** | T | F | U | +| **F** | F | F | F | +| **U** | U | F | U | + +**OR** — `true` is absorbing/dominant (mirror image of AND): + +| OR | T | F | U | +|---|---|---|---| +| **T** | T | T | T | +| **F** | T | F | U | +| **U** | T | U | U | + +**NOT** — negates a definite result; leaves indeterminate as indeterminate, reason unchanged: + +| NOT | result | +|---|---| +| T | F | +| F | T | +| U | U | + +**Identity elements for the N-ary and collection forms.** AND is a fold over `true` (the identity for AND), OR is a fold over `false` (the identity for OR) — this is a structural property of the operation, not a separate design choice, so it applies consistently everywhere an AND/OR is taken across a list: an empty `allOf` is definitely `true`; an empty `anyOf` is definitely `false`; a "some" quantifier over an empty collection is definitely `false` (no item can satisfy it). + +> **Deliberate, settled: `every` over an empty collection is definitely `true`.** This is vacuous truth — the standard convention for universal quantification over an empty set, and exactly the same identity-element reasoning already used for `allOf` above (an empty `allOf`'s `true` and an empty `every`'s `true` are the same fact, stated twice because `every` is a quantifier over resolved items rather than a literal list of sub-nodes). This is worth stating explicitly and prominently, rather than leaving it as something an implementer might reasonably second-guess, because at least one other real, existing tool in this space gets exactly this case wrong — its own "all" operator returns `false` for an empty collection, which is simply an incorrect implementation of universal quantification, not an equally valid alternative convention. Nothing about a genuinely empty collection can violate "every item satisfies X", so `true` is the only value consistent with what the quantifier claims to mean; this document's `every` must not be "fixed" to match that other tool's behaviour. + +## Derived connectives + +Exclusive-or, NAND, NOR, implication, and the biconditional are never implemented as independently-evaluated node kinds. Each is defined purely as a fixed composition of unary NOT and binary AND/OR, expressed as ordinary builder functions that construct a tree of primitive nodes: + +```ts +const not = (a: PredicateNode): PredicateNode => ({ kind: "not", operand: a }); +const and = (a: PredicateNode, b: PredicateNode): PredicateNode => ({ kind: "and", left: a, right: b }); +const or = (a: PredicateNode, b: PredicateNode): PredicateNode => ({ kind: "or", left: a, right: b }); + +const xor = (a: PredicateNode, b: PredicateNode): PredicateNode => or(and(a, not(b)), and(not(a), b)); +const nand = (a: PredicateNode, b: PredicateNode): PredicateNode => not(and(a, b)); +const nor = (a: PredicateNode, b: PredicateNode): PredicateNode => not(or(a, b)); +const implies = (a: PredicateNode, b: PredicateNode): PredicateNode => or(not(a), b); +const iff = (a: PredicateNode, b: PredicateNode): PredicateNode => not(xor(a, b)); + +const none = (collection: JsonValue, item: PredicateNode, filter?: PredicateNode): PredicateNode => + not({ kind: "some", collection, item, filter }); +``` + +None of `xor`/`nand`/`nor`/`implies`/`iff` ever appears as a `kind` discriminant on the wire — a serialised tree containing an XOR is indistinguishable from one written out by hand using `or`/`and`/`not`. Three-valued correctness for all five is therefore inherited automatically from the already-verified AND/OR/NOT tables above, never requiring a separate proof for each. + +The same treatment applies to a third quantifier, `none` ("no item satisfies") — defined purely as `not(some(...))`, never as its own independently-evaluated node kind, and so never appearing as its own `kind` discriminant either. Its three-valued correctness is inherited automatically from NOT and from `some`'s own already-established correctness (including its absorbing behaviour and its `filter` handling) — no new truth table or worked proof is needed, exactly as for the five connectives above. + +### Worked correctness check: exclusive-or + +Applying the AND/OR/NOT tables above to `xor(A, B) = or(and(A, not(B)), and(not(A), B))` across all nine combinations of `{T, F, U}` for `A` and `B`: + +| A | B | not B | A ∧ ¬B | not A | ¬A ∧ B | result (∨) | expected | +|---|---|---|---|---|---|---|---| +| T | T | F | F | F | F | F | F | +| T | F | T | T | F | F | T | T | +| T | U | U | U | F | F | U | U | +| F | T | F | F | T | T | T | T | +| F | F | T | F | T | F | F | F | +| F | U | U | F | T | U | U | U | +| U | T | F | F | U | U | U | U | +| U | F | T | U | U | F | U | U | +| U | U | U | U | U | U | U | U | + +Every fully-known input pair produces the correct classical XOR, and every combination with at least one `U` produces `U`. This is the correct three-valued extension specifically for XOR — unlike AND/OR, exclusive-or has no operand value that determines the result on its own (there is no value of `B` for which `xor(anything, B)` is fixed regardless of the other side), so it has no absorbing value and "any unknown input yields an unknown output" is exactly right here — even though the identical blanket rule would be *wrong* for AND/OR, where it would ignore real absorption. NAND, NOR, implication, and the biconditional each inherit correct behaviour the same way, purely from being built out of NOT/AND/OR — check any of them the same way, by writing out all nine input combinations and confirming the result matches intuition. As one further spot check: `implies(F, U) = or(not(F), U) = or(T, U) = T` — a false antecedent makes an implication vacuously true regardless of whether the consequent is even knowable, which is the absorbing behaviour correctly carried through from OR. + +## Schema strategy + +The canonical definition lives in one place: a [Zod](https://zod.dev) schema per node kind. The TypeScript type is inferred from the schema (`z.infer<...>`), and a portable wire-format schema for documentation or cross-language interoperability is mechanically derived from the same Zod schema via `z.toJSONSchema()`. There is exactly one hand-authored artefact; the runtime validator and the JSON Schema document cannot drift apart because the second is generated from the first, not maintained alongside it. + +```ts +import { z } from "zod"; + +// A JSON value with no further meaning imposed by this schema — used for every +// opaque payload (reference keys, table identifiers, collection references, +// delegation payloads). "Opaque" means "uninterpreted by this package", not +// "untyped" — every one of these must still be plain, serialisable JSON. +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +const JsonValueSchema: z.ZodType = z.lazy(() => + z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValueSchema), z.record(z.string(), JsonValueSchema)]) +); +``` + +Node schemas are `z.discriminatedUnion("kind", [...])` over per-kind `z.object` shapes, following the concrete definitions below. A generated JSON Schema document (produced once, as a build step, via `z.toJSONSchema(PredicateNodeSchema)` / `z.toJSONSchema(ExpressionNodeSchema)`) is what a non-TypeScript consumer or an authoring UI would target. + +The generated document carries a version-pinned `$id` — a jsDelivr URL naming the exact published version, e.g. `https://cdn.jsdelivr.net/npm/trilean@1.2.3/schemas/trilean.schema.json` — so a consumer's own rule file can point its `$schema` at a fixed target rather than a moving one. The file's bytes are exactly its RFC 8785 (JSON Canonicalization Scheme) canonical form — keys sorted recursively, no whitespace between tokens, no trailing newline — so `canonicalize(JSON.parse(file)) === file` holds under any JCS implementation, and the same input always produces the same bytes. That makes the file's own SHA-256 re-derivable from its parsed content alone, which is what lets a downloaded copy be checked against this package's SBOM and build-provenance attestations (see the release workflow). + +### Performance + +A consumer that parses and evaluates many trees at high throughput can opt into Zod 4.5's compiled-schema fast path by importing `zod/compile` once, at their own application's entry point: + +```ts +import "zod/compile"; +``` + +This package deliberately does **not** import it itself — `zod/compile` has global side effects on the Zod runtime, which would contradict this package's own `sideEffects: false` declaration and could surprise a consumer who never asked for it. Opting in (or not) is left entirely to whoever embeds the package. + +## The predicate tree + +A `PredicateNode` evaluates to `Evaluation` — true, false, or indeterminate-with-reason. + +```ts +type ComparisonOperator = "gt" | "gte" | "lt" | "lte" | "eq" | "neq"; +type TextComparisonOperator = "equals" | "notEquals" | "matches" | "notMatches"; +type MembershipOperator = "in" | "notIn"; + +type PredicateNode = + | { kind: "not"; operand: PredicateNode } + | { kind: "and"; left: PredicateNode; right: PredicateNode } + | { kind: "or"; left: PredicateNode; right: PredicateNode } + | { kind: "allOf"; operands: PredicateNode[] } + | { kind: "anyOf"; operands: PredicateNode[] } + | { kind: "compare"; op: ComparisonOperator; left: ExpressionNode; right: ExpressionNode } + | { kind: "textCompare"; op: TextComparisonOperator; left: ExpressionNode; right: ExpressionNode } + | { kind: "memberOf"; op: MembershipOperator; operand: ExpressionNode; candidates: ExpressionNode[] } + | { kind: "exists"; operand: ExpressionNode } + | { kind: "some"; collection: JsonValue; item: PredicateNode; filter?: PredicateNode } + | { kind: "every"; collection: JsonValue; item: PredicateNode; filter?: PredicateNode } + | { kind: "treeReference"; key: JsonValue }; +``` + +### `not`, `and`, `or` + +The three primitives. `not` takes exactly one operand — it is never modelled as a two-operand node with an unused second slot. `and`/`or` each take exactly two named operands (`left`/`right`), evaluated per the truth tables above. + +### `allOf`, `anyOf` + +The N-ary forms of `and`/`or`: given an ordered list of operands (rather than exactly two), combine all of them with AND, or all of them with OR, respectively. Defined as repeated pairwise application of `and`/`or` — an implementation detail, not a new evaluation rule requiring separate verification. Because resolvers are asynchronous, a reference implementation is free to evaluate every operand concurrently and then apply the absorption rule when combining results, rather than evaluating strictly left-to-right; both strategies produce an identical final `Evaluation` because absorption is a property of the values, not of execution order. The empty-list identity values from [Three-valued propagation rules](#three-valued-propagation-rules) apply: `allOf([])` is definitely `true`; `anyOf([])` is definitely `false`. + +### `compare` + +A relational-comparison leaf: compares two computed values using `gt`/`gte`/`lt`/`lte`/`eq`/`neq`. **Both `left` and `right` are `ExpressionNode`** — either side may be a plain literal/reference or an arbitrary formula from the expression tree; the comparison is symmetric, and an implementation that only allows a formula on one side is incomplete. Valid operand kinds are `number` (matching units required — see [Units](#units)), `instant`, `duration`, or `boolean`, plus `complex` for `eq`/`neq` only (see [Complex values](#complex-values)); comparing across different computed-value kinds, or comparing two numbers with incompatible units, is `wrong-type`. `boolean` only supports `eq`/`neq` — there is no natural ordering for a truth value, so `gt`/`gte`/`lt`/`lte` are `wrong-type` for a `boolean` operand. + +### `textCompare` + +A text-matching leaf, symmetric in the same way as `compare`: both `left` and `right` are `ExpressionNode`, and either may be a literal or an arbitrary formula. `equals`/`notEquals` are exact string equality; `matches`/`notMatches` interpret `right` as a pattern (an ECMAScript-style regular expression) tested against `left`'s text. Both operands must resolve to the `text` computed-value kind; anything else is `wrong-type`. A "small fixed category" value (e.g. a status label) is simply a `text` computed value from this leaf's point of view — no separate category kind exists. + +### Pattern-matching builders + +`matches` already covers arbitrary pattern matching, but writing the regular expression by hand is where the common, narrower cases go wrong: getting the escape-then-convert ordering backwards either stops wildcards working or silently reinterprets a literal asterisk in real data as one. Three builder functions compile a pattern string into an ordinary `textCompare` node instead — never a new node kind, never an evaluator branch, exactly the same composition-not-new-logic treatment [Derived connectives](#derived-connectives), [Derived aggregates](#derived-aggregates), and [Derived values](#derived-values) already give `xor`/`sum`/`coalesce`: + +```ts +const command: ExpressionNode = { kind: "reference", key: "command" }; +const path: ExpressionNode = { kind: "reference", key: "path" }; + +// Matches "ls" and "ls -la", never "lsof". +prefixPattern(command, "ls"); +// Matches "git add file" and, by the trailing-wildcard convenience below, bare "git". +wildcardPattern(command, "git *"); +// Matches "workspace/report.txt", but not "workspace/archive/report.txt". +hierarchicalGlobPattern(path, "workspace/*"); +``` + +Each returns an ordinary predicate node — `prefixPattern(command, "ls")` is exactly `{ kind: "textCompare", op: "matches", left: command, right: { kind: "textLiteral", value: "^ls(?: [\\s\\S]*)?$" } }`. Compilation happens once, when the tree is built, so what is stored and serialised is a `textCompare` tree indistinguishable from one written out by hand — a consumer that never calls a builder loses nothing, and a serialised tree carries no dependency on the builder that produced it. + +The three are separate dialects, deliberately not one function with a mode argument, because they answer different questions and mixing them silently changes what a pattern means: + +| Builder | `*` | `**` | `?` | Escapes | Intended for | +|---|---|---|---|---|---| +| `prefixPattern` | literal | literal | literal | none — the prefix is a plain literal throughout | Command/label prefixes where `"ls"` must match `"ls"` and `"ls -la"` but never `"lsof"` | +| `wildcardPattern` | any characters | (two wildcards in a row) | literal | `\*` for a literal asterisk, `\\` for a literal backslash | Flat strings with no internal hierarchy | +| `hierarchicalGlobPattern` | any characters within one `/`-delimited segment | any characters across segments | one character within a segment | none — a backslash is a literal backslash | Path- or category-tree-shaped values | + +Two behaviours in `wildcardPattern` are worth stating rather than leaving to be inferred. Its pattern is trimmed before compiling. And a pattern whose **only** unescaped wildcard is a trailing `" *"` also matches the bare prefix, so `"git *"` matches `"git"` as well as `"git add file"` — the convenience does not apply to `"git * *"`, where both wildcards are still required. + +The compiled pattern is a fully anchored, flag-free string: "any character" is spelled `[\s\S]` rather than `.`, because the stored pattern carries no `s` flag and nothing downstream can add one, and the only characters escaped are ECMAScript's own `SyntaxCharacter` set plus `/`, which are exactly the escapes that stay valid under a `u`/`v`-flagged `RegExp` as well as an unflagged one. A compiled pattern is therefore portable in the strongest available sense — it means the same thing wherever it is compiled, including pasted verbatim into a `/.../` literal. + +Three-valued behaviour is inherited unchanged from [`textCompare`](#textcompare) and needs no separate proof: an unresolvable subject is indeterminate rather than a non-match, and a non-`text` subject is `wrong-type` — a compiled pattern never turns a data problem into a definite `false`. + +### `memberOf` + +A membership-test leaf, parallel to `compare` and `textCompare` rather than folded into either one's operator set: `operand` is the `ExpressionNode` being tested; `candidates` is a list of `ExpressionNode`s to test it against, every element of which may independently be an arbitrary formula, not only a literal — the same symmetry principle already applied to `compare` and `textCompare`. `op: "in"` asks whether `operand` equals any candidate; `op: "notIn"` asks whether it equals none of them. + +Membership is decided by value equality between computed values of the same kind, respecting units for numeric values exactly as `compare`'s own `eq` already does — a candidate of an incompatible kind, or a `number` candidate with an incompatible unit, can never be a match, and the comparison for that one element is `wrong-type`, not simply "not equal". + +Evaluate `operand` first; if it is indeterminate, the whole leaf is indeterminate with that reason. Otherwise, scan `candidates` in order: a candidate that is a **definite match** immediately settles the result — `in` is definitely `true`, `notIn` is definitely `false` — regardless of any not-yet-scanned or indeterminate candidates, mirroring the same absorbing-value discipline already established for OR and `some` elsewhere in this document (a confirmed match cannot be undone by an unrelated element's data problem). If scanning completes with no definite match: the leaf is indeterminate (first indeterminate candidate's reason, per the tie-break rule in [The evaluation model](#the-evaluation-model)) if at least one candidate was itself indeterminate or of an incompatible kind/unit; otherwise every candidate was a definite, comparable non-match, and `in` is definitely `false`, `notIn` is definitely `true`. An empty `candidates` list is never scanned and never indeterminate: `in` is definitely `false` and `notIn` is definitely `true` — the same non-vacuous facts an empty `anyOf`/`allOf` already establishes for OR/AND. + +### `exists` + +Evaluates `true` if the given `ExpressionNode` can be resolved to some value at all, `false` if it definitely cannot be resolved (the data point is genuinely absent), independent of whether that value would itself be usable in further computation. Concretely: evaluate the operand; if the result is definite, `exists` is `true`; if the result is indeterminate with reason `not-found`, `exists` is `false`; if the result is indeterminate with reason `wrong-type` or `domain-error`, `exists` is still `true` — the underlying data point *did* resolve to something, it merely wasn't usable for whatever computation was attempted around it, which is exactly why section [The evaluation model](#the-evaluation-model) distinguishes "did not exist" from "existed but unusable" in the first place. `exists` itself is never indeterminate — it always produces a definite boolean. + +### `some`, `every` + +Quantifiers over a collection, sharing the exact collection-resolution mechanism described in [Collections](#collections). `some` is semantically an OR of `item` evaluated once per participating item; `every` is semantically an AND of `item` evaluated once per participating item — both inherit the absorbing-value propagation from the AND/OR tables applied across the whole collection (e.g. `some` can be definitely `true` from one known-true item even if every other participating item is unresolvable). An optional `filter` narrows which resolved items participate at all before either quantifier runs over them — see [Collections](#collections) for exactly how a `filter` result feeds into this same absorption. The item's own evaluation context (for both `filter` and `item`) is the item itself — see [Collections](#collections). A third quantifier, "no item satisfies", is derived from `some` — see [Derived connectives](#derived-connectives). + +## The expression tree + +An `ExpressionNode` evaluates to `Evaluation`. + +```ts +type Unit = Record; // dimension symbol -> exponent, e.g. { m: 1, s: -1 } for metres per second +type DurationUnit = "ms" | "s" | "min" | "h" | "d"; + +type ComputedValue = + | { kind: "number"; value: number; unit?: Unit } + | { kind: "text"; value: string } + | { kind: "boolean"; value: boolean } + | { kind: "instant"; value: string } // ISO-8601 timestamp + | { kind: "duration"; value: number; unit: DurationUnit } + | { kind: "complex"; re: number; im: number; unit?: Unit }; + +type ArithmeticOperator = "add" | "subtract" | "multiply" | "divide" | "power" | "modulo"; + +type FoldCombiner = + | { mode: "max"; item: ExpressionNode } + | { mode: "min"; item: ExpressionNode } + | { mode: "reduce"; initial: ExpressionNode; combine: ExpressionNode }; + +type HitPolicy = "first" | "unique"; + +type ExpressionNode = + | { kind: "numberLiteral"; value: number; unit?: Unit } + | { kind: "textLiteral"; value: string } + | { kind: "booleanLiteral"; value: boolean } + | { kind: "instantLiteral"; value: string } + | { kind: "durationLiteral"; value: number; unit: DurationUnit } + | { kind: "complexLiteral"; re: number; im: number; unit?: Unit } // rectangular + | { kind: "complexLiteral"; magnitude: number; phase: number; unit?: Unit } // polar -- see Complex values + | { kind: "reference"; key: JsonValue; unit?: Unit } + | { kind: "arithmetic"; op: ArithmeticOperator; left: ExpressionNode; right: ExpressionNode } + | { kind: "negate"; operand: ExpressionNode } + | { kind: "call"; fn: string; args: ExpressionNode[] } + | { kind: "lookup"; table: JsonValue; keys: ExpressionNode[] } + | { kind: "conditional"; hitPolicy?: HitPolicy; cases: { when: PredicateNode; then: ExpressionNode }[]; fallback: ExpressionNode } + | { kind: "fold"; collection: JsonValue; filter?: PredicateNode; combiner: FoldCombiner } + | { kind: "accumulator" } + | { kind: "delegate"; system: string; payload: JsonValue } + | { kind: "treeReference"; key: JsonValue }; +``` + +A `textLiteral` kind is included even though it is not separately enumerated as its own top-level construct, because `textCompare`'s symmetry requirement (either side may be an arbitrary computed value, per the section above) is meaningless without a way to write a constant string or pattern — matching a field against the fixed text `"active"`, or against a fixed regular expression, needs a text constant on one side. This is a structural consequence of the symmetry already required for text matching, not an added feature. + +### Literals + +`numberLiteral`, `textLiteral`, `booleanLiteral`, `instantLiteral` (an ISO-8601 timestamp string), `durationLiteral` (a magnitude plus a `DurationUnit`), and `complexLiteral` (either a real and an imaginary component, or a magnitude and a phase, plus an optional `Unit` — see [Complex values](#complex-values)) are always definite by construction — a literal node never itself produces an indeterminate outcome. + +### `reference` + +A reference to a single external value, identified by an opaque `key` whose meaning is entirely up to the embedding consumer — the schema never interprets it (see [Resolvers](#resolvers), resolver 1). May optionally carry an expected `unit`, validated against whatever the resolver actually returns for a `number` result; a mismatch (or an expectation of a unit on a non-numeric result) is `wrong-type`. If the resolver reports absence, the result is `not-found`. + +### `arithmetic`, `negate` + +Binary arithmetic (`add`/`subtract`/`multiply`/`divide`/`power`/`modulo`) and unary negation, each over `number` computed values by default, with the temporal exceptions listed under [Temporal values](#temporal-values) and the complex ones under [Complex values](#complex-values) below. `negate` is an explicit node — never sugar for "zero minus the value" — because it also applies to `duration` values (negating a duration reverses its direction) where "zero minus" has no natural literal-zero counterpart; over a `complex` value it flips both components. Division by zero, or any operator given an operand outside its mathematical domain, is `domain-error`; a non-numeric, non-temporal operand where a number was required is `wrong-type`; any operand that is itself indeterminate makes the whole node indeterminate, with no rescuing value on the other side (see [Three-valued propagation rules](#three-valued-propagation-rules)). + +### `call` + +A named function applied to an ordered list of `ExpressionNode` arguments. The set of named functions is intentionally open-ended and resolved through a function registry supplied at evaluator construction time — `minimum`, `maximum`, `absoluteValue`, `round`, `squareRoot`, and `logarithm` are starting examples, not an exhaustive list; new functions are added to the registry as concrete need arises. Calling an unregistered function name is `wrong-type` ("no function registered under this name"); calling a registered function with an argument outside its domain (e.g. `squareRoot` given a negative number) is `domain-error`. + +### Units + +`numberLiteral`, `complexLiteral`, and `reference` may carry a `unit`, represented as a dimensional-exponent map (e.g. `{ m: 1, s: -1 }` for metres per second) rather than an opaque string, so that unit combination follows real dimensional analysis instead of string matching. A bare symbol like `"kg"` is shorthand for `{ kg: 1 }`. + +- `add`/`subtract` between two unit-tagged numbers require **identical** dimensional-exponent maps. A mismatch is `wrong-type` ("incompatible units") — units are never silently coerced or dropped. +- `multiply`/`divide` combine the two operands' unit maps by dimensional analysis: multiplying adds exponents per dimension, dividing subtracts them. An operand with no `unit` is treated as dimensionless (an empty map) for this purpose. + +### Temporal values + +`instant` (a point in time) and `duration` are computed-value kinds distinct from `number`, even though a duration ultimately carries a numeric magnitude — an instant is never treated as "a number that happens to represent a date". The only well-defined cross-kind arithmetic is: + +- `instant − instant → duration` +- `instant + duration → instant` (and `duration + instant → instant`) + +Any other arithmetic combination touching an `instant` or `duration` (adding two instants, multiplying a duration by an instant, comparing an instant against a plain number, and so on) is `wrong-type`. A reference implementation normalises `duration` values to a single base unit (milliseconds) internally before combining two durations of different `DurationUnit`s, then reports the result in whichever unit the node's own context calls for. + +### Complex values + +`complex` is a computed-value kind alongside `number`, for the domains — signal processing, control theory, anything phasor-shaped — where a formula naturally mixes real and complex terms in one expression. It stays inside this evaluator rather than behind [`delegate`](#delegate) because it is closed-form numeric evaluation, exactly what every other kind here already does; see [Design principles](#design-principles) for that scope test in general. + +**One canonical representation, rectangular.** A `complex` value is stored as `{ re, im }` and never as a magnitude and a phase, and there is deliberately no `form` discriminant offering both. Three reasons, in order of weight: + +1. **A second form would make equality ambiguous.** Polar coordinates do not encode a value uniquely — phase is only defined modulo a full turn, and a zero-magnitude value has no meaningful phase at all — so the same complex number would have unboundedly many polar encodings. `eq` and `memberOf` are exact equality throughout this design (see [`compare`](#compare)); making them work across two forms would mean either normalising on every comparison or introducing an approximate equality for this one kind, and neither belongs in a design where every other kind compares exactly. +2. **A discriminant would double the branching in every operator** — quadruple it for a binary one — for a choice that changes no value. Every operator would still convert to rectangular internally, because that is where the closed forms live, so the discriminant would buy nothing at evaluation time and cost at every boundary. +3. **Rectangular is what the operators actually need.** `add`/`subtract` are component-wise in it; `multiply`, `divide`, `negate`, and integer `power` all have standard closed forms in it. Polar's advantage — multiplication and division as one product of magnitudes and one sum of angles — does not extend to addition at all, which would have to convert back and forth. + +The magnitude-and-phase view stays reachable through four exported conversion helpers rather than a second encoding: `complexFromPolar(magnitude, phase, unit?)` and `complexLiteralFromPolar(magnitude, phase, unit?)` build a value or a literal node from polar terms, and `complexMagnitude(value)` and `complexPhase(value)` read them back out — the magnitude as a real number in the value's own unit, the phase as a dimensionless real number of radians. Conversions at the edges, one representation in the middle. + +**The wire-format literal accepts either authoring form, structurally discriminated.** `ComputedValue`'s own `complex` kind stays exactly the single rectangular shape described above — nothing about it changes. But the `complexLiteral` *node* is a plain union of two shapes, `{ kind: "complexLiteral", re, im, unit? }` and `{ kind: "complexLiteral", magnitude, phase, unit? }`, told apart by which fields are present rather than by a `form` tag, since both still share the one literal `kind`. This is not a second encoding of `ComputedValue` reappearing through the back door — it exists only at the authoring boundary, for whichever of the two forms is natural for a given domain to write directly into JSON rather than hand-computing a conversion before ever constructing the tree, and the evaluator normalises whichever form was used to the single rectangular `ComputedValue` immediately, before any arithmetic, comparison, or negation ever runs. A rectangular literal and a polar literal representing the same underlying number are therefore indistinguishable from that point on: they evaluate to the identical `ComputedValue` and compare `eq` to one another exactly as two rectangular literals with the same components would. + +**Arithmetic.** + +- `add`/`subtract` are component-wise, requiring **identical** dimensional-exponent maps exactly as real numbers do (see [Units](#units)). +- `multiply`/`divide` are real complex multiplication and division — `(a + bi)(c + di) = (ac − bd) + (ad + bc)i`, and the corresponding quotient — never component-wise. Units combine by the same dimensional analysis real numbers use. A zero divisor means both components zero; a divisor with only a zero real part divides perfectly well. +- `power` is defined for a **real integer exponent** and evaluated as the repeated multiplication that integer exponentiation is, with a negative exponent the reciprocal of the positive one. Like a real `power`, it requires dimensionless operands. An arbitrary complex exponent is a genuinely bigger question — it needs the complex logarithm, which is multivalued, so it needs a branch-cut convention this design has not chosen — and is deliberately out of scope for now: it is `wrong-type`, as is a non-integer real exponent, on the same reading of that code used throughout ("an answer exists, but this operator does not accept this operand" — compare `power`'s existing dimensionless-operands requirement, also `wrong-type`). +- `modulo` is `domain-error`, not `wrong-type`: a remainder needs a canonical notion of how many whole divisors fit, and the complex plane has no ordering to supply one. There is no answer to accept, which is the same category as division by zero. + +**A real operand is promoted, never rejected.** Mixing a `number` with a `complex` in one `arithmetic` node works: every real number *is* a complex number with a zero imaginary part, so the promotion is exact, total, and canonical — unlike the temporal cross-kind combinations above, which had to be enumerated one by one precisely because no such embedding exists between an `instant` and a `duration`. Scaling a complex value by a real one, or offsetting it by a real constant, is the common case, and forcing every real literal in such a formula to be rewritten as a complex one would defeat the point. The result is `complex` whenever either operand is, even when the imaginary part comes out zero: a node's result kind follows its operand kinds, never the values that happen to flow through it. + +**Comparison is kind-strict, deliberately unlike arithmetic.** `gt`/`gte`/`lt`/`lte` are `wrong-type` for a `complex` operand — the complex plane carries no total order — exactly as they already are for `text`. `eq`/`neq` work normally, as exact equality across both components under the same unit-compatibility rule numbers already have, and `memberOf` matches the same way. But a `complex` compared against a `number` is `wrong-type`, with no promotion: arithmetic *produces* a value, so promoting a real operand 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` despite being a count of milliseconds underneath. + +Ordering a complex quantity therefore goes through whichever real projection the formula actually means — most often its magnitude. This package ships no built-in function set (see [`call`](#call)), so that bridge is an ordinary registry entry, one line over the exported helper: + +```ts +const functions: FunctionRegistry = { + magnitude: (args) => + args[0]?.kind === "complex" + ? complexMagnitude(args[0]) + : { domainError: "expected a complex argument" }, +}; +``` + +which a tree then calls like any other function, putting a real number back on the left of an ordinary `compare`: + +```json +{ + "kind": "compare", + "op": "gt", + "left": { "kind": "call", "fn": "magnitude", "args": [{ "kind": "reference", "key": "x" }] }, + "right": { "kind": "numberLiteral", "value": 13 } +} +``` + +### `lookup` + +Resolves a single value from a named external table-like source, keyed by one or more `ExpressionNode` keys, via resolver 2 (see [Resolvers](#resolvers)). The schema never interprets what "table" or "key" mean to a given consumer; `table` and the resolved key values are passed through verbatim. If any key expression is itself indeterminate, the lookup is indeterminate with that reason (no key evaluation, no lookup attempt). If the resolver reports no match, the result is `not-found`. + +### `conditional` + +A piecewise/conditional-value node: an ordered, possibly-empty list of `{ when, then }` cases plus a required `fallback`. An optional `hitPolicy` field (`"first"` or `"unique"`) decides how cases are read; absent is treated as `"first"` — the exact, unchanged behaviour of every tree serialised before this field existed, not a masked-bug fallback. + +**`hitPolicy: "first"`** (the default). Evaluates to the `then` of the first case whose `when` predicate is definitely `true`; if no case matches, evaluates to `fallback`. If evaluating a `when` predicate produces an indeterminate outcome **before any earlier case has matched**, the whole `conditional` node's own result is that same indeterminate outcome (reason preserved) — evaluation does not skip past an unknown guard to try the next one, because doing so could silently pick a later branch that only looks correct because an earlier one couldn't actually be checked. + +**`hitPolicy: "unique"`** asserts that at most one case is expected to match, and treats two or more matches as a data error rather than silently taking the first. Every case's `when` is evaluated concurrently (there is no "earlier case" to short-circuit on), then resolved in this order: + +1. **Two or more cases are definitely `true`** — `domain-error` ("more than one case matched under the 'unique' hit policy"), regardless of any other case's own indeterminacy. This mirrors `memberOf`/`some`/`every`'s existing absorption: a confirmed outcome (here, "there is a genuine ambiguity") cannot be undone by an unrelated case's data problem. +2. Otherwise, **any case's `when` is indeterminate** — the whole node is indeterminate with that reason (first such candidate, in declared case order, per [The evaluation model](#the-evaluation-model)'s tie-break rule). This is deliberately **not** absorbed by a single already-confirmed match, unlike step 1 above and unlike `memberOf`/`some`/`every`'s own absorption: an unresolved case might still turn out to be a second match, so "exactly one match so far" cannot be trusted as final until every other case is known to not also match. +3. Otherwise, **exactly one case is definitely `true`** — evaluate and return that case's `then`. No other case's `then` is ever evaluated. +4. Otherwise (zero matches, and nothing indeterminate) — evaluate and return `fallback`, exactly as `"first"` already does. + +### `fold` + +An aggregation over a collection (see [Collections](#collections)): `collection` is the opaque collection reference; an optional `filter` narrows which resolved items participate (see [Collections](#collections)); `combiner` decides how the participating items' values become one result. There is exactly one general mechanism, `reduce`, and exactly two named forms, `max`/`min`, that cannot be expressed as an instance of it — see [Derived aggregates](#derived-aggregates) for why `sum`, `count`, and `average` need no combiner mode of their own at all. + +**`reduce`** is "fold with an accumulator": `initial` is evaluated once, in the fold node's own (outer) context, to seed the running result; then, for each participating item in turn, `combine` is evaluated with that item as its evaluation context to produce the new running result from the old one. `combine` reaches the running result through the dedicated [`accumulator`](#accumulator) leaf; the item's own fields are reached the ordinary way, through `reference`/`lookup` nodes resolved against the item context. Over an empty (post-filter) collection, a `reduce` fold evaluates to `initial` without ever touching `combine`. + +**`max`/`min`** each carry an `item`, evaluated once per participating item using that item as its evaluation context, and keep the largest/smallest projected value seen. These two are the only combining behaviours that stay as their own directly-specified forms, for a precise mathematical reason rather than an arbitrary exception: `reduce` needs a seed value that is also the identity for `combine` (as `0` is for addition), and there is no largest or smallest real number to seed a running maximum or minimum with — the JSON number model has no literal for an unbounded sentinel. `max`/`min` are still the same underlying mechanism, just its standard *unseeded* variant (sometimes called "reduce1" elsewhere): the running result starts as the first participating item's own projected value, and `combine` (the ordinary "keep the larger"/"keep the smaller" comparison) is applied to each item after that — not an independently-invented special case, only the one variant of the mechanism that a literal `initial` genuinely cannot express. Over an empty (post-filter) collection, both are `domain-error` (undefined over an empty set, the same category as division by zero, per [The evaluation model](#the-evaluation-model)'s explicit allowance for "any comparable domain violation for any function added later") — there is no first item to seed from. + +**Indeterminacy, both forms.** If any participating item's `filter` evaluation is indeterminate, the whole `fold` is indeterminate with that reason — `fold` has no absorbing value (see [Three-valued propagation rules](#three-valued-propagation-rules)), so unlike a quantifier's OR/AND there is no other item's outcome that can override this (see [Pre-filtering which items participate](#pre-filtering-which-items-participate)). The same is true of any participating item's `item`/`combine` evaluation, and of a `reduce`'s `initial`: if any is indeterminate, the whole `fold` is indeterminate with that reason (first such candidate, in resolved-list order, `initial` counting as evaluated before any item). + +### `accumulator` + +A zero-field leaf, meaningful only inside the `combine` expression of an enclosing `fold`'s `reduce` form (see [`fold`](#fold) above), where it evaluates to that step's running accumulated result. A nested `fold`'s own `combine` expression introduces its own, separate accumulator scope — `accumulator` always refers to the innermost enclosing reduce fold. Using `accumulator` anywhere else (a `max`/`min` fold's `item`, a `filter` predicate, a quantifier's `item`, or outside any fold at all) is `wrong-type` — there is no running accumulator in scope. + +### Derived aggregates + +`sum`, `count`, and `average` are never their own `FoldCombiner` mode — each is a builder function that assembles an ordinary `fold` (and, for `average`, one `arithmetic` division of two ordinary folds), exactly the same treatment [Derived connectives](#derived-connectives) already gives `xor`/`nand`/`nor`/`implies`/`iff`/`none`: correctness is inherited from the mechanism they're built from, rather than needing its own independent implementation that could silently drift from it. + +```ts +const sum = (collection: JsonValue, item: ExpressionNode, filter?: PredicateNode): ExpressionNode => ({ + kind: "fold", + collection, + filter, + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 0 }, + combine: { kind: "arithmetic", op: "add", left: { kind: "accumulator" }, right: item }, + }, +}); + +const presenceOf = (probe: ExpressionNode): ExpressionNode => ({ + kind: "conditional", + cases: [ + { + when: { kind: "memberOf", op: "in", operand: probe, candidates: [probe] }, + then: { kind: "numberLiteral", value: 1 }, + }, + ], + fallback: { kind: "numberLiteral", value: 0 }, // unreachable: a definite probe is always a member of the single-element list containing only itself +}); + +const count = (collection: JsonValue, filter?: PredicateNode, probe?: ExpressionNode): ExpressionNode => ({ + kind: "fold", + collection, + filter, + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 0 }, + combine: { + kind: "arithmetic", + op: "add", + left: { kind: "accumulator" }, + right: probe ? presenceOf(probe) : { kind: "numberLiteral", value: 1 }, + }, + }, +}); + +const average = (collection: JsonValue, item: ExpressionNode, filter?: PredicateNode): ExpressionNode => ({ + kind: "arithmetic", + op: "divide", + left: sum(collection, item, filter), + right: count(collection, filter), +}); +``` + +`sum` needs no per-item probe beyond `item` itself: it is a literal `reduce` seeded at `0`, adding each participating item's projected value to the running total, and it already goes indeterminate if `item` fails to resolve for any participating item — no separate mechanism needed, since `item`'s value is exactly what gets added. + +`count` takes an optional third argument, `probe`, and this is where it matters that `filter` and a probe are not the same thing. `filter` *excludes* an item from participating — a filtered-out item's absence is invisible in the final result, exactly as if it had never been in the collection at all. A `probe` does the opposite: it doesn't decide whether an item participates, it makes the *whole count* indeterminate if it fails to resolve for *any* participating item, surfacing "I cannot give you a trustworthy count" rather than silently reporting a smaller, technically-successful count for the same underlying data-quality problem — precisely the distinction the rest of this document's indeterminate-outcome model exists to preserve (see [The evaluation model](#the-evaluation-model)). `count(collection, filter)` with no `probe` is a plain `reduce` seeded at `0` that adds `1` per participating item, with no indeterminacy of its own beyond `filter`'s. `count(collection, filter, probe)` instead adds `presenceOf(probe)` per participating item — a small helper built entirely from already-established primitives, with no restriction on `probe`'s kind: it tests `probe` for membership in the single-element list `[probe]`, so a `memberOf` "in" test against itself is trivially true whenever `probe` resolves to a definite value of *any* kind (`memberOf`'s equality is already kind-agnostic across `number`/`text`/`boolean`/`instant`/`duration` — see [`memberOf`](#memberof)), and exactly `probe`'s own indeterminate outcome otherwise, per `memberOf`'s own "evaluate `operand` first" rule. A `conditional` then turns that boolean into the number `1`; its `fallback` is never reached, since a definite `probe` always equals itself. (A real implementation may memoise `probe`'s single evaluation rather than running the resolver twice for `operand` and its one `candidates` entry — resolvers are pure functions of their inputs throughout this design, so this is a performance choice, not a correctness one.) + +`average` is `sum` divided by `count` over the same `collection`/`filter`, with no `probe` — `sum`'s own `item` already forces every participating item's projected value to resolve, so `average`'s numerator is already indeterminate under exactly the condition a `count` probe exists to detect, with nothing left to duplicate. Nothing new to verify for the empty-collection case either: division's own already-established rule (zero divisor is `domain-error`) is *why* `average` over an empty collection is `domain-error`, since `count` over an empty collection is `0` and `sum(...)/0` already means exactly that. + +### Derived values + +`coalesce` is never its own evaluated node kind — it is a builder function that assembles an ordinary `conditional`, the same treatment [Derived connectives](#derived-connectives) and [Derived aggregates](#derived-aggregates) already give `xor`/`nand`/`nor`/`implies`/`iff`/`none`/`sum`/`count`/`average`: correctness is inherited from the mechanism it's built from, rather than needing its own independent implementation that could silently drift from it. + +```ts +const coalesce = ( + first: ExpressionNode, + second: ExpressionNode, + ...rest: ExpressionNode[] +): ExpressionNode => + [first, second, ...rest].reduceRight( + (fallback, candidate): ExpressionNode => ({ + kind: "conditional", + cases: [{ when: { kind: "exists", operand: candidate }, then: candidate }], + fallback, + }), + ); +``` + +Built right-to-left over the full candidate list via `reduceRight`, needing no seed value: `first`/`second` are required arguments (rather than accepting a single `ExpressionNode[]`), which guarantees the list always has at least two elements, so the no-initial-value overload of `reduceRight` never hits an empty array. + +**Worked correctness check.** `coalesce`'s only interesting behaviour — whether a given candidate is skipped past or propagated — reduces entirely to what its single `exists` probe reports, per [`exists`](#exists)'s and [`conditional`](#conditional)'s own already-established rules: + +| A candidate's own evaluation | `exists(candidate)` | The `conditional` case | `coalesce` evaluates to | +|---|---|---|---| +| A definite value | definite `true` | matches | The candidate's value (re-evaluated as `then`, same result) | +| Indeterminate, `not-found` | definite `false` | does not match | The next candidate (the enclosing `fallback`), evaluated fresh | +| Indeterminate, `wrong-type` | definite `true` | matches | The candidate's own `wrong-type` result (re-evaluated as `then`) | +| Indeterminate, `domain-error` | definite `true` | matches | The candidate's own `domain-error` result (re-evaluated as `then`) | + +Falling through to the next candidate therefore happens only on `exists`'s own `false` — a genuinely absent value (`not-found`) — never on a candidate that resolved to something merely unusable (`wrong-type`/`domain-error`): `exists` already draws exactly that line, and `coalesce` inherits it unmodified rather than re-deciding it. This is the one behaviour a naive reimplementation is likely to get backwards (treating *any* indeterminate candidate as "try the next one"), so it is worth stating explicitly rather than leaving it to be inferred from the composition alone. + +A real implementation may memoise a candidate's single evaluation rather than running its resolver twice — once for the `exists` probe, once again for `then` — exactly the same performance caveat [Derived aggregates](#derived-aggregates)'s `presenceOf` already documents for its own `memberOf` probe; resolvers are pure functions of their inputs throughout this design, so this is a performance choice, not a correctness one. + +### Defining your own named presets + +This is exactly the same composition-not-new-logic treatment already given to `xor`/`sum`/`coalesce` above — nothing stops application code from defining its own named builder functions the same way, for whatever domain-specific composed queries come up repeatedly in a given consumer's own rules. + +```ts +/** isRecentlyActive(30) reads as "the item's lastActiveAt instant is within the last 30 days" — a small, named composition over compare/arithmetic, exactly the same "assembles ordinary nodes" treatment sum/coalesce already get above. Built as `now + (-days)` rather than `now - days`: per "Temporal values" above, `instant - duration` is not a defined cross-kind combination, only `instant + duration` is, so negating the duration first is how this reaches the same "days ago" instant using only defined operators. */ +const isRecentlyActive = (days: number): PredicateNode => ({ + kind: "compare", + op: "gt", + left: { kind: "reference", key: "lastActiveAt" }, + right: { + kind: "arithmetic", + op: "add", + left: { kind: "reference", key: "now" }, + right: { kind: "negate", operand: { kind: "durationLiteral", value: days, unit: "d" } }, + }, +}); +``` + +A UI surfacing these to an end user treats each one as a named preset in a node-picker — a label ("Recently active") plus whatever parameters the builder function takes (`days`) — and splices the expanded tree in at the point the user picked it, exactly as if they'd hand-built that subtree themselves; nothing about the resulting PredicateNode/ExpressionNode distinguishes a preset-sourced subtree from a manually-authored one. + +This is the "bake in at authoring time" half of the picture: a preset's own definition lives in application code, expanded once, at the moment a user picks it, into an ordinary static subtree. [`treeReference`](#treereference) is the complementary half — a *live*, centrally-editable reference, resolved fresh on every evaluation rather than expanded once at authoring time. Choosing between them is exactly the choice between "this composition is fixed application logic" (a named preset, this section) and "this composition is itself data someone should be able to edit without a deploy" (a treeReference). + +### `delegate` + +An explicitly-named external system plus an arbitrary, unevaluated JSON payload, standing in for the whole node without this package attempting to evaluate it itself — see [Out of scope](#out-of-scope). Evaluating a `delegate` node is not part of this package's own evaluation semantics. The reference evaluator accepts an optional delegate handler per external system name; if none is registered for the named `system`, evaluating the node is indeterminate (`wrong-type`, "no delegate handler registered for external system ''"). Consumers who want a `delegate` node to actually resolve are expected either to register a handler, or to pre-process the tree — walk it, find delegation nodes, invoke the named external system out of band, and substitute the result as a literal — before the tree ever reaches this package's evaluator. + +### `treeReference` + +A reference to a whole other tree, identified by an opaque `key` (see [Resolvers](#resolvers)) — the only node kind valid from *both* a `PredicateNode` and an `ExpressionNode` position: the exact same schema is appended as the last member of each of the two discriminated unions above, not two separately-declared copies that happen to look alike. Unlike [`delegate`](#delegate), which hands the whole node off to an *external* system this package never evaluates, `treeReference` resolves to *another tree of this same schema* and evaluates it with this same evaluator — a sub-rule reference, not an escape hatch. + +`resolveTree` is optional on `Resolvers`, for the same reason `resolveDelegate` is: a well-defined indeterminate result on absence, not a masked bug, and an additive, non-breaking interface change for every consumer implemented before this node kind existed. If no `resolveTree` is registered, evaluating a `treeReference` node is `wrong-type` ("no tree resolver registered for treeReference nodes"). If the resolver reports no match, the result is `not-found`. + +A resolved tree is never merely trusted — it is re-validated with a fresh `PredicateNodeSchema`/`ExpressionNodeSchema` parse (`PredicateNodeSchema` from a predicate-position reference, `ExpressionNodeSchema` from an expression-position one) before evaluation proceeds, exactly the same discipline the top-level tree itself is subject to when it first arrives at this package. A resolver fetching "a named rule from storage" is very often surfacing the same non-developer-authored JSON the top-level tree already is, with no static guarantee it still matches the schema; a failed parse is `wrong-type`. + +`context` and the enclosing fold's `accumulator` both pass through a `treeReference` unchanged — the referenced tree shares the caller's evaluation scope, like a subroutine call, not a nested evaluation with its own fresh context. There is deliberately no mechanism to override the context at a `treeReference` boundary; a consumer wanting that already has [`delegate`](#delegate). + +**Cycle and depth protection.** A tree that references itself, directly or through a longer chain, is guarded by two independent, layered checks rather than one: a cycle detector tracks every `key` (by its `JSON.stringify`'d form) already on the current reference chain, and reports `domain-error` ("circular treeReference detected") the moment a repeat is seen; a fixed depth cap separately reports `domain-error` ("...exceeds the maximum depth") on a long *acyclic* chain the cycle detector alone would never catch. Neither check is a substitute for the other. + +**A known, accepted design consequence.** `resolveTree` has no built-in way to know whether a given `key` is being resolved from a predicate-position or an expression-position reference — the same is already true of `reference.key` and `lookup.table`, neither of which carries a type discriminator either. A consumer needing to disambiguate structures their own key accordingly (e.g. `{ kind: "predicate", id: "..." }` as the `JsonValue` itself) rather than this schema growing a bespoke field for it. + +## Collections + +Both `fold` and the two quantifier leaves (`some`/`every`, and transitively `none`) need "a collection of items" resolved from something the schema itself treats as opaque data. The schema's job is only to carry an opaque reference to what collection is meant, plus a sub-node (an `ExpressionNode` for `fold`, a `PredicateNode` for the quantifiers) to be evaluated once per resolved item, using that single item as its evaluation context, plus an optional per-item pre-filter — see [Pre-filtering which items participate](#pre-filtering-which-items-participate). + +How an opaque collection reference actually becomes a concrete list of items is entirely the resolver's responsibility, and is expected to vary enormously between consumers — one consumer's "collection" might be an array already sitting inside a single in-hand record (zero further lookups needed); a completely different consumer's "collection" might require actively traversing some larger connected structure outward from a starting point to discover which items even belong to it, with nothing available up front. The schema and evaluator support both extremes, and anything in between, equally well, purely by keeping the reference opaque and leaving all resolution logic behind the injected collection resolver — there is no assumption anywhere about how many steps are involved in turning a reference into a list. + +### Evaluation context + +```ts +type EvaluationContext = unknown; +``` + +Every evaluation call is threaded through an `EvaluationContext` — an opaque, purely in-process value supplied by the caller, never itself part of the serialised tree and never required to be JSON-serialisable (unlike every payload described above, which *does* travel inside the tree and must be plain JSON). `reference` and `lookup` resolution both receive the current context. Descending into a `fold` or a quantifier replaces the context for the sub-node's evaluation with the single resolved item — literally the item itself, not a wrapper around it — so that a `reference` inside `item`/case sub-trees resolves against that item rather than against whatever the outer context was. + +### Pre-filtering which items participate + +`fold`, `some`, and `every` each accept an optional `filter: PredicateNode`, evaluated once per candidate item using that item as its own evaluation context — exactly the same mechanism `fold`'s own per-item expression and the quantifiers' own `item` sub-node already use. An item for which `filter` is definitely `true` participates; one for which it is definitely `false` is excluded, exactly as if it had never been in the collection at all. Time-window narrowing (only include items whose own timestamp falls within given bounds) is simply one example use of this general mechanism — a `filter` predicate comparing the item's own timestamp field against bounds via `compare` — not a separate concept, and there is no dedicated time-scoping field alongside it. A resolver that already knows how to push a narrowing hint down into its own data access remains free to do so using whatever it can infer from the opaque `collection` reference and `context` it already receives — `filter` narrows the schema's own view of the result, it doesn't preclude a resolver-side optimisation underneath. + +An item whose `filter` is itself indeterminate is never silently included or excluded — silently picking either would hide a real data-quality problem behind an arbitrary default. What happens next depends on whether the surrounding node has an absorbing value: `fold` has none (see [Three-valued propagation rules](#three-valued-propagation-rules)), so an indeterminate `filter` on any candidate item unconditionally makes the whole `fold` indeterminate, exactly as an indeterminate `item`/`combine` evaluation already does. The quantifiers do have one: an indeterminate `filter` makes that one item's own contribution to the surrounding OR (`some`)/AND (`every`) indeterminate, and the quantifier's already-established absorption rule then decides the final result exactly as it already does for an indeterminate `item` evaluation — a `some` with one item whose `filter` can't be resolved still comes back definitely `true` if a different, cleanly-filtered item is a definite match. Treating an indeterminate filter as an automatic override of an already-decided quantifier result would reintroduce, for filtering specifically, exactly the "any indeterminate operand poisons everything, no absorption" defect this document already identifies as wrong for AND/OR in general. + +## Resolvers + +Three core, required points of extension, plus two further independent optional ones, each supplied separately by the embedding consumer, each treated by the schema as pure data to hand over — never as resolver logic living inside the schema itself: + +```ts +type Resolution = + | { found: true; value: ComputedValue } + | { found: false }; + +type TreeResolution = + | { found: true; node: JsonValue } + | { found: false }; + +interface Resolvers { + /** Resolver 1 — a single opaque key to a single value (IV.reference). */ + resolveValue(key: JsonValue, context: EvaluationContext): Promise; + + /** Resolver 2 — an opaque table identifier plus computed keys to a single value (IV.lookup). */ + resolveLookup(table: JsonValue, keys: ComputedValue[], context: EvaluationContext): Promise; + + /** Resolver 3 — an opaque collection reference to a concrete list of items (fold/some/every). */ + resolveCollection(collection: JsonValue, context: EvaluationContext): Promise; + + /** Optional, separate from the three core contracts — see the `delegate` node kind. */ + resolveDelegate?(system: string, payload: JsonValue, context: EvaluationContext): Promise; + + /** Optional, separate from the three core contracts — see the `treeReference` node kind. */ + resolveTree?(key: JsonValue, context: EvaluationContext): Promise; +} +``` + +`resolveCollection` takes no narrowing parameter of its own: it always returns the full candidate list for the given reference, and narrowing which of those candidates actually take part is handled uniformly, after resolution, by the `filter` mechanism described under [Pre-filtering which items participate](#pre-filtering-which-items-participate) — no resolver needs a bespoke narrowing argument for this. It also returns a plain array rather than a `Resolution` envelope: a collection's "nothing here" state is unambiguously an empty array, unlike a single value's absence, which needs an explicit flag to distinguish "there is genuinely nothing here" from any value the resolver might otherwise legitimately return. Each resolver may itself be asynchronous, independently of the others. None of the three core resolvers needs to know anything about the other two, or about either optional one; a consumer implementing all five is free to have them share underlying data-access logic, but the schema and evaluator never require or assume that they do. + +`resolveTree` returns a `TreeResolution`, deliberately shaped like `Resolution` but distinct from it: `node` carries opaque JSON — the referenced tree, re-validated by the evaluator rather than trusted (see [`treeReference`](#treereference)) — where `Resolution`'s `value` carries an already-typed `ComputedValue`. It is otherwise the same "found" envelope for the same reason: a `treeReference`'s absence needs to be distinguishable from any tree the resolver might otherwise legitimately return, exactly as a `reference`'s absence needs to be distinguishable from any value. `resolveDelegate` and `resolveTree` solve different problems and are never a substitute for one another: `resolveDelegate` hands a payload to an *external* system this package never evaluates; `resolveTree` hands back *more of this same schema*, for this same evaluator to keep evaluating. + +## Evaluator entry points + +```ts +function evaluatePredicate( + node: PredicateNode, + context: EvaluationContext, + resolvers: Resolvers, +): Promise>; + +function evaluateValue( + node: ExpressionNode, + context: EvaluationContext, + resolvers: Resolvers, +): Promise>; +``` + +Both are exported directly, bound to an empty function registry — under them, any [`call`](#call) node is `wrong-type`. The registry a `call` resolves against is fixed at evaluator construction time rather than passed per evaluation (unlike `resolvers`, which are supplied fresh on every call), so supplying one means building a bound pair: + +```ts +type FunctionRegistry = Record< + string, + (args: readonly ComputedValue[]) => ComputedValue | { domainError: string } +>; + +function createEvaluator(options: { functions?: FunctionRegistry }): { + evaluatePredicate: (node: PredicateNode, context: EvaluationContext, resolvers: Resolvers) => Promise>; + evaluateValue: (node: ExpressionNode, context: EvaluationContext, resolvers: Resolvers) => Promise>; +}; +``` + +A registered function signals an argument outside its domain by *returning* `{ domainError: message }` rather than throwing, which is what keeps `call` inside the same three-outcome model as every other node kind (see [The evaluation model](#the-evaluation-model)); only the registry's own keys count as registered names, so a tree naming an inherited `Object.prototype` member is `wrong-type` like any other unregistered name. + +```ts +const { evaluateValue } = createEvaluator({ + functions: { + squareRoot: (args) => { + const [arg] = args; + if (arg?.kind !== "number") return { domainError: "squareRoot requires one number argument" }; + if (arg.value < 0) return { domainError: "squareRoot of a negative number is not a real number" }; + return { kind: "number", value: Math.sqrt(arg.value) }; + }, + }, +}); +``` + +## Indeterminacy reference + +How each reason category can arise, per node kind. "Propagates" means: an indeterminate operand/sub-result, with no other rule overriding it, makes the whole node indeterminate with that same reason (subject to the tie-break rule in [The evaluation model](#the-evaluation-model) when more than one candidate reason is present, and to the absorbing-value exceptions called out explicitly below). + +| Node kind | `not-found` | `wrong-type` | `domain-error` | +|---|---|---|---| +| `not` | propagates from operand | propagates from operand | propagates from operand | +| `and` | propagates, **unless** the other operand is definitely `false` (absorbs) | as `not-found` | as `not-found` | +| `or` | propagates, **unless** the other operand is definitely `true` (absorbs) | as `not-found` | as `not-found` | +| `allOf` / `anyOf` | as `and`/`or`, extended pairwise across the list | as `and`/`or` | as `and`/`or` | +| `compare` | either operand not found | operand kinds differ, or units incompatible, or kind is not `number`/`instant`/`duration`/`complex`, or an ordering operator was given a `complex` operand (see [Complex values](#complex-values)) | never directly (comparison itself has no domain restriction) | +| `textCompare` | either operand not found | either operand is not `text` | never directly | +| `memberOf` | `operand` not found, or (with no definite match found) a scanned candidate not found | `operand`/a candidate resolves to an incompatible kind or unit, with no definite match found among the rest | never directly | +| `exists` | never — converts operand `not-found` to definite `false` | never — converts operand `wrong-type`/`domain-error` to definite `true` | never — see `wrong-type` column | +| `some` / `every` | an item's `filter` or `item` sub-node reports not-found, and it is not absorbed by an already-decided item | as `not-found` | as `not-found` | +| literals (`numberLiteral`, `textLiteral`, `instantLiteral`, `durationLiteral`, `complexLiteral`) | never | never | never | +| `reference` | resolver reports absence | resolver's value doesn't match an expected `unit`, or is used where an incompatible kind is required upstream | never directly | +| `arithmetic` | either operand not found | operand not numeric (or temporal-kind mismatch — see [Temporal values](#temporal-values)), or unit mismatch on add/subtract, or a `power` exponent that is not a real integer over a `complex` operand (see [Complex values](#complex-values)) | zero divisor, `modulo` over a `complex` operand, or any other documented domain violation for the operator | +| `negate` | operand not found | operand not `number`/`duration`/`complex` | never directly | +| `call` | any argument not found | unregistered function name, or an argument of the wrong kind for that function | argument outside the function's valid domain (e.g. negative input to `squareRoot`) | +| `lookup` | any key not found, or resolver reports no match | a key expression resolves to the wrong kind for that table | never directly | +| `conditional` | `"first"`: an unmatched guard's own evaluation is `not-found`, before any earlier guard matched.
`"unique"`: any case's `when` is `not-found`, unless 2+ cases already definitely matched (see `domain-error`, which then takes priority).
Both: also the chosen branch's (`then`/`fallback`) own result if it is `not-found`. | Same pattern as `not-found`, substituting `wrong-type` throughout (guard evaluation and chosen branch alike). | `"unique"` only: 2+ cases are definitely `true` — see [`conditional`](#conditional)'s absorption order.
Both: same pattern as `not-found`, substituting `domain-error` (guard evaluation and chosen branch alike). | +| `fold` | any participating item's `filter`, `item`, or `combine` evaluation is `not-found`; or a `reduce`'s `initial` is `not-found` | any participating item's `filter`, `item`, or `combine` evaluation is `wrong-type`; or a `reduce`'s `initial` is `wrong-type` | empty (post-filter) collection with `max`/`min` (no first item to seed from); or any participating item's `item`/`combine` evaluation is `domain-error`; or a `reduce`'s `initial` is `domain-error` | +| `accumulator` | never | used outside a reduce fold's `combine` expression | never | +| `delegate` | never (no resolution attempted without a handler) | no handler registered for the named `system` | never | +| `treeReference` | resolver reports no match | no `resolveTree` registered; or the resolved node fails schema validation | a circular reference is detected; or the reference chain exceeds the maximum depth | + +## Worked example + +A single condition combining a boolean tree, a comparison leaf whose value side is itself a formula, a fold/aggregation node, and all three resolver contracts in use — every name below is a generic placeholder. + +**Rule:** "`isActive` is true, and the sum of `amount` across the `items` collection is greater than `x + y`." `isActive` is a `boolean` computed value, compared for equality against the literal `true`. The `fold` below is exactly what the [`sum`](#derived-aggregates) builder produces — shown here as the literal tree it assembles, to keep the resolver trace below concrete. + +```json +{ + "kind": "and", + "left": { + "kind": "compare", + "op": "eq", + "left": { "kind": "reference", "key": "isActive" }, + "right": { "kind": "booleanLiteral", "value": true } + }, + "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" } + } + } +} +``` + +A minimal set of resolvers backing this against a plain in-memory record: + +```ts +const data = { + isActive: true, + x: 10, + y: 5, + items: [{ amount: 8 }, { amount: 12 }, { amount: 1 }], +}; + +const resolvers: Resolvers = { + async resolveValue(key, context) { + const record = context as Record; + if (typeof key !== "string" || !(key in record)) return { found: false }; + const value = record[key]; + if (typeof value === "boolean") return { found: true, value: { kind: "boolean", value } }; + return { found: true, value: { kind: "number", value: value as number } }; + }, + async resolveLookup() { + return { found: false }; // unused by this example + }, + async resolveCollection(collection, context) { + const record = context as Record; + return collection === "items" ? (record.items as unknown[]) : []; + }, +}; +``` + +Tracing the evaluation against `data` as the root `EvaluationContext`: + +1. `compare eq` (left branch): `resolveValue("isActive", data)` → `{ found: true, value: { kind: "boolean", value: true } }`; compared against `booleanLiteral true` → definite `true`. +2. `fold` (`reduce`, seeded at `0`): `resolveCollection("items", data)` → three items. The accumulator starts at `0`; for each item in turn, `combine` evaluates `accumulator + reference("amount")` with that single item as context — `resolveValue("amount", item)` → `8`, `12`, `1`, all definite — stepping the accumulator `0 → 8 → 20 → 21`. Final accumulator → `21`. +3. `arithmetic add`: `resolveValue("x", data)` → `10`; `resolveValue("y", data)` → `5`. Sum → `15`. +4. `compare gt` (right branch): `21 > 15` → definite `true`. +5. `and(true, true)` → definite `true`. + +Final result: `{ status: "definite", value: true }`. + +Two variations show the propagation rules in action without changing the tree at all. If `items` resolved to `[]`, step 2 would be `0` (the `sum`-over-empty identity), step 4 would be `0 > 15 → false`, and step 5 would be `and(true, false) → false` — still fully definite, because `false` absorbs regardless of how step 1 turned out. If instead `x` were missing from `data`, `resolveValue("x", data)` would report `{ found: false }`, making the `arithmetic add` indeterminate (`not-found`), the `compare gt` indeterminate for the same reason, and `and(true, indeterminate)` indeterminate too — `true` is not an absorbing value for AND, so the missing data surfaces all the way to the top-level result rather than being silently swallowed. + +## Out of scope + +This package is a representation-plus-evaluator for conditions and formulas over already-available (or resolver-obtained) data. It deliberately does not include: + +- **Symbolic algebra.** It cannot solve an expression for an unknown quantity, symbolically simplify an expression, or perform symbolic differentiation or integration. A consumer needing any of that is expected to translate the pure-arithmetic portion of an expression tree into the input format of existing, general-purpose symbolic-mathematics software — several mature, freely available options already exist — and let that external system do the symbolic work. This package's job stops at representing and numerically evaluating a tree, not manipulating it symbolically. +- **Batch unresolvable-reference reporting.** This design deliberately has no node kind for asking "which of these references, across a whole batch, are unresolvable" as a single evaluation — only the [`exists`](#exists) leaf's one-at-a-time true/false/false-on-absence check. A tool that wants to report a *list* of every missing reference (for an authoring UI validating a tree before it's saved, say) is expected to build that on top of `exists` — walk the references of interest and evaluate an `exists` leaf over each — at the authoring/tooling layer, rather than this package growing a bespoke aggregate-diagnostic node kind for it. This is a deliberate boundary, not an oversight: it keeps the evaluation tree itself limited to producing one `Evaluation` per node, and leaves "collect many such results and report on them together" to whatever sits above the evaluator, exactly like symbolic algebra above is left to whatever sits beside it. + +This package does not name or depend on any specific external tool for the delegation case above — it only defines the shape of the hand-off (an opaque payload plus a named destination system). + +Complex-number and phasor arithmetic used to be listed here too, delegated out on the reasoning that supporting them would be a far larger and more invasive change than adding one more named function. That sizing was wrong: unlike symbolic algebra, which is a genuinely different kind of system, complex arithmetic is closed-form numeric evaluation, exactly what this evaluator already does for every other computed-value kind. It is now part of the core numeric model — see [Complex values](#complex-values), and [Design principles](#design-principles) for the scope test that judgement is now written down as. + +## Prior art + +Twenty-three existing tools — JSON rule engines, expression languages, query-filter conventions, and three-valued-logic precedents — researched against seven properties trilean combines: a genuinely portable representation, injected async data access, a real three-outcome logic, vendor-agnostic scope, mixing logic and arithmetic in one tree, no code-execution surface for an untrusted author, and a formally published schema. None of the twenty-three combine all seven. Each verdict below was checked against the tool's own documentation, specification, or a security advisory, not assumed from category. + +The closest structural relative is [GoRules' Zen Engine](https://gorules.io) (`@gorules/zen-engine`), whose JDM format is a genuinely portable JSON decision graph with a real injected extension point — but its outcome model is value-level nulls, not a propagating three-valued logic, and its "Function" node type runs real JavaScript rather than staying within a bounded grammar. The closest semantic relative is [DMN](https://www.omg.org/spec/DMN)'s FEEL expression language, which implements the identical absorbing-AND/OR three-valued truth tables trilean does — but FEEL's canonical form is XML, not JSON. + +Worth knowing: a Rust crate on crates.io is also named [`trilean`](https://crates.io/crates/trilean) and also implements Kleene's three-valued logic — a genuine name collision, different ecosystem, no npm conflict, unrelated project. + +The security research here surfaced findings worth knowing independent of the comparison: [JSONata](https://jsonata.org) has had multiple prototype-pollution CVEs reaching `Function`/`child_process`; [jexl](https://github.com/TomFrost/Jexl) has a documented, unfixed path to `Function.prototype` via `__proto__`; [expr-eval](https://github.com/silentmatt/expr-eval) has a prototype-pollution CVE (CVE-2026-12866); and MongoDB's `$where`/`$function` and JsonLogic's `method` operator are documented, acknowledged arbitrary-code escape hatches. [CEL](https://cel.dev), [filtrex](https://github.com/cshaa/filtrex), and [Rego](https://www.openpolicyagent.org/docs/policy-language) are the standouts, each explicitly designed and marketed as safe for untrusted input. + +| Tool | Category | Portable data | Async access | Missing ≠ false | Vendor-agnostic | Mixes logic & math | No code-exec risk | Published schema | +|---|---|---|---|---|---|---|---|---| +| [trilean](https://www.npmjs.com/package/trilean) | — | 🟢
JSON, Zod-validated, RFC 8785 canonical | 🟢
three typed resolver contracts | 🟢
definite/indeterminate, typed reason | 🟢
no assumptions about consumer data | 🟢
compare/textCompare/memberOf take formulas | 🟢
call's fn is a registry key, never code | 🟢
one Zod schema generates both | +| [JsonLogic](https://www.npmjs.com/package/json-logic-js) | Rule engine | 🟢 | 🔴
direct path lookup | 🔴
counts as false | 🟢 | 🟢
any operand can be another rule | 🟡
`method` op is an acknowledged escape hatch | 🔴
a JSON Schema request was never resolved | +| [json-rules-engine](https://www.npmjs.com/package/json-rules-engine) | Rule engine | 🟢 | 🟢
async fact handlers | 🔴
undocumented | 🟢 | 🔴
docs call inline formulas "a design smell" | 🟢
operators are name-based registry lookups | 🔴
a proposed schema was never merged | +| [json-rules-engine-simplified](https://www.npmjs.com/package/json-rules-engine-simplified) | Rule engine | 🟢 | 🔴
direct path lookup | 🔴
falls through to false | 🟢 | 🔴
no arithmetic/formula node exists | 🟢
no `eval()`, by the project's own claim | 🔴
README prose only | +| [Zen Engine](https://www.npmjs.com/package/@gorules/zen-engine) / [JDM](https://docs.gorules.io/developers/jdm/standard) | Rule engine | 🟢 | 🟢
injected custom-node callback | 🟡
null-coalescing only | 🟢 | 🟢
ZEN expressions nest arithmetic in comparisons | 🟡
Function nodes run real JS, sandboxed | 🟡
docs claim one, none found published | +| [nools](https://www.npmjs.com/package/nools) | Rule engine | 🔴
JS/DSL | 🔴 | 🔴 | 🟢 | 🟢
DSL nests arithmetic in comparisons | 🔴
the `then` block is literal JS | 🔴
DSL documented only in prose | +| [rools](https://www.npmjs.com/package/rools) | Rule engine | 🔴
rules are JS | 🔴 | 🔴 | 🟢 | 🟢
but only because it's unrestricted JS | 🔴
"rules are specified in pure JavaScript" | 🔴
plain JS, prose docs only | +| [node-rules](https://www.npmjs.com/package/node-rules) | Rule engine | 🔴
conditions are JS | 🔴 | 🔴 | 🟢 | 🟢
but only because it's unrestricted JS | 🔴
a condition is explicitly "a function" | 🔴
prose docs only | +| [JSONata](https://www.npmjs.com/package/jsonata) | Expression lang. | 🟡
undocumented shape | 🟢 | — | 🟢 | 🟢
arithmetic on both sides of any comparison | 🔴
multiple prototype-pollution CVEs to RCE | 🔴
hand-written parser, a grammar request was declined | +| [CEL](https://cel.dev) | Expression lang. | 🟢
as Protobuf, not JSON | 🔴
sync by design | — | 🟢 | 🟢
arithmetic feeds directly into comparison | 🟢
explicitly designed safe for untrusted code | 🟢
versioned .proto files, wire-compatible forever | +| [jexl](https://www.npmjs.com/package/jexl) | Expression lang. | 🔴
private, unexposed | 🟢
closest match | — | 🟢 | 🟢
arithmetic and logical ops nest freely | 🔴
documented unfixed `__proto__` access issue | 🔴
grammar lives in a JS source file | +| [filtrex](https://www.npmjs.com/package/filtrex) | Expression lang. | 🔴
AST retained | 🔴
sandboxed sync closure | — | 🟢 | 🟢
documented example nests a product in a condition | 🟢
markets itself explicitly as safe for end-users | 🔴
a real grammar file exists but ships unpublished | +| [expr-eval](https://www.npmjs.com/package/expr-eval) | Expression lang. | 🔴
(jsep-based siblings do) | 🔴 | — | 🟢 | 🟢
and/or plus comparisons alongside arithmetic | 🔴
CVE-2026-12866, prototype pollution to RCE | 🔴
prose README only | +| [mathjs](https://www.npmjs.com/package/mathjs) | Expression lang. | 🔴
round-trip unreliable | 🔴
sync `evaluate()` | — | 🟢 | 🟢
arithmetic binds tighter than and/or, by design | 🟡
`eval` removed, but real sandbox-escape CVEs existed | 🔴
documented only in prose | +| [MongoDB query operators](https://www.mongodb.com/docs/manual/reference/operator/query/) | Query/filter DSL | 🟢 | — | — | 🔴
MQL only | 🟡
only behind the `$expr` escape hatch | 🟡
`$where`/`$function` run arbitrary server-side JS | 🟡
an official grammar exists but is archived since 2021 | +| [Prisma `where`](https://www.prisma.io/docs/orm/prisma-client/queries/filtering-and-sorting) | Query/filter DSL | 🔴
never transmitted | — | — | 🔴
per-schema generated | 🔴
computed fields aren't usable for filtering at all | 🟢
a fixed, enumerated operator set only | 🔴
generated internal TypeScript types only | +| [OData `$filter`](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html) | Query/filter DSL | 🟢
as a query string | — | — | 🟢
OASIS standard | 🟢
arithmetic operators combine directly with comparisons | 🟢
fixed operator set, no code-reference mechanism | 🟢
a normative, versioned ABNF grammar document | +| [JSON:API `filter`](https://jsonapi.org/format/#fetching-filtering) | Query/filter DSL | 🔴
reservation only | — | — | 🟡
no grammar defined | — | — | — | +| [GraphQL (Hasura-style)](https://hasura.io/docs/2.0/queries/postgres/filters/boolean-operators/) | Query/filter DSL | 🟢 | — | — | 🟡
de facto convention | 🟡
operand can be a column, never a computed formula | 🟢
fixed comparison-operator vocabulary | 🟡
real schema, but generated per deployment | +| [SQL `NULL` / `UNKNOWN`](https://www.postgresql.org/docs/current/functions-comparison.html) | 3VL precedent | 🔴
language semantic | — | 🟢
same absorbing tables | 🟢 | 🟢
WHERE-clause operands are arbitrary expressions | — | — | +| [DMN's FEEL](https://www.omg.org/spec/DMN) | 3VL precedent | 🟢
as XML, not JSON | — | 🟢
absorbing | 🟢
OMG standard | 🟢
full FEEL mixes arithmetic and and/or freely | 🟡
a boxed function can invoke external Java/PMML by name | 🟡
DMN XML has an XSD, FEEL itself is prose BNF | +| [OPA / Rego](https://www.openpolicyagent.org/docs/policy-language) | 3VL precedent | 🟢
, as source text | — | 🟡
absence, not a value | 🟢 | 🟢
comparisons take arithmetic sub-expressions | 🟢
explicitly not Turing-complete, by design | 🔴
grammar is prose EBNF, no standalone file | +| [AWS IAM policy language](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_grammar.html) | 3VL precedent | 🟢 | — | 🟡
default, not propagating | 🔴
AWS-specific | 🔴
every condition value is a literal string | 🟢
bounded grammar, fixed operator set | 🔴
only a prose BNF-like description | +| [`trinary`](https://pypi.org/project/trinary/), [`tvl`](https://github.com/archanpatkar/tvl), [`3vl`](https://www.npmjs.com/package/3vl), Go [`ternary`](https://github.com/mithrandie/ternary) | 3VL precedent | 🔴
in-memory only | — | 🟢
Kleene K3 | 🟢 | — | — | — | diff --git a/packages/trilean/eslint.config.ts b/packages/trilean/eslint.config.ts new file mode 100644 index 0000000..3a779e7 --- /dev/null +++ b/packages/trilean/eslint.config.ts @@ -0,0 +1,87 @@ +import { builtinModules } from "node:module"; +import { exadevConfig } from "@exadev/eslint-config"; +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; +import globals from "globals"; + +const nodeBuiltinBaseModules = [ + ...new Set( + builtinModules + .filter((name) => !name.startsWith("_") && !name.startsWith("node:")) + .map((name) => + name.includes("/") ? name.slice(0, name.indexOf("/")) : name, + ), + ), +].sort(); +const bareNodeBuiltinPattern = `^(${nodeBuiltinBaseModules.join("|")})(/.*)?$`; + +const runtimeSrcExemptions = ["src/**/*.test.ts", "src/test-support/**"]; + +export default exadevConfig( + {}, + { + ignores: ["dist", "coverage", "node_modules", ".turbo", "schemas"], + }, + { + languageOptions: { + parserOptions: { + project: ["./tsconfig.json", "./tsconfig.node.json"], + tsconfigRootDir: import.meta.dirname, + }, + globals: { ...globals.node }, + }, + }, + { + rules: { + "@typescript-eslint/consistent-type-imports": [ + "error", + { fixStyle: "inline-type-imports" }, + ], + "exadev/barrel-policy": ["error", { mode: "single" }], + }, + }, + { + files: ["src/**/*.ts"], + ignores: runtimeSrcExemptions, + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["node:*", "node:*/**"], + message: + "This is an isomorphic library: node:* imports are banned in runtime src.", + }, + { + regex: bareNodeBuiltinPattern, + message: + "This is an isomorphic library: bare Node builtin imports are banned in runtime src.", + }, + ], + }, + ], + "no-restricted-globals": [ + "error", + { + name: "Buffer", + message: "Buffer is Node-only; use Uint8Array/plain objects instead.", + }, + ], + }, + }, + { + // recommendedTypeChecked sets linterOptions.noInlineConfig, banning eslint-disable comments everywhere. src/tree.ts genuinely needs one (see the comment at its top) for its z.lazy() mutual-recursion pattern, so inline directives are permitted for this one file only. + files: ["src/tree.ts"], + linterOptions: { noInlineConfig: false }, + }, + { + files: ["**/*.test.ts"], + rules: { + "@typescript-eslint/no-empty-function": [ + "error", + { allow: ["arrowFunctions", "asyncFunctions"] }, + ], + }, + }, + eslintPluginPrettierRecommended, +); diff --git a/packages/trilean/package.json b/packages/trilean/package.json new file mode 100644 index 0000000..89a49cb --- /dev/null +++ b/packages/trilean/package.json @@ -0,0 +1,106 @@ +{ + "name": "trilean", + "version": "1.4.0", + "description": "Three-valued predicate and expression evaluation trees, stored as JSON and evaluated against injected resolvers, for domain logic — business rules, eligibility checks, formulas, search filters, and more — that needs to be data instead of code.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/trilean.git", + "directory": "packages/trilean" + }, + "homepage": "https://github.com/ExaDev/trilean/tree/main/packages/trilean", + "bugs": { + "url": "https://github.com/ExaDev/trilean/issues" + }, + "exports": { + ".": { + "types": { + "import": "./dist/index.d.ts", + "require": "./dist/index.d.cts" + }, + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./*": { + "types": { + "import": "./dist/*.d.ts", + "require": "./dist/*.d.cts" + }, + "import": "./dist/*.js", + "require": "./dist/*.cjs" + }, + "./schemas/*.schema.json": "./schemas/*.schema.json" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "schemas" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "registry": "https://registry.npmjs.org/" + }, + "sideEffects": false, + "engines": { + "node": ">=20" + }, + "license": "MIT", + "dependencies": { + "zod": "^4.5.4" + }, + "scripts": { + "build": "turbo run _build", + "_build": "tsdown && tsx scripts/generate-json-schema.ts", + "lint": "turbo run _lint", + "_lint": "eslint . --fix --cache --max-warnings 0", + "typecheck": "turbo run _typecheck _typecheck:attw", + "_typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json", + "_typecheck:attw": "attw --pack", + "test": "turbo run _test", + "_test": "vitest run --project unit", + "test:coverage": "turbo run _test:coverage", + "_test:coverage": "vitest run --project unit --coverage", + "test:integration": "turbo run _test:integration", + "_test:integration": "vitest run --project integration", + "test:smoke": "turbo run _test:smoke", + "_test:smoke": "vitest run --project smoke", + "test:workers": "turbo run _test:workers", + "_test:workers": "vitest run --project workers", + "prepush": "turbo run _prepush", + "_prepush": "true", + "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run test:integration && pnpm run build && pnpm run test:smoke && publint && attw --pack" + }, + "keywords": [ + "json", + "predicate", + "expression", + "evaluator", + "three-valued-logic", + "zod", + "rules-engine", + "isomorphic" + ], + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@cloudflare/vitest-pool-workers": "^0.22.0", + "@exadev/eslint-config": "^2.10.2", + "@types/node": "^26.4.0", + "@vitest/coverage-v8": "^4.1.11", + "canonicalize": "^4.0.0", + "eslint": "^10.9.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "globals": "^17.11.0", + "prettier": "^3.9.6", + "publint": "^0.3.24", + "tsdown": "^0.22.14", + "tsx": "^4.23.13", + "turbo": "^2.10.12", + "typescript": "^6.0.3", + "typescript-eslint": "^8.68.0", + "vitest": "^4.1.11" + } +} diff --git a/scripts/generate-json-schema.ts b/packages/trilean/scripts/generate-json-schema.ts similarity index 100% rename from scripts/generate-json-schema.ts rename to packages/trilean/scripts/generate-json-schema.ts diff --git a/src/complex.test.ts b/packages/trilean/src/complex.test.ts similarity index 100% rename from src/complex.test.ts rename to packages/trilean/src/complex.test.ts diff --git a/src/complex.ts b/packages/trilean/src/complex.ts similarity index 100% rename from src/complex.ts rename to packages/trilean/src/complex.ts diff --git a/src/computed-value.test.ts b/packages/trilean/src/computed-value.test.ts similarity index 100% rename from src/computed-value.test.ts rename to packages/trilean/src/computed-value.test.ts diff --git a/src/computed-value.ts b/packages/trilean/src/computed-value.ts similarity index 100% rename from src/computed-value.ts rename to packages/trilean/src/computed-value.ts diff --git a/src/derived-aggregates.test.ts b/packages/trilean/src/derived-aggregates.test.ts similarity index 100% rename from src/derived-aggregates.test.ts rename to packages/trilean/src/derived-aggregates.test.ts diff --git a/src/derived-aggregates.ts b/packages/trilean/src/derived-aggregates.ts similarity index 100% rename from src/derived-aggregates.ts rename to packages/trilean/src/derived-aggregates.ts diff --git a/src/derived-connectives.test.ts b/packages/trilean/src/derived-connectives.test.ts similarity index 100% rename from src/derived-connectives.test.ts rename to packages/trilean/src/derived-connectives.test.ts diff --git a/src/derived-connectives.ts b/packages/trilean/src/derived-connectives.ts similarity index 100% rename from src/derived-connectives.ts rename to packages/trilean/src/derived-connectives.ts diff --git a/src/derived-patterns.test.ts b/packages/trilean/src/derived-patterns.test.ts similarity index 100% rename from src/derived-patterns.test.ts rename to packages/trilean/src/derived-patterns.test.ts diff --git a/src/derived-patterns.ts b/packages/trilean/src/derived-patterns.ts similarity index 100% rename from src/derived-patterns.ts rename to packages/trilean/src/derived-patterns.ts diff --git a/src/derived-values.test.ts b/packages/trilean/src/derived-values.test.ts similarity index 100% rename from src/derived-values.test.ts rename to packages/trilean/src/derived-values.test.ts diff --git a/src/derived-values.ts b/packages/trilean/src/derived-values.ts similarity index 100% rename from src/derived-values.ts rename to packages/trilean/src/derived-values.ts diff --git a/src/evaluation.ts b/packages/trilean/src/evaluation.ts similarity index 100% rename from src/evaluation.ts rename to packages/trilean/src/evaluation.ts diff --git a/src/evaluator.indeterminacy.test.ts b/packages/trilean/src/evaluator.indeterminacy.test.ts similarity index 100% rename from src/evaluator.indeterminacy.test.ts rename to packages/trilean/src/evaluator.indeterminacy.test.ts diff --git a/src/evaluator.test.ts b/packages/trilean/src/evaluator.test.ts similarity index 100% rename from src/evaluator.test.ts rename to packages/trilean/src/evaluator.test.ts diff --git a/src/evaluator.ts b/packages/trilean/src/evaluator.ts similarity index 100% rename from src/evaluator.ts rename to packages/trilean/src/evaluator.ts diff --git a/src/functions.ts b/packages/trilean/src/functions.ts similarity index 100% rename from src/functions.ts rename to packages/trilean/src/functions.ts diff --git a/src/golden-examples.test.ts b/packages/trilean/src/golden-examples.test.ts similarity index 100% rename from src/golden-examples.test.ts rename to packages/trilean/src/golden-examples.test.ts diff --git a/src/index.ts b/packages/trilean/src/index.ts similarity index 100% rename from src/index.ts rename to packages/trilean/src/index.ts diff --git a/src/json-value.ts b/packages/trilean/src/json-value.ts similarity index 100% rename from src/json-value.ts rename to packages/trilean/src/json-value.ts diff --git a/src/resolvers.ts b/packages/trilean/src/resolvers.ts similarity index 100% rename from src/resolvers.ts rename to packages/trilean/src/resolvers.ts diff --git a/src/test-support/golden-example.ts b/packages/trilean/src/test-support/golden-example.ts similarity index 100% rename from src/test-support/golden-example.ts rename to packages/trilean/src/test-support/golden-example.ts diff --git a/src/tree.test.ts b/packages/trilean/src/tree.test.ts similarity index 100% rename from src/tree.test.ts rename to packages/trilean/src/tree.test.ts diff --git a/src/tree.ts b/packages/trilean/src/tree.ts similarity index 100% rename from src/tree.ts rename to packages/trilean/src/tree.ts diff --git a/src/truth-tables.test.ts b/packages/trilean/src/truth-tables.test.ts similarity index 100% rename from src/truth-tables.test.ts rename to packages/trilean/src/truth-tables.test.ts diff --git a/test/integration/complex-arithmetic.test.ts b/packages/trilean/test/integration/complex-arithmetic.test.ts similarity index 100% rename from test/integration/complex-arithmetic.test.ts rename to packages/trilean/test/integration/complex-arithmetic.test.ts diff --git a/test/integration/composed-rules.test.ts b/packages/trilean/test/integration/composed-rules.test.ts similarity index 100% rename from test/integration/composed-rules.test.ts rename to packages/trilean/test/integration/composed-rules.test.ts diff --git a/test/integration/delegate-handler.test.ts b/packages/trilean/test/integration/delegate-handler.test.ts similarity index 100% rename from test/integration/delegate-handler.test.ts rename to packages/trilean/test/integration/delegate-handler.test.ts diff --git a/test/integration/function-registry.test.ts b/packages/trilean/test/integration/function-registry.test.ts similarity index 100% rename from test/integration/function-registry.test.ts rename to packages/trilean/test/integration/function-registry.test.ts diff --git a/test/integration/json-schema-consistency.test.ts b/packages/trilean/test/integration/json-schema-consistency.test.ts similarity index 100% rename from test/integration/json-schema-consistency.test.ts rename to packages/trilean/test/integration/json-schema-consistency.test.ts diff --git a/test/integration/pattern-matching.test.ts b/packages/trilean/test/integration/pattern-matching.test.ts similarity index 100% rename from test/integration/pattern-matching.test.ts rename to packages/trilean/test/integration/pattern-matching.test.ts diff --git a/test/integration/schema-pipeline.test.ts b/packages/trilean/test/integration/schema-pipeline.test.ts similarity index 100% rename from test/integration/schema-pipeline.test.ts rename to packages/trilean/test/integration/schema-pipeline.test.ts diff --git a/test/integration/tree-reference.test.ts b/packages/trilean/test/integration/tree-reference.test.ts similarity index 100% rename from test/integration/tree-reference.test.ts rename to packages/trilean/test/integration/tree-reference.test.ts diff --git a/test/smoke.test.ts b/packages/trilean/test/smoke.test.ts similarity index 100% rename from test/smoke.test.ts rename to packages/trilean/test/smoke.test.ts diff --git a/test/workers/trilean.test.ts b/packages/trilean/test/workers/trilean.test.ts similarity index 100% rename from test/workers/trilean.test.ts rename to packages/trilean/test/workers/trilean.test.ts diff --git a/packages/trilean/tsconfig.json b/packages/trilean/tsconfig.json new file mode 100644 index 0000000..13e017d --- /dev/null +++ b/packages/trilean/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + // No "node" types and a WebWorker lib: this is the isomorphism gate, so a Node-only global referenced from runtime src/ fails to compile here rather than at some consumer's runtime. + "lib": ["ES2024", "WebWorker"], + "types": [], + "resolveJsonModule": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/test-support/**/*.ts"] +} diff --git a/tsconfig.node.json b/packages/trilean/tsconfig.node.json similarity index 100% rename from tsconfig.node.json rename to packages/trilean/tsconfig.node.json diff --git a/tsdown.config.ts b/packages/trilean/tsdown.config.ts similarity index 100% rename from tsdown.config.ts rename to packages/trilean/tsdown.config.ts diff --git a/vitest.config.ts b/packages/trilean/vitest.config.ts similarity index 100% rename from vitest.config.ts rename to packages/trilean/vitest.config.ts diff --git a/wrangler.jsonc b/packages/trilean/wrangler.jsonc similarity index 100% rename from wrangler.jsonc rename to packages/trilean/wrangler.jsonc diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ec04d5..924dbb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,23 +4,16 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@semantic-release/release-notes-generator>conventional-changelog-writer': 9.2.1 + importers: .: - dependencies: - zod: - specifier: ^4.5.4 - version: 4.5.4 devDependencies: - '@arethetypeswrong/cli': - specifier: ^0.18.5 - version: 0.18.5 - '@cloudflare/vitest-pool-workers': - specifier: ^0.22.0 - version: 0.22.0(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11) '@commitlint/cli': specifier: ^21.2.2 - version: 21.2.2(@types/node@26.4.0)(conventional-commits-parser@7.1.2)(typescript@6.0.3) + version: 21.2.2(@types/node@26.4.1)(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3) '@commitlint/config-conventional': specifier: ^21.2.2 version: 21.2.2 @@ -29,7 +22,10 @@ importers: version: 10.0.1(eslint@10.9.1(jiti@2.6.1)) '@exadev/eslint-config': specifier: ^2.10.2 - version: 2.10.2(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3) + 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) + '@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) '@semantic-release/changelog': specifier: ^7.0.0 version: 7.0.0(semantic-release@25.0.9(typescript@6.0.3)) @@ -50,13 +46,10 @@ importers: version: 14.1.1(semantic-release@25.0.9(typescript@6.0.3)) '@types/node': specifier: ^26.4.0 - version: 26.4.0 - '@vitest/coverage-v8': - specifier: ^4.1.11 - version: 4.1.11(vitest@4.1.11) - canonicalize: - specifier: ^4.0.0 - version: 4.0.0 + version: 26.4.1 + conventional-changelog-conventionalcommits: + specifier: ^10.4.0 + version: 10.4.0 eslint: specifier: ^10.9.1 version: 10.9.1(jiti@2.6.1) @@ -68,7 +61,7 @@ importers: version: 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) globals: specifier: ^17.11.0 - version: 17.11.0 + version: 17.12.0 husky: specifier: ^9.1.7 version: 9.1.7 @@ -78,12 +71,61 @@ importers: prettier: specifier: ^3.9.6 version: 3.9.6 - publint: - specifier: ^0.3.24 - version: 0.3.24 semantic-release: specifier: ^25.0.9 version: 25.0.9(typescript@6.0.3) + turbo: + specifier: ^2.10.12 + version: 2.10.12 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + typescript-eslint: + specifier: ^8.68.0 + version: 8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + + packages/trilean: + dependencies: + zod: + specifier: ^4.5.4 + version: 4.5.4 + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.5 + version: 0.18.5 + '@cloudflare/vitest-pool-workers': + 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) + '@types/node': + specifier: ^26.4.0 + version: 26.4.1 + '@vitest/coverage-v8': + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) + canonicalize: + specifier: ^4.0.0 + version: 4.0.0 + eslint: + specifier: ^10.9.1 + version: 10.9.1(jiti@2.6.1) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.9.1(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.5.6 + version: 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) + globals: + specifier: ^17.11.0 + version: 17.12.0 + prettier: + specifier: ^3.9.6 + version: 3.9.6 + publint: + specifier: ^0.3.24 + version: 0.3.24 tsdown: specifier: ^0.22.14 version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.24)(tsx@4.23.13)(typescript@6.0.3) @@ -98,10 +140,10 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.68.0 - version: 8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + version: 8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -309,156 +351,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -498,8 +696,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.2': - resolution: {integrity: sha512-UAGK9aPkpd2mC3x8XQbJcnmdhYhOdH2TQGzVD88QZRMKm9FlASXWcnJpwox8miV6RVVmzOaKhxZJCj8QGGDM6w==} + '@exadev/eslint-config@2.10.4': + resolution: {integrity: sha512-E+YLfg3RbQ5DVfWMiRdpJo9sjYfwWzuRef6JidWbvoQxHMkEp7IWEPedfIvBvBPQPjK8hDVUMdnKPojdhxEpYA==} engines: {node: '>=20'} peerDependencies: '@next/eslint-plugin-next': ^16.3.2 @@ -519,6 +717,19 @@ packages: eslint-plugin-react-hooks: optional: true + '@exadev/semantic-release-workspace@1.2.1': + resolution: {integrity: sha512-qMyGpKS4Ksof37MKZ/Zso/jIGyMiUOR98KtSg9JBkc//S8KiVgrBmkL/E0/WRWbQlk2OIJfCbV0/UYtc8fd1eg==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + '@semantic-release/changelog': ^7.0.0 + '@semantic-release/commit-analyzer': ^13.0.1 + '@semantic-release/git': ^11.0.1 + '@semantic-release/github': ^12.0.9 + '@semantic-release/npm': ^13.1.5 + '@semantic-release/release-notes-generator': ^14.1.1 + semantic-release: ^25.0.9 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -777,8 +988,8 @@ packages: '@octokit/types@18.0.0': resolution: {integrity: sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==} - '@oxc-project/types@0.147.0': - resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} '@pkgr/core@0.3.6': resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} @@ -812,98 +1023,98 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - '@rolldown/binding-android-arm-eabi@1.2.6': - resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.6': - resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.6': - resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.6': - resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.6': - resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.6': - resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.6': - resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.6': - resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.6': - resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.6': - resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.6': - resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.6': - resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.6': - resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.6': - resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.6': - resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1029,69 +1240,69 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@26.4.0': - resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} + '@types/node@26.4.1': + resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} - '@typescript-eslint/eslint-plugin@8.68.0': - resolution: {integrity: sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==} + '@typescript-eslint/eslint-plugin@8.69.0': + resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.68.0 + '@typescript-eslint/parser': ^8.69.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.68.0': - resolution: {integrity: sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==} + '@typescript-eslint/parser@8.69.0': + resolution: {integrity: sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==} 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.1.0' - '@typescript-eslint/project-service@8.68.0': - resolution: {integrity: sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==} + '@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.68.0': - resolution: {integrity: sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==} + '@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.68.0': - resolution: {integrity: sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==} + '@typescript-eslint/tsconfig-utils@8.69.0': + resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.68.0': - resolution: {integrity: sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==} + '@typescript-eslint/type-utils@8.69.0': + resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} 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.1.0' - '@typescript-eslint/types@8.68.0': - resolution: {integrity: sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==} + '@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.68.0': - resolution: {integrity: sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==} + '@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.68.0': - resolution: {integrity: sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==} + '@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} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.68.0': - resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} + '@typescript-eslint/visitor-keys@8.69.0': + resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitest/coverage-v8@4.1.11': @@ -1325,8 +1536,8 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - argue-cli@3.1.0: - resolution: {integrity: sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==} + argue-cli@3.2.0: + resolution: {integrity: sha512-VipTB0gXgGIFO2Rg9yEVN5wLt2AurJcZqDbgmYSwPwsykhLrQhs240/bfceev4w68lI8JshoOJIk9uW7tFzROw==} engines: {node: '>=22'} argv-formatter@1.0.0: @@ -1439,6 +1650,10 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -1466,10 +1681,19 @@ packages: engines: {node: '>=18'} hasBin: true + conventional-changelog-writer@9.2.1: + resolution: {integrity: sha512-StlYSmW3wLedRaqohJMpP3YuWiAqtgD0/cpsai9frdDkXv7rxj0hRbpJrubPYvJxJsjplONsOobEQnPuS9UgCg==} + engines: {node: '>=22'} + hasBin: true + conventional-commits-filter@5.0.0: resolution: {integrity: sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==} engines: {node: '>=18'} + conventional-commits-filter@6.0.1: + resolution: {integrity: sha512-cs+LadpH7Kpw0M3k8wurk+sOVVDAENA0iK4OBOrkL94j5lEVYRJ4j3zd2bhY9qgzyrPqthdcYT3axzRN7AliMg==} + engines: {node: '>=22'} + conventional-commits-parser@6.4.0: resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} engines: {node: '>=18'} @@ -1604,6 +1828,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1713,8 +1942,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.6: - resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1815,8 +2044,8 @@ packages: resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} engines: {node: '>=20'} - globals@17.11.0: - resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} + globals@17.12.0: + resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} engines: {node: '>=18'} graceful-fs@4.2.10: @@ -1884,8 +2113,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.7: - resolution: {integrity: sha512-dML0wP6oak21rsNYCJpJB6O1BJIEwNpGrTw0URPfAk4hm0e3pRfCtzkfB6olBcXcVlU2rouCyz7lCyRB0OMVCA==} + ignore@7.0.8: + resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==} engines: {node: '>= 4'} import-fresh@3.3.1: @@ -2475,8 +2704,8 @@ packages: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + postcss@8.5.27: + resolution: {integrity: sha512-79Iho8QeYyooJ8e9lCRyTVlyTAkS/kXBYKff6TMzS3kEWGQ8Ds5UEtXpGrSUDLUWok6QTvxeYy0GO8fopHnaSA==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -2588,8 +2817,8 @@ packages: vue-tsc: optional: true - rolldown@1.2.6: - resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2772,8 +3001,8 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.3.0: - resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -2872,8 +3101,8 @@ packages: resolution: {integrity: sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==} engines: {node: '>=20'} - typescript-eslint@8.68.0: - resolution: {integrity: sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==} + typescript-eslint@8.69.0: + resolution: {integrity: sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2955,6 +3184,10 @@ packages: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + validate-npm-package-name@8.0.0: + resolution: {integrity: sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + verkit@0.3.2: resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} engines: {node: '>=18.12.0'} @@ -3240,7 +3473,7 @@ snapshots: cjs-module-lexer: 1.2.3 esbuild: 0.28.1 miniflare: 5.20260815.0-alpha - vitest: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0)) wrangler: 4.124.0 zod: 4.4.3 transitivePeerDependencies: @@ -3266,15 +3499,15 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@commitlint/cli@21.2.2(@types/node@26.4.0)(conventional-commits-parser@7.1.2)(typescript@6.0.3)': + '@commitlint/cli@21.2.2(@types/node@26.4.1)(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3)': dependencies: '@commitlint/config-conventional': 21.2.2 '@commitlint/format': 21.2.2 '@commitlint/lint': 21.2.2 - '@commitlint/load': 21.2.2(@types/node@26.4.0)(typescript@6.0.3) - '@commitlint/read': 21.2.1(conventional-commits-parser@7.1.2) + '@commitlint/load': 21.2.2(@types/node@26.4.1)(typescript@6.0.3) + '@commitlint/read': 21.2.1(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2) '@commitlint/types': 21.2.0 - tinyexec: 1.3.0 + tinyexec: 1.3.1 yargs: 18.1.0 transitivePeerDependencies: - '@types/node' @@ -3316,14 +3549,14 @@ snapshots: '@commitlint/rules': 21.2.2 '@commitlint/types': 21.2.0 - '@commitlint/load@21.2.2(@types/node@26.4.0)(typescript@6.0.3)': + '@commitlint/load@21.2.2(@types/node@26.4.1)(typescript@6.0.3)': dependencies: '@commitlint/config-validator': 21.2.0 '@commitlint/execute-rule': 21.0.1 '@commitlint/resolve-extends': 21.2.2 '@commitlint/types': 21.2.0 cosmiconfig: 9.0.2(typescript@6.0.3) - cosmiconfig-typescript-loader: 6.3.0(@types/node@26.4.0)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@26.4.1)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3) es-toolkit: 1.52.0 is-plain-obj: 4.1.0 picocolors: 1.1.1 @@ -3339,12 +3572,12 @@ snapshots: conventional-changelog-angular: 9.4.0 conventional-commits-parser: 7.1.2 - '@commitlint/read@21.2.1(conventional-commits-parser@7.1.2)': + '@commitlint/read@21.2.1(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)': dependencies: '@commitlint/top-level': 21.2.0 '@commitlint/types': 21.2.0 - '@conventional-changelog/git-client': 3.1.2(conventional-commits-parser@7.1.2) - tinyexec: 1.3.0 + '@conventional-changelog/git-client': 3.1.2(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2) + tinyexec: 1.3.1 transitivePeerDependencies: - conventional-commits-filter - conventional-commits-parser @@ -3375,12 +3608,13 @@ snapshots: conventional-commits-parser: 7.1.2 picocolors: 1.1.1 - '@conventional-changelog/git-client@3.1.2(conventional-commits-parser@7.1.2)': + '@conventional-changelog/git-client@3.1.2(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)': dependencies: '@simple-libs/child-process-utils': 2.0.0 '@simple-libs/stream-utils': 2.0.0 semver: 7.8.5 optionalDependencies: + conventional-commits-filter: 6.0.1 conventional-commits-parser: 7.1.2 '@conventional-changelog/template@1.4.0': {} @@ -3397,81 +3631,159 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1(jiti@2.6.1))': dependencies: eslint: 10.9.1(jiti@2.6.1) @@ -3506,17 +3818,34 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exadev/eslint-config@2.10.2(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3)': + '@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)': dependencies: '@eslint/js': 10.0.1(eslint@10.9.1(jiti@2.6.1)) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + '@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) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 - typescript-eslint: 8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + typescript-eslint: 8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) transitivePeerDependencies: - supports-color + '@exadev/semantic-release-workspace@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)': + dependencies: + '@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)) + commander: 15.0.0 + cosmiconfig: 9.0.2(typescript@6.0.3) + semantic-release: 25.0.9(typescript@6.0.3) + tinyglobby: 0.2.17 + validate-npm-package-name: 8.0.0 + yaml: 2.9.0 + transitivePeerDependencies: + - typescript + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3729,7 +4058,7 @@ snapshots: dependencies: '@octokit/openapi-types': 29.0.1 - '@oxc-project/types@0.147.0': {} + '@oxc-project/types@0.148.0': {} '@pkgr/core@0.3.6': {} @@ -3759,55 +4088,55 @@ snapshots: '@publint/pack@0.1.7': dependencies: - tinyexec: 1.3.0 + tinyexec: 1.3.1 '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 - '@rolldown/binding-android-arm-eabi@1.2.6': + '@rolldown/binding-android-arm-eabi@1.2.7': optional: true - '@rolldown/binding-android-arm64@1.2.6': + '@rolldown/binding-android-arm64@1.2.7': optional: true - '@rolldown/binding-darwin-arm64@1.2.6': + '@rolldown/binding-darwin-arm64@1.2.7': optional: true - '@rolldown/binding-darwin-x64@1.2.6': + '@rolldown/binding-darwin-x64@1.2.7': optional: true - '@rolldown/binding-freebsd-x64@1.2.6': + '@rolldown/binding-freebsd-x64@1.2.7': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.6': + '@rolldown/binding-linux-arm64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.6': + '@rolldown/binding-linux-arm64-musl@1.2.7': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.6': + '@rolldown/binding-linux-ppc64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.6': + '@rolldown/binding-linux-s390x-gnu@1.2.7': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.6': + '@rolldown/binding-linux-x64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-x64-musl@1.2.6': + '@rolldown/binding-linux-x64-musl@1.2.7': optional: true - '@rolldown/binding-openharmony-arm64@1.2.6': + '@rolldown/binding-openharmony-arm64@1.2.7': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.6': + '@rolldown/binding-win32-arm64-msvc@1.2.7': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.6': + '@rolldown/binding-win32-x64-msvc@1.2.7': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -3897,7 +4226,7 @@ snapshots: '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.9(typescript@6.0.3))': dependencies: conventional-changelog-angular: 8.3.1 - conventional-changelog-writer: 8.4.0 + conventional-changelog-writer: 9.2.1 conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.4.0 debug: 4.4.3 @@ -3957,63 +4286,63 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@26.4.0': + '@types/node@26.4.1': dependencies: undici-types: 8.3.0 '@types/normalize-package-data@2.4.4': {} - '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.68.0 - '@typescript-eslint/type-utils': 8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.68.0 + '@typescript-eslint/parser': 8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/type-utils': 8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.69.0 eslint: 10.9.1(jiti@2.6.1) - ignore: 7.0.7 + ignore: 7.0.8 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3)': + '@typescript-eslint/parser@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.68.0 - '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.68.0 + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.69.0 debug: 4.4.3 eslint: 10.9.1(jiti@2.6.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.68.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.69.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) - '@typescript-eslint/types': 8.68.0 + '@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/scope-manager@8.68.0': + '@typescript-eslint/scope-manager@8.69.0': dependencies: - '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/visitor-keys': 8.68.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 - '@typescript-eslint/tsconfig-utils@8.68.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.69.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) debug: 4.4.3 eslint: 10.9.1(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@6.0.3) @@ -4021,14 +4350,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.68.0': {} + '@typescript-eslint/types@8.69.0': {} - '@typescript-eslint/typescript-estree@8.68.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.69.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.68.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) - '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/visitor-keys': 8.68.0 + '@typescript-eslint/project-service': 8.69.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 @@ -4038,20 +4367,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3)': + '@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)) - '@typescript-eslint/scope-manager': 8.68.0 - '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) eslint: 10.9.1(jiti@2.6.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.68.0': + '@typescript-eslint/visitor-keys@8.69.0': dependencies: - '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': @@ -4066,7 +4395,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -4077,13 +4406,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -4206,7 +4535,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.6 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4234,7 +4563,7 @@ snapshots: argparse@2.0.1: {} - argue-cli@3.1.0: {} + argue-cli@3.2.0: {} argv-formatter@1.0.0: {} @@ -4336,6 +4665,8 @@ snapshots: commander@10.0.1: {} + commander@15.0.0: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -4368,8 +4699,18 @@ snapshots: meow: 13.2.0 semver: 7.8.5 + conventional-changelog-writer@9.2.1: + dependencies: + '@conventional-changelog/template': 1.4.0 + '@simple-libs/stream-utils': 2.0.0 + argue-cli: 3.2.0 + conventional-commits-filter: 6.0.1 + semver: 7.8.5 + conventional-commits-filter@5.0.0: {} + conventional-commits-filter@6.0.1: {} + conventional-commits-parser@6.4.0: dependencies: '@simple-libs/stream-utils': 1.2.0 @@ -4378,7 +4719,7 @@ snapshots: conventional-commits-parser@7.1.2: dependencies: '@simple-libs/stream-utils': 2.0.0 - argue-cli: 3.1.0 + argue-cli: 3.2.0 convert-hrtime@5.0.0: {} @@ -4388,9 +4729,9 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@6.3.0(@types/node@26.4.0)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3): + cosmiconfig-typescript-loader@6.3.0(@types/node@26.4.1)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3): dependencies: - '@types/node': 26.4.0 + '@types/node': 26.4.1 cosmiconfig: 9.0.2(typescript@6.0.3) jiti: 2.6.1 typescript: 6.0.3 @@ -4496,6 +4837,35 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-string-regexp@1.0.5: {} @@ -4639,7 +5009,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.6: {} + fast-uri@3.1.7: {} fdir@6.5.0(picomatch@4.0.7): optionalDependencies: @@ -4731,7 +5101,7 @@ snapshots: dependencies: ini: 6.0.0 - globals@17.11.0: {} + globals@17.12.0: {} graceful-fs@4.2.10: {} @@ -4792,7 +5162,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.7: {} + ignore@7.0.8: {} import-fresh@3.3.1: dependencies: @@ -4967,7 +5337,7 @@ snapshots: dependencies: picomatch: 4.0.7 string-argv: 0.3.2 - tinyexec: 1.3.0 + tinyexec: 1.3.1 optionalDependencies: yaml: 2.9.0 @@ -5242,7 +5612,7 @@ snapshots: find-up: 2.1.0 load-json-file: 4.0.0 - postcss@8.5.26: + postcss@8.5.27: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 @@ -5336,12 +5706,12 @@ snapshots: resolve-pkg-maps@1.0.0: {} - rolldown-plugin-dts@0.27.14(rolldown@1.2.6)(typescript@6.0.3): + rolldown-plugin-dts@0.27.14(rolldown@1.2.7)(typescript@6.0.3): dependencies: dts-resolver: 3.0.0 get-tsconfig: 5.0.0-beta.5 obug: 2.1.4 - rolldown: 1.2.6 + rolldown: 1.2.7 yuku-ast: 0.8.7 yuku-codegen: 0.8.7 yuku-parser: 0.8.7 @@ -5350,26 +5720,26 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown@1.2.6: + rolldown@1.2.7: dependencies: - '@oxc-project/types': 0.147.0 + '@oxc-project/types': 0.148.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.6 - '@rolldown/binding-android-arm64': 1.2.6 - '@rolldown/binding-darwin-arm64': 1.2.6 - '@rolldown/binding-darwin-x64': 1.2.6 - '@rolldown/binding-freebsd-x64': 1.2.6 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 - '@rolldown/binding-linux-arm64-gnu': 1.2.6 - '@rolldown/binding-linux-arm64-musl': 1.2.6 - '@rolldown/binding-linux-ppc64-gnu': 1.2.6 - '@rolldown/binding-linux-s390x-gnu': 1.2.6 - '@rolldown/binding-linux-x64-gnu': 1.2.6 - '@rolldown/binding-linux-x64-musl': 1.2.6 - '@rolldown/binding-openharmony-arm64': 1.2.6 - '@rolldown/binding-win32-arm64-msvc': 1.2.6 - '@rolldown/binding-win32-x64-msvc': 1.2.6 + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 sade@1.8.1: dependencies: @@ -5595,7 +5965,7 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.3.0: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: @@ -5626,9 +5996,9 @@ snapshots: import-without-cache: 0.4.0 obug: 2.1.4 picomatch: 4.0.7 - rolldown: 1.2.6 - rolldown-plugin-dts: 0.27.14(rolldown@1.2.6)(typescript@6.0.3) - tinyexec: 1.3.0 + rolldown: 1.2.7 + rolldown-plugin-dts: 0.27.14(rolldown@1.2.7)(typescript@6.0.3) + tinyexec: 1.3.1 tinyglobby: 0.2.17 tree-kill: 1.2.2 unconfig-core: 7.5.0 @@ -5649,7 +6019,7 @@ snapshots: tsx@4.23.13: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -5678,12 +6048,12 @@ snapshots: dependencies: tagged-tag: 1.0.0 - typescript-eslint@8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3): + typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/parser': 8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) + '@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) typescript: 6.0.3 transitivePeerDependencies: @@ -5742,27 +6112,29 @@ snapshots: validate-npm-package-name@5.0.1: {} + validate-npm-package-name@8.0.0: {} + verkit@0.3.2: {} - vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0): + vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.26 - rolldown: 1.2.6 + postcss: 8.5.27 + rolldown: 1.2.7 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.4.0 - esbuild: 0.28.1 + '@types/node': 26.4.1 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.6.1 tsx: 4.23.13 yaml: 2.9.0 - vitest@4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -5776,13 +6148,13 @@ snapshots: picomatch: 4.0.7 std-env: 4.2.0 tinybench: 2.9.0 - tinyexec: 1.3.0 + tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.23.13)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.4.0 + '@types/node': 26.4.1 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) transitivePeerDependencies: - msw @@ -5846,8 +6218,7 @@ snapshots: y18n@5.0.8: {} - yaml@2.9.0: - optional: true + yaml@2.9.0: {} yargs-parser@20.2.9: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6f207b2..4152b63 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,8 +1,24 @@ +packages: + - "packages/*" + +# pnpm 11 defaults this to false, which means a dependency on a sibling package resolves from the npm registry even when the workspace holds a version satisfying the range. Setting it true links a sibling whenever the declared range is satisfied by the workspace version, and falls back to the registry when it is not. There is one package here today, so nothing exercises it yet -- it is set now so the first sibling added is linked rather than silently downloaded. +linkWorkspacePackages: true + +# workerd (the Cloudflare Workers runtime @cloudflare/vitest-pool-workers drives the workers test suite through) and esbuild (its bundler) both ship native binaries installed via a postinstall script -- pnpm 11 ignores those by default, so allow them explicitly. allowBuilds: esbuild: true workerd: true + +# 60 minutes. pnpm reads workspace-level settings only from the workspace root, so this file is the one place they can take effect at all. minimumReleaseAge: 60 -# @exadev/eslint-config is an org-owned devDependency, not a third-party supply-chain -# risk -- exempted so a just-published fix (verified and published by us) doesn't have to wait out the cooldown meant for external packages. + +# Bare package names (no @version), per pnpm's own documented syntax (https://pnpm.io/settings#minimumreleaseageexclude). Both are org-owned packages published only by their own semantic-release CI with npm OIDC trusted publishing, so a version-agnostic exemption is a deliberate, low-risk choice rather than an entry needing an edit on every release. minimumReleaseAgeExclude: - "@exadev/eslint-config" + - "@exadev/semantic-release-workspace" + +overrides: + # release-workspace.config.ts's generateNotes step uses the conventionalcommits preset, whose templates call a helper that only conventional-changelog-writer@9 or newer provides. @semantic-release/release-notes-generator depends on conventional-changelog-writer@^8, so without this the preset loads and then throws from inside handlebars at render time -- the preset itself detects the too-old writer and registers a helper whose only job is to raise "requires conventional-changelog-writer@9 or newer". Scoped to release-notes-generator rather than global because it is the only consumer that renders with the writer at all: @semantic-release/commit-analyzer declares the same dependency but never imports it, using only the preset's whatBump. + # + # 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 diff --git a/release-workspace.config.ts b/release-workspace.config.ts new file mode 100644 index 0000000..99485d4 --- /dev/null +++ b/release-workspace.config.ts @@ -0,0 +1,66 @@ +import type { ReleaseWorkspaceOptions } from "@exadev/semantic-release-workspace"; + +type ReleaseLevel = "major" | "minor" | "patch" | false; + +interface CommitType { + readonly type: string; + readonly release: ReleaseLevel; + readonly section: string; +} + +/** + * Single source of truth for the conventional-commit types this repository uses. commitlint's allowed type-enum (commitlint.config.ts imports this), the commit analyser's releaseRules, and the changelog's section headings all derive from it, so a type cannot trigger a release without also being accepted by commit-msg validation, or appear in a changelog under no heading. + * + * Defined here rather than in a shared commit-types.ts: this file is loaded through cosmiconfig, which transpiles only the file it loads, so a sibling .ts module would not resolve from it. commitlint's jiti loader has no such limit, so it imports commitTypes from here. + */ +export const commitTypes: readonly CommitType[] = [ + { type: "feat", release: "minor", section: "Features" }, + { type: "fix", release: "patch", section: "Bug Fixes" }, + { type: "perf", release: "patch", section: "Performance Improvements" }, + { type: "revert", release: "patch", section: "Reverts" }, + { type: "refactor", release: "patch", section: "Code Refactoring" }, + { type: "docs", release: "patch", section: "Documentation" }, + { type: "style", release: "patch", section: "Styles" }, + { type: "test", release: "patch", section: "Tests" }, + { type: "build", release: "patch", section: "Build System" }, + { type: "ci", release: "patch", section: "Continuous Integration" }, + { type: "chore", release: "patch", section: "Miscellaneous Chores" }, +]; + +/** + * 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. + */ +const config: Pick< + ReleaseWorkspaceOptions, + "branches" | "commitStrategy" | "plugins" | "analyzeCommits" | "generateNotes" +> = { + branches: ["main"], + commitStrategy: "single", + plugins: [ + "@semantic-release/changelog", + ["@semantic-release/npm", { npmPublish: true }], + "@semantic-release/github", + ], + analyzeCommits: { + preset: "conventionalcommits", + releaseRules: [ + { breaking: true, release: "major" }, + ...commitTypes.map(({ type, release }) => ({ type, release })), + ], + }, + generateNotes: { + // The conventionalcommits preset, not angular: it is the one that groups the changelog by commit type, and the presetConfig below names every type's section. It renders only against conventional-changelog-writer 9 or newer, which @semantic-release/release-notes-generator does not itself depend on -- see the pnpm override that supplies it. + preset: "conventionalcommits", + presetConfig: { + types: commitTypes.map(({ type, section }) => ({ type, section })), + }, + }, +}; + +export default config; diff --git a/release.config.ts b/release.config.ts deleted file mode 100644 index 7665058..0000000 --- a/release.config.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Options } from "semantic-release"; - -type ReleaseLevel = "major" | "minor" | "patch" | false; - -interface CommitType { - readonly type: string; - readonly release: ReleaseLevel; -} - -/** - * Single source of truth for the conventional-commit types this project uses. commitlint's allowed type-enum (commitlint.config.ts imports this) and commit-analyzer's releaseRules below both derive from it, so a type can't trigger a release without also being accepted by commit-msg validation, or the reverse. - * - * Defined here rather than in a shared commit-types.ts: semantic-release loads this file via cosmiconfig, which transpiles only this one file to ESM, so a sibling .ts module would not resolve. commitlint's jiti loader has no such limit, so it imports commitTypes from here. - */ -export const commitTypes: readonly CommitType[] = [ - { type: "feat", release: "minor" }, - { type: "fix", release: "patch" }, - { type: "perf", release: "patch" }, - { type: "revert", release: "patch" }, - { type: "refactor", release: "patch" }, - { type: "docs", release: "patch" }, - { type: "style", release: "patch" }, - { type: "test", release: "patch" }, - { type: "build", release: "patch" }, - { type: "ci", release: "patch" }, - { type: "chore", release: "patch" }, -]; - -/** - * Runs on `main`. Analyses commits since the last tag, bumps the version, publishes to npmjs.org (trusted OIDC publishing, no stored token -- see .github/workflows/ci.yml), creates a versioned tag and GitHub Release with generated notes, and commits CHANGELOG.md + package.json back to main. The release commit's [skip ci] message avoids a redundant CI run. - */ -const config: Options = { - branches: ["main"], - plugins: [ - [ - "@semantic-release/commit-analyzer", - { - preset: "conventionalcommits", - releaseRules: [ - { breaking: true, release: "major" }, - ...commitTypes.map((t) => ({ type: t.type, release: t.release })), - ], - }, - ], - [ - "@semantic-release/release-notes-generator", - { - // Deliberately angular, not conventionalcommits: conventional-changelog-writer's bundled commit partial doesn't match the conventionalcommits preset's function-based partial signature, producing a changelog with a version header and nothing under it. commitTypes still drives commit-analyzer's releaseRules and commitlint's type-enum regardless of which changelog preset is used. - preset: "angular", - }, - ], - "@semantic-release/changelog", - ["@semantic-release/npm", { npmPublish: true }], - "@semantic-release/github", - [ - "@semantic-release/git", - { - assets: ["CHANGELOG.md", "package.json"], - message: "chore(release): ${nextRelease.version} [skip ci]", - }, - ], - ], -}; - -export default config; diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..bfc254e --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,19 @@ +{ + // The compiler options every package in this workspace shares, in one place. Each package's own tsconfig.json extends this and keeps only what genuinely differs for it -- its `lib` and `types` (the isomorphism gate), its own `include`/`exclude`. Its tsconfig.node.json extends that package's tsconfig.json in turn, so it inherits this transitively. + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + // Every indexed read is typed as possibly-undefined, so the code narrows properly instead of trusting a bound. + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/tsconfig.json b/tsconfig.json index 9200e51..75906d7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,10 @@ { + // The workspace root's own tooling configs, which are Node programs rather than library source: ESLint's and Prettier's configs, commitlint's, lint-staged's, and the release orchestrator's. packages/trilean has its own pair of tsconfigs and is not part of this program. + "extends": "./tsconfig.base.json", "compilerOptions": { - "target": "ES2024", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "verbatimModuleSyntax": true, - "isolatedModules": true, - "esModuleInterop": true, - "noEmit": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "lib": ["ES2024", "WebWorker"], - "types": [], - "resolveJsonModule": true + "lib": ["ES2024"], + "types": ["node"] }, - "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/test-support/**/*.ts"] + // A glob rather than a hand-listed set: a config file absent from every program is not a silent no-op, type-aware ESLint fails on it outright with "not found in any of the provided project(s)", so adding one must not also require an edit here. + "include": ["*.config.ts"] } diff --git a/turbo.json b/turbo.json index 9d357d6..8a6fb4e 100644 --- a/turbo.json +++ b/turbo.json @@ -1,44 +1,118 @@ { "$schema": "https://turborepo.com/schema.json", - "globalDependencies": ["pnpm-lock.yaml"], + "globalDependencies": [ + "pnpm-lock.yaml", + ".tool-versions", + "prettier.config.ts", + "tsconfig.base.json" + ], + + // Every task name is the underscore-prefixed one the package already used for its real command (`_build` runs tsdown; the package's own `build` script is `turbo run _build`). Running the public names from here instead would make turbo invoke those wrapper scripts, which invoke turbo again -- the recursive-call case Turborepo's own docs warn against. The wrapper scripts stay in place and stay usable from inside a single package, but the root pipeline reaches straight past them to the leaf commands. + // + // Every task declares `^_build`, its dependencies' builds: a workspace sibling is a symlink whose dist/ exists only once its own build has run, and tsc, type-aware eslint, and vitest all resolve imports through it. With one package that edge is inert today; it is declared so the first sibling added is ordered correctly rather than racing. + // + // `_typecheck`, `_lint` and `_test:smoke` additionally depend on the package's OWN `_build`, which is not the same thing and is not optional here: test/smoke.test.ts and scripts/generate-json-schema.ts both import from ../dist/ by relative path, so tsc, eslint's typed rules, and the smoke suite itself all need this package's dist/ present before they run. "tasks": { + "_build": { + "dependsOn": ["^_build"], + // scripts/** covers the JSON-schema generation step that runs as part of the build; package.json is an input because that generation reads it. + "inputs": ["src/**", "tsdown.config.ts", "scripts/**", "package.json"], + // schemas/** is the build's second output, alongside tsdown's dist/. + "outputs": ["dist/**", "schemas/**"] + }, "_typecheck": { - "dependsOn": ["_build"], + "dependsOn": ["^_build", "_build"], "inputs": ["$TURBO_DEFAULT$", "tsconfig.json", "tsconfig.node.json"], "outputs": [] }, - "_lint": { + // attw --pack inspects the package's own dist/ against its own package.json exports map, so it depends on this package's own `_build` rather than on `^_build` alone. It was previously a bare `pnpm exec attw --pack` step in ci.yml's Typecheck job, run after `pnpm typecheck` purely to reuse the dist/ that task had left behind; as a task it is cached and ordered like everything else. + "_typecheck:attw": { "dependsOn": ["_build"], + "inputs": ["package.json"], + "outputs": [] + }, + "_lint": { + "dependsOn": ["^_build", "_build"], "inputs": ["$TURBO_DEFAULT$", "eslint.config.ts"], "outputs": [".eslintcache"] }, "_test": { + "dependsOn": ["^_build"], "inputs": ["src/**", "vitest.config.ts", "package.json"], "outputs": [] }, "_test:coverage": { + "dependsOn": ["^_build"], "inputs": ["src/**", "vitest.config.ts", "package.json"], "outputs": ["coverage/**"] }, "_test:integration": { - "inputs": ["src/**", "package.json", "vitest.config.ts", "test/integration/**"], + "dependsOn": ["^_build"], + "inputs": [ + "src/**", + "package.json", + "vitest.config.ts", + "test/integration/**" + ], "outputs": [] }, "_test:smoke": { - "dependsOn": ["_build"], - "inputs": ["src/**", "tsdown.config.ts", "package.json", "vitest.config.ts", "test/smoke.test.ts"], + "dependsOn": ["^_build", "_build"], + "inputs": [ + "src/**", + "tsdown.config.ts", + "package.json", + "vitest.config.ts", + "test/smoke.test.ts" + ], + // Deliberately empty rather than dist/**: dist/ is `_build`'s output, and two tasks claiming the same output directory means a cache replay of one can overwrite the other's. The smoke run itself produces nothing worth caching. "outputs": [] }, "_test:workers": { - "inputs": ["src/**", "package.json", "vitest.config.ts", "wrangler.jsonc", "test/workers/**"], + "dependsOn": ["^_build"], + "inputs": [ + "src/**", + "package.json", + "vitest.config.ts", + "wrangler.jsonc", + "test/workers/**" + ], "outputs": [] }, - "_build": { - "inputs": ["src/**", "tsdown.config.ts", "package.json", "scripts/**"], - "outputs": ["dist/**", "schemas/**"] - }, "_prepush": { - "dependsOn": ["_lint", "_typecheck", "_test", "_test:integration", "_test:smoke", "_test:workers"], + "dependsOn": [ + "_lint", + "_typecheck", + "_typecheck:attw", + "_test", + "_test:integration", + "_test:smoke", + "_test:workers" + ], + "outputs": [] + }, + + // The workspace root's own tooling files (eslint.config.ts, commitlint.config.ts, lint-staged.config.ts, release-workspace.config.ts) are held to the same lint and typecheck gates as package code. Registered as root tasks so `turbo run _lint` and `turbo run _typecheck` cover them without a separate command. + "//#_lint": { + "inputs": [ + "eslint.config.ts", + "prettier.config.ts", + "commitlint.config.ts", + "lint-staged.config.ts", + "release-workspace.config.ts", + "tsconfig.json" + ], + "outputs": [".eslintcache"] + }, + "//#_typecheck": { + "inputs": [ + "eslint.config.ts", + "prettier.config.ts", + "commitlint.config.ts", + "lint-staged.config.ts", + "release-workspace.config.ts", + "tsconfig.json" + ], "outputs": [] } }