rulebookguard 0.7.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/README.md +180 -60
- package/dist/{anchors-C2j9c5MI.mjs → anchors-DQwbqb7w.mjs} +1 -1
- package/dist/cli.mjs +597 -186
- package/dist/index.d.mts +36 -8
- package/dist/index.mjs +3 -3
- package/dist/{lint-DfibQDW3.mjs → lint-CaJnNLmU.mjs} +199 -9
- package/dist/{site-DmY5YVte.mjs → site-BTCwdt_H.mjs} +15 -1
- package/package.json +3 -3
- package/schemas/v1/rule.schema.json +4 -0
package/dist/cli.mjs
CHANGED
|
@@ -1,23 +1,397 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { F as messages,
|
|
3
|
-
import { t as rulesForPath } from "./anchors-
|
|
4
|
-
import { 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
|
|
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,60 +459,7 @@ function render(path, rules, text) {
|
|
|
80
459
|
return parts.join("\n\n");
|
|
81
460
|
}
|
|
82
461
|
// package.json
|
|
83
|
-
var version = "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
|
-
}
|
|
462
|
+
var version = "0.9.0";
|
|
137
463
|
// src/commands/new.ts
|
|
138
464
|
const MAX_NUMBER = 999;
|
|
139
465
|
const CONTEXT_NAME = /^[a-z0-9][a-z0-9_-]*$/;
|
|
@@ -163,7 +489,7 @@ async function createContext(catalog, name, option, text) {
|
|
|
163
489
|
const directory = `${toRepoPath(catalog.config.rulesDir).replace(/\/+$/, "")}/${name}`;
|
|
164
490
|
const file = `${directory}/${CONTEXT_FILE_NAME}`;
|
|
165
491
|
await mkdir(join(catalog.root, directory), { recursive: true });
|
|
166
|
-
await writeNew(catalog, file, contextTemplate(name, prefix, text), text);
|
|
492
|
+
await writeNew$1(catalog, file, contextTemplate(name, prefix, text), text);
|
|
167
493
|
return {
|
|
168
494
|
file,
|
|
169
495
|
context: name,
|
|
@@ -181,10 +507,10 @@ async function writeNextRule(catalog, context, text) {
|
|
|
181
507
|
if (last >= MAX_NUMBER) throw new CliError(text.noFreeId(context.context, `R-${context.prefix}-${MAX_NUMBER}`));
|
|
182
508
|
const id = `R-${context.prefix}-${String(last + 1).padStart(3, "0")}`;
|
|
183
509
|
const file = `${directory}/${id}.md`;
|
|
184
|
-
await writeNew(catalog, file, ruleTemplate(id, context.context, catalog.config.language ?? "en"), text);
|
|
510
|
+
await writeNew$1(catalog, file, ruleTemplate(id, context.context, catalog.config.language ?? "en"), text);
|
|
185
511
|
return file;
|
|
186
512
|
}
|
|
187
|
-
async function writeNew(catalog, file, content, text) {
|
|
513
|
+
async function writeNew$1(catalog, file, content, text) {
|
|
188
514
|
try {
|
|
189
515
|
await writeFile(join(catalog.root, file), content, { flag: "wx" });
|
|
190
516
|
} catch (error) {
|
|
@@ -243,143 +569,174 @@ ${text.scenario}
|
|
|
243
569
|
\`\`\`
|
|
244
570
|
`;
|
|
245
571
|
}
|
|
246
|
-
// src/commands/
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
"
|
|
250
|
-
"
|
|
572
|
+
// src/commands/init.ts
|
|
573
|
+
const WORKFLOW_FILE = ".github/workflows/rulebookguard.yml";
|
|
574
|
+
const TEST_GLOBS = [
|
|
575
|
+
"**/*.test.*",
|
|
576
|
+
"**/*.spec.*",
|
|
577
|
+
"**/test_*.py",
|
|
578
|
+
"**/*_test.go"
|
|
251
579
|
];
|
|
252
|
-
function
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
writeLines(io, contexts.map((item) => `${item.context} (${item.prefix}, ${messages(catalog.config.language).ruleCount(item.rules)}): ${item.summary}`));
|
|
269
|
-
}
|
|
270
|
-
function listCommand(catalog, io, options) {
|
|
271
|
-
const { context, status } = options;
|
|
272
|
-
const text = messages(catalog.config.language);
|
|
273
|
-
if (status !== void 0 && !RULE_STATUSES.includes(status)) throw new CliError(text.invalidStatus(status, RULE_STATUSES.join(", ")), 2);
|
|
274
|
-
if (context !== void 0 && !catalog.contexts.some((item) => item.context === context)) throw new CliError(text.unknownContext(context));
|
|
275
|
-
const rules = sortById(catalog.rules).filter((rule) => context === void 0 || rule.context === context).filter((rule) => status === void 0 || rule.status === status).map(toSummary);
|
|
276
|
-
if (options.json) {
|
|
277
|
-
writeJson(io, { rules });
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
if (rules.length === 0) {
|
|
281
|
-
writeLines(io, [text.noRules]);
|
|
282
|
-
return;
|
|
580
|
+
async function initCommand(io, name, options) {
|
|
581
|
+
const root = io.cwd;
|
|
582
|
+
const text = messages(options.language);
|
|
583
|
+
const files = options.ci ? [CONFIG_FILE_NAME, WORKFLOW_FILE] : [CONFIG_FILE_NAME];
|
|
584
|
+
for (const file of files) if (await exists(join(root, file))) throw new CliError(text.alreadyExists(file));
|
|
585
|
+
const written = [];
|
|
586
|
+
try {
|
|
587
|
+
await writeNew(root, CONFIG_FILE_NAME, configTemplate(options.language), written);
|
|
588
|
+
const context = await createContext(await loadCatalog(root), name, options.prefix, text);
|
|
589
|
+
written.push(context.file);
|
|
590
|
+
if (options.ci) await writeNew(root, WORKFLOW_FILE, WORKFLOW, written);
|
|
591
|
+
} catch (error) {
|
|
592
|
+
await Promise.all(written.map((file) => rm(join(root, file), { force: true })));
|
|
593
|
+
if (error.code === "EEXIST") throw new CliError(text.alreadyExists(written.at(-1) ?? "rulebookguard.config.yaml"));
|
|
594
|
+
throw error;
|
|
283
595
|
}
|
|
284
|
-
writeLines(io,
|
|
596
|
+
writeLines(io, written);
|
|
285
597
|
}
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
...toSummary(rule),
|
|
291
|
-
entities: rule.entities,
|
|
292
|
-
anchors: rule.anchors,
|
|
293
|
-
supersedes: rule.supersedes ?? null,
|
|
294
|
-
file: rule.file,
|
|
295
|
-
body: rule.body
|
|
296
|
-
} });
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
const none = messages(catalog.config.language).none;
|
|
300
|
-
const header = [
|
|
301
|
-
`${rule.id}: ${rule.summary}`,
|
|
302
|
-
`context: ${rule.context}`,
|
|
303
|
-
`status: ${rule.status}`,
|
|
304
|
-
`entities: ${rule.entities.length === 0 ? none : rule.entities.join(", ")}`,
|
|
305
|
-
"anchors:",
|
|
306
|
-
...indent(rule.anchors, none),
|
|
307
|
-
...rule.supersedes === void 0 ? [] : [`supersedes: ${rule.supersedes}`],
|
|
308
|
-
`file: ${rule.file}`
|
|
309
|
-
];
|
|
310
|
-
io.stdout(`${header.join("\n")}\n\n${rule.body}`);
|
|
598
|
+
async function writeNew(root, file, content, written) {
|
|
599
|
+
await mkdir(dirname(join(root, file)), { recursive: true });
|
|
600
|
+
await writeFile(join(root, file), content, { flag: "wx" });
|
|
601
|
+
written.push(file);
|
|
311
602
|
}
|
|
312
|
-
function
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}));
|
|
319
|
-
if (json) {
|
|
320
|
-
writeJson(io, {
|
|
321
|
-
path,
|
|
322
|
-
rules
|
|
323
|
-
});
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
if (rules.length === 0) {
|
|
327
|
-
writeLines(io, [text.noActiveRulesFor(path)]);
|
|
328
|
-
return;
|
|
603
|
+
async function exists(path) {
|
|
604
|
+
try {
|
|
605
|
+
await access(path);
|
|
606
|
+
return true;
|
|
607
|
+
} catch {
|
|
608
|
+
return false;
|
|
329
609
|
}
|
|
330
|
-
writeLines(io, rules.map((rule) => `${rule.id} (${rule.context}): ${rule.summary}`));
|
|
331
610
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const text = messages(catalog.config.language);
|
|
335
|
-
const warn = gitFallbackWarning(io, catalog.config.language);
|
|
336
|
-
const files = await expandGlobs(catalog.root, rule.anchors, warn);
|
|
337
|
-
const tests = (await scanTestTags(catalog.root, catalog.config.testGlobs, warn)).get(id) ?? [];
|
|
338
|
-
if (json) {
|
|
339
|
-
writeJson(io, {
|
|
340
|
-
id,
|
|
341
|
-
files,
|
|
342
|
-
tests
|
|
343
|
-
});
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
writeLines(io, [
|
|
347
|
-
`${rule.id}: ${rule.summary}`,
|
|
348
|
-
text.filesMatched(files.length),
|
|
349
|
-
...indent(files, text.none),
|
|
350
|
-
text.testsTagging(rule.id, tests.length),
|
|
351
|
-
...indent(tests.map((test) => `${test.file}:${test.line}`), text.none)
|
|
352
|
-
]);
|
|
611
|
+
function configTemplate(language) {
|
|
612
|
+
return `schema: 1\nrulesDir: docs/rules\ntestGlobs:\n${TEST_GLOBS.map((glob) => ` - '${glob}'\n`).join("")}${language === void 0 ? "" : `language: ${language}\n`}`;
|
|
353
613
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
614
|
+
const WORKFLOW = `name: rules
|
|
615
|
+
on: pull_request
|
|
616
|
+
|
|
617
|
+
jobs:
|
|
618
|
+
rulebookguard:
|
|
619
|
+
runs-on: ubuntu-latest
|
|
620
|
+
steps:
|
|
621
|
+
- uses: actions/checkout@v7.0.1
|
|
622
|
+
with:
|
|
623
|
+
fetch-depth: 0
|
|
624
|
+
- uses: actions/setup-node@v6
|
|
625
|
+
with:
|
|
626
|
+
node-version: 22
|
|
627
|
+
- run: npx --yes rulebookguard@${version} lint --base origin/\${{ github.base_ref }}
|
|
628
|
+
`;
|
|
629
|
+
// src/mcp/server.ts
|
|
630
|
+
function createRulesServer(projectDir) {
|
|
631
|
+
const server = new McpServer({
|
|
632
|
+
name: "rulebookguard",
|
|
633
|
+
version
|
|
634
|
+
});
|
|
635
|
+
server.registerTool("rules_for", {
|
|
636
|
+
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.",
|
|
637
|
+
inputSchema: z.object({ path: z.string().describe("File path, absolute or relative to the project directory") }),
|
|
638
|
+
annotations: { readOnlyHint: true }
|
|
639
|
+
}, ({ path }) => cli(projectDir, [
|
|
640
|
+
"for",
|
|
641
|
+
path,
|
|
642
|
+
"--json"
|
|
643
|
+
]));
|
|
644
|
+
server.registerTool("rules_show", {
|
|
645
|
+
description: "Full text of one business rule: summary, anchors, entities, supersedes and the rule body with its examples.",
|
|
646
|
+
inputSchema: z.object({ id: z.string().describe("Rule id, for example R-CLI-001") }),
|
|
647
|
+
annotations: { readOnlyHint: true }
|
|
648
|
+
}, ({ id }) => cli(projectDir, [
|
|
649
|
+
"show",
|
|
650
|
+
id,
|
|
651
|
+
"--json"
|
|
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 }));
|
|
697
|
+
return server;
|
|
358
698
|
}
|
|
359
|
-
function
|
|
699
|
+
async function cli(cwd, args, { findingsAreData = false } = {}) {
|
|
700
|
+
let stdout = "";
|
|
701
|
+
let stderr = "";
|
|
702
|
+
if (await runCli(args, {
|
|
703
|
+
cwd,
|
|
704
|
+
stdout: (text) => {
|
|
705
|
+
stdout += text;
|
|
706
|
+
},
|
|
707
|
+
stderr: (text) => {
|
|
708
|
+
stderr += text;
|
|
709
|
+
}
|
|
710
|
+
}) === 0 || findingsAreData && stdout !== "") return { content: [{
|
|
711
|
+
type: "text",
|
|
712
|
+
text: stdout
|
|
713
|
+
}] };
|
|
360
714
|
return {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
715
|
+
content: [{
|
|
716
|
+
type: "text",
|
|
717
|
+
text: stderr.trim()
|
|
718
|
+
}],
|
|
719
|
+
isError: true
|
|
365
720
|
};
|
|
366
721
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
function sortById(rules) {
|
|
371
|
-
return [...rules].sort((a, b) => compare(a.id, b.id));
|
|
372
|
-
}
|
|
373
|
-
function compare(a, b) {
|
|
374
|
-
return a < b ? -1 : a > b ? 1 : 0;
|
|
722
|
+
// src/commands/mcp.ts
|
|
723
|
+
async function mcpCommand(io, project) {
|
|
724
|
+
await createRulesServer(resolve(io.cwd, project ?? ".")).connect(new StdioServerTransport());
|
|
375
725
|
}
|
|
376
726
|
// src/commands/run.ts
|
|
377
727
|
const USAGE = `Usage: rulebookguard <command> [options]
|
|
378
728
|
|
|
379
729
|
Commands:
|
|
730
|
+
init <context> [--prefix <P>] [--language <en|es>] [--ci]
|
|
731
|
+
create rulebookguard.config.yaml and a first context in
|
|
732
|
+
the current directory; --language: language of generated
|
|
733
|
+
text (default: en), --ci: also a GitHub Actions workflow
|
|
734
|
+
that runs lint --base on pull requests
|
|
380
735
|
contexts [--json] L0: list the contexts
|
|
381
736
|
list [--context <c>] [--status <s>] [--json] L1: id and summary of each rule
|
|
382
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
|
|
383
740
|
for <path> [--json] active rules anchored to a path
|
|
384
741
|
impact <id> [--json] files matched by a rule's anchors and tests tagging it
|
|
385
742
|
new <context> [--prefix <P>] scaffold the next free rule id as a proposed rule;
|
|
@@ -387,10 +744,20 @@ Commands:
|
|
|
387
744
|
--prefix: its id prefix (default: first 3 letters)
|
|
388
745
|
lint [--base <ref>] [--json] check the catalog, anchors and test tags;
|
|
389
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)
|
|
390
756
|
hook Claude Code hook for PreToolUse and PostToolUse: reads
|
|
391
757
|
the event on stdin and prints the rules anchored to
|
|
392
758
|
the file of the tool (Read, Edit, Write...)
|
|
393
|
-
mcp [--project <dir>] MCP server over stdio with rules_for
|
|
759
|
+
mcp [--project <dir>] MCP server over stdio with rules_for, rules_show, contexts,
|
|
760
|
+
rules_list, rules_search and lint;
|
|
394
761
|
--project: directory to find the rulebook from (default: cwd)
|
|
395
762
|
site:generate [--out <dir>] [--base <path>] build the read-only static site of the rulebook (alias: site);
|
|
396
763
|
--out: output directory (default: site), --base: URL path
|
|
@@ -407,6 +774,15 @@ const SITE_OPTIONS = {
|
|
|
407
774
|
base: { type: "string" }
|
|
408
775
|
};
|
|
409
776
|
const COMMANDS = {
|
|
777
|
+
init: {
|
|
778
|
+
usage: "rulebookguard init <context> [--prefix <P>] [--language <en|es>] [--ci]",
|
|
779
|
+
argument: "context",
|
|
780
|
+
options: {
|
|
781
|
+
prefix: { type: "string" },
|
|
782
|
+
language: { type: "string" },
|
|
783
|
+
ci: { type: "boolean" }
|
|
784
|
+
}
|
|
785
|
+
},
|
|
410
786
|
contexts: {
|
|
411
787
|
usage: "rulebookguard contexts [--json]",
|
|
412
788
|
options: JSON_OPTION
|
|
@@ -424,6 +800,14 @@ const COMMANDS = {
|
|
|
424
800
|
argument: "id",
|
|
425
801
|
options: JSON_OPTION
|
|
426
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
|
+
},
|
|
427
811
|
for: {
|
|
428
812
|
usage: "rulebookguard for <path> [--json]",
|
|
429
813
|
argument: "path",
|
|
@@ -439,6 +823,11 @@ const COMMANDS = {
|
|
|
439
823
|
argument: "context",
|
|
440
824
|
options: { prefix: { type: "string" } }
|
|
441
825
|
},
|
|
826
|
+
export: {
|
|
827
|
+
usage: EXPORT_USAGE,
|
|
828
|
+
argument: "format",
|
|
829
|
+
options: { out: { type: "string" } }
|
|
830
|
+
},
|
|
442
831
|
hook: {
|
|
443
832
|
usage: "rulebookguard hook",
|
|
444
833
|
options: {}
|
|
@@ -504,7 +893,7 @@ async function dispatch(argv, io) {
|
|
|
504
893
|
return 0;
|
|
505
894
|
}
|
|
506
895
|
if (name === "site" || name === "site:generate" || name === "site:run") {
|
|
507
|
-
const { siteCommand } = await import("./site-
|
|
896
|
+
const { siteCommand } = await import("./site-BTCwdt_H.mjs");
|
|
508
897
|
await siteCommand(io, {
|
|
509
898
|
out: values.out,
|
|
510
899
|
base: values.base,
|
|
@@ -513,10 +902,19 @@ async function dispatch(argv, io) {
|
|
|
513
902
|
});
|
|
514
903
|
return 0;
|
|
515
904
|
}
|
|
905
|
+
if (name === "init") {
|
|
906
|
+
await initCommand(io, argument, {
|
|
907
|
+
prefix: values.prefix,
|
|
908
|
+
language: initLanguage(values.language, spec.usage),
|
|
909
|
+
ci: values.ci === true
|
|
910
|
+
});
|
|
911
|
+
return 0;
|
|
912
|
+
}
|
|
516
913
|
if (name === "lint") return lintCommand(io, {
|
|
517
914
|
base: values.base,
|
|
518
915
|
json
|
|
519
916
|
});
|
|
917
|
+
if (name === "search" && argument.trim() === "") throw new CliError(`Search text is empty. Usage: ${spec.usage}`, 2);
|
|
520
918
|
const catalog = await openCatalog(io);
|
|
521
919
|
switch (name) {
|
|
522
920
|
case "contexts":
|
|
@@ -530,18 +928,31 @@ async function dispatch(argv, io) {
|
|
|
530
928
|
});
|
|
531
929
|
break;
|
|
532
930
|
case "show":
|
|
533
|
-
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
|
+
});
|
|
534
938
|
break;
|
|
535
939
|
case "for":
|
|
536
|
-
forCommand(catalog, io, argument, json);
|
|
940
|
+
await forCommand(catalog, io, argument, json);
|
|
537
941
|
break;
|
|
538
942
|
case "impact":
|
|
539
943
|
await impactCommand(catalog, io, argument, json);
|
|
540
944
|
break;
|
|
541
|
-
case "new":
|
|
945
|
+
case "new":
|
|
946
|
+
await newCommand(catalog, io, argument, values.prefix);
|
|
947
|
+
break;
|
|
948
|
+
case "export": await exportCommand(catalog, io, argument, values.out);
|
|
542
949
|
}
|
|
543
950
|
return 0;
|
|
544
951
|
}
|
|
952
|
+
function initLanguage(value, usage) {
|
|
953
|
+
if (value === void 0 || value === "en" || value === "es") return value;
|
|
954
|
+
throw new CliError(`--language must be en or es.\nUsage: ${usage}`, 2);
|
|
955
|
+
}
|
|
545
956
|
function sitePort(value, usage) {
|
|
546
957
|
if (value === void 0) return;
|
|
547
958
|
const port = Number(value);
|