rulit 1.0.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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jordi Bermejo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,545 @@
1
+ <p align="center">
2
+ <img src="static/ui.png" alt="Rulit UI screenshot" width="720" style="max-width: 100%; height: auto;" />
3
+ </p>
4
+
5
+ # Rulit
6
+
7
+ Type-safe rules engine with a fluent builder API and explainable traces.
8
+
9
+ ## Problem It Solves
10
+
11
+ When business rules live as scattered `if/else` chains, they become hard to read,
12
+ hard to change, and risky to refactor. This library centralizes decision logic,
13
+ keeps it type-safe, and produces explainable traces so you can audit outcomes.
14
+
15
+ ## When to use
16
+
17
+ Use Rulit when you need:
18
+
19
+ - Complex decision logic that changes frequently.
20
+ - Transparent, explainable outcomes for audit or support.
21
+ - Typed facts/effects to make refactors safe.
22
+ - A single, reusable rules engine shared across services.
23
+
24
+ You may not need it when:
25
+
26
+ - Logic is trivial and unlikely to grow.
27
+ - Performance requires a specialized rule engine or Rete.
28
+ - You only need a static feature flag system.
29
+
30
+ ## Features
31
+
32
+ - Fluent builder API (`ruleset().rule().when().then().end()`).
33
+ - Typed facts and effects with path-safe selectors.
34
+ - Deterministic ordering with priorities and insertion order.
35
+ - Explainable traces with reason codes and notes.
36
+ - Custom operators and operator registry.
37
+ - Rule metadata (tags, descriptions, versioning) with filtering.
38
+ - Immutable or mutable effects with patch merging.
39
+ - Validation hooks and Zod helpers.
40
+ - Async rule actions with `thenAsync()` + `runAsync()`.
41
+ - Optional OpenTelemetry adapter for rule execution spans.
42
+
43
+ ## At a glance
44
+
45
+ - Define rules with strong typing and composable conditions.
46
+ - Run engines deterministically and capture explainable traces.
47
+ - Visualize rulesets with Mermaid + inspect trace playback in the UI.
48
+
49
+ ## Install
50
+
51
+ ```sh
52
+ pnpm add rulit
53
+ ```
54
+
55
+ ## Quickstart
56
+
57
+ ```ts
58
+ import { Rules } from "rulit";
59
+
60
+ type Facts = {
61
+ user: { age: number; tags: string[] };
62
+ };
63
+
64
+ type Effects = { flags: string[] };
65
+
66
+ const rs = Rules.ruleset<Facts, Effects>("eligibility");
67
+ const factsField = rs.field();
68
+
69
+ rs.defaultEffects(() => ({ flags: [] }))
70
+ .rule("vip-adult")
71
+ .priority(100)
72
+ .when(Rules.op.and(factsField("user.age").gte(18), factsField("user.tags").contains("vip")))
73
+ .then(({ effects }) => {
74
+ effects.flags.push("vip-adult");
75
+ })
76
+ .end();
77
+
78
+ const result = rs.compile().run({
79
+ facts: { user: { age: 21, tags: ["vip"] } },
80
+ });
81
+
82
+ result.effects.flags;
83
+ result.explain();
84
+ ```
85
+
86
+ ## Core Concepts
87
+
88
+ - `ruleset(...)` starts a collection of rules for a given `Facts` and `Effects` type.
89
+ - `rule(id)` adds a new rule with a stable identifier (used in traces and `fired`).
90
+ - `priority(n)` controls ordering: higher numbers run first; ties use insertion order.
91
+ - `when(...)` defines conditions for the rule. If omitted, the rule always matches.
92
+ - `then(...)` defines what happens when a rule matches; it can mutate effects or return a patch.
93
+ - `thenAsync(...)` defines async effects; use `runAsync()` to execute them.
94
+ - `end()` finalizes the rule and returns to the ruleset builder.
95
+ - `defaultEffects(fn)` creates a fresh effects object for each run.
96
+ - `compile()` freezes the ruleset into an engine; `run()` or `runAsync()` executes it on facts.
97
+
98
+ ### What is an effect?
99
+
100
+ An **effect** is the output you build up while rules run. Think of it as a result
101
+ object that starts empty (from `defaultEffects`) and gets filled in by `then(...)`.
102
+
103
+ Common uses:
104
+
105
+ - Collect flags (`effects.flags.push("vip")`)
106
+ - Compute a decision (`effects.decision = "approve"`)
107
+ - Accumulate totals (`effects.total += 10`)
108
+
109
+ Effects are **not** the input facts. Facts stay immutable; effects are the
110
+ mutable or patched output of the ruleset.
111
+
112
+ ```ts
113
+ type Effects = { flags: string[]; decision?: "approve" | "deny" };
114
+
115
+ const rs = Rules.ruleset<Facts, Effects>("effects")
116
+ .defaultEffects(() => ({ flags: [] }))
117
+ .rule("approve")
118
+ .when(Rules.condition("always", () => true))
119
+ .then(({ effects }) => {
120
+ effects.flags.push("matched");
121
+ effects.decision = "approve";
122
+ })
123
+ .end();
124
+
125
+ const result = rs.compile().run({ facts });
126
+ result.effects.decision; // "approve"
127
+ ```
128
+
129
+ ### `defaultEffects` in practice
130
+
131
+ Always provide a factory that returns a new object so runs don't share state.
132
+
133
+ ```ts
134
+ const rs = Rules.ruleset<Facts, Effects>("rs").defaultEffects(() => ({
135
+ flags: [],
136
+ }));
137
+ ```
138
+
139
+ ### `priority` in practice
140
+
141
+ If two rules match, the higher priority rule runs first. Use it to enforce overrides.
142
+
143
+ ```ts
144
+ ruleset
145
+ .rule("high")
146
+ .priority(100)
147
+ .when(...)
148
+ .then(...)
149
+ .end()
150
+ .rule("low")
151
+ .priority(10)
152
+ .when(...)
153
+ .then(...)
154
+ .end();
155
+ ```
156
+
157
+ ### Run Options
158
+
159
+ - `activation`: `"all"` or `"first"`.
160
+ - `effectsMode`: `"mutable"` or `"immutable"` (clone per rule).
161
+ - `mergeStrategy`: `"assign"` or `"deep"` for returned patches.
162
+ - `includeTags` / `excludeTags`: rule filtering by tags.
163
+ - `rollbackOnError`: keep previous effects when a rule throws.
164
+ - Use `runAsync()` when any rule action is async.
165
+ - Rule actions may return a partial effects patch, merged by `mergeStrategy`.
166
+
167
+ ### What are tags?
168
+
169
+ Tags are optional labels you attach to rules for filtering and organization. Use them
170
+ to group rules by feature, environment, or business segment, then run only the
171
+ relevant subset with `includeTags` / `excludeTags`.
172
+
173
+ ```ts
174
+ const rs = Rules.ruleset<Facts, Effects>("tagged")
175
+ .defaultEffects(() => ({ flags: [] }))
176
+ .rule("vip")
177
+ .tags("vip", "beta")
178
+ .when(Rules.condition("always", () => true))
179
+ .then(({ effects }) => effects.flags.push("vip"))
180
+ .end();
181
+
182
+ rs.compile().run({ facts, includeTags: ["vip"] });
183
+ ```
184
+
185
+ ## Traces and Explainability
186
+
187
+ Every rule produces a `RuleTrace` with condition evaluations, match status, and notes.
188
+ You can read raw trace data or use `result.explain()` for a human-readable summary.
189
+
190
+ ```ts
191
+ const rs = Rules.ruleset<Facts, Effects>("trace-demo")
192
+ .defaultEffects(() => ({ flags: [] }))
193
+ .rule("adult")
194
+ .reasonCode("RULE_ADULT")
195
+ .when(
196
+ Rules.condition("age >= 18", (facts) => facts.user.age >= 18, {
197
+ details: (facts) => ({ left: facts.user.age, op: ">=", right: 18 }),
198
+ reasonCode: "COND_AGE",
199
+ }),
200
+ )
201
+ .then(({ effects, trace }) => {
202
+ effects.flags.push("adult");
203
+ trace.note("age check passed");
204
+ })
205
+ .end();
206
+
207
+ const result = rs.compile().run({ facts: { user: { age: 20, tags: [] } } });
208
+
209
+ result.trace[0]; // structured trace data
210
+ result.explain(); // formatted explanation
211
+ ```
212
+
213
+ Example explain output:
214
+
215
+ ```
216
+ Ruleset trace-demo
217
+ - Rule adult: matched [reason: RULE_ADULT]
218
+ - [true] age >= 18 {reason: COND_AGE} ("20" >= "18")
219
+ - note: age check passed
220
+ ```
221
+
222
+ ## Examples
223
+
224
+ ### Conditional composition
225
+
226
+ ```ts
227
+ const factsField = Rules.field<Facts>();
228
+ const isAdult = factsField("user.age").gte(18);
229
+ const isVip = factsField("user.tags").contains("vip");
230
+
231
+ Rules.ruleset<Facts, Effects>("combo")
232
+ .defaultEffects(() => ({ flags: [] }))
233
+ .rule("vip-adult")
234
+ .when(Rules.op.and(isAdult, isVip))
235
+ .then(({ effects }) => {
236
+ effects.flags.push("vip-adult");
237
+ })
238
+ .end();
239
+ ```
240
+
241
+ ### Visualize a ruleset
242
+
243
+ ```ts
244
+ const rs = Rules.ruleset<Facts, Effects>("viz")
245
+ .defaultEffects(() => ({ flags: [] }))
246
+ .rule("adult")
247
+ .when(Rules.field<Facts>()("user.age").gte(18))
248
+ .then(({ effects }) => effects.flags.push("adult"))
249
+ .end();
250
+
251
+ const mermaid = rs.toMermaid();
252
+ ```
253
+
254
+ Mermaid output (snippet):
255
+
256
+ ```
257
+ flowchart TD
258
+ n0["Ruleset: viz"]
259
+ n1["Rule: adult"]
260
+ n2["Condition: user.age >= 18"]
261
+ n0 --> n1
262
+ n1 --> n2
263
+ ```
264
+
265
+ ### Registry and Static UI
266
+
267
+ Rulesets are tracked in-memory and can be listed or visualized:
268
+
269
+ ```ts
270
+ Rules.registry.list();
271
+ Rules.registry.getGraph("eligibility");
272
+ Rules.registry.getMermaid("eligibility");
273
+ ```
274
+
275
+ CLI to run the UI locally (TS loader via `tsx`):
276
+
277
+ ```sh
278
+ pnpm run ui --load ./examples/rules.ts --port 5173
279
+ ```
280
+
281
+ The `--load` option imports a module that defines your rulesets so the registry is populated.
282
+
283
+ The UI includes Mermaid diagrams, JSON graph output, and trace playback with run inputs.
284
+ Trace playback appears after you execute a compiled engine; each run captures the input facts,
285
+ fired rules, and per-rule condition traces in expandable rows.
286
+
287
+ You can also load rules via env vars:
288
+
289
+ ```sh
290
+ RULIT_UI_LOAD=./path/to/rules.ts RULIT_UI_PORT=5173 pnpm run ui
291
+ ```
292
+
293
+ ### Async effects
294
+
295
+ Use `thenAsync()` to define async rule actions and `runAsync()` to execute them.
296
+ Calling `run()` when async actions are present will throw.
297
+
298
+ ```ts
299
+ const rs = Rules.ruleset<Facts, Effects>("async")
300
+ .defaultEffects(() => ({ flags: [] }))
301
+ .rule("fetch")
302
+ .when(factsField("user.age").gte(18))
303
+ .thenAsync(async ({ effects }) => {
304
+ const flag = await Promise.resolve("verified");
305
+ effects.flags.push(flag);
306
+ })
307
+ .end();
308
+
309
+ const result = await rs.compile().runAsync({ facts });
310
+ ```
311
+
312
+ ### OpenTelemetry
313
+
314
+ Attach an OpenTelemetry-compatible adapter to emit spans for runs, rules, and conditions:
315
+
316
+ ```ts
317
+ import { trace } from "@opentelemetry/api";
318
+
319
+ const adapter = Rules.otel.createAdapter(trace.getTracer("rulit"));
320
+ const rs = Rules.ruleset<Facts, Effects>("telemetry")
321
+ .defaultEffects(() => ({ flags: [] }))
322
+ .telemetry(adapter)
323
+ .rule("adult")
324
+ .when(factsField("user.age").gte(18))
325
+ .then(({ effects }) => {
326
+ effects.flags.push("adult");
327
+ })
328
+ .end();
329
+
330
+ rs.compile().run({ facts });
331
+ ```
332
+
333
+ ### Rule metadata and filters
334
+
335
+ ```ts
336
+ const rs = Rules.ruleset<Facts, Effects>("meta")
337
+ .defaultEffects(() => ({ flags: [] }))
338
+ .rule("vip-adult")
339
+ .tags("vip", "adult")
340
+ .description("VIP adults")
341
+ .version("1.0.0")
342
+ .reasonCode("VIP_ADULT")
343
+ .when(factsField("user.age").gte(18))
344
+ .then(({ effects }) => {
345
+ effects.flags.push("vip-adult");
346
+ })
347
+ .end();
348
+
349
+ const result = rs.compile().run({
350
+ facts,
351
+ includeTags: ["vip"],
352
+ });
353
+ ```
354
+
355
+ ### Return a patch from actions
356
+
357
+ ```ts
358
+ const rs = Rules.ruleset<Facts, Effects>("patch")
359
+ .defaultEffects(() => ({ flags: [] }))
360
+ .rule("vip")
361
+ .when(Rules.field<Facts>()("user.tags").contains("vip"))
362
+ .then(() => ({ flags: ["vip"] }))
363
+ .end();
364
+
365
+ const result = rs.compile().run({ facts, mergeStrategy: "assign" });
366
+ ```
367
+
368
+ ### Immutable effects
369
+
370
+ ```ts
371
+ type Effects = { stats: { count: number } };
372
+
373
+ const rs = Rules.ruleset<Facts, Effects>("immutable")
374
+ .defaultEffects(() => ({ stats: { count: 0 } }))
375
+ .rule("increment")
376
+ .when(Rules.condition("always", () => true))
377
+ .then(() => ({ stats: { count: 1 } }))
378
+ .end();
379
+
380
+ const result = rs.compile().run({
381
+ facts,
382
+ effectsMode: "immutable",
383
+ mergeStrategy: "deep",
384
+ });
385
+ ```
386
+
387
+ ### Zod validation
388
+
389
+ ```ts
390
+ import { z } from "zod";
391
+
392
+ const factsSchema = z.object({
393
+ user: z.object({ age: z.number(), tags: z.array(z.string()) }),
394
+ });
395
+ const effectsSchema = z.object({ flags: z.array(z.string()) });
396
+
397
+ const rs = Rules.ruleset<Facts, Effects>("validate")
398
+ .defaultEffects(() => ({ flags: [] }))
399
+ .validateFacts(Rules.zodFacts(factsSchema))
400
+ .validateEffects(Rules.zodEffects(effectsSchema));
401
+ ```
402
+
403
+ ### Custom operators
404
+
405
+ ```ts
406
+ const isEven = Rules.op.custom("is-even", (facts: { value: number }) => facts.value % 2 === 0);
407
+
408
+ Rules.op.register("positive", () =>
409
+ Rules.condition("positive", (facts: { value: number }) => facts.value > 0),
410
+ );
411
+
412
+ const positive = Rules.op.use<{ value: number }, []>("positive");
413
+ ```
414
+
415
+ ### Integrate into an existing codebase
416
+
417
+ **Before**
418
+
419
+ ```ts
420
+ // eligibility.ts
421
+ export function evaluateEligibility(user: { age: number; tags: string[] }) {
422
+ const flags: string[] = [];
423
+ let decision: "approve" | "deny" = "deny";
424
+
425
+ if (user.age >= 18 && user.tags.includes("vip")) {
426
+ flags.push("vip-adult");
427
+ decision = "approve";
428
+ }
429
+
430
+ return { decision, flags };
431
+ }
432
+ ```
433
+
434
+ **After**
435
+
436
+ ```ts
437
+ // eligibility.ts
438
+ import { Rules } from "rulit";
439
+
440
+ type Facts = { user: { age: number; tags: string[] } };
441
+ type Effects = { flags: string[]; decision?: "approve" | "deny" };
442
+
443
+ const ruleset = Rules.ruleset<Facts, Effects>("eligibility")
444
+ .defaultEffects(() => ({ flags: [] }))
445
+ .rule("vip-adult")
446
+ .tags("vip")
447
+ .when(Rules.field<Facts>()("user.age").gte(18))
448
+ .then(({ effects }) => {
449
+ effects.flags.push("vip-adult");
450
+ effects.decision = "approve";
451
+ })
452
+ .end()
453
+ .compile();
454
+
455
+ export function evaluateEligibility(user: Facts["user"]) {
456
+ const result = ruleset.run({ facts: { user }, includeTags: ["vip"] });
457
+ return {
458
+ decision: result.effects.decision ?? "deny",
459
+ flags: result.effects.flags,
460
+ trace: result.trace,
461
+ };
462
+ }
463
+ ```
464
+
465
+ ## Scripts
466
+
467
+ - `pnpm run typecheck` - TypeScript typecheck
468
+ - `pnpm run build` - Compile to `dist`
469
+ - `pnpm run test` - Run unit tests (Vitest)
470
+ - `pnpm run format` - Format with Prettier
471
+ - `pnpm run ci` - Typecheck + tests + format check
472
+
473
+ ## API
474
+
475
+ | Category | Member | Description |
476
+ | -------------- | ---------------------------- | ---------------------------------------------------------- |
477
+ | Rules | `Rules.ruleset` | Create a new ruleset builder. |
478
+ | Rules | `Rules.condition` | Build a condition with optional trace metadata. |
479
+ | Rules | `Rules.field` | Create a typed field accessor for facts. |
480
+ | Rules | `Rules.op` | Boolean/composition operators and operator registry. |
481
+ | Rules | `Rules.registry` | In-memory ruleset registry and trace store. |
482
+ | Rules | `Rules.zodFacts` | Wrap a Zod schema as a facts validator. |
483
+ | Rules | `Rules.zodEffects` | Wrap a Zod schema as an effects validator. |
484
+ | Rules | `Rules.otel` | OpenTelemetry helpers container. |
485
+ | Rules | `Rules.otel.createAdapter` | Build a telemetry adapter from an OTEL tracer. |
486
+ | Rules.op | `Rules.op.and` | Combine conditions with logical AND. |
487
+ | Rules.op | `Rules.op.or` | Combine conditions with logical OR. |
488
+ | Rules.op | `Rules.op.not` | Negate a condition. |
489
+ | Rules.op | `Rules.op.custom` | Create a custom condition wrapper. |
490
+ | Rules.op | `Rules.op.register` | Register a named operator factory. |
491
+ | Rules.op | `Rules.op.use` | Build a condition using a registered operator. |
492
+ | Rules.op | `Rules.op.has` | Check if an operator name is registered. |
493
+ | Rules.op | `Rules.op.list` | List registered operator names. |
494
+ | Rules.registry | `Rules.registry.register` | Register a ruleset in the registry. |
495
+ | Rules.registry | `Rules.registry.list` | List registered rulesets. |
496
+ | Rules.registry | `Rules.registry.getGraph` | Get a ruleset graph by id or name. |
497
+ | Rules.registry | `Rules.registry.getMermaid` | Get Mermaid output by id or name. |
498
+ | Rules.registry | `Rules.registry.recordTrace` | Record a trace run for a ruleset. |
499
+ | Rules.registry | `Rules.registry.listTraces` | List trace runs for a ruleset. |
500
+ | Rules.registry | `Rules.registry.getTrace` | Get a specific trace run by id. |
501
+ | Rules.registry | `Rules.registry.clear` | Clear the registry (useful in tests). |
502
+ | RulesetBuilder | `defaultEffects` | Set the default effects factory (required before compile). |
503
+ | RulesetBuilder | `validateFacts` | Register a facts validation hook. |
504
+ | RulesetBuilder | `validateEffects` | Register an effects validation hook. |
505
+ | RulesetBuilder | `telemetry` | Attach a telemetry adapter for spans. |
506
+ | RulesetBuilder | `rule` | Start building a new rule. |
507
+ | RulesetBuilder | `field` | Create a field helper bound to the ruleset facts type. |
508
+ | RulesetBuilder | `graph` | Export the ruleset graph structure. |
509
+ | RulesetBuilder | `toMermaid` | Export a Mermaid flowchart string. |
510
+ | RulesetBuilder | `compile` | Freeze the ruleset into an executable engine. |
511
+ | RuleBuilder | `priority` | Set rule priority (higher runs first). |
512
+ | RuleBuilder | `when` | Attach conditions to the rule. |
513
+ | RuleBuilder | `meta` | Set metadata (tags, version, reason, etc.). |
514
+ | RuleBuilder | `tags` | Set rule tags for filtering. |
515
+ | RuleBuilder | `description` | Set a human-readable description. |
516
+ | RuleBuilder | `version` | Set a version string for the rule. |
517
+ | RuleBuilder | `reasonCode` | Set a reason code used in traces. |
518
+ | RuleBuilder | `enabled` | Enable or disable the rule. |
519
+ | RuleBuilder | `then` | Define a synchronous rule action. |
520
+ | RuleBuilder | `thenAsync` | Define an async rule action. |
521
+ | RuleBuilder | `end` | Finalize the rule and return to the ruleset. |
522
+ | Engine | `run` | Execute rules synchronously against facts. |
523
+ | Engine | `runAsync` | Execute rules asynchronously against facts. |
524
+ | RunResult | `effects` | Final effects object after execution. |
525
+ | RunResult | `fired` | List of rule ids that fired. |
526
+ | RunResult | `trace` | Per-rule trace data. |
527
+ | RunResult | `explain` | Render a human-readable explanation string. |
528
+ | Field | `path` | The path string for the accessor. |
529
+ | Field | `get` | Read the value at the field path. |
530
+ | Field | `eq` | Condition for strict equality. |
531
+ | Field | `in` | Condition for membership in a list. |
532
+ | Field | `gt` | Condition for greater than (number). |
533
+ | Field | `gte` | Condition for greater than or equal (number). |
534
+ | Field | `lt` | Condition for less than (number). |
535
+ | Field | `lte` | Condition for less than or equal (number). |
536
+ | Field | `between` | Condition for numeric range inclusion. |
537
+ | Field | `contains` | Condition for string/array containment. |
538
+ | Field | `startsWith` | Condition for string prefix match. |
539
+ | Field | `matches` | Condition for regex match. |
540
+ | Field | `isTrue` | Condition for boolean true. |
541
+ | Field | `isFalse` | Condition for boolean false. |
542
+ | Field | `before` | Condition for date before another. |
543
+ | Field | `after` | Condition for date after another. |
544
+ | Field | `any` | Condition when any array item passes predicate. |
545
+ | Field | `all` | Condition when all array items pass predicate. |