From 71caa633888fd460f68806b03c49b4a79164377e Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 09:56:36 +0200 Subject: [PATCH 01/13] feat: first implementation --- .github/workflows/ci.yml | 26 + .gitignore | 5 + AGENTS.md | 50 + LICENSE | 21 + README.md | 247 +++++ package.json | 63 ++ pnpm-lock.yaml | 2272 ++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 3 + src/codec.ts | 23 + src/errors.ts | 18 + src/evaluate.ts | 126 +++ src/fields.ts | 61 + src/filter.ts | 31 + src/index.ts | 12 + src/rows.ts | 97 ++ src/types.ts | 91 ++ src/validation.ts | 370 +++++++ tests/config.test.ts | 60 + tests/evaluate.test.ts | 384 +++++++ tests/laws.test.ts | 465 ++++++++ tests/parse.test.ts | 208 ++++ tests/readme.test.ts | 102 ++ tests/rows.test.ts | 208 ++++ tests/types.ts | 128 +++ tsconfig.json | 24 + tsconfig.test.json | 9 + vite.config.ts | 18 + 27 files changed, 5122 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 src/codec.ts create mode 100644 src/errors.ts create mode 100644 src/evaluate.ts create mode 100644 src/fields.ts create mode 100644 src/filter.ts create mode 100644 src/index.ts create mode 100644 src/rows.ts create mode 100644 src/types.ts create mode 100644 src/validation.ts create mode 100644 tests/config.test.ts create mode 100644 tests/evaluate.test.ts create mode 100644 tests/laws.test.ts create mode 100644 tests/parse.test.ts create mode 100644 tests/readme.test.ts create mode 100644 tests/rows.test.ts create mode 100644 tests/types.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.test.json create mode 100644 vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c4a1a8a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + ci: + strategy: + matrix: + node-version: [22, 24, 26] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: voidzero-dev/setup-vp@v1 + with: + node-version: ${{ matrix.node-version }} + cache: true + + - run: vp install --frozen-lockfile + - run: vp run check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0dd97f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +.pnpm-store +dist +*.log +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1e2fdb5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# @b2m9/anyall — agent guide + +`@b2m9/anyall` evaluates a deliberately closed, serializable filter language: +OR across `any` groups and AND across the predicates in each group's `all`. +Read the public contract in `README.md` and the API JSDoc in `src/` before +changing behavior. This file records the constraints behind that contract. + +## Toolchain + +ESM-only, Node ≥22, zero runtime dependencies. Use Vite+ (`vp`) through the +pnpm-managed workspace: `vp check` (format/lint/types), `vp test run`, and +`vp pack` (build). Run `vp run check` before handing off; it also validates the +published package and types. +Import test APIs from `vite-plus/test` so the suite uses Vite+'s bundled, +version-locked runner. + +## Working method + +- Work in one observable slice at a time: write the smallest failing test, + confirm it fails for the intended reason, then implement only enough to pass. +- Keep tests table-driven where the contract is a semantics matrix. Use + property tests for laws such as round trips, purity, and reference identity. +- Treat `code` and `path` in issues as stable API. Message wording is not. + +## Constraints to preserve + +- **The language stays closed.** Six operators plus the orthogonal `not` flag; + custom behavior belongs in declared field normalizers, never in expressions. +- **DNF stays fixed-depth.** Do not add recursive groups or left-to-right + reduction; the two-level shape is the protection against grouping ambiguity. +- **Fields are declared capabilities.** Expressions select registry names; the + package does not parse paths or inspect record structure itself. +- **No-filter is explicit.** `null` matches everything. Empty expressions and + empty groups are invalid rather than acquiring vacuous truth values. +- **Validation has one source of truth.** `parse`, `compile`, and row conversion + must not drift into subtly different definitions of a valid predicate. +- **Segment before discarding rows.** An invalid row may be removed in + interactive mode, but it must never alter the groups around it. +- **Stored data stays data.** Parsing rebuilds fresh canonical plain objects; + evaluation-time normalization never rewrites saved needles. +- **Preserve reference no-ops.** `filter` returns its input array when every + record survives, and returns a fresh array only when something is excluded. +- **Remain pure and synchronous.** Inputs are never mutated. Accessor and + normalizer failures propagate so programmer bugs are not disguised as empty + fields. + +## Comments + +Comments explain why an ordering, guard, or no-op is load-bearing. Do not +paraphrase self-evident code. Prefer terse, complete sentences in present tense. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ede8f8a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bob Massarczyk + +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/README.md b/README.md new file mode 100644 index 0000000..acf7fa0 --- /dev/null +++ b/README.md @@ -0,0 +1,247 @@ +# @b2m9/anyall + +Four filter rows can hide a boolean bug: + +```text +A account contains "acme" +AND B status is not one of ["closed", "spam"] +OR C name starts with "test" +AND D plan equals "pro" +``` + +A left-to-right reducer computes `(((A AND B) OR C) AND D)`. It wrongly rejects +an open Acme customer on the basic plan. The rows mean `(A AND B) OR (C AND D)`: + +| Record | Left to right | `anyall` | +| ------------------------- | ------------- | -------- | +| Acme North, open, basic | no | **yes** | +| Test Labs, closed, pro | yes | **yes** | +| Test Garage, closed, free | no | no | + +`anyall` evaluates serializable text filters in that fixed shape: any group may +match, and all predicates in a matching group must match. It is headless, +ESM-only, has zero runtime dependencies, and does not inspect record structure +beyond the accessors you declare. + +## Install + +```sh +npm install @b2m9/anyall +``` + +Node.js 22 or newer is required. + +## Usage + +Declare the fields an expression is allowed to address: + +```ts +import { createFilter, type FilterRow } from "@b2m9/anyall"; + +interface Customer { + account?: { name: string }; + status?: string; + name: string; + plan: "free" | "basic" | "pro"; +} + +const customers = createFilter()({ + account: (customer) => customer.account?.name, + status: (customer) => customer.status, + name: (customer) => customer.name, + plan: (customer) => customer.plan, +}); +``` + +The double call is deliberate. TypeScript cannot partially infer type +arguments: the first call fixes `Customer`, while the second infers the literal +field names from the registry. + +Now fold the flat rows from a filter UI into an expression: + +```ts +const rows = [ + { field: "account", operator: "contains", value: "acme" }, + { + join: "and", + field: "status", + operator: "one-of", + value: ["closed", "spam"], + not: true, + }, + { join: "or", field: "name", operator: "starts-with", value: "test" }, + { join: "and", field: "plan", operator: "equals", value: "pro" }, +] satisfies readonly FilterRow[]; + +const built = customers.fromRows(rows); +if (!built.ok) throw new Error("The filter UI produced incomplete rows"); + +const records: Customer[] = [ + { account: { name: "Acme North" }, status: "open", name: "Alice", plan: "basic" }, + { account: { name: "Other" }, status: "closed", name: "Test Labs", plan: "pro" }, + { account: { name: "Other" }, status: "closed", name: "Test Garage", plan: "free" }, +]; + +const matches = customers.filter(records, built.expression); +// [records[0], records[1]] +``` + +The expression is versioned plain data: + +```text +{ + v: 1, + any: [ + { all: [A, B] }, + { all: [C, D] }, + ], +} +``` + +Evaluation is exactly `any.some(group => group.all.every(predicate))`. +There is no recursive tree and no ambiguous reduction order. + +## Saved views + +Expressions need no serializer. Store them with `JSON.stringify`, then bring +them back through `parse` so renamed fields or newer wire versions become +structured issues instead of runtime surprises: + +```ts +localStorage.setItem("view:customers", JSON.stringify(built.expression)); + +const raw = localStorage.getItem("view:customers"); +if (raw === null) { + renderDefaultView(); // This key has never been saved. +} else { + const restored = customers.parse(raw); + if (!restored.ok) { + showStaleView(restored.issues); + } else { + render(customers.filter(records, restored.expression)); + } +} +``` + +A missing storage key and a stored no-filter state are different. Store the +latter as `JSON.stringify(null)`, which yields the JSON text `"null"`; +`customers.parse("null")` succeeds with `expression: null`. + +`parse` accepts JSON text or an already-parsed inert value. On success it +returns fresh canonical plain objects while preserving needle text. Invalid +JSON and ordinary invalid data become issues, provided the declared normalizers +honor their pure-and-total contract; reflective exotica are outside that +guarantee. + +## API + +```text +const filter = createFilter()({ + fieldName: (record) => record.value, + normalizedField: { + get: (record) => record.otherValue, + normalize: (text) => text.trim().toLowerCase(), + }, +}); + +filter.fromRows(rows, { incomplete: "reject" }); +filter.compile(expression); // (record) => boolean +filter.filter(records, expression); // readonly RecordType[] +filter.parse(jsonOrValue); +``` + +`fromRows` and `parse` return one result shape: + +```ts +type ExpressionResult = + | { ok: true; expression: Expression | null } + | { ok: false; issues: readonly Issue[] }; +``` + +`null` is the explicit no-filter value. `compile(null)` returns a predicate +that always passes, and `filter(records, null)` returns `records` itself. + +Malformed configuration throws `AnyallConfigError`. Passing an asserted but +invalid expression to `compile` or `filter` throws `AnyallExpressionError` with +an `issues` property. Both classes are exported for `instanceof` checks. +Issue `code` and `path` are stable API; message wording is not. + +All public types are exported: `Expression`, `ExpressionResult`, `FieldDef`, +`Filter`, `FilterRow`, `Issue`, `Operator`, and `Predicate`. + +## Operators and empty values + +Values resolve to a list of matchable texts before an operator runs: + +| Accessor result | Matchable texts | +| -------------------------------- | --------------------------------------- | +| string | normalized string, unless it is empty | +| number, boolean, bigint | `String(value)`, then normalized | +| `null`, `undefined` | none | +| array | scalar rules for each top-level element | +| object, `Date`, function, symbol | none | +| nested array element | none | + +The default normalizer trims and lowercases. A field is empty when resolution +yields no matchable text, including `""`, whitespace, `[]`, and `["", null]`. + +| Operator | Base result | Empty field | +| ------------- | -------------------------------------------- | ----------- | +| `equals` | some text equals the needle | false | +| `contains` | some text contains the needle | false | +| `starts-with` | some text starts with the needle | false | +| `ends-with` | some text ends with the needle | false | +| `one-of` | some text equals a member of the needle list | false | +| `empty` | there are no matchable texts | true | + +`not: true` negates the base result. Consequently, a negated value operator is +true on an empty field, while `{ operator: "empty", not: true }` means the +field has at least one matchable text. Empty needles are invalid. + +Normalization never rewrites stored expressions. `parse` and `fromRows` invoke +it only to reject empty needles, `compile` captures normalized needles, and the +compiled predicate normalizes record texts. Arrays use some-semantics and are +resolved only one level deep. + +## Rows and grouping + +`fromRows` segments the complete row sequence at each `join: "or"`, then +validates rows within those groups. This order is load-bearing: discarding an +invalid row must not pull later predicates into an earlier group. + +Incomplete rows reject the whole conversion by default. For a live filter +drawer, `{ incomplete: "discard" }` removes invalid rows after grouping, drops +groups left empty, and returns `expression: null` if nothing survives. Row +values stay strict: scalar operators require a string, and `one-of` requires a +string array. Record values are the only values coerced to text. + +Expressions require at least one group and every group requires at least one +predicate; empty containers are rejected rather than given vacuous meanings. + +## Guardrails + +- Field names and their meaning are a saved-view contract. When renaming a + field, keep the old name as an alias; changing an accessor or normalizer can + silently change what existing views match. +- Accessors and normalizers must be pure and total. Their errors propagate so + programmer bugs are not disguised as empty fields. +- V1 is text-only. There are no numeric or date comparisons, and numbers are + matched as strings: `contains "4"` matches `42`. +- Lowercasing is deterministic, not locale-aware collation. Supply a custom + normalizer when your domain needs another text equivalence. +- Objects never coerce to `"[object Object]"`. Project a label or identifier in + the accessor. +- Expressions cannot introduce code or access undeclared fields, but that + bounds capability, not work. Cap bytes, groups, predicates, tokens, and + needle lengths before parsing untrusted input. +- `filter` preserves order and returns its input array by reference exactly + when no record is excluded; otherwise it returns a fresh array. + +There is deliberately no regex, arbitrary nesting, user-defined operator, +field-path grammar, rendering, table integration, URL state, sorting, +pagination, server adapter, query-language compilation, fuzzy search, numeric +comparison, or date comparison. + +## License + +MIT © 2026 Bob Massarczyk diff --git a/package.json b/package.json new file mode 100644 index 0000000..8b2b569 --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "@b2m9/anyall", + "version": "0.1.0", + "description": "Evaluate serializable any-of-all filter expressions over declared record fields.", + "keywords": [ + "boolean-expression", + "disjunctive-normal-form", + "dnf", + "filter", + "headless", + "predicate", + "saved-views", + "typescript" + ], + "homepage": "https://github.com/b2m9/anyall#readme", + "bugs": { + "url": "https://github.com/b2m9/anyall/issues" + }, + "license": "MIT", + "author": "Bob Massarczyk ", + "repository": { + "type": "git", + "url": "git+https://github.com/b2m9/anyall.git" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "vp pack", + "dev": "vp pack --watch", + "test": "vp test run", + "test:watch": "vp test", + "typecheck:tests": "tsgo --noEmit -p tsconfig.test.json", + "check": "vp check && vp run typecheck:tests && vp test run && vp run check:package", + "check:package": "vp pack && publint && attw --pack --profile esm-only .", + "prepack": "vp run build" + }, + "devDependencies": { + "@arethetypeswrong/cli": "0.18.4", + "@types/node": "25.6.2", + "@typescript/native-preview": "7.0.0-dev.20260509.2", + "fast-check": "4.8.0", + "publint": "0.3.21", + "typescript": "6.0.3", + "vite-plus": "0.2.4" + }, + "engines": { + "node": ">=22" + }, + "packageManager": "pnpm@11.7.0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..c43b3d9 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2272 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.4 + version: 0.18.4 + '@types/node': + specifier: 25.6.2 + version: 25.6.2 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260509.2 + version: 7.0.0-dev.20260509.2 + fast-check: + specifier: 4.8.0 + version: 4.8.0 + publint: + specifier: 0.3.21 + version: 0.3.21 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite-plus: + specifier: 0.2.4 + version: 0.2.4(@arethetypeswrong/core@0.18.4)(@types/node@25.6.2)(publint@0.3.21)(typescript@6.0.3)(vite@8.1.4(@types/node@25.6.2)) + +packages: + + '@andrewbranch/untar.js@1.0.3': + resolution: {integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==} + + '@arethetypeswrong/cli@0.18.4': + resolution: {integrity: sha512-kNWo6LTzGAuLYPpJ7Sgo63whSUeeSuKMlYx6IBgzs4ONEG807gW4hSSENvpeCHzO2H2wIzG5EFl0OKBbqGBAyA==} + engines: {node: '>=20'} + hasBin: true + + '@arethetypeswrong/core@0.18.4': + resolution: {integrity: sha512-M5F0ePyN6h2Z6XxRiyIPqjGbltotXLjR0CKA0uKspsDu0QmgTNYvRb4RSQPMUs2ZXZHCCYpbaZbFbYOXLxCjUA==} + engines: {node: '>=20'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + + '@braidai/lang@1.1.2': + resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@loaderkit/resolve@1.0.6': + resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/runtime@0.138.0': + resolution: {integrity: sha512-yHhoXsN8tYxgdJCdD91PbySNjEEaBX/tH2OQRDXJpsQv5b184oC4/qVbU7qlblvfil/JP15Lh2HW7+HN5DS90Q==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.138.0': + resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@oxfmt/binding-android-arm-eabi@0.57.0': + resolution: {integrity: sha512-qVBsEO+KugOsCmUHcO8iqNnqc65p7PCKpCs8M66mPZ+Ri+CWbcpoQOEJBg2OTu03+0qu++NK1jj6IzvQVs0Sig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.57.0': + resolution: {integrity: sha512-mp6PibWbao3aizijcheOeHQaYEhcUAt8pwLniYbtLfHxL/psFF0BykAwCj+s3c6qIpa8yN8keZICWrqtZ70w8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.57.0': + resolution: {integrity: sha512-T+0stuCBqmUVY+aMIvrgXhzGhHO3sD5tNiiEcYqgSdPsnukskQqn2u5qOVD0sv1l7RLdFS5Z/f5Wi9Ktyjr3Eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.57.0': + resolution: {integrity: sha512-O+3JbqWs/mCI2oi4xfhRO2IVPFJNDDEBV8Odo+ZpmsUOeKJfjXoNH7nDmBEQcDgK7NfjDIyE7kRgYSZcTLDO0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.57.0': + resolution: {integrity: sha512-pxwhxVC+JkLX9twOQ/8C/vbuOQcMZyKIDmiRDZfO7yITuVcIdZCiLRqqf4QOxb2+8FWrRXzQpm+1DBKcMpHSSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': + resolution: {integrity: sha512-pxBU4zH2imB/MDBfth2rOMeVxXUMjRQLCazagwLARIFH3hVlxZJBlM4nSnHXaIHJK4/qezoFCIORN6AY8Mra4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.57.0': + resolution: {integrity: sha512-JAprOzt8tycYou36ZgEw14DlRHTiN8qdtKANdV3VZIRIvTI/lh/cX13c9pJ/EnDk2GT3FASH7KvCgQ2AufAifQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.57.0': + resolution: {integrity: sha512-ajtjaxSaj9xl4BW7REt+Cef/ttzbAq00Bq4z7JUDZEfgFXdwSjH8K9bF+IcIJzZB9lKqMfQ4eHuSFOvvlvtqOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.57.0': + resolution: {integrity: sha512-p4Y/+RYk9Bk5WO+zHSUXAClRmZ2fbJCejMuCAsU2HhyME4jqf6Ftt/mJYEwIah1wGCBDYOB7wEGV1x5bCEZ6hA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.57.0': + resolution: {integrity: sha512-By6tRALAZsno0F4zedmtG+wdMvJiJmJoXM4d3+A9zHE4HRXLqXITwRH8mgrlcXc5yJM2g2W3riRPwTYdgemZLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.57.0': + resolution: {integrity: sha512-skYeG+RgvyzspqVEBsEprL90OYYZfoVNqB3HcCNR6QDJyXKOzfDRT3zncnHmUaFluIlBHuY23mU1b5WGgR98hA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.57.0': + resolution: {integrity: sha512-FFgACrZOXAXUh5KQh2mt1CDOVOZmn+QzHP71wM9QobNwyQvoFfyAeefVUltW83g3sm7LTiH3yfFqLLVUpA5ZFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.57.0': + resolution: {integrity: sha512-Nm/BAOfQeFiiKd502mZn/GAVKJwtd0RdCg17G3Wz/WSOIQmDi3+7/SZH4BHn1Ye5KvTVH3ua8WvfwLLycNIuvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.57.0': + resolution: {integrity: sha512-BiSy5Ku3mQqyxS6YIqAJgd403wEUWvI7kerfzPxc2l/txZVmZM0pSj7oDM+4bGBExowxOi7o73jEam1W0EDTZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.57.0': + resolution: {integrity: sha512-BCRkJiotz5s9afLYD2LuMvzAoDYx9H17E/YbDyu4xK7l4zHDPeny9ErSXL//i/nJyaOwRk08x4b8cgJC00+JDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.57.0': + resolution: {integrity: sha512-4Oaxe1qrGgXfpCJ1C/ERJ2iCtV2rN1R79ga9fsfyVHfSQRu/hVW780u2KDqZWFZ/iGTHODJji0JemxqFZ63eIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.57.0': + resolution: {integrity: sha512-MYLAsDnhdNsSGheLYhWgbk0vfIrlS84iQYun/y21fX6u0jj8iBtYtbpZMdiqYeuf8U12eVPUjVY2xE2NrCfJ0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.57.0': + resolution: {integrity: sha512-PBwdzZALJY/jcCx2E6is0yu+cuVXeySTDmwuseD+9j0mHqlRNxwlKgsyRTBed/woPeqfVfuXfWjoq4Cx2Zt3Eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.57.0': + resolution: {integrity: sha512-bQJdH9i4RRfw55jm7+8/xS7GzHLLTbHx4huhrrDxQJaJtbSDbsyOnODvP1ftT7EG0KFKAYO2S+q6AcioXODx8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@0.24.0': + resolution: {integrity: sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@0.24.0': + resolution: {integrity: sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@0.24.0': + resolution: {integrity: sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@0.24.0': + resolution: {integrity: sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@0.24.0': + resolution: {integrity: sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@0.24.0': + resolution: {integrity: sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.72.0': + resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.72.0': + resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.72.0': + resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.72.0': + resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.72.0': + resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.72.0': + resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.72.0': + resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.72.0': + resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.72.0': + resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.72.0': + resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/plugins@1.68.0': + resolution: {integrity: sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@publint/pack@0.1.5': + resolution: {integrity: sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw==} + engines: {node: '>=18'} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-oG9KahiCpx4q70Ood/rRJhYio4oIMHEHfX0g0LhfenlSIjIonitZWjUmUVG9N9q1ev9QWcM8pWpDrGGP0Osp3Q==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-xdEkp23Gu8I7PJCMmSMYtSLX76NKODWj74AoWFPi6MM59ICsjnTSqZf/HmXKSvuNZ5MGb4CMpP3c40dLjGB2PQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-rd+bMRtUAFBClOAKi9p2rOu6jPmnrjZVljoFyxHw+6bIRLerEQlxP+nIH1olC3HOZPyZ6/x75WtfzTHYeqffiQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-ar5HN/V/4HLF4FZCoVVFj+ET1Soi758hb4WhhzYQfSUXQ/bpVGUGP86JAy8EhVMoeN6qxqWet93MkLSszJOIVg==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-lB26mGzdolYIZiOdBII8roVJCxCUR8zkYszvvHyjB1IPs7d5fmOhT6OzI1zYPYujiSRJi4HVYM1iXTcIfp7KDg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-gH3UmtyxHiRNEP0LgQXCVlB5+ZN/U+/Z7jM/zULQtTOxIIFK3Y4b8gbGLvP7uW3u2cqYOg2hc2nuN8OdsCmOig==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-kZV0Vh64hp10saOghPlFZE1qahonqvRgU3iubt8pUY4XLe8IQIofwWCN5vzNNeULE4W4mRtAJbHuvP/muOFomw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-JAJpEX0yBaEle2zzbX5z9QAhmEfML1SyQafLwbKCdcOtnkGdk5xD8NKIVxq+nTwYjRwuV7kKnQ+fqU3gpWY0qQ==} + engines: {node: '>=16.20.0'} + hasBin: true + + '@vitest/browser-preview@4.1.10': + resolution: {integrity: sha512-14MJrL59ZFkqXLjwfSk6RzTDy5Czf9UG4+8q8L6Gxjs2aPjEce/cVNYV14bXAc2BvMjUNu904+ZEZA1Xc1wtvQ==} + peerDependencies: + vitest: 4.1.10 + + '@vitest/browser@4.1.10': + resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==} + peerDependencies: + vitest: 4.1.10 + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + '@voidzero-dev/vite-plus-core@0.2.4': + resolution: {integrity: sha512-AoAYGPwNO56o9TuCR+KaQGA5XpSnTpn2QYHK0DQ0f8j3wvaMmToeWOJF6STo+XMntMDfiaB7sOsSFNE+hPYyjg==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + publint: ^0.3.8 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + yaml: ^2.4.2 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + publint: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + yaml: + optional: true + + '@voidzero-dev/vite-plus-darwin-arm64@0.2.4': + resolution: {integrity: sha512-UQwoLpnBW3qLqj5H3airPQeMCX3kfffVDM2EYGe889Fn5dOS9VhikuWloJlcjwtKwqLbFqkrsGjfb6XRvuLozg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [darwin] + + '@voidzero-dev/vite-plus-darwin-x64@0.2.4': + resolution: {integrity: sha512-f4XtiA4cBc/Z260QvvXIlBqTb/dZMAioohQDoR5jLG9o9vIOaWE2Ujm/zcWDyNpyZ88RLRrcO8ZwiMrEsdzbJw==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.4': + resolution: {integrity: sha512-TUamG9wEehZI4SFG7M6nUKb6z/7x3nNOBbnPdWIpv4C8uWKHwNtsnaaVLeygHIUIJEhbByR3oEFDylYLLr4KRw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.4': + resolution: {integrity: sha512-CS9uQ22QFr+lZZp95Huccg3cip3QYVU9GWrhvATqMBXWXb8IRHOulzuXrFxzvhMBITyPaRS9m/eIHmnM4L4h4Q==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.4': + resolution: {integrity: sha512-X5XfvftFERjer78SG+QKaJCa9LgIKygx4w13sD1/RS/kYOtaf1+yifrJpOFel+t9TRe/YqGTZa5C8uFrDz3OHw==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-x64-musl@0.2.4': + resolution: {integrity: sha512-2iIWW6JR0UOK4QIFKgUQHjaF/7hZxELePny8R1OIn2jzShRfQhS1cmxksSg8ce/5nhYrfQ9dkg5ZmdLbxhpS/A==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.4': + resolution: {integrity: sha512-3yY4FnpvKBxjmHtEcao6Wcs/VBstzC0GhXAPWetbiFEl/sgL66V4Uhws02esfOSWw8abqLrXyPE9vK+newtsuw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [win32] + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.4': + resolution: {integrity: sha512-ERnFrh1O0XZhmGSqND04jtHM7tyKaGAOeT1w/HpVFmL/cQK2vuLl2GKjEwOqkBLd2csNGCcnk3e7C2qDmrNR/Q==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emojilib@2.4.0: + resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-check@4.8.0: + resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} + engines: {node: '>=12.17.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marked-terminal@7.3.0: + resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} + engines: {node: '>=16.0.0'} + peerDependencies: + marked: '>=1 <16' + + marked@9.1.6: + resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} + engines: {node: '>= 16'} + hasBin: true + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-emoji@2.2.0: + resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + oxfmt@0.57.0: + resolution: {integrity: sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint-tsgolint@0.24.0: + resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==} + hasBin: true + + oxlint@1.72.0: + resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + package-manager-detector@1.7.0: + resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} + + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + publint@0.3.21: + resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==} + engines: {node: '>=18'} + hasBin: true + + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + skin-tone@2.0.0: + resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.6.1-rc: + resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + unicode-emoji-modifier-base@1.0.0: + resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} + engines: {node: '>=4'} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vite-plus@0.2.4: + resolution: {integrity: sha512-gaBBjOXIq9lLRU44oAYdIr99p+JBLX1kxs+l/6LqGgSXwcVKAdDa1boSrOTELqYCkQQ0fpppXUGWi9o6JDT5zw==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + peerDependenciesMeta: + '@vitest/browser-playwright': + optional: true + '@vitest/browser-webdriverio': + optional: true + + vite@8.1.4: + resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + engines: {node: '>=10'} + +snapshots: + + '@andrewbranch/untar.js@1.0.3': {} + + '@arethetypeswrong/cli@0.18.4': + dependencies: + '@arethetypeswrong/core': 0.18.4 + chalk: 4.1.2 + cli-table3: 0.6.5 + commander: 10.0.1 + marked: 9.1.6 + marked-terminal: 7.3.0(marked@9.1.6) + semver: 7.8.5 + + '@arethetypeswrong/core@0.18.4': + dependencies: + '@andrewbranch/untar.js': 1.0.3 + '@loaderkit/resolve': 1.0.6 + cjs-module-lexer: 1.4.3 + fflate: 0.8.3 + lru-cache: 11.5.2 + semver: 7.8.5 + typescript: 5.6.1-rc + validate-npm-package-name: 5.0.1 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + + '@blazediff/core@1.9.1': {} + + '@braidai/lang@1.1.2': {} + + '@colors/colors@1.5.0': + optional: true + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@loaderkit/resolve@1.0.6': + dependencies: + '@braidai/lang': 1.1.2 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/runtime@0.138.0': {} + + '@oxc-project/types@0.138.0': {} + + '@oxc-project/types@0.139.0': {} + + '@oxfmt/binding-android-arm-eabi@0.57.0': + optional: true + + '@oxfmt/binding-android-arm64@0.57.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.57.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.57.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.57.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.57.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.57.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.57.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.57.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.57.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.57.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.57.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.57.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.57.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.57.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.57.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.57.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.57.0': + optional: true + + '@oxlint-tsgolint/darwin-arm64@0.24.0': + optional: true + + '@oxlint-tsgolint/darwin-x64@0.24.0': + optional: true + + '@oxlint-tsgolint/linux-arm64@0.24.0': + optional: true + + '@oxlint-tsgolint/linux-x64@0.24.0': + optional: true + + '@oxlint-tsgolint/win32-arm64@0.24.0': + optional: true + + '@oxlint-tsgolint/win32-x64@0.24.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.72.0': + optional: true + + '@oxlint/binding-android-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-x64@1.72.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.72.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.72.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.72.0': + optional: true + + '@oxlint/plugins@1.68.0': {} + + '@polka/url@1.0.0-next.29': {} + + '@publint/pack@0.1.5': + dependencies: + tinyexec: 1.2.4 + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sindresorhus/is@4.6.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@25.6.2': + dependencies: + undici-types: 7.19.2 + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260509.2': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260509.2 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260509.2 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260509.2 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260509.2 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260509.2 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260509.2 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260509.2 + + '@vitest/browser-preview@4.1.10(vite@8.1.4(@types/node@25.6.2))(vitest@4.1.10)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/browser': 4.1.10(vite@8.1.4(@types/node@25.6.2))(vitest@4.1.10) + vitest: 4.1.10(@types/node@25.6.2)(@vitest/browser-preview@4.1.10)(vite@8.1.4(@types/node@25.6.2)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.10(vite@8.1.4(@types/node@25.6.2))(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@25.6.2)) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@25.6.2)(@vitest/browser-preview@4.1.10)(vite@8.1.4(@types/node@25.6.2)) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@25.6.2))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.4(@types/node@25.6.2) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@voidzero-dev/vite-plus-core@0.2.4(@arethetypeswrong/core@0.18.4)(@types/node@25.6.2)(publint@0.3.21)(typescript@6.0.3)': + dependencies: + '@oxc-project/runtime': 0.138.0 + '@oxc-project/types': 0.138.0 + lightningcss: 1.32.0 + postcss: 8.5.19 + optionalDependencies: + '@arethetypeswrong/core': 0.18.4 + '@types/node': 25.6.2 + fsevents: 2.3.3 + publint: 0.3.21 + typescript: 6.0.3 + + '@voidzero-dev/vite-plus-darwin-arm64@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-darwin-x64@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-musl@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.4': + optional: true + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.4': + optional: true + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + any-promise@1.3.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + char-regex@1.0.2: {} + + cjs-module-lexer@1.4.3: {} + + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.2 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + + convert-source-map@2.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + emoji-regex@8.0.0: {} + + emojilib@2.4.0: {} + + environment@1.1.0: {} + + es-module-lexer@2.3.1: {} + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fast-check@4.8.0: + dependencies: + pure-rand: 8.4.2 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fflate@0.8.3: {} + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + has-flag@4.0.0: {} + + highlight.js@10.7.3: {} + + is-fullwidth-code-point@3.0.0: {} + + js-tokens@4.0.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lru-cache@11.5.2: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marked-terminal@7.3.0(marked@9.1.6): + dependencies: + ansi-escapes: 7.3.0 + ansi-regex: 6.2.2 + chalk: 5.6.2 + cli-highlight: 2.1.11 + cli-table3: 0.6.5 + marked: 9.1.6 + node-emoji: 2.2.0 + supports-hyperlinks: 3.2.0 + + marked@9.1.6: {} + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.16: {} + + node-emoji@2.2.0: + dependencies: + '@sindresorhus/is': 4.6.0 + char-regex: 1.0.2 + emojilib: 2.4.0 + skin-tone: 2.0.0 + + object-assign@4.1.1: {} + + obug@2.1.3: {} + + oxfmt@0.57.0(vite-plus@0.2.4(@arethetypeswrong/core@0.18.4)(@types/node@25.6.2)(publint@0.3.21)(typescript@6.0.3)(vite@8.1.4(@types/node@25.6.2))): + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.57.0 + '@oxfmt/binding-android-arm64': 0.57.0 + '@oxfmt/binding-darwin-arm64': 0.57.0 + '@oxfmt/binding-darwin-x64': 0.57.0 + '@oxfmt/binding-freebsd-x64': 0.57.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.57.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.57.0 + '@oxfmt/binding-linux-arm64-gnu': 0.57.0 + '@oxfmt/binding-linux-arm64-musl': 0.57.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.57.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.57.0 + '@oxfmt/binding-linux-riscv64-musl': 0.57.0 + '@oxfmt/binding-linux-s390x-gnu': 0.57.0 + '@oxfmt/binding-linux-x64-gnu': 0.57.0 + '@oxfmt/binding-linux-x64-musl': 0.57.0 + '@oxfmt/binding-openharmony-arm64': 0.57.0 + '@oxfmt/binding-win32-arm64-msvc': 0.57.0 + '@oxfmt/binding-win32-ia32-msvc': 0.57.0 + '@oxfmt/binding-win32-x64-msvc': 0.57.0 + vite-plus: 0.2.4(@arethetypeswrong/core@0.18.4)(@types/node@25.6.2)(publint@0.3.21)(typescript@6.0.3)(vite@8.1.4(@types/node@25.6.2)) + + oxlint-tsgolint@0.24.0: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 0.24.0 + '@oxlint-tsgolint/darwin-x64': 0.24.0 + '@oxlint-tsgolint/linux-arm64': 0.24.0 + '@oxlint-tsgolint/linux-x64': 0.24.0 + '@oxlint-tsgolint/win32-arm64': 0.24.0 + '@oxlint-tsgolint/win32-x64': 0.24.0 + + oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@arethetypeswrong/core@0.18.4)(@types/node@25.6.2)(publint@0.3.21)(typescript@6.0.3)(vite@8.1.4(@types/node@25.6.2))): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.72.0 + '@oxlint/binding-android-arm64': 1.72.0 + '@oxlint/binding-darwin-arm64': 1.72.0 + '@oxlint/binding-darwin-x64': 1.72.0 + '@oxlint/binding-freebsd-x64': 1.72.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 + '@oxlint/binding-linux-arm-musleabihf': 1.72.0 + '@oxlint/binding-linux-arm64-gnu': 1.72.0 + '@oxlint/binding-linux-arm64-musl': 1.72.0 + '@oxlint/binding-linux-ppc64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-musl': 1.72.0 + '@oxlint/binding-linux-s390x-gnu': 1.72.0 + '@oxlint/binding-linux-x64-gnu': 1.72.0 + '@oxlint/binding-linux-x64-musl': 1.72.0 + '@oxlint/binding-openharmony-arm64': 1.72.0 + '@oxlint/binding-win32-arm64-msvc': 1.72.0 + '@oxlint/binding-win32-ia32-msvc': 1.72.0 + '@oxlint/binding-win32-x64-msvc': 1.72.0 + oxlint-tsgolint: 0.24.0 + vite-plus: 0.2.4(@arethetypeswrong/core@0.18.4)(@types/node@25.6.2)(publint@0.3.21)(typescript@6.0.3)(vite@8.1.4(@types/node@25.6.2)) + + package-manager-detector@1.7.0: {} + + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pngjs@7.0.0: {} + + postcss@8.5.19: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + publint@0.3.21: + dependencies: + '@publint/pack': 0.1.5 + package-manager-detector: 1.7.0 + picocolors: 1.1.1 + sade: 1.8.1 + + pure-rand@8.4.2: {} + + react-is@17.0.2: {} + + require-directory@2.1.1: {} + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + semver@7.8.5: {} + + siginfo@2.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + skin-tone@2.0.0: + dependencies: + unicode-emoji-modifier-base: 1.0.0 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@2.1.0: {} + + tinyrainbow@3.1.0: {} + + totalist@3.0.1: {} + + tslib@2.8.1: + optional: true + + typescript@5.6.1-rc: {} + + typescript@6.0.3: {} + + undici-types@7.19.2: {} + + unicode-emoji-modifier-base@1.0.0: {} + + validate-npm-package-name@5.0.1: {} + + vite-plus@0.2.4(@arethetypeswrong/core@0.18.4)(@types/node@25.6.2)(publint@0.3.21)(typescript@6.0.3)(vite@8.1.4(@types/node@25.6.2)): + dependencies: + '@oxc-project/types': 0.138.0 + '@oxlint/plugins': 1.68.0 + '@vitest/browser': 4.1.10(vite@8.1.4(@types/node@25.6.2))(vitest@4.1.10) + '@vitest/browser-preview': 4.1.10(vite@8.1.4(@types/node@25.6.2))(vitest@4.1.10) + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@25.6.2)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + '@voidzero-dev/vite-plus-core': 0.2.4(@arethetypeswrong/core@0.18.4)(@types/node@25.6.2)(publint@0.3.21)(typescript@6.0.3) + oxfmt: 0.57.0(vite-plus@0.2.4(@arethetypeswrong/core@0.18.4)(@types/node@25.6.2)(publint@0.3.21)(typescript@6.0.3)(vite@8.1.4(@types/node@25.6.2))) + oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@arethetypeswrong/core@0.18.4)(@types/node@25.6.2)(publint@0.3.21)(typescript@6.0.3)(vite@8.1.4(@types/node@25.6.2))) + oxlint-tsgolint: 0.24.0 + vitest: 4.1.10(@types/node@25.6.2)(@vitest/browser-preview@4.1.10)(vite@8.1.4(@types/node@25.6.2)) + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.2.4 + '@voidzero-dev/vite-plus-darwin-x64': 0.2.4 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.2.4 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.2.4 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.2.4 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.2.4 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.2.4 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.2.4 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + + vite@8.1.4(@types/node@25.6.2): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.19 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.6.2 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@25.6.2)(@vitest/browser-preview@4.1.10)(vite@8.1.4(@types/node@25.6.2)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@25.6.2)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.4(@types/node@25.6.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.2 + '@vitest/browser-preview': 4.1.10(vite@8.1.4(@types/node@25.6.2))(vitest@4.1.10) + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + ws@8.21.0: {} + + y18n@5.0.8: {} + + yargs-parser@20.2.9: {} + + yargs@16.2.2: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..30ed30e --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +# Supply-chain cooldown: only install versions published at least 7 days ago, +# giving compromised releases time to be caught and yanked (minutes). +minimumReleaseAge: 10080 diff --git a/src/codec.ts b/src/codec.ts new file mode 100644 index 0000000..7c46040 --- /dev/null +++ b/src/codec.ts @@ -0,0 +1,23 @@ +import type { FieldRegistry } from "./fields.js"; +import type { ExpressionResult, Issue } from "./types.js"; +import { validateExpression } from "./validation.js"; + +export function parseExpression( + input: unknown, + registry: FieldRegistry, +): ExpressionResult { + let value = input; + if (typeof input === "string") { + try { + value = JSON.parse(input) as unknown; + } catch { + const issue: Issue = { + code: "invalid-json", + path: [], + message: "input is not valid JSON", + }; + return { ok: false, issues: [issue] }; + } + } + return validateExpression(value, registry); +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..868f17d --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,18 @@ +import type { Issue } from "./types.js"; + +/** Thrown by `createFilter` when the field registry cannot form a valid filter. */ +export class AnyallConfigError extends Error { + override readonly name = "AnyallConfigError"; +} + +/** Thrown by `compile` and `filter` when an asserted expression is invalid. */ +export class AnyallExpressionError extends Error { + override readonly name = "AnyallExpressionError"; + + constructor( + message: string, + readonly issues: readonly Issue[], + ) { + super(message); + } +} diff --git a/src/evaluate.ts b/src/evaluate.ts new file mode 100644 index 0000000..c6fcb65 --- /dev/null +++ b/src/evaluate.ts @@ -0,0 +1,126 @@ +import { AnyallExpressionError } from "./errors.js"; +import type { FieldRegistry, RegisteredField } from "./fields.js"; +import type { Expression, Predicate } from "./types.js"; +import { validateExpression } from "./validation.js"; + +type CompiledPredicate = (record: R) => boolean; + +export function compileExpression( + expression: Expression | null, + registry: FieldRegistry, +): (record: R) => boolean { + const normalizedNeedles: string[] = []; + // Validation already normalizes needles to reject empty ones; capturing that + // exact result avoids duplicate domain work at the compile boundary. + const result = validateExpression(expression, registry, (needle) => { + normalizedNeedles.push(needle); + }); + if (!result.ok) { + throw new AnyallExpressionError("invalid filter expression", result.issues); + } + if (result.expression === null) { + return () => true; + } + + let needleIndex = 0; + const groups = result.expression.any.map((group) => + group.all.map((predicate) => { + const compiled = compilePredicate(predicate, registry, normalizedNeedles, needleIndex); + needleIndex = compiled.nextNeedleIndex; + return compiled.matches; + }), + ); + + return (record) => groups.some((group) => group.every((matches) => matches(record))); +} + +export function filterRecords( + records: readonly R[], + matches: (record: R) => boolean, +): readonly R[] { + let selected: R[] | undefined; + for (const [index, record] of records.entries()) { + if (matches(record)) { + selected?.push(record); + } else if (selected === undefined) { + // Delaying the copy makes the reference-preserving no-op a natural result, + // not a second pass or an after-the-fact special case. + selected = records.slice(0, index); + } + } + return selected ?? records; +} + +function compilePredicate( + predicate: Predicate, + registry: FieldRegistry, + normalizedNeedles: readonly string[], + needleIndex: number, +): { readonly matches: CompiledPredicate; readonly nextNeedleIndex: number } { + const field = registry.get(predicate.field)!; + let base: CompiledPredicate; + + switch (predicate.operator) { + case "empty": + base = (record) => resolveTexts(field.get(record), field).length === 0; + break; + case "one-of": { + const needles = new Set( + normalizedNeedles.slice(needleIndex, needleIndex + predicate.value.length), + ); + needleIndex += predicate.value.length; + base = (record) => resolveTexts(field.get(record), field).some((text) => needles.has(text)); + break; + } + case "equals": { + const needle = normalizedNeedles[needleIndex++]!; + base = (record) => resolveTexts(field.get(record), field).some((text) => text === needle); + break; + } + case "contains": { + const needle = normalizedNeedles[needleIndex++]!; + base = (record) => + resolveTexts(field.get(record), field).some((text) => text.includes(needle)); + break; + } + case "starts-with": { + const needle = normalizedNeedles[needleIndex++]!; + base = (record) => + resolveTexts(field.get(record), field).some((text) => text.startsWith(needle)); + break; + } + case "ends-with": { + const needle = normalizedNeedles[needleIndex++]!; + base = (record) => + resolveTexts(field.get(record), field).some((text) => text.endsWith(needle)); + break; + } + } + + return { + matches: predicate.not === true ? (record) => !base(record) : base, + nextNeedleIndex: needleIndex, + }; +} + +function resolveTexts(value: unknown, field: RegisteredField): string[] { + const texts: string[] = []; + if (Array.isArray(value)) { + // One scalar pass keeps array fields useful without turning record + // resolution into recursive traversal over caller-owned structures. + for (const element of value) appendScalar(element, field.normalize, texts); + } else { + appendScalar(value, field.normalize, texts); + } + return texts; +} + +function appendScalar(value: unknown, normalize: (text: string) => string, texts: string[]): void { + const type = typeof value; + if (type !== "string" && type !== "number" && type !== "boolean" && type !== "bigint") { + return; + } + + const text = normalize(String(value)); + if (text !== "") texts.push(text); +} diff --git a/src/fields.ts b/src/fields.ts new file mode 100644 index 0000000..2f35a23 --- /dev/null +++ b/src/fields.ts @@ -0,0 +1,61 @@ +import { AnyallConfigError } from "./errors.js"; +import type { FieldDef } from "./types.js"; + +export interface RegisteredField { + readonly get: (record: R) => unknown; + readonly normalize: (text: string) => string; +} + +export type FieldRegistry = ReadonlyMap>; + +/** Validate and snapshot field callables before expressions can depend on them. */ +export function createFieldRegistry( + fields: Readonly>>, +): FieldRegistry { + if (typeof fields !== "object" || fields === null || Array.isArray(fields)) { + throw new AnyallConfigError("fields must be an object of named accessors"); + } + + const entries = Object.entries(fields) as [Name, unknown][]; + if (entries.length === 0) { + throw new AnyallConfigError("at least one field is required"); + } + + const registry = new Map>(); + for (const [name, definition] of entries) { + registry.set(name, registerField(name, definition)); + } + return registry; +} + +function registerField(name: string, definition: unknown): RegisteredField { + if (typeof definition === "function") { + return { get: definition as (record: R) => unknown, normalize: defaultNormalize }; + } + + if (typeof definition !== "object" || definition === null || Array.isArray(definition)) { + throw invalidField(name, "must be an accessor function or an object with get"); + } + + const candidate = definition as { get?: unknown; normalize?: unknown }; + if (typeof candidate.get !== "function") { + throw invalidField(name, "get must be a function"); + } + if (candidate.normalize !== undefined && typeof candidate.normalize !== "function") { + throw invalidField(name, "normalize must be a function when provided"); + } + + const get = candidate.get as (record: R) => unknown; + if (candidate.normalize === undefined) { + return { get, normalize: defaultNormalize }; + } + return { get, normalize: candidate.normalize as (text: string) => string }; +} + +function defaultNormalize(text: string): string { + return text.trim().toLowerCase(); +} + +function invalidField(name: string, reason: string): AnyallConfigError { + return new AnyallConfigError(`field ${JSON.stringify(name)} ${reason}`); +} diff --git a/src/filter.ts b/src/filter.ts new file mode 100644 index 0000000..438975d --- /dev/null +++ b/src/filter.ts @@ -0,0 +1,31 @@ +import { parseExpression } from "./codec.js"; +import { compileExpression, filterRecords } from "./evaluate.js"; +import { createFieldRegistry } from "./fields.js"; +import type { FieldRegistry } from "./fields.js"; +import { expressionFromRows } from "./rows.js"; +import type { FieldDef, Filter } from "./types.js"; + +/** + * Bind an explicit record type first so the field map can still infer its + * literal names; TypeScript cannot partially infer one generic call. + */ +export function createFilter(): >>( + fields: F, +) => Filter { + return >>(fields: F): Filter => { + const registry = createFieldRegistry(fields); + return createBoundFilter(registry); + }; +} + +function createBoundFilter( + registry: FieldRegistry, +): Filter { + return { + fromRows: (rows, options) => expressionFromRows(rows, registry, options?.incomplete), + compile: (expression) => compileExpression(expression, registry), + filter: (records, expression) => + filterRecords(records, compileExpression(expression, registry)), + parse: (input) => parseExpression(input, registry), + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..cfd0959 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +export { AnyallConfigError, AnyallExpressionError } from "./errors.js"; +export { createFilter } from "./filter.js"; +export type { + Expression, + ExpressionResult, + FieldDef, + Filter, + FilterRow, + Issue, + Operator, + Predicate, +} from "./types.js"; diff --git a/src/rows.ts b/src/rows.ts new file mode 100644 index 0000000..c763350 --- /dev/null +++ b/src/rows.ts @@ -0,0 +1,97 @@ +import type { FieldRegistry } from "./fields.js"; +import type { ExpressionResult, FilterRow, Issue, Predicate } from "./types.js"; +import { validatePredicateInput } from "./validation.js"; + +interface IndexedRow { + readonly index: number; + readonly row: FilterRow; +} + +export function expressionFromRows( + rows: readonly FilterRow[], + registry: FieldRegistry, + incomplete: "reject" | "discard" = "reject", +): ExpressionResult { + const issues: Issue[] = []; + const any: Array<{ readonly all: readonly Predicate[] }> = []; + + for (const slice of segmentRows(rows)) { + const all: Predicate[] = []; + for (const { index, row } of slice) { + const predicate = predicateFromRow(row, registry); + if (predicate === undefined) { + issues.push({ + code: "incomplete-row", + path: [index], + message: "row does not form a valid predicate", + }); + } else { + all.push(predicate); + } + } + if (all.length > 0) { + any.push({ all }); + } + } + + if (incomplete !== "discard" && issues.length > 0) { + return { ok: false, issues }; + } + return { ok: true, expression: any.length === 0 ? null : { v: 1, any } }; +} + +function segmentRows(rows: readonly FilterRow[]): IndexedRow[][] { + const slices: IndexedRow[][] = [[]]; + Array.from(rows).forEach((row, index) => { + // Boundaries come from the authored sequence, so later discards cannot + // silently move a predicate into a different boolean group. + if (index > 0 && isObject(row) && row.join === "or") { + slices.push([]); + } + slices[slices.length - 1]!.push({ index, row }); + }); + return slices; +} + +function predicateFromRow( + row: FilterRow, + registry: FieldRegistry, +): Predicate | undefined { + if (!isObject(row) || !hasValidJoin(row) || !hasValidNot(row)) { + return undefined; + } + + const candidate: Record = {}; + copyOwn(row, candidate, "field"); + copyOwn(row, candidate, "operator"); + copyOwn(row, candidate, "value"); + if (Object.hasOwn(row, "not") && row.not === true) { + candidate.not = true; + } + + // A shared boundary keeps rows and stored data from growing subtly + // different definitions of a predicate. + return validatePredicateInput(candidate, registry); +} + +function hasValidJoin(row: Record): boolean { + return row.join === undefined || row.join === "and" || row.join === "or"; +} + +function hasValidNot(row: Record): boolean { + return !Object.hasOwn(row, "not") || typeof row.not === "boolean"; +} + +function copyOwn( + source: Record, + target: Record, + key: "field" | "operator" | "value", +): void { + if (Object.hasOwn(source, key)) { + target[key] = source[key]; + } +} + +function isObject(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3341dcb --- /dev/null +++ b/src/types.ts @@ -0,0 +1,91 @@ +/** A declared record capability, optionally with domain-specific text normalization. */ +export type FieldDef = + | ((record: R) => unknown) + | { + readonly get: (record: R) => unknown; + /** Must be pure; comparison applies it to both record texts and needles. */ + readonly normalize?: (text: string) => string; + }; + +/** A value-bearing or empty predicate from the package's closed vocabulary. */ +export type Predicate = + | { + readonly field: Name; + readonly operator: "equals" | "contains" | "starts-with" | "ends-with"; + readonly value: string; + readonly not?: true; + } + | { + readonly field: Name; + readonly operator: "one-of"; + readonly value: readonly string[]; + readonly not?: true; + } + | { + readonly field: Name; + readonly operator: "empty"; + readonly not?: true; + }; + +/** The six operators understood by every anyall expression. */ +export type Operator = Predicate["operator"]; + +/** + * A versioned disjunctive-normal-form expression: any group may pass, while + * every predicate in a passing group's `all` list must pass. + * + * Non-empty groups and expressions are enforced at runtime because ordinary + * array builders should not need tuple assertions. + */ +export interface Expression { + readonly v: 1; + readonly any: ReadonlyArray<{ + readonly all: readonly Predicate[]; + }>; +} + +/** Deliberately loose UI state; conversion is the validation boundary. */ +export interface FilterRow { + readonly join?: "and" | "or"; + readonly not?: boolean; + readonly field?: string; + readonly operator?: string; + readonly value?: unknown; +} + +/** A structured boundary failure with a stable machine-readable location. */ +export interface Issue { + readonly code: + | "invalid-json" + | "invalid-shape" + | "unsupported-version" + | "empty-expression" + | "empty-group" + | "unknown-field" + | "unknown-operator" + | "invalid-value" + | "empty-needle" + | "incomplete-row"; + readonly path: ReadonlyArray; + /** Human-readable context; consumers should branch on `code` and `path`. */ + readonly message: string; +} + +/** Rows and stored data share one result shape so callers need one error path. */ +export type ExpressionResult = + | { readonly ok: true; readonly expression: Expression | null } + | { readonly ok: false; readonly issues: readonly Issue[] }; + +/** A filter surface bound to one declared record capability registry. */ +export interface Filter { + fromRows( + rows: readonly FilterRow[], + options?: { readonly incomplete?: "reject" | "discard" }, + ): ExpressionResult; + + compile(expression: Expression | null): (record: R) => boolean; + + filter(records: readonly R[], expression: Expression | null): readonly R[]; + + parse(input: unknown): ExpressionResult; +} diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..6bdb03d --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,370 @@ +import type { FieldRegistry } from "./fields.js"; +import type { Expression, ExpressionResult, Issue, Operator, Predicate } from "./types.js"; + +const operators = new Set([ + "equals", + "contains", + "starts-with", + "ends-with", + "one-of", + "empty", +]); +const scalarOperators = new Set(["equals", "contains", "starts-with", "ends-with"]); + +type Path = ReadonlyArray; + +interface ValidationContext { + readonly registry: FieldRegistry; + readonly issues: Issue[]; + readonly captureNeedle: ((needle: string) => void) | undefined; +} + +export function validatePredicateInput( + input: unknown, + registry: FieldRegistry, +): Predicate | undefined { + const context: ValidationContext = { + registry, + issues: [], + captureNeedle: undefined, + }; + const predicate = validatePredicate(input, [], context); + return context.issues.length === 0 ? predicate : undefined; +} + +export function validateExpression( + input: unknown, + registry: FieldRegistry, + captureNeedle?: (needle: string) => void, +): ExpressionResult { + if (input === null) { + return { ok: true, expression: null }; + } + + const context: ValidationContext = { registry, issues: [], captureNeedle }; + const expression = validateRoot(input, context); + if (context.issues.length > 0 || expression === undefined) { + return { ok: false, issues: orderByDocumentPath(input, context.issues) }; + } + return { ok: true, expression }; +} + +function validateRoot( + input: unknown, + context: ValidationContext, +): Expression | undefined { + if (!isObject(input)) { + addIssue(context, "invalid-shape", [], "expression must be an object or null"); + return undefined; + } + + if (!Object.hasOwn(input, "v")) { + addIssue(context, "invalid-shape", ["v"], "version is required"); + } else if (input.v !== 1) { + addIssue(context, "unsupported-version", ["v"], "only expression version 1 is supported"); + } + + let any: Array<{ readonly all: readonly Predicate[] } | undefined> | undefined; + if (!Object.hasOwn(input, "any")) { + addIssue(context, "invalid-shape", ["any"], "any is required"); + } else if (!Array.isArray(input.any)) { + addIssue(context, "invalid-shape", ["any"], "any must be an array"); + } else if (input.any.length === 0) { + addIssue(context, "empty-expression", ["any"], "an expression must contain a group"); + any = []; + } else { + // Array.from both visits holes and refuses an input subclass's species. + any = Array.from(input.any, (group, index) => validateGroup(group, ["any", index], context)); + } + + rejectUnknownKeys(input, new Set(["v", "any"]), [], context); + + if (input.v !== 1 || any === undefined || any.some((group) => group === undefined)) { + return undefined; + } + return { v: 1, any: any as Array<{ readonly all: readonly Predicate[] }> }; +} + +function validateGroup( + input: unknown, + path: Path, + context: ValidationContext, +): { readonly all: readonly Predicate[] } | undefined { + if (!isObject(input)) { + addIssue(context, "invalid-shape", path, "group must be an object"); + return undefined; + } + + let all: Array | undefined> | undefined; + if (!Object.hasOwn(input, "all")) { + addIssue(context, "invalid-shape", [...path, "all"], "all is required"); + } else if (!Array.isArray(input.all)) { + addIssue(context, "invalid-shape", [...path, "all"], "all must be an array"); + } else if (input.all.length === 0) { + addIssue(context, "empty-group", [...path, "all"], "a group must contain a predicate"); + all = []; + } else { + all = Array.from(input.all, (predicate, index) => + validatePredicate(predicate, [...path, "all", index], context), + ); + } + + rejectUnknownKeys(input, new Set(["all"]), path, context); + if (all === undefined || all.some((predicate) => predicate === undefined)) { + return undefined; + } + return { all: all as Predicate[] }; +} + +function validatePredicate( + input: unknown, + path: Path, + context: ValidationContext, +): Predicate | undefined { + if (!isObject(input)) { + addIssue(context, "invalid-shape", path, "predicate must be an object"); + return undefined; + } + + const field = validateField(input, path, context); + const operator = validateOperator(input, path, context); + const value = validateValue(input, field, operator, path, context); + const not = validateNot(input, path, context); + rejectUnknownKeys(input, new Set(["field", "operator", "value", "not"]), path, context); + + if ( + field === undefined || + operator === undefined || + value === invalidValue || + not === invalidNot + ) { + return undefined; + } + if (operator === "empty") { + return not === true ? { field, operator, not: true } : { field, operator }; + } + if (operator === "one-of") { + if (!Array.isArray(value)) { + return undefined; + } + return not === true + ? { field, operator, value: value as string[], not: true } + : { field, operator, value: value as string[] }; + } + if (typeof value !== "string") { + return undefined; + } + return not === true ? { field, operator, value, not: true } : { field, operator, value }; +} + +function validateField( + input: Record, + path: Path, + context: ValidationContext, +): Name | undefined { + if (!Object.hasOwn(input, "field") || typeof input.field !== "string") { + addIssue(context, "invalid-shape", [...path, "field"], "field must be a string"); + return undefined; + } + if (!context.registry.has(input.field as Name)) { + addIssue( + context, + "unknown-field", + [...path, "field"], + `unknown field ${JSON.stringify(input.field)}`, + ); + return undefined; + } + return input.field as Name; +} + +function validateOperator( + input: Record, + path: Path, + context: ValidationContext, +): Operator | undefined { + if (!Object.hasOwn(input, "operator") || typeof input.operator !== "string") { + addIssue(context, "invalid-shape", [...path, "operator"], "operator must be a string"); + return undefined; + } + if (!operators.has(input.operator as Operator)) { + addIssue( + context, + "unknown-operator", + [...path, "operator"], + `unknown operator ${JSON.stringify(input.operator)}`, + ); + return undefined; + } + return input.operator as Operator; +} + +const invalidValue = Symbol("invalid value"); + +function validateValue( + input: Record, + field: Name | undefined, + operator: Operator | undefined, + path: Path, + context: ValidationContext, +): string | readonly string[] | undefined | typeof invalidValue { + if (operator === undefined) { + return undefined; + } + + const valuePath = [...path, "value"]; + if (operator === "empty") { + if (Object.hasOwn(input, "value")) { + addIssue(context, "invalid-value", valuePath, "empty predicates do not accept a value"); + return invalidValue; + } + return undefined; + } + + if (!Object.hasOwn(input, "value")) { + addIssue(context, "invalid-value", valuePath, `${operator} requires a value`); + return invalidValue; + } + + if (scalarOperators.has(operator)) { + if (typeof input.value !== "string") { + addIssue(context, "invalid-value", valuePath, `${operator} requires a string value`); + return invalidValue; + } + if ( + field !== undefined && + normalizeNeedle(input.value, field, context.registry, context.captureNeedle) === "" + ) { + addIssue(context, "empty-needle", valuePath, "needle is empty after normalization"); + return invalidValue; + } + return input.value; + } + + if (!Array.isArray(input.value)) { + addIssue(context, "invalid-value", valuePath, "one-of requires an array of strings"); + return invalidValue; + } + if (input.value.length === 0) { + addIssue(context, "empty-needle", valuePath, "one-of requires at least one needle"); + return invalidValue; + } + + let valid = true; + const tokens: string[] = []; + for (const [index, token] of input.value.entries()) { + if (typeof token !== "string") { + addIssue(context, "invalid-value", [...valuePath, index], "one-of tokens must be strings"); + valid = false; + continue; + } + tokens.push(token); + if ( + field !== undefined && + normalizeNeedle(token, field, context.registry, context.captureNeedle) === "" + ) { + addIssue( + context, + "empty-needle", + [...valuePath, index], + "needle is empty after normalization", + ); + valid = false; + } + } + return valid ? tokens : invalidValue; +} + +const invalidNot = Symbol("invalid not"); + +function validateNot( + input: Record, + path: Path, + context: ValidationContext, +): true | undefined | typeof invalidNot { + if (!Object.hasOwn(input, "not") || input.not === undefined) { + return undefined; + } + if (input.not !== true) { + addIssue(context, "invalid-value", [...path, "not"], "not must be true when present"); + return invalidNot; + } + return true; +} + +function normalizeNeedle( + needle: string, + field: Name, + registry: FieldRegistry, + captureNeedle?: (needle: string) => void, +): string { + const normalized = registry.get(field)!.normalize(needle); + captureNeedle?.(normalized); + return normalized; +} + +function rejectUnknownKeys( + input: Record, + allowed: ReadonlySet, + path: Path, + context: ValidationContext, +): void { + for (const key of Object.getOwnPropertyNames(input)) { + if (!allowed.has(key)) { + addIssue(context, "invalid-shape", [...path, key], `unknown key ${JSON.stringify(key)}`); + } + } +} + +function orderByDocumentPath(input: unknown, issues: readonly Issue[]): Issue[] { + return issues + .map((issue, index) => ({ issue, index })) + .sort((left, right) => { + const order = compareDocumentPaths(input, left.issue.path, right.issue.path); + return order === 0 ? left.index - right.index : order; + }) + .map(({ issue }) => issue); +} + +function compareDocumentPaths(input: unknown, left: Path, right: Path): number { + let parent = input; + const sharedLength = Math.min(left.length, right.length); + + for (let index = 0; index < sharedLength; index += 1) { + const leftPart = left[index]; + const rightPart = right[index]; + if (leftPart !== rightPart) { + if (Array.isArray(parent) && typeof leftPart === "number" && typeof rightPart === "number") { + return leftPart - rightPart; + } + if (isObject(parent) && typeof leftPart === "string" && typeof rightPart === "string") { + const keys = Object.getOwnPropertyNames(parent); + const leftIndex = keys.indexOf(leftPart); + const rightIndex = keys.indexOf(rightPart); + if (leftIndex >= 0 && rightIndex >= 0) { + return leftIndex - rightIndex; + } + } + return 0; + } + + if ((Array.isArray(parent) || isObject(parent)) && leftPart !== undefined) { + parent = parent[leftPart as keyof typeof parent]; + } + } + + return left.length - right.length; +} + +function addIssue( + context: ValidationContext, + code: Issue["code"], + path: Path, + message: string, +): void { + context.issues.push({ code, path, message }); +} + +function isObject(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input); +} diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..df3ca02 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vite-plus/test"; +import { AnyallConfigError, createFilter } from "../src/index.js"; + +interface Customer { + name: string; +} + +const configure = createFilter(); + +describe("field configuration", () => { + test("accepts accessor and accessor-with-normalizer definitions", () => { + const filter = configure({ + name: (customer) => customer.name, + exactName: { + get: (customer) => customer.name, + normalize: (text) => text, + }, + }); + + expect(filter).toEqual({ + fromRows: expect.any(Function), + compile: expect.any(Function), + filter: expect.any(Function), + parse: expect.any(Function), + }); + }); + + test("rejects a registry with no addressable fields", () => { + expect(() => configure({})).toThrow(AnyallConfigError); + }); + + test.each([ + ["null", null], + ["an array", []], + ["a scalar", "name"], + ])("rejects %s as the registry", (_case, fields) => { + expect(() => configure(fields as never)).toThrow(AnyallConfigError); + }); + + test.each([ + ["null", null], + ["a scalar", "name"], + ["an array", []], + ["an object without get", {}], + ["a non-function get", { get: "name" }], + ["a non-function normalizer", { get: (customer: Customer) => customer.name, normalize: true }], + ])("rejects %s as a field definition", (_case, definition) => { + expect(() => configure({ name: definition } as never)).toThrow(AnyallConfigError); + }); + + test("identifies configuration failures without matching message text", () => { + try { + configure({ name: { get: () => "", normalize: 1 } } as never); + expect.unreachable("expected malformed configuration to throw"); + } catch (error) { + expect(error).toBeInstanceOf(AnyallConfigError); + expect(error).toMatchObject({ name: "AnyallConfigError" }); + } + }); +}); diff --git a/tests/evaluate.test.ts b/tests/evaluate.test.ts new file mode 100644 index 0000000..0a245e5 --- /dev/null +++ b/tests/evaluate.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + AnyallExpressionError, + createFilter, + type Expression, + type Predicate, +} from "../src/index.js"; + +interface Box { + value: unknown; +} + +const boxes = createFilter()({ + value: (box) => box.value, +}); + +const expression = (...predicates: readonly Predicate<"value">[]): Expression<"value"> => ({ + v: 1, + any: [{ all: predicates }], +}); + +interface ResolutionCase { + readonly label: string; + readonly value: unknown; + readonly needles: { + readonly equals: string; + readonly contains: string; + readonly "starts-with": string; + readonly "ends-with": string; + readonly "one-of": readonly string[]; + }; + readonly matchesValueOperators: boolean; + readonly isEmpty: boolean; +} + +const ordinaryNeedles = { + equals: " ALPHA ", + contains: " PH ", + "starts-with": " AL ", + "ends-with": " HA ", + "one-of": ["NOPE", " ALPHA "], +} as const; + +const resolutionCases: readonly ResolutionCase[] = [ + { + label: "undefined", + value: undefined, + needles: { ...ordinaryNeedles, equals: "undefined" }, + matchesValueOperators: false, + isEmpty: true, + }, + { + label: "null", + value: null, + needles: { ...ordinaryNeedles, equals: "null" }, + matchesValueOperators: false, + isEmpty: true, + }, + { + label: "a blank string", + value: " \t ", + needles: ordinaryNeedles, + matchesValueOperators: false, + isEmpty: true, + }, + { + label: "an empty string", + value: "", + needles: ordinaryNeedles, + matchesValueOperators: false, + isEmpty: true, + }, + { + label: "a string", + value: " Alpha ", + needles: ordinaryNeedles, + matchesValueOperators: true, + isEmpty: false, + }, + { + label: "a number", + value: 42, + needles: { + equals: "42", + contains: "4", + "starts-with": "4", + "ends-with": "2", + "one-of": ["7", "42"], + }, + matchesValueOperators: true, + isEmpty: false, + }, + { + label: "a boolean", + value: true, + needles: { + equals: "true", + contains: "ru", + "starts-with": "tr", + "ends-with": "ue", + "one-of": ["false", "true"], + }, + matchesValueOperators: true, + isEmpty: false, + }, + { + label: "a bigint", + value: 42n, + needles: { + equals: "42", + contains: "4", + "starts-with": "4", + "ends-with": "2", + "one-of": ["7", "42"], + }, + matchesValueOperators: true, + isEmpty: false, + }, + { + label: "an object", + value: { toString: () => "alpha" }, + needles: ordinaryNeedles, + matchesValueOperators: false, + isEmpty: true, + }, + { + label: "a Date", + value: new Date("2026-07-21T00:00:00.000Z"), + needles: { ...ordinaryNeedles, equals: "Tue Jul 21 2026" }, + matchesValueOperators: false, + isEmpty: true, + }, + { + label: "a function", + value: () => "alpha", + needles: ordinaryNeedles, + matchesValueOperators: false, + isEmpty: true, + }, + { + label: "a symbol", + value: Symbol("alpha"), + needles: ordinaryNeedles, + matchesValueOperators: false, + isEmpty: true, + }, + { + label: "an empty array", + value: [], + needles: ordinaryNeedles, + matchesValueOperators: false, + isEmpty: true, + }, + { + label: "a singleton text array", + value: [" Alpha "], + needles: ordinaryNeedles, + matchesValueOperators: true, + isEmpty: false, + }, + { + label: "an array whose scalars normalize away", + value: ["", null], + needles: ordinaryNeedles, + matchesValueOperators: false, + isEmpty: true, + }, + { + label: "a mixed array", + value: [null, "", {}, 42, ["alpha"], " Alpha "], + needles: ordinaryNeedles, + matchesValueOperators: true, + isEmpty: false, + }, + { + label: "an array containing only nested arrays", + value: [["alpha"], [42]], + needles: ordinaryNeedles, + matchesValueOperators: false, + isEmpty: true, + }, +]; + +describe("compile", () => { + test.each(resolutionCases)( + "resolves $label across every operator and its negation", + ({ value, needles, matchesValueOperators, isEmpty }) => { + const valueOperators = ["equals", "contains", "starts-with", "ends-with", "one-of"] as const; + + for (const operator of valueOperators) { + const valuePredicate = + operator === "one-of" + ? ({ field: "value", operator, value: needles[operator] } as const) + : ({ field: "value", operator, value: needles[operator] } as const); + + expect(boxes.compile(expression(valuePredicate))({ value })).toBe(matchesValueOperators); + expect(boxes.compile(expression({ ...valuePredicate, not: true }))({ value })).toBe( + !matchesValueOperators, + ); + } + + expect(boxes.compile(expression({ field: "value", operator: "empty" }))({ value })).toBe( + isEmpty, + ); + expect( + boxes.compile(expression({ field: "value", operator: "empty", not: true }))({ value }), + ).toBe(!isEmpty); + }, + ); + + test("normalizes record texts and each needle exactly once with the field normalizer", () => { + const calls: string[] = []; + const normalized = createFilter()({ + value: { + get: (box) => box.value, + normalize: (text) => { + calls.push(text); + return text.replaceAll(/[- ]/g, "").toLowerCase(); + }, + }, + }); + + const matches = normalized.compile({ + v: 1, + any: [ + { + all: [{ field: "value", operator: "one-of", value: [" A-B ", "ab", "NOPE"] }], + }, + ], + }); + + expect(calls).toEqual([" A-B ", "ab", "NOPE"]); + expect(matches({ value: "a b" })).toBe(true); + expect(calls).toEqual([" A-B ", "ab", "NOPE", "a b"]); + expect(matches({ value: "a-b" })).toBe(true); + expect(calls).toEqual([" A-B ", "ab", "NOPE", "a b", "a-b"]); + }); + + test("uses one-level some semantics without coercing objects", () => { + let coercions = 0; + const object = { + toString() { + coercions += 1; + return "alpha"; + }, + }; + const matches = boxes.compile( + expression({ field: "value", operator: "equals", value: "alpha" }), + ); + + expect(matches({ value: [object, ["alpha"], "bravo", "alpha"] })).toBe(true); + expect(matches({ value: [object, ["alpha"]] })).toBe(false); + expect(coercions).toBe(0); + }); + + test("short-circuits AND predicates and OR groups", () => { + const calls: string[] = []; + const filter = createFilter()({ + fail: () => { + calls.push("fail"); + return "no"; + }, + skippedPredicate: () => { + calls.push("skipped predicate"); + throw new Error("AND did not short-circuit"); + }, + pass: () => { + calls.push("pass"); + return "yes"; + }, + skippedGroup: () => { + calls.push("skipped group"); + throw new Error("OR did not short-circuit"); + }, + }); + const matches = filter.compile({ + v: 1, + any: [ + { + all: [ + { field: "fail", operator: "equals", value: "yes" }, + { field: "skippedPredicate", operator: "empty" }, + ], + }, + { all: [{ field: "pass", operator: "equals", value: "yes" }] }, + { all: [{ field: "skippedGroup", operator: "empty" }] }, + ], + }); + + expect(matches({})).toBe(true); + expect(calls).toEqual(["fail", "pass"]); + }); + + test("compiles null to a constant-true predicate", () => { + const matches = boxes.compile(null); + + expect(matches({ value: undefined })).toBe(true); + expect(matches({ value: Symbol("anything") })).toBe(true); + }); + + test("throws structured expression errors from compile", () => { + try { + boxes.compile({ v: 1, any: [] }); + expect.unreachable("expected an invalid expression to throw"); + } catch (error) { + expect(error).toBeInstanceOf(AnyallExpressionError); + expect(error).toMatchObject({ + name: "AnyallExpressionError", + issues: [{ code: "empty-expression", path: ["any"] }], + }); + } + }); + + test("propagates accessor and normalizer failures", () => { + const accessorFailure = new Error("accessor failed"); + const normalizerFailure = new Error("normalizer failed"); + const badAccessor = createFilter()({ + value: () => { + throw accessorFailure; + }, + }).compile(expression({ field: "value", operator: "empty" })); + const badRecordNormalizer = createFilter()({ + value: { + get: (box) => box.value, + normalize: (text) => { + if (text === "record") throw normalizerFailure; + return text; + }, + }, + }).compile(expression({ field: "value", operator: "equals", value: "needle" })); + + expect(() => badAccessor({ value: "anything" })).toThrow(accessorFailure); + expect(() => badRecordNormalizer({ value: "record" })).toThrow(normalizerFailure); + expect(() => + createFilter()({ + value: { + get: (box) => box.value, + normalize: () => { + throw normalizerFailure; + }, + }, + }).compile(expression({ field: "value", operator: "equals", value: "needle" })), + ).toThrow(normalizerFailure); + }); +}); + +describe("filter", () => { + const byId = createFilter<{ id: number }>()({ + id: (record) => record.id, + }); + + test("preserves record order and allocates only when a record is excluded", () => { + const records = [{ id: 3 }, { id: 1 }, { id: 2 }]; + const empty: Array<{ id: number }> = []; + + const selected = byId.filter(records, { + v: 1, + any: [{ all: [{ field: "id", operator: "one-of", value: ["3", "2"] }] }], + }); + const unchanged = byId.filter(records, { + v: 1, + any: [{ all: [{ field: "id", operator: "one-of", value: ["3", "1", "2"] }] }], + }); + + expect(selected).toEqual([records[0], records[2]]); + expect(selected).not.toBe(records); + expect(unchanged).toBe(records); + expect(byId.filter(records, null)).toBe(records); + expect(byId.filter(empty, null)).toBe(empty); + }); + + test("validates before reading records and throws the same structured error", () => { + expect(() => byId.filter([], { v: 1, any: [] })).toThrow(AnyallExpressionError); + + try { + byId.filter([], { v: 1, any: [] }); + expect.unreachable("expected an invalid expression to throw"); + } catch (error) { + expect(error).toMatchObject({ + issues: [{ code: "empty-expression", path: ["any"] }], + }); + } + }); +}); diff --git a/tests/laws.test.ts b/tests/laws.test.ts new file mode 100644 index 0000000..687347e --- /dev/null +++ b/tests/laws.test.ts @@ -0,0 +1,465 @@ +import fc from "fast-check"; +import { describe, expect, test } from "vite-plus/test"; +import { createFilter, type Expression, type FilterRow, type Predicate } from "../src/index.js"; + +type FieldName = "a" | "b" | "c" | "d"; + +interface LawRecord { + readonly id: number; + readonly a: unknown; + readonly b: unknown; + readonly c: unknown; + readonly d: unknown; +} + +interface GeneratedRow { + readonly row: FilterRow; + readonly predicate: Predicate; +} + +const LAW_SEED = 0x0a11a11; +const LAW_RUNS = 200; +const TOTALITY_RUNS = 300; +const lawParameters = { seed: LAW_SEED, numRuns: LAW_RUNS, endOnFailure: true } as const; + +const filters = createFilter()({ + a: (record) => record.a, + b: (record) => record.b, + c: (record) => record.c, + d: (record) => record.d, +}); + +const fieldArbitrary = fc.constantFrom("a", "b", "c", "d"); +const tokenCoreArbitrary = fc + .array(fc.constantFrom("a", "b", "c", "d", "e", "x", "y", "z", "0", "1", "9", "-"), { + minLength: 1, + maxLength: 8, + }) + .map((characters) => characters.join("")); +const tokenArbitrary = fc + .tuple(fc.constantFrom("", " ", " "), tokenCoreArbitrary, fc.constantFrom("", " ", " ")) + .map(([before, token, after]) => `${before}${token}${after}`); + +const plainPredicateArbitrary: fc.Arbitrary> = fc.oneof( + fc.record({ + field: fieldArbitrary, + operator: fc.constantFrom("equals", "contains", "starts-with", "ends-with"), + value: tokenArbitrary, + }), + fc + .tuple(fieldArbitrary, tokenArbitrary, fc.array(tokenArbitrary, { maxLength: 3 })) + .map(([field, duplicate, remainder]) => ({ + field, + operator: "one-of" as const, + value: [duplicate, duplicate, ...remainder], + })), + fieldArbitrary.map((field) => ({ field, operator: "empty" as const })), +); + +const predicateArbitrary: fc.Arbitrary> = fc + .tuple(plainPredicateArbitrary, fc.boolean()) + .map(([predicate, negated]) => (negated ? ({ ...predicate, not: true } as const) : predicate)); + +const expressionArbitrary: fc.Arbitrary> = fc + .array(fc.array(predicateArbitrary, { minLength: 1, maxLength: 3 }), { + minLength: 1, + maxLength: 3, + }) + .map((groups) => ({ v: 1 as const, any: groups.map((all) => ({ all })) })); +const nullableExpressionArbitrary = fc.option(expressionArbitrary, { nil: null }); + +const jsonishScalarArbitrary = fc.oneof( + fc.string({ maxLength: 12 }), + fc.integer({ min: -100, max: 100 }), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), +); +const comparisonValueArbitrary: fc.Arbitrary = fc.oneof( + jsonishScalarArbitrary, + fc.array(jsonishScalarArbitrary, { maxLength: 5 }), + fc.record({ label: fc.string({ maxLength: 8 }) }), + fc.bigInt({ min: -100n, max: 100n }), + fc.date({ min: new Date(0), max: new Date("2030-01-01T00:00:00.000Z") }), +); +const recordArbitrary: fc.Arbitrary = fc.record( + { + id: fc.nat({ max: 1_000 }), + a: comparisonValueArbitrary, + b: comparisonValueArbitrary, + c: comparisonValueArbitrary, + d: comparisonValueArbitrary, + }, + { requiredKeys: ["id", "a", "b", "c", "d"] }, +); + +const generatedRowArbitrary: fc.Arbitrary = fc + .tuple( + plainPredicateArbitrary, + fc.constantFrom<"and" | "or" | undefined>(undefined, "and", "or"), + fc.boolean(), + ) + .map(([plain, join, negated]) => { + const predicate: Predicate = negated ? ({ ...plain, not: true } as const) : plain; + const row: FilterRow = { ...plain, ...(join === undefined ? {} : { join }), not: negated }; + return { row, predicate }; + }); + +function resolveTexts(value: unknown): string[] { + const values = Array.isArray(value) ? value : [value]; + const texts: string[] = []; + for (const candidate of values) { + const type = typeof candidate; + if (type !== "string" && type !== "number" && type !== "boolean" && type !== "bigint") { + continue; + } + const normalized = String(candidate).trim().toLowerCase(); + if (normalized !== "") texts.push(normalized); + } + return texts; +} + +function evaluatePredicate(record: LawRecord, predicate: Predicate): boolean { + const texts = resolveTexts(record[predicate.field]); + let result: boolean; + + switch (predicate.operator) { + case "empty": + result = texts.length === 0; + break; + case "one-of": { + const needles = new Set(predicate.value.map((value) => value.trim().toLowerCase())); + result = texts.some((text) => needles.has(text)); + break; + } + case "equals": { + const needle = predicate.value.trim().toLowerCase(); + result = texts.some((text) => text === needle); + break; + } + case "contains": { + const needle = predicate.value.trim().toLowerCase(); + result = texts.some((text) => text.includes(needle)); + break; + } + case "starts-with": { + const needle = predicate.value.trim().toLowerCase(); + result = texts.some((text) => text.startsWith(needle)); + break; + } + case "ends-with": { + const needle = predicate.value.trim().toLowerCase(); + result = texts.some((text) => text.endsWith(needle)); + break; + } + } + + return predicate.not === true ? !result : result; +} + +function evaluateExhaustively( + record: LawRecord, + expression: Expression | null, +): boolean { + if (expression === null) return true; + + // Materializing every intermediate result makes this deliberately unlike + // the short-circuiting implementation whose observable result it checks. + const groups = expression.any.map((group) => { + const predicates = group.all.map((predicate) => evaluatePredicate(record, predicate)); + return predicates.every(Boolean); + }); + return groups.some(Boolean); +} + +function handBuildExpression(rows: readonly GeneratedRow[]): Expression { + const any: Array<{ all: Predicate[] }> = [{ all: [] }]; + rows.forEach(({ row, predicate }, index) => { + if (index > 0 && row.join === "or") any.push({ all: [] }); + any.at(-1)!.all.push(predicate); + }); + return { v: 1, any }; +} + +function evaluateLeftToRight(a: boolean, b: boolean, c: boolean, d: boolean): boolean { + return ((a && b) || c) && d; +} + +describe("wire and row laws", () => { + test("round-trips every valid expression and null without changing evaluation", () => { + fc.assert( + fc.property(nullableExpressionArbitrary, recordArbitrary, (expression, record) => { + const parsed = filters.parse(JSON.stringify(expression)); + + expect(parsed).toMatchObject({ ok: true }); + if (!parsed.ok) return; + expect(parsed.expression).toEqual(expression); + expect(filters.compile(parsed.expression)(record)).toBe( + filters.compile(expression)(record), + ); + }), + lawParameters, + ); + }); + + test("round-trips duplicate one-of tokens verbatim", () => { + const expression: Expression = { + v: 1, + any: [{ all: [{ field: "a", operator: "one-of", value: [" Alpha ", " Alpha "] }] }], + }; + const parsed = filters.parse(JSON.stringify(expression)); + + expect(parsed).toEqual({ ok: true, expression }); + if (!parsed.ok) return; + const matches = filters.compile(parsed.expression); + expect(matches({ id: 1, a: "alpha", b: null, c: null, d: null })).toBe(true); + expect(matches({ id: 2, a: "omega", b: null, c: null, d: null })).toBe(false); + }); + + test("round-trips the null produced by an empty row list", () => { + const built = filters.fromRows([]); + + expect(built).toEqual({ ok: true, expression: null }); + if (!built.ok) return; + expect(filters.parse(JSON.stringify(built.expression))).toEqual(built); + }); + + test("fromRows preserves authored DNF semantics, including not", () => { + fc.assert( + fc.property( + fc.array(generatedRowArbitrary, { minLength: 1, maxLength: 8 }), + recordArbitrary, + (generatedRows, record) => { + const rows = generatedRows.map(({ row }) => row); + const snapshot = structuredClone(rows); + const expected = handBuildExpression(generatedRows); + const built = filters.fromRows(rows); + + expect(built).toEqual({ ok: true, expression: expected }); + expect(rows).toEqual(snapshot); + if (!built.ok) return; + expect(filters.compile(built.expression)(record)).toBe( + evaluateExhaustively(record, expected), + ); + }, + ), + lawParameters, + ); + }); +}); + +describe("evaluation laws", () => { + test("is deterministic, stateless, and does not mutate records or expressions", () => { + fc.assert( + fc.property( + fc.array(recordArbitrary, { maxLength: 12 }), + nullableExpressionArbitrary, + (records, expression) => { + const recordsSnapshot = structuredClone(records); + const expressionSnapshot = structuredClone(expression); + const predicate = filters.compile(expression); + + const firstPredicateRun = records.map(predicate); + const secondPredicateRun = records.map(predicate); + const firstFilterRun = filters.filter(records, expression); + const secondFilterRun = filters.filter(records, expression); + + expect(secondPredicateRun).toEqual(firstPredicateRun); + expect(secondFilterRun).toEqual(firstFilterRun); + expect(records).toEqual(recordsSnapshot); + expect(expression).toEqual(expressionSnapshot); + }, + ), + lawParameters, + ); + }); + + test("negation exactly complements the plain predicate", () => { + fc.assert( + fc.property(plainPredicateArbitrary, recordArbitrary, (plain, record) => { + const plainExpression: Expression = { + v: 1, + any: [{ all: [plain] }], + }; + const negatedExpression: Expression = { + v: 1, + any: [{ all: [{ ...plain, not: true }] }], + }; + + expect(filters.compile(negatedExpression)(record)).toBe( + !filters.compile(plainExpression)(record), + ); + }), + lawParameters, + ); + }); + + test("agrees with an exhaustive reference evaluator", () => { + fc.assert( + fc.property(nullableExpressionArbitrary, recordArbitrary, (expression, record) => { + expect(filters.compile(expression)(record)).toBe(evaluateExhaustively(record, expression)); + }), + lawParameters, + ); + }); + + test("compiled predicates own their structure and needles", () => { + const tokens = [" Alpha ", "alpha"]; + const all: Predicate[] = [{ field: "a", operator: "one-of", value: tokens }]; + const any = [{ all }]; + const source: Expression = { v: 1, any }; + const matches = filters.compile(source); + const probes: LawRecord[] = [ + { id: 1, a: "alpha", b: null, c: null, d: null }, + { id: 2, a: "omega", b: null, c: null, d: null }, + ]; + + const beforeMutation = probes.map(matches); + tokens.splice(0, tokens.length, "omega"); + all.splice(0, all.length, { field: "a", operator: "empty" }); + any.splice(0, any.length); + + expect(beforeMutation).toEqual([true, false]); + expect(probes.map(matches)).toEqual(beforeMutation); + }); + + test("returns the input reference iff no record is excluded and otherwise stays stable", () => { + fc.assert( + fc.property( + fc.array(recordArbitrary, { maxLength: 12 }), + nullableExpressionArbitrary, + (records, expression) => { + const expected = records.filter((record) => evaluateExhaustively(record, expression)); + const actual = filters.filter(records, expression); + + expect(actual).toEqual(expected); + actual.forEach((record, index) => expect(record).toBe(expected[index])); + if (expected.length === records.length) { + expect(actual).toBe(records); + } else { + expect(actual).not.toBe(records); + } + }, + ), + lawParameters, + ); + }); +}); + +describe("resolution totality", () => { + const values = createFilter<{ readonly value: unknown }>()({ + value: (record) => record.value, + }); + const valueExpressionArbitrary: fc.Arbitrary> = fc + .array( + fc.array( + predicateArbitrary.map( + (predicate): Predicate<"value"> => ({ + ...predicate, + field: "value", + }), + ), + { minLength: 1, maxLength: 3 }, + ), + { minLength: 1, maxLength: 3 }, + ) + .map((groups) => ({ v: 1 as const, any: groups.map((all) => ({ all })) })); + const exoticAtomArbitrary = fc.oneof( + fc.string({ maxLength: 12 }), + fc.integer(), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), + fc.bigInt(), + fc.date(), + fc.string({ maxLength: 8 }).map((description) => Symbol(description)), + fc.string({ maxLength: 8 }).map((result) => () => result), + ); + const inertAccessorValueArbitrary: fc.Arbitrary = fc.oneof( + fc.jsonValue(), + exoticAtomArbitrary, + fc.array(exoticAtomArbitrary, { maxLength: 6 }), + ); + + test("is total for JSON data, functions, symbols, bigints, Dates, and mixtures", () => { + fc.assert( + fc.property(inertAccessorValueArbitrary, valueExpressionArbitrary, (value, expression) => { + const matches = values.compile(expression); + expect(() => matches({ value })).not.toThrow(); + expect(typeof matches({ value })).toBe("boolean"); + }), + { ...lawParameters, numRuns: TOTALITY_RUNS }, + ); + }); + + test("treats post-normalization empties as empty", () => { + const normalized = createFilter<{ readonly value: unknown }>()({ + value: { + get: (record) => record.value, + normalize: (text) => text.replaceAll(/[\s-]/g, "").toLowerCase(), + }, + }); + const isEmpty = normalized.compile({ + v: 1, + any: [{ all: [{ field: "value", operator: "empty" }] }], + }); + const isAlpha = normalized.compile({ + v: 1, + any: [{ all: [{ field: "value", operator: "equals", value: "alpha" }] }], + }); + + expect(isEmpty({ value: " - " })).toBe(true); + expect(isEmpty({ value: ["--", " "] })).toBe(true); + expect(isAlpha({ value: ["--", " alpha "] })).toBe(true); + }); + + test("keeps zero and false as matchable texts", () => { + const isZero = values.compile({ + v: 1, + any: [{ all: [{ field: "value", operator: "equals", value: "0" }] }], + }); + const isFalse = values.compile({ + v: 1, + any: [{ all: [{ field: "value", operator: "equals", value: "false" }] }], + }); + const isEmpty = values.compile({ + v: 1, + any: [{ all: [{ field: "value", operator: "empty" }] }], + }); + + expect(isZero({ value: 0 })).toBe(true); + expect(isFalse({ value: false })).toBe(true); + expect(isEmpty({ value: 0 })).toBe(false); + expect(isEmpty({ value: false })).toBe(false); + }); +}); + +describe("the four-row grouping case", () => { + test("evaluates (A && B) || (C && D) for the full truth table", () => { + const built = filters.fromRows([ + { field: "a", operator: "equals", value: "true" }, + { join: "and", field: "b", operator: "equals", value: "true" }, + { join: "or", field: "c", operator: "equals", value: "true" }, + { join: "and", field: "d", operator: "equals", value: "true" }, + ]); + + expect(built).toMatchObject({ ok: true }); + if (!built.ok) return; + const matches = filters.compile(built.expression); + let id = 0; + + for (const a of [false, true]) { + for (const b of [false, true]) { + for (const c of [false, true]) { + for (const d of [false, true]) { + const record = { id: id++, a, b, c, d }; + expect(matches(record), JSON.stringify({ a, b, c, d })).toBe((a && b) || (c && d)); + } + } + } + } + + expect(matches({ id, a: true, b: true, c: false, d: false })).toBe(true); + expect(evaluateLeftToRight(true, true, false, false)).toBe(false); + }); +}); diff --git a/tests/parse.test.ts b/tests/parse.test.ts new file mode 100644 index 0000000..ec48f1a --- /dev/null +++ b/tests/parse.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "vite-plus/test"; +import { createFilter } from "../src/index.js"; + +interface Customer { + name: string; + status?: string; +} + +const customers = createFilter()({ + name: (customer) => customer.name, + status: { + get: (customer) => customer.status, + normalize: (text) => text.replaceAll("-", "").trim().toLowerCase(), + }, +}); + +describe("parse", () => { + test("treats every string as JSON text", () => { + expect(customers.parse("not JSON")).toMatchObject({ + ok: false, + issues: [{ code: "invalid-json", path: [] }], + }); + expect(customers.parse('"name"')).toMatchObject({ + ok: false, + issues: [{ code: "invalid-shape", path: [] }], + }); + }); + + test.each([null, "null"])("accepts the explicit no-filter value %#", (input) => { + expect(customers.parse(input)).toEqual({ ok: true, expression: null }); + }); + + test("rebuilds canonical plain data without rewriting or deduplicating needles", () => { + const tokens = ["Open", "open"]; + const predicate = { + field: "status", + operator: "one-of", + value: tokens, + not: undefined, + }; + const group = { all: [predicate] }; + const input = { v: 1, any: [group] }; + + const result = customers.parse(input); + + expect(result).toEqual({ + ok: true, + expression: { + v: 1, + any: [{ all: [{ field: "status", operator: "one-of", value: ["Open", "open"] }] }], + }, + }); + if (!result.ok || result.expression === null) { + expect.unreachable("expected a canonical expression"); + } + const rebuiltGroup = result.expression.any[0]; + const rebuiltPredicate = rebuiltGroup?.all[0]; + expect(result.expression).not.toBe(input); + expect(result.expression.any).not.toBe(input.any); + expect(rebuiltGroup).not.toBe(group); + expect(rebuiltGroup?.all).not.toBe(group.all); + expect(rebuiltPredicate).not.toBe(predicate); + expect( + rebuiltPredicate && "value" in rebuiltPredicate ? rebuiltPredicate.value : undefined, + ).not.toBe(tokens); + expect(rebuiltPredicate).not.toHaveProperty("not"); + expect(Object.getPrototypeOf(result.expression)).toBe(Object.prototype); + }); + + test("rejects sparse expression containers instead of blessing holes", () => { + expect(customers.parse({ v: 1, any: Array(1) })).toMatchObject({ + ok: false, + issues: [{ code: "invalid-shape", path: ["any", 0] }], + }); + expect(customers.parse({ v: 1, any: [{ all: Array(1) }] })).toMatchObject({ + ok: false, + issues: [{ code: "invalid-shape", path: ["any", 0, "all", 0] }], + }); + }); + + test("rebuilds array subclasses as plain arrays", () => { + class InputArray extends Array {} + const all = new InputArray({ field: "name", operator: "empty" }); + const any = new InputArray({ all }); + + const result = customers.parse({ v: 1, any }); + + expect(result).toMatchObject({ ok: true }); + if (!result.ok || result.expression === null) { + expect.unreachable("expected a canonical expression"); + } + expect(Object.getPrototypeOf(result.expression.any)).toBe(Array.prototype); + expect(Object.getPrototypeOf(result.expression.any[0]?.all)).toBe(Array.prototype); + }); + + test("reports unknown keys where they occur in document order", () => { + expect(customers.parse({ extra: true, v: 2, any: [] })).toMatchObject({ + ok: false, + issues: [ + { code: "invalid-shape", path: ["extra"] }, + { code: "unsupported-version", path: ["v"] }, + { code: "empty-expression", path: ["any"] }, + ], + }); + }); + + test("rejects non-enumerable unknown string keys", () => { + const input = { v: 1, any: [{ all: [{ field: "name", operator: "empty" }] }] }; + Object.defineProperty(input, "extra", { value: true }); + + expect(customers.parse(input)).toMatchObject({ + ok: false, + issues: [{ code: "invalid-shape", path: ["extra"] }], + }); + }); + + test.each([ + [ + "a missing version", + { any: [{ all: [{ field: "name", operator: "empty" }] }] }, + "invalid-shape", + ["v"], + ], + [ + "a future version", + { v: 2, any: [{ all: [{ field: "name", operator: "empty" }] }] }, + "unsupported-version", + ["v"], + ], + ["a non-array any", { v: 1, any: {} }, "invalid-shape", ["any"]], + ["an empty expression", { v: 1, any: [] }, "empty-expression", ["any"]], + ["a non-object group", { v: 1, any: [null] }, "invalid-shape", ["any", 0]], + ["an empty group", { v: 1, any: [{ all: [] }] }, "empty-group", ["any", 0, "all"]], + [ + "an unknown root key", + { v: 1, any: [{ all: [{ field: "name", operator: "empty" }] }], extra: true }, + "invalid-shape", + ["extra"], + ], + [ + "an unknown group key", + { v: 1, any: [{ all: [{ field: "name", operator: "empty" }], extra: true }] }, + "invalid-shape", + ["any", 0, "extra"], + ], + [ + "an unknown predicate key", + { v: 1, any: [{ all: [{ field: "name", operator: "empty", extra: true }] }] }, + "invalid-shape", + ["any", 0, "all", 0, "extra"], + ], + ] as const)("rejects %s", (_case, input, code, path) => { + expect(customers.parse(input)).toMatchObject({ ok: false, issues: [{ code, path }] }); + }); + + test("reports independent reachable issues in document order", () => { + const result = customers.parse({ + v: 1, + any: [ + { + all: [ + { field: "retired", operator: "unknown", value: false, not: false }, + null, + { field: "name", operator: "equals", value: " " }, + { field: "status", operator: "one-of", value: ["open", " - ", 4] }, + { field: "name", operator: "empty", value: "surplus" }, + ], + }, + { all: [] }, + ], + }); + + expect(result).toMatchObject({ + ok: false, + issues: [ + { code: "unknown-field", path: ["any", 0, "all", 0, "field"] }, + { code: "unknown-operator", path: ["any", 0, "all", 0, "operator"] }, + { code: "invalid-value", path: ["any", 0, "all", 0, "not"] }, + { code: "invalid-shape", path: ["any", 0, "all", 1] }, + { code: "empty-needle", path: ["any", 0, "all", 2, "value"] }, + { code: "empty-needle", path: ["any", 0, "all", 3, "value", 1] }, + { code: "invalid-value", path: ["any", 0, "all", 3, "value", 2] }, + { code: "invalid-value", path: ["any", 0, "all", 4, "value"] }, + { code: "empty-group", path: ["any", 1, "all"] }, + ], + }); + }); + + test.each([ + ["missing field", { operator: "empty" }, "invalid-shape", ["field"]], + ["non-string field", { field: 1, operator: "empty" }, "invalid-shape", ["field"]], + ["missing operator", { field: "name" }, "invalid-shape", ["operator"]], + [ + "scalar one-of", + { field: "name", operator: "one-of", value: "Ada" }, + "invalid-value", + ["value"], + ], + ["empty one-of", { field: "name", operator: "one-of", value: [] }, "empty-needle", ["value"]], + ["missing scalar value", { field: "name", operator: "equals" }, "invalid-value", ["value"]], + ["false not", { field: "name", operator: "empty", not: false }, "invalid-value", ["not"]], + ] as const)("rejects predicate with %s", (_case, predicate, code, suffix) => { + expect(customers.parse({ v: 1, any: [{ all: [predicate] }] })).toMatchObject({ + ok: false, + issues: [{ code, path: ["any", 0, "all", 0, ...suffix] }], + }); + }); +}); diff --git a/tests/readme.test.ts b/tests/readme.test.ts new file mode 100644 index 0000000..1321f04 --- /dev/null +++ b/tests/readme.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "vite-plus/test"; +import { createFilter, type FilterRow } from "../src/index.js"; + +interface Customer { + account?: { name: string }; + status?: string; + name: string; + plan: "free" | "basic" | "pro"; +} + +const customers = createFilter()({ + account: (customer) => customer.account?.name, + status: (customer) => customer.status, + name: (customer) => customer.name, + plan: (customer) => customer.plan, +}); + +const rows = [ + { field: "account", operator: "contains", value: "acme" }, + { + join: "and", + field: "status", + operator: "one-of", + value: ["closed", "spam"], + not: true, + }, + { join: "or", field: "name", operator: "starts-with", value: "test" }, + { join: "and", field: "plan", operator: "equals", value: "pro" }, +] satisfies readonly FilterRow[]; + +const records: Customer[] = [ + { account: { name: "Acme North" }, status: "open", name: "Alice", plan: "basic" }, + { account: { name: "Other" }, status: "closed", name: "Test Labs", plan: "pro" }, + { account: { name: "Other" }, status: "closed", name: "Test Garage", plan: "free" }, +]; + +describe("README examples", () => { + test("groups the four-row bug as any-of-all instead of left to right", () => { + const built = customers.fromRows(rows); + if (!built.ok) { + expect.unreachable("the documented rows should form a valid expression"); + } + + expect(built.expression).toEqual({ + v: 1, + any: [ + { + all: [ + { field: "account", operator: "contains", value: "acme" }, + { + field: "status", + operator: "one-of", + value: ["closed", "spam"], + not: true, + }, + ], + }, + { + all: [ + { field: "name", operator: "starts-with", value: "test" }, + { field: "plan", operator: "equals", value: "pro" }, + ], + }, + ], + }); + expect(customers.filter(records, built.expression)).toEqual([records[0], records[1]]); + }); + + test("restores saved expressions and distinguishes a missing key from saved null", () => { + const built = customers.fromRows(rows); + if (!built.ok) { + expect.unreachable("the documented rows should form a valid expression"); + } + + const saved = JSON.stringify(built.expression); + expect(customers.parse(saved)).toEqual(built); + + const missingStorageKey: string | null = null; + const savedNoFilter = JSON.stringify(null); + expect(missingStorageKey).toBeNull(); + expect(savedNoFilter).toBe("null"); + expect(customers.parse(savedNoFilter)).toEqual({ ok: true, expression: null }); + }); + + test("preserves the input reference exactly when filtering excludes nothing", () => { + const noFilter = customers.filter(records, null); + const allMatch = customers.filter(records, { + v: 1, + any: [{ all: [{ field: "name", operator: "contains", value: "e" }] }], + }); + + expect(noFilter).toBe(records); + expect(allMatch).toBe(records); + + const nobodyIsEmpty = customers.filter(records, { + v: 1, + any: [{ all: [{ field: "name", operator: "empty" }] }], + }); + expect(nobodyIsEmpty).toEqual([]); + expect(nobodyIsEmpty).not.toBe(records); + }); +}); diff --git a/tests/rows.test.ts b/tests/rows.test.ts new file mode 100644 index 0000000..53b98fd --- /dev/null +++ b/tests/rows.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "vite-plus/test"; +import { createFilter, type FilterRow } from "../src/index.js"; + +interface Customer { + name: string; + status?: string; +} + +const customers = createFilter()({ + name: (customer) => customer.name, + status: { + get: (customer) => customer.status, + normalize: (text) => text.replaceAll("-", "").trim().toLowerCase(), + }, +}); + +const predicate = (field: "name" | "status", value: string) => ({ + field, + operator: "equals" as const, + value, +}); + +describe("fromRows", () => { + test("segments at each non-leading or before validating rows", () => { + expect( + customers.fromRows([ + { join: "or", ...predicate("name", "A") }, + { ...predicate("status", "B") }, + { join: "or", ...predicate("name", "C") }, + { join: "or", ...predicate("status", "D") }, + { join: "and", ...predicate("name", "E") }, + ]), + ).toEqual({ + ok: true, + expression: { + v: 1, + any: [ + { all: [predicate("name", "A"), predicate("status", "B")] }, + { all: [predicate("name", "C")] }, + { all: [predicate("status", "D"), predicate("name", "E")] }, + ], + }, + }); + }); + + test.each([ + ["a missing field", { operator: "empty" }], + ["an unknown field", { field: "retired", operator: "empty" }], + ["a missing operator", { field: "name" }], + ["an unknown operator", { field: "name", operator: "matches", value: "Ada" }], + ["a number scalar value", { field: "name", operator: "equals", value: 42 }], + ["a boolean scalar value", { field: "name", operator: "contains", value: true }], + ["a post-normalization empty scalar", { field: "status", operator: "equals", value: " - " }], + ["a value on empty", { field: "name", operator: "empty", value: "Ada" }], + ["a scalar one-of value", { field: "name", operator: "one-of", value: "Ada" }], + ["an empty one-of list", { field: "name", operator: "one-of", value: [] }], + ["a malformed one-of token", { field: "name", operator: "one-of", value: ["Ada", 42] }], + [ + "a post-normalization empty one-of token", + { field: "status", operator: "one-of", value: ["-"] }, + ], + ["a junk join", { join: "xor", field: "name", operator: "empty" }], + ["a non-boolean not", { not: "yes", field: "name", operator: "empty" }], + ])("rejects %s as one incomplete row", (_case, row) => { + expect(customers.fromRows([row as FilterRow])).toMatchObject({ + ok: false, + issues: [{ code: "incomplete-row", path: [0] }], + }); + }); + + test("rejects every incomplete row by default in source order", () => { + const rows = [ + { field: "name" }, + { field: "name", operator: "equals", value: 42 }, + { field: "retired", operator: "empty" }, + ] as readonly FilterRow[]; + + expect(customers.fromRows(rows)).toMatchObject({ + ok: false, + issues: [ + { code: "incomplete-row", path: [0] }, + { code: "incomplete-row", path: [1] }, + { code: "incomplete-row", path: [2] }, + ], + }); + expect(customers.fromRows(rows, { incomplete: "reject" })).toEqual(customers.fromRows(rows)); + }); + + test("does not turn a malformed runtime policy into discard mode", () => { + expect(customers.fromRows([{ field: "name" }], { incomplete: "typo" } as never)).toMatchObject({ + ok: false, + issues: [{ code: "incomplete-row", path: [0] }], + }); + }); + + test("emits not only for true", () => { + expect( + customers.fromRows([ + { ...predicate("name", "A"), not: true }, + { ...predicate("name", "B"), not: false }, + predicate("name", "C"), + ]), + ).toMatchObject({ + ok: true, + expression: { + any: [ + { + all: [ + { ...predicate("name", "A"), not: true }, + predicate("name", "B"), + predicate("name", "C"), + ], + }, + ], + }, + }); + }); + + test("requires an own not value to be boolean and ignores inherited flags", () => { + expect( + customers.fromRows([{ field: "name", operator: "empty", not: undefined } as never]), + ).toMatchObject({ + ok: false, + issues: [{ code: "incomplete-row", path: [0] }], + }); + + const inherited = Object.assign(Object.create({ not: true }) as object, { + field: "name", + operator: "empty", + }) as FilterRow; + expect(customers.fromRows([inherited])).toEqual({ + ok: true, + expression: { v: 1, any: [{ all: [{ field: "name", operator: "empty" }] }] }, + }); + }); + + test("discards invalid rows without changing the groups around them", () => { + expect( + customers.fromRows( + [ + predicate("name", "A"), + { join: "or", field: "name" }, + { join: "and", ...predicate("name", "B") }, + ], + { incomplete: "discard" }, + ), + ).toEqual({ + ok: true, + expression: { + v: 1, + any: [{ all: [predicate("name", "A")] }, { all: [predicate("name", "B")] }], + }, + }); + }); + + test("drops emptied groups and returns null when no predicate survives", () => { + expect( + customers.fromRows( + [ + { field: "name" }, + { join: "or", ...predicate("name", "A") }, + { join: "or", field: "status", operator: "one-of", value: [] }, + ], + { incomplete: "discard" }, + ), + ).toEqual({ + ok: true, + expression: { v: 1, any: [{ all: [predicate("name", "A")] }] }, + }); + expect(customers.fromRows([], { incomplete: "discard" })).toEqual({ + ok: true, + expression: null, + }); + expect(customers.fromRows([{ field: "name" }], { incomplete: "discard" })).toEqual({ + ok: true, + expression: null, + }); + }); + + test("returns fresh canonical plain data while preserving raw needles", () => { + const tokens = [" Open ", "open"]; + const row = { field: "status", operator: "one-of", value: tokens, not: false } as const; + + const result = customers.fromRows([row]); + + expect(result).toEqual({ + ok: true, + expression: { + v: 1, + any: [{ all: [{ field: "status", operator: "one-of", value: [" Open ", "open"] }] }], + }, + }); + if (!result.ok || result.expression === null) { + expect.unreachable("expected an expression"); + } + const group = result.expression.any[0]; + const built = group?.all[0]; + expect(result.expression).not.toBe(row); + expect(built).not.toBe(row); + expect(built && "value" in built ? built.value : undefined).not.toBe(tokens); + expect(built).not.toHaveProperty("not"); + expect(Object.getPrototypeOf(result.expression)).toBe(Object.prototype); + expect(Object.getPrototypeOf(result.expression.any)).toBe(Array.prototype); + expect(Object.getPrototypeOf(group)).toBe(Object.prototype); + expect(Object.getPrototypeOf(group?.all)).toBe(Array.prototype); + expect(Object.getPrototypeOf(built)).toBe(Object.prototype); + }); +}); diff --git a/tests/types.ts b/tests/types.ts new file mode 100644 index 0000000..30c7225 --- /dev/null +++ b/tests/types.ts @@ -0,0 +1,128 @@ +import { + createFilter, + type Expression, + type ExpressionResult, + type FieldDef, + type Filter, + type FilterRow, + type Issue, + type Operator, + type Predicate, +} from "../src/index.js"; + +interface Customer { + name: string; + status?: string; +} + +const customerFields = { + name: (customer: Customer) => customer.name, + status: { + get: (customer: Customer) => customer.status, + normalize: (text: string) => text.trim(), + }, + structuralMetadataIsAllowed: { + get: (customer: Customer) => customer.name, + description: "TypeScript object types are structural", + }, +}; + +const fieldDefinition: FieldDef = customerFields.name; +void fieldDefinition; + +const customers = createFilter()(customerFields); +const typedFilter: Filter = customers; +void typedFilter; + +const expression: Expression<"name" | "status"> = { + v: 1, + any: [ + { + all: [ + { field: "name", operator: "contains", value: "acme" }, + { field: "status", operator: "empty", not: true }, + ], + }, + ], +}; +void expression; + +const oneOf: Predicate<"status"> = { + field: "status", + operator: "one-of", + value: ["open", "pending"], +}; +void oneOf; + +const operator: Operator = "starts-with"; +void operator; + +const looseRow: FilterRow = { + field: "not-necessarily-registered-yet", + operator: "still-being-edited", + value: { draft: true }, +}; +void looseRow; + +const result: ExpressionResult<"name"> = { ok: true, expression: null }; +void result; + +const issue: Issue = { + code: "unknown-field", + path: ["any", 0, "all", 0, "field"], + message: "human-readable only", +}; +void issue; + +const emptyWithValue: Predicate = { + field: "status", + operator: "empty", + // @ts-expect-error empty predicates carry no value + value: "", +}; +void emptyWithValue; + +const oneOfWithScalar: Predicate = { + field: "status", + operator: "one-of", + // @ts-expect-error one-of predicates require an array of strings + value: "open", +}; +void oneOfWithScalar; + +const valueOperatorWithoutValue: Predicate = { + field: "status", + operator: "equals", + // @ts-expect-error value operators require a string value + value: undefined, +}; +void valueOperatorWithoutValue; + +const falseNegation: Predicate = { + field: "status", + operator: "empty", + // @ts-expect-error negation is represented only by the literal true + not: false, +}; +void falseNegation; + +customers.compile({ + v: 1, + any: [{ all: [{ field: "name", operator: "equals", value: "Ada" }] }], +}); + +customers.compile({ + v: 1, + any: [ + { + all: [ + { + // @ts-expect-error configured field names remain narrow + field: "missing", + operator: "equals", + value: "Ada", + }, + ], + }, + ], +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8bd361d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2024", + "lib": ["es2024"], + "moduleDetection": "force", + "module": "nodenext", + "moduleResolution": "nodenext", + "types": [], + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..25cae3c --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + // Keeps editor/raw tsc coverage for Node-run files while tsconfig.json + // remains the shipped-source contract with no Node ambient globals. + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["tests", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..03c2aed --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + pack: { + dts: { + tsgo: true, + }, + // The hand-written map keeps the public type condition explicit. + exports: false, + }, + lint: { + options: { + typeAware: true, + typeCheck: true, + }, + }, + fmt: {}, +}); From 050e3c08f1a948af2673ae24c786f30e2290b665 Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 12:41:01 +0200 Subject: [PATCH 02/13] fixes --- AGENTS.md | 36 ++++++++++++++++++++++++++++++++---- README.md | 37 +++++++++++++++++++++++++++++++++++-- package.json | 2 +- src/rows.ts | 41 +++++++++++++++++++++++++---------------- src/types.ts | 38 +++++++++++++++++++++++++++++++++++--- src/validation.ts | 9 +++++++-- tests/readme.test.ts | 31 +++++++++++++++++++++++++++++++ tests/rows.test.ts | 31 +++++++++++++++++++++++++++++++ 8 files changed, 197 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1e2fdb5..0e36d0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ version-locked runner. - **The language stays closed.** Six operators plus the orthogonal `not` flag; custom behavior belongs in declared field normalizers, never in expressions. + This is a security boundary, not a style preference. - **DNF stays fixed-depth.** Do not add recursive groups or left-to-right reduction; the two-level shape is the protection against grouping ambiguity. - **Fields are declared capabilities.** Expressions select registry names; the @@ -37,12 +38,39 @@ version-locked runner. - **Segment before discarding rows.** An invalid row may be removed in interactive mode, but it must never alter the groups around it. - **Stored data stays data.** Parsing rebuilds fresh canonical plain objects; - evaluation-time normalization never rewrites saved needles. + evaluation-time normalization never rewrites saved needles. Token arrays + keep their order and duplicates; deduplication exists only in a compiled + `one-of` set. +- **Empty has one meaning.** A value is empty exactly when resolution yields no + matchable texts. Every operator and its negation derive boundary behavior + from that definition rather than special-casing cells. - **Preserve reference no-ops.** `filter` returns its input array when every record survives, and returns a fresh array only when something is excluded. -- **Remain pure and synchronous.** Inputs are never mutated. Accessor and - normalizer failures propagate so programmer bugs are not disguised as empty - fields. +- **Remain pure, deterministic, and synchronous.** No clock, locale or `Intl`, + global state, or input mutation. Accessor and normalizer failures propagate + so programmer bugs are not disguised as empty fields. + +## Design principles + +The library's value is partly the vocabulary it refuses to grow. Hold these +boundaries when recurring feature requests appear: + +- **Regex operators:** no. They create a ReDoS surface as soon as expressions + are untrusted data. +- **User-defined operators:** no. Data that carries or names code stops being + portable, storable, and trustable. Custom behavior is a registry normalizer, + versioned with the application. +- **Nesting:** no. JSON Logic already serves recursive expression trees. Fixed + DNF plus per-predicate `not` keeps grouping explicit, at worst with some + predicate duplication. +- **Path strings:** accessors already provide compiler checks without adding a + grammar, prototype-safety policy, or getter semantics. If data-driven + registries eventually need convenience, a helper that returns an accessor is + the v1-compatible direction. + +Prefer removing code to adding it. Every option, operator, and public export +must earn its maintenance cost. Anything that changes the expression grammar +increments the wire version independently of package semver. ## Comments diff --git a/README.md b/README.md index acf7fa0..e9661e9 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,26 @@ The double call is deliberate. TypeScript cannot partially infer type arguments: the first call fixes `Customer`, while the second infers the literal field names from the registry. +Accessors make record projection visible and type-checked. A field can also +define the text equivalence its domain needs; normalization always applies to +both record text and the compiled needle: + +```ts +const places = createFilter<{ city: string }>()({ + city: { + get: (place) => place.city, + normalize: (text) => text.trim().toLowerCase().normalize("NFD").replaceAll(/\p{M}/gu, ""), + }, +}); + +const inAarhus = places.compile({ + v: 1, + any: [{ all: [{ field: "city", operator: "equals", value: "Arhus" }] }], +}); + +inAarhus({ city: "Århus" }); // true +``` + Now fold the flat rows from a filter UI into an expression: ```ts @@ -116,6 +136,8 @@ if (raw === null) { } else { const restored = customers.parse(raw); if (!restored.ok) { + // For example, a removed `plan` field is reported at + // ["any", 1, "all", 1, "field"] with code "unknown-field". showStaleView(restored.issues); } else { render(customers.filter(records, restored.expression)); @@ -237,8 +259,19 @@ predicate; empty containers are rejected rather than given vacuous meanings. - `filter` preserves order and returns its input array by reference exactly when no record is excluded; otherwise it returns a fresh array. -There is deliberately no regex, arbitrary nesting, user-defined operator, -field-path grammar, rendering, table integration, URL state, sorting, +Expressions carry `v: 1`. A grammar change increments the wire version even +when package semver would not otherwise require a major release. A future +reader must either continue accepting every older grammar it supersets or ship +an explicit migration. + +The expression language deliberately has no regex, arbitrary nesting, +user-defined operators, or field-path grammar. Regex becomes a ReDoS surface +when expressions are untrusted data. An expression that names executable +behavior stops being portable and trustable. Recursive boolean trees are the +problem JSON Logic already solves, while declared accessors avoid a path +grammar and its prototype-safety policy entirely. + +The package also provides no rendering, table integration, URL state, sorting, pagination, server adapter, query-language compilation, fuzzy search, numeric comparison, or date comparison. diff --git a/package.json b/package.json index 8b2b569..9db6adb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@b2m9/anyall", "version": "0.1.0", - "description": "Evaluate serializable any-of-all filter expressions over declared record fields.", + "description": "Build, store, and evaluate serializable DNF filters over declared record fields.", "keywords": [ "boolean-expression", "disjunctive-normal-form", diff --git a/src/rows.ts b/src/rows.ts index c763350..7cfcfb3 100644 --- a/src/rows.ts +++ b/src/rows.ts @@ -7,6 +7,10 @@ interface IndexedRow { readonly row: FilterRow; } +type RowConversion = + | { readonly ok: true; readonly predicate: Predicate } + | { readonly ok: false; readonly message: string }; + export function expressionFromRows( rows: readonly FilterRow[], registry: FieldRegistry, @@ -18,15 +22,15 @@ export function expressionFromRows( for (const slice of segmentRows(rows)) { const all: Predicate[] = []; for (const { index, row } of slice) { - const predicate = predicateFromRow(row, registry); - if (predicate === undefined) { + const converted = predicateFromRow(row, registry); + if (!converted.ok) { issues.push({ code: "incomplete-row", path: [index], - message: "row does not form a valid predicate", + message: converted.message, }); } else { - all.push(predicate); + all.push(converted.predicate); } } if (all.length > 0) { @@ -56,9 +60,16 @@ function segmentRows(rows: readonly FilterRow[]): IndexedRow[][] { function predicateFromRow( row: FilterRow, registry: FieldRegistry, -): Predicate | undefined { - if (!isObject(row) || !hasValidJoin(row) || !hasValidNot(row)) { - return undefined; +): RowConversion { + if (!isObject(row)) { + return { ok: false, message: "row must be an object" }; + } + + if (row.join !== undefined && row.join !== "and" && row.join !== "or") { + return { ok: false, message: 'join must be "and" or "or" when present' }; + } + if (Object.hasOwn(row, "not") && typeof row.not !== "boolean") { + return { ok: false, message: "not must be a boolean when present" }; } const candidate: Record = {}; @@ -71,15 +82,13 @@ function predicateFromRow( // A shared boundary keeps rows and stored data from growing subtly // different definitions of a predicate. - return validatePredicateInput(candidate, registry); -} - -function hasValidJoin(row: Record): boolean { - return row.join === undefined || row.join === "and" || row.join === "or"; -} - -function hasValidNot(row: Record): boolean { - return !Object.hasOwn(row, "not") || typeof row.not === "boolean"; + const validated = validatePredicateInput(candidate, registry); + if (!validated.ok) { + // Rows expose one issue by index; combining predicate reasons preserves + // that stable cardinality without reducing the message to a generic fault. + return { ok: false, message: validated.issues.map((issue) => issue.message).join("; ") }; + } + return { ok: true, predicate: validated.predicate }; } function copyOwn( diff --git a/src/types.ts b/src/types.ts index 3341dcb..4d69e1a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,13 +1,23 @@ -/** A declared record capability, optionally with domain-specific text normalization. */ +/** + * A declared record capability: an accessor function, or an object when the + * field also needs domain-specific text normalization. + */ export type FieldDef = | ((record: R) => unknown) | { + /** Reads the field value from a record. Must be pure and total. */ readonly get: (record: R) => unknown; - /** Must be pure; comparison applies it to both record texts and needles. */ + /** + * Applied to record texts and needles at evaluation time. Must be pure + * and total; defaults to trimming and lowercasing. + */ readonly normalize?: (text: string) => string; }; -/** A value-bearing or empty predicate from the package's closed vocabulary. */ +/** + * One field test from the package's closed vocabulary. Operator/value + * mismatches are unrepresentable; `not: true` negates any base result. + */ export type Predicate = | { readonly field: Name; @@ -46,7 +56,9 @@ export interface Expression { /** Deliberately loose UI state; conversion is the validation boundary. */ export interface FilterRow { + /** Starts a new group only when literally `"or"`. */ readonly join?: "and" | "or"; + /** `true` negates the predicate; `false` and absence do not. */ readonly not?: boolean; readonly field?: string; readonly operator?: string; @@ -66,6 +78,7 @@ export interface Issue { | "invalid-value" | "empty-needle" | "incomplete-row"; + /** Into an expression, or the source row index for `incomplete-row`. */ readonly path: ReadonlyArray; /** Human-readable context; consumers should branch on `code` and `path`. */ readonly message: string; @@ -78,14 +91,33 @@ export type ExpressionResult = /** A filter surface bound to one declared record capability registry. */ export interface Filter { + /** + * Segments flat UI rows at each `join: "or"`, then validates within those + * groups. Incomplete rows reject by default; `"discard"` removes them only + * after grouping. Zero surviving groups produce the no-filter value `null`. + */ fromRows( rows: readonly FilterRow[], options?: { readonly incomplete?: "reject" | "discard" }, ): ExpressionResult; + /** + * Compiles an expression into a reusable record predicate. `null` compiles + * to a constant-true predicate. Invalid asserted input throws + * `AnyallExpressionError` with the issues `parse` would return. + */ compile(expression: Expression | null): (record: R) => boolean; + /** + * Filters without reordering records. Returns the input array by reference + * exactly when no record is excluded; invalid input throws as `compile` does. + */ filter(records: readonly R[], expression: Expression | null): readonly R[]; + /** + * Restores JSON text or an already-parsed value. A string is always treated + * as JSON text. Success returns fresh canonical data; ordinary invalid input + * returns structured issues, and `null` is the valid no-filter value. + */ parse(input: unknown): ExpressionResult; } diff --git a/src/validation.ts b/src/validation.ts index 6bdb03d..a3d72b5 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -22,14 +22,19 @@ interface ValidationContext { export function validatePredicateInput( input: unknown, registry: FieldRegistry, -): Predicate | undefined { +): + | { readonly ok: true; readonly predicate: Predicate } + | { readonly ok: false; readonly issues: readonly Issue[] } { const context: ValidationContext = { registry, issues: [], captureNeedle: undefined, }; const predicate = validatePredicate(input, [], context); - return context.issues.length === 0 ? predicate : undefined; + if (context.issues.length > 0 || predicate === undefined) { + return { ok: false, issues: context.issues }; + } + return { ok: true, predicate }; } export function validateExpression( diff --git a/tests/readme.test.ts b/tests/readme.test.ts index 1321f04..70f9e38 100644 --- a/tests/readme.test.ts +++ b/tests/readme.test.ts @@ -15,6 +15,13 @@ const customers = createFilter()({ plan: (customer) => customer.plan, }); +const places = createFilter<{ city: string }>()({ + city: { + get: (place) => place.city, + normalize: (text) => text.trim().toLowerCase().normalize("NFD").replaceAll(/\p{M}/gu, ""), + }, +}); + const rows = [ { field: "account", operator: "contains", value: "acme" }, { @@ -35,6 +42,15 @@ const records: Customer[] = [ ]; describe("README examples", () => { + test("applies a custom normalizer to both record text and the compiled needle", () => { + const inAarhus = places.compile({ + v: 1, + any: [{ all: [{ field: "city", operator: "equals", value: "Arhus" }] }], + }); + + expect(inAarhus({ city: "Århus" })).toBe(true); + }); + test("groups the four-row bug as any-of-all instead of left to right", () => { const built = customers.fromRows(rows); if (!built.ok) { @@ -75,6 +91,21 @@ describe("README examples", () => { const saved = JSON.stringify(built.expression); expect(customers.parse(saved)).toEqual(built); + const customersWithoutPlan = createFilter()({ + account: (customer) => customer.account?.name, + status: (customer) => customer.status, + name: (customer) => customer.name, + }); + expect(customersWithoutPlan.parse(saved)).toMatchObject({ + ok: false, + issues: [ + { + code: "unknown-field", + path: ["any", 1, "all", 1, "field"], + }, + ], + }); + const missingStorageKey: string | null = null; const savedNoFilter = JSON.stringify(null); expect(missingStorageKey).toBeNull(); diff --git a/tests/rows.test.ts b/tests/rows.test.ts index 53b98fd..c7591ad 100644 --- a/tests/rows.test.ts +++ b/tests/rows.test.ts @@ -86,6 +86,37 @@ describe("fromRows", () => { expect(customers.fromRows(rows, { incomplete: "reject" })).toEqual(customers.fromRows(rows)); }); + test("explains why each incomplete row cannot form a predicate", () => { + expect( + customers.fromRows([ + { field: "retired", operator: "empty" }, + { field: "name", operator: "equals", value: 42 }, + { join: "xor", field: "name", operator: "empty" } as never, + { not: "yes", field: "name", operator: "empty" } as never, + ]), + ).toEqual({ + ok: false, + issues: [ + { code: "incomplete-row", path: [0], message: 'unknown field "retired"' }, + { + code: "incomplete-row", + path: [1], + message: "equals requires a string value", + }, + { + code: "incomplete-row", + path: [2], + message: 'join must be "and" or "or" when present', + }, + { + code: "incomplete-row", + path: [3], + message: "not must be a boolean when present", + }, + ], + }); + }); + test("does not turn a malformed runtime policy into discard mode", () => { expect(customers.fromRows([{ field: "name" }], { incomplete: "typo" } as never)).toMatchObject({ ok: false, From 718f30aa23b9b8bb36afe2d7e571152571a3b6ff Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 12:54:28 +0200 Subject: [PATCH 03/13] Simplify validation and predicate compilation --- AGENTS.md | 5 ++- README.md | 5 ++- src/evaluate.ts | 38 +++++++---------------- src/validation.ts | 70 ++++++++---------------------------------- tests/evaluate.test.ts | 13 +++++--- tests/parse.test.ts | 42 ++++++++++++++++++++----- 6 files changed, 75 insertions(+), 98 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0e36d0d..2433e5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,8 @@ version-locked runner. confirm it fails for the intended reason, then implement only enough to pass. - Keep tests table-driven where the contract is a semantics matrix. Use property tests for laws such as round trips, purity, and reference identity. -- Treat `code` and `path` in issues as stable API. Message wording is not. +- Treat `code` and `path` in issues as stable API. Message wording and issue + position are not. ## Constraints to preserve @@ -35,6 +36,8 @@ version-locked runner. empty groups are invalid rather than acquiring vacuous truth values. - **Validation has one source of truth.** `parse`, `compile`, and row conversion must not drift into subtly different definitions of a valid predicate. +- **Foreign versions stop validation.** A reader cannot make useful claims + about a grammar it does not understand; report only `unsupported-version`. - **Segment before discarding rows.** An invalid row may be removed in interactive mode, but it must never alter the groups around it. - **Stored data stays data.** Parsing rebuilds fresh canonical plain objects; diff --git a/README.md b/README.md index e9661e9..ab25a97 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,10 @@ latter as `JSON.stringify(null)`, which yields the JSON text `"null"`; returns fresh canonical plain objects while preserving needle text. Invalid JSON and ordinary invalid data become issues, provided the declared normalizers honor their pure-and-total contract; reflective exotica are outside that -guarantee. +guarantee. Issues follow grammar and array order rather than object key +insertion order. Consumers should branch on `code` and `path`, not issue +position or message text. An unsupported wire version is reported alone because +the reader cannot safely judge the rest of a foreign grammar. ## API diff --git a/src/evaluate.ts b/src/evaluate.ts index c6fcb65..9e0c48d 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -9,12 +9,7 @@ export function compileExpression( expression: Expression | null, registry: FieldRegistry, ): (record: R) => boolean { - const normalizedNeedles: string[] = []; - // Validation already normalizes needles to reject empty ones; capturing that - // exact result avoids duplicate domain work at the compile boundary. - const result = validateExpression(expression, registry, (needle) => { - normalizedNeedles.push(needle); - }); + const result = validateExpression(expression, registry); if (!result.ok) { throw new AnyallExpressionError("invalid filter expression", result.issues); } @@ -22,13 +17,8 @@ export function compileExpression( return () => true; } - let needleIndex = 0; const groups = result.expression.any.map((group) => - group.all.map((predicate) => { - const compiled = compilePredicate(predicate, registry, normalizedNeedles, needleIndex); - needleIndex = compiled.nextNeedleIndex; - return compiled.matches; - }), + group.all.map((predicate) => compilePredicate(predicate, registry)), ); return (record) => groups.some((group) => group.every((matches) => matches(record))); @@ -54,53 +44,47 @@ export function filterRecords( function compilePredicate( predicate: Predicate, registry: FieldRegistry, - normalizedNeedles: readonly string[], - needleIndex: number, -): { readonly matches: CompiledPredicate; readonly nextNeedleIndex: number } { +): CompiledPredicate { const field = registry.get(predicate.field)!; let base: CompiledPredicate; + // Each predicate owns its compiled needles; validation and compilation + // never depend on matching traversal positions. switch (predicate.operator) { case "empty": base = (record) => resolveTexts(field.get(record), field).length === 0; break; case "one-of": { - const needles = new Set( - normalizedNeedles.slice(needleIndex, needleIndex + predicate.value.length), - ); - needleIndex += predicate.value.length; + const needles = new Set(predicate.value.map((needle) => field.normalize(needle))); base = (record) => resolveTexts(field.get(record), field).some((text) => needles.has(text)); break; } case "equals": { - const needle = normalizedNeedles[needleIndex++]!; + const needle = field.normalize(predicate.value); base = (record) => resolveTexts(field.get(record), field).some((text) => text === needle); break; } case "contains": { - const needle = normalizedNeedles[needleIndex++]!; + const needle = field.normalize(predicate.value); base = (record) => resolveTexts(field.get(record), field).some((text) => text.includes(needle)); break; } case "starts-with": { - const needle = normalizedNeedles[needleIndex++]!; + const needle = field.normalize(predicate.value); base = (record) => resolveTexts(field.get(record), field).some((text) => text.startsWith(needle)); break; } case "ends-with": { - const needle = normalizedNeedles[needleIndex++]!; + const needle = field.normalize(predicate.value); base = (record) => resolveTexts(field.get(record), field).some((text) => text.endsWith(needle)); break; } } - return { - matches: predicate.not === true ? (record) => !base(record) : base, - nextNeedleIndex: needleIndex, - }; + return predicate.not === true ? (record) => !base(record) : base; } function resolveTexts(value: unknown, field: RegisteredField): string[] { diff --git a/src/validation.ts b/src/validation.ts index a3d72b5..2e04f13 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -16,7 +16,6 @@ type Path = ReadonlyArray; interface ValidationContext { readonly registry: FieldRegistry; readonly issues: Issue[]; - readonly captureNeedle: ((needle: string) => void) | undefined; } export function validatePredicateInput( @@ -28,7 +27,6 @@ export function validatePredicateInput( const context: ValidationContext = { registry, issues: [], - captureNeedle: undefined, }; const predicate = validatePredicate(input, [], context); if (context.issues.length > 0 || predicate === undefined) { @@ -40,16 +38,17 @@ export function validatePredicateInput( export function validateExpression( input: unknown, registry: FieldRegistry, - captureNeedle?: (needle: string) => void, ): ExpressionResult { if (input === null) { return { ok: true, expression: null }; } - const context: ValidationContext = { registry, issues: [], captureNeedle }; + const context: ValidationContext = { registry, issues: [] }; + // Validators append in grammar order, so object key insertion order cannot + // rearrange diagnostics. const expression = validateRoot(input, context); if (context.issues.length > 0 || expression === undefined) { - return { ok: false, issues: orderByDocumentPath(input, context.issues) }; + return { ok: false, issues: context.issues }; } return { ok: true, expression }; } @@ -63,10 +62,13 @@ function validateRoot( return undefined; } - if (!Object.hasOwn(input, "v")) { + if (!Object.hasOwn(input, "v") || input.v === undefined) { addIssue(context, "invalid-shape", ["v"], "version is required"); } else if (input.v !== 1) { addIssue(context, "unsupported-version", ["v"], "only expression version 1 is supported"); + // A foreign version names a foreign grammar, so deeper judgments would be + // guesses rather than actionable validation. + return undefined; } let any: Array<{ readonly all: readonly Predicate[] } | undefined> | undefined; @@ -236,10 +238,7 @@ function validateValue( addIssue(context, "invalid-value", valuePath, `${operator} requires a string value`); return invalidValue; } - if ( - field !== undefined && - normalizeNeedle(input.value, field, context.registry, context.captureNeedle) === "" - ) { + if (field !== undefined && normalizeNeedle(input.value, field, context.registry) === "") { addIssue(context, "empty-needle", valuePath, "needle is empty after normalization"); return invalidValue; } @@ -264,10 +263,7 @@ function validateValue( continue; } tokens.push(token); - if ( - field !== undefined && - normalizeNeedle(token, field, context.registry, context.captureNeedle) === "" - ) { + if (field !== undefined && normalizeNeedle(token, field, context.registry) === "") { addIssue( context, "empty-needle", @@ -301,11 +297,8 @@ function normalizeNeedle( needle: string, field: Name, registry: FieldRegistry, - captureNeedle?: (needle: string) => void, ): string { - const normalized = registry.get(field)!.normalize(needle); - captureNeedle?.(normalized); - return normalized; + return registry.get(field)!.normalize(needle); } function rejectUnknownKeys( @@ -314,6 +307,7 @@ function rejectUnknownKeys( path: Path, context: ValidationContext, ): void { + // Known grammar fields are judged before extras regardless of source order. for (const key of Object.getOwnPropertyNames(input)) { if (!allowed.has(key)) { addIssue(context, "invalid-shape", [...path, key], `unknown key ${JSON.stringify(key)}`); @@ -321,46 +315,6 @@ function rejectUnknownKeys( } } -function orderByDocumentPath(input: unknown, issues: readonly Issue[]): Issue[] { - return issues - .map((issue, index) => ({ issue, index })) - .sort((left, right) => { - const order = compareDocumentPaths(input, left.issue.path, right.issue.path); - return order === 0 ? left.index - right.index : order; - }) - .map(({ issue }) => issue); -} - -function compareDocumentPaths(input: unknown, left: Path, right: Path): number { - let parent = input; - const sharedLength = Math.min(left.length, right.length); - - for (let index = 0; index < sharedLength; index += 1) { - const leftPart = left[index]; - const rightPart = right[index]; - if (leftPart !== rightPart) { - if (Array.isArray(parent) && typeof leftPart === "number" && typeof rightPart === "number") { - return leftPart - rightPart; - } - if (isObject(parent) && typeof leftPart === "string" && typeof rightPart === "string") { - const keys = Object.getOwnPropertyNames(parent); - const leftIndex = keys.indexOf(leftPart); - const rightIndex = keys.indexOf(rightPart); - if (leftIndex >= 0 && rightIndex >= 0) { - return leftIndex - rightIndex; - } - } - return 0; - } - - if ((Array.isArray(parent) || isObject(parent)) && leftPart !== undefined) { - parent = parent[leftPart as keyof typeof parent]; - } - } - - return left.length - right.length; -} - function addIssue( context: ValidationContext, code: Issue["code"], diff --git a/tests/evaluate.test.ts b/tests/evaluate.test.ts index 0a245e5..40ed99a 100644 --- a/tests/evaluate.test.ts +++ b/tests/evaluate.test.ts @@ -208,7 +208,7 @@ describe("compile", () => { }, ); - test("normalizes record texts and each needle exactly once with the field normalizer", () => { + test("normalizes needles at compile time and record texts only when evaluated", () => { const calls: string[] = []; const normalized = createFilter()({ value: { @@ -229,11 +229,16 @@ describe("compile", () => { ], }); - expect(calls).toEqual([" A-B ", "ab", "NOPE"]); + const compileCalls = [...calls]; + expect(compileCalls).toContain(" A-B "); + expect(compileCalls).toContain("ab"); + expect(compileCalls).toContain("NOPE"); + expect(matches({ value: "a b" })).toBe(true); - expect(calls).toEqual([" A-B ", "ab", "NOPE", "a b"]); + expect(calls.slice(compileCalls.length)).toEqual(["a b"]); + expect(matches({ value: "a-b" })).toBe(true); - expect(calls).toEqual([" A-B ", "ab", "NOPE", "a b", "a-b"]); + expect(calls.slice(compileCalls.length)).toEqual(["a b", "a-b"]); }); test("uses one-level some semantics without coercing objects", () => { diff --git a/tests/parse.test.ts b/tests/parse.test.ts index ec48f1a..58e2d08 100644 --- a/tests/parse.test.ts +++ b/tests/parse.test.ts @@ -93,14 +93,42 @@ describe("parse", () => { expect(Object.getPrototypeOf(result.expression.any[0]?.all)).toBe(Array.prototype); }); - test("reports unknown keys where they occur in document order", () => { - expect(customers.parse({ extra: true, v: 2, any: [] })).toMatchObject({ + test("reports an unsupported wire version without judging a foreign grammar", () => { + const result = customers.parse({ extra: true, v: 2, any: [] }); + + expect(result).toMatchObject({ ok: false, - issues: [ - { code: "invalid-shape", path: ["extra"] }, - { code: "unsupported-version", path: ["v"] }, + issues: [{ code: "unsupported-version", path: ["v"] }], + }); + if (result.ok) expect.unreachable("expected an unsupported version"); + expect(result.issues).toHaveLength(1); + }); + + test("reports reachable issues in grammar order, independent of object key order", () => { + for (const input of [ + { extra: true, any: [] }, + { any: [], extra: true }, + ]) { + const result = customers.parse(input); + if (result.ok) expect.unreachable("expected invalid input"); + + expect(result.issues.map(({ code, path }) => ({ code, path }))).toEqual([ + { code: "invalid-shape", path: ["v"] }, { code: "empty-expression", path: ["any"] }, - ], + { code: "invalid-shape", path: ["extra"] }, + ]); + } + }); + + test("treats an undefined version as a missing required value", () => { + expect( + customers.parse({ + v: undefined, + any: [{ all: [{ field: "name", operator: "empty" }] }], + }), + ).toMatchObject({ + ok: false, + issues: [{ code: "invalid-shape", path: ["v"] }], }); }); @@ -153,7 +181,7 @@ describe("parse", () => { expect(customers.parse(input)).toMatchObject({ ok: false, issues: [{ code, path }] }); }); - test("reports independent reachable issues in document order", () => { + test("reports independent reachable issues in grammar order", () => { const result = customers.parse({ v: 1, any: [ From 7624eb609aff8d97938bdd3a4d636d5f468a07e6 Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 13:21:45 +0200 Subject: [PATCH 04/13] Clarify undefined boundary semantics --- AGENTS.md | 2 ++ README.md | 9 +++++++-- src/rows.ts | 6 ++++-- src/types.ts | 1 + src/validation.ts | 34 ++++++++++++---------------------- tests/parse.test.ts | 26 ++++++++++++++++++++++++++ tests/rows.test.ts | 28 +++++++++++++++++++++++----- 7 files changed, 75 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2433e5d..9fe7ea9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,8 @@ version-locked runner. evaluation-time normalization never rewrites saved needles. Token arrays keep their order and duplicates; deduplication exists only in a compiled `one-of` set. +- **Explicit `undefined` means absence.** Required properties still fail as + missing; successful canonical output omits optional undefined properties. - **Empty has one meaning.** A value is empty exactly when resolution yields no matchable texts. Every operator and its negation derive boundary behavior from that definition rather than special-casing cells. diff --git a/README.md b/README.md index ab25a97..5ad0362 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ field names from the registry. Accessors make record projection visible and type-checked. A field can also define the text equivalence its domain needs; normalization always applies to -both record text and the compiled needle: +both record text and the compiled search text, or needle: ```ts const places = createFilter<{ city: string }>()({ @@ -175,6 +175,9 @@ filter.filter(records, expression); // readonly RecordType[] filter.parse(jsonOrValue); ``` +`filter` validates and compiles its expression on every call. When evaluating +the same expression repeatedly, call `compile` once and reuse the predicate. + `fromRows` and `parse` return one result shape: ```ts @@ -238,7 +241,9 @@ Incomplete rows reject the whole conversion by default. For a live filter drawer, `{ incomplete: "discard" }` removes invalid rows after grouping, drops groups left empty, and returns `expression: null` if nothing survives. Row values stay strict: scalar operators require a string, and `one-of` requires a -string array. Record values are the only values coerced to text. +string array. An explicit `undefined` behaves like an omitted property; required +properties remain invalid when omitted. Record values are the only values +coerced to text. Expressions require at least one group and every group requires at least one predicate; empty containers are rejected rather than given vacuous meanings. diff --git a/src/rows.ts b/src/rows.ts index 7cfcfb3..34ed58c 100644 --- a/src/rows.ts +++ b/src/rows.ts @@ -46,6 +46,8 @@ export function expressionFromRows( function segmentRows(rows: readonly FilterRow[]): IndexedRow[][] { const slices: IndexedRow[][] = [[]]; + // Materializing the sequence visits holes so sparse UI state is reported + // as incomplete instead of silently changing the authored filter. Array.from(rows).forEach((row, index) => { // Boundaries come from the authored sequence, so later discards cannot // silently move a predicate into a different boolean group. @@ -68,7 +70,7 @@ function predicateFromRow( if (row.join !== undefined && row.join !== "and" && row.join !== "or") { return { ok: false, message: 'join must be "and" or "or" when present' }; } - if (Object.hasOwn(row, "not") && typeof row.not !== "boolean") { + if (Object.hasOwn(row, "not") && row.not !== undefined && typeof row.not !== "boolean") { return { ok: false, message: "not must be a boolean when present" }; } @@ -96,7 +98,7 @@ function copyOwn( target: Record, key: "field" | "operator" | "value", ): void { - if (Object.hasOwn(source, key)) { + if (Object.hasOwn(source, key) && source[key] !== undefined) { target[key] = source[key]; } } diff --git a/src/types.ts b/src/types.ts index 4d69e1a..135c17c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -111,6 +111,7 @@ export interface Filter { /** * Filters without reordering records. Returns the input array by reference * exactly when no record is excluded; invalid input throws as `compile` does. + * Validates and compiles on each call; reuse `compile` for repeated evaluation. */ filter(records: readonly R[], expression: Expression | null): readonly R[]; diff --git a/src/validation.ts b/src/validation.ts index 2e04f13..9abda27 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -139,12 +139,7 @@ function validatePredicate( const not = validateNot(input, path, context); rejectUnknownKeys(input, new Set(["field", "operator", "value", "not"]), path, context); - if ( - field === undefined || - operator === undefined || - value === invalidValue || - not === invalidNot - ) { + if (field === undefined || operator === undefined) { return undefined; } if (operator === "empty") { @@ -206,52 +201,49 @@ function validateOperator( return input.operator as Operator; } -const invalidValue = Symbol("invalid value"); - function validateValue( input: Record, field: Name | undefined, operator: Operator | undefined, path: Path, context: ValidationContext, -): string | readonly string[] | undefined | typeof invalidValue { +): string | readonly string[] | undefined { if (operator === undefined) { return undefined; } const valuePath = [...path, "value"]; if (operator === "empty") { - if (Object.hasOwn(input, "value")) { + if (Object.hasOwn(input, "value") && input.value !== undefined) { addIssue(context, "invalid-value", valuePath, "empty predicates do not accept a value"); - return invalidValue; } return undefined; } - if (!Object.hasOwn(input, "value")) { + if (!Object.hasOwn(input, "value") || input.value === undefined) { addIssue(context, "invalid-value", valuePath, `${operator} requires a value`); - return invalidValue; + return undefined; } if (scalarOperators.has(operator)) { if (typeof input.value !== "string") { addIssue(context, "invalid-value", valuePath, `${operator} requires a string value`); - return invalidValue; + return undefined; } if (field !== undefined && normalizeNeedle(input.value, field, context.registry) === "") { addIssue(context, "empty-needle", valuePath, "needle is empty after normalization"); - return invalidValue; + return undefined; } return input.value; } if (!Array.isArray(input.value)) { addIssue(context, "invalid-value", valuePath, "one-of requires an array of strings"); - return invalidValue; + return undefined; } if (input.value.length === 0) { addIssue(context, "empty-needle", valuePath, "one-of requires at least one needle"); - return invalidValue; + return undefined; } let valid = true; @@ -273,22 +265,20 @@ function validateValue( valid = false; } } - return valid ? tokens : invalidValue; + return valid ? tokens : undefined; } -const invalidNot = Symbol("invalid not"); - function validateNot( input: Record, path: Path, context: ValidationContext, -): true | undefined | typeof invalidNot { +): true | undefined { if (!Object.hasOwn(input, "not") || input.not === undefined) { return undefined; } if (input.not !== true) { addIssue(context, "invalid-value", [...path, "not"], "not must be true when present"); - return invalidNot; + return undefined; } return true; } diff --git a/tests/parse.test.ts b/tests/parse.test.ts index 58e2d08..2493d41 100644 --- a/tests/parse.test.ts +++ b/tests/parse.test.ts @@ -132,6 +132,32 @@ describe("parse", () => { }); }); + test("treats undefined predicate properties as absent", () => { + expect( + customers.parse({ + v: 1, + any: [ + { + all: [{ field: "name", operator: "empty", value: undefined, not: undefined }], + }, + ], + }), + ).toEqual({ + ok: true, + expression: { v: 1, any: [{ all: [{ field: "name", operator: "empty" }] }] }, + }); + + expect( + customers.parse({ + v: 1, + any: [{ all: [{ field: "name", operator: "equals", value: undefined }] }], + }), + ).toMatchObject({ + ok: false, + issues: [{ code: "invalid-value", path: ["any", 0, "all", 0, "value"] }], + }); + }); + test("rejects non-enumerable unknown string keys", () => { const input = { v: 1, any: [{ all: [{ field: "name", operator: "empty" }] }] }; Object.defineProperty(input, "extra", { value: true }); diff --git a/tests/rows.test.ts b/tests/rows.test.ts index c7591ad..1bba3ee 100644 --- a/tests/rows.test.ts +++ b/tests/rows.test.ts @@ -50,6 +50,7 @@ describe("fromRows", () => { ["an unknown operator", { field: "name", operator: "matches", value: "Ada" }], ["a number scalar value", { field: "name", operator: "equals", value: 42 }], ["a boolean scalar value", { field: "name", operator: "contains", value: true }], + ["an undefined scalar value", { field: "name", operator: "equals", value: undefined }], ["a post-normalization empty scalar", { field: "status", operator: "equals", value: " - " }], ["a value on empty", { field: "name", operator: "empty", value: "Ada" }], ["a scalar one-of value", { field: "name", operator: "one-of", value: "Ada" }], @@ -147,14 +148,24 @@ describe("fromRows", () => { }); }); - test("requires an own not value to be boolean and ignores inherited flags", () => { + test("treats explicit undefined row state as absent", () => { expect( - customers.fromRows([{ field: "name", operator: "empty", not: undefined } as never]), - ).toMatchObject({ - ok: false, - issues: [{ code: "incomplete-row", path: [0] }], + customers.fromRows([ + { + join: undefined, + field: "name", + operator: "empty", + value: undefined, + not: undefined, + } as never, + ]), + ).toEqual({ + ok: true, + expression: { v: 1, any: [{ all: [{ field: "name", operator: "empty" }] }] }, }); + }); + test("ignores inherited negation flags", () => { const inherited = Object.assign(Object.create({ not: true }) as object, { field: "name", operator: "empty", @@ -165,6 +176,13 @@ describe("fromRows", () => { }); }); + test("reports sparse rows instead of silently dropping holes", () => { + expect(customers.fromRows(Array(1))).toEqual({ + ok: false, + issues: [{ code: "incomplete-row", path: [0], message: "row must be an object" }], + }); + }); + test("discards invalid rows without changing the groups around them", () => { expect( customers.fromRows( From 3344cd5e7110e9188627c297410639d0519d2a84 Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 13:30:38 +0200 Subject: [PATCH 05/13] Validate custom normalizer results --- AGENTS.md | 3 ++- README.md | 14 ++++++++------ src/errors.ts | 2 +- src/fields.ts | 15 ++++++++++++++- src/types.ts | 5 +++-- src/validation.ts | 4 ++-- tests/config.test.ts | 24 ++++++++++++++++++++++++ tests/parse.test.ts | 2 +- 8 files changed, 55 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9fe7ea9..5202d2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,8 @@ version-locked runner. record survives, and returns a fresh array only when something is excluded. - **Remain pure, deterministic, and synchronous.** No clock, locale or `Intl`, global state, or input mutation. Accessor and normalizer failures propagate - so programmer bugs are not disguised as empty fields. + so programmer bugs are not disguised as empty fields; a non-string normalizer + result is malformed configuration, never a matchable value. ## Design principles diff --git a/README.md b/README.md index 5ad0362..f80492a 100644 --- a/README.md +++ b/README.md @@ -153,10 +153,11 @@ latter as `JSON.stringify(null)`, which yields the JSON text `"null"`; returns fresh canonical plain objects while preserving needle text. Invalid JSON and ordinary invalid data become issues, provided the declared normalizers honor their pure-and-total contract; reflective exotica are outside that -guarantee. Issues follow grammar and array order rather than object key -insertion order. Consumers should branch on `code` and `path`, not issue -position or message text. An unsupported wire version is reported alone because -the reader cannot safely judge the rest of a foreign grammar. +guarantee. Issues for known fields follow grammar and array order before extra +keys; extra-key ordering is not normalized. Consumers should branch on `code` +and `path`, not issue position or message text. An unsupported wire version is +reported alone because the reader cannot safely judge the rest of a foreign +grammar. ## API @@ -253,8 +254,9 @@ predicate; empty containers are rejected rather than given vacuous meanings. - Field names and their meaning are a saved-view contract. When renaming a field, keep the old name as an alias; changing an accessor or normalizer can silently change what existing views match. -- Accessors and normalizers must be pure and total. Their errors propagate so - programmer bugs are not disguised as empty fields. +- Accessors and normalizers must be pure and total, and normalizers must return + strings. Their errors propagate; a non-string result throws + `AnyallConfigError` rather than becoming a matchable value. - V1 is text-only. There are no numeric or date comparisons, and numbers are matched as strings: `contains "4"` matches `42`. - Lowercasing is deterministic, not locale-aware collation. Supply a custom diff --git a/src/errors.ts b/src/errors.ts index 868f17d..fe35c26 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,6 +1,6 @@ import type { Issue } from "./types.js"; -/** Thrown by `createFilter` when the field registry cannot form a valid filter. */ +/** Thrown when field registry configuration cannot form a valid filter. */ export class AnyallConfigError extends Error { override readonly name = "AnyallConfigError"; } diff --git a/src/fields.ts b/src/fields.ts index 2f35a23..c2d0973 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -49,7 +49,20 @@ function registerField(name: string, definition: unknown): RegisteredField if (candidate.normalize === undefined) { return { get, normalize: defaultNormalize }; } - return { get, normalize: candidate.normalize as (text: string) => string }; + + const normalize = candidate.normalize as (text: string) => unknown; + return { + get, + normalize: (text) => { + const normalized = normalize(text); + // A shared non-string result can make unrelated values compare equal or + // fail later inside a string operator. + if (typeof normalized !== "string") { + throw invalidField(name, "normalize must return a string"); + } + return normalized; + }, + }; } function defaultNormalize(text: string): string { diff --git a/src/types.ts b/src/types.ts index 135c17c..05fba11 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,8 +8,9 @@ export type FieldDef = /** Reads the field value from a record. Must be pure and total. */ readonly get: (record: R) => unknown; /** - * Applied to record texts and needles at evaluation time. Must be pure - * and total; defaults to trimming and lowercasing. + * Applied to needles during validation and compilation, and to record + * texts during evaluation. Must be pure and total; defaults to trimming + * and lowercasing. */ readonly normalize?: (text: string) => string; }; diff --git a/src/validation.ts b/src/validation.ts index 9abda27..df45116 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -44,8 +44,8 @@ export function validateExpression( } const context: ValidationContext = { registry, issues: [] }; - // Validators append in grammar order, so object key insertion order cannot - // rearrange diagnostics. + // Known fields are judged in grammar order before extras, keeping actionable + // diagnostics stable without sorting caller-owned keys. const expression = validateRoot(input, context); if (context.issues.length > 0 || expression === undefined) { return { ok: false, issues: context.issues }; diff --git a/tests/config.test.ts b/tests/config.test.ts index df3ca02..63cf4a3 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -57,4 +57,28 @@ describe("field configuration", () => { expect(error).toMatchObject({ name: "AnyallConfigError" }); } }); + + test("rejects non-string normalizer results when they are observed", () => { + const invalidNeedle = configure({ + name: { + get: (customer) => customer.name, + normalize: (() => undefined) as never, + }, + }); + const expression = { + v: 1 as const, + any: [{ all: [{ field: "name" as const, operator: "equals" as const, value: "needle" }] }], + }; + + expect(() => invalidNeedle.compile(expression)).toThrow(AnyallConfigError); + + const invalidRecord = configure({ + name: { + get: (customer) => customer.name, + normalize: ((text: string) => (text === "needle" ? text : undefined)) as never, + }, + }).compile(expression); + + expect(() => invalidRecord({ name: "record" })).toThrow(AnyallConfigError); + }); }); diff --git a/tests/parse.test.ts b/tests/parse.test.ts index 2493d41..96c3d1a 100644 --- a/tests/parse.test.ts +++ b/tests/parse.test.ts @@ -104,7 +104,7 @@ describe("parse", () => { expect(result.issues).toHaveLength(1); }); - test("reports reachable issues in grammar order, independent of object key order", () => { + test("reports grammar issues before extras regardless of source key order", () => { for (const input of [ { extra: true, any: [] }, { any: [], extra: true }, From 93d9ff0f83f7afda3fde3e3394ada7ed4843073c Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 14:00:43 +0200 Subject: [PATCH 06/13] Harden filter input boundaries --- AGENTS.md | 5 +++-- README.md | 11 ++++++----- src/errors.ts | 2 +- src/fields.ts | 3 +-- src/filter.ts | 9 ++++++--- src/rows.ts | 14 ++++++++++++-- src/types.ts | 4 +++- src/validation.ts | 18 +++++++++++------- tests/parse.test.ts | 13 +++++++++++++ tests/rows.test.ts | 32 ++++++++++++++++++++++++++------ tests/types.ts | 23 +++++++++++++++++++++++ 11 files changed, 105 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5202d2a..111f3de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,8 +36,9 @@ version-locked runner. empty groups are invalid rather than acquiring vacuous truth values. - **Validation has one source of truth.** `parse`, `compile`, and row conversion must not drift into subtly different definitions of a valid predicate. -- **Foreign versions stop validation.** A reader cannot make useful claims - about a grammar it does not understand; report only `unsupported-version`. +- **Well-formed foreign versions stop validation.** A reader cannot make useful + claims about a positive-integer grammar version it does not understand; + malformed versions are V1 shape issues, not `unsupported-version`. - **Segment before discarding rows.** An invalid row may be removed in interactive mode, but it must never alter the groups around it. - **Stored data stays data.** Parsing rebuilds fresh canonical plain objects; diff --git a/README.md b/README.md index f80492a..8cc4b9c 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,9 @@ JSON and ordinary invalid data become issues, provided the declared normalizers honor their pure-and-total contract; reflective exotica are outside that guarantee. Issues for known fields follow grammar and array order before extra keys; extra-key ordering is not normalized. Consumers should branch on `code` -and `path`, not issue position or message text. An unsupported wire version is -reported alone because the reader cannot safely judge the rest of a foreign -grammar. +and `path`, not issue position or message text. A well-formed unsupported wire +version is reported alone because the reader cannot safely judge the rest of a +foreign grammar. ## API @@ -257,8 +257,9 @@ predicate; empty containers are rejected rather than given vacuous meanings. - Accessors and normalizers must be pure and total, and normalizers must return strings. Their errors propagate; a non-string result throws `AnyallConfigError` rather than becoming a matchable value. -- V1 is text-only. There are no numeric or date comparisons, and numbers are - matched as strings: `contains "4"` matches `42`. +- V1 is text-only. There are no numeric or date comparisons. Numbers use + JavaScript's `String` representation, so `contains "4"` matches `42`; project + a formatted string when notation matters. - Lowercasing is deterministic, not locale-aware collation. Supply a custom normalizer when your domain needs another text equivalence. - Objects never coerce to `"[object Object]"`. Project a label or identifier in diff --git a/src/errors.ts b/src/errors.ts index fe35c26..a2d6af6 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,6 +1,6 @@ import type { Issue } from "./types.js"; -/** Thrown when field registry configuration cannot form a valid filter. */ +/** Thrown when filter configuration cannot form a valid filter. */ export class AnyallConfigError extends Error { override readonly name = "AnyallConfigError"; } diff --git a/src/fields.ts b/src/fields.ts index c2d0973..53f5f32 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -1,5 +1,4 @@ import { AnyallConfigError } from "./errors.js"; -import type { FieldDef } from "./types.js"; export interface RegisteredField { readonly get: (record: R) => unknown; @@ -10,7 +9,7 @@ export type FieldRegistry = ReadonlyMap( - fields: Readonly>>, + fields: unknown, ): FieldRegistry { if (typeof fields !== "object" || fields === null || Array.isArray(fields)) { throw new AnyallConfigError("fields must be an object of named accessors"); diff --git a/src/filter.ts b/src/filter.ts index 438975d..b55d930 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -5,15 +5,18 @@ import type { FieldRegistry } from "./fields.js"; import { expressionFromRows } from "./rows.js"; import type { FieldDef, Filter } from "./types.js"; +// Object keys reach the runtime registry in their string form. +type StringKeyOf = `${Extract}`; + /** * Bind an explicit record type first so the field map can still infer its * literal names; TypeScript cannot partially infer one generic call. */ export function createFilter(): >>( fields: F, -) => Filter { - return >>(fields: F): Filter => { - const registry = createFieldRegistry(fields); +) => Filter> { + return >>(fields: F): Filter> => { + const registry = createFieldRegistry>(fields); return createBoundFilter(registry); }; } diff --git a/src/rows.ts b/src/rows.ts index 34ed58c..59dddd6 100644 --- a/src/rows.ts +++ b/src/rows.ts @@ -1,3 +1,4 @@ +import { AnyallConfigError } from "./errors.js"; import type { FieldRegistry } from "./fields.js"; import type { ExpressionResult, FilterRow, Issue, Predicate } from "./types.js"; import { validatePredicateInput } from "./validation.js"; @@ -16,6 +17,10 @@ export function expressionFromRows( registry: FieldRegistry, incomplete: "reject" | "discard" = "reject", ): ExpressionResult { + if (incomplete !== "reject" && incomplete !== "discard") { + throw new AnyallConfigError('incomplete must be "reject" or "discard"'); + } + const issues: Issue[] = []; const any: Array<{ readonly all: readonly Predicate[] }> = []; @@ -51,7 +56,7 @@ function segmentRows(rows: readonly FilterRow[]): IndexedRow[][] { Array.from(rows).forEach((row, index) => { // Boundaries come from the authored sequence, so later discards cannot // silently move a predicate into a different boolean group. - if (index > 0 && isObject(row) && row.join === "or") { + if (index > 0 && isObject(row) && Object.hasOwn(row, "join") && row.join === "or") { slices.push([]); } slices[slices.length - 1]!.push({ index, row }); @@ -67,7 +72,12 @@ function predicateFromRow( return { ok: false, message: "row must be an object" }; } - if (row.join !== undefined && row.join !== "and" && row.join !== "or") { + if ( + Object.hasOwn(row, "join") && + row.join !== undefined && + row.join !== "and" && + row.join !== "or" + ) { return { ok: false, message: 'join must be "and" or "or" when present' }; } if (Object.hasOwn(row, "not") && row.not !== undefined && typeof row.not !== "boolean") { diff --git a/src/types.ts b/src/types.ts index 05fba11..e2780ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,7 +95,8 @@ export interface Filter { /** * Segments flat UI rows at each `join: "or"`, then validates within those * groups. Incomplete rows reject by default; `"discard"` removes them only - * after grouping. Zero surviving groups produce the no-filter value `null`. + * after grouping. Zero surviving groups produce the no-filter value `null`; + * malformed options throw `AnyallConfigError`. */ fromRows( rows: readonly FilterRow[], @@ -120,6 +121,7 @@ export interface Filter { * Restores JSON text or an already-parsed value. A string is always treated * as JSON text. Success returns fresh canonical data; ordinary invalid input * returns structured issues, and `null` is the valid no-filter value. + * Normalizer failures and configuration contract violations propagate. */ parse(input: unknown): ExpressionResult; } diff --git a/src/validation.ts b/src/validation.ts index df45116..d6987cf 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -62,31 +62,35 @@ function validateRoot( return undefined; } - if (!Object.hasOwn(input, "v") || input.v === undefined) { + const version = Object.hasOwn(input, "v") ? input.v : undefined; + if (version === undefined) { addIssue(context, "invalid-shape", ["v"], "version is required"); - } else if (input.v !== 1) { + } else if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) { + addIssue(context, "invalid-shape", ["v"], "version must be a positive safe integer"); + } else if (version !== 1) { addIssue(context, "unsupported-version", ["v"], "only expression version 1 is supported"); // A foreign version names a foreign grammar, so deeper judgments would be // guesses rather than actionable validation. return undefined; } + const anyInput = Object.hasOwn(input, "any") ? input.any : undefined; let any: Array<{ readonly all: readonly Predicate[] } | undefined> | undefined; - if (!Object.hasOwn(input, "any")) { + if (anyInput === undefined) { addIssue(context, "invalid-shape", ["any"], "any is required"); - } else if (!Array.isArray(input.any)) { + } else if (!Array.isArray(anyInput)) { addIssue(context, "invalid-shape", ["any"], "any must be an array"); - } else if (input.any.length === 0) { + } else if (anyInput.length === 0) { addIssue(context, "empty-expression", ["any"], "an expression must contain a group"); any = []; } else { // Array.from both visits holes and refuses an input subclass's species. - any = Array.from(input.any, (group, index) => validateGroup(group, ["any", index], context)); + any = Array.from(anyInput, (group, index) => validateGroup(group, ["any", index], context)); } rejectUnknownKeys(input, new Set(["v", "any"]), [], context); - if (input.v !== 1 || any === undefined || any.some((group) => group === undefined)) { + if (version !== 1 || any === undefined || any.some((group) => group === undefined)) { return undefined; } return { v: 1, any: any as Array<{ readonly all: readonly Predicate[] }> }; diff --git a/tests/parse.test.ts b/tests/parse.test.ts index 96c3d1a..4843b42 100644 --- a/tests/parse.test.ts +++ b/tests/parse.test.ts @@ -132,6 +132,19 @@ describe("parse", () => { }); }); + test.each([null, "1", 0, true, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "reports malformed version %# as an invalid shape", + (version) => { + expect(customers.parse({ v: version, any: "junk" })).toMatchObject({ + ok: false, + issues: [ + { code: "invalid-shape", path: ["v"] }, + { code: "invalid-shape", path: ["any"] }, + ], + }); + }, + ); + test("treats undefined predicate properties as absent", () => { expect( customers.parse({ diff --git a/tests/rows.test.ts b/tests/rows.test.ts index 1bba3ee..4e66cf6 100644 --- a/tests/rows.test.ts +++ b/tests/rows.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { createFilter, type FilterRow } from "../src/index.js"; +import { AnyallConfigError, createFilter, type FilterRow } from "../src/index.js"; interface Customer { name: string; @@ -118,11 +118,10 @@ describe("fromRows", () => { }); }); - test("does not turn a malformed runtime policy into discard mode", () => { - expect(customers.fromRows([{ field: "name" }], { incomplete: "typo" } as never)).toMatchObject({ - ok: false, - issues: [{ code: "incomplete-row", path: [0] }], - }); + test("rejects a malformed runtime policy explicitly", () => { + expect(() => + customers.fromRows([predicate("name", "A")], { incomplete: "typo" } as never), + ).toThrow(AnyallConfigError); }); test("emits not only for true", () => { @@ -176,6 +175,27 @@ describe("fromRows", () => { }); }); + test("ignores inherited join values", () => { + const inheritedOr = Object.assign(Object.create({ join: "or" }) as object, { + ...predicate("name", "B"), + }) as FilterRow; + const inheritedJunk = Object.assign(Object.create({ join: "xor" }) as object, { + ...predicate("status", "C"), + }) as FilterRow; + + expect(customers.fromRows([predicate("name", "A"), inheritedOr, inheritedJunk])).toEqual({ + ok: true, + expression: { + v: 1, + any: [ + { + all: [predicate("name", "A"), predicate("name", "B"), predicate("status", "C")], + }, + ], + }, + }); + }); + test("reports sparse rows instead of silently dropping holes", () => { expect(customers.fromRows(Array(1))).toEqual({ ok: false, diff --git a/tests/types.ts b/tests/types.ts index 30c7225..f5f8aa1 100644 --- a/tests/types.ts +++ b/tests/types.ts @@ -34,6 +34,29 @@ const customers = createFilter()(customerFields); const typedFilter: Filter = customers; void typedFilter; +const numberedFields = createFilter()({ 0: (customer) => customer.name }); +const numberedFilter: Filter = numberedFields; +void numberedFilter; +numberedFields.compile({ + v: 1, + any: [{ all: [{ field: "0", operator: "equals", value: "Ada" }] }], +}); +numberedFields.compile({ + v: 1, + any: [ + { + all: [ + { + // @ts-expect-error numeric field keys become their runtime string form + field: "1", + operator: "equals", + value: "Ada", + }, + ], + }, + ], +}); + const expression: Expression<"name" | "status"> = { v: 1, any: [ From b8dc78d8001479b075d00eecada8feab84c1e371 Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 14:15:50 +0200 Subject: [PATCH 07/13] Tighten field configuration contract --- AGENTS.md | 7 ++++-- README.md | 56 +++++++++++++++++++++++++------------------- src/fields.ts | 6 +++++ src/types.ts | 16 +++++++------ tests/config.test.ts | 5 ++++ tests/types.ts | 11 +++++---- 6 files changed, 64 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 111f3de..c3c58ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,9 @@ version-locked runner. - **DNF stays fixed-depth.** Do not add recursive groups or left-to-right reduction; the two-level shape is the protection against grouping ambiguity. - **Fields are declared capabilities.** Expressions select registry names; the - package does not parse paths or inspect record structure itself. + package does not parse paths or inspect record structure itself. Definition + objects reject string keys beyond `get` and `normalize`; ignored keys would + hide typos. - **No-filter is explicit.** `null` matches everything. Empty expressions and empty groups are invalid rather than acquiring vacuous truth values. - **Validation has one source of truth.** `parse`, `compile`, and row conversion @@ -40,7 +42,8 @@ version-locked runner. claims about a positive-integer grammar version it does not understand; malformed versions are V1 shape issues, not `unsupported-version`. - **Segment before discarding rows.** An invalid row may be removed in - interactive mode, but it must never alter the groups around it. + interactive mode, but it must never alter the groups around it. A leading + join is ignored because it cannot separate two groups. - **Stored data stays data.** Parsing rebuilds fresh canonical plain objects; evaluation-time normalization never rewrites saved needles. Token arrays keep their order and duplicates; deduplication exists only in a compiled diff --git a/README.md b/README.md index 8cc4b9c..80f222a 100644 --- a/README.md +++ b/README.md @@ -57,26 +57,6 @@ The double call is deliberate. TypeScript cannot partially infer type arguments: the first call fixes `Customer`, while the second infers the literal field names from the registry. -Accessors make record projection visible and type-checked. A field can also -define the text equivalence its domain needs; normalization always applies to -both record text and the compiled search text, or needle: - -```ts -const places = createFilter<{ city: string }>()({ - city: { - get: (place) => place.city, - normalize: (text) => text.trim().toLowerCase().normalize("NFD").replaceAll(/\p{M}/gu, ""), - }, -}); - -const inAarhus = places.compile({ - v: 1, - any: [{ all: [{ field: "city", operator: "equals", value: "Arhus" }] }], -}); - -inAarhus({ city: "Århus" }); // true -``` - Now fold the flat rows from a filter UI into an expression: ```ts @@ -121,6 +101,31 @@ The expression is versioned plain data: Evaluation is exactly `any.some(group => group.all.every(predicate))`. There is no recursive tree and no ambiguous reduction order. +### Custom normalization + +Accessors make record projection visible and type-checked. A field can also +define the text equivalence its domain needs; normalization always applies to +both record text and the compiled search text, or needle: + +```ts +const places = createFilter<{ city: string }>()({ + city: { + get: (place) => place.city, + normalize: (text) => text.trim().toLowerCase().normalize("NFD").replaceAll(/\p{M}/gu, ""), + }, +}); + +const inAarhus = places.compile({ + v: 1, + any: [{ all: [{ field: "city", operator: "equals", value: "Arhus" }] }], +}); + +inAarhus({ city: "Århus" }); // true +``` + +Field-definition objects accept only `get` and `normalize` as string keys; +unknown string keys are configuration errors rather than ignored metadata. + ## Saved views Expressions need no serializer. Store them with `JSON.stringify`, then bring @@ -212,7 +217,8 @@ Values resolve to a list of matchable texts before an operator runs: | nested array element | none | The default normalizer trims and lowercases. A field is empty when resolution -yields no matchable text, including `""`, whitespace, `[]`, and `["", null]`. +yields no matchable text. With the default normalizer, this includes `""`, +whitespace, `[]`, and `["", null]`. | Operator | Base result | Empty field | | ------------- | -------------------------------------------- | ----------- | @@ -234,9 +240,11 @@ resolved only one level deep. ## Rows and grouping -`fromRows` segments the complete row sequence at each `join: "or"`, then -validates rows within those groups. This order is load-bearing: discarding an -invalid row must not pull later predicates into an earlier group. +`fromRows` segments the complete row sequence at each non-leading +`join: "or"`, then validates rows within those groups. The first row's join is +ignored because there is no preceding group to separate. This order is +load-bearing: discarding an invalid row must not pull later predicates into an +earlier group. Incomplete rows reject the whole conversion by default. For a live filter drawer, `{ incomplete: "discard" }` removes invalid rows after grouping, drops diff --git a/src/fields.ts b/src/fields.ts index 53f5f32..72b6318 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -37,6 +37,12 @@ function registerField(name: string, definition: unknown): RegisteredField } const candidate = definition as { get?: unknown; normalize?: unknown }; + // Ignored configuration turns misspellings into silently different filters. + for (const key of Object.getOwnPropertyNames(candidate)) { + if (key !== "get" && key !== "normalize") { + throw invalidField(name, `has unknown key ${JSON.stringify(key)}`); + } + } if (typeof candidate.get !== "function") { throw invalidField(name, "get must be a function"); } diff --git a/src/types.ts b/src/types.ts index e2780ea..f5cd08c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ /** - * A declared record capability: an accessor function, or an object when the - * field also needs domain-specific text normalization. + * A declared record capability: an accessor function, or an object with `get` + * and optional `normalize` when the field needs domain-specific text + * normalization. Other own string keys are rejected. */ export type FieldDef = | ((record: R) => unknown) @@ -57,7 +58,7 @@ export interface Expression { /** Deliberately loose UI state; conversion is the validation boundary. */ export interface FilterRow { - /** Starts a new group only when literally `"or"`. */ + /** Starts a new group only when literally `"or"`; ignored on the first row. */ readonly join?: "and" | "or"; /** `true` negates the predicate; `false` and absence do not. */ readonly not?: boolean; @@ -93,10 +94,11 @@ export type ExpressionResult = /** A filter surface bound to one declared record capability registry. */ export interface Filter { /** - * Segments flat UI rows at each `join: "or"`, then validates within those - * groups. Incomplete rows reject by default; `"discard"` removes them only - * after grouping. Zero surviving groups produce the no-filter value `null`; - * malformed options throw `AnyallConfigError`. + * Segments flat UI rows at each non-leading `join: "or"`, then validates + * within those groups. The first row's join is ignored. Incomplete rows reject + * by default; `"discard"` removes them only after grouping. Zero surviving + * groups produce the no-filter value `null`; malformed options throw + * `AnyallConfigError`. */ fromRows( rows: readonly FilterRow[], diff --git a/tests/config.test.ts b/tests/config.test.ts index 63cf4a3..50f9cf7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -44,6 +44,11 @@ describe("field configuration", () => { ["an object without get", {}], ["a non-function get", { get: "name" }], ["a non-function normalizer", { get: (customer: Customer) => customer.name, normalize: true }], + [ + "a misspelled normalizer", + { get: (customer: Customer) => customer.name, normalise: (text: string) => text }, + ], + ["extra metadata", { get: (customer: Customer) => customer.name, description: "Name" }], ])("rejects %s as a field definition", (_case, definition) => { expect(() => configure({ name: definition } as never)).toThrow(AnyallConfigError); }); diff --git a/tests/types.ts b/tests/types.ts index f5f8aa1..4484777 100644 --- a/tests/types.ts +++ b/tests/types.ts @@ -21,15 +21,18 @@ const customerFields = { get: (customer: Customer) => customer.status, normalize: (text: string) => text.trim(), }, - structuralMetadataIsAllowed: { - get: (customer: Customer) => customer.name, - description: "TypeScript object types are structural", - }, }; const fieldDefinition: FieldDef = customerFields.name; void fieldDefinition; +const fieldWithUnknownKey: FieldDef = { + get: (customer) => customer.name, + // @ts-expect-error field-definition metadata belongs outside the filter registry + description: "Name", +}; +void fieldWithUnknownKey; + const customers = createFilter()(customerFields); const typedFilter: Filter = customers; void typedFilter; From c016168afb77ca6d362b89432558beb58032101c Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 14:31:13 +0200 Subject: [PATCH 08/13] Focus executable laws --- tests/evaluate.test.ts | 60 +++++++++++++++++++++++++++ tests/laws.test.ts | 92 ++---------------------------------------- tests/rows.test.ts | 2 +- 3 files changed, 64 insertions(+), 90 deletions(-) diff --git a/tests/evaluate.test.ts b/tests/evaluate.test.ts index 40ed99a..e129428 100644 --- a/tests/evaluate.test.ts +++ b/tests/evaluate.test.ts @@ -90,6 +90,19 @@ const resolutionCases: readonly ResolutionCase[] = [ matchesValueOperators: true, isEmpty: false, }, + { + label: "zero", + value: 0, + needles: { + equals: "0", + contains: "0", + "starts-with": "0", + "ends-with": "0", + "one-of": ["1", "0"], + }, + matchesValueOperators: true, + isEmpty: false, + }, { label: "a boolean", value: true, @@ -103,6 +116,19 @@ const resolutionCases: readonly ResolutionCase[] = [ matchesValueOperators: true, isEmpty: false, }, + { + label: "false", + value: false, + needles: { + equals: "false", + contains: "als", + "starts-with": "fa", + "ends-with": "se", + "one-of": ["true", "false"], + }, + matchesValueOperators: true, + isEmpty: false, + }, { label: "a bigint", value: 42n, @@ -241,6 +267,40 @@ describe("compile", () => { expect(calls.slice(compileCalls.length)).toEqual(["a b", "a-b"]); }); + test("treats values removed by custom normalization as empty", () => { + const normalized = createFilter()({ + value: { + get: (box) => box.value, + normalize: (text) => text.replaceAll(/[\s-]/g, "").toLowerCase(), + }, + }); + const isEmpty = normalized.compile(expression({ field: "value", operator: "empty" })); + const isAlpha = normalized.compile( + expression({ field: "value", operator: "equals", value: "alpha" }), + ); + + expect(isEmpty({ value: " - " })).toBe(true); + expect(isEmpty({ value: ["--", " "] })).toBe(true); + expect(isAlpha({ value: ["--", " alpha "] })).toBe(true); + }); + + test("owns compiled structure and needles", () => { + const tokens = [" Alpha ", "alpha"]; + const all: Predicate<"value">[] = [{ field: "value", operator: "one-of", value: tokens }]; + const any = [{ all }]; + const source: Expression<"value"> = { v: 1, any }; + const matches = boxes.compile(source); + const probes: Box[] = [{ value: "alpha" }, { value: "omega" }]; + + const beforeMutation = probes.map(matches); + tokens.splice(0, tokens.length, "omega"); + all.splice(0, all.length, { field: "value", operator: "empty" }); + any.splice(0, any.length); + + expect(beforeMutation).toEqual([true, false]); + expect(probes.map(matches)).toEqual(beforeMutation); + }); + test("uses one-level some semantics without coercing objects", () => { let coercions = 0; const object = { diff --git a/tests/laws.test.ts b/tests/laws.test.ts index 687347e..2469b6e 100644 --- a/tests/laws.test.ts +++ b/tests/laws.test.ts @@ -20,7 +20,7 @@ interface GeneratedRow { const LAW_SEED = 0x0a11a11; const LAW_RUNS = 200; const TOTALITY_RUNS = 300; -const lawParameters = { seed: LAW_SEED, numRuns: LAW_RUNS, endOnFailure: true } as const; +const lawParameters = { seed: LAW_SEED, numRuns: LAW_RUNS } as const; const filters = createFilter()({ a: (record) => record.a, @@ -186,44 +186,19 @@ function evaluateLeftToRight(a: boolean, b: boolean, c: boolean, d: boolean): bo } describe("wire and row laws", () => { - test("round-trips every valid expression and null without changing evaluation", () => { + test("round-trips every valid expression and null verbatim", () => { fc.assert( - fc.property(nullableExpressionArbitrary, recordArbitrary, (expression, record) => { + fc.property(nullableExpressionArbitrary, (expression) => { const parsed = filters.parse(JSON.stringify(expression)); expect(parsed).toMatchObject({ ok: true }); if (!parsed.ok) return; expect(parsed.expression).toEqual(expression); - expect(filters.compile(parsed.expression)(record)).toBe( - filters.compile(expression)(record), - ); }), lawParameters, ); }); - test("round-trips duplicate one-of tokens verbatim", () => { - const expression: Expression = { - v: 1, - any: [{ all: [{ field: "a", operator: "one-of", value: [" Alpha ", " Alpha "] }] }], - }; - const parsed = filters.parse(JSON.stringify(expression)); - - expect(parsed).toEqual({ ok: true, expression }); - if (!parsed.ok) return; - const matches = filters.compile(parsed.expression); - expect(matches({ id: 1, a: "alpha", b: null, c: null, d: null })).toBe(true); - expect(matches({ id: 2, a: "omega", b: null, c: null, d: null })).toBe(false); - }); - - test("round-trips the null produced by an empty row list", () => { - const built = filters.fromRows([]); - - expect(built).toEqual({ ok: true, expression: null }); - if (!built.ok) return; - expect(filters.parse(JSON.stringify(built.expression))).toEqual(built); - }); - test("fromRows preserves authored DNF semantics, including not", () => { fc.assert( fc.property( @@ -303,26 +278,6 @@ describe("evaluation laws", () => { ); }); - test("compiled predicates own their structure and needles", () => { - const tokens = [" Alpha ", "alpha"]; - const all: Predicate[] = [{ field: "a", operator: "one-of", value: tokens }]; - const any = [{ all }]; - const source: Expression = { v: 1, any }; - const matches = filters.compile(source); - const probes: LawRecord[] = [ - { id: 1, a: "alpha", b: null, c: null, d: null }, - { id: 2, a: "omega", b: null, c: null, d: null }, - ]; - - const beforeMutation = probes.map(matches); - tokens.splice(0, tokens.length, "omega"); - all.splice(0, all.length, { field: "a", operator: "empty" }); - any.splice(0, any.length); - - expect(beforeMutation).toEqual([true, false]); - expect(probes.map(matches)).toEqual(beforeMutation); - }); - test("returns the input reference iff no record is excluded and otherwise stays stable", () => { fc.assert( fc.property( @@ -391,47 +346,6 @@ describe("resolution totality", () => { { ...lawParameters, numRuns: TOTALITY_RUNS }, ); }); - - test("treats post-normalization empties as empty", () => { - const normalized = createFilter<{ readonly value: unknown }>()({ - value: { - get: (record) => record.value, - normalize: (text) => text.replaceAll(/[\s-]/g, "").toLowerCase(), - }, - }); - const isEmpty = normalized.compile({ - v: 1, - any: [{ all: [{ field: "value", operator: "empty" }] }], - }); - const isAlpha = normalized.compile({ - v: 1, - any: [{ all: [{ field: "value", operator: "equals", value: "alpha" }] }], - }); - - expect(isEmpty({ value: " - " })).toBe(true); - expect(isEmpty({ value: ["--", " "] })).toBe(true); - expect(isAlpha({ value: ["--", " alpha "] })).toBe(true); - }); - - test("keeps zero and false as matchable texts", () => { - const isZero = values.compile({ - v: 1, - any: [{ all: [{ field: "value", operator: "equals", value: "0" }] }], - }); - const isFalse = values.compile({ - v: 1, - any: [{ all: [{ field: "value", operator: "equals", value: "false" }] }], - }); - const isEmpty = values.compile({ - v: 1, - any: [{ all: [{ field: "value", operator: "empty" }] }], - }); - - expect(isZero({ value: 0 })).toBe(true); - expect(isFalse({ value: false })).toBe(true); - expect(isEmpty({ value: 0 })).toBe(false); - expect(isEmpty({ value: false })).toBe(false); - }); }); describe("the four-row grouping case", () => { diff --git a/tests/rows.test.ts b/tests/rows.test.ts index 4e66cf6..133ca4d 100644 --- a/tests/rows.test.ts +++ b/tests/rows.test.ts @@ -236,7 +236,7 @@ describe("fromRows", () => { ok: true, expression: { v: 1, any: [{ all: [predicate("name", "A")] }] }, }); - expect(customers.fromRows([], { incomplete: "discard" })).toEqual({ + expect(customers.fromRows([])).toEqual({ ok: true, expression: null, }); From 20ad3264fc8237a00ab6e83aa10d3f7ebfe3189c Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 14:57:09 +0200 Subject: [PATCH 09/13] Restructure README around the reader journey --- README.md | 182 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 114 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 80f222a..353f50d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,14 @@ -# @b2m9/anyall +# anyall + +Build, store, and evaluate serializable text filters over declared record fields. + +`@b2m9/anyall` turns flat filter-builder rows into explicit `any`-of-`all` +expressions, validates saved views, and compiles them into predicates over +in-memory records. + +Headless · TypeScript · ESM-only · Zero runtime dependencies · Node.js 22+ + +## Why grouping matters Four filter rows can hide a boolean bug: @@ -18,10 +28,7 @@ an open Acme customer on the basic plan. The rows mean `(A AND B) OR (C AND D)`: | Test Labs, closed, pro | yes | **yes** | | Test Garage, closed, free | no | no | -`anyall` evaluates serializable text filters in that fixed shape: any group may -match, and all predicates in a matching group must match. It is headless, -ESM-only, has zero runtime dependencies, and does not inspect record structure -beyond the accessors you declare. +`anyall` makes that grouping explicit instead of relying on reduction order. ## Install @@ -29,9 +36,7 @@ beyond the accessors you declare. npm install @b2m9/anyall ``` -Node.js 22 or newer is required. - -## Usage +## Quick start Declare the fields an expression is allowed to address: @@ -53,11 +58,7 @@ const customers = createFilter()({ }); ``` -The double call is deliberate. TypeScript cannot partially infer type -arguments: the first call fixes `Customer`, while the second infers the literal -field names from the registry. - -Now fold the flat rows from a filter UI into an expression: +Convert flat rows from a filter UI, then apply the resulting expression: ```ts const rows = [ @@ -82,11 +83,18 @@ const records: Customer[] = [ { account: { name: "Other" }, status: "closed", name: "Test Garage", plan: "free" }, ]; -const matches = customers.filter(records, built.expression); -// [records[0], records[1]] +const filtered = customers.filter(records, built.expression); +filtered.map((customer) => customer.name); +// ["Alice", "Test Labs"] ``` -The expression is versioned plain data: +The double call to `createFilter` is deliberate. TypeScript cannot partially +infer type arguments: the first call fixes `Customer`, while the second infers +the literal field names from the registry. + +## Expression model + +The rows above produce versioned plain data: ```text { @@ -98,14 +106,34 @@ The expression is versioned plain data: } ``` -Evaluation is exactly `any.some(group => group.all.every(predicate))`. -There is no recursive tree and no ambiguous reduction order. +Evaluation is exactly: -### Custom normalization +```ts +expression.any.some((group) => group.all.every((predicate) => matches(predicate))); +``` + +Any group may match, while every predicate in a matching group must match. This +shape is disjunctive normal form, or DNF. There is no recursive tree and no +ambiguous reduction order. + +Expressions require at least one group, and every group requires at least one +predicate. Empty containers are rejected rather than given vacuous meanings. +`null` is the explicit no-filter value. + +## Choose an entry point + +| Starting with | Use | Result | +| ------------------------------------ | ---------- | ---------------------------------- | +| Flat state from a filter UI | `fromRows` | Validated expression or issues | +| Stored JSON or another unknown value | `parse` | Validated expression or issues | +| An expression used repeatedly | `compile` | Reusable `(record) => boolean` | +| Records and a one-off expression | `filter` | Filtered records in original order | + +## Field registry and normalization Accessors make record projection visible and type-checked. A field can also -define the text equivalence its domain needs; normalization always applies to -both record text and the compiled search text, or needle: +define the text equivalence its domain needs. Normalization applies to both +record text and the compiled search text, or needle: ```ts const places = createFilter<{ city: string }>()({ @@ -123,8 +151,8 @@ const inAarhus = places.compile({ inAarhus({ city: "Århus" }); // true ``` -Field-definition objects accept only `get` and `normalize` as string keys; -unknown string keys are configuration errors rather than ignored metadata. +Field-definition objects accept only `get` and `normalize` as string keys. +Unknown string keys are configuration errors rather than ignored metadata. ## Saved views @@ -154,37 +182,60 @@ A missing storage key and a stored no-filter state are different. Store the latter as `JSON.stringify(null)`, which yields the JSON text `"null"`; `customers.parse("null")` succeeds with `expression: null`. -`parse` accepts JSON text or an already-parsed inert value. On success it -returns fresh canonical plain objects while preserving needle text. Invalid -JSON and ordinary invalid data become issues, provided the declared normalizers -honor their pure-and-total contract; reflective exotica are outside that -guarantee. Issues for known fields follow grammar and array order before extra -keys; extra-key ordering is not normalized. Consumers should branch on `code` -and `path`, not issue position or message text. A well-formed unsupported wire -version is reported alone because the reader cannot safely judge the rest of a -foreign grammar. +`parse` provides these boundary guarantees for ordinary inert data: -## API +- JSON syntax and expression shape failures become structured issues. +- Success returns fresh plain objects without aliasing caller input. +- Raw needle text, token order, and duplicate tokens are preserved. +- Known grammar fields are reported in grammar and array order before extra + keys; ordering among extra keys is not normalized. +- A well-formed unsupported wire version is reported alone because the reader + cannot safely judge the rest of a foreign grammar. -```text -const filter = createFilter()({ - fieldName: (record) => record.value, - normalizedField: { - get: (record) => record.otherValue, - normalize: (text) => text.trim().toLowerCase(), - }, -}); +Consumers should branch on issue `code` and `path`, not position or message +text. + +## API reference + +### `createFilter()(fields)` + +Creates a filter bound to a declared field registry. Each field is either an +accessor or `{ get, normalize }`; field names are inferred as string literals. +Malformed field definitions throw `AnyallConfigError`. +### `filter.fromRows(rows, options?)` + +```ts filter.fromRows(rows, { incomplete: "reject" }); -filter.compile(expression); // (record) => boolean -filter.filter(records, expression); // readonly RecordType[] -filter.parse(jsonOrValue); ``` -`filter` validates and compiles its expression on every call. When evaluating -the same expression repeatedly, call `compile` once and reuse the predicate. +Segments flat UI rows, validates their predicates, and returns +`ExpressionResult`. Incomplete rows reject the conversion by default; +`{ incomplete: "discard" }` is intended for transient filter-drawer state. +Malformed options throw `AnyallConfigError`. + +### `filter.parse(jsonOrValue)` + +Accepts JSON text or an already-parsed value and returns `ExpressionResult`. +A string is always treated as JSON text. `null` is accepted as no filter. +Configuration failures and errors thrown by declared normalizers propagate. -`fromRows` and `parse` return one result shape: +### `filter.compile(expression)` + +Validates an expression and returns a reusable `(record) => boolean` predicate. +`compile(null)` returns a predicate that always passes. Invalid asserted input +throws `AnyallExpressionError` with the same issues that `parse` reports. + +### `filter.filter(records, expression)` + +Validates and compiles the expression on every call, then filters without +reordering records. It returns the input array by reference exactly when no +record is excluded; otherwise it returns a fresh array. Compile once and reuse +the predicate when evaluating the same expression repeatedly. + +### Results, errors, and types + +`fromRows` and `parse` share one result shape: ```ts type ExpressionResult = @@ -192,9 +243,6 @@ type ExpressionResult = | { ok: false; issues: readonly Issue[] }; ``` -`null` is the explicit no-filter value. `compile(null)` returns a predicate -that always passes, and `filter(records, null)` returns `records` itself. - Malformed configuration throws `AnyallConfigError`. Passing an asserted but invalid expression to `compile` or `filter` throws `AnyallExpressionError` with an `issues` property. Both classes are exported for `instanceof` checks. @@ -242,9 +290,9 @@ resolved only one level deep. `fromRows` segments the complete row sequence at each non-leading `join: "or"`, then validates rows within those groups. The first row's join is -ignored because there is no preceding group to separate. This order is -load-bearing: discarding an invalid row must not pull later predicates into an -earlier group. +ignored because there is no preceding group to separate. Segmentation happens +before validation so discarding an invalid row cannot pull later predicates +into an earlier group. Incomplete rows reject the whole conversion by default. For a live filter drawer, `{ incomplete: "discard" }` removes invalid rows after grouping, drops @@ -254,10 +302,7 @@ string array. An explicit `undefined` behaves like an omitted property; required properties remain invalid when omitted. Record values are the only values coerced to text. -Expressions require at least one group and every group requires at least one -predicate; empty containers are rejected rather than given vacuous meanings. - -## Guardrails +## Operational guardrails - Field names and their meaning are a saved-view contract. When renaming a field, keep the old name as an alias; changing an accessor or normalizer can @@ -265,9 +310,8 @@ predicate; empty containers are rejected rather than given vacuous meanings. - Accessors and normalizers must be pure and total, and normalizers must return strings. Their errors propagate; a non-string result throws `AnyallConfigError` rather than becoming a matchable value. -- V1 is text-only. There are no numeric or date comparisons. Numbers use - JavaScript's `String` representation, so `contains "4"` matches `42`; project - a formatted string when notation matters. +- V1 is text-only. Numbers use JavaScript's `String` representation, so + `contains "4"` matches `42`; project a formatted string when notation matters. - Lowercasing is deterministic, not locale-aware collation. Supply a custom normalizer when your domain needs another text equivalence. - Objects never coerce to `"[object Object]"`. Project a label or identifier in @@ -275,20 +319,22 @@ predicate; empty containers are rejected rather than given vacuous meanings. - Expressions cannot introduce code or access undeclared fields, but that bounds capability, not work. Cap bytes, groups, predicates, tokens, and needle lengths before parsing untrusted input. -- `filter` preserves order and returns its input array by reference exactly - when no record is excluded; otherwise it returns a fresh array. +- Proxies, stateful getters, and similar reflective inputs can execute caller + code and are outside `parse`'s issues-only guarantee. Expressions carry `v: 1`. A grammar change increments the wire version even when package semver would not otherwise require a major release. A future reader must either continue accepting every older grammar it supersets or ship an explicit migration. -The expression language deliberately has no regex, arbitrary nesting, -user-defined operators, or field-path grammar. Regex becomes a ReDoS surface -when expressions are untrusted data. An expression that names executable -behavior stops being portable and trustable. Recursive boolean trees are the -problem JSON Logic already solves, while declared accessors avoid a path -grammar and its prototype-safety policy entirely. +## Deliberate limits + +The expression language has no regex, arbitrary nesting, user-defined +operators, or field-path grammar. Regex becomes a ReDoS surface when +expressions are untrusted data. An expression that names executable behavior +stops being portable and trustable. Recursive boolean trees are the problem +JSON Logic already solves, while declared accessors avoid a path grammar and +its prototype-safety policy entirely. The package also provides no rendering, table integration, URL state, sorting, pagination, server adapter, query-language compilation, fuzzy search, numeric From effbaa8b19295997cdd9f49d33d82cf0b680a95c Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 15:21:52 +0200 Subject: [PATCH 10/13] Link Claude guidance to agent instructions --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From b9ce9a008bb7c82b3c3a218332dd6756754e11b3 Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 15:23:36 +0200 Subject: [PATCH 11/13] Use portable Claude guidance include --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 120000 => 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 85fe496e2419210a72172123e6a7c6e29068cb5c Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 15:58:10 +0200 Subject: [PATCH 12/13] Snapshot field callables during registration --- src/fields.ts | 20 ++++++++++++-------- tests/config.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/fields.ts b/src/fields.ts index 72b6318..81c3f8a 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -43,23 +43,27 @@ function registerField(name: string, definition: unknown): RegisteredField throw invalidField(name, `has unknown key ${JSON.stringify(key)}`); } } - if (typeof candidate.get !== "function") { + // Accessor properties may be stateful, so validation and registration must + // observe the same callable. + const get = candidate.get; + const normalize = candidate.normalize; + if (typeof get !== "function") { throw invalidField(name, "get must be a function"); } - if (candidate.normalize !== undefined && typeof candidate.normalize !== "function") { + if (normalize !== undefined && typeof normalize !== "function") { throw invalidField(name, "normalize must be a function when provided"); } - const get = candidate.get as (record: R) => unknown; - if (candidate.normalize === undefined) { - return { get, normalize: defaultNormalize }; + const registeredGet = get as (record: R) => unknown; + if (normalize === undefined) { + return { get: registeredGet, normalize: defaultNormalize }; } - const normalize = candidate.normalize as (text: string) => unknown; + const registeredNormalize = normalize as (text: string) => unknown; return { - get, + get: registeredGet, normalize: (text) => { - const normalized = normalize(text); + const normalized = registeredNormalize(text); // A shared non-string result can make unrelated values compare equal or // fail later inside a string operator. if (typeof normalized !== "string") { diff --git a/tests/config.test.ts b/tests/config.test.ts index 50f9cf7..e6af95d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -63,6 +63,30 @@ describe("field configuration", () => { } }); + test("snapshots accessor properties before validation and registration", () => { + let getReads = 0; + let normalizeReads = 0; + const definition = { + get get() { + getReads += 1; + return getReads === 1 ? (customer: Customer) => customer.name : undefined; + }, + get normalize() { + normalizeReads += 1; + return normalizeReads === 1 ? (text: string) => text.trim().toLowerCase() : undefined; + }, + }; + + const matches = configure({ name: definition } as never).compile({ + v: 1, + any: [{ all: [{ field: "name", operator: "equals", value: "ada" }] }], + }); + + expect(matches({ name: " Ada " })).toBe(true); + expect(getReads).toBe(1); + expect(normalizeReads).toBe(1); + }); + test("rejects non-string normalizer results when they are observed", () => { const invalidNeedle = configure({ name: { From 5407888ca506a14011c7b4c3ac632b48f52e7106 Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Tue, 21 Jul 2026 16:00:41 +0200 Subject: [PATCH 13/13] Avoid duplicate dependency install in CI --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4a1a8a..967fc6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: true + run-install: false - run: vp install --frozen-lockfile - run: vp run check