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.
@@ -0,0 +1,313 @@
1
+ <html lang="en">
2
+ <head>
3
+ <meta charset="UTF-8" />
4
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5
+ <meta name="color-scheme" content="dark" />
6
+ <title>Rulit Registry</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Manrope:wght@400;500;600;700&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <script src="https://cdn.tailwindcss.com"></script>
14
+ <script>
15
+ tailwind.config = { theme: { extend: { fontFamily: { display: ["Manrope", "ui-sans-serif",
16
+ "system-ui"], mono: ["JetBrains Mono", "ui-monospace", "SFMono-Regular", "Menlo",
17
+ "monospace"], }, }, }, };
18
+ </script>
19
+ <style>
20
+ .grid-surface {
21
+ background-image:
22
+ linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
23
+ linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
24
+ background-size: 48px 48px;
25
+ }
26
+ </style>
27
+ </head>
28
+ <body class="min-h-screen bg-neutral-950 text-neutral-100 font-display">
29
+ <div class="pointer-events-none fixed inset-0 -z-10 opacity-40 grid-surface"></div>
30
+ <div
31
+ class="pointer-events-none fixed inset-0 -z-10 bg-[radial-gradient(600px_circle_at_15%_10%,rgba(16,185,129,0.2),transparent_60%),radial-gradient(520px_circle_at_85%_0%,rgba(59,130,246,0.2),transparent_55%)]"
32
+ ></div>
33
+ <header class="border-b border-neutral-800/80 px-6 py-6 sm:px-10">
34
+ <div class="mx-auto flex max-w-6xl flex-col gap-3">
35
+ <div class="flex items-center gap-3 text-xs uppercase tracking-[0.35em] text-neutral-400">
36
+ <span class="rounded-full border border-neutral-700/80 px-2 py-1">Rulit</span>
37
+ <span>Registry UI</span>
38
+ </div>
39
+ <div class="flex flex-col gap-2">
40
+ <h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">Rulesets in memory</h1>
41
+ <p class="text-sm text-neutral-400">
42
+ Inspect graphs, trace executions, and verify rule decisions across your active rulesets.
43
+ </p>
44
+ </div>
45
+ </div>
46
+ </header>
47
+ <section
48
+ class="mx-auto grid max-w-6xl gap-6 px-6 py-8 lg:grid-cols-[260px_1fr] lg:gap-8 lg:px-10"
49
+ >
50
+ <aside
51
+ class="h-fit rounded-2xl border border-neutral-800/80 bg-neutral-950/60 p-5 shadow-[0_20px_60px_rgba(0,0,0,0.4)] backdrop-blur sm:sticky sm:top-6"
52
+ >
53
+ <div class="flex items-center justify-between">
54
+ <h2
55
+ class="text-xs font-semibold uppercase tracking-[0.35em] text-neutral-400"
56
+ >Rulesets</h2>
57
+ <span
58
+ class="rounded-full border border-neutral-700/80 bg-neutral-950/80 px-2 py-0.5 text-[11px] text-neutral-300"
59
+ >
60
+ {{entries.length}}
61
+ </span>
62
+ </div>
63
+ <input
64
+ class="mt-4 w-full rounded-xl border border-neutral-800 bg-neutral-950/80 px-3 py-2 text-sm text-neutral-100 placeholder:text-neutral-600 focus:border-emerald-400 focus:outline-none"
65
+ id="search"
66
+ placeholder="Filter rulesets..."
67
+ />
68
+ <ul class="mt-4 grid gap-2 text-sm" id="nav">
69
+ {{#if entries.length}}
70
+ {{#each entries}}
71
+ <li>
72
+ <a
73
+ href="#{{id}}"
74
+ class="block rounded-xl border border-transparent bg-neutral-950/40 px-3 py-3 transition hover:border-neutral-700 hover:bg-neutral-900/80"
75
+ >
76
+ <div class="font-semibold text-neutral-100">{{name}}</div>
77
+ <div class="text-xs text-neutral-500">{{createdAt}}</div>
78
+ </a>
79
+ </li>
80
+ {{/each}}
81
+ {{else}}
82
+ <li
83
+ class="rounded-xl border border-dashed border-neutral-800 p-3 text-xs text-neutral-500"
84
+ >
85
+ No rulesets found.
86
+ </li>
87
+ {{/if}}
88
+ </ul>
89
+ </aside>
90
+ <main class="grid gap-5" id="cards">
91
+ {{#if entries.length}}
92
+ {{#each entries}}
93
+ <section
94
+ id="{{id}}"
95
+ data-trace-card
96
+ class="rounded-2xl border border-neutral-800/80 bg-neutral-950/60 p-6 shadow-[0_20px_60px_rgba(0,0,0,0.4)]"
97
+ >
98
+ <header class="flex flex-wrap items-start justify-between gap-4">
99
+ <div>
100
+ <h2 class="text-xl font-semibold text-neutral-100">{{name}}</h2>
101
+ <div class="text-xs text-neutral-500">{{createdAt}}</div>
102
+ </div>
103
+ <div
104
+ class="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-200"
105
+ >
106
+ {{id}}
107
+ </div>
108
+ </header>
109
+ <div class="mt-5">
110
+ <button
111
+ type="button"
112
+ class="group relative w-full overflow-auto rounded-2xl border border-neutral-800 bg-neutral-950/80 p-0 text-left focus:outline-none focus:ring-2 focus:ring-emerald-400"
113
+ data-expand
114
+ data-mermaid="{{mermaidEncoded}}"
115
+ >
116
+ <span
117
+ class="pointer-events-none absolute right-3 top-3 rounded-full bg-neutral-900/80 px-2 py-1 text-[10px] font-semibold text-neutral-200 opacity-0 transition group-hover:opacity-100"
118
+ >
119
+ Expand
120
+ </span>
121
+ <pre
122
+ class="mermaid whitespace-pre-wrap px-4 py-5 font-mono text-[11px] leading-relaxed text-neutral-100"
123
+ >{{mermaid}}</pre>
124
+ </button>
125
+ </div>
126
+ <details class="mt-4 rounded-2xl border border-neutral-800 bg-neutral-950/40 p-4">
127
+ <summary class="cursor-pointer text-sm font-medium text-emerald-200">JSON graph</summary>
128
+ <pre
129
+ class="mt-3 overflow-auto rounded-lg bg-neutral-950/80 p-3 font-mono text-xs text-neutral-200"
130
+ >{{json}}</pre>
131
+ </details>
132
+ <details class="mt-6 border-t border-neutral-800 pt-5" open>
133
+ <summary class="flex items-center justify-between text-left">
134
+ <div class="text-xs uppercase tracking-[0.35em] text-neutral-400">Trace playback</div>
135
+ <div class="text-xs text-neutral-500">{{traces.length}} runs</div>
136
+ </summary>
137
+ <div class="mt-4">
138
+ <div class="mt-3 space-y-2">
139
+ {{#if traces.length}}
140
+ {{#each traces}}
141
+ <details
142
+ class="rounded-2xl border border-neutral-800 bg-neutral-950/60 p-4"
143
+ data-trace-row
144
+ data-trace="{{traceEncoded}}"
145
+ >
146
+ <summary
147
+ class="grid cursor-pointer grid-cols-[1fr_160px_160px_100px] items-center gap-3 text-xs text-neutral-200"
148
+ >
149
+ <div>
150
+ <div class="text-sm font-semibold text-neutral-100">{{id}}</div>
151
+ <div class="text-[11px] text-neutral-500">Trace run</div>
152
+ </div>
153
+ <div
154
+ class="text-center text-[11px] text-neutral-400"
155
+ >{{createdAt}}</div>
156
+ <div class="text-center text-[11px] text-neutral-400">{{firedCount}}
157
+ fired</div>
158
+ <div class="text-center text-[11px] text-neutral-400">{{matchedCount}}
159
+ matched</div>
160
+ </summary>
161
+ <div class="mt-4 space-y-4" data-trace-row-body></div>
162
+ </details>
163
+ {{/each}}
164
+ {{else}}
165
+ <div
166
+ class="w-full rounded-xl border border-dashed border-neutral-800 bg-neutral-950/40 p-4 text-xs text-neutral-500"
167
+ >
168
+ No traces recorded. Run an engine to capture a playback.
169
+ </div>
170
+ {{/if}}
171
+ </div>
172
+ </div>
173
+ </details>
174
+ </section>
175
+ {{/each}}
176
+ {{else}}
177
+ <p
178
+ class="rounded-xl border border-dashed border-neutral-800 p-6 text-sm text-neutral-500"
179
+ >
180
+ No rulesets found.
181
+ </p>
182
+ {{/if}}
183
+ </main>
184
+ </section>
185
+ <div
186
+ id="overlay"
187
+ class="fixed inset-0 z-50 hidden flex items-center justify-center bg-neutral-950/85 p-4"
188
+ aria-hidden="true"
189
+ >
190
+ <div
191
+ class="relative max-h-[92vh] w-full max-w-7xl overflow-auto rounded-2xl border border-neutral-800 bg-neutral-950 p-6 shadow-[0_30px_80px_rgba(0,0,0,0.55)]"
192
+ >
193
+ <div class="flex flex-wrap items-center justify-between gap-3">
194
+ <div class="text-xs uppercase tracking-[0.3em] text-neutral-500">Expanded graph</div>
195
+ <button
196
+ type="button"
197
+ id="overlay-close"
198
+ class="rounded-full border border-neutral-700 bg-neutral-950/80 px-3 py-1 text-xs font-semibold text-neutral-200 hover:border-emerald-400"
199
+ >
200
+ Close
201
+ </button>
202
+ </div>
203
+ <div
204
+ id="overlay-mermaid"
205
+ class="mt-4 rounded-2xl border border-neutral-800 bg-neutral-950/80 p-4"
206
+ ></div>
207
+ </div>
208
+ </div>
209
+ <script type="module">
210
+ import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
211
+ mermaid.initialize({ startOnLoad: true, theme: "dark" }); const search =
212
+ document.getElementById("search"); const nav = document.getElementById("nav"); const cards =
213
+ document.getElementById("cards"); const overlay = document.getElementById("overlay"); const
214
+ overlayClose = document.getElementById("overlay-close"); const overlayMermaid =
215
+ document.getElementById("overlay-mermaid"); const filter = () => { const value =
216
+ (search?.value || "").toLowerCase(); const links = nav?.querySelectorAll("a") || []; const
217
+ sections = cards?.querySelectorAll("section") || []; links.forEach((link) => { const text =
218
+ link.textContent?.toLowerCase() || ""; const show = text.includes(value);
219
+ link.parentElement.style.display = show ? "" : "none"; }); sections.forEach((section) => {
220
+ const text = section.textContent?.toLowerCase() || ""; section.style.display =
221
+ text.includes(value) ? "" : "none"; }); }; if (search) { search.addEventListener("input",
222
+ filter); } const showOverlayWithSvg = (svgMarkup) => { if (!overlay || !overlayMermaid) {
223
+ return; } overlayMermaid.innerHTML = svgMarkup; overlay.classList.remove("hidden");
224
+ overlay.setAttribute("aria-hidden", "false"); }; const showOverlayFromMermaid = async
225
+ (content) => { if (!overlay || !overlayMermaid) { return; } const { svg } = await
226
+ mermaid.render(`expanded-${Date.now()}`, content); showOverlayWithSvg(svg); };
227
+ document.querySelectorAll("[data-expand]").forEach((button) => {
228
+ button.addEventListener("click", () => { const svg = button.querySelector("svg"); if
229
+ (svg?.outerHTML) { showOverlayWithSvg(svg.outerHTML); return; } const content =
230
+ button.dataset.mermaid ? decodeURIComponent(button.dataset.mermaid) : ""; if (content) { void
231
+ showOverlayFromMermaid(content); } }); }); const hideOverlay = () => { if (!overlay) { return;
232
+ } overlay.classList.add("hidden"); overlay.setAttribute("aria-hidden", "true"); };
233
+ overlay?.addEventListener("click", (event) => { if (event.target === overlay) { hideOverlay();
234
+ } }); overlayClose?.addEventListener("click", hideOverlay); const formatValue = (value) => {
235
+ if (value === undefined) { return "—"; } if (typeof value === "string") { return `"${value}"`;
236
+ } try { return JSON.stringify(value); } catch { return String(value); } }; const formatFacts =
237
+ (value) => { if (value === undefined) { return "—"; } try { return JSON.stringify(value, null,
238
+ 2); } catch { return String(value); } }; const statusClass = (result) => result ?
239
+ "border-emerald-500/40 text-emerald-200" : "border-rose-500/40 text-rose-200"; const
240
+ renderCondition = (container, condition, depth) => { const row =
241
+ document.createElement("div"); row.className = "rounded-xl border border-neutral-800
242
+ bg-neutral-950/70 px-3 py-2 text-xs text-neutral-200"; row.style.marginLeft = `${depth *
243
+ 12}px`; const header = document.createElement("div"); header.className = "flex flex-wrap
244
+ items-center justify-between gap-2"; const title = document.createElement("div");
245
+ title.className = "font-semibold text-neutral-100"; title.textContent = condition.label ||
246
+ "condition"; const badge = document.createElement("span"); badge.className = `rounded-full
247
+ border px-2 py-0.5 text-[10px] uppercase tracking-[0.2em] ${statusClass( condition.result,
248
+ )}`; badge.textContent = condition.result ? "pass" : "fail"; header.appendChild(title);
249
+ header.appendChild(badge); row.appendChild(header); if (condition.reasonCode) { const reason =
250
+ document.createElement("div"); reason.className = "mt-1 text-[11px] text-neutral-400";
251
+ reason.textContent = `Reason: ${condition.reasonCode}`; row.appendChild(reason); } if
252
+ (condition.left !== undefined || condition.op || condition.right !== undefined) { const detail
253
+ = document.createElement("div"); detail.className = "mt-1 font-mono text-[11px]
254
+ text-neutral-300"; detail.textContent = `${formatValue(condition.left)} ${condition.op || ""}
255
+ ${formatValue( condition.right, )}`.trim(); row.appendChild(detail); }
256
+ container.appendChild(row); if (condition.children && condition.children.length > 0) {
257
+ condition.children.forEach((child) => renderCondition(container, child, depth + 1)); } };
258
+ const renderRuleRow = (container, rule, index) => { const wrapper =
259
+ document.createElement("div"); wrapper.className = "rounded-xl border border-neutral-800
260
+ bg-neutral-950/70 p-3"; const header = document.createElement("div"); header.className = "flex
261
+ flex-wrap items-center justify-between gap-2"; const title = document.createElement("div");
262
+ title.className = "text-sm font-semibold text-neutral-100"; title.textContent = `${index + 1}.
263
+ ${rule.ruleId}`; const status = document.createElement("span"); status.className =
264
+ "rounded-full border px-2 py-0.5 text-[10px] uppercase tracking-[0.2em]"; if
265
+ (rule.skippedReason) { status.textContent = `skipped (${rule.skippedReason})`;
266
+ status.classList.add("border-amber-500/40", "bg-amber-500/10", "text-amber-200"); } else if
267
+ (rule.matched) { status.textContent = "matched"; status.classList.add("border-emerald-500/40",
268
+ "bg-emerald-500/10", "text-emerald-200"); } else { status.textContent = "not matched";
269
+ status.classList.add("border-rose-500/40", "bg-rose-500/10", "text-rose-200"); }
270
+ header.appendChild(title); header.appendChild(status); wrapper.appendChild(header); const meta
271
+ = document.createElement("div"); meta.className = "mt-1 text-xs text-neutral-500"; const tags
272
+ = rule.meta?.tags?.length ? `Tags: ${rule.meta.tags.join(", ")}` : ""; const reason =
273
+ rule.meta?.reasonCode ? `Reason: ${rule.meta.reasonCode}` : ""; const duration =
274
+ rule.durationMs ? `${rule.durationMs}ms` : ""; const parts = [tags, reason,
275
+ duration].filter(Boolean); meta.textContent = parts.length ? parts.join(" • ") : "No
276
+ metadata"; wrapper.appendChild(meta); if (rule.conditions?.length) { const conditionsHeader =
277
+ document.createElement("div"); conditionsHeader.className = "mt-3 text-[10px] uppercase
278
+ tracking-[0.3em] text-neutral-400"; conditionsHeader.textContent = "Conditions";
279
+ wrapper.appendChild(conditionsHeader); const conditions = document.createElement("div");
280
+ conditions.className = "mt-2 space-y-2"; rule.conditions.forEach((condition) =>
281
+ renderCondition(conditions, condition, 0)); wrapper.appendChild(conditions); } if
282
+ (rule.notes?.length) { const notesHeader = document.createElement("div");
283
+ notesHeader.className = "mt-3 text-[10px] uppercase tracking-[0.3em] text-neutral-400";
284
+ notesHeader.textContent = "Notes"; wrapper.appendChild(notesHeader); const notes =
285
+ document.createElement("ul"); notes.className = "mt-2 space-y-1 text-xs text-neutral-300";
286
+ rule.notes.forEach((note) => { const li = document.createElement("li"); li.textContent = note;
287
+ notes.appendChild(li); }); wrapper.appendChild(notes); } container.appendChild(wrapper); };
288
+ const renderTraceRow = (row) => { const body = row.querySelector("[data-trace-row-body]"); if
289
+ (!body || body.childElementCount > 0) { return; } let trace = null; try { trace =
290
+ JSON.parse(decodeURIComponent(row.dataset.trace || "")); } catch { return; } const inputHeader
291
+ = document.createElement("div"); inputHeader.className = "flex items-center justify-between";
292
+ const inputTitle = document.createElement("div"); inputTitle.className = "text-xs uppercase
293
+ tracking-[0.3em] text-neutral-400"; inputTitle.textContent = "Input"; const copyBtn =
294
+ document.createElement("button"); copyBtn.type = "button"; copyBtn.className = "rounded-full
295
+ border border-neutral-700 bg-neutral-950/80 px-2 py-0.5 text-[11px] text-neutral-300
296
+ hover:border-emerald-400"; copyBtn.textContent = "Copy"; inputHeader.appendChild(inputTitle);
297
+ inputHeader.appendChild(copyBtn); const input = document.createElement("pre"); input.className
298
+ = "mt-2 overflow-auto rounded-lg border border-neutral-800 bg-neutral-950/80 p-3 font-mono
299
+ text-[11px] text-neutral-200 shadow-[0_0_0_1px_rgba(16,185,129,0.05)]"; input.textContent =
300
+ formatFacts(trace.facts); copyBtn.addEventListener("click", () => {
301
+ navigator.clipboard?.writeText(input.textContent || ""); }); body.appendChild(inputHeader);
302
+ body.appendChild(input); const rulesHeader = document.createElement("div");
303
+ rulesHeader.className = "text-xs uppercase tracking-[0.3em] text-neutral-400";
304
+ rulesHeader.textContent = "Rule evaluations"; body.appendChild(rulesHeader); const rulesList =
305
+ document.createElement("div"); rulesList.className = "space-y-3"; trace.trace.forEach((rule,
306
+ index) => renderRuleRow(rulesList, rule, index)); body.appendChild(rulesList); }; const
307
+ initTraceRows = (card) => { const rows = card.querySelectorAll("[data-trace-row]");
308
+ rows.forEach((row) => { row.addEventListener("toggle", () => { if (row.open) {
309
+ renderTraceRow(row); } }); }); };
310
+ document.querySelectorAll("[data-trace-card]").forEach((card) => { initTraceRows(card); });
311
+ </script>
312
+ </body>
313
+ </html>
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ type CliOptions = {
3
+ port: number;
4
+ load: string[];
5
+ };
6
+ declare function startServer(options: CliOptions): Promise<void>;
7
+ declare function buildHtml(): string;
8
+
9
+ export { buildHtml, startServer };
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ type CliOptions = {
3
+ port: number;
4
+ load: string[];
5
+ };
6
+ declare function startServer(options: CliOptions): Promise<void>;
7
+ declare function buildHtml(): string;
8
+
9
+ export { buildHtml, startServer };
package/dist/cli/ui.js ADDED
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(to, key) && key !== except)
17
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
18
+ }
19
+ return to;
20
+ };
21
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
22
+ // If the importer is in node compatibility mode or this is not an ESM
23
+ // file that has been converted to a CommonJS file using a Babel-
24
+ // compatible transform (i.e. "__esModule" has not been set), then set
25
+ // "default" to the CommonJS "module.exports" for node compatibility.
26
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
27
+ mod
28
+ ));
29
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
30
+
31
+ // src/cli/ui.ts
32
+ var ui_exports = {};
33
+ __export(ui_exports, {
34
+ buildHtml: () => buildHtml,
35
+ startServer: () => startServer
36
+ });
37
+ module.exports = __toCommonJS(ui_exports);
38
+
39
+ // node_modules/.pnpm/tsup@8.5.1_postcss@8.5.6_tsx@4.21.0_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js
40
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
41
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
42
+
43
+ // src/cli/ui.ts
44
+ var import_node_fs = __toESM(require("fs"));
45
+ var import_node_http = __toESM(require("http"));
46
+ var import_node_path = __toESM(require("path"));
47
+ var import_node_url = require("url");
48
+ var import_handlebars = __toESM(require("handlebars"));
49
+
50
+ // src/registry.ts
51
+ var entries = /* @__PURE__ */ new Map();
52
+ var nameIndex = /* @__PURE__ */ new Map();
53
+ var counter = 0;
54
+ var traceCounter = 0;
55
+ var traceLimit = 25;
56
+ function makeId() {
57
+ return `ruleset-${counter++}`;
58
+ }
59
+ var registry = {
60
+ /**
61
+ * Register a ruleset in the global registry.
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * const rs = Rules.ruleset<Facts, Effects>("eligibility");
66
+ * Rules.registry.list();
67
+ * ```
68
+ */
69
+ register(source, name) {
70
+ const id = makeId();
71
+ entries.set(id, { id, name, createdAt: Date.now(), source, traces: [] });
72
+ if (name) {
73
+ nameIndex.set(name, id);
74
+ }
75
+ return id;
76
+ },
77
+ /**
78
+ * List all registered rulesets.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * const list = Rules.registry.list();
83
+ * ```
84
+ */
85
+ list() {
86
+ return Array.from(entries.values()).map(({ id, name, createdAt }) => ({
87
+ id,
88
+ name,
89
+ createdAt
90
+ }));
91
+ },
92
+ /**
93
+ * Get a graph by id or ruleset name.
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * const graph = Rules.registry.getGraph("eligibility");
98
+ * ```
99
+ */
100
+ getGraph(idOrName) {
101
+ const entry = getEntry(idOrName);
102
+ return entry?.source.graph();
103
+ },
104
+ /**
105
+ * Get Mermaid output by id or ruleset name.
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * const mermaid = Rules.registry.getMermaid("eligibility");
110
+ * ```
111
+ */
112
+ getMermaid(idOrName) {
113
+ const entry = getEntry(idOrName);
114
+ return entry?.source.toMermaid();
115
+ },
116
+ /**
117
+ * Record a trace run for a ruleset. Keeps a rolling window of traces.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * Rules.registry.recordTrace("eligibility", trace, fired);
122
+ * ```
123
+ */
124
+ recordTrace(idOrName, trace, fired, facts) {
125
+ const entry = getEntry(idOrName);
126
+ if (!entry) {
127
+ return void 0;
128
+ }
129
+ const run = {
130
+ id: `trace-${traceCounter++}`,
131
+ createdAt: Date.now(),
132
+ facts,
133
+ fired,
134
+ trace
135
+ };
136
+ entry.traces.push(run);
137
+ if (entry.traces.length > traceLimit) {
138
+ entry.traces.splice(0, entry.traces.length - traceLimit);
139
+ }
140
+ return run;
141
+ },
142
+ /**
143
+ * List trace runs for a ruleset.
144
+ *
145
+ * @example
146
+ * ```ts
147
+ * const traces = Rules.registry.listTraces("eligibility");
148
+ * ```
149
+ */
150
+ listTraces(idOrName) {
151
+ const entry = getEntry(idOrName);
152
+ return entry ? [...entry.traces] : [];
153
+ },
154
+ /**
155
+ * Get a trace by id for a ruleset.
156
+ *
157
+ * @example
158
+ * ```ts
159
+ * const trace = Rules.registry.getTrace("eligibility", "trace-0");
160
+ * ```
161
+ */
162
+ getTrace(idOrName, traceId) {
163
+ const entry = getEntry(idOrName);
164
+ return entry?.traces.find((run) => run.id === traceId);
165
+ },
166
+ /**
167
+ * Clear the registry (useful in tests).
168
+ *
169
+ * @example
170
+ * ```ts
171
+ * Rules.registry.clear();
172
+ * ```
173
+ */
174
+ clear() {
175
+ entries.clear();
176
+ nameIndex.clear();
177
+ traceCounter = 0;
178
+ }
179
+ };
180
+ function getEntry(idOrName) {
181
+ const byId = entries.get(idOrName);
182
+ if (byId) {
183
+ return byId;
184
+ }
185
+ const id = nameIndex.get(idOrName);
186
+ return id ? entries.get(id) : void 0;
187
+ }
188
+
189
+ // src/cli/ui.ts
190
+ async function startServer(options) {
191
+ await loadModules(options.load, Date.now());
192
+ const server = import_node_http.default.createServer((req, res) => {
193
+ if (!req.url || req.url === "/") {
194
+ res.writeHead(200, { "Content-Type": "text/html" });
195
+ res.end(buildHtml());
196
+ return;
197
+ }
198
+ res.writeHead(404);
199
+ res.end("Not found");
200
+ });
201
+ server.listen(options.port, () => {
202
+ console.log(`Rulit UI running at http://localhost:${options.port}`);
203
+ });
204
+ }
205
+ function buildHtml() {
206
+ const entries2 = registry.list();
207
+ const template = import_handlebars.default.compile(loadTemplate());
208
+ const view = {
209
+ entries: entries2.map((entry) => {
210
+ const mermaid = registry.getMermaid(entry.id) ?? "flowchart TD\n empty";
211
+ const graph = registry.getGraph(entry.id) ?? { nodes: [], edges: [] };
212
+ return {
213
+ id: entry.id,
214
+ name: entry.name ?? entry.id,
215
+ createdAt: new Date(entry.createdAt).toLocaleString(),
216
+ mermaid,
217
+ mermaidEncoded: encodeURIComponent(mermaid),
218
+ traces: registry.listTraces(entry.id).map((trace) => ({
219
+ id: trace.id,
220
+ createdAt: new Date(trace.createdAt).toLocaleString(),
221
+ firedCount: trace.fired.length,
222
+ matchedCount: trace.trace.filter((rule) => rule.matched).length,
223
+ traceEncoded: encodeURIComponent(JSON.stringify(trace))
224
+ })),
225
+ json: JSON.stringify(graph, null, 2)
226
+ };
227
+ })
228
+ };
229
+ return template(view);
230
+ }
231
+ function parseArgs(args) {
232
+ const options = {
233
+ port: 5173,
234
+ load: []
235
+ };
236
+ for (let i = 0; i < args.length; i += 1) {
237
+ const value = args[i];
238
+ if (!value) {
239
+ continue;
240
+ }
241
+ if (value === "--port") {
242
+ const next = args[i + 1];
243
+ if (next) {
244
+ options.port = Number(next);
245
+ i += 1;
246
+ }
247
+ continue;
248
+ }
249
+ if (value === "--load") {
250
+ const next = args[i + 1];
251
+ if (next) {
252
+ options.load.push(next);
253
+ i += 1;
254
+ }
255
+ continue;
256
+ }
257
+ }
258
+ const envLoad = process.env.RULIT_UI_LOAD;
259
+ if (envLoad) {
260
+ options.load.push(
261
+ ...envLoad.split(",").map((value) => value.trim()).filter(Boolean)
262
+ );
263
+ }
264
+ const envPort = process.env.RULIT_UI_PORT;
265
+ if (envPort) {
266
+ options.port = Number(envPort);
267
+ }
268
+ return options;
269
+ }
270
+ async function loadModules(paths, cacheBust) {
271
+ for (const modulePath of paths) {
272
+ const fullPath = import_node_path.default.resolve(process.cwd(), modulePath);
273
+ await import(`${(0, import_node_url.pathToFileURL)(fullPath).href}?t=${cacheBust}`);
274
+ }
275
+ }
276
+ var isMain = typeof importMetaUrl === "string" && importMetaUrl === (0, import_node_url.pathToFileURL)(process.argv[1] ?? "").href;
277
+ if (isMain) {
278
+ const options = parseArgs(process.argv.slice(2));
279
+ void startServer(options);
280
+ }
281
+ function loadTemplate() {
282
+ const templatePath = resolveTemplatePath();
283
+ if (!templatePath) {
284
+ throw new Error("UI template not found. Expected ui-template.hbs near cli ui.");
285
+ }
286
+ return import_node_fs.default.readFileSync(templatePath, "utf8");
287
+ }
288
+ function resolveTemplatePath() {
289
+ const candidates = [];
290
+ if (typeof importMetaUrl === "string") {
291
+ candidates.push(
292
+ new URL("./ui-template.hbs", importMetaUrl),
293
+ new URL("../../src/cli/ui-template.hbs", importMetaUrl)
294
+ );
295
+ } else {
296
+ candidates.push(import_node_path.default.join(__dirname, "ui-template.hbs"));
297
+ }
298
+ for (const candidate of candidates) {
299
+ const filePath = typeof candidate === "string" ? candidate : (0, import_node_url.fileURLToPath)(candidate);
300
+ if (import_node_fs.default.existsSync(filePath)) {
301
+ return filePath;
302
+ }
303
+ }
304
+ return null;
305
+ }
306
+ // Annotate the CommonJS export names for ESM import in node:
307
+ 0 && (module.exports = {
308
+ buildHtml,
309
+ startServer
310
+ });
311
+ //# sourceMappingURL=ui.js.map