rulebookguard 0.2.0 → 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 +21 -1
- package/dist/anchors-BFJCm1NU.mjs +10 -0
- package/dist/cli.mjs +20 -2
- package/dist/index.mjs +3 -1
- package/dist/{lint-DtD_Ekei.mjs → lint-C1yUeLOq.mjs} +1 -9
- package/dist/site-DYOm-dEC.mjs +491 -0
- package/package.json +5 -2
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
|
|
@@ -260,6 +260,26 @@ An [MCP](https://modelcontextprotocol.io) server over stdio for AI agents, with
|
|
|
260
260
|
|
|
261
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
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
|
+
|
|
263
283
|
### `lint [--base <ref>] [--json]`
|
|
264
284
|
|
|
265
285
|
Checks the whole catalog. Each finding is printed as `<severity> <code> <file>[:line] <message>`, followed by a count.
|
|
@@ -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,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { M as FormatError, N as formatIssue,
|
|
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
5
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
6
|
import { parseArgs } from "node:util";
|
|
@@ -79,7 +80,7 @@ function render(path, rules, text) {
|
|
|
79
80
|
return parts.join("\n\n");
|
|
80
81
|
}
|
|
81
82
|
// package.json
|
|
82
|
-
var version = "0.
|
|
83
|
+
var version = "0.3.0";
|
|
83
84
|
// src/mcp/server.ts
|
|
84
85
|
function createRulesServer(projectDir) {
|
|
85
86
|
const server = new McpServer({
|
|
@@ -328,6 +329,8 @@ Commands:
|
|
|
328
329
|
and prints the rules anchored to the edited file
|
|
329
330
|
mcp [--project <dir>] MCP server over stdio with rules_for and rules_show;
|
|
330
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
|
|
331
334
|
|
|
332
335
|
Exit codes: 0 success, 1 failure (lint: any error), 2 usage error.
|
|
333
336
|
`;
|
|
@@ -373,6 +376,13 @@ const COMMANDS = {
|
|
|
373
376
|
usage: "rulebookguard mcp [--project <dir>]",
|
|
374
377
|
options: { project: { type: "string" } }
|
|
375
378
|
},
|
|
379
|
+
site: {
|
|
380
|
+
usage: "rulebookguard site [--out <dir>] [--base <path>]",
|
|
381
|
+
options: {
|
|
382
|
+
out: { type: "string" },
|
|
383
|
+
base: { type: "string" }
|
|
384
|
+
}
|
|
385
|
+
},
|
|
376
386
|
lint: {
|
|
377
387
|
usage: "rulebookguard lint [--base <ref>] [--json]",
|
|
378
388
|
options: {
|
|
@@ -414,6 +424,14 @@ async function dispatch(argv, io) {
|
|
|
414
424
|
await mcpCommand(io, values.project);
|
|
415
425
|
return 0;
|
|
416
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
|
+
}
|
|
417
435
|
if (name === "lint") return lintCommand(io, {
|
|
418
436
|
base: values.base,
|
|
419
437
|
json
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1,3 @@
|
|
|
1
|
-
|
|
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,
|
|
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("<", "<");
|
|
350
|
+
}
|
|
351
|
+
/** Text inside the Astro page, where braces start expressions. */
|
|
352
|
+
function html(text) {
|
|
353
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("{", "{").replaceAll("}", "}");
|
|
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.
|
|
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,8 +28,11 @@
|
|
|
28
28
|
"./package.json": "./package.json"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
+
"@astrojs/markdown-remark": "^7.3.2",
|
|
32
|
+
"@astrojs/starlight": "^0.42.6",
|
|
31
33
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
32
34
|
"ajv": "^8.20.0",
|
|
35
|
+
"astro": "^7.3.8",
|
|
33
36
|
"picomatch": "^4.0.7",
|
|
34
37
|
"yaml": "^2.9.1",
|
|
35
38
|
"zod": "^4.6.7"
|
|
@@ -42,6 +45,6 @@
|
|
|
42
45
|
"vite-plus": "^1.4.0"
|
|
43
46
|
},
|
|
44
47
|
"engines": {
|
|
45
|
-
"node": ">=22"
|
|
48
|
+
"node": ">=22.23.4"
|
|
46
49
|
}
|
|
47
50
|
}
|