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/dist/index.js ADDED
@@ -0,0 +1,1357 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ Rules: () => Rules,
24
+ condition: () => condition,
25
+ createOtelAdapter: () => createOtelAdapter,
26
+ field: () => field,
27
+ op: () => op,
28
+ registry: () => registry2,
29
+ ruleset: () => ruleset,
30
+ zodEffects: () => zodEffects,
31
+ zodFacts: () => zodFacts
32
+ });
33
+ module.exports = __toCommonJS(src_exports);
34
+
35
+ // src/condition.ts
36
+ function condition(label, test, options) {
37
+ const meta = {
38
+ label,
39
+ reasonCode: typeof options === "object" ? options?.reasonCode : void 0,
40
+ kind: "atomic"
41
+ };
42
+ const cond = (facts) => {
43
+ const result = test(facts);
44
+ const details = typeof options === "function" ? options : options?.details;
45
+ const info = details ? details(facts) : void 0;
46
+ return {
47
+ label,
48
+ result,
49
+ left: info?.left,
50
+ op: info?.op,
51
+ right: info?.right,
52
+ reasonCode: meta.reasonCode
53
+ };
54
+ };
55
+ cond.meta = meta;
56
+ return cond;
57
+ }
58
+
59
+ // src/field.ts
60
+ function field(path) {
61
+ if (path === void 0) {
62
+ return (nextPath) => createField(nextPath);
63
+ }
64
+ return createField(path);
65
+ }
66
+ function createField(path) {
67
+ const getter = (facts) => getPathValue(facts, path);
68
+ const base = {
69
+ path,
70
+ get: getter,
71
+ eq: (value) => condition(
72
+ `${path} == ${String(value)}`,
73
+ (facts) => getter(facts) === value,
74
+ (facts) => ({
75
+ left: getter(facts),
76
+ op: "==",
77
+ right: value
78
+ })
79
+ ),
80
+ in: (values) => condition(
81
+ `${path} in [${values.length}]`,
82
+ (facts) => values.includes(getter(facts)),
83
+ (facts) => ({
84
+ left: getter(facts),
85
+ op: "in",
86
+ right: values
87
+ })
88
+ )
89
+ };
90
+ return addOperators(base);
91
+ }
92
+ function addOperators(base) {
93
+ const anyBase = base;
94
+ anyBase.gt = (value) => condition(
95
+ `${base.path} > ${value}`,
96
+ (facts) => Number(base.get(facts)) > value,
97
+ (facts) => ({
98
+ left: base.get(facts),
99
+ op: ">",
100
+ right: value
101
+ })
102
+ );
103
+ anyBase.gte = (value) => condition(
104
+ `${base.path} >= ${value}`,
105
+ (facts) => Number(base.get(facts)) >= value,
106
+ (facts) => ({
107
+ left: base.get(facts),
108
+ op: ">=",
109
+ right: value
110
+ })
111
+ );
112
+ anyBase.lt = (value) => condition(
113
+ `${base.path} < ${value}`,
114
+ (facts) => Number(base.get(facts)) < value,
115
+ (facts) => ({
116
+ left: base.get(facts),
117
+ op: "<",
118
+ right: value
119
+ })
120
+ );
121
+ anyBase.lte = (value) => condition(
122
+ `${base.path} <= ${value}`,
123
+ (facts) => Number(base.get(facts)) <= value,
124
+ (facts) => ({
125
+ left: base.get(facts),
126
+ op: "<=",
127
+ right: value
128
+ })
129
+ );
130
+ anyBase.between = (min, max) => condition(
131
+ `${base.path} between ${min} and ${max}`,
132
+ (facts) => {
133
+ const value = Number(base.get(facts));
134
+ return value >= min && value <= max;
135
+ },
136
+ (facts) => ({
137
+ left: base.get(facts),
138
+ op: "between",
139
+ right: [min, max]
140
+ })
141
+ );
142
+ anyBase.contains = (value) => condition(
143
+ `${base.path} contains ${String(value)}`,
144
+ (facts) => {
145
+ const current = base.get(facts);
146
+ if (typeof current === "string") {
147
+ return current.includes(String(value));
148
+ }
149
+ if (Array.isArray(current)) {
150
+ return current.includes(value);
151
+ }
152
+ return false;
153
+ },
154
+ (facts) => ({
155
+ left: base.get(facts),
156
+ op: "contains",
157
+ right: value
158
+ })
159
+ );
160
+ anyBase.startsWith = (value) => condition(
161
+ `${base.path} startsWith ${value}`,
162
+ (facts) => {
163
+ const current = base.get(facts);
164
+ return typeof current === "string" ? current.startsWith(value) : false;
165
+ },
166
+ (facts) => ({
167
+ left: base.get(facts),
168
+ op: "startsWith",
169
+ right: value
170
+ })
171
+ );
172
+ anyBase.matches = (value) => condition(
173
+ `${base.path} matches ${value.toString()}`,
174
+ (facts) => {
175
+ const current = base.get(facts);
176
+ return typeof current === "string" ? value.test(current) : false;
177
+ },
178
+ (facts) => ({
179
+ left: base.get(facts),
180
+ op: "matches",
181
+ right: value.toString()
182
+ })
183
+ );
184
+ anyBase.isTrue = () => condition(
185
+ `${base.path} is true`,
186
+ (facts) => base.get(facts) === true,
187
+ (facts) => ({
188
+ left: base.get(facts),
189
+ op: "is",
190
+ right: true
191
+ })
192
+ );
193
+ anyBase.isFalse = () => condition(
194
+ `${base.path} is false`,
195
+ (facts) => base.get(facts) === false,
196
+ (facts) => ({
197
+ left: base.get(facts),
198
+ op: "is",
199
+ right: false
200
+ })
201
+ );
202
+ anyBase.before = (value) => condition(
203
+ `${base.path} before ${value.toISOString()}`,
204
+ (facts) => {
205
+ const current = base.get(facts);
206
+ return current instanceof Date ? current.getTime() < value.getTime() : false;
207
+ },
208
+ (facts) => ({
209
+ left: base.get(facts),
210
+ op: "before",
211
+ right: value.toISOString()
212
+ })
213
+ );
214
+ anyBase.after = (value) => condition(
215
+ `${base.path} after ${value.toISOString()}`,
216
+ (facts) => {
217
+ const current = base.get(facts);
218
+ return current instanceof Date ? current.getTime() > value.getTime() : false;
219
+ },
220
+ (facts) => ({
221
+ left: base.get(facts),
222
+ op: "after",
223
+ right: value.toISOString()
224
+ })
225
+ );
226
+ anyBase.any = (predicate, label = `${base.path} any`) => condition(
227
+ label,
228
+ (facts) => {
229
+ const current = base.get(facts);
230
+ return Array.isArray(current) ? current.some(predicate) : false;
231
+ },
232
+ (facts) => ({
233
+ left: base.get(facts),
234
+ op: "any",
235
+ right: "predicate"
236
+ })
237
+ );
238
+ anyBase.all = (predicate, label = `${base.path} all`) => condition(
239
+ label,
240
+ (facts) => {
241
+ const current = base.get(facts);
242
+ return Array.isArray(current) ? current.every(predicate) : false;
243
+ },
244
+ (facts) => ({
245
+ left: base.get(facts),
246
+ op: "all",
247
+ right: "predicate"
248
+ })
249
+ );
250
+ return base;
251
+ }
252
+ function getPathValue(facts, path) {
253
+ const parts = String(path).split(".");
254
+ let current = facts;
255
+ for (const part of parts) {
256
+ if (current && typeof current === "object") {
257
+ current = current[part];
258
+ } else {
259
+ return void 0;
260
+ }
261
+ }
262
+ return current;
263
+ }
264
+
265
+ // src/op.ts
266
+ function resolveLabel(defaultLabel, label) {
267
+ if (!label) {
268
+ return defaultLabel;
269
+ }
270
+ return typeof label === "string" ? label : label.label;
271
+ }
272
+ var op = { and, or, not, custom, register, use, has, list };
273
+ var registry = {};
274
+ function and(...args) {
275
+ const { label, conditions } = splitArgs("and", args);
276
+ const meta = { label, kind: "and", children: conditions };
277
+ const cond = (facts) => {
278
+ const children = conditions.map((cond2) => cond2(facts));
279
+ const result = children.every((child) => child.result);
280
+ return {
281
+ label,
282
+ result,
283
+ op: "and",
284
+ left: children.map((child) => child.label),
285
+ right: void 0,
286
+ children
287
+ };
288
+ };
289
+ cond.meta = meta;
290
+ return cond;
291
+ }
292
+ function or(...args) {
293
+ const { label, conditions } = splitArgs("or", args);
294
+ const meta = { label, kind: "or", children: conditions };
295
+ const cond = (facts) => {
296
+ const children = conditions.map((cond2) => cond2(facts));
297
+ const result = children.some((child) => child.result);
298
+ return {
299
+ label,
300
+ result,
301
+ op: "or",
302
+ left: children.map((child) => child.label),
303
+ right: void 0,
304
+ children
305
+ };
306
+ };
307
+ cond.meta = meta;
308
+ return cond;
309
+ }
310
+ function not(labelOrCondition, maybeCondition) {
311
+ const label = resolveLabel(
312
+ "not",
313
+ typeof labelOrCondition === "function" ? void 0 : labelOrCondition
314
+ );
315
+ const conditionToNegate = typeof labelOrCondition === "function" ? labelOrCondition : maybeCondition;
316
+ const meta = {
317
+ label,
318
+ kind: "not",
319
+ children: [conditionToNegate]
320
+ };
321
+ const cond = (facts) => {
322
+ const child = conditionToNegate(facts);
323
+ return {
324
+ label,
325
+ result: !child.result,
326
+ op: "not",
327
+ left: child.label,
328
+ right: void 0,
329
+ children: [child]
330
+ };
331
+ };
332
+ cond.meta = meta;
333
+ return cond;
334
+ }
335
+ function custom(label, test, options) {
336
+ return condition(label, test, options);
337
+ }
338
+ function register(name, factory) {
339
+ if (registry[name]) {
340
+ throw new Error(`Operator "${name}" is already registered.`);
341
+ }
342
+ registry[name] = factory;
343
+ }
344
+ function use(name, ...args) {
345
+ const factory = registry[name];
346
+ if (!factory) {
347
+ throw new Error(`Operator "${name}" is not registered.`);
348
+ }
349
+ return factory(...args);
350
+ }
351
+ function has(name) {
352
+ return Boolean(registry[name]);
353
+ }
354
+ function list() {
355
+ return Object.keys(registry).sort();
356
+ }
357
+ function splitArgs(defaultLabel, args) {
358
+ if (args.length === 0) {
359
+ return { label: defaultLabel, conditions: [] };
360
+ }
361
+ const [first, ...rest] = args;
362
+ if (typeof first === "function") {
363
+ return { label: defaultLabel, conditions: args };
364
+ }
365
+ const conditions = rest;
366
+ return {
367
+ label: resolveLabel(defaultLabel, first),
368
+ conditions
369
+ };
370
+ }
371
+
372
+ // src/trace.ts
373
+ function explainTrace(trace, name) {
374
+ const header = name ? `Ruleset ${name}` : "Ruleset";
375
+ const lines = [header];
376
+ for (const rule of trace) {
377
+ const tags = rule.meta?.tags?.length ? ` [tags: ${rule.meta.tags.join(", ")}]` : "";
378
+ const reason = rule.meta?.reasonCode ? ` [reason: ${rule.meta.reasonCode}]` : "";
379
+ const skipped = rule.skippedReason ? ` (skipped: ${rule.skippedReason})` : "";
380
+ lines.push(
381
+ `- Rule ${rule.ruleId}: ${rule.matched ? "matched" : "skipped"}${skipped}${tags}${reason}`
382
+ );
383
+ for (const condition2 of rule.conditions) {
384
+ lines.push(...renderCondition(condition2, 2));
385
+ }
386
+ for (const note of rule.notes) {
387
+ lines.push(` - note: ${note}`);
388
+ }
389
+ }
390
+ return lines.join("\n");
391
+ }
392
+ function formatCondition(condition2) {
393
+ const parts = [`[${condition2.result ? "true" : "false"}]`, condition2.label];
394
+ if (condition2.reasonCode) {
395
+ parts.push(`{reason: ${condition2.reasonCode}}`);
396
+ }
397
+ if (condition2.op) {
398
+ const left = formatValue(condition2.left);
399
+ const right = formatValue(condition2.right);
400
+ parts.push(`(${left} ${condition2.op} ${right})`);
401
+ }
402
+ return parts.join(" ");
403
+ }
404
+ function renderCondition(condition2, indent) {
405
+ const pad = " ".repeat(indent);
406
+ const lines = [`${pad}- ${formatCondition(condition2)}`];
407
+ if (condition2.children && condition2.children.length > 0) {
408
+ for (const child of condition2.children) {
409
+ lines.push(...renderCondition(child, indent + 2));
410
+ }
411
+ }
412
+ return lines;
413
+ }
414
+ function formatValue(value) {
415
+ if (typeof value === "string") {
416
+ return `"${value}"`;
417
+ }
418
+ return String(value);
419
+ }
420
+
421
+ // src/registry.ts
422
+ var entries = /* @__PURE__ */ new Map();
423
+ var nameIndex = /* @__PURE__ */ new Map();
424
+ var counter = 0;
425
+ var traceCounter = 0;
426
+ var traceLimit = 25;
427
+ function makeId() {
428
+ return `ruleset-${counter++}`;
429
+ }
430
+ var registry2 = {
431
+ /**
432
+ * Register a ruleset in the global registry.
433
+ *
434
+ * @example
435
+ * ```ts
436
+ * const rs = Rules.ruleset<Facts, Effects>("eligibility");
437
+ * Rules.registry.list();
438
+ * ```
439
+ */
440
+ register(source, name) {
441
+ const id = makeId();
442
+ entries.set(id, { id, name, createdAt: Date.now(), source, traces: [] });
443
+ if (name) {
444
+ nameIndex.set(name, id);
445
+ }
446
+ return id;
447
+ },
448
+ /**
449
+ * List all registered rulesets.
450
+ *
451
+ * @example
452
+ * ```ts
453
+ * const list = Rules.registry.list();
454
+ * ```
455
+ */
456
+ list() {
457
+ return Array.from(entries.values()).map(({ id, name, createdAt }) => ({
458
+ id,
459
+ name,
460
+ createdAt
461
+ }));
462
+ },
463
+ /**
464
+ * Get a graph by id or ruleset name.
465
+ *
466
+ * @example
467
+ * ```ts
468
+ * const graph = Rules.registry.getGraph("eligibility");
469
+ * ```
470
+ */
471
+ getGraph(idOrName) {
472
+ const entry = getEntry(idOrName);
473
+ return entry?.source.graph();
474
+ },
475
+ /**
476
+ * Get Mermaid output by id or ruleset name.
477
+ *
478
+ * @example
479
+ * ```ts
480
+ * const mermaid = Rules.registry.getMermaid("eligibility");
481
+ * ```
482
+ */
483
+ getMermaid(idOrName) {
484
+ const entry = getEntry(idOrName);
485
+ return entry?.source.toMermaid();
486
+ },
487
+ /**
488
+ * Record a trace run for a ruleset. Keeps a rolling window of traces.
489
+ *
490
+ * @example
491
+ * ```ts
492
+ * Rules.registry.recordTrace("eligibility", trace, fired);
493
+ * ```
494
+ */
495
+ recordTrace(idOrName, trace, fired, facts) {
496
+ const entry = getEntry(idOrName);
497
+ if (!entry) {
498
+ return void 0;
499
+ }
500
+ const run = {
501
+ id: `trace-${traceCounter++}`,
502
+ createdAt: Date.now(),
503
+ facts,
504
+ fired,
505
+ trace
506
+ };
507
+ entry.traces.push(run);
508
+ if (entry.traces.length > traceLimit) {
509
+ entry.traces.splice(0, entry.traces.length - traceLimit);
510
+ }
511
+ return run;
512
+ },
513
+ /**
514
+ * List trace runs for a ruleset.
515
+ *
516
+ * @example
517
+ * ```ts
518
+ * const traces = Rules.registry.listTraces("eligibility");
519
+ * ```
520
+ */
521
+ listTraces(idOrName) {
522
+ const entry = getEntry(idOrName);
523
+ return entry ? [...entry.traces] : [];
524
+ },
525
+ /**
526
+ * Get a trace by id for a ruleset.
527
+ *
528
+ * @example
529
+ * ```ts
530
+ * const trace = Rules.registry.getTrace("eligibility", "trace-0");
531
+ * ```
532
+ */
533
+ getTrace(idOrName, traceId) {
534
+ const entry = getEntry(idOrName);
535
+ return entry?.traces.find((run) => run.id === traceId);
536
+ },
537
+ /**
538
+ * Clear the registry (useful in tests).
539
+ *
540
+ * @example
541
+ * ```ts
542
+ * Rules.registry.clear();
543
+ * ```
544
+ */
545
+ clear() {
546
+ entries.clear();
547
+ nameIndex.clear();
548
+ traceCounter = 0;
549
+ }
550
+ };
551
+ function getEntry(idOrName) {
552
+ const byId = entries.get(idOrName);
553
+ if (byId) {
554
+ return byId;
555
+ }
556
+ const id = nameIndex.get(idOrName);
557
+ return id ? entries.get(id) : void 0;
558
+ }
559
+
560
+ // src/ruleset.ts
561
+ var RulesetBuilderImpl = class {
562
+ constructor(name) {
563
+ this.rules = [];
564
+ this.nextOrder = 0;
565
+ this.name = name;
566
+ }
567
+ /**
568
+ * Set the default effects factory. Required before compile().
569
+ *
570
+ * @example
571
+ * ```ts
572
+ * const rs = Rules.ruleset<Facts, Effects>("rs")
573
+ * .defaultEffects(() => ({ flags: [] }));
574
+ * ```
575
+ */
576
+ defaultEffects(factory) {
577
+ this.defaultEffectsFactory = factory;
578
+ return this;
579
+ }
580
+ /**
581
+ * Provide a validation function for facts. Called before each run.
582
+ *
583
+ * @example
584
+ * ```ts
585
+ * const rs = Rules.ruleset<Facts, Effects>("validate")
586
+ * .validateFacts((facts) => {
587
+ * if (!facts.user) throw new Error("missing user");
588
+ * });
589
+ * ```
590
+ */
591
+ validateFacts(validator) {
592
+ this.validateFactsFn = validator;
593
+ return this;
594
+ }
595
+ /**
596
+ * Provide a validation function for effects. Called after default effects creation
597
+ * and after each run completes.
598
+ *
599
+ * @example
600
+ * ```ts
601
+ * const rs = Rules.ruleset<Facts, Effects>("validate")
602
+ * .validateEffects((effects) => {
603
+ * if (!Array.isArray(effects.flags)) throw new Error("invalid effects");
604
+ * });
605
+ * ```
606
+ */
607
+ validateEffects(validator) {
608
+ this.validateEffectsFn = validator;
609
+ return this;
610
+ }
611
+ /**
612
+ * Attach telemetry adapter (OpenTelemetry-compatible).
613
+ *
614
+ * @example
615
+ * ```ts
616
+ * const adapter = Rules.otel.createAdapter(trace.getTracer("rulit"));
617
+ * Rules.ruleset("rs").telemetry(adapter);
618
+ * ```
619
+ */
620
+ telemetry(adapter) {
621
+ this.telemetryAdapter = adapter;
622
+ return this;
623
+ }
624
+ _setRegistryId(id) {
625
+ this.registryId = id;
626
+ }
627
+ /**
628
+ * Add a new rule to the ruleset.
629
+ *
630
+ * @example
631
+ * ```ts
632
+ * Rules.ruleset<Facts, Effects>("rs")
633
+ * .defaultEffects(() => ({ flags: [] }))
634
+ * .rule("vip")
635
+ * .when(factsField("user.tags").contains("vip"))
636
+ * .then(({ effects }) => effects.flags.push("vip"))
637
+ * .end();
638
+ * ```
639
+ */
640
+ rule(id) {
641
+ return new RuleBuilderImpl(this, id);
642
+ }
643
+ /**
644
+ * Create a typed field helper bound to the facts type.
645
+ *
646
+ * @example
647
+ * ```ts
648
+ * const factsField = Rules.ruleset<Facts, Effects>("rs").field();
649
+ * const isAdult = factsField("user.age").gte(18);
650
+ * ```
651
+ */
652
+ field() {
653
+ return field();
654
+ }
655
+ _addRule(rule) {
656
+ this.rules.push(rule);
657
+ }
658
+ _nextOrder() {
659
+ const order = this.nextOrder;
660
+ this.nextOrder += 1;
661
+ return order;
662
+ }
663
+ /**
664
+ * Export a graph representation of the ruleset.
665
+ *
666
+ * @example
667
+ * ```ts
668
+ * const graph = ruleset.graph();
669
+ * ```
670
+ */
671
+ graph() {
672
+ const nodes = [];
673
+ const edges = [];
674
+ const rulesetId = `ruleset:${this.name ?? "ruleset"}`;
675
+ nodes.push({
676
+ id: rulesetId,
677
+ type: "ruleset",
678
+ label: this.name ?? "ruleset"
679
+ });
680
+ let conditionCounter = 0;
681
+ const visitCondition = (condition2, parentId) => {
682
+ const meta = condition2.meta;
683
+ const conditionId = `condition:${conditionCounter++}`;
684
+ nodes.push({
685
+ id: conditionId,
686
+ type: "condition",
687
+ label: meta?.label ?? "condition",
688
+ reasonCode: meta?.reasonCode
689
+ });
690
+ edges.push({ from: parentId, to: conditionId });
691
+ if (meta?.children && meta.children.length > 0) {
692
+ for (const child of meta.children) {
693
+ visitCondition(child, conditionId);
694
+ }
695
+ }
696
+ };
697
+ for (const rule of this.rules) {
698
+ const ruleId = `rule:${rule.id}`;
699
+ nodes.push({
700
+ id: ruleId,
701
+ type: "rule",
702
+ label: rule.id,
703
+ reasonCode: rule.meta?.reasonCode,
704
+ tags: rule.meta?.tags,
705
+ description: rule.meta?.description,
706
+ version: rule.meta?.version
707
+ });
708
+ edges.push({ from: rulesetId, to: ruleId });
709
+ for (const condition2 of rule.conditions) {
710
+ visitCondition(condition2, ruleId);
711
+ }
712
+ }
713
+ return { nodes, edges };
714
+ }
715
+ /**
716
+ * Export a Mermaid flowchart for visualization.
717
+ *
718
+ * @example
719
+ * ```ts
720
+ * const mermaid = ruleset.toMermaid();
721
+ * ```
722
+ */
723
+ toMermaid() {
724
+ const graph = this.graph();
725
+ const idMap = /* @__PURE__ */ new Map();
726
+ let counter2 = 0;
727
+ for (const node of graph.nodes) {
728
+ idMap.set(node.id, `n${counter2++}`);
729
+ }
730
+ const lines = ["flowchart TD"];
731
+ for (const node of graph.nodes) {
732
+ const id = idMap.get(node.id);
733
+ if (!id) {
734
+ continue;
735
+ }
736
+ const reason = node.reasonCode ? ` [reason: ${node.reasonCode}]` : "";
737
+ const tags = node.tags?.length ? ` [tags: ${node.tags.join(", ")}]` : "";
738
+ const label = `${capitalize(node.type)}: ${node.label}${tags}${reason}`;
739
+ lines.push(` ${id}["${label}"]`);
740
+ }
741
+ for (const edge of graph.edges) {
742
+ const from = idMap.get(edge.from);
743
+ const to = idMap.get(edge.to);
744
+ if (from && to) {
745
+ lines.push(` ${from} --> ${to}`);
746
+ }
747
+ }
748
+ return lines.join("\n");
749
+ }
750
+ /**
751
+ * Compile the ruleset into an engine.
752
+ *
753
+ * @example
754
+ * ```ts
755
+ * const engine = Rules.ruleset<Facts, Effects>("rs")
756
+ * .defaultEffects(() => ({ flags: [] }))
757
+ * .compile();
758
+ * ```
759
+ */
760
+ compile() {
761
+ const defaultEffectsFactory = this.defaultEffectsFactory;
762
+ if (!defaultEffectsFactory) {
763
+ throw new Error("defaultEffects() is required before compile().");
764
+ }
765
+ const sortedRules = [...this.rules].sort((a, b) => {
766
+ if (a.priority !== b.priority) {
767
+ return b.priority - a.priority;
768
+ }
769
+ return a.order - b.order;
770
+ });
771
+ return {
772
+ run: ({
773
+ facts,
774
+ activation = "all",
775
+ effectsMode = "mutable",
776
+ mergeStrategy = "assign",
777
+ rollbackOnError = false,
778
+ includeTags,
779
+ excludeTags
780
+ }) => {
781
+ return runWithSpan(
782
+ this.telemetryAdapter,
783
+ "rulit.run",
784
+ rulesetSpanAttrs(this.name, this.registryId),
785
+ () => runSync({
786
+ facts,
787
+ activation,
788
+ effectsMode,
789
+ mergeStrategy,
790
+ rollbackOnError,
791
+ includeTags,
792
+ excludeTags,
793
+ rules: sortedRules,
794
+ defaultEffectsFactory,
795
+ validateFactsFn: this.validateFactsFn,
796
+ validateEffectsFn: this.validateEffectsFn,
797
+ registryId: this.registryId,
798
+ rulesetName: this.name,
799
+ telemetryAdapter: this.telemetryAdapter
800
+ })
801
+ );
802
+ },
803
+ runAsync: async ({
804
+ facts,
805
+ activation = "all",
806
+ effectsMode = "mutable",
807
+ mergeStrategy = "assign",
808
+ rollbackOnError = false,
809
+ includeTags,
810
+ excludeTags
811
+ }) => {
812
+ return runWithSpanAsync(
813
+ this.telemetryAdapter,
814
+ "rulit.run",
815
+ rulesetSpanAttrs(this.name, this.registryId),
816
+ () => runAsync({
817
+ facts,
818
+ activation,
819
+ effectsMode,
820
+ mergeStrategy,
821
+ rollbackOnError,
822
+ includeTags,
823
+ excludeTags,
824
+ rules: sortedRules,
825
+ defaultEffectsFactory,
826
+ validateFactsFn: this.validateFactsFn,
827
+ validateEffectsFn: this.validateEffectsFn,
828
+ registryId: this.registryId,
829
+ rulesetName: this.name,
830
+ telemetryAdapter: this.telemetryAdapter
831
+ })
832
+ );
833
+ }
834
+ };
835
+ }
836
+ };
837
+ var RuleBuilderImpl = class {
838
+ constructor(ruleset2, id) {
839
+ this.priorityValue = 0;
840
+ this.conditions = [];
841
+ this.metaValue = {};
842
+ this.ruleset = ruleset2;
843
+ this.id = id;
844
+ }
845
+ /**
846
+ * Set rule priority. Higher runs first.
847
+ *
848
+ * @example
849
+ * ```ts
850
+ * rule.priority(100);
851
+ * ```
852
+ */
853
+ priority(value) {
854
+ this.priorityValue = value;
855
+ return this;
856
+ }
857
+ /**
858
+ * Add conditions to a rule.
859
+ *
860
+ * @example
861
+ * ```ts
862
+ * rule.when(factsField("user.age").gte(18), factsField("user.tags").contains("vip"));
863
+ * ```
864
+ */
865
+ when(...conditions) {
866
+ this.conditions = conditions;
867
+ return this;
868
+ }
869
+ /**
870
+ * Set rule metadata in one call.
871
+ *
872
+ * @example
873
+ * ```ts
874
+ * rule.meta({ tags: ["vip"], version: "1.0.0", reasonCode: "VIP_RULE" });
875
+ * ```
876
+ */
877
+ meta(meta) {
878
+ const tags = meta.tags ?? this.metaValue.tags;
879
+ this.metaValue = { ...this.metaValue, ...meta, tags };
880
+ return this;
881
+ }
882
+ /**
883
+ * Set rule tags used for filtering.
884
+ *
885
+ * @example
886
+ * ```ts
887
+ * rule.tags("vip", "adult");
888
+ * ```
889
+ */
890
+ tags(...tags) {
891
+ this.metaValue = { ...this.metaValue, tags };
892
+ return this;
893
+ }
894
+ /**
895
+ * Set a human-readable description.
896
+ *
897
+ * @example
898
+ * ```ts
899
+ * rule.description("VIP adult rule");
900
+ * ```
901
+ */
902
+ description(description) {
903
+ this.metaValue = { ...this.metaValue, description };
904
+ return this;
905
+ }
906
+ /**
907
+ * Set a rule version string.
908
+ *
909
+ * @example
910
+ * ```ts
911
+ * rule.version("1.2.3");
912
+ * ```
913
+ */
914
+ version(version) {
915
+ this.metaValue = { ...this.metaValue, version };
916
+ return this;
917
+ }
918
+ /**
919
+ * Set a reason code for audit/explain output.
920
+ *
921
+ * @example
922
+ * ```ts
923
+ * rule.reasonCode("VIP_ADULT");
924
+ * ```
925
+ */
926
+ reasonCode(reasonCode) {
927
+ this.metaValue = { ...this.metaValue, reasonCode };
928
+ return this;
929
+ }
930
+ /**
931
+ * Enable or disable a rule.
932
+ *
933
+ * @example
934
+ * ```ts
935
+ * rule.enabled(false);
936
+ * ```
937
+ */
938
+ enabled(enabled) {
939
+ this.metaValue = { ...this.metaValue, enabled };
940
+ return this;
941
+ }
942
+ /**
943
+ * Set the rule action. Returning a partial effects object applies a patch.
944
+ *
945
+ * @example
946
+ * ```ts
947
+ * rule.then(({ effects }) => {
948
+ * effects.flags.push("vip");
949
+ * });
950
+ * ```
951
+ */
952
+ then(action) {
953
+ this.action = action;
954
+ return this;
955
+ }
956
+ /**
957
+ * Set an async rule action. Returning a partial effects object applies a patch.
958
+ *
959
+ * @example
960
+ * ```ts
961
+ * rule.thenAsync(async ({ effects }) => {
962
+ * effects.flags.push("vip");
963
+ * });
964
+ * ```
965
+ */
966
+ thenAsync(action) {
967
+ this.action = action;
968
+ return this;
969
+ }
970
+ /**
971
+ * Finalize the rule and return to the ruleset builder.
972
+ *
973
+ * @example
974
+ * ```ts
975
+ * ruleset.rule("vip").then(() => undefined).end();
976
+ * ```
977
+ */
978
+ end() {
979
+ if (!this.action) {
980
+ throw new Error("then() is required before end().");
981
+ }
982
+ const rule = {
983
+ id: this.id,
984
+ priority: this.priorityValue,
985
+ order: this.ruleset._nextOrder(),
986
+ conditions: this.conditions,
987
+ action: this.action,
988
+ meta: Object.keys(this.metaValue).length ? this.metaValue : void 0
989
+ };
990
+ this.ruleset._addRule(rule);
991
+ return this.ruleset;
992
+ }
993
+ };
994
+ function ruleset(name) {
995
+ const builder = new RulesetBuilderImpl(name);
996
+ const registryId = registry2.register(builder, name);
997
+ builder._setRegistryId(registryId);
998
+ return builder;
999
+ }
1000
+ function getSkipReason(meta, includeTags, excludeTags) {
1001
+ if (meta?.enabled === false) {
1002
+ return "disabled";
1003
+ }
1004
+ const tags = meta?.tags ?? [];
1005
+ if (includeTags && includeTags.length > 0 && !includeTags.some((tag) => tags.includes(tag))) {
1006
+ return "tag-filtered";
1007
+ }
1008
+ if (excludeTags && excludeTags.length > 0 && excludeTags.some((tag) => tags.includes(tag))) {
1009
+ return "tag-excluded";
1010
+ }
1011
+ return void 0;
1012
+ }
1013
+ function mergeEffects(target, patch, strategy) {
1014
+ if (strategy === "assign") {
1015
+ Object.assign(target, patch);
1016
+ return;
1017
+ }
1018
+ deepMerge(target, patch);
1019
+ }
1020
+ function deepMerge(target, patch) {
1021
+ for (const [key, value] of Object.entries(patch)) {
1022
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1023
+ const current = target[key];
1024
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
1025
+ target[key] = {};
1026
+ }
1027
+ deepMerge(target[key], value);
1028
+ } else {
1029
+ target[key] = value;
1030
+ }
1031
+ }
1032
+ }
1033
+ function cloneEffects(effects) {
1034
+ if (typeof structuredClone === "function") {
1035
+ return structuredClone(effects);
1036
+ }
1037
+ return JSON.parse(JSON.stringify(effects));
1038
+ }
1039
+ function runSync({
1040
+ facts,
1041
+ activation,
1042
+ effectsMode,
1043
+ mergeStrategy,
1044
+ rollbackOnError,
1045
+ includeTags,
1046
+ excludeTags,
1047
+ rules,
1048
+ defaultEffectsFactory,
1049
+ validateFactsFn,
1050
+ validateEffectsFn,
1051
+ registryId,
1052
+ rulesetName,
1053
+ telemetryAdapter
1054
+ }) {
1055
+ validateFactsFn?.(facts);
1056
+ let effects = defaultEffectsFactory();
1057
+ validateEffectsFn?.(effects);
1058
+ const trace = [];
1059
+ const fired = [];
1060
+ for (const rule of rules) {
1061
+ const start = Date.now();
1062
+ const ruleTrace = {
1063
+ ruleId: rule.id,
1064
+ matched: false,
1065
+ conditions: [],
1066
+ notes: [],
1067
+ meta: rule.meta
1068
+ };
1069
+ const skipReason = getSkipReason(rule.meta, includeTags, excludeTags);
1070
+ if (skipReason) {
1071
+ ruleTrace.skippedReason = skipReason;
1072
+ ruleTrace.durationMs = Date.now() - start;
1073
+ trace.push(ruleTrace);
1074
+ continue;
1075
+ }
1076
+ let matched = true;
1077
+ for (const condition2 of rule.conditions) {
1078
+ const conditionTrace = runWithSpan(
1079
+ telemetryAdapter,
1080
+ "rulit.condition",
1081
+ conditionSpanAttrs(rulesetName, rule, condition2),
1082
+ () => condition2(facts)
1083
+ );
1084
+ ruleTrace.conditions.push(conditionTrace);
1085
+ if (!conditionTrace.result) {
1086
+ matched = false;
1087
+ break;
1088
+ }
1089
+ }
1090
+ ruleTrace.matched = matched;
1091
+ if (matched) {
1092
+ const workingEffects = effectsMode === "immutable" ? cloneEffects(effects) : effects;
1093
+ const ctx = {
1094
+ facts,
1095
+ effects: workingEffects,
1096
+ trace: {
1097
+ note: (message) => {
1098
+ ruleTrace.notes.push(message);
1099
+ }
1100
+ }
1101
+ };
1102
+ try {
1103
+ const patch = runWithSpan(
1104
+ telemetryAdapter,
1105
+ "rulit.rule",
1106
+ ruleSpanAttrs(rulesetName, rule),
1107
+ () => rule.action(ctx)
1108
+ );
1109
+ if (isPromise(patch)) {
1110
+ throw new Error("Async rule action detected. Use runAsync().");
1111
+ }
1112
+ if (patch && typeof patch === "object") {
1113
+ mergeEffects(workingEffects, patch, mergeStrategy);
1114
+ }
1115
+ if (effectsMode === "immutable") {
1116
+ effects = workingEffects;
1117
+ }
1118
+ } catch (error) {
1119
+ ruleTrace.error = String(error);
1120
+ ruleTrace.notes.push(`error: ${String(error)}`);
1121
+ if (!rollbackOnError) {
1122
+ throw error;
1123
+ }
1124
+ if (effectsMode === "immutable") {
1125
+ effects = effects;
1126
+ }
1127
+ }
1128
+ fired.push(rule.id);
1129
+ }
1130
+ ruleTrace.durationMs = Date.now() - start;
1131
+ trace.push(ruleTrace);
1132
+ if (matched && activation === "first") {
1133
+ break;
1134
+ }
1135
+ }
1136
+ validateEffectsFn?.(effects);
1137
+ if (registryId) {
1138
+ registry2.recordTrace(registryId, trace, fired, facts);
1139
+ }
1140
+ return {
1141
+ effects,
1142
+ fired,
1143
+ trace,
1144
+ explain: () => explainTrace(trace, rulesetName)
1145
+ };
1146
+ }
1147
+ async function runAsync({
1148
+ facts,
1149
+ activation,
1150
+ effectsMode,
1151
+ mergeStrategy,
1152
+ rollbackOnError,
1153
+ includeTags,
1154
+ excludeTags,
1155
+ rules,
1156
+ defaultEffectsFactory,
1157
+ validateFactsFn,
1158
+ validateEffectsFn,
1159
+ registryId,
1160
+ rulesetName,
1161
+ telemetryAdapter
1162
+ }) {
1163
+ validateFactsFn?.(facts);
1164
+ let effects = defaultEffectsFactory();
1165
+ validateEffectsFn?.(effects);
1166
+ const trace = [];
1167
+ const fired = [];
1168
+ for (const rule of rules) {
1169
+ const start = Date.now();
1170
+ const ruleTrace = {
1171
+ ruleId: rule.id,
1172
+ matched: false,
1173
+ conditions: [],
1174
+ notes: [],
1175
+ meta: rule.meta
1176
+ };
1177
+ const skipReason = getSkipReason(rule.meta, includeTags, excludeTags);
1178
+ if (skipReason) {
1179
+ ruleTrace.skippedReason = skipReason;
1180
+ ruleTrace.durationMs = Date.now() - start;
1181
+ trace.push(ruleTrace);
1182
+ continue;
1183
+ }
1184
+ let matched = true;
1185
+ for (const condition2 of rule.conditions) {
1186
+ const conditionTrace = runWithSpan(
1187
+ telemetryAdapter,
1188
+ "rulit.condition",
1189
+ conditionSpanAttrs(rulesetName, rule, condition2),
1190
+ () => condition2(facts)
1191
+ );
1192
+ ruleTrace.conditions.push(conditionTrace);
1193
+ if (!conditionTrace.result) {
1194
+ matched = false;
1195
+ break;
1196
+ }
1197
+ }
1198
+ ruleTrace.matched = matched;
1199
+ if (matched) {
1200
+ const workingEffects = effectsMode === "immutable" ? cloneEffects(effects) : effects;
1201
+ const ctx = {
1202
+ facts,
1203
+ effects: workingEffects,
1204
+ trace: {
1205
+ note: (message) => {
1206
+ ruleTrace.notes.push(message);
1207
+ }
1208
+ }
1209
+ };
1210
+ try {
1211
+ const patch = await runWithSpanAsync(
1212
+ telemetryAdapter,
1213
+ "rulit.rule",
1214
+ ruleSpanAttrs(rulesetName, rule),
1215
+ () => rule.action(ctx)
1216
+ );
1217
+ if (patch && typeof patch === "object") {
1218
+ mergeEffects(workingEffects, patch, mergeStrategy);
1219
+ }
1220
+ if (effectsMode === "immutable") {
1221
+ effects = workingEffects;
1222
+ }
1223
+ } catch (error) {
1224
+ ruleTrace.error = String(error);
1225
+ ruleTrace.notes.push(`error: ${String(error)}`);
1226
+ if (!rollbackOnError) {
1227
+ throw error;
1228
+ }
1229
+ if (effectsMode === "immutable") {
1230
+ effects = effects;
1231
+ }
1232
+ }
1233
+ fired.push(rule.id);
1234
+ }
1235
+ ruleTrace.durationMs = Date.now() - start;
1236
+ trace.push(ruleTrace);
1237
+ if (matched && activation === "first") {
1238
+ break;
1239
+ }
1240
+ }
1241
+ validateEffectsFn?.(effects);
1242
+ if (registryId) {
1243
+ registry2.recordTrace(registryId, trace, fired, facts);
1244
+ }
1245
+ return {
1246
+ effects,
1247
+ fired,
1248
+ trace,
1249
+ explain: () => explainTrace(trace, rulesetName)
1250
+ };
1251
+ }
1252
+ function isPromise(value) {
1253
+ return Boolean(value) && typeof value.then === "function";
1254
+ }
1255
+ function runWithSpan(adapter, name, attributes, fn) {
1256
+ if (!adapter) {
1257
+ return fn();
1258
+ }
1259
+ const span = adapter.startSpan(name, attributes);
1260
+ try {
1261
+ const result = fn();
1262
+ span.end();
1263
+ return result;
1264
+ } catch (error) {
1265
+ span.recordException?.(error);
1266
+ span.end();
1267
+ throw error;
1268
+ }
1269
+ }
1270
+ async function runWithSpanAsync(adapter, name, attributes, fn) {
1271
+ if (!adapter) {
1272
+ return await fn();
1273
+ }
1274
+ const span = adapter.startSpan(name, attributes);
1275
+ try {
1276
+ const result = await fn();
1277
+ span.end();
1278
+ return result;
1279
+ } catch (error) {
1280
+ span.recordException?.(error);
1281
+ span.end();
1282
+ throw error;
1283
+ }
1284
+ }
1285
+ function rulesetSpanAttrs(name, registryId) {
1286
+ return {
1287
+ "rulit.ruleset": name ?? registryId ?? "ruleset"
1288
+ };
1289
+ }
1290
+ function ruleSpanAttrs(rulesetName, rule) {
1291
+ return {
1292
+ "rulit.ruleset": rulesetName ?? "ruleset",
1293
+ "rulit.rule_id": rule.id
1294
+ };
1295
+ }
1296
+ function conditionSpanAttrs(rulesetName, rule, condition2) {
1297
+ return {
1298
+ "rulit.ruleset": rulesetName ?? "ruleset",
1299
+ "rulit.rule_id": rule.id,
1300
+ "rulit.condition": condition2.meta?.label ?? "condition"
1301
+ };
1302
+ }
1303
+ function capitalize(value) {
1304
+ return value.length > 0 ? value[0].toUpperCase() + value.slice(1) : value;
1305
+ }
1306
+
1307
+ // src/zod.ts
1308
+ function zodFacts(schema) {
1309
+ return (facts) => {
1310
+ schema.parse(facts);
1311
+ };
1312
+ }
1313
+ function zodEffects(schema) {
1314
+ return (effects) => {
1315
+ schema.parse(effects);
1316
+ };
1317
+ }
1318
+
1319
+ // src/otel.ts
1320
+ function createOtelAdapter(tracer) {
1321
+ return {
1322
+ startSpan(name, attributes) {
1323
+ const span = tracer.startSpan(name, { attributes });
1324
+ if (attributes) {
1325
+ span.setAttributes?.(attributes);
1326
+ }
1327
+ return span;
1328
+ }
1329
+ };
1330
+ }
1331
+
1332
+ // src/index.ts
1333
+ var Rules = {
1334
+ ruleset,
1335
+ condition,
1336
+ field,
1337
+ op,
1338
+ registry: registry2,
1339
+ zodFacts,
1340
+ zodEffects,
1341
+ otel: {
1342
+ createAdapter: createOtelAdapter
1343
+ }
1344
+ };
1345
+ // Annotate the CommonJS export names for ESM import in node:
1346
+ 0 && (module.exports = {
1347
+ Rules,
1348
+ condition,
1349
+ createOtelAdapter,
1350
+ field,
1351
+ op,
1352
+ registry,
1353
+ ruleset,
1354
+ zodEffects,
1355
+ zodFacts
1356
+ });
1357
+ //# sourceMappingURL=index.js.map