@coldsmirk/abacus-core 0.4.1 → 0.5.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/README.md CHANGED
@@ -18,7 +18,7 @@ Part of [abacus](https://github.com/coldsmirk/abacus). To edit expressions in an
18
18
  pnpm add @coldsmirk/abacus-core
19
19
  ```
20
20
 
21
- The ZEN WebAssembly engine (`@gorules/zen-engine-wasm`) is a regular dependency and installs automatically. Node.js >= 22; browsers need ES2023 support (Chrome/Edge 111+, Safari 16.2+, Firefox 115+) — the bundles ship untranspiled.
21
+ The ZEN WebAssembly engine (`@gorules/zen-engine-wasm`) is a regular dependency and installs automatically. Node.js >= 24; browsers need ES2023 support (Chrome/Edge 111+, Safari 16.2+, Firefox 115+) — the bundles ship untranspiled.
22
22
 
23
23
  ## Quick start
24
24
 
@@ -131,7 +131,7 @@ configureEngine({ wasmInput: await fetch("https://cdn.example.com/zen.wasm").the
131
131
  await loadEngine();
132
132
  ```
133
133
 
134
- `configureEngine()` throws if called after the engine has started loading. `resetEngine()` drops the loaded engine, the cached type context, the latched error, and the configured input — mainly for tests, but also the way to re-`configureEngine()` after a load.
134
+ `configureEngine()` throws if called after the engine has started loading. `resetEngine()` drops the loaded engine, the cached type context, the latched error, and the configured input, and orphans any in-flight load its later settlement is discarded, and the next `loadEngine()` waits for it to settle before touching the wasm initializer (which tolerates no concurrent calls). Mainly for tests, and the retry primitive for a **failed** load (reset, re-`configureEngine()`, load again). Once the wasm module has initialized successfully it stays instantiated for the lifetime of the JS realm, so a *different* `wasmInput` cannot take effect after a successful load.
135
135
 
136
136
  ## Type analysis & intelligence
137
137
 
@@ -193,7 +193,7 @@ interface ExpressionTypeSpan {
193
193
  error: string | null; // a type error attached to this span, if any
194
194
  kind: ExpressionType; // the inferred type of this span
195
195
  nodeKind: string; // the syntax-node kind
196
- span: [number, number]; // [from, to] character offsets
196
+ span: [number, number]; // [from, to] UTF-16 code-unit offsets
197
197
  }
198
198
  interface ExpressionDiagnostic {
199
199
  from: number; // start offset
@@ -296,17 +296,20 @@ type BranchSelection =
296
296
 
297
297
  ### Condition trees
298
298
 
299
- The flat model above is one AND-group per branch. For a structured builder UI — arbitrarily nested AND/OR groups of typed comparison rules — the package also models a **condition tree** and compiles it to a single canonical expression, with an exact inverse:
299
+ The flat model above is one AND-group per branch. For a structured builder UI — bounded AND/OR groups of typed comparison rules — the package also models a **condition tree**, compiles it to a single canonical expression, and lifts non-empty canonical expressions back to trees:
300
300
 
301
301
  ```ts
302
302
  interface ConditionTreeRule { // a leaf comparison
303
303
  kind: "rule";
304
+ id?: string; // stable UI identity (React keys); ignored by the compiler
304
305
  left: string; // an identifier path, like `subject`
305
306
  operator: ConditionTreeOperator; // the compiler's full 14-operator set
306
307
  right?: ConditionTreeValue; // per the operator's arity (see below)
308
+ rightElementType?: "string" | "number"; // UI hint: element type intended for an EMPTY membership array
307
309
  }
308
- interface ConditionTreeGroup { // an and/or group of rules and subgroups
310
+ interface ConditionTreeGroup { // an and/or group, at most 64 nested group edges below the root
309
311
  kind: "group";
312
+ id?: string;
310
313
  op: "and" | "or";
311
314
  items: readonly ConditionTreeNode[];
312
315
  }
@@ -315,6 +318,10 @@ type ConditionTreeNode = ConditionTreeGroup | ConditionTreeRule;
315
318
  function compileConditionTree(tree: ConditionTreeGroup): string;
316
319
  function liftConditionTree(expression: string): ConditionTreeGroup | null;
317
320
  function conditionOperatorArity(operator: ConditionOperator): "scalar" | "array" | "none";
321
+ function emptyConditionGroup(): ConditionTreeGroup; // a fresh "no condition yet" and-group
322
+ function newConditionNodeId(): string; // a fresh id for hand-built UI nodes
323
+ function ensureConditionNodeIds(tree: ConditionTreeGroup): ConditionTreeGroup;
324
+ const MAX_CONDITION_TREE_DEPTH = 64;
318
325
  ```
319
326
 
320
327
  ```ts
@@ -339,14 +346,16 @@ A rule's `right` follows its operator's **arity** — a single scalar (`string |
339
346
 
340
347
  The emitted expression is **canonical**: nested groups are always parenthesized (never relying on `and`/`or` precedence), single-item groups collapse to their item, and a rule the tree cannot represent canonically — a non-path `left`, an off-arity `right`, a value with no ZEN literal — is dropped like an invalid flat condition. A tree with nothing compilable yields `""`.
341
348
 
342
- `liftConditionTree` recognizes exactly that canonical subset (whitespace-tolerantly), so the two functions round-trip: everything `compileConditionTree` emits lifts back to an equal tree, and anything else — a raw hand-authored expression, mixed `and`/`or` without parentheses, groups nested beyond 64 levels — returns `null` rather than guessing at structure. That `null` is a host's signal to fall back to raw-expression editing instead of a structured builder; [`@coldsmirk/abacus-mantine`](https://www.npmjs.com/package/@coldsmirk/abacus-mantine)'s `<ConditionBuilder>` is built on exactly this contract.
349
+ `liftConditionTree` recognizes exactly that non-empty canonical subset (whitespace-tolerantly): every non-empty expression `compileConditionTree` emits lifts back to the same **compiled** structure. This is not an identity round-trip for an arbitrary input tree. Compilation drops incomplete or unrepresentable rules, collapses single-item groups, and returns `""` when nothing is compilable (`liftConditionTree("")` returns `null`). The UI-only fields never reach the expression either, so a compile → lift trip regenerates `id`s and **loses `rightElementType`**: an empty membership's Number intent survives while you store the tree, but not through the expression (`a in []` lifts back with the default Text intent). Persist the tree itself when incomplete editing state, exact group shape, or UI intent must survive. Anything outside the canonical expression subset — a raw hand-authored expression, mixed `and`/`or` without parentheses, groups nested beyond 64 levels, number literals the compiler could never emit (`9007199254740993` would silently round through float64, `1e999` collapses to Infinity, `-0` stringifies to `0`, `-9223372036854776000` has no faithful literal even though its positive twin does) makes the lifter return `null` rather than guessing at structure or rewriting a value. That `null` is a host's signal to fall back to raw-expression editing instead of a structured builder; [`@coldsmirk/abacus-mantine`](https://www.npmjs.com/package/@coldsmirk/abacus-mantine)'s `<ConditionBuilder>` is built on exactly this contract.
350
+
351
+ All tree consumers share `MAX_CONDITION_TREE_DEPTH`: the root is depth 0 and up to 64 nested group edges are accepted. `compileConditionTree` and `ensureConditionNodeIds` throw `ExpressionError` for a deeper tree or an ancestor cycle, before entering their bounded recursive work; `liftConditionTree` returns `null` when expression nesting exceeds the same budget. The builder disables subgroup creation at the limit, so it cannot produce a tree the compiler would reject.
343
352
 
344
353
  ### Safety: subjects, values, and escaping
345
354
 
346
355
  The compiler is designed not to become an expression-injection sink:
347
356
 
348
- - A **subject** is emitted verbatim, so it must be a plain identifier path (`amount`, `user.age`, `items[0]`) whose segments are not ZEN reserved words (`and`, `or`, `not`, `in`, `true`, `false`, `null`). Anything else (`len(secret)`, `a or b`, a bare `true`, …) makes the condition compile to `null` and be dropped.
349
- - A **value** is serialized to a ZEN literal via `toZenLiteral`, never interpolated as code. Strings are quoted _raw_ (ZEN honours no backslash escapes) by picking a delimiter the value does not contain; a value that cannot be represented (an object, a string containing both quote styles, a non-finite number) makes the condition degrade to `null`.
357
+ - A **subject** is emitted verbatim, so it must be a plain identifier path (`amount`, `user.age`, `items[0]`) whose segments are not ZEN reserved words (`and`, `or`, `not`, `in`, `true`, `false`, `null`). Every array index token must also be inside ZEN's unsigned 96-bit decimal domain (`0` through `2^96 - 1`); the next value is a parser error, so the compiler rejects it instead of poisoning a larger expression. Anything else (`len(secret)`, `a or b`, a bare `true`, …) makes the condition compile to `null` and be dropped.
358
+ - A **value** is serialized to a ZEN literal via `toZenLiteral`, never interpolated as code. Strings are quoted _raw_ (ZEN honours no backslash escapes) by picking a delimiter the value does not contain; a value that cannot be represented faithfully makes the condition degrade to `null`. For numbers that means passing `isZenRepresentableNumber`: inside ZEN's 96-bit decimal domain (finite, magnitude below ~7.9e28, no finer than 1e-28) **and** held identically by the engine's own context conversion — the engine converts context JS numbers lossily (roughly: integers whose text fits u64 are exact; other f64s are trimmed to ~16 significant digits), so a value like `0.1 + 0.2` or `1.2345678901234567` would compile into a literal that silently compares **unequal to itself** in a context. Such values are rejected instead.
350
359
 
351
360
  ```ts
352
361
  function toZenLiteral(value: unknown): string; // throws if the value has no faithful ZEN representation
@@ -415,8 +424,8 @@ class ExpressionNotReadyError extends ExpressionError { // a *Sync helper calle
415
424
  - **Engine lifecycle** — `loadEngine`, `isEngineReady`, `getEngineSync`, `getEngineError`, `configureEngine`, `resetEngine`
416
425
  - **Type analysis** — `analyzeTypes` / `analyzeTypesSync`, `getDiagnostics` / `getDiagnosticsSync`, `getCompletionItems` / `getCompletionItemsSync`, `satisfiesType` / `satisfiesTypeSync`
417
426
  - **Templates** — `analyzeTemplate` / `analyzeTemplateSync`, `getTemplateDiagnostics` / `getTemplateDiagnosticsSync`, `parseTemplateHoles`, `templateHoleAt`
418
- - **Conditions** — `compileCondition`, `compileGroup`, `compileBranch`, `selectBranch`, `selectBranchWith`, `toZenLiteral`, `CONDITION_OPERATORS`, `conditionOperatorArity`
419
- - **Condition trees** — `compileConditionTree`, `liftConditionTree`, `CONDITION_TREE_OPERATORS`
427
+ - **Conditions** — `compileCondition`, `compileGroup`, `compileBranch`, `selectBranch`, `selectBranchWith`, `toZenLiteral`, `isZenRepresentableNumber`, `CONDITION_OPERATORS`, `conditionOperatorArity`
428
+ - **Condition trees** — `compileConditionTree`, `liftConditionTree`, `emptyConditionGroup`, `newConditionNodeId`, `ensureConditionNodeIds`, `MAX_CONDITION_TREE_DEPTH`, `CONDITION_TREE_OPERATORS`
420
429
  - **Localization** — `configureExpressionMessages`, `registerExpressionLocale`, `getExpressionMessages`, `enMessages`, `zhCNMessages`
421
430
  - **Errors** — `ExpressionError`, `ExpressionNotReadyError`
422
431
  - **Types** — `ExpressionType`, `ExpressionMode`, `TemplateHole`, `ExpressionContext`, `ExpressionEngine`, `LoadEngineOptions`, `ExpressionAnalysis`, `ExpressionTypeSpan`, `ExpressionCompletion`, `ExpressionDiagnostic`, `ExpressionMessages`, `ExpressionLocale`, `BuiltInExpressionLocale`, `ConfigureMessagesOptions`, `ConditionInput`, `FieldConditionInput`, `ExpressionConditionInput`, `ConditionGroupInput`, `ConditionBranchInput`, `ConditionOperator`, `ConditionOperatorArity`, `BranchSelection`, `ConditionTreeGroup`, `ConditionTreeNode`, `ConditionTreeRule`, `ConditionTreeOperator`, `ConditionTreeValue`, `ConditionScalar`