bonsai-js 1.0.0-rc.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +27 -7
- package/dist/artifact.d.mts +47 -0
- package/dist/artifact.mjs +139 -0
- package/dist/artifact.mjs.map +1 -0
- package/dist/check/bind.mjs +117 -0
- package/dist/check/bind.mjs.map +1 -0
- package/dist/check/checker.mjs +14 -116
- package/dist/check/checker.mjs.map +1 -1
- package/dist/compile/compiler.mjs +2 -3
- package/dist/compile/compiler.mjs.map +1 -1
- package/dist/declared.mjs +8 -1
- package/dist/declared.mjs.map +1 -1
- package/dist/digest.mjs +109 -0
- package/dist/digest.mjs.map +1 -0
- package/dist/environment.d.mts +1 -1
- package/dist/environment.mjs +54 -304
- package/dist/environment.mjs.map +1 -1
- package/dist/errors.d.mts +21 -2
- package/dist/errors.mjs +17 -1
- package/dist/errors.mjs.map +1 -1
- package/dist/features.mjs +3 -3
- package/dist/features.mjs.map +1 -1
- package/dist/functions/builtins.mjs +2 -2
- package/dist/functions/define.mjs +13 -6
- package/dist/functions/define.mjs.map +1 -1
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +3 -2
- package/dist/lite/index.d.mts +3 -2
- package/dist/lite/index.mjs +3 -2
- package/dist/lite/index.mjs.map +1 -1
- package/dist/load.mjs +308 -0
- package/dist/load.mjs.map +1 -0
- package/dist/program.mjs +323 -0
- package/dist/program.mjs.map +1 -0
- package/dist/query/index.mjs +11 -3
- package/dist/query/index.mjs.map +1 -1
- package/dist/runtime/index.d.mts +61 -0
- package/dist/runtime/index.mjs +95 -0
- package/dist/runtime/index.mjs.map +1 -0
- package/dist/runtime/time.mjs +8 -1
- package/dist/runtime/time.mjs.map +1 -1
- package/dist/service/index.mjs +8 -5
- package/dist/service/index.mjs.map +1 -1
- package/dist/syntax/parser.mjs +1 -1
- package/dist/syntax/printer.mjs +1 -1
- package/dist/types.mjs +103 -23
- package/dist/types.mjs.map +1 -1
- package/dist/version.mjs +7 -0
- package/dist/version.mjs.map +1 -0
- package/package.json +14 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,61 @@ From 1.0.0, entries are generated at release time from the bump files in
|
|
|
7
7
|
[`.bumpy/`](./.bumpy/); entries up to 0.5.0 follow
|
|
8
8
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
9
9
|
|
|
10
|
+
|
|
11
|
+
## 1.1.0
|
|
12
|
+
<sub>2026-10-24</sub>
|
|
13
|
+
|
|
14
|
+
- *(minor)*
|
|
15
|
+
Add `bonsai-js/runtime` and `toArtifact()`: check and compile rules where the full toolchain runs, and evaluate them in the browser without the checker (about 44 KB minified and gzipped), with exactly the results the compiled programs give. A runtime whose definitions differ refuses an artifact with `BonsaiArtifactError` (code `ARTIFACT`) and a `reason`.
|
|
16
|
+
- *(patch)*
|
|
17
|
+
Charge the step budget for each union member tried when a result is checked against its expected type or a host function's declared type. A list result checked against a large enum could previously run far past `maxSteps` and `timeout`.
|
|
18
|
+
- *(patch)*
|
|
19
|
+
Check and cache a program against a private copy of its `expect` type. A type object with a `toJSON` method could previously be served a cached program checked against a different type, and a type object changed after compiling changed what the program's results were checked against. The language service's `expect` option is kept the same way. Types built with `t` are used as given.
|
|
20
|
+
|
|
21
|
+
## 1.0.0
|
|
22
|
+
<sub>2026-10-19</sub>
|
|
23
|
+
|
|
24
|
+
- *(major)*
|
|
25
|
+
A redesign of the language and the engine. Expressions and host code written for 0.x need migrating; the [migration guide](https://danfry1.github.io/bonsai-js/guide/migrating) covers every change, including results that change without an error.
|
|
26
|
+
|
|
27
|
+
**Language**
|
|
28
|
+
|
|
29
|
+
- Every function is callable as `f(x, a)` or `x.f(a)`. The pipe operator `|>` is removed and its token reserved.
|
|
30
|
+
- The standard library is built in, with JavaScript names (`toUpperCase`, `includes`, `filter`) plus `sum`, `sortBy`, `groupBy`, `unique`, `keys`, `round(x, digits)`, and calendar functions with time zones. `upper`, `lower`, `flatten`, `isString`, `isNumber`, `isArray`, `isNull`, `toBool`, `diffDays`, `charCodeAt`, `toSpliced`, `with`, `substring`, `charAt`, and `concat` are removed.
|
|
31
|
+
- Lambdas: `x => ...` and `(acc, x) => ...` alongside `.field`. The implicit `.` binds to the nearest argument whose parameter takes a function, and only where that function's first parameter is the current item (so not in `reduce`). No-argument `filter`, `some`, and `every` are removed.
|
|
32
|
+
- New: `let` bindings, `try(expr, fallback)`, `has(a.b)`, comments, timestamps and durations (`now() - t > days(30)`), deep `==` on lists and maps. Durations are whole milliseconds (a fraction rounds, halves away from zero), so their arithmetic is exact, within ±(2^53 - 1) ms (about 285,000 years); a duration outside that range is `INVALID_ARGUMENT`, and `new Duration(ms)` throws a `RangeError` for one. Time zones are `"UTC"` or IANA `Area/Location` names in the tz database's spelling; abbreviations, legacy names, other spellings, and offsets are `INVALID_ARGUMENT`.
|
|
33
|
+
- `null` is the single absent value: missing properties and host `undefined` read as `null`, and `x == null` holds for both. A map key holding `undefined` is absent to `==`, `keys`, `values`, `entries`, `has`, `in`, and spread. Ordering comparisons with `null` are `false`.
|
|
34
|
+
- `&&`, `||`, `!`, and `?:` take booleans (`null` counts as false). There is no coercion: `"a" + 1` and `null + 1` are errors. Division and remainder by zero and non-finite results are errors. Chained comparisons are parse errors, and so is `??` beside another binary operator without parentheses (`a ?? 0 > 10`).
|
|
35
|
+
- Results that changed: `round` rounds halves away from zero and honors its digits argument; `toFixed` rounds the written decimal; `avg([])` is `null`; `unique` compares by value; templates render `null` as empty text, dates as ISO-8601, and reject lists and maps; `toString(null)` is `""`; `toNumber` accepts decimal text only (no `0x`, `0b`, or `0o`); `formatNumber` and `formatCurrency` reject a locale the runtime has no data for instead of using the host's default, a locale tag over 64 characters, and a currency that is not a three-letter code, and never show a negative zero (an amount that rounds to zero at the shown precision prints unsigned, so not `-$0.00`); `round` leaves a number of 2^53 or more as it is at zero or more digits; `toFixed` writes every digit of a number of 1e21 or more instead of exponent form; `formatDate` reads `''` inside quotes as a quote and rejects a quote left open; `timestamp()` reads back the six-digit signed years a timestamp renders outside 0000-9999; `sum` and `avg` of durations check the range at each step, as `a + b + c` does; `sum` of a list of durations sums them whether or not the list's type is known; `sum` and `avg` add left to right; `now()` is a timestamp; `sort()` orders numbers numerically; `includes`, `indexOf`, and `in` compare lists and maps by value, and `Date`s at the same instant are equal; `replace` and `replaceAll` insert their replacement text literally (no `$&` patterns).
|
|
36
|
+
- Only plain objects and class instances are read as maps: reads see their own properties, and `keys()`, spread, and `==` see the enumerable ones, as in JavaScript. `Map`, `Set`, `RegExp`, promises, typed arrays, and errors are opaque; a plain object with a `then` method is an ordinary map and is never awaited. Map keys follow JavaScript property order. A computed read of `__proto__`, `constructor`, or `prototype` is `null`; writing one is a `BLOCKED_PROPERTY` error. `groupBy` keys are text, so `1` and `"1"` share a group.
|
|
37
|
+
- `duration("PT1H30M")` reads ISO-8601 duration text, the text a duration renders as (weeks down to milliseconds; years and months have no fixed length and are refused). `min`, `max`, and `avg` take durations as `sum` does, given as a list or one by one (`max(a, b)`). A reserved word is not a method name: `a.not(b)` is a syntax error. Syntax errors suggest `**` for `^` and `!=` for `<>`. A `\u{...}` escape takes any number of leading zeros, as in JavaScript, and `1.e3` (a read of `e3` on `1`) is a check error that says to write `1e3`.
|
|
38
|
+
|
|
39
|
+
**API**
|
|
40
|
+
|
|
41
|
+
- `bonsai({ variables, strict, functions, libraries, limits, cacheSize, clock, validateContext })` creates an immutable environment; `extend()` derives one. `use()`, plugins, `addFunction`, `addContextFunction`, `addTransform`, `remove*`, `has*`, `isContextFunction`, `getPolicy`, and `clearCache` are removed.
|
|
42
|
+
- Declaring `variables` makes an environment strict: undeclared names are check errors unless `strict: false`.
|
|
43
|
+
- Host functions are declared with `fn({ params, returns, run })`, validated on every call, and must be marked `async` to be awaited. With `call: true`, `run` first receives `{ context, signal }`: the read-only context, and a signal that aborts when the evaluation times out or is cancelled while waiting on the call. A result that does not match the declared type, or a promise from a function not marked `async`, is a `HOST_CONTRACT` error that `try()` does not catch.
|
|
44
|
+
- `env.compile(source, { expect })` type-checks and returns a `Program`, cached per source and expected type; `env.check()` replaces `validate()` and returns every diagnostic without throwing. Evaluation options include `now`, the time `now()` returns for that evaluation, and `validateContext`, which turns context validation on or off for that evaluation (context validation rejects `NaN` and infinities where a number is declared), and a `BonsaiLimitError` names the option that bounds it in `limit`. The context type comes from `variables` and the variables of the `libraries` used, instead of `bonsai<Ctx>()`. A schema typed `Record<string, Type>` accepts interface-typed and class contexts, and a variable `extend()` declares again replaces the earlier one in the context type. A context that is not an object is a `TypeError`.
|
|
45
|
+
- `bonsai-js/service` (`createLanguageService`) replaces `bonsai-js/autocomplete`; the `stdlib` and `autocomplete` subpaths are removed. A source that is not a string or an offset that is not a number is a `TypeError`, and a negative or fractional offset a `RangeError`.
|
|
46
|
+
- `print(tree, { calls })` turns a syntax tree back into source, round-tripping with `env.parse`. Syntax tree node types are renamed. `forEachChild` walks a tree and `mapChildren` rewrites one, both visiting children in evaluation order; member and call nodes record the span of their name (`nameStart`, `nameEnd`; optional in trees built by hand).
|
|
47
|
+
- `program.explain(context)` (and `explainSync`, `env.explain`, `env.explainSync`) evaluates and returns a trace of every sub-expression's value, with skipped branches and per-item lambda runs; `reasons()` lists the conditions that decided the result, a negated condition as written (`!("vip" in tags)`, with its own value). Trace text is capped at 120 characters, and no cut in a trace or its rendering splits a surrogate pair. `String(explanation)`, `JSON.stringify(explanation)`, `JSON.stringify(explanation.reasons())`, and `JSON.stringify(explanation.trace)` are bounded and cycle-safe, and any trace, lambda run, or list of them picked out of it serializes as its part of `JSON.stringify(explanation)`; a map shows its first 50 keys and is listed once per explanation, so serializing never grows with its size, and trace records stay plain objects. An explanation serializes to the same frozen snapshot every time, whatever was serialized before. `explain()` refuses a promise-like result as `evaluate()` does, and marks a result refused after evaluation (a promise-like value, a failed `expect` check) on the root of the trace.
|
|
48
|
+
- `program.partial(known, { unknown })` (or `env.partial(source, known)`) evaluates what the known data decides and returns a value, an error, or a residual expression over the unknown variables (its tree is `ast`, as on a program), to pre-evaluate rules once per tenant or request. Data missing from `known`, including a field missing from a known object, and a known object the expression reads whole, is unknown unless an `unknown` list is given; with one, an object above a listed path that `known` leaves out is unknown as a whole, not null. An `unknown` path starts with a variable name and names fields as reads spell them; an empty or blocked segment is a `TypeError`. It takes the same step, time, and cancellation budgets as evaluation; a size limit (`LIST_LIMIT`, `STRING_LIMIT`, `VALUE_DEPTH_LIMIT`, `PATTERN_LIMIT`) in a known part that evaluation may not reach stays in the residual rather than ending `partial()`, and a list literal over `maxListLength` reports that limit before its items, as evaluation does, in async programs too. Known data that throws when read (a getter, a Proxy trap) fails at that read, as in evaluation, so a read behind `&&` or inside `try()` decides as the program does, and no getter runs to decide which variables are given. A residual longer than `maxSourceLength` is a `SOURCE_TOO_LONG` limit error (`toSQL` and `toMongo`, which never evaluate its text, are not bound by it), and a residual can be explained like a program, at the step cost of evaluating it. A residual runs every call with the overload the program chose, and a part whose inputs all turn out known (`(2 ?? row.total) >= 1`) is decided rather than left in the residual. A residual's bindings are the known values themselves (shared with `known`, not copied), and `call: true` host functions in a residual read exactly the context it is given (`readsContext` says when it calls one; that context must then hold every variable `known` gave, or the call is an `INVALID_CONTEXT` error). With `validateContext`, `partial()` validates the known data (all but what lies under a listed unknown path), and a residual validates the paths it reads, each step against its own declared type, so a context holding only `dependsOn` passes and invalid data on those paths fails as it would for the program (the whole context when `readsContext`). `PartialResult` and `ResidualResult` take the context type first, as `Program` does. Partial evaluation keeps memory in proportion to the program and charges the work of indexing and compiling it.
|
|
49
|
+
- `bonsai-js/query` (`toSQL`, `toMongo`) translates a filter over a declared record into a parameterized SQL `WHERE` clause (Postgres, SQLite) or a MongoDB filter that selects exactly the records the filter accepts. This covers the null-safe text forms (`x?.endsWith(k) ?? false`, `(x ?? "").endsWith(k)`), columns with a known default (`(order.total ?? 0) >= 500`), duration columns, and relative dates (`now() - order.placed < days(14)`). A path the filter reads that is missing from `known` is refused rather than read as null (in a typed environment, so is a known object read whole without every declared field), and every refusal says why. A known value that throws when read is refused with the host's error as the `cause`. One `maxSteps`, `timeout`, and `signal` budget covers the whole translation, including the partial evaluation it runs, and in SQL a known value is sent once however often the filter uses it. SQL arithmetic covers unary `-` as well as `+ - *`; in Postgres a record whose arithmetic leaves the double range fails the query with `value out of range` (never a different set of rows), where SQLite excludes it. `params` is typed so `node:sqlite` and Postgres drivers take it without a cast. Known dates and durations are checked as evaluation checks them, so a value evaluation treats as opaque is refused. A comparison whose sides are both known is decided before translating, a known list with repeated entries matches as its entries, and a text function on `(field ?? default)` runs on the default where the field is null, so it is never null. A part that fails whenever it is reached after a read of the record (a list literal over `maxListLength`, say) is refused as `UNTRANSLATABLE` (naming its error), never sent to the database, and a relative date excludes a record whose distance from the known time leaves the duration range, as evaluation fails it. Records are not validated: with `validateContext`, the database must enforce the declared types for exact results. With `callHostFunctions: true`, sync host functions with known inputs are called before translating. `BonsaiTranslationError` is exported from `bonsai-js` as well as `bonsai-js/query`.
|
|
50
|
+
- Errors are `BonsaiError` subclasses with stable codes and `{ start, end }` spans. Diagnostics carry the same `span`, `position`, and `formatted` code frame, plus a `suggestion` for an unknown name; `env.check()` also returns the checked tree, the type of each node, and (when ok) the compiled program. Any `BonsaiError` that a getter or Proxy in the context throws (from another evaluation, a parse, a check, or a translation it runs, or one the host built) is a `HOST_ERROR` with it as `cause`, as for host functions, in evaluation, explanation, validation, and `partial()` alike; only the errors an evaluation raises itself keep their code. Any other thrown value becomes a `HOST_ERROR` too, including one whose inspection throws (a revoked Proxy), so `try()` recovers from it, and every trace node an explanation records for it says `HOST_ERROR`; `explain({ exhaustive: true })` ignores it in the extra work, whatever it is. A stack overflow in a host function is a `HOST_ERROR` like anything else it throws; one in a getter or Proxy trap is `TOO_DEEP` (the engine's error is its `cause`), which `try()` does not recover from and `partial()` leaves to a branch that may not run. Only the engines' own overflow errors count as overflows, not any `RangeError`. Async evaluation never awaits a value from the data: a thenable in the context stays opaque inside every node, lambda, and host call result (an `async` host function's own result is awaited by JavaScript, so it must not return one). Async evaluation and explanation check list limits, expected types, and the deadline in the same order as sync evaluation, so they run the same host calls and report the same error. A host call's signal is aborted with a copy of the evaluation's error, so host code rethrowing it elsewhere is that evaluation's `HOST_ERROR`. `ExpressionError`, `BonsaiTypeError`, `BonsaiReferenceError`, `BonsaiSecurityError`, `formatError`, `formatBonsaiError`, `evaluateExpression`, `tokenize`, and `compile` are removed (`parse(source)` returns the new syntax tree), as are the `allowedProperties` and `deniedProperties` options.
|
|
51
|
+
- `JSON.stringify(error)` gives one documented shape (`BonsaiErrorJSON`: name, code, message, span, position, plus `limit` or `diagnostics`) wherever an error is serialized. Check results, their diagnostics, and partial results are frozen. A program's environment types are checked against `toSQL` and `toMongo` column types. `DiagnosticJSON` types the diagnostics in that JSON, without the `formatted` code frame. `isBonsaiError` also recognizes errors from another copy of the package.
|
|
52
|
+
- Limits move under `limits`; `maxArrayLength` is now `maxListLength`, and `maxDepth` now bounds syntax nesting (`TOO_DEEP`); nested data is bounded by `maxValueDepth` (`VALUE_DEPTH_LIMIT`), counted alike when building, comparing, `unique()`, and validating. Built-ins check `maxListLength` before they build a list, `groupBy` groups included.
|
|
53
|
+
- `maxDepth` is at most 256 and `maxValueDepth` at most 1,024 (a larger value is a `RangeError`), so every stage stays inside the JavaScript call stack; a stack overflow anywhere is a `TOO_DEEP` limit error, never a raw `RangeError`. Host `Duration` values are read by their own frozen length and rendered by the engine, never by their own methods; one built without the constructor is an opaque value.
|
|
54
|
+
- `expect` means "has at least these fields": an extra field in a map literal is an `EXPECTED_TYPE` warning, and the result keeps it. Literal locales, currencies, and duration text are checked before anything runs. In a non-strict environment with declared variables, an undeclared name one edit from a declared one is an `UNKNOWN_VARIABLE` warning with a `suggestion`. Diagnostic messages shorten type text to 120 characters.
|
|
55
|
+
- Spans are balanced: a parenthesized expression's span includes its parentheses, so slicing the source at any node, error, diagnostic, or explanation span gives source that parses on its own. A later computed key or spread record makes earlier map fields it may overwrite (and keys it may leave absent) part of the checked type, and `print()` keeps the parentheses of a right-nested `&&`, `||`, or `??` so the printed tree parses back to the same shape. `createLanguageService(env, { expect })` adds `EXPECTED_TYPE` to the service's diagnostics, its completions work inside `try()` and computed keys with the same types check() gives, and a negative or fractional offset is a `RangeError`. An ordering with a value that may be `null` is a `MAYBE_NULL` warning only where it is written so its `false` becomes a verdict (under `!`, as a `?:` test, in `every` or `none`, or compared with `false`, looking through `?:` branches, `let` bodies, `try`, and `== true`), not where it means "skip" (`users.filter(.age >= 18)`); it is a heuristic over how the comparison is written, and a literal written with an exponent is an `UNSAFE_INTEGER` warning only when the number cannot keep the digits written (`9007199254740993e0`, not `1e308` or an exact `1.180591620717411303424e21`). A computed or `groupBy` key that is always one evaluation blocks (`{["__proto__"]: 1}`, `` {[`constructor`]: 1} ``) is a `BLOCKED_PROPERTY` error at check time, a template without substitutions is typed as its text, and a key a map literal writes is always present when spread, and a spread after an open declared object keeps any key it may lack typed as whatever the object held. Code frames and shortened type text never split a surrogate pair, a CRLF line is shown without its `\r`, and the caret lines up by characters. Completions also work inside brackets, calls, templates, and branches of a `let` value, nested ones included (`let v = try(b ? user.`), and inside `has()`, and detail each field with the type the read has there, narrowing included. A completion costs at most 100 parses, and about 12 of a source near the length limit.
|
|
56
|
+
- `withContext<Ctx>()` builds host functions typed against a context, `Infer` gives the TypeScript type of a `t` type, `formatType` and `isAssignable` print and compare types, and `env.describeFunction` / `env.listFunctions()` (read-only) describe the callable functions for editors and builders. Error classes keep their `name` (and the JSON `name`, typed `BonsaiErrorName`) in minified bundles; a call stack overflow is `TOO_DEEP` with no `limit`, since no option raises it. `ExplanationJSON` types `explanation.toJSON()`, `SQLParam` and `QueryOptions` name the query types, and the standalone `parse(source, limits)` ignores the limits parsing does not use. The [stability policy](https://danfry1.github.io/bonsai-js/guide/stability) says what a minor release may change; the result type parameter of `env.evaluate<R>()` and the other environment calls is an unchecked assertion.
|
|
57
|
+
- Requires Node.js 22 or newer.
|
|
58
|
+
|
|
59
|
+
**Engine**
|
|
60
|
+
|
|
61
|
+
- Expressions compile to closures. Async code is generated only for subtrees that reach an async host function. Compiling an expression that uses time zones loads their data (each zone it names once, and at most 16 per expression), so a first evaluation does not spend its `timeout` on it; a zone that comes from data, or that `partial()` writes into a residual, loads on first use.
|
|
62
|
+
- The package ships one module per source file, so bundlers drop what an app does not reach: importing only `t` adds under 1 KB gzipped, and all the error classes about 1.6 KB. The `bonsai-js/service` entry is about 46 KB gzipped, since it includes the type checker. A minimal browser bundle that evaluates expressions is about 68 KB gzipped, and about 56 KB from `bonsai-js/lite`, which exports the same API without explaining and partial evaluation (`LiteEnvironment`, `LiteProgram`, and `LiteCheckResult` type its environments; the language service accepts them, and `toSQL` and `toMongo`, which translate through partial evaluation, say they cannot take its programs).
|
|
63
|
+
- Every limit is on by default: source size, depth, node count, a deterministic step budget charged by the real cost of each operation, produced string and list sizes, value depth, regular expression pattern length, plus an optional timeout (in milliseconds, fractions allowed; only `0` turns it off) and `AbortSignal`. A built-in call is charged by the length of its string arguments, and an `ABORTED` error carries the signal's reason as its `cause`.
|
|
64
|
+
|
|
10
65
|
## [0.5.0] - 2026-06-02
|
|
11
66
|
|
|
12
67
|
### Security
|
package/README.md
CHANGED
|
@@ -62,19 +62,19 @@ Try it in the [Playground](https://danfry1.github.io/bonsai-js/playground).
|
|
|
62
62
|
| Evaluation budget for untrusted input (as documented) | Yes, on by default | Parse-time limits only | No expression budget | No | No | No | No |
|
|
63
63
|
| SQL or MongoDB translation | Yes | No | No | No | No | No | No |
|
|
64
64
|
|
|
65
|
-
Bonsai is the largest of these in the browser (about 56 KB from `bonsai-js/lite`, without explaining and partial evaluation) and the newest. If rules must also run in Go or Java, CEL or ZEN fit better; if rules are JSON produced by a form builder and size matters most, json-logic-js does. The [full comparison](https://danfry1.github.io/bonsai-js/guide/comparison) covers more libraries, throughput, and the method behind every number.
|
|
65
|
+
Bonsai is the largest of these in the browser (about 56 KB from `bonsai-js/lite`, without explaining and partial evaluation, and about 44 KB from `bonsai-js/runtime` for rules checked and compiled on a server) and the newest. If rules must also run in Go or Java, CEL or ZEN fit better; if rules are JSON produced by a form builder and size matters most, json-logic-js does. The [full comparison](https://danfry1.github.io/bonsai-js/guide/comparison) covers more libraries, throughput, and the method behind every number.
|
|
66
66
|
|
|
67
67
|
## Install
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
npm install bonsai-js
|
|
70
|
+
npm install bonsai-js
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
Upgrading from 0.x? The [migration guide](https://danfry1.github.io/bonsai-js/guide/migrating) covers every change.
|
|
74
74
|
|
|
75
|
-
Requires Node.js 22 or newer, current Bun, or a modern browser. The package is ESM; `require('bonsai-js')` works where Node can load ES modules synchronously (22.12 and newer).
|
|
75
|
+
Requires Node.js 22 or newer, current Bun, or a modern browser. The package is ESM; `require('bonsai-js')` works where Node can load ES modules synchronously (22.12 and newer); for the TypeScript settings that allow it, see [Install](https://danfry1.github.io/bonsai-js/guide/install#typescript-settings).
|
|
76
76
|
|
|
77
|
-
In a browser bundle, an app that evaluates expressions adds about 68 KB minified and gzipped: the parser, type checker, compiler, and built-in library, with explaining and partial evaluation included. `bonsai-js/lite` leaves those two out, for about 56 KB. `bonsai-js/query` and `bonsai-js/service` are separate entry points; the language service on its own is about 46 KB, since it includes the type checker. Unused modules are tree-shaken: importing only `t` adds under 1 KB, and all the error classes about 1.6 KB. CI enforces these budgets (`bun run check:size`).
|
|
77
|
+
In a browser bundle, an app that evaluates expressions adds about 68 KB minified and gzipped: the parser, type checker, compiler, and built-in library, with explaining and partial evaluation included. `bonsai-js/lite` leaves those two out, for about 56 KB, and `bonsai-js/runtime`, which evaluates rules checked and compiled elsewhere, leaves out the checker too, for about 44 KB. `bonsai-js/query` and `bonsai-js/service` are separate entry points; the language service on its own is about 46 KB, since it includes the type checker. Unused modules are tree-shaken: importing only `t` adds under 1 KB, and all the error classes about 1.6 KB. CI enforces these budgets (`bun run check:size`).
|
|
78
78
|
|
|
79
79
|
## The language in one screen
|
|
80
80
|
|
|
@@ -94,7 +94,7 @@ startOfDay(order.placedAt, "Europe/Berlin") // calendar math in time
|
|
|
94
94
|
// comments work too
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
The full reference is [docs/language.md](https://github.com/danfry1/bonsai-js/blob/
|
|
97
|
+
The full reference is [docs/language.md](https://github.com/danfry1/bonsai-js/blob/v1.1.0/docs/language.md). The rules that matter most:
|
|
98
98
|
|
|
99
99
|
- There is one absent value, `null`. Missing properties and host `undefined` read as `null`, so `x == null` means "missing or null". A key holding `undefined` is absent everywhere: `{ a: 1, b: undefined }` equals `{ a: 1 }`, and `"b" in` it is false.
|
|
100
100
|
- `==` compares by value (lists and maps deeply). There is no type coercion: `1 == "1"` is `false` and `"a" + 1` is an error.
|
|
@@ -201,7 +201,7 @@ Every limit except `timeout` is on by default; `limits` changes the budget. `0`
|
|
|
201
201
|
| `maxPatternLength` | 4,096 | regular expression patterns passed to `matches` |
|
|
202
202
|
| `timeout` | none | wall-clock milliseconds per evaluation |
|
|
203
203
|
|
|
204
|
-
The step budget is deterministic: the same expression over the same data uses the same steps on every machine, and at the default budget any expression finishes or fails within about 100 ms on Node (about 150 ms for the slowest shapes we have found). Host functions are trusted code: limits cannot interrupt a synchronous host function that is already running, and a getter or Proxy you put in the context runs when it is read. Set a `timeout` when expressions call slow host functions. See [SECURITY.md](https://github.com/danfry1/bonsai-js/blob/main/SECURITY.md) and [docs/threat-model.md](https://github.com/danfry1/bonsai-js/blob/
|
|
204
|
+
The step budget is deterministic: the same expression over the same data uses the same steps on every machine, and at the default budget any expression finishes or fails within about 100 ms on Node (about 150 ms for the slowest shapes we have found). Host functions are trusted code: limits cannot interrupt a synchronous host function that is already running, and a getter or Proxy you put in the context runs when it is read. Set a `timeout` when expressions call slow host functions. See [SECURITY.md](https://github.com/danfry1/bonsai-js/blob/main/SECURITY.md) and [docs/threat-model.md](https://github.com/danfry1/bonsai-js/blob/v1.1.0/docs/threat-model.md).
|
|
205
205
|
|
|
206
206
|
## Built-in functions
|
|
207
207
|
|
|
@@ -299,6 +299,26 @@ toMongo(filter, { row: 'order', fields: columns, known: { minTotal: 100 } }) //
|
|
|
299
299
|
|
|
300
300
|
The query selects exactly the records for which the filter evaluates to `true` in Bonsai, including null handling (SQL's three-valued logic is made two-valued) and records where the filter would fail (they are excluded). One exception: in Postgres, a record whose arithmetic overflows a double (or whose nonzero product rounds to zero) makes the whole query fail with `value out of range`, an error rather than a different set of rows, where SQLite excludes the record; keep stored numbers within range to avoid it (see the query guide's caveats). Only declared columns can be queried, values are always parameters, and anything without an exact database equivalent throws a `BonsaiTranslationError` pointing at it. This is checked by differential tests against real SQLite, Postgres, and a MongoDB query engine.
|
|
301
301
|
|
|
302
|
+
## Rules in the browser
|
|
303
|
+
|
|
304
|
+
Check and compile where the full toolchain runs, then evaluate in the browser with `bonsai-js/runtime`, which does not bundle the checker. `toArtifact()` turns a compiled program into plain JSON:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
import { bonsai, t, toArtifact } from 'bonsai-js'
|
|
308
|
+
import { runtime } from 'bonsai-js/runtime'
|
|
309
|
+
|
|
310
|
+
const variables = { user: t.object({ age: t.number() }) }
|
|
311
|
+
|
|
312
|
+
// On the server, when the rule is saved.
|
|
313
|
+
const artifact = JSON.stringify(toArtifact(bonsai({ variables }).compile('user.age >= 18', { expect: t.boolean() })))
|
|
314
|
+
|
|
315
|
+
// In the browser.
|
|
316
|
+
const rule = runtime({ variables }).load(JSON.parse(artifact), { expect: t.boolean() })
|
|
317
|
+
rule.evaluateSync({ user: { age: 36 } }) // => true
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
An artifact records the checker's choices that affect results and a digest of the definitions they depend on, so it evaluates exactly as the compiled program does, or a runtime with different definitions refuses it with a `BonsaiArtifactError` saying why. See [Runtime](https://danfry1.github.io/bonsai-js/api/runtime).
|
|
321
|
+
|
|
302
322
|
## Syntax trees and visual editors
|
|
303
323
|
|
|
304
324
|
`env.parse(source)` returns a JSON syntax tree with source offsets on every node, and `print(tree)` turns a tree back into source. Printing is deterministic and round-trips: `print(env.parse(print(tree)))` is the same text, with the fewest parentheses that keep the meaning. That is what a visual rule builder needs: parse, edit the tree, print, save.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Type } from "./types.mjs";
|
|
2
|
+
//#region src/artifact.d.ts
|
|
3
|
+
/** The artifact format this release writes and reads. */
|
|
4
|
+
export declare const ARTIFACT_FORMAT = 1;
|
|
5
|
+
/**
|
|
6
|
+
* A call whose checked candidates are narrower than its function's overloads:
|
|
7
|
+
* `[nameStart, name, ...candidate overload indices]`.
|
|
8
|
+
*/
|
|
9
|
+
export type Plan = readonly [nameStart: number, name: string, ...candidates: number[]];
|
|
10
|
+
/**
|
|
11
|
+
* A checked, compiled expression as plain JSON data, for `bonsai-js/runtime`
|
|
12
|
+
* to load where the checker is not bundled. The source stays the record: a
|
|
13
|
+
* full environment can always compile it again.
|
|
14
|
+
*/
|
|
15
|
+
export interface Artifact {
|
|
16
|
+
/** Format version. */
|
|
17
|
+
readonly bonsai: typeof ARTIFACT_FORMAT;
|
|
18
|
+
/** The bonsai-js version that produced it (informational). */
|
|
19
|
+
readonly by: string;
|
|
20
|
+
readonly source: string;
|
|
21
|
+
/** The checker's overload choices where they are narrower than all overloads, by call. */
|
|
22
|
+
readonly plans: readonly Plan[];
|
|
23
|
+
/** The type the program was compiled to produce, if any. */
|
|
24
|
+
readonly expect?: Type;
|
|
25
|
+
/** Whether the result must be checked against `expect` at run time. */
|
|
26
|
+
readonly guard: boolean;
|
|
27
|
+
/** Whether evaluation may call an async host function. */
|
|
28
|
+
readonly async: boolean;
|
|
29
|
+
/** Binds the artifact to the definitions the checker saw; see artifactDigest. */
|
|
30
|
+
readonly digest: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A compiled program as plain JSON data, for `bonsai-js/runtime` to load and
|
|
34
|
+
* evaluate without the checker. It records the source, the checker's overload
|
|
35
|
+
* choices, the expected type, and a digest of the definitions they depend on,
|
|
36
|
+
* so a runtime with different definitions refuses it instead of running it
|
|
37
|
+
* differently. Throws TypeError for anything that is not a program compiled
|
|
38
|
+
* by an environment, and RangeError when the expected type is too large for a
|
|
39
|
+
* runtime to load (more than 100,000 type nodes).
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* const rule = env.compile('user.age >= 18', { expect: t.boolean() })
|
|
43
|
+
* const artifact = toArtifact(rule) // store it, or send it to the browser
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare function toArtifact(program: object): Artifact;
|
|
47
|
+
//#endregion
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { typeData } from "./types.mjs";
|
|
2
|
+
import { forEachChild } from "./syntax/ast.mjs";
|
|
3
|
+
import { compiledFacts } from "./declared.mjs";
|
|
4
|
+
import { encodeFunction, encodeType, hash64, lengthPrefixed } from "./digest.mjs";
|
|
5
|
+
import { VERSION } from "./version.mjs";
|
|
6
|
+
/**
|
|
7
|
+
* The most type nodes an artifact's `expect` may hold, counted along every
|
|
8
|
+
* path (shared subtrees count each time they are reached), so a type that
|
|
9
|
+
* repeats one subtree cannot hide its size. Its own constant rather than
|
|
10
|
+
* maxNodes: a large `t.enum` compiles on a default environment and must load
|
|
11
|
+
* on a default runtime.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_EXPECT_NODES = 1e5;
|
|
14
|
+
/** Throws RangeError when a type holds more than MAX_EXPECT_NODES nodes along all its paths. */
|
|
15
|
+
function assertExpectSize(type, what) {
|
|
16
|
+
let count = 0;
|
|
17
|
+
const stack = [type];
|
|
18
|
+
while (stack.length > 0) {
|
|
19
|
+
const next = stack.pop();
|
|
20
|
+
if (++count > 1e5) throw new RangeError(`${what} has more than ${MAX_EXPECT_NODES} type nodes`);
|
|
21
|
+
if (next.kind === "list") stack.push(next.element);
|
|
22
|
+
else if (next.kind === "union") stack.push(...next.types);
|
|
23
|
+
else if (next.kind === "map") {
|
|
24
|
+
stack.push(...Object.values(next.fields));
|
|
25
|
+
if (next.rest !== void 0) stack.push(next.rest);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Names of the functions a bound tree calls and the context variables it reads. */
|
|
30
|
+
function treeNames(root) {
|
|
31
|
+
const functions = /* @__PURE__ */ new Set();
|
|
32
|
+
const variables = /* @__PURE__ */ new Set();
|
|
33
|
+
const stack = [root];
|
|
34
|
+
while (stack.length > 0) {
|
|
35
|
+
const node = stack.pop();
|
|
36
|
+
if (node.type === "Call") functions.add(node.name);
|
|
37
|
+
else if (node.type === "Variable") variables.add(node.name);
|
|
38
|
+
forEachChild(node, (child) => {
|
|
39
|
+
stack.push(child);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
functions: [...functions].sort(),
|
|
44
|
+
variables: [...variables].sort()
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The digest of an artifact: its fields (all but `digest`), every function the
|
|
49
|
+
* bound tree calls, and the declared type of every variable it reads, since
|
|
50
|
+
* the checker's overload choices and result guard depend on all of them. The
|
|
51
|
+
* producer and the runtime compute it the same way, so a stale or edited
|
|
52
|
+
* artifact fails to load instead of running with choices made for something
|
|
53
|
+
* else.
|
|
54
|
+
*/
|
|
55
|
+
function artifactDigest(fields, root, lookup, variables) {
|
|
56
|
+
const names = treeNames(root);
|
|
57
|
+
const plans = fields.plans.map(([start, name, ...candidates]) => lengthPrefixed(`${start}:${lengthPrefixed(name)}${candidates.join(",")}`));
|
|
58
|
+
const functions = names.functions.map((name) => {
|
|
59
|
+
const def = lookup(name);
|
|
60
|
+
return def === void 0 ? lengthPrefixed(`?${name}`) : lengthPrefixed(encodeFunction(def));
|
|
61
|
+
});
|
|
62
|
+
const declared = names.variables.map((name) => {
|
|
63
|
+
const type = variables !== void 0 && Object.hasOwn(variables, name) ? variables[name] : void 0;
|
|
64
|
+
return lengthPrefixed(`${lengthPrefixed(name)}${type === void 0 ? "?" : encodeType(type)}`);
|
|
65
|
+
});
|
|
66
|
+
return hash64([
|
|
67
|
+
`bonsai${fields.bonsai}`,
|
|
68
|
+
lengthPrefixed(fields.by),
|
|
69
|
+
lengthPrefixed(fields.source),
|
|
70
|
+
lengthPrefixed(plans.join("")),
|
|
71
|
+
fields.expect === void 0 ? "-" : lengthPrefixed(encodeType(fields.expect)),
|
|
72
|
+
fields.guard ? "g" : "-",
|
|
73
|
+
fields.async ? "a" : "-",
|
|
74
|
+
lengthPrefixed(functions.join("")),
|
|
75
|
+
lengthPrefixed(declared.join(""))
|
|
76
|
+
].join(""));
|
|
77
|
+
}
|
|
78
|
+
const artifacts = /* @__PURE__ */ new WeakMap();
|
|
79
|
+
/**
|
|
80
|
+
* A compiled program as plain JSON data, for `bonsai-js/runtime` to load and
|
|
81
|
+
* evaluate without the checker. It records the source, the checker's overload
|
|
82
|
+
* choices, the expected type, and a digest of the definitions they depend on,
|
|
83
|
+
* so a runtime with different definitions refuses it instead of running it
|
|
84
|
+
* differently. Throws TypeError for anything that is not a program compiled
|
|
85
|
+
* by an environment, and RangeError when the expected type is too large for a
|
|
86
|
+
* runtime to load (more than 100,000 type nodes).
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* const rule = env.compile('user.age >= 18', { expect: t.boolean() })
|
|
90
|
+
* const artifact = toArtifact(rule) // store it, or send it to the browser
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
function toArtifact(program) {
|
|
94
|
+
const known = artifacts.get(program);
|
|
95
|
+
if (known !== void 0) return known;
|
|
96
|
+
const facts = compiledFacts(program);
|
|
97
|
+
if (facts === void 0) throw new TypeError("Not a program compiled by a Bonsai environment");
|
|
98
|
+
const artifact = makeArtifact(facts);
|
|
99
|
+
artifacts.set(program, artifact);
|
|
100
|
+
return artifact;
|
|
101
|
+
}
|
|
102
|
+
function makeArtifact({ source, analysis, expect, lookup, variables }) {
|
|
103
|
+
if (expect !== void 0) assertExpectSize(expect, "The expected type");
|
|
104
|
+
const plans = [];
|
|
105
|
+
for (const [call, plan] of analysis.calls) {
|
|
106
|
+
if (plan.candidates.length === plan.def.overloads.length) continue;
|
|
107
|
+
plans.push([
|
|
108
|
+
call.nameStart,
|
|
109
|
+
call.name,
|
|
110
|
+
...plan.candidates
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
plans.sort((a, b) => a[0] - b[0]);
|
|
114
|
+
const fields = {
|
|
115
|
+
bonsai: 1,
|
|
116
|
+
by: VERSION,
|
|
117
|
+
source,
|
|
118
|
+
plans,
|
|
119
|
+
...expect === void 0 ? {} : { expect: typeData(expect) },
|
|
120
|
+
guard: expect !== void 0 && analysis.checkResult,
|
|
121
|
+
async: analysis.async
|
|
122
|
+
};
|
|
123
|
+
const digest = artifactDigest(fields, analysis.root, lookup, variables);
|
|
124
|
+
return deepFreeze({
|
|
125
|
+
...fields,
|
|
126
|
+
digest
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function deepFreeze(value) {
|
|
130
|
+
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
|
131
|
+
Object.freeze(value);
|
|
132
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
export { MAX_EXPECT_NODES, artifactDigest, assertExpectSize, toArtifact, treeNames };
|
|
138
|
+
|
|
139
|
+
//# sourceMappingURL=artifact.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"artifact.mjs","names":[],"sources":["../src/artifact.ts"],"sourcesContent":["import { compiledFacts, type CompiledFacts } from './declared.js'\nimport { encodeFunction, encodeType, hash64, lengthPrefixed } from './digest.js'\nimport type { FunctionDef } from './functions/define.js'\nimport { forEachChild, type Node } from './syntax/ast.js'\nimport { typeData, type Type } from './types.js'\nimport { VERSION } from './version.js'\n\n/** The artifact format this release writes and reads. */\nexport const ARTIFACT_FORMAT = 1\n\n/**\n * The most type nodes an artifact's `expect` may hold, counted along every\n * path (shared subtrees count each time they are reached), so a type that\n * repeats one subtree cannot hide its size. Its own constant rather than\n * maxNodes: a large `t.enum` compiles on a default environment and must load\n * on a default runtime.\n */\nexport const MAX_EXPECT_NODES = 100_000\n\n/**\n * A call whose checked candidates are narrower than its function's overloads:\n * `[nameStart, name, ...candidate overload indices]`.\n */\nexport type Plan = readonly [nameStart: number, name: string, ...candidates: number[]]\n\n/**\n * A checked, compiled expression as plain JSON data, for `bonsai-js/runtime`\n * to load where the checker is not bundled. The source stays the record: a\n * full environment can always compile it again.\n */\nexport interface Artifact {\n /** Format version. */\n readonly bonsai: typeof ARTIFACT_FORMAT\n /** The bonsai-js version that produced it (informational). */\n readonly by: string\n readonly source: string\n /** The checker's overload choices where they are narrower than all overloads, by call. */\n readonly plans: readonly Plan[]\n /** The type the program was compiled to produce, if any. */\n readonly expect?: Type\n /** Whether the result must be checked against `expect` at run time. */\n readonly guard: boolean\n /** Whether evaluation may call an async host function. */\n readonly async: boolean\n /** Binds the artifact to the definitions the checker saw; see artifactDigest. */\n readonly digest: string\n}\n\n/** Throws RangeError when a type holds more than MAX_EXPECT_NODES nodes along all its paths. */\nexport function assertExpectSize(type: Type, what: string): void {\n let count = 0\n const stack: Type[] = [type]\n while (stack.length > 0) {\n const next = stack.pop() as Type\n if (++count > MAX_EXPECT_NODES)\n throw new RangeError(`${what} has more than ${MAX_EXPECT_NODES} type nodes`)\n if (next.kind === 'list') stack.push(next.element)\n else if (next.kind === 'union') stack.push(...next.types)\n else if (next.kind === 'map') {\n stack.push(...Object.values(next.fields))\n if (next.rest !== undefined) stack.push(next.rest)\n }\n }\n}\n\n/** Names of the functions a bound tree calls and the context variables it reads. */\nexport function treeNames(root: Node): { functions: string[]; variables: string[] } {\n const functions = new Set<string>()\n const variables = new Set<string>()\n const stack: Node[] = [root]\n while (stack.length > 0) {\n const node = stack.pop() as Node\n if (node.type === 'Call') functions.add(node.name)\n else if (node.type === 'Variable') variables.add(node.name)\n forEachChild(node, (child) => {\n stack.push(child)\n })\n }\n return { functions: [...functions].sort(), variables: [...variables].sort() }\n}\n\n/**\n * The digest of an artifact: its fields (all but `digest`), every function the\n * bound tree calls, and the declared type of every variable it reads, since\n * the checker's overload choices and result guard depend on all of them. The\n * producer and the runtime compute it the same way, so a stale or edited\n * artifact fails to load instead of running with choices made for something\n * else.\n */\nexport function artifactDigest(\n fields: Omit<Artifact, 'digest'>,\n root: Node,\n lookup: (name: string) => FunctionDef | undefined,\n variables: Readonly<Record<string, Type>> | undefined,\n): string {\n const names = treeNames(root)\n const plans = fields.plans.map(([start, name, ...candidates]) =>\n lengthPrefixed(`${start}:${lengthPrefixed(name)}${candidates.join(',')}`),\n )\n const functions = names.functions.map((name) => {\n const def = lookup(name)\n return def === undefined ? lengthPrefixed(`?${name}`) : lengthPrefixed(encodeFunction(def))\n })\n const declared = names.variables.map((name) => {\n const type =\n variables !== undefined && Object.hasOwn(variables, name) ? variables[name] : undefined\n return lengthPrefixed(`${lengthPrefixed(name)}${type === undefined ? '?' : encodeType(type)}`)\n })\n return hash64(\n [\n `bonsai${fields.bonsai}`,\n lengthPrefixed(fields.by),\n lengthPrefixed(fields.source),\n lengthPrefixed(plans.join('')),\n fields.expect === undefined ? '-' : lengthPrefixed(encodeType(fields.expect)),\n fields.guard ? 'g' : '-',\n fields.async ? 'a' : '-',\n lengthPrefixed(functions.join('')),\n lengthPrefixed(declared.join('')),\n ].join(''),\n )\n}\n\nconst artifacts = new WeakMap<object, Artifact>()\n\n/**\n * A compiled program as plain JSON data, for `bonsai-js/runtime` to load and\n * evaluate without the checker. It records the source, the checker's overload\n * choices, the expected type, and a digest of the definitions they depend on,\n * so a runtime with different definitions refuses it instead of running it\n * differently. Throws TypeError for anything that is not a program compiled\n * by an environment, and RangeError when the expected type is too large for a\n * runtime to load (more than 100,000 type nodes).\n *\n * ```ts\n * const rule = env.compile('user.age >= 18', { expect: t.boolean() })\n * const artifact = toArtifact(rule) // store it, or send it to the browser\n * ```\n */\nexport function toArtifact(program: object): Artifact {\n const known = artifacts.get(program)\n if (known !== undefined) return known\n const facts = compiledFacts(program)\n if (facts === undefined) throw new TypeError('Not a program compiled by a Bonsai environment')\n const artifact = makeArtifact(facts)\n artifacts.set(program, artifact)\n return artifact\n}\n\nfunction makeArtifact({ source, analysis, expect, lookup, variables }: CompiledFacts): Artifact {\n if (expect !== undefined) assertExpectSize(expect, 'The expected type')\n const plans: Plan[] = []\n for (const [call, plan] of analysis.calls) {\n if (plan.candidates.length === plan.def.overloads.length) continue\n // The parser sets nameStart on every call it builds.\n plans.push([call.nameStart as number, call.name, ...plan.candidates])\n }\n plans.sort((a, b) => a[0] - b[0])\n const fields: Omit<Artifact, 'digest'> = {\n bonsai: ARTIFACT_FORMAT,\n by: VERSION,\n source,\n plans,\n // Frozen plain data, as the artifact must be; a type built by t already is.\n ...(expect === undefined ? {} : { expect: typeData(expect) }),\n guard: expect !== undefined && analysis.checkResult,\n async: analysis.async,\n }\n const digest = artifactDigest(fields, analysis.root, lookup, variables)\n return deepFreeze({ ...fields, digest })\n}\n\nfunction deepFreeze<T>(value: T): T {\n if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {\n Object.freeze(value)\n for (const child of Object.values(value)) deepFreeze(child)\n }\n return value\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { isItemLambdaPosition, isLambdaPosition } from "../functions/define.mjs";
|
|
2
|
+
import { forEachChild, mapChildren } from "../syntax/ast.mjs";
|
|
3
|
+
//#region src/check/bind.ts
|
|
4
|
+
/**
|
|
5
|
+
* Binds the implicit parameter: an argument in a lambda position of the called
|
|
6
|
+
* function (per its overloads, see isLambdaPosition) that contains a free `.`
|
|
7
|
+
* becomes an implicit Lambda. Depends only on the tree and the signatures of
|
|
8
|
+
* the functions it calls, so the checker and code that compiles without it
|
|
9
|
+
* bind every source the same way.
|
|
10
|
+
*/
|
|
11
|
+
function bindImplicit(root, lookup) {
|
|
12
|
+
const problems = [];
|
|
13
|
+
if (!containsIt(root)) return {
|
|
14
|
+
root,
|
|
15
|
+
problems
|
|
16
|
+
};
|
|
17
|
+
/** Rewrites implicit lambdas as explicit ones; `free` when a `.` is left unbound. */
|
|
18
|
+
function bind(node) {
|
|
19
|
+
if (node.type === "It") return {
|
|
20
|
+
node,
|
|
21
|
+
free: true
|
|
22
|
+
};
|
|
23
|
+
if (node.type === "Call") return bindCall(node);
|
|
24
|
+
let free = false;
|
|
25
|
+
return {
|
|
26
|
+
node: mapChildren(node, (child) => {
|
|
27
|
+
const r = bind(child);
|
|
28
|
+
free ||= r.free;
|
|
29
|
+
return r.node;
|
|
30
|
+
}),
|
|
31
|
+
free: node.type === "Lambda" ? false : free
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function bindCall(node) {
|
|
35
|
+
const def = lookup(node.name);
|
|
36
|
+
let free = false;
|
|
37
|
+
let sawSpread = false;
|
|
38
|
+
const args = node.args.map((arg, index) => {
|
|
39
|
+
if (arg.type === "Spread") {
|
|
40
|
+
sawSpread = true;
|
|
41
|
+
const r = bind(arg.argument);
|
|
42
|
+
free ||= r.free;
|
|
43
|
+
return {
|
|
44
|
+
...arg,
|
|
45
|
+
argument: r.node
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const lambdaPosition = !sawSpread && (def === void 0 || isLambdaPosition(def, index));
|
|
49
|
+
if (arg.type === "Lambda") {
|
|
50
|
+
const body = bind(arg.body);
|
|
51
|
+
if (body.free) problems.push({
|
|
52
|
+
message: "Use the lambda parameter instead of \".\" inside an explicit lambda",
|
|
53
|
+
at: findIt(body.node) ?? arg
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
...arg,
|
|
57
|
+
body: body.node
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const r = bind(arg);
|
|
61
|
+
if (lambdaPosition && r.free) {
|
|
62
|
+
if (def !== void 0 && !isItemLambdaPosition(def, index)) problems.push({
|
|
63
|
+
message: `"." is the current item, but the first parameter of ${def.name}'s lambda is not; write the parameters, e.g. (acc, x) => acc + x`,
|
|
64
|
+
at: findIt(r.node) ?? arg
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
type: "Lambda",
|
|
68
|
+
params: [],
|
|
69
|
+
implicit: true,
|
|
70
|
+
body: r.node,
|
|
71
|
+
start: r.node.start,
|
|
72
|
+
end: r.node.end
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
free ||= r.free;
|
|
76
|
+
return r.node;
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
node: {
|
|
80
|
+
...node,
|
|
81
|
+
args
|
|
82
|
+
},
|
|
83
|
+
free
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const bound = bind(root);
|
|
87
|
+
if (bound.free) problems.push({
|
|
88
|
+
message: "\".\" (the current item) must be inside an argument of a function that takes a lambda, e.g. items.filter(.active)",
|
|
89
|
+
at: findIt(bound.node) ?? bound.node
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
root: bound.node,
|
|
93
|
+
problems
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function containsIt(node) {
|
|
97
|
+
if (node.type === "It") return true;
|
|
98
|
+
let found = false;
|
|
99
|
+
forEachChild(node, (child) => {
|
|
100
|
+
if (!found && containsIt(child)) found = true;
|
|
101
|
+
});
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
function findIt(node) {
|
|
105
|
+
if (node.type === "It") return node;
|
|
106
|
+
let found;
|
|
107
|
+
const visit = (child) => {
|
|
108
|
+
found ??= findIt(child);
|
|
109
|
+
};
|
|
110
|
+
if (node.type === "Lambda") return void 0;
|
|
111
|
+
forEachChild(node, visit);
|
|
112
|
+
return found;
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
export { bindImplicit };
|
|
116
|
+
|
|
117
|
+
//# sourceMappingURL=bind.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bind.mjs","names":[],"sources":["../../src/check/bind.ts"],"sourcesContent":["import { isItemLambdaPosition, isLambdaPosition, type FunctionDef } from '../functions/define.js'\nimport {\n forEachChild,\n mapChildren,\n type CallNode,\n type LambdaNode,\n type Node,\n type SpreadNode,\n} from '../syntax/ast.js'\n\n/** A `.` that binds to no lambda, or to the wrong parameter: an INVALID_LAMBDA diagnostic. */\nexport interface BindProblem {\n readonly message: string\n readonly at: { readonly start: number; readonly end: number }\n}\n\nexport interface Bound {\n /** The tree with implicit lambdas made explicit. */\n readonly root: Node\n /** In the order the checker reports them. */\n readonly problems: readonly BindProblem[]\n}\n\n/**\n * Binds the implicit parameter: an argument in a lambda position of the called\n * function (per its overloads, see isLambdaPosition) that contains a free `.`\n * becomes an implicit Lambda. Depends only on the tree and the signatures of\n * the functions it calls, so the checker and code that compiles without it\n * bind every source the same way.\n */\nexport function bindImplicit(root: Node, lookup: (name: string) => FunctionDef | undefined): Bound {\n const problems: BindProblem[] = []\n // Binding only rewrites the tree around `.`; most expressions have none.\n if (!containsIt(root)) return { root, problems }\n\n /** Rewrites implicit lambdas as explicit ones; `free` when a `.` is left unbound. */\n function bind(node: Node): { node: Node; free: boolean } {\n if (node.type === 'It') return { node, free: true }\n if (node.type === 'Call') return bindCall(node)\n let free = false\n const out = mapChildren(node, (child) => {\n const r = bind(child)\n free ||= r.free\n return r.node\n })\n // A lambda outside a call argument position (the parser only allows\n // arguments, so this is a lambda in a non-function parameter).\n return { node: out, free: node.type === 'Lambda' ? false : free }\n }\n\n function bindCall(node: CallNode): { node: Node; free: boolean } {\n const def = lookup(node.name)\n let free = false\n let sawSpread = false\n const args = node.args.map((arg, index): Node | SpreadNode => {\n if (arg.type === 'Spread') {\n sawSpread = true\n const r = bind(arg.argument)\n free ||= r.free\n return { ...arg, argument: r.node }\n }\n // An unknown function reports itself; treat its arguments as lambdas so a\n // misspelled filter(.x) does not also report a stray `.`.\n const lambdaPosition = !sawSpread && (def === undefined || isLambdaPosition(def, index))\n if (arg.type === 'Lambda') {\n const body = bind(arg.body)\n if (body.free) {\n problems.push({\n message: 'Use the lambda parameter instead of \".\" inside an explicit lambda',\n at: findIt(body.node) ?? arg,\n })\n }\n return { ...arg, body: body.node }\n }\n const r = bind(arg)\n if (lambdaPosition && r.free) {\n if (def !== undefined && !isItemLambdaPosition(def, index)) {\n problems.push({\n message: `\".\" is the current item, but the first parameter of ${def.name}'s lambda is not; write the parameters, e.g. (acc, x) => acc + x`,\n at: findIt(r.node) ?? arg,\n })\n }\n const lambda: LambdaNode = {\n type: 'Lambda',\n params: [],\n implicit: true,\n body: r.node,\n start: r.node.start,\n end: r.node.end,\n }\n return lambda\n }\n free ||= r.free\n return r.node\n })\n return { node: { ...node, args }, free }\n }\n\n const bound = bind(root)\n if (bound.free) {\n problems.push({\n message:\n '\".\" (the current item) must be inside an argument of a function that takes a lambda, e.g. items.filter(.active)',\n at: findIt(bound.node) ?? bound.node,\n })\n }\n return { root: bound.node, problems }\n}\n\nfunction containsIt(node: Node): boolean {\n if (node.type === 'It') return true\n let found = false\n forEachChild(node, (child) => {\n if (!found && containsIt(child)) found = true\n })\n return found\n}\n\nfunction findIt(node: Node): Node | undefined {\n if (node.type === 'It') return node\n let found: Node | undefined\n const visit = (child: Node): void => {\n found ??= findIt(child)\n }\n if (node.type === 'Lambda') return undefined\n forEachChild(node, visit)\n return found\n}\n"],"mappings":";;;;;;;;;;AA8BA,SAAgB,aAAa,MAAY,QAA0D;CACjG,MAAM,WAA0B,CAAC;CAEjC,IAAI,CAAC,WAAW,IAAI,GAAG,OAAO;EAAE;EAAM;CAAS;;CAG/C,SAAS,KAAK,MAA2C;EACvD,IAAI,KAAK,SAAS,MAAM,OAAO;GAAE;GAAM,MAAM;EAAK;EAClD,IAAI,KAAK,SAAS,QAAQ,OAAO,SAAS,IAAI;EAC9C,IAAI,OAAO;EAQX,OAAO;GAAE,MAPG,YAAY,OAAO,UAAU;IACvC,MAAM,IAAI,KAAK,KAAK;IACpB,SAAS,EAAE;IACX,OAAO,EAAE;GACX,CAGiB;GAAG,MAAM,KAAK,SAAS,WAAW,QAAQ;EAAK;CAClE;CAEA,SAAS,SAAS,MAA+C;EAC/D,MAAM,MAAM,OAAO,KAAK,IAAI;EAC5B,IAAI,OAAO;EACX,IAAI,YAAY;EAChB,MAAM,OAAO,KAAK,KAAK,KAAK,KAAK,UAA6B;GAC5D,IAAI,IAAI,SAAS,UAAU;IACzB,YAAY;IACZ,MAAM,IAAI,KAAK,IAAI,QAAQ;IAC3B,SAAS,EAAE;IACX,OAAO;KAAE,GAAG;KAAK,UAAU,EAAE;IAAK;GACpC;GAGA,MAAM,iBAAiB,CAAC,cAAc,QAAQ,KAAA,KAAa,iBAAiB,KAAK,KAAK;GACtF,IAAI,IAAI,SAAS,UAAU;IACzB,MAAM,OAAO,KAAK,IAAI,IAAI;IAC1B,IAAI,KAAK,MACP,SAAS,KAAK;KACZ,SAAS;KACT,IAAI,OAAO,KAAK,IAAI,KAAK;IAC3B,CAAC;IAEH,OAAO;KAAE,GAAG;KAAK,MAAM,KAAK;IAAK;GACnC;GACA,MAAM,IAAI,KAAK,GAAG;GAClB,IAAI,kBAAkB,EAAE,MAAM;IAC5B,IAAI,QAAQ,KAAA,KAAa,CAAC,qBAAqB,KAAK,KAAK,GACvD,SAAS,KAAK;KACZ,SAAS,uDAAuD,IAAI,KAAK;KACzE,IAAI,OAAO,EAAE,IAAI,KAAK;IACxB,CAAC;IAUH,OAAO;KAPL,MAAM;KACN,QAAQ,CAAC;KACT,UAAU;KACV,MAAM,EAAE;KACR,OAAO,EAAE,KAAK;KACd,KAAK,EAAE,KAAK;IAEF;GACd;GACA,SAAS,EAAE;GACX,OAAO,EAAE;EACX,CAAC;EACD,OAAO;GAAE,MAAM;IAAE,GAAG;IAAM;GAAK;GAAG;EAAK;CACzC;CAEA,MAAM,QAAQ,KAAK,IAAI;CACvB,IAAI,MAAM,MACR,SAAS,KAAK;EACZ,SACE;EACF,IAAI,OAAO,MAAM,IAAI,KAAK,MAAM;CAClC,CAAC;CAEH,OAAO;EAAE,MAAM,MAAM;EAAM;CAAS;AACtC;AAEA,SAAS,WAAW,MAAqB;CACvC,IAAI,KAAK,SAAS,MAAM,OAAO;CAC/B,IAAI,QAAQ;CACZ,aAAa,OAAO,UAAU;EAC5B,IAAI,CAAC,SAAS,WAAW,KAAK,GAAG,QAAQ;CAC3C,CAAC;CACD,OAAO;AACT;AAEA,SAAS,OAAO,MAA8B;CAC5C,IAAI,KAAK,SAAS,MAAM,OAAO;CAC/B,IAAI;CACJ,MAAM,SAAS,UAAsB;EACnC,UAAU,OAAO,KAAK;CACxB;CACA,IAAI,KAAK,SAAS,UAAU,OAAO,KAAA;CACnC,aAAa,MAAM,KAAK;CACxB,OAAO;AACT"}
|