rulebookguard 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,23 +1,397 @@
1
1
  #!/usr/bin/env node
2
- import { F as messages, I as FormatError, L as formatIssue, M as CONFIG_FILE_NAME, O as CONTEXT_FILE_NAME, _ as expandGlobs, a as CliError, c as openCatalog, f as scanTestTags, l as writeJson, m as loadCatalog, o as findRule, p as findRulebookRoot, r as lintCommand, s as gitFallbackWarning, u as writeLines, y as toRepoPath } from "./lint-DfibQDW3.mjs";
3
- import { t as rulesForPath } from "./anchors-C2j9c5MI.mjs";
4
- import { access, mkdir, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { B as formatIssue, F as CONFIG_FILE_NAME, O as isRuleId, R as messages, a as CliError, c as openCatalog, d as writeLines, f as scanImplementations, g as loadCatalog, h as findRulebookRoot, j as CONTEXT_FILE_NAME, l as shownPath, m as scanTestTags, o as findRule, r as lintCommand, s as gitFallbackWarning, u as writeJson, x as toRepoPath, y as expandGlobs, z as FormatError } from "./lint-CaJnNLmU.mjs";
3
+ import { t as rulesForPath } from "./anchors-DQwbqb7w.mjs";
4
+ import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { parseArgs } from "node:util";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
8
8
  import { McpServer } from "@modelcontextprotocol/server";
9
9
  import * as z from "zod/v4";
10
+ // src/commands/export-agents.ts
11
+ const RULE_FILE_TARGETS = {
12
+ cursor: {
13
+ dir: ".cursor/rules",
14
+ extension: ".mdc",
15
+ frontmatter: (patterns) => [`globs: ${patterns.join(", ")}`, "alwaysApply: false"]
16
+ },
17
+ copilot: {
18
+ dir: ".github/instructions",
19
+ extension: ".instructions.md",
20
+ frontmatter: (patterns) => [`applyTo: ${JSON.stringify(patterns.join(","))}`]
21
+ },
22
+ kiro: {
23
+ dir: ".kiro/steering",
24
+ extension: ".md",
25
+ frontmatter: (patterns) => ["inclusion: fileMatch", `fileMatchPattern: ${JSON.stringify(patterns.length === 1 ? patterns[0] : patterns)}`]
26
+ }
27
+ };
28
+ const AGENTS_MD_FORMAT = "agents-md";
29
+ const AGENTS_MD_FILE = "AGENTS.md";
30
+ const BLOCK_START = "<!-- rulebookguard:start -->";
31
+ const BLOCK_END = "<!-- rulebookguard:end -->";
32
+ const RULE_FILE_PREFIX = "rulebookguard-";
33
+ const AGENT_FORMATS = [...Object.keys(RULE_FILE_TARGETS), AGENTS_MD_FORMAT];
34
+ async function exportAgentRules(catalog, io, format, out) {
35
+ const rules = catalog.rules.filter((rule) => rule.status === "active").sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
36
+ const target = RULE_FILE_TARGETS[format];
37
+ const outDir = (dir) => out === void 0 ? join(catalog.root, dir) : resolve(io.cwd, out);
38
+ writeLines(io, target === void 0 ? await writeAgentsMd(catalog, io, rules, outDir(".")) : await writeRuleFiles(catalog, io, rules, format, target, outDir(target.dir)));
39
+ }
40
+ function anchoredRules(rules, io, text) {
41
+ const unanchored = rules.filter((rule) => rule.anchors.length === 0);
42
+ if (unanchored.length > 0) io.stderr(unanchored.map((rule) => `${text.unanchoredRuleSkipped(rule.id)}\n`).join(""));
43
+ return rules.filter((rule) => rule.anchors.length > 0);
44
+ }
45
+ async function writeRuleFiles(catalog, io, active, format, target, dir) {
46
+ const text = messages(catalog.config.language);
47
+ const rules = anchoredRules(active, io, text);
48
+ const nothing = active.length === 0 ? text.noActiveRules : text.noAnchoredRules;
49
+ const lines = rules.length === 0 ? [nothing] : [];
50
+ const fileName = (rule) => `${RULE_FILE_PREFIX}${rule.id}${target.extension}`;
51
+ const handWritten = await handWrittenFiles(dir, format, rules.map(fileName));
52
+ if (handWritten.length > 0) throw new CliError(handWritten.map((name) => text.handWrittenExport(toRepoPath(shownPath(io.cwd, join(dir, name))), format)).join("\n"));
53
+ const current = /* @__PURE__ */ new Set();
54
+ for (const rule of rules) {
55
+ const name = fileName(rule);
56
+ current.add(name);
57
+ await mkdir(dir, { recursive: true });
58
+ await writeFile(join(dir, name), ruleFile(rule, format, target, text));
59
+ lines.push(toRepoPath(shownPath(io.cwd, join(dir, name))));
60
+ }
61
+ for (const name of await staleRuleFiles(dir, format, target, current)) {
62
+ await rm(join(dir, name));
63
+ lines.push(text.removedExport(toRepoPath(shownPath(io.cwd, join(dir, name)))));
64
+ }
65
+ return lines;
66
+ }
67
+ function ruleFile(rule, format, target, text) {
68
+ return [
69
+ "---",
70
+ ...target.frontmatter(patternsOf(rule)),
71
+ "---",
72
+ "",
73
+ `<!-- ${text.generatedRule(format, rule.file)} -->`,
74
+ "",
75
+ `**${rule.id}:** ${rule.summary}`,
76
+ "",
77
+ `${normalizedBody(rule)}\n`
78
+ ].join("\n");
79
+ }
80
+ async function handWrittenFiles(dir, format, names) {
81
+ const existing = new Set(await listNames(dir));
82
+ const handWritten = [];
83
+ for (const name of names) if (existing.has(name) && !hasGeneratedLine(await readFile(join(dir, name), "utf8"), format)) handWritten.push(name);
84
+ return handWritten;
85
+ }
86
+ async function staleRuleFiles(dir, format, target, current) {
87
+ const stale = [];
88
+ for (const name of await listNames(dir)) if (!current.has(name) && name.startsWith(RULE_FILE_PREFIX) && name.endsWith(target.extension) && isRuleId(name.slice(14, -target.extension.length)) && hasGeneratedLine(await readFile(join(dir, name), "utf8"), format)) stale.push(name);
89
+ return stale.sort();
90
+ }
91
+ function hasGeneratedLine(content, format) {
92
+ const lines = content.split(/\r?\n/);
93
+ const closing = lines.indexOf("---", 1);
94
+ const line = lines[closing + 2] ?? "";
95
+ return lines[0] === "---" && closing !== -1 && lines[closing + 1] === "" && line.startsWith("<!-- ") && line.includes(` "rulebookguard export ${format}" `) && line.endsWith(" -->");
96
+ }
97
+ async function listNames(dir) {
98
+ try {
99
+ return await readdir(dir);
100
+ } catch (error) {
101
+ if (error.code === "ENOENT") return [];
102
+ throw error;
103
+ }
104
+ }
105
+ async function writeAgentsMd(catalog, io, rules, dir) {
106
+ const text = messages(catalog.config.language);
107
+ const file = join(dir, AGENTS_MD_FILE);
108
+ const shown = toRepoPath(shownPath(io.cwd, file));
109
+ const existing = await readOptional(file);
110
+ const start = existing.indexOf(BLOCK_START);
111
+ const end = existing.indexOf(BLOCK_END);
112
+ const starts = existing.split(BLOCK_START).length - 1;
113
+ if (starts !== existing.split(BLOCK_END).length - 1 || starts > 1 || end < start) throw new CliError(text.invalidAgentsMdMarkers(shown, BLOCK_START, BLOCK_END));
114
+ const hasBlock = starts === 1;
115
+ if (rules.length === 0 && !hasBlock) return [text.noActiveRules];
116
+ const before = hasBlock ? existing.slice(0, start) : existing;
117
+ const after = hasBlock ? existing.slice(end + 26) : "";
118
+ await mkdir(dir, { recursive: true });
119
+ await writeFile(file, rules.length === 0 ? withoutBlock(before, after) : withBlock(before, after, agentsBlock(rules, text), hasBlock));
120
+ return rules.length === 0 ? [text.noActiveRules, shown] : [shown];
121
+ }
122
+ function withBlock(before, after, block, hasBlock) {
123
+ if (hasBlock) return `${before}${block}${after}`;
124
+ return before.trim() === "" ? `${block}\n` : `${before.trimEnd()}\n\n${block}\n`;
125
+ }
126
+ function withoutBlock(before, after) {
127
+ const joined = `${before}${after.replace(/^(?:\r?\n)+/, "")}`;
128
+ return joined.trim() === "" ? "" : `${joined.trimEnd()}\n`;
129
+ }
130
+ function agentsBlock(rules, text) {
131
+ return [
132
+ BLOCK_START,
133
+ `<!-- ${text.generatedAgentsMd} -->`,
134
+ `## ${text.agentsMdHeading}`,
135
+ "",
136
+ text.agentsMdIntro,
137
+ "",
138
+ ...rules.map((rule) => {
139
+ const anchors = rule.anchors.map((anchor) => `\`${toRepoPath(anchor)}\``).join(", ") || text.agentsMdNoAnchors;
140
+ return `- \`${rule.id}\` (${anchors}): ${rule.summary} ${text.agentsMdSeeFile(rule.file)}`;
141
+ }),
142
+ BLOCK_END
143
+ ].join("\n");
144
+ }
145
+ async function readOptional(file) {
146
+ try {
147
+ return await readFile(file, "utf8");
148
+ } catch (error) {
149
+ if (error.code === "ENOENT") return "";
150
+ throw error;
151
+ }
152
+ }
153
+ function patternsOf(rule) {
154
+ return [...new Set(rule.anchors.flatMap((anchor) => expandBraces(toRepoPath(anchor))))];
155
+ }
156
+ function expandBraces(glob) {
157
+ const match = /\{([^{}]*,[^{}]*)\}/.exec(glob);
158
+ if (match === null) return [glob];
159
+ const head = glob.slice(0, match.index);
160
+ const tail = glob.slice(match.index + match[0].length);
161
+ return (match[1] ?? "").split(",").flatMap((option) => expandBraces(`${head}${option}${tail}`));
162
+ }
163
+ function normalizedBody(rule) {
164
+ return rule.body.replaceAll("\r\n", "\n").trim();
165
+ }
166
+ // src/commands/export.ts
167
+ const EXPORT_USAGE = "rulebookguard export <format> [--out <dir>]";
168
+ const GHERKIN_BLOCK = /^```gherkin[^\n]*\n([\s\S]*?)^```/gm;
169
+ const LANGUAGE_LINE = /^\s*#\s*language:.*$/;
170
+ async function exportCommand(catalog, io, format, out) {
171
+ if (AGENT_FORMATS.includes(format)) {
172
+ await exportAgentRules(catalog, io, format, out);
173
+ return;
174
+ }
175
+ if (format !== "gherkin") {
176
+ const supported = ["gherkin", ...AGENT_FORMATS].join(", ");
177
+ throw new CliError(`Unknown export format "${format}"; supported: ${supported}.\nUsage: ${EXPORT_USAGE}`, 2);
178
+ }
179
+ const language = catalog.config.language ?? "en";
180
+ const text = messages(language);
181
+ const outDir = resolve(io.cwd, out ?? "features/rules");
182
+ const written = [];
183
+ const rules = catalog.rules.filter((rule) => rule.status !== "deprecated").sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
184
+ for (const rule of rules) {
185
+ const scenarios = gherkinBlocks(rule.body);
186
+ if (scenarios.length === 0) continue;
187
+ const file = join(outDir, rule.context, `${rule.id}.feature`);
188
+ await mkdir(dirname(file), { recursive: true });
189
+ await writeFile(file, featureFile(rule, scenarios, language === "es", text));
190
+ written.push(shownPath(io.cwd, file));
191
+ }
192
+ writeLines(io, written.length === 0 ? [text.noGherkinScenarios] : written);
193
+ }
194
+ function gherkinBlocks(body) {
195
+ return [...body.matchAll(GHERKIN_BLOCK)].map((match) => (match[1] ?? "").split("\n").filter((line) => !LANGUAGE_LINE.test(line)).join("\n").trim()).filter((block) => block !== "");
196
+ }
197
+ function featureFile(rule, scenarios, spanish, text) {
198
+ const indented = scenarios.map((block) => block.split("\n").map((line) => line.trim() === "" ? "" : ` ${line}`).join("\n"));
199
+ return [
200
+ ...spanish ? ["# language: es"] : [],
201
+ `# ${text.generatedFeature(rule.file)}`,
202
+ `@${rule.id}`,
203
+ `${text.featureKeyword}: ${rule.id} ${rule.summary}`,
204
+ "",
205
+ `${indented.join("\n\n")}\n`
206
+ ].join("\n");
207
+ }
208
+ // src/commands/read.ts
209
+ const RULE_STATUSES = [
210
+ "proposed",
211
+ "active",
212
+ "deprecated"
213
+ ];
214
+ function contextsCommand(catalog, io, json) {
215
+ const contexts = [...catalog.contexts].sort((a, b) => compare(a.context, b.context)).map(({ context, prefix, summary, file }) => ({
216
+ context,
217
+ prefix,
218
+ summary,
219
+ rules: catalog.rules.filter((rule) => rule.context === context).length,
220
+ file
221
+ }));
222
+ if (json) {
223
+ writeJson(io, { contexts });
224
+ return;
225
+ }
226
+ if (contexts.length === 0) {
227
+ writeLines(io, [messages(catalog.config.language).noContexts]);
228
+ return;
229
+ }
230
+ writeLines(io, contexts.map((item) => `${item.context} (${item.prefix}, ${messages(catalog.config.language).ruleCount(item.rules)}): ${item.summary}`));
231
+ }
232
+ function listCommand(catalog, io, options) {
233
+ const { context, status } = options;
234
+ const text = messages(catalog.config.language);
235
+ if (status !== void 0 && !RULE_STATUSES.includes(status)) throw new CliError(text.invalidStatus(status, RULE_STATUSES.join(", ")), 2);
236
+ if (context !== void 0 && !catalog.contexts.some((item) => item.context === context)) throw new CliError(text.unknownContext(context));
237
+ const rules = sortById(catalog.rules).filter((rule) => context === void 0 || rule.context === context).filter((rule) => status === void 0 || rule.status === status).map(toSummary);
238
+ if (options.json) {
239
+ writeJson(io, { rules });
240
+ return;
241
+ }
242
+ if (rules.length === 0) {
243
+ writeLines(io, [text.noRules]);
244
+ return;
245
+ }
246
+ writeLines(io, rules.map((rule) => `${rule.id} [${rule.status}] ${rule.summary}`));
247
+ }
248
+ function searchCommand(catalog, io, query, options) {
249
+ const { context } = options;
250
+ const text = messages(catalog.config.language);
251
+ if (context !== void 0 && !catalog.contexts.some((item) => item.context === context)) throw new CliError(text.unknownContext(context));
252
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
253
+ const rules = sortById(catalog.rules).filter((rule) => context === void 0 || rule.context === context).filter((rule) => {
254
+ const haystack = [
255
+ rule.id,
256
+ rule.summary,
257
+ ...rule.entities,
258
+ rule.body
259
+ ].join("\n").toLowerCase();
260
+ return terms.every((term) => haystack.includes(term));
261
+ }).map(toSummary);
262
+ if (options.json) {
263
+ writeJson(io, { rules });
264
+ return;
265
+ }
266
+ writeLines(io, rules.length === 0 ? [text.noRules] : rules.map((rule) => `${rule.id} [${rule.status}] ${rule.summary}`));
267
+ }
268
+ async function showCommand(catalog, io, id, json) {
269
+ const rule = findRule(catalog, id);
270
+ const files = await expandGlobs(catalog.root, rule.anchors, gitFallbackWarning(io, catalog.config.language));
271
+ const implementations = (await scanImplementations(catalog.root, files)).regions.get(rule.id) ?? [];
272
+ if (json) {
273
+ writeJson(io, { rule: {
274
+ ...toSummary(rule),
275
+ entities: rule.entities,
276
+ anchors: rule.anchors,
277
+ supersedes: rule.supersedes ?? null,
278
+ dependsOn: rule.dependsOn ?? [],
279
+ conflictsWith: rule.conflictsWith ?? [],
280
+ origin: rule.origin ?? null,
281
+ evidence: rule.evidence ?? [],
282
+ implementations,
283
+ file: rule.file,
284
+ body: rule.body
285
+ } });
286
+ return;
287
+ }
288
+ const none = messages(catalog.config.language).none;
289
+ const header = [
290
+ `${rule.id}: ${rule.summary}`,
291
+ `context: ${rule.context}`,
292
+ `status: ${rule.status}`,
293
+ `entities: ${rule.entities.length === 0 ? none : rule.entities.join(", ")}`,
294
+ "anchors:",
295
+ ...indent(rule.anchors, none),
296
+ ...rule.supersedes === void 0 ? [] : [`supersedes: ${rule.supersedes}`],
297
+ ...rule.dependsOn?.length ? [`dependsOn: ${rule.dependsOn.join(", ")}`] : [],
298
+ ...rule.conflictsWith?.length ? [`conflictsWith: ${rule.conflictsWith.join(", ")}`] : [],
299
+ ...rule.origin === void 0 ? [] : [`origin: ${rule.origin}`],
300
+ ...rule.evidence?.length ? ["evidence:", ...indent(rule.evidence, none)] : [],
301
+ ...implementations.length ? ["implementations:", ...indent(implementations.map((region) => `${region.file}:${region.line}-${region.endLine}`), none)] : [],
302
+ `file: ${rule.file}`
303
+ ];
304
+ io.stdout(`${header.join("\n")}\n\n${rule.body}`);
305
+ }
306
+ async function forCommand(catalog, io, input, json) {
307
+ const text = messages(catalog.config.language);
308
+ const path = toRootPath(catalog, resolve(io.cwd, input), input);
309
+ const regions = await regionsIn(catalog.root, path);
310
+ const rules = rulesForPath(catalog.rules, path).filter((rule) => rule.status === "active").map((rule) => ({
311
+ ...toSummary(rule),
312
+ file: rule.file,
313
+ regions: regions(rule.id)
314
+ }));
315
+ if (json) {
316
+ writeJson(io, {
317
+ path,
318
+ rules
319
+ });
320
+ return;
321
+ }
322
+ if (rules.length === 0) {
323
+ writeLines(io, [text.noActiveRulesFor(path)]);
324
+ return;
325
+ }
326
+ writeLines(io, rules.map((rule) => `${rule.id} (${rule.context}): ${rule.summary}${rule.regions.length === 0 ? "" : ` (${text.lines(formatSpans(rule.regions))})`}`));
327
+ }
328
+ async function regionsIn(root, path) {
329
+ const found = (await scanImplementations(root, [path])).regions;
330
+ return (id) => (found.get(id) ?? []).map(({ line, endLine }) => ({
331
+ line,
332
+ endLine
333
+ }));
334
+ }
335
+ function formatSpans(spans) {
336
+ return spans.map(({ line, endLine }) => `${line}-${endLine}`).join(", ");
337
+ }
338
+ async function impactCommand(catalog, io, id, json) {
339
+ const rule = findRule(catalog, id);
340
+ const text = messages(catalog.config.language);
341
+ const warn = gitFallbackWarning(io, catalog.config.language);
342
+ const files = await expandGlobs(catalog.root, rule.anchors, warn);
343
+ const tests = (await scanTestTags(catalog.root, catalog.config.testGlobs, warn)).get(id) ?? [];
344
+ if (json) {
345
+ writeJson(io, {
346
+ id,
347
+ files,
348
+ tests
349
+ });
350
+ return;
351
+ }
352
+ writeLines(io, [
353
+ `${rule.id}: ${rule.summary}`,
354
+ text.filesMatched(files.length),
355
+ ...indent(files, text.none),
356
+ text.testsTagging(rule.id, tests.length),
357
+ ...indent(tests.map((test) => `${test.file}:${test.line}`), text.none)
358
+ ]);
359
+ }
360
+ function toRootPath(catalog, absolute, input) {
361
+ const path = toRepoPath(relative(catalog.root, absolute));
362
+ if (path === "" || path === ".." || path.startsWith("../") || isAbsolute(path)) throw new CliError(messages(catalog.config.language).outsideRoot(input, catalog.root));
363
+ return path;
364
+ }
365
+ function toSummary(rule) {
366
+ return {
367
+ id: rule.id,
368
+ context: rule.context,
369
+ status: rule.status,
370
+ summary: rule.summary
371
+ };
372
+ }
373
+ function indent(lines, none) {
374
+ return lines.length === 0 ? [` ${none}`] : lines.map((line) => ` ${line}`);
375
+ }
376
+ function sortById(rules) {
377
+ return [...rules].sort((a, b) => compare(a.id, b.id));
378
+ }
379
+ function compare(a, b) {
380
+ return a < b ? -1 : a > b ? 1 : 0;
381
+ }
10
382
  // src/commands/hook.ts
11
383
  const HOOK_BODY_LIMIT = 1e4;
12
384
  const TEXT = {
13
385
  en: {
14
386
  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
387
  ruleFile: "Rule file",
388
+ lines: "Lines",
16
389
  omitted: `Rule texts omitted (over ${HOOK_BODY_LIMIT} characters); run \`rulebookguard show <id>\` for the full text.`
17
390
  },
18
391
  es: {
19
392
  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
393
  ruleFile: "Archivo de la regla",
394
+ lines: "Líneas",
21
395
  omitted: `Se omitió el texto de las reglas (más de ${HOOK_BODY_LIMIT} caracteres); ejecuta \`rulebookguard show <id>\` para verlo completo.`
22
396
  }
23
397
  };
@@ -42,7 +416,7 @@ async function hookContext(event, cwd) {
42
416
  const repoPath = path.split(sep).join("/");
43
417
  const rules = rulesForPath(catalog.rules, repoPath).filter((rule) => rule.status === "active");
44
418
  if (rules.length === 0) return;
45
- return render(repoPath, rules, TEXT[catalog.config.language ?? "en"]);
419
+ return render(repoPath, rules, await regionsIn(root, repoPath).catch(() => () => []), TEXT[catalog.config.language ?? "en"]);
46
420
  }
47
421
  function parseEvent(input) {
48
422
  let event;
@@ -65,10 +439,15 @@ function parseEvent(input) {
65
439
  filePath
66
440
  };
67
441
  }
68
- function render(path, rules, text) {
442
+ function render(path, rules, regions, text) {
69
443
  const withBodies = rules.reduce((total, rule) => total + rule.body.trim().length, 0) <= HOOK_BODY_LIMIT;
70
444
  const sections = rules.map((rule) => {
71
- const lines = [`## ${rule.id} (${rule.context}): ${rule.summary}`, `${text.ruleFile}: ${rule.file}`];
445
+ const spans = regions(rule.id);
446
+ const lines = [
447
+ `## ${rule.id} (${rule.context}): ${rule.summary}`,
448
+ `${text.ruleFile}: ${rule.file}`,
449
+ ...spans.length === 0 ? [] : [`${text.lines}: ${formatSpans(spans)}`]
450
+ ];
72
451
  return withBodies ? [
73
452
  ...lines,
74
453
  "",
@@ -80,7 +459,7 @@ function render(path, rules, text) {
80
459
  return parts.join("\n\n");
81
460
  }
82
461
  // package.json
83
- var version = "0.8.0";
462
+ var version = "0.9.0";
84
463
  // src/commands/new.ts
85
464
  const MAX_NUMBER = 999;
86
465
  const CONTEXT_NAME = /^[a-z0-9][a-z0-9_-]*$/;
@@ -271,9 +650,53 @@ function createRulesServer(projectDir) {
271
650
  id,
272
651
  "--json"
273
652
  ]));
653
+ server.registerTool("contexts", {
654
+ description: "L0 map of the rulebook: each context with its prefix, summary and number of rules. Start here to find the contexts a change touches.",
655
+ inputSchema: z.object({}),
656
+ annotations: { readOnlyHint: true }
657
+ }, () => cli(projectDir, ["contexts", "--json"]));
658
+ server.registerTool("rules_list", {
659
+ description: "L1 list of rules: id, context, status and summary, optionally narrowed to one context and one status. Read the full text of the relevant ones with rules_show.",
660
+ inputSchema: z.object({
661
+ context: z.string().optional().describe("Context name, for example clients"),
662
+ status: z.enum([
663
+ "proposed",
664
+ "active",
665
+ "deprecated"
666
+ ]).optional()
667
+ }),
668
+ annotations: { readOnlyHint: true }
669
+ }, ({ context, status }) => cli(projectDir, [
670
+ "list",
671
+ ...context === void 0 ? [] : ["--context", context],
672
+ ...status === void 0 ? [] : ["--status", status],
673
+ "--json"
674
+ ]));
675
+ server.registerTool("rules_search", {
676
+ description: "Rules in any status whose id, summary, entities or text hold every word of the text, ignoring case (plain text search, no embeddings). Use it to find existing rules before proposing a new one.",
677
+ inputSchema: z.object({
678
+ text: z.string().describe("Words to look for, for example \"invoice number\""),
679
+ context: z.string().optional().describe("Context name to search in")
680
+ }),
681
+ annotations: { readOnlyHint: true }
682
+ }, ({ text, context }) => cli(projectDir, [
683
+ "search",
684
+ text,
685
+ ...context === void 0 ? [] : ["--context", context],
686
+ "--json"
687
+ ]));
688
+ server.registerTool("lint", {
689
+ description: "Checks the whole rulebook (schema, anchors, test tags, relations between rules) and answers the findings with a hint each; with base, rules changed since that git ref also need a changed test. Findings are data: the tool fails only when lint cannot run.",
690
+ inputSchema: z.object({ base: z.string().optional().describe("Git ref to compare with, for example origin/main") }),
691
+ annotations: { readOnlyHint: true }
692
+ }, ({ base }) => cli(projectDir, [
693
+ "lint",
694
+ ...base === void 0 ? [] : ["--base", base],
695
+ "--json"
696
+ ], { findingsAreData: true }));
274
697
  return server;
275
698
  }
276
- async function cli(cwd, args) {
699
+ async function cli(cwd, args, { findingsAreData = false } = {}) {
277
700
  let stdout = "";
278
701
  let stderr = "";
279
702
  if (await runCli(args, {
@@ -284,7 +707,7 @@ async function cli(cwd, args) {
284
707
  stderr: (text) => {
285
708
  stderr += text;
286
709
  }
287
- }) === 0) return { content: [{
710
+ }) === 0 || findingsAreData && stdout !== "") return { content: [{
288
711
  type: "text",
289
712
  text: stdout
290
713
  }] };
@@ -300,136 +723,6 @@ async function cli(cwd, args) {
300
723
  async function mcpCommand(io, project) {
301
724
  await createRulesServer(resolve(io.cwd, project ?? ".")).connect(new StdioServerTransport());
302
725
  }
303
- // src/commands/read.ts
304
- const RULE_STATUSES = [
305
- "proposed",
306
- "active",
307
- "deprecated"
308
- ];
309
- function contextsCommand(catalog, io, json) {
310
- const contexts = [...catalog.contexts].sort((a, b) => compare(a.context, b.context)).map(({ context, prefix, summary, file }) => ({
311
- context,
312
- prefix,
313
- summary,
314
- rules: catalog.rules.filter((rule) => rule.context === context).length,
315
- file
316
- }));
317
- if (json) {
318
- writeJson(io, { contexts });
319
- return;
320
- }
321
- if (contexts.length === 0) {
322
- writeLines(io, [messages(catalog.config.language).noContexts]);
323
- return;
324
- }
325
- writeLines(io, contexts.map((item) => `${item.context} (${item.prefix}, ${messages(catalog.config.language).ruleCount(item.rules)}): ${item.summary}`));
326
- }
327
- function listCommand(catalog, io, options) {
328
- const { context, status } = options;
329
- const text = messages(catalog.config.language);
330
- if (status !== void 0 && !RULE_STATUSES.includes(status)) throw new CliError(text.invalidStatus(status, RULE_STATUSES.join(", ")), 2);
331
- if (context !== void 0 && !catalog.contexts.some((item) => item.context === context)) throw new CliError(text.unknownContext(context));
332
- const rules = sortById(catalog.rules).filter((rule) => context === void 0 || rule.context === context).filter((rule) => status === void 0 || rule.status === status).map(toSummary);
333
- if (options.json) {
334
- writeJson(io, { rules });
335
- return;
336
- }
337
- if (rules.length === 0) {
338
- writeLines(io, [text.noRules]);
339
- return;
340
- }
341
- writeLines(io, rules.map((rule) => `${rule.id} [${rule.status}] ${rule.summary}`));
342
- }
343
- function showCommand(catalog, io, id, json) {
344
- const rule = findRule(catalog, id);
345
- if (json) {
346
- writeJson(io, { rule: {
347
- ...toSummary(rule),
348
- entities: rule.entities,
349
- anchors: rule.anchors,
350
- supersedes: rule.supersedes ?? null,
351
- file: rule.file,
352
- body: rule.body
353
- } });
354
- return;
355
- }
356
- const none = messages(catalog.config.language).none;
357
- const header = [
358
- `${rule.id}: ${rule.summary}`,
359
- `context: ${rule.context}`,
360
- `status: ${rule.status}`,
361
- `entities: ${rule.entities.length === 0 ? none : rule.entities.join(", ")}`,
362
- "anchors:",
363
- ...indent(rule.anchors, none),
364
- ...rule.supersedes === void 0 ? [] : [`supersedes: ${rule.supersedes}`],
365
- `file: ${rule.file}`
366
- ];
367
- io.stdout(`${header.join("\n")}\n\n${rule.body}`);
368
- }
369
- function forCommand(catalog, io, input, json) {
370
- const text = messages(catalog.config.language);
371
- const path = toRootPath(catalog, resolve(io.cwd, input), input);
372
- const rules = rulesForPath(catalog.rules, path).filter((rule) => rule.status === "active").map((rule) => ({
373
- ...toSummary(rule),
374
- file: rule.file
375
- }));
376
- if (json) {
377
- writeJson(io, {
378
- path,
379
- rules
380
- });
381
- return;
382
- }
383
- if (rules.length === 0) {
384
- writeLines(io, [text.noActiveRulesFor(path)]);
385
- return;
386
- }
387
- writeLines(io, rules.map((rule) => `${rule.id} (${rule.context}): ${rule.summary}`));
388
- }
389
- async function impactCommand(catalog, io, id, json) {
390
- const rule = findRule(catalog, id);
391
- const text = messages(catalog.config.language);
392
- const warn = gitFallbackWarning(io, catalog.config.language);
393
- const files = await expandGlobs(catalog.root, rule.anchors, warn);
394
- const tests = (await scanTestTags(catalog.root, catalog.config.testGlobs, warn)).get(id) ?? [];
395
- if (json) {
396
- writeJson(io, {
397
- id,
398
- files,
399
- tests
400
- });
401
- return;
402
- }
403
- writeLines(io, [
404
- `${rule.id}: ${rule.summary}`,
405
- text.filesMatched(files.length),
406
- ...indent(files, text.none),
407
- text.testsTagging(rule.id, tests.length),
408
- ...indent(tests.map((test) => `${test.file}:${test.line}`), text.none)
409
- ]);
410
- }
411
- function toRootPath(catalog, absolute, input) {
412
- const path = toRepoPath(relative(catalog.root, absolute));
413
- if (path === "" || path === ".." || path.startsWith("../") || isAbsolute(path)) throw new CliError(messages(catalog.config.language).outsideRoot(input, catalog.root));
414
- return path;
415
- }
416
- function toSummary(rule) {
417
- return {
418
- id: rule.id,
419
- context: rule.context,
420
- status: rule.status,
421
- summary: rule.summary
422
- };
423
- }
424
- function indent(lines, none) {
425
- return lines.length === 0 ? [` ${none}`] : lines.map((line) => ` ${line}`);
426
- }
427
- function sortById(rules) {
428
- return [...rules].sort((a, b) => compare(a.id, b.id));
429
- }
430
- function compare(a, b) {
431
- return a < b ? -1 : a > b ? 1 : 0;
432
- }
433
726
  // src/commands/run.ts
434
727
  const USAGE = `Usage: rulebookguard <command> [options]
435
728
 
@@ -442,6 +735,8 @@ Commands:
442
735
  contexts [--json] L0: list the contexts
443
736
  list [--context <c>] [--status <s>] [--json] L1: id and summary of each rule
444
737
  show <id> [--json] L2: the full rule
738
+ search <text> [--context <c>] [--json] rules whose id, summary, entities or text hold every
739
+ word of <text>, ignoring case
445
740
  for <path> [--json] active rules anchored to a path
446
741
  impact <id> [--json] files matched by a rule's anchors and tests tagging it
447
742
  new <context> [--prefix <P>] scaffold the next free rule id as a proposed rule;
@@ -449,10 +744,20 @@ Commands:
449
744
  --prefix: its id prefix (default: first 3 letters)
450
745
  lint [--base <ref>] [--json] check the catalog, anchors and test tags;
451
746
  --base: rules changed since <ref> need a changed test
747
+ export gherkin [--out <dir>] write the Gherkin blocks of each non-deprecated rule as
748
+ <out>/<context>/<id>.feature tagged @<id>;
749
+ --out: output directory (default: features/rules)
750
+ export cursor|copilot|kiro [--out <dir>] write each active rule as a rule file of the tool, scoped
751
+ to its anchors, in .cursor/rules, .github/instructions
752
+ or .kiro/steering, and remove those of rules no longer
753
+ active; --out: output directory
754
+ export agents-md [--out <dir>] keep an index of the active rules in a marked block of
755
+ AGENTS.md; --out: its directory (default: rulebook root)
452
756
  hook Claude Code hook for PreToolUse and PostToolUse: reads
453
757
  the event on stdin and prints the rules anchored to
454
758
  the file of the tool (Read, Edit, Write...)
455
- mcp [--project <dir>] MCP server over stdio with rules_for and rules_show;
759
+ mcp [--project <dir>] MCP server over stdio with rules_for, rules_show, contexts,
760
+ rules_list, rules_search and lint;
456
761
  --project: directory to find the rulebook from (default: cwd)
457
762
  site:generate [--out <dir>] [--base <path>] build the read-only static site of the rulebook (alias: site);
458
763
  --out: output directory (default: site), --base: URL path
@@ -495,6 +800,14 @@ const COMMANDS = {
495
800
  argument: "id",
496
801
  options: JSON_OPTION
497
802
  },
803
+ search: {
804
+ usage: "rulebookguard search <text> [--context <c>] [--json]",
805
+ argument: "text",
806
+ options: {
807
+ ...JSON_OPTION,
808
+ context: { type: "string" }
809
+ }
810
+ },
498
811
  for: {
499
812
  usage: "rulebookguard for <path> [--json]",
500
813
  argument: "path",
@@ -510,6 +823,11 @@ const COMMANDS = {
510
823
  argument: "context",
511
824
  options: { prefix: { type: "string" } }
512
825
  },
826
+ export: {
827
+ usage: EXPORT_USAGE,
828
+ argument: "format",
829
+ options: { out: { type: "string" } }
830
+ },
513
831
  hook: {
514
832
  usage: "rulebookguard hook",
515
833
  options: {}
@@ -575,7 +893,7 @@ async function dispatch(argv, io) {
575
893
  return 0;
576
894
  }
577
895
  if (name === "site" || name === "site:generate" || name === "site:run") {
578
- const { siteCommand } = await import("./site-DmY5YVte.mjs");
896
+ const { siteCommand } = await import("./site-BTCwdt_H.mjs");
579
897
  await siteCommand(io, {
580
898
  out: values.out,
581
899
  base: values.base,
@@ -596,6 +914,7 @@ async function dispatch(argv, io) {
596
914
  base: values.base,
597
915
  json
598
916
  });
917
+ if (name === "search" && argument.trim() === "") throw new CliError(`Search text is empty. Usage: ${spec.usage}`, 2);
599
918
  const catalog = await openCatalog(io);
600
919
  switch (name) {
601
920
  case "contexts":
@@ -609,15 +928,24 @@ async function dispatch(argv, io) {
609
928
  });
610
929
  break;
611
930
  case "show":
612
- showCommand(catalog, io, argument, json);
931
+ await showCommand(catalog, io, argument, json);
932
+ break;
933
+ case "search":
934
+ searchCommand(catalog, io, argument, {
935
+ context: values.context,
936
+ json
937
+ });
613
938
  break;
614
939
  case "for":
615
- forCommand(catalog, io, argument, json);
940
+ await forCommand(catalog, io, argument, json);
616
941
  break;
617
942
  case "impact":
618
943
  await impactCommand(catalog, io, argument, json);
619
944
  break;
620
- case "new": await newCommand(catalog, io, argument, values.prefix);
945
+ case "new":
946
+ await newCommand(catalog, io, argument, values.prefix);
947
+ break;
948
+ case "export": await exportCommand(catalog, io, argument, values.out);
621
949
  }
622
950
  return 0;
623
951
  }