rulebookguard 0.1.1 → 0.3.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
@@ -13,7 +13,7 @@ It is language-agnostic: rules are Markdown, and tests in any language link to t
13
13
 
14
14
  ## Install
15
15
 
16
- Requires Node.js 22 or later.
16
+ Requires Node.js 22.23.4 or later (the `site` command builds with Astro 7, which needs it).
17
17
 
18
18
  ```sh
19
19
  npx rulebookguard lint
@@ -236,6 +236,50 @@ docs/rules/clients/R-CLI-004.md
236
236
 
237
237
  Exits 1 for an unknown context or when the context has no free id left.
238
238
 
239
+ ### `hook`
240
+
241
+ A Claude Code hook for `PreToolUse` and `PostToolUse` events of tools that take a `file_path` (the Claude Code plugin below wires it up). It reads the hook event as JSON on stdin, takes `tool_input.file_path` (relative paths resolve against the event's `cwd`), finds `rulebookguard.config.yaml` by walking up from that file, and prints the active rules anchored to it as additional context for Claude, under the same `hookEventName`:
242
+
243
+ ```sh
244
+ $ echo '{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":"/repo/src/billing/invoice.go"}}' | rulebookguard hook
245
+ {"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"Business rules anchored to src/billing/invoice.go (rulebookguard). …"}}
246
+ ```
247
+
248
+ Claude Code adds the context next to the tool result, so a `PostToolUse` hook on `Read` delivers the rules together with the file contents, before Claude writes a change (Claude Code requires reading a file before editing it).
249
+
250
+ Each rule comes with its id, context, summary, rule file and full text; when the texts add up to more than 10,000 characters only the summaries are listed, with a pointer to `rulebookguard show <id>`. The header follows `language` in the config. The hook never blocks a tool and never answers the permission prompt: with no config, a path outside the rulebook root, no active rule, another event, unreadable input or any error, it prints nothing and exits 0.
251
+
252
+ ### `mcp [--project <dir>]`
253
+
254
+ An [MCP](https://modelcontextprotocol.io) server over stdio for AI agents, with two read-only tools:
255
+
256
+ | Tool | Input | Answers |
257
+ | ------------ | ------------------------------------------------------ | --------------------------------------------- |
258
+ | `rules_for` | `path`: absolute, or relative to the project directory | what `rulebookguard for <path> --json` prints |
259
+ | `rules_show` | `id`: a rule id | what `rulebookguard show <id> --json` prints |
260
+
261
+ The rulebook is found from `--project` (default: the current directory). A command that would fail answers a tool error carrying the same message the CLI prints, for example `Unknown rule id "R-NOPE-001". Run "rulebookguard list" to list them.`
262
+
263
+ ### `site [--out <dir>] [--base <path>]`
264
+
265
+ Builds a read-only static site of the rulebook with [Astro Starlight](https://starlight.astro.build) into `--out` (default `site`, relative to the current directory) and prints that path. Open it from disk or publish it as is, for example on GitHub Pages; pass `--base` when it is served from a sub-path (`--base /rules`).
266
+
267
+ ```sh
268
+ $ rulebookguard site --out public/rules --base /rules
269
+ public/rules
270
+ ```
271
+
272
+ The site has:
273
+
274
+ - **Home:** the contexts map, with how many rules of each status every context holds.
275
+ - **A page per context** with its description and rules.
276
+ - **All rules:** one table filterable by text, context and status.
277
+ - **A page per rule:** summary, status, entities, rule file, the supersedes chain both ways, the rule text, its anchors and the files they match, the tests tagging it, and the git history of the rule file.
278
+ - **Health:** the `lint` findings when the site was built.
279
+ - Full-text search (Pagefind).
280
+
281
+ Generated text and the interface follow `language` in the config. The site is a view: rules are still edited in their files, through pull requests. `site` replaces `--out` only when it is empty or was written by an earlier `site`; any other non-empty directory makes it fail, exit 1, without touching it. Add the output directory to `.gitignore` unless you mean to commit it.
282
+
239
283
  ### `lint [--base <ref>] [--json]`
240
284
 
241
285
  Checks the whole catalog. Each finding is printed as `<severity> <code> <file>[:line] <message>`, followed by a count.
@@ -295,9 +339,28 @@ jobs:
295
339
 
296
340
  Pin the version, or add rulebookguard as a dev dependency and run it from the lockfile, so a new release never changes CI results by itself.
297
341
 
342
+ ## Claude Code plugin
343
+
344
+ The plugin in [`plugin/`](plugin) connects the rulebook to Claude Code:
345
+
346
+ - **Hooks:** after every `Read` and before every `Write`, it runs `rulebookguard hook`, so Claude gets the active rules anchored to a file together with its contents, before it writes a change. A `Write` of a new file, which needs no `Read`, gets them right after the file is created.
347
+ - **MCP server:** the `rules_for` and `rules_show` tools from `rulebookguard mcp`, for rules of files Claude has not opened yet.
348
+ - **Skill** `rulebookguard:rules`: the protocol, that is, follow the rules, and change a rule (then its tests, then the code) only when the user asks for the behavior change.
349
+
350
+ Install it from this repository's marketplace:
351
+
352
+ ```sh
353
+ claude plugin marketplace add JuanCJR/rulebookguard
354
+ claude plugin install rulebookguard@rulebookguard
355
+ ```
356
+
357
+ Or, inside Claude Code 2.1.275 or later: `/plugin install rulebookguard --marketplace JuanCJR/rulebookguard`.
358
+
359
+ The plugin runs the project's own rulebookguard (`node_modules/rulebookguard`, in the project directory or a parent) when its version is at least the plugin's; otherwise it runs `npx rulebookguard@<plugin version>`, which needs Node.js and network access the first time. The hooks never block a tool: any failure, including a timeout, lets it go ahead without rules.
360
+
298
361
  ## Releasing
299
362
 
300
- Releases are published to npm from GitHub Actions with [trusted publishing](https://docs.npmjs.com/trusted-publishers), so no npm token is stored and every version carries a provenance attestation. To release, bump `version` in `package.json`, merge it to `main`, then push a matching tag:
363
+ Releases are published to npm from GitHub Actions with [trusted publishing](https://docs.npmjs.com/trusted-publishers), so no npm token is stored and every version carries a provenance attestation. To release, bump `version` in `package.json` and in `plugin/.claude-plugin/plugin.json` (a test keeps them equal), merge it to `main`, then push a matching tag:
301
364
 
302
365
  ```sh
303
366
  git tag v0.1.0
@@ -0,0 +1,10 @@
1
+ import { h as createGlobMatcher, v as toRepoPath } from "./lint-C1yUeLOq.mjs";
2
+ // src/catalog/anchors.ts
3
+ function rulesForPath(rules, path) {
4
+ const target = toRepoPath(path);
5
+ return rules.filter((rule) => createGlobMatcher(rule.anchors)(target)).sort((a, b) => compare(a.id, b.id));
6
+ }
7
+ function compare(a, b) {
8
+ return a < b ? -1 : a > b ? 1 : 0;
9
+ }
10
+ export { rulesForPath as t };
package/dist/cli.mjs CHANGED
@@ -1,8 +1,139 @@
1
1
  #!/usr/bin/env node
2
- import { M as FormatError, N as formatIssue, _ as expandGlobs, a as CliError, c as openCatalog, d as scanTestTags, h as rulesForPath, l as writeJson, o as findRule, r as lintCommand, s as gitFallbackWarning, u as writeLines, y as toRepoPath } from "./lint-DtD_Ekei.mjs";
2
+ import { M as FormatError, N as formatIssue, a as CliError, c as openCatalog, d as scanTestTags, f as findRulebookRoot, g as expandGlobs, l as writeJson, o as findRule, p as loadCatalog, r as lintCommand, s as gitFallbackWarning, u as writeLines, v as toRepoPath } from "./lint-C1yUeLOq.mjs";
3
+ import { t as rulesForPath } from "./anchors-BFJCm1NU.mjs";
3
4
  import { readdir, writeFile } from "node:fs/promises";
4
- import { isAbsolute, join, relative, resolve } from "node:path";
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
6
  import { parseArgs } from "node:util";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
8
+ import { McpServer } from "@modelcontextprotocol/server";
9
+ import * as z from "zod/v4";
10
+ // src/commands/hook.ts
11
+ const HOOK_BODY_LIMIT = 1e4;
12
+ const TEXT = {
13
+ en: {
14
+ header: (path) => `Business rules anchored to ${path} (rulebookguard). They are the source of truth: keep any change to this file consistent with them, and change the rule first if a change alters its behavior.`,
15
+ ruleFile: "Rule file",
16
+ omitted: `Rule texts omitted (over ${HOOK_BODY_LIMIT} characters); run \`rulebookguard show <id>\` for the full text.`
17
+ },
18
+ es: {
19
+ header: (path) => `Reglas de negocio ancladas a ${path} (rulebookguard). Son la fuente de verdad: mantén cualquier cambio a este archivo coherente con ellas y, si un cambio altera su comportamiento, cambia primero la regla.`,
20
+ ruleFile: "Archivo de la regla",
21
+ omitted: `Se omitió el texto de las reglas (más de ${HOOK_BODY_LIMIT} caracteres); ejecuta \`rulebookguard show <id>\` para verlo completo.`
22
+ }
23
+ };
24
+ async function hookCommand(io) {
25
+ try {
26
+ const event = parseEvent(await (io.stdin?.() ?? Promise.resolve("")));
27
+ const context = event === void 0 ? void 0 : await hookContext(event, io.cwd);
28
+ if (event !== void 0 && context !== void 0) io.stdout(`${JSON.stringify({ hookSpecificOutput: {
29
+ hookEventName: event.name,
30
+ additionalContext: context
31
+ } })}\n`);
32
+ } catch {}
33
+ }
34
+ const TOOL_EVENTS = ["PreToolUse", "PostToolUse"];
35
+ async function hookContext(event, cwd) {
36
+ const file = resolve(event.cwd ?? cwd, event.filePath);
37
+ const root = await findRulebookRoot(dirname(file));
38
+ if (root === void 0) return;
39
+ const path = relative(root, file);
40
+ if (path === "" || path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return;
41
+ const catalog = await loadCatalog(root);
42
+ const repoPath = path.split(sep).join("/");
43
+ const rules = rulesForPath(catalog.rules, repoPath).filter((rule) => rule.status === "active");
44
+ if (rules.length === 0) return;
45
+ return render(repoPath, rules, TEXT[catalog.config.language ?? "en"]);
46
+ }
47
+ function parseEvent(input) {
48
+ let event;
49
+ try {
50
+ event = JSON.parse(input);
51
+ } catch {
52
+ return;
53
+ }
54
+ if (typeof event !== "object" || event === null) return;
55
+ const { hook_event_name: name, tool_input: toolInput, cwd } = event;
56
+ const filePath = toolInput?.file_path;
57
+ const toolEvent = TOOL_EVENTS.find((candidate) => candidate === name);
58
+ if (toolEvent === void 0 || typeof filePath !== "string" || filePath === "") return;
59
+ return typeof cwd === "string" ? {
60
+ name: toolEvent,
61
+ filePath,
62
+ cwd
63
+ } : {
64
+ name: toolEvent,
65
+ filePath
66
+ };
67
+ }
68
+ function render(path, rules, text) {
69
+ const withBodies = rules.reduce((total, rule) => total + rule.body.trim().length, 0) <= HOOK_BODY_LIMIT;
70
+ const sections = rules.map((rule) => {
71
+ const lines = [`## ${rule.id} (${rule.context}): ${rule.summary}`, `${text.ruleFile}: ${rule.file}`];
72
+ return withBodies ? [
73
+ ...lines,
74
+ "",
75
+ rule.body.trim()
76
+ ].join("\n") : lines.join("\n");
77
+ });
78
+ const parts = [text.header(path), ...sections];
79
+ if (!withBodies) parts.push(text.omitted);
80
+ return parts.join("\n\n");
81
+ }
82
+ // package.json
83
+ var version = "0.3.0";
84
+ // src/mcp/server.ts
85
+ function createRulesServer(projectDir) {
86
+ const server = new McpServer({
87
+ name: "rulebookguard",
88
+ version
89
+ });
90
+ server.registerTool("rules_for", {
91
+ description: "Active business rules anchored to a file (id, context, summary, rule file). Call it before changing a file; the rules are the source of truth for its behavior.",
92
+ inputSchema: z.object({ path: z.string().describe("File path, absolute or relative to the project directory") }),
93
+ annotations: { readOnlyHint: true }
94
+ }, ({ path }) => cli(projectDir, [
95
+ "for",
96
+ path,
97
+ "--json"
98
+ ]));
99
+ server.registerTool("rules_show", {
100
+ description: "Full text of one business rule: summary, anchors, entities, supersedes and the rule body with its examples.",
101
+ inputSchema: z.object({ id: z.string().describe("Rule id, for example R-CLI-001") }),
102
+ annotations: { readOnlyHint: true }
103
+ }, ({ id }) => cli(projectDir, [
104
+ "show",
105
+ id,
106
+ "--json"
107
+ ]));
108
+ return server;
109
+ }
110
+ async function cli(cwd, args) {
111
+ let stdout = "";
112
+ let stderr = "";
113
+ if (await runCli(args, {
114
+ cwd,
115
+ stdout: (text) => {
116
+ stdout += text;
117
+ },
118
+ stderr: (text) => {
119
+ stderr += text;
120
+ }
121
+ }) === 0) return { content: [{
122
+ type: "text",
123
+ text: stdout
124
+ }] };
125
+ return {
126
+ content: [{
127
+ type: "text",
128
+ text: stderr.trim()
129
+ }],
130
+ isError: true
131
+ };
132
+ }
133
+ // src/commands/mcp.ts
134
+ async function mcpCommand(io, project) {
135
+ await createRulesServer(resolve(io.cwd, project ?? ".")).connect(new StdioServerTransport());
136
+ }
6
137
  // src/commands/new.ts
7
138
  const MAX_NUMBER = 999;
8
139
  async function newCommand(catalog, io, name) {
@@ -194,6 +325,12 @@ Commands:
194
325
  new <context> scaffold the next free rule id as a proposed rule
195
326
  lint [--base <ref>] [--json] check the catalog, anchors and test tags;
196
327
  --base: rules changed since <ref> need a changed test
328
+ hook Claude Code PreToolUse hook: reads the event on stdin
329
+ and prints the rules anchored to the edited file
330
+ mcp [--project <dir>] MCP server over stdio with rules_for and rules_show;
331
+ --project: directory to find the rulebook from (default: cwd)
332
+ site [--out <dir>] [--base <path>] build the read-only static site of the rulebook;
333
+ --out: output directory (default: site), --base: URL path
197
334
 
198
335
  Exit codes: 0 success, 1 failure (lint: any error), 2 usage error.
199
336
  `;
@@ -231,6 +368,21 @@ const COMMANDS = {
231
368
  argument: "context",
232
369
  options: {}
233
370
  },
371
+ hook: {
372
+ usage: "rulebookguard hook",
373
+ options: {}
374
+ },
375
+ mcp: {
376
+ usage: "rulebookguard mcp [--project <dir>]",
377
+ options: { project: { type: "string" } }
378
+ },
379
+ site: {
380
+ usage: "rulebookguard site [--out <dir>] [--base <path>]",
381
+ options: {
382
+ out: { type: "string" },
383
+ base: { type: "string" }
384
+ }
385
+ },
234
386
  lint: {
235
387
  usage: "rulebookguard lint [--base <ref>] [--json]",
236
388
  options: {
@@ -264,6 +416,22 @@ async function dispatch(argv, io) {
264
416
  if (spec === void 0) throw new CliError(`Unknown command "${name}".\n${USAGE.trimEnd()}`, 2);
265
417
  const { values, argument } = parse(spec, rest);
266
418
  const json = values.json === true;
419
+ if (name === "hook") {
420
+ await hookCommand(io);
421
+ return 0;
422
+ }
423
+ if (name === "mcp") {
424
+ await mcpCommand(io, values.project);
425
+ return 0;
426
+ }
427
+ if (name === "site") {
428
+ const { siteCommand } = await import("./site-DYOm-dEC.mjs");
429
+ await siteCommand(io, {
430
+ out: values.out,
431
+ base: values.base
432
+ });
433
+ return 0;
434
+ }
267
435
  if (name === "lint") return lintCommand(io, {
268
436
  base: values.base,
269
437
  json
@@ -317,6 +485,11 @@ function parse(spec, args) {
317
485
  process.exitCode = await runCli(process.argv.slice(2), {
318
486
  cwd: process.cwd(),
319
487
  stdout: (text) => process.stdout.write(text),
320
- stderr: (text) => process.stderr.write(text)
488
+ stderr: (text) => process.stderr.write(text),
489
+ stdin: async () => {
490
+ let text = "";
491
+ for await (const chunk of process.stdin) text += String(chunk);
492
+ return text;
493
+ }
321
494
  });
322
495
  export {};
package/dist/index.mjs CHANGED
@@ -1 +1,3 @@
1
- export { k as CONFIG_FILE_NAME, T as CONTEXT_FILE_NAME, M as FormatError, b as RULE_TAG_PATTERN, g as createGlobMatcher, _ as expandGlobs, x as findRuleTags, f as findRulebookRoot, t as formatFinding, N as formatIssue, S as isRuleId, n as lintCatalog, v as listRepoFiles, p as loadCatalog, A as loadConfig, E as loadContext, C as loadRule, j as parseConfig, D as parseContext, w as parseRule, i as pendingImplementation, m as relativeToRoot, h as rulesForPath, d as scanTestTags, O as splitFrontmatter, y as toRepoPath } from "./lint-DtD_Ekei.mjs";
1
+ import { A as loadConfig, C as loadRule, D as parseContext, E as loadContext, M as FormatError, N as formatIssue, O as splitFrontmatter, S as isRuleId, T as CONTEXT_FILE_NAME, _ as listRepoFiles, b as RULE_TAG_PATTERN, d as scanTestTags, f as findRulebookRoot, g as expandGlobs, h as createGlobMatcher, i as pendingImplementation, j as parseConfig, k as CONFIG_FILE_NAME, m as relativeToRoot, n as lintCatalog, p as loadCatalog, t as formatFinding, v as toRepoPath, w as parseRule, x as findRuleTags } from "./lint-C1yUeLOq.mjs";
2
+ import { t as rulesForPath } from "./anchors-BFJCm1NU.mjs";
3
+ export { CONFIG_FILE_NAME, CONTEXT_FILE_NAME, FormatError, RULE_TAG_PATTERN, createGlobMatcher, expandGlobs, findRuleTags, findRulebookRoot, formatFinding, formatIssue, isRuleId, lintCatalog, listRepoFiles, loadCatalog, loadConfig, loadContext, loadRule, parseConfig, parseContext, parseRule, pendingImplementation, relativeToRoot, rulesForPath, scanTestTags, splitFrontmatter, toRepoPath };
@@ -452,14 +452,6 @@ async function walk(root, prefix, files) {
452
452
  } else if (entry.isFile()) files.push(path);
453
453
  }
454
454
  }
455
- // src/catalog/anchors.ts
456
- function rulesForPath(rules, path) {
457
- const target = toRepoPath(path);
458
- return rules.filter((rule) => createGlobMatcher(rule.anchors)(target)).sort((a, b) => compare$1(a.id, b.id));
459
- }
460
- function compare$1(a, b) {
461
- return a < b ? -1 : a > b ? 1 : 0;
462
- }
463
455
  // src/catalog/load.ts
464
456
  async function findRulebookRoot(start) {
465
457
  let directory = resolve(start);
@@ -760,4 +752,4 @@ function plural(count, noun) {
760
752
  function compare(a, b) {
761
753
  return a < b ? -1 : a > b ? 1 : 0;
762
754
  }
763
- export { loadConfig as A, loadRule as C, parseContext as D, loadContext as E, FormatError as M, formatIssue as N, splitFrontmatter as O, isRuleId as S, CONTEXT_FILE_NAME as T, expandGlobs as _, CliError as a, RULE_TAG_PATTERN as b, openCatalog as c, scanTestTags as d, findRulebookRoot as f, createGlobMatcher as g, rulesForPath as h, pendingImplementation as i, parseConfig as j, CONFIG_FILE_NAME as k, writeJson as l, relativeToRoot as m, lintCatalog as n, findRule as o, loadCatalog as p, lintCommand as r, gitFallbackWarning as s, formatFinding as t, writeLines as u, listRepoFiles as v, parseRule as w, findRuleTags as x, toRepoPath as y };
755
+ export { loadConfig as A, loadRule as C, parseContext as D, loadContext as E, FormatError as M, formatIssue as N, splitFrontmatter as O, isRuleId as S, CONTEXT_FILE_NAME as T, listRepoFiles as _, CliError as a, RULE_TAG_PATTERN as b, openCatalog as c, scanTestTags as d, findRulebookRoot as f, expandGlobs as g, createGlobMatcher as h, pendingImplementation as i, parseConfig as j, CONFIG_FILE_NAME as k, writeJson as l, relativeToRoot as m, lintCatalog as n, findRule as o, loadCatalog as p, lintCommand as r, gitFallbackWarning as s, formatFinding as t, writeLines as u, toRepoPath as v, parseRule as w, findRuleTags as x, runGit as y };
@@ -0,0 +1,491 @@
1
+ import { a as CliError, c as openCatalog, d as scanTestTags, g as expandGlobs, n as lintCatalog, s as gitFallbackWarning, u as writeLines, y as runGit } from "./lint-C1yUeLOq.mjs";
2
+ import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { randomUUID } from "node:crypto";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ // src/site/data.ts
10
+ const FIELD = "";
11
+ async function siteData(catalog, onGitFallback) {
12
+ const tags = await scanTestTags(catalog.root, catalog.config.testGlobs, onGitFallback);
13
+ const byId = new Map(catalog.rules.map((rule) => [rule.id, rule]));
14
+ const rules = [...catalog.rules].sort((a, b) => compare(a.id, b.id));
15
+ return {
16
+ contexts: [...catalog.contexts].sort((a, b) => compare(a.context, b.context)),
17
+ rules: await Promise.all(rules.map(async (rule) => ({
18
+ rule,
19
+ files: await expandGlobs(catalog.root, rule.anchors, onGitFallback),
20
+ tests: tags.get(rule.id) ?? [],
21
+ history: await history(catalog.root, rule.file),
22
+ supersedes: chain(rule, byId),
23
+ supersededBy: rules.find((other) => other.supersedes === rule.id)?.id
24
+ }))),
25
+ findings: await lintCatalog(catalog, tags, onGitFallback)
26
+ };
27
+ }
28
+ function chain(rule, byId) {
29
+ const ids = [];
30
+ for (let next = rule.supersedes; next !== void 0; next = byId.get(next)?.supersedes) {
31
+ ids.push(next);
32
+ if (next === rule.id || ids.indexOf(next) !== ids.length - 1) break;
33
+ }
34
+ return ids;
35
+ }
36
+ async function history(root, file) {
37
+ let output;
38
+ try {
39
+ output = await runGit(root, [
40
+ "log",
41
+ "--follow",
42
+ `--format=%h${FIELD}%as${FIELD}%an${FIELD}%s`,
43
+ "--",
44
+ file
45
+ ]);
46
+ } catch {
47
+ return [];
48
+ }
49
+ return output.split("\n").filter((line) => line !== "").map((line) => {
50
+ const [hash = "", date = "", author = "", subject = ""] = line.split(FIELD);
51
+ return {
52
+ hash,
53
+ date,
54
+ author,
55
+ subject
56
+ };
57
+ });
58
+ }
59
+ function compare(a, b) {
60
+ return a < b ? -1 : a > b ? 1 : 0;
61
+ }
62
+ // src/site/pages.ts
63
+ const STATUSES = [
64
+ "active",
65
+ "proposed",
66
+ "deprecated"
67
+ ];
68
+ const TEXT = {
69
+ en: {
70
+ title: "Business rules",
71
+ homeIntro: "The business rules of this repository: the source of truth that the code implements and the tests verify.",
72
+ contexts: "Contexts",
73
+ context: "Context",
74
+ prefix: "Prefix",
75
+ summary: "Summary",
76
+ allRules: "All rules",
77
+ health: "Health",
78
+ rule: "Rule",
79
+ status: "Status",
80
+ entities: "Entities",
81
+ supersedes: "Supersedes",
82
+ supersededBy: "Superseded by",
83
+ ruleFile: "Rule file",
84
+ ruleText: "Rule",
85
+ anchors: "Anchors",
86
+ matchedFiles: "Files matched by the anchors",
87
+ tests: "Tests",
88
+ history: "History",
89
+ date: "Date",
90
+ commit: "Commit",
91
+ author: "Author",
92
+ change: "Change",
93
+ noHistory: "No git history.",
94
+ none: "None.",
95
+ search: "Search rules",
96
+ anyContext: "Any context",
97
+ anyStatus: "Any status",
98
+ noMatch: "No rule matches the filters.",
99
+ severity: "Severity",
100
+ code: "Code",
101
+ file: "File",
102
+ line: "Line",
103
+ message: "Message",
104
+ noIssues: "No issues found.",
105
+ counts: (errors, warnings) => `${errors} ${errors === 1 ? "error" : "errors"}, ${warnings} ${warnings === 1 ? "warning" : "warnings"}`,
106
+ healthIntro: "Findings of `rulebookguard lint` when the site was built."
107
+ },
108
+ es: {
109
+ title: "Reglas de negocio",
110
+ homeIntro: "Las reglas de negocio de este repositorio: la fuente de verdad que el código implementa y los tests verifican.",
111
+ contexts: "Contextos",
112
+ context: "Contexto",
113
+ prefix: "Prefijo",
114
+ summary: "Resumen",
115
+ allRules: "Todas las reglas",
116
+ health: "Salud",
117
+ rule: "Regla",
118
+ status: "Estado",
119
+ entities: "Entidades",
120
+ supersedes: "Reemplaza a",
121
+ supersededBy: "Reemplazada por",
122
+ ruleFile: "Archivo de la regla",
123
+ ruleText: "Regla",
124
+ anchors: "Anclas",
125
+ matchedFiles: "Archivos que coinciden con las anclas",
126
+ tests: "Tests",
127
+ history: "Historial",
128
+ date: "Fecha",
129
+ commit: "Commit",
130
+ author: "Autor",
131
+ change: "Cambio",
132
+ noHistory: "Sin historial de git.",
133
+ none: "Ninguno.",
134
+ search: "Buscar reglas",
135
+ anyContext: "Cualquier contexto",
136
+ anyStatus: "Cualquier estado",
137
+ noMatch: "Ninguna regla coincide con los filtros.",
138
+ severity: "Severidad",
139
+ code: "Código",
140
+ file: "Archivo",
141
+ line: "Línea",
142
+ message: "Mensaje",
143
+ noIssues: "No se encontraron problemas.",
144
+ counts: (errors, warnings) => `${errors} ${errors === 1 ? "error" : "errores"}, ${warnings} ${warnings === 1 ? "advertencia" : "advertencias"}`,
145
+ healthIntro: "Hallazgos de `rulebookguard lint` al momento de generar el sitio."
146
+ }
147
+ };
148
+ function siteText(language) {
149
+ return TEXT[language];
150
+ }
151
+ function normalizeBase(base) {
152
+ const trimmed = base.replace(/^\/+|\/+$/g, "");
153
+ return trimmed === "" ? "/" : `/${trimmed}/`;
154
+ }
155
+ function sitePages(data, options) {
156
+ const text = TEXT[options.language];
157
+ const base = normalizeBase(options.base);
158
+ const rulePath = new Map(data.rules.map(({ rule }) => [rule.id, `${base}${rule.context}/${rule.id.toLowerCase()}/`]));
159
+ const ruleByFile = new Map(data.rules.map(({ rule }) => [rule.file, rule.id]));
160
+ const ruleLink = (id) => {
161
+ const path = rulePath.get(id);
162
+ return path === void 0 ? id : `[${id}](${path})`;
163
+ };
164
+ const files = /* @__PURE__ */ new Map();
165
+ files.set("src/content.config.ts", `import { defineCollection } from 'astro:content';
166
+ import { docsLoader } from '@astrojs/starlight/loaders';
167
+ import { docsSchema } from '@astrojs/starlight/schema';
168
+
169
+ export const collections = { docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }) };
170
+ `);
171
+ files.set("src/content/docs/index.md", page(text.title, text.homeIntro, [
172
+ text.homeIntro,
173
+ "",
174
+ `[${text.allRules}](${base}rules/) · [${text.health}](${base}health/)`,
175
+ "",
176
+ `## ${text.contexts}`,
177
+ "",
178
+ `| ${text.context} | ${text.prefix} | ${text.summary} | ${STATUSES.join(" | ")} |`,
179
+ `| --- | --- | --- | ${STATUSES.map(() => "---:").join(" | ")} |`,
180
+ ...data.contexts.map((context) => {
181
+ const rules = data.rules.filter(({ rule }) => rule.context === context.context);
182
+ const counts = STATUSES.map((status) => rules.filter(({ rule }) => rule.status === status).length);
183
+ return `| [${context.context}](${base}${context.context}/) | ${context.prefix} | ${cell(context.summary)} | ${counts.join(" | ")} |`;
184
+ })
185
+ ]));
186
+ for (const context of data.contexts) {
187
+ const rules = data.rules.filter(({ rule }) => rule.context === context.context);
188
+ files.set(`src/content/docs/${context.context}/index.md`, page(context.context, context.summary, [
189
+ `**${cell(context.summary)}**`,
190
+ "",
191
+ demote(context.body.trim()),
192
+ "",
193
+ `| ${text.rule} | ${text.status} | ${text.summary} |`,
194
+ "| --- | --- | --- |",
195
+ ...rules.map(({ rule }) => `| ${ruleLink(rule.id)} | ${rule.status} | ${cell(rule.summary)} |`)
196
+ ]));
197
+ }
198
+ for (const entry of data.rules) files.set(`src/content/docs/${entry.rule.context}/${entry.rule.id.toLowerCase()}.md`, rulePage(entry, text, base, ruleLink));
199
+ files.set("src/pages/rules.astro", rulesPage(data, text, rulePath));
200
+ const errors = data.findings.filter((finding) => finding.severity === "error").length;
201
+ const warnings = data.findings.length - errors;
202
+ files.set("src/content/docs/health.md", page(text.health, text.healthIntro, [
203
+ text.healthIntro,
204
+ "",
205
+ data.findings.length === 0 ? text.noIssues : `**${text.counts(errors, warnings)}**`,
206
+ "",
207
+ ...data.findings.length === 0 ? [] : [
208
+ `| ${text.severity} | ${text.code} | ${text.file} | ${text.line} | ${text.message} |`,
209
+ "| --- | --- | --- | ---: | --- |",
210
+ ...data.findings.map((finding) => {
211
+ const id = ruleByFile.get(finding.file);
212
+ const path = id === void 0 ? void 0 : rulePath.get(id);
213
+ const file = path === void 0 ? `\`${finding.file}\`` : `[${finding.file}](${path})`;
214
+ return `| ${finding.severity} | ${finding.code} | ${file} | ${finding.line ?? ""} | ${cell(finding.message)} |`;
215
+ })
216
+ ]
217
+ ]));
218
+ return files;
219
+ }
220
+ function rulePage({ rule, files, tests, history, supersedes, supersededBy }, text, base, ruleLink) {
221
+ const rows = [
222
+ [text.status, rule.status],
223
+ [text.context, `[${rule.context}](${base}${rule.context}/)`],
224
+ [text.entities, rule.entities.length === 0 ? "—" : rule.entities.map(cell).join(", ")],
225
+ [text.ruleFile, `\`${rule.file}\``]
226
+ ];
227
+ if (supersedes.length > 0) rows.push([text.supersedes, supersedes.map(ruleLink).join(" → ")]);
228
+ if (supersededBy !== void 0) rows.push([text.supersededBy, ruleLink(supersededBy)]);
229
+ return page(rule.id, rule.summary, [
230
+ `**${cell(rule.summary)}**`,
231
+ "",
232
+ "| | |",
233
+ "| --- | --- |",
234
+ ...rows.map(([label, value]) => `| ${label} | ${value} |`),
235
+ "",
236
+ `## ${text.ruleText}`,
237
+ "",
238
+ demote(rule.body.trim()),
239
+ "",
240
+ `## ${text.anchors}`,
241
+ "",
242
+ ...list(rule.anchors.map((anchor) => `\`${anchor}\``), text.none),
243
+ "",
244
+ `### ${text.matchedFiles} (${files.length})`,
245
+ "",
246
+ ...list(files.map((file) => `\`${file}\``), text.none),
247
+ "",
248
+ `## ${text.tests} (${tests.length})`,
249
+ "",
250
+ ...list(tests.map((test) => `\`${test.file}:${test.line}\``), text.none),
251
+ "",
252
+ `## ${text.history}`,
253
+ "",
254
+ ...history.length === 0 ? [text.noHistory] : [
255
+ `| ${text.date} | ${text.commit} | ${text.author} | ${text.change} |`,
256
+ "| --- | --- | --- | --- |",
257
+ ...history.map((commit) => `| ${commit.date} | \`${commit.hash}\` | ${cell(commit.author)} | ${cell(commit.subject)} |`)
258
+ ]
259
+ ]);
260
+ }
261
+ function rulesPage(data, text, rulePath) {
262
+ const contexts = data.contexts.map((context) => context.context);
263
+ const rows = data.rules.map(({ rule }) => {
264
+ const search = [
265
+ rule.id,
266
+ rule.context,
267
+ rule.summary
268
+ ].join(" ").toLowerCase();
269
+ return ` <tr data-context="${html(rule.context)}" data-status="${rule.status}" data-search="${html(search)}">
270
+ <td><a href="${rulePath.get(rule.id) ?? ""}">${rule.id}</a></td>
271
+ <td>${html(rule.context)}</td>
272
+ <td>${rule.status}</td>
273
+ <td>${html(rule.summary)}</td>
274
+ </tr>`;
275
+ });
276
+ return `---
277
+ import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
278
+ ---
279
+
280
+ <StarlightPage frontmatter={{ title: ${JSON.stringify(text.allRules)}, tableOfContents: false }}>
281
+ <div class="rbg-filters">
282
+ <input type="search" data-filter="search" placeholder="${html(text.search)}" aria-label="${html(text.search)}" />
283
+ <select data-filter="context" aria-label="${html(text.context)}">
284
+ <option value="">${html(text.anyContext)}</option>
285
+ ${contexts.map((context) => ` <option value="${html(context)}">${html(context)}</option>`).join("\n")}
286
+ </select>
287
+ <select data-filter="status" aria-label="${html(text.status)}">
288
+ <option value="">${html(text.anyStatus)}</option>
289
+ ${STATUSES.map((status) => ` <option value="${status}">${status}</option>`).join("\n")}
290
+ </select>
291
+ </div>
292
+ <table class="rbg-rules">
293
+ <thead>
294
+ <tr><th>${html(text.rule)}</th><th>${html(text.context)}</th><th>${html(text.status)}</th><th>${html(text.summary)}</th></tr>
295
+ </thead>
296
+ <tbody>
297
+ ${rows.join("\n")}
298
+ </tbody>
299
+ </table>
300
+ <p class="rbg-empty" hidden>${html(text.noMatch)}</p>
301
+ </StarlightPage>
302
+
303
+ <style>
304
+ .rbg-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
305
+ .rbg-filters input { flex: 1 1 16rem; }
306
+ </style>
307
+
308
+ <script>
309
+ const value = (name: string) =>
310
+ (document.querySelector<HTMLInputElement | HTMLSelectElement>(\`[data-filter="\${name}"]\`)?.value ?? '').trim().toLowerCase();
311
+ const apply = () => {
312
+ const search = value('search');
313
+ const context = value('context');
314
+ const status = value('status');
315
+ let shown = 0;
316
+ for (const row of document.querySelectorAll<HTMLTableRowElement>('.rbg-rules tbody tr')) {
317
+ const visible =
318
+ (search === '' || (row.dataset.search ?? '').includes(search)) &&
319
+ (context === '' || row.dataset.context === context) &&
320
+ (status === '' || row.dataset.status === status);
321
+ row.hidden = !visible;
322
+ if (visible) shown += 1;
323
+ }
324
+ const empty = document.querySelector<HTMLElement>('.rbg-empty');
325
+ if (empty) empty.hidden = shown > 0;
326
+ };
327
+ for (const control of document.querySelectorAll('[data-filter]')) {
328
+ control.addEventListener('input', apply);
329
+ }
330
+ <\/script>
331
+ `;
332
+ }
333
+ function page(title, description, lines) {
334
+ return [
335
+ "---",
336
+ `title: ${JSON.stringify(title)}`,
337
+ `description: ${JSON.stringify(description)}`,
338
+ "---",
339
+ "",
340
+ ...lines,
341
+ ""
342
+ ].join("\n");
343
+ }
344
+ function list(items, empty) {
345
+ return items.length === 0 ? [empty] : items.map((item) => `- ${item}`);
346
+ }
347
+ /** Text inside a Markdown table cell. */
348
+ function cell(text) {
349
+ return text.replaceAll("\\", "\\\\").replaceAll("|", "\\|").replaceAll("<", "&lt;");
350
+ }
351
+ /** Text inside the Astro page, where braces start expressions. */
352
+ function html(text) {
353
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("{", "&#123;").replaceAll("}", "&#125;");
354
+ }
355
+ /** Pushes the headings of a rule text two levels down, below the page's own sections. */
356
+ function demote(markdown) {
357
+ let fence;
358
+ return markdown.split("\n").map((line) => {
359
+ const marker = /^\s*(```+|~~~+)/.exec(line)?.[1];
360
+ if (marker !== void 0) {
361
+ if (fence === void 0) fence = marker;
362
+ else if (marker.startsWith(fence)) fence = void 0;
363
+ return line;
364
+ }
365
+ if (fence === void 0 && /^#{1,4} /.test(line)) return `##${line}`;
366
+ return line;
367
+ }).join("\n");
368
+ }
369
+ // src/commands/site.ts
370
+ const execFileAsync = promisify(execFile);
371
+ /** Marks an output directory as written by `site`, so a rebuild may replace it. */
372
+ const MARKER = ".rulebookguard-site";
373
+ async function siteCommand(io, options) {
374
+ const catalog = await openCatalog(io);
375
+ const out = resolve(io.cwd, options.out ?? "site");
376
+ const fromCwd = relative(io.cwd, out);
377
+ const shown = fromCwd === "" ? "." : fromCwd.startsWith("..") || isAbsolute(fromCwd) ? out : fromCwd;
378
+ await assertReplaceable(out, shown);
379
+ const language = catalog.config.language ?? "en";
380
+ const base = normalizeBase(options.base ?? "/");
381
+ const files = sitePages(await siteData(catalog, gitFallbackWarning(io)), {
382
+ language,
383
+ base
384
+ });
385
+ const text = siteText(language);
386
+ const sidebar = [
387
+ {
388
+ label: text.allRules,
389
+ link: "/rules/"
390
+ },
391
+ {
392
+ label: text.health,
393
+ slug: "health"
394
+ },
395
+ ...catalog.contexts.map((context) => context.context).sort().map((context) => ({
396
+ label: context,
397
+ items: [{ autogenerate: { directory: context } }]
398
+ }))
399
+ ];
400
+ files.set("astro.config.mjs", astroConfig(text.title, language, base, sidebar));
401
+ const work = join(siteWorkRoot(), randomUUID());
402
+ try {
403
+ for (const [path, content] of files) {
404
+ await mkdir(dirname(join(work, path)), { recursive: true });
405
+ await writeFile(join(work, path), content);
406
+ }
407
+ await buildWithAstro(work);
408
+ await rm(out, {
409
+ recursive: true,
410
+ force: true
411
+ });
412
+ await cp(join(work, "dist"), out, { recursive: true });
413
+ await writeFile(join(out, MARKER), "");
414
+ } finally {
415
+ await rm(work, {
416
+ recursive: true,
417
+ force: true
418
+ });
419
+ }
420
+ writeLines(io, [shown]);
421
+ }
422
+ /**
423
+ * Starlight injects its routes as package specifiers that Astro resolves from the project root, so the generated
424
+ * project must live inside the node_modules directory that holds rulebookguard's own dependencies.
425
+ */
426
+ function siteWorkRoot() {
427
+ let directory = dirname(fileURLToPath(import.meta.url));
428
+ while (!isOwnPackageRoot(directory)) {
429
+ const parent = dirname(directory);
430
+ if (parent === directory) throw new Error("rulebookguard package root not found");
431
+ directory = parent;
432
+ }
433
+ const parent = dirname(directory);
434
+ const modules = basename(parent) === "node_modules" ? parent : join(directory, "node_modules");
435
+ return join(modules, ".rulebookguard-site");
436
+ }
437
+ function isOwnPackageRoot(directory) {
438
+ const file = join(directory, "package.json");
439
+ return existsSync(file) && JSON.parse(readFileSync(file, "utf8")).name === "rulebookguard";
440
+ }
441
+ async function assertReplaceable(out, shown) {
442
+ let entries;
443
+ try {
444
+ entries = await readdir(out);
445
+ } catch (error) {
446
+ if (error.code === "ENOENT") return;
447
+ throw error;
448
+ }
449
+ if (entries.length > 0 && !entries.includes(MARKER)) throw new CliError(`${shown} is not empty and was not written by "rulebookguard site"; choose another --out or empty it first.`);
450
+ }
451
+ async function buildWithAstro(work) {
452
+ const astro = join(dirname(siteWorkRoot()), "astro");
453
+ const { bin } = JSON.parse(readFileSync(join(astro, "package.json"), "utf8"));
454
+ try {
455
+ await execFileAsync(process.execPath, [
456
+ join(astro, bin.astro),
457
+ "build",
458
+ "--silent"
459
+ ], {
460
+ cwd: work,
461
+ env: {
462
+ ...process.env,
463
+ ASTRO_TELEMETRY_DISABLED: "1"
464
+ },
465
+ maxBuffer: 67108864
466
+ });
467
+ } catch (error) {
468
+ const { stderr, stdout } = error;
469
+ const detail = `${stderr ?? ""}${stdout ?? ""}`.trim().split("\n").slice(-5).join("\n");
470
+ throw new CliError(`The site build failed:\n${detail}`);
471
+ }
472
+ }
473
+ function astroConfig(title, language, base, sidebar) {
474
+ return `import starlight from '@astrojs/starlight';
475
+ import { defineConfig } from 'astro/config';
476
+
477
+ export default defineConfig({
478
+ base: ${JSON.stringify(base)},
479
+ outDir: './dist',
480
+ cacheDir: './.astro-cache',
481
+ integrations: [
482
+ starlight({
483
+ title: ${JSON.stringify(title)},
484
+ locales: { root: { label: ${JSON.stringify(language)}, lang: ${JSON.stringify(language)} } },
485
+ sidebar: ${JSON.stringify(sidebar)},
486
+ }),
487
+ ],
488
+ });
489
+ `;
490
+ }
491
+ export { siteCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulebookguard",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Keep business rules as the single source of truth, bound to code and tests.",
5
5
  "homepage": "https://github.com/JuanCJR/rulebookguard#readme",
6
6
  "bugs": {
@@ -28,17 +28,23 @@
28
28
  "./package.json": "./package.json"
29
29
  },
30
30
  "dependencies": {
31
+ "@astrojs/markdown-remark": "^7.3.2",
32
+ "@astrojs/starlight": "^0.42.6",
33
+ "@modelcontextprotocol/server": "^2.3.1",
31
34
  "ajv": "^8.20.0",
35
+ "astro": "^7.3.8",
32
36
  "picomatch": "^4.0.7",
33
- "yaml": "^2.9.1"
37
+ "yaml": "^2.9.1",
38
+ "zod": "^4.6.7"
34
39
  },
35
40
  "devDependencies": {
41
+ "@modelcontextprotocol/client": "2.3.1",
36
42
  "@types/node": "^20.19.43",
37
43
  "@types/picomatch": "^4.0.3",
38
44
  "typescript": "^7.0.2",
39
45
  "vite-plus": "^1.4.0"
40
46
  },
41
47
  "engines": {
42
- "node": ">=22"
48
+ "node": ">=22.23.4"
43
49
  }
44
50
  }