rulebookguard 0.11.0 → 0.13.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 +58 -12
- package/dist/{anchors-jf2bKfyg.mjs → anchors-BGiDD0zY.mjs} +1 -1
- package/dist/cli.mjs +155 -13
- package/dist/index.d.mts +12 -0
- package/dist/index.mjs +2 -2
- package/dist/{lint-BPdk6XxH.mjs → lint-BdC2U8K3.mjs} +241 -5
- package/dist/{site-B7g8lbjx.mjs → site-hSQxz6_e.mjs} +13 -17
- package/package.json +11 -3
- package/plugin/agents/rule-author.md +58 -0
- package/plugin/agents/rule-miner.md +45 -0
- package/plugin/plugin.json +13 -0
- package/plugin/skills/rule-writing/SKILL.md +38 -0
- package/plugin/skills/rules/SKILL.md +28 -0
- package/npm-shrinkwrap.json +0 -148
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { access, lstat, readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
3
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
3
4
|
import { YAMLParseError, parse } from "yaml";
|
|
@@ -5,6 +6,7 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
|
5
6
|
import picomatch from "picomatch";
|
|
6
7
|
import { execFile } from "node:child_process";
|
|
7
8
|
import { promisify } from "node:util";
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
10
|
// src/format/errors.ts
|
|
9
11
|
function formatIssue(issue) {
|
|
10
12
|
return issue.field === void 0 ? `${issue.file}: ${issue.message}` : `${issue.file}: field "${issue.field}" ${issue.message}`;
|
|
@@ -18,7 +20,7 @@ var FormatError = class extends Error {
|
|
|
18
20
|
}
|
|
19
21
|
};
|
|
20
22
|
// src/format/read.ts
|
|
21
|
-
async function readSource(path) {
|
|
23
|
+
async function readSource$1(path) {
|
|
22
24
|
try {
|
|
23
25
|
return await readFile(path, "utf8");
|
|
24
26
|
} catch (error) {
|
|
@@ -212,6 +214,8 @@ const MESSAGES = {
|
|
|
212
214
|
unmatchedEndimplementsTag: (id) => `@endimplements ${id} has no open @implements ${id} before it`,
|
|
213
215
|
implementsOutsideAnchors: (id) => `marks code as implementing ${id}, but no anchor of ${id} matches this file`,
|
|
214
216
|
contextOverBudget: (context, count, budget) => `context "${context}" has ${count} rules, more than the contextBudget of ${budget}`,
|
|
217
|
+
deadEvidenceFile: (id, entry, file) => `evidence "${entry}" of ${id}: ${file} does not exist`,
|
|
218
|
+
deadEvidenceSymbol: (id, entry, symbol, file) => `evidence "${entry}" of ${id}: "${symbol}" is not found in ${file}`,
|
|
215
219
|
pendingImplementation: (id, base) => `rule ${id} changed since ${base} but none of the tests tagging it changed`,
|
|
216
220
|
unknownRuleReference: (field, id) => `${field} names ${id}, which does not exist`,
|
|
217
221
|
selfRuleReference: (field) => `${field} names the rule itself`,
|
|
@@ -227,6 +231,7 @@ const MESSAGES = {
|
|
|
227
231
|
removedExport: (path) => `removed ${path}`,
|
|
228
232
|
handWrittenExport: (path, format) => `${path} was not generated by "rulebookguard export ${format}"; move it away and run the export again.`,
|
|
229
233
|
generatedRule: (format, file) => `Generated by "rulebookguard export ${format}" from ${file}; edit the rule instead.`,
|
|
234
|
+
generatedKiroAgent: (format, version) => `Generated by "rulebookguard export ${format}" from rulebookguard ${version}; run it again to update.`,
|
|
230
235
|
generatedAgentsMd: "Generated by \"rulebookguard export agents-md\"; edit the rules instead.",
|
|
231
236
|
agentsMdHeading: "Business rules",
|
|
232
237
|
agentsMdIntro: "Before changing a file, follow the active rules anchored to it:",
|
|
@@ -258,6 +263,7 @@ const MESSAGES = {
|
|
|
258
263
|
unmatchedEndimplementsTagHint: (id) => `add "@implements ${id}" before the code that enforces the rule, or remove the stray @endimplements`,
|
|
259
264
|
implementsOutsideAnchorsHint: (id) => `add an anchor to ${id} that matches this file, or move the region to a file it anchors`,
|
|
260
265
|
contextOverBudgetHint: "split the context into smaller ones, or raise contextBudget in rulebookguard.config.yaml",
|
|
266
|
+
deadEvidenceHint: "point the entry at the file and symbol that show the rule now (\"path symbol\"), or remove it",
|
|
261
267
|
pendingImplementationHint: (id) => `update or add a test inside an "@rule ${id}" … "@endrule ${id}" region that covers the changed rule`,
|
|
262
268
|
lintSummary: (errors, warnings) => `${errors} ${errors === 1 ? "error" : "errors"}, ${warnings} ${warnings === 1 ? "warning" : "warnings"}`,
|
|
263
269
|
needsWorkTree: (command, root) => `${command} needs a git work tree, but ${root} is not inside one.`,
|
|
@@ -320,6 +326,8 @@ const MESSAGES = {
|
|
|
320
326
|
unmatchedEndimplementsTag: (id) => `@endimplements ${id} no tiene un @implements ${id} abierto antes`,
|
|
321
327
|
implementsOutsideAnchors: (id) => `marca código como implementación de ${id}, pero ningún anchor de ${id} coincide con este archivo`,
|
|
322
328
|
contextOverBudget: (context, count, budget) => `el contexto "${context}" tiene ${count} reglas, más que el contextBudget de ${budget}`,
|
|
329
|
+
deadEvidenceFile: (id, entry, file) => `la evidencia "${entry}" de ${id}: ${file} no existe`,
|
|
330
|
+
deadEvidenceSymbol: (id, entry, symbol, file) => `la evidencia "${entry}" de ${id}: "${symbol}" no aparece en ${file}`,
|
|
323
331
|
pendingImplementation: (id, base) => `la regla ${id} cambió desde ${base} pero no cambió ninguno de los tests que la etiquetan`,
|
|
324
332
|
unknownRuleReference: (field, id) => `${field} nombra a ${id}, que no existe`,
|
|
325
333
|
selfRuleReference: (field) => `${field} nombra a la propia regla`,
|
|
@@ -335,6 +343,7 @@ const MESSAGES = {
|
|
|
335
343
|
removedExport: (path) => `eliminado ${path}`,
|
|
336
344
|
handWrittenExport: (path, format) => `${path} no fue generado por "rulebookguard export ${format}"; muévelo y vuelve a exportar.`,
|
|
337
345
|
generatedRule: (format, file) => `Generado por "rulebookguard export ${format}" desde ${file}; edita la regla en su lugar.`,
|
|
346
|
+
generatedKiroAgent: (format, version) => `Generado por "rulebookguard export ${format}" desde rulebookguard ${version}; vuelve a ejecutarlo para actualizar.`,
|
|
338
347
|
generatedAgentsMd: "Generado por \"rulebookguard export agents-md\"; edita las reglas en su lugar.",
|
|
339
348
|
agentsMdHeading: "Reglas de negocio",
|
|
340
349
|
agentsMdIntro: "Antes de cambiar un archivo, sigue las reglas activas ancladas a él:",
|
|
@@ -366,6 +375,7 @@ const MESSAGES = {
|
|
|
366
375
|
unmatchedEndimplementsTagHint: (id) => `agrega "@implements ${id}" antes del código que cumple la regla, o quita el @endimplements que sobra`,
|
|
367
376
|
implementsOutsideAnchorsHint: (id) => `agrega a ${id} un anchor que coincida con este archivo, o mueve la región a un archivo que ancle`,
|
|
368
377
|
contextOverBudgetHint: "divide el contexto en otros más chicos o sube contextBudget en rulebookguard.config.yaml",
|
|
378
|
+
deadEvidenceHint: "apunta la entrada al archivo y al símbolo que muestran la regla hoy (\"ruta símbolo\"), o quítala",
|
|
369
379
|
pendingImplementationHint: (id) => `actualiza o agrega un test dentro de una región "@rule ${id}" … "@endrule ${id}" que cubra el cambio de la regla`,
|
|
370
380
|
lintSummary: (errors, warnings) => `${errors} ${errors === 1 ? "error" : "errores"}, ${warnings} ${warnings === 1 ? "aviso" : "avisos"}`,
|
|
371
381
|
needsWorkTree: (command, root) => `${command} necesita un work tree de git, pero ${root} no está en uno.`,
|
|
@@ -457,7 +467,7 @@ function parseConfig(source, file) {
|
|
|
457
467
|
return validate(validators.config, parseYaml(source, file), file);
|
|
458
468
|
}
|
|
459
469
|
async function loadConfig(path) {
|
|
460
|
-
return parseConfig(await readSource(path), path);
|
|
470
|
+
return parseConfig(await readSource$1(path), path);
|
|
461
471
|
}
|
|
462
472
|
// src/format/frontmatter.ts
|
|
463
473
|
const OPENING = /^---\r?\n/;
|
|
@@ -493,7 +503,7 @@ function parseContext(source, file, { language } = {}) {
|
|
|
493
503
|
};
|
|
494
504
|
}
|
|
495
505
|
async function loadContext(path, options) {
|
|
496
|
-
return parseContext(await readSource(path), path, options);
|
|
506
|
+
return parseContext(await readSource$1(path), path, options);
|
|
497
507
|
}
|
|
498
508
|
// src/format/rule.ts
|
|
499
509
|
const RULE_ID = /^R-[A-Z][A-Z0-9]*-[0-9]{3}$/;
|
|
@@ -509,7 +519,7 @@ function parseRule(source, file, { language } = {}) {
|
|
|
509
519
|
};
|
|
510
520
|
}
|
|
511
521
|
async function loadRule(path, options) {
|
|
512
|
-
return parseRule(await readSource(path), path, options);
|
|
522
|
+
return parseRule(await readSource$1(path), path, options);
|
|
513
523
|
}
|
|
514
524
|
// src/format/tag.ts
|
|
515
525
|
const RULE_TAG_PATTERN = /(?<![\w@.-])@rule\s+(R-[A-Z][A-Z0-9]*-[0-9]{3})(?![\w-])/g;
|
|
@@ -972,6 +982,213 @@ function push(index, id, value) {
|
|
|
972
982
|
function sortById(index) {
|
|
973
983
|
return new Map([...index].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
974
984
|
}
|
|
985
|
+
// src/format/evidence.ts
|
|
986
|
+
const LINE_SUFFIX = /^(.*):([0-9]+)(?:-([0-9]+))?$/;
|
|
987
|
+
const QUOTED = /^(["'`])(.+)\1$/;
|
|
988
|
+
function parseEvidence(entry) {
|
|
989
|
+
const trimmed = entry.trim();
|
|
990
|
+
const space = trimmed.search(/\s/);
|
|
991
|
+
const path = repoPath(space === -1 ? trimmed : trimmed.slice(0, space));
|
|
992
|
+
const rest = space === -1 ? "" : trimmed.slice(space).trim();
|
|
993
|
+
const lines = LINE_SUFFIX.exec(path);
|
|
994
|
+
if (lines !== null && rest === "") {
|
|
995
|
+
const line = Number(lines[2]);
|
|
996
|
+
return {
|
|
997
|
+
entry,
|
|
998
|
+
file: lines[1],
|
|
999
|
+
lines: {
|
|
1000
|
+
line,
|
|
1001
|
+
endLine: lines[3] === void 0 ? line : Number(lines[3])
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
if (rest === "") return {
|
|
1006
|
+
entry,
|
|
1007
|
+
file: path
|
|
1008
|
+
};
|
|
1009
|
+
const quoted = QUOTED.exec(rest);
|
|
1010
|
+
return {
|
|
1011
|
+
entry,
|
|
1012
|
+
file: path,
|
|
1013
|
+
symbol: quoted === null ? {
|
|
1014
|
+
kind: "name",
|
|
1015
|
+
parts: rest.split(/\.|::|#/).filter((part) => part !== "")
|
|
1016
|
+
} : {
|
|
1017
|
+
kind: "text",
|
|
1018
|
+
text: quoted[2]
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
function repoPath(path) {
|
|
1023
|
+
return path.replaceAll("\\", "/").replace(/^(?:\.\/)+/, "");
|
|
1024
|
+
}
|
|
1025
|
+
const MODIFIERS = "(?:(?:public|private|protected|internal|static|async|override|abstract|virtual|sealed|final|export|default|get|set|readonly|unsafe|extern|synchronized|pub(?:\\([^)]*\\))?)\\s+)*";
|
|
1026
|
+
function escape(text) {
|
|
1027
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1028
|
+
}
|
|
1029
|
+
function definitionPatterns(name) {
|
|
1030
|
+
const n = escape(name);
|
|
1031
|
+
const end = "(?![\\w$])";
|
|
1032
|
+
return [
|
|
1033
|
+
new RegExp(`(?:^|[^\\w$.])(?:function\\*?|def|fn|func|class|interface|type|enum|struct|trait|impl|module|namespace|record|object|const|let|var|val|sub|macro|alias)\\s+${n}${end}`),
|
|
1034
|
+
new RegExp(`\\bfunc\\s*\\([^)]*\\)\\s*${n}${end}`),
|
|
1035
|
+
new RegExp(`^\\s*${MODIFIERS}${n}\\s*(?::[^=]*)?=(?![=>])`),
|
|
1036
|
+
new RegExp(`["']${n}["']\\s*:`),
|
|
1037
|
+
new RegExp(`^\\s*${n}\\s*:`),
|
|
1038
|
+
new RegExp(`^\\s*${MODIFIERS}(?:[\\w$<>\\[\\],.?*&]+\\s+)?${n}\\s*(?:<[^>]*>)?\\s*\\([^;]*$`)
|
|
1039
|
+
];
|
|
1040
|
+
}
|
|
1041
|
+
/** The 1-based line defining `name`, searching after line `after`; comment lines never count. */
|
|
1042
|
+
function findDefinitionLine(lines, name, after = 0) {
|
|
1043
|
+
for (const pattern of definitionPatterns(name)) for (let index = after; index < lines.length; index += 1) {
|
|
1044
|
+
const text = lines[index];
|
|
1045
|
+
if (!COMMENT_LINE.test(text) && pattern.test(text)) return index + 1;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
/** The 1-based line holding `text` as a whole quoted string, such as a test name. */
|
|
1049
|
+
function findTextLine(lines, text) {
|
|
1050
|
+
const pattern = new RegExp(`(["'\`])${escape(text)}\\1`);
|
|
1051
|
+
const index = lines.findIndex((line) => pattern.test(line));
|
|
1052
|
+
return index === -1 ? void 0 : index + 1;
|
|
1053
|
+
}
|
|
1054
|
+
// src/optional-package.ts
|
|
1055
|
+
/** Imports `name` from the first of `from` that resolves it, or returns undefined when none does. */
|
|
1056
|
+
async function importOptionalPackage(name, from) {
|
|
1057
|
+
for (const directory of from) {
|
|
1058
|
+
let entry;
|
|
1059
|
+
try {
|
|
1060
|
+
entry = createRequire(join(directory, "package.json")).resolve(name);
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
if (error.code === "MODULE_NOT_FOUND") continue;
|
|
1063
|
+
throw error;
|
|
1064
|
+
}
|
|
1065
|
+
return await import(pathToFileURL(entry).href);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
// src/catalog/evidence.ts
|
|
1069
|
+
const SYMBOLS_PACKAGE = "@rulebookguard/symbols";
|
|
1070
|
+
/**
|
|
1071
|
+
* Resolves evidence entries to the lines they name: exact ranges through `@rulebookguard/symbols`
|
|
1072
|
+
* when it is installed and knows the language, otherwise the definition line a regex finds.
|
|
1073
|
+
*/
|
|
1074
|
+
async function createEvidenceResolver(root, loadSymbols = () => importOptionalPackage(SYMBOLS_PACKAGE, [root, dirname(fileURLToPath(import.meta.url))])) {
|
|
1075
|
+
const realRoot = await realpath(root);
|
|
1076
|
+
const symbols = await loadSymbols().catch(() => void 0);
|
|
1077
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
1078
|
+
const sources = /* @__PURE__ */ new Map();
|
|
1079
|
+
const kindOf = (file) => {
|
|
1080
|
+
let kind = kinds.get(file);
|
|
1081
|
+
if (kind === void 0) {
|
|
1082
|
+
kind = pathKind(join(root, file), realRoot);
|
|
1083
|
+
kinds.set(file, kind);
|
|
1084
|
+
}
|
|
1085
|
+
return kind;
|
|
1086
|
+
};
|
|
1087
|
+
const sourceOf = (file) => {
|
|
1088
|
+
let source = sources.get(file);
|
|
1089
|
+
if (source === void 0) {
|
|
1090
|
+
source = readSource(file, join(root, file), symbols);
|
|
1091
|
+
sources.set(file, source);
|
|
1092
|
+
}
|
|
1093
|
+
return source;
|
|
1094
|
+
};
|
|
1095
|
+
return async (entry) => {
|
|
1096
|
+
const parsed = parseEvidence(entry);
|
|
1097
|
+
const symbol = symbolText(parsed);
|
|
1098
|
+
const link = {
|
|
1099
|
+
entry,
|
|
1100
|
+
file: parsed.file,
|
|
1101
|
+
...symbol === void 0 ? {} : { symbol },
|
|
1102
|
+
line: null,
|
|
1103
|
+
endLine: null
|
|
1104
|
+
};
|
|
1105
|
+
const kind = await kindOf(parsed.file);
|
|
1106
|
+
if (kind === "missing" || kind === "directory" && parsed.symbol !== void 0) return {
|
|
1107
|
+
...link,
|
|
1108
|
+
problem: "missing-file"
|
|
1109
|
+
};
|
|
1110
|
+
if (parsed.lines !== void 0) return {
|
|
1111
|
+
...link,
|
|
1112
|
+
...parsed.lines
|
|
1113
|
+
};
|
|
1114
|
+
if (parsed.symbol === void 0) return link;
|
|
1115
|
+
const source = await sourceOf(parsed.file);
|
|
1116
|
+
const span = parsed.symbol.kind === "name" ? resolveName(parsed.symbol.parts, source) : resolveText(parsed.symbol.text, source);
|
|
1117
|
+
return span === void 0 ? {
|
|
1118
|
+
...link,
|
|
1119
|
+
problem: "missing-symbol"
|
|
1120
|
+
} : {
|
|
1121
|
+
...link,
|
|
1122
|
+
...span
|
|
1123
|
+
};
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
function symbolText(parsed) {
|
|
1127
|
+
if (parsed.symbol === void 0) return;
|
|
1128
|
+
if (parsed.symbol.kind === "text") return parsed.symbol.text;
|
|
1129
|
+
const trimmed = parsed.entry.trim();
|
|
1130
|
+
return trimmed.slice(trimmed.search(/\s/)).trim();
|
|
1131
|
+
}
|
|
1132
|
+
async function pathKind(path, realRoot) {
|
|
1133
|
+
try {
|
|
1134
|
+
if (!await resolvesInside(path, realRoot)) return "missing";
|
|
1135
|
+
const info = await stat(path);
|
|
1136
|
+
return info.isFile() ? "file" : info.isDirectory() ? "directory" : "missing";
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
if (error.code === "ENOTDIR") return "missing";
|
|
1139
|
+
throw error;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
async function readSource(file, path, symbols) {
|
|
1143
|
+
const text = await readFile(path, "utf8");
|
|
1144
|
+
let read;
|
|
1145
|
+
try {
|
|
1146
|
+
read = await symbols?.readSymbols(file, text);
|
|
1147
|
+
} catch {
|
|
1148
|
+
read = void 0;
|
|
1149
|
+
}
|
|
1150
|
+
return {
|
|
1151
|
+
lines: text.split(/\r?\n/),
|
|
1152
|
+
symbols: read
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
function resolveName(parts, source) {
|
|
1156
|
+
if (parts.length === 0) return;
|
|
1157
|
+
return (source.symbols === void 0 ? void 0 : inDefinitions(parts, source.symbols.definitions)) ?? byRegex(parts, source.lines);
|
|
1158
|
+
}
|
|
1159
|
+
function inDefinitions(parts, definitions) {
|
|
1160
|
+
let scope;
|
|
1161
|
+
for (const part of parts) {
|
|
1162
|
+
const outer = scope;
|
|
1163
|
+
scope = definitions.find((definition) => definition.name === part && (outer === void 0 || definition !== outer && definition.line >= outer.line && definition.endLine <= outer.endLine));
|
|
1164
|
+
if (scope === void 0) return;
|
|
1165
|
+
}
|
|
1166
|
+
return scope === void 0 ? void 0 : {
|
|
1167
|
+
line: scope.line,
|
|
1168
|
+
endLine: scope.endLine
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
function byRegex(parts, lines) {
|
|
1172
|
+
let line = 0;
|
|
1173
|
+
for (const part of parts) {
|
|
1174
|
+
const found = findDefinitionLine(lines, part, line);
|
|
1175
|
+
if (found === void 0) return;
|
|
1176
|
+
line = found;
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
line,
|
|
1180
|
+
endLine: line
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
function resolveText(text, source) {
|
|
1184
|
+
const call = source.symbols?.calls(text)[0];
|
|
1185
|
+
if (call !== void 0) return call;
|
|
1186
|
+
const line = findTextLine(source.lines, text);
|
|
1187
|
+
return line === void 0 ? void 0 : {
|
|
1188
|
+
line,
|
|
1189
|
+
endLine: line
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
975
1192
|
var CliError = class extends Error {
|
|
976
1193
|
exitCode;
|
|
977
1194
|
constructor(message, exitCode = 1) {
|
|
@@ -1107,6 +1324,25 @@ async function lintCatalog(catalog, scan, onGitFallback) {
|
|
|
1107
1324
|
hint: text.contextOverBudgetHint
|
|
1108
1325
|
});
|
|
1109
1326
|
}
|
|
1327
|
+
findings.push(...await deadEvidence(catalog, text));
|
|
1328
|
+
return findings;
|
|
1329
|
+
}
|
|
1330
|
+
async function deadEvidence(catalog, text) {
|
|
1331
|
+
const rules = catalog.rules.filter((rule) => rule.status !== "deprecated" && (rule.evidence?.length ?? 0) > 0);
|
|
1332
|
+
if (rules.length === 0) return [];
|
|
1333
|
+
const resolve = await createEvidenceResolver(catalog.root);
|
|
1334
|
+
const findings = [];
|
|
1335
|
+
for (const rule of rules) for (const entry of rule.evidence ?? []) {
|
|
1336
|
+
const link = await resolve(entry);
|
|
1337
|
+
if (link.problem === void 0) continue;
|
|
1338
|
+
findings.push({
|
|
1339
|
+
severity: "warning",
|
|
1340
|
+
code: "dead-evidence",
|
|
1341
|
+
file: rule.file,
|
|
1342
|
+
message: link.problem === "missing-file" ? text.deadEvidenceFile(rule.id, entry, link.file) : text.deadEvidenceSymbol(rule.id, entry, link.symbol ?? "", link.file),
|
|
1343
|
+
hint: text.deadEvidenceHint
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1110
1346
|
return findings;
|
|
1111
1347
|
}
|
|
1112
1348
|
async function implementations(catalog, files, invalid, successors, text) {
|
|
@@ -1232,4 +1468,4 @@ function sortFindings(findings) {
|
|
|
1232
1468
|
function compare(a, b) {
|
|
1233
1469
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
1234
1470
|
}
|
|
1235
|
-
export {
|
|
1471
|
+
export { findRuleRegions as A, loadConfig as B, expandGlobs as C, IMPLEMENTS_MARKER_PATTERN as D, runGit as E, CONTEXT_FILE_NAME as F, messages as H, loadContext as I, parseContext as L, isRuleId as M, loadRule as N, RULE_MARKER_PATTERN as O, parseRule as P, splitFrontmatter as R, createGlobMatcher as S, toRepoPath as T, FormatError as U, parseConfig as V, formatIssue as W, scanTestRegions as _, pendingImplementation as a, loadCatalog as b, gitFallbackWarning as c, writeJson as d, writeLines as f, scanImplementations as g, parseEvidence as h, lintCommand as i, findRuleTags as j, RULE_TAG_PATTERN as k, openCatalog as l, importOptionalPackage as m, formatFinding as n, CliError as o, createEvidenceResolver as p, lintCatalog as r, findRule as s, changedFiles as t, shownPath as u, scanTestTags as v, listRepoFiles as w, relativeToRoot as x, findRulebookRoot as y, CONFIG_FILE_NAME as z };
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { C as runGit,
|
|
2
|
-
import { createRequire } from "node:module";
|
|
1
|
+
import { C as expandGlobs, E as runGit, T as toRepoPath, _ as scanTestRegions, c as gitFallbackWarning, f as writeLines, h as parseEvidence, l as openCatalog, m as importOptionalPackage, o as CliError, p as createEvidenceResolver, r as lintCatalog } from "./lint-BdC2U8K3.mjs";
|
|
3
2
|
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
4
3
|
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
6
|
// src/site/data.ts
|
|
8
7
|
const FIELD = "";
|
|
9
8
|
async function siteData(catalog, onGitFallback) {
|
|
10
9
|
const scan = await scanTestRegions(catalog.root, catalog.config.testGlobs, onGitFallback);
|
|
11
10
|
const byId = new Map(catalog.rules.map((rule) => [rule.id, rule]));
|
|
12
11
|
const rules = [...catalog.rules].sort((a, b) => compare(a.id, b.id));
|
|
12
|
+
const resolveEvidence = rules.some((rule) => (rule.evidence?.length ?? 0) > 0) ? await createEvidenceResolver(catalog.root) : void 0;
|
|
13
13
|
return {
|
|
14
14
|
contexts: [...catalog.contexts].sort((a, b) => compare(a.context, b.context)),
|
|
15
15
|
rules: await Promise.all(rules.map(async (rule) => ({
|
|
@@ -18,7 +18,8 @@ async function siteData(catalog, onGitFallback) {
|
|
|
18
18
|
tests: scan.tags.get(rule.id) ?? [],
|
|
19
19
|
history: await history(catalog.root, rule.file),
|
|
20
20
|
supersedes: chain(rule, byId),
|
|
21
|
-
supersededBy: rules.find((other) => other.supersedes === rule.id)?.id
|
|
21
|
+
supersededBy: rules.find((other) => other.supersedes === rule.id)?.id,
|
|
22
|
+
evidence: resolveEvidence === void 0 ? [] : await Promise.all((rule.evidence ?? []).map(resolveEvidence))
|
|
22
23
|
}))),
|
|
23
24
|
findings: await lintCatalog(catalog, scan, onGitFallback)
|
|
24
25
|
};
|
|
@@ -228,7 +229,7 @@ export const collections = { docs: defineCollection({ loader: docsLoader(), sche
|
|
|
228
229
|
]));
|
|
229
230
|
return files;
|
|
230
231
|
}
|
|
231
|
-
function rulePage({ rule, files, tests, history, supersedes, supersededBy }, text, base, ruleLink) {
|
|
232
|
+
function rulePage({ rule, files, tests, history, supersedes, supersededBy, evidence }, text, base, ruleLink) {
|
|
232
233
|
const rows = [
|
|
233
234
|
[text.status, rule.status],
|
|
234
235
|
[text.context, `[${rule.context}](${base}${rule.context}/)`],
|
|
@@ -238,7 +239,6 @@ function rulePage({ rule, files, tests, history, supersedes, supersededBy }, tex
|
|
|
238
239
|
if (supersedes.length > 0) rows.push([text.supersedes, supersedes.map(ruleLink).join(" → ")]);
|
|
239
240
|
if (supersededBy !== void 0) rows.push([text.supersededBy, ruleLink(supersededBy)]);
|
|
240
241
|
if (rule.origin !== void 0) rows.push([text.origin, rule.origin === "extracted" && rule.status === "proposed" ? `${rule.origin}, ${text.notValidated}` : rule.origin]);
|
|
241
|
-
const evidence = rule.evidence ?? [];
|
|
242
242
|
return page(rule.id, rule.summary, [
|
|
243
243
|
`**${cell(rule.summary)}**`,
|
|
244
244
|
"",
|
|
@@ -253,7 +253,7 @@ function rulePage({ rule, files, tests, history, supersedes, supersededBy }, tex
|
|
|
253
253
|
...evidence.length === 0 ? [] : [
|
|
254
254
|
`## ${text.evidence}`,
|
|
255
255
|
"",
|
|
256
|
-
...list(evidence.map(
|
|
256
|
+
...list(evidence.map(evidenceItem), text.none),
|
|
257
257
|
""
|
|
258
258
|
],
|
|
259
259
|
`## ${text.anchors}`,
|
|
@@ -381,6 +381,10 @@ function page(title, description, lines) {
|
|
|
381
381
|
""
|
|
382
382
|
].join("\n");
|
|
383
383
|
}
|
|
384
|
+
function evidenceItem(link) {
|
|
385
|
+
const item = `\`${link.entry}\``;
|
|
386
|
+
return link.line === null || link.endLine === null || parseEvidence(link.entry).lines !== void 0 ? item : `${item} → \`${link.file}:${link.line}-${link.endLine}\``;
|
|
387
|
+
}
|
|
384
388
|
function list(items, empty) {
|
|
385
389
|
return items.length === 0 ? [empty] : items.map((item) => `- ${item}`);
|
|
386
390
|
}
|
|
@@ -441,16 +445,8 @@ async function siteCommand(io, options, until = untilInterrupted()) {
|
|
|
441
445
|
}
|
|
442
446
|
/** Imports the site builder from the first of `from` that resolves it. */
|
|
443
447
|
async function importSitePackage(from) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
try {
|
|
447
|
-
entry = createRequire(join(directory, "package.json")).resolve(SITE_PACKAGE);
|
|
448
|
-
} catch (error) {
|
|
449
|
-
if (error.code === "MODULE_NOT_FOUND") continue;
|
|
450
|
-
throw error;
|
|
451
|
-
}
|
|
452
|
-
return await import(pathToFileURL(entry).href);
|
|
453
|
-
}
|
|
448
|
+
const site = await importOptionalPackage(SITE_PACKAGE, from);
|
|
449
|
+
if (site !== void 0) return site;
|
|
454
450
|
throw new CliError(`The site commands need the ${SITE_PACKAGE} package. Install it next to rulebookguard: npm install --save-dev ${SITE_PACKAGE}`);
|
|
455
451
|
}
|
|
456
452
|
async function buildSite(site, catalog, out, language, base, io) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulebookguard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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": {
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
19
|
"schemas",
|
|
20
|
+
"plugin/agents",
|
|
21
|
+
"plugin/skills",
|
|
22
|
+
"plugin/plugin.json",
|
|
20
23
|
"npm-shrinkwrap.json"
|
|
21
24
|
],
|
|
22
25
|
"type": "module",
|
|
@@ -37,18 +40,23 @@
|
|
|
37
40
|
},
|
|
38
41
|
"devDependencies": {
|
|
39
42
|
"@modelcontextprotocol/client": "2.3.1",
|
|
40
|
-
"@rulebookguard/site": "
|
|
43
|
+
"@rulebookguard/site": "workspace:*",
|
|
44
|
+
"@rulebookguard/symbols": "workspace:*",
|
|
41
45
|
"@types/node": "^20.19.43",
|
|
42
46
|
"@types/picomatch": "^4.0.3",
|
|
43
47
|
"typescript": "^7.0.2",
|
|
44
48
|
"vite-plus": "^1.4.0"
|
|
45
49
|
},
|
|
46
50
|
"peerDependencies": {
|
|
47
|
-
"@rulebookguard/site": "^0.
|
|
51
|
+
"@rulebookguard/site": "^0.13.0",
|
|
52
|
+
"@rulebookguard/symbols": "^0.13.0"
|
|
48
53
|
},
|
|
49
54
|
"peerDependenciesMeta": {
|
|
50
55
|
"@rulebookguard/site": {
|
|
51
56
|
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"@rulebookguard/symbols": {
|
|
59
|
+
"optional": true
|
|
52
60
|
}
|
|
53
61
|
},
|
|
54
62
|
"engines": {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rule-author
|
|
3
|
+
description: Turns a feature, a ticket or a behavior change into a rulebookguard rule change plan (new rules with Gherkin, changed rules as diffs, deprecations, conflicts, test skeletons and open questions) for a person to approve, then writes the approved rules as proposed. Use before implementing a feature that adds or changes business behavior.
|
|
4
|
+
tools: Read, Grep, Glob, Bash, Edit, Write, mcp__plugin_rulebookguard_rulebookguard__contexts, mcp__plugin_rulebookguard_rulebookguard__rules_list, mcp__plugin_rulebookguard_rulebookguard__rules_search, mcp__plugin_rulebookguard_rulebookguard__rules_show, mcp__plugin_rulebookguard_rulebookguard__rules_for, mcp__plugin_rulebookguard_rulebookguard__lint
|
|
5
|
+
skills:
|
|
6
|
+
- rulebookguard:rule-writing
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You design the business rules a feature needs before anyone writes its code. You answer: which rules does this feature add, change or retire, and which existing rules does it collide with? Write every rule as the preloaded `rulebookguard:rule-writing` skill says.
|
|
10
|
+
|
|
11
|
+
You run in one of two modes:
|
|
12
|
+
|
|
13
|
+
- **Plan**, the default: you read, but write nothing. You return a rule change plan and stop.
|
|
14
|
+
- **Apply**, only when the request includes a plan a person has approved: you write exactly that plan into the rulebook.
|
|
15
|
+
|
|
16
|
+
## Hard limits
|
|
17
|
+
|
|
18
|
+
- Never set a rule `active`, and never write tests or production code. After the rules, the work follows the usual flow: rules, then tests, then code, and `lint --base` holds it to that order.
|
|
19
|
+
- Never read the whole catalog. Read by levels: contexts, then the rule lists of the contexts involved, then only the relevant rules.
|
|
20
|
+
- In plan mode, edit nothing and run no command that writes.
|
|
21
|
+
- In apply mode, write only what the approved plan says. If the plan is ambiguous or no longer matches the rulebook, stop and ask instead of guessing.
|
|
22
|
+
- Never delete rule files, commit or push.
|
|
23
|
+
|
|
24
|
+
## Plan mode
|
|
25
|
+
|
|
26
|
+
1. **Understand the feature.** Restate it in one or two sentences of business language. Go through every effect a person could observe: results and exit status, messages and printed output, machine-readable output, stored or written data. If the request does not say what happens to one of them, or in a case the feature obviously raises, record that as an open question; do not invent the answer.
|
|
27
|
+
2. **Find the contexts.** `contexts` (L0) for the map. When the feature names files or code areas, `rules_for` on them shows the active rules that already govern them.
|
|
28
|
+
3. **Read the rules involved.** `rules_list` (L1) for each affected context, then `rules_show` (L2) only for the rules whose summary touches the feature. Run `rules_search` once per key word of the feature, and once per obvious synonym (it matches text, needs every word, and knows no synonyms: "exit code" will not find "exit status"), to catch rules in other contexts. Search results are summaries; open only those that touch the feature.
|
|
29
|
+
4. **Decide, rule by rule:**
|
|
30
|
+
- a new behavior: a new rule;
|
|
31
|
+
- an existing rule whose statement or examples change, or a proposed rule the feature contradicts: a change to that rule, shown as a diff. Keep its `origin` and `evidence`; a "Questions for the business" item the feature answers is removed in the diff;
|
|
32
|
+
- an existing rule the feature replaces: a deprecation, with the new rule naming it in `supersedes`;
|
|
33
|
+
- an active rule the feature would contradict and the request does not ask to change: a conflict. Do not resolve it; describe it.
|
|
34
|
+
|
|
35
|
+
A rule you touch that already breaks the writing guide (for example two behaviors in one rule) is an open question, not something to fix in passing.
|
|
36
|
+
|
|
37
|
+
5. **Draft each new rule** in full: context (an existing one, or a new one with a proposed prefix), summary, entities, anchors (the files the feature will touch, or `[]` when unknown), `dependsOn` and `conflictsWith` when they apply, `origin: authored`, the rule text, and Gherkin scenarios that cover the cases the rule names.
|
|
38
|
+
6. **Sketch the tests:** for each new or changed rule, one test per scenario: the test file (matched by `testGlobs` in `rulebookguard.config.yaml`), the test name, and the `@rule <ID>` and `@endrule <ID>` comment lines around it. Use `NEW-1`, `NEW-2`… for rules that have no id yet. Give names and structure, not test code.
|
|
39
|
+
|
|
40
|
+
End plan mode with the plan below and nothing after it:
|
|
41
|
+
|
|
42
|
+
1. **Feature:** the restatement.
|
|
43
|
+
2. **Rules read:** the ids you read in full.
|
|
44
|
+
3. **New rules:** each as `NEW-n` with its complete draft.
|
|
45
|
+
4. **Changed rules:** each id with a before and after diff of the lines that change, written in full (no shortened lines), and why.
|
|
46
|
+
5. **Deprecated rules:** each id and what replaces it.
|
|
47
|
+
6. **Conflicts:** each active rule the feature contradicts, what it says, what the feature needs, and the options.
|
|
48
|
+
7. **Test skeletons:** per rule.
|
|
49
|
+
8. **Open questions:** what a person must decide before approving.
|
|
50
|
+
9. **Waiting for approval.** Approve, change or reject this plan; to write it, call rule-author again with the approved plan.
|
|
51
|
+
|
|
52
|
+
## Apply mode
|
|
53
|
+
|
|
54
|
+
1. Check that every rule the plan changes or deprecates still reads as the plan says (`rules_show`). If one changed since, stop and report it. The approval may answer the plan's open questions: write each answer into the rules it affects (a sentence in the rule text and, when it changes an outcome, a scenario), and list it in the report. An open question left unanswered stops you.
|
|
55
|
+
2. For each `NEW-n`, run `rulebookguard.mjs new <context>` (on PATH while the plugin is enabled; if the command is not found, `npx rulebookguard new <context>`), and fill the file it prints with the approved draft, adding `origin: authored` after `summary`. Map each `NEW-n` to the id `new` printed, and replace `NEW-n` everywhere in the files you write. If `new` created a context, fill its `_context.md`.
|
|
56
|
+
3. Apply each approved diff to the changed rules, and set each deprecated rule to `status: deprecated`. Change no other line.
|
|
57
|
+
4. Run the `lint` tool. Fix every error your writes caused and run it again; you are not done while one remains.
|
|
58
|
+
5. Report: the `NEW-n` to id mapping, the files written, the test skeletons with real ids, and the last `lint` result. The next step is writing those tests, then the code.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rule-miner
|
|
3
|
+
description: Lifts the business rules that existing code already enforces into proposed rulebookguard rules, one context at a time, each with evidence and questions for the business. Use when adopting rulebookguard in an existing project, or when asked to extract, mine or document the rules of a module.
|
|
4
|
+
tools: Read, Grep, Glob, Bash, Edit, Write, mcp__plugin_rulebookguard_rulebookguard__contexts, mcp__plugin_rulebookguard_rulebookguard__rules_list, mcp__plugin_rulebookguard_rulebookguard__rules_search, mcp__plugin_rulebookguard_rulebookguard__rules_show, mcp__plugin_rulebookguard_rulebookguard__rules_for, mcp__plugin_rulebookguard_rulebookguard__lint
|
|
5
|
+
skills:
|
|
6
|
+
- rulebookguard:rule-writing
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You lift the business rules that existing code already enforces into the rulebook of this repository. The code says what it does, not what it should do: you propose, a person decides. Write every rule as the preloaded `rulebookguard:rule-writing` skill says.
|
|
10
|
+
|
|
11
|
+
## Hard limits
|
|
12
|
+
|
|
13
|
+
- One context per run. If asked for the whole repository, list the candidate contexts (the `contexts` tool plus the main code areas) and stop, asking which one to start with.
|
|
14
|
+
- Every rule you write has `status: proposed` and `origin: extracted`. Never set `active`.
|
|
15
|
+
- Create rule files only with `rulebookguard.mjs new <context>` (on PATH while the plugin is enabled; if the command is not found, `npx rulebookguard new <context>`). Never write a rule file by hand or pick an id yourself.
|
|
16
|
+
- No evidence, no rule: each rule names at least one place in the code that enforces it.
|
|
17
|
+
- Never edit an existing rule, production code, test logic, the config, or files outside the rulebook and the test tags. Add `@implements` markers only when the request asks for them (step 10). Never delete files, commit or push.
|
|
18
|
+
|
|
19
|
+
## Steps
|
|
20
|
+
|
|
21
|
+
1. **Scope.** Take the context from the request. Read `rulebookguard.config.yaml` (`rulesDir`, `testGlobs`, and `contextBudget`, 30 when unset). Find the code area: the one the user names, or the anchors of the context's existing rules. If neither tells you, ask. A check the area only passes through from another module belongs to the context that owns that module: do not write a rule for it here, and suggest that context. Run the `lint` tool once now and keep its findings as the baseline.
|
|
22
|
+
2. **Read the rulebook by levels.** `contexts`, then `rules_list` for the context, then `rules_show` for the rules close to the area. Never read the whole catalog.
|
|
23
|
+
3. **Trace every flow to the bottom.** List the entry points of the area (handlers, routes, commands, jobs, event consumers, public use cases). From each one, follow every call the business outcome depends on, through services, helpers, validators, guards, mappers and repositories, down to persistence and external calls: database constraints and defaults in schemas or migrations, enums and state machines, shared constants, configuration values the code reads. Do not stop at the first function that looks like the answer: a rule is often completed by a check two or three calls deeper. Stop only where the code leaves the context or is pure technical plumbing. A check owned by another module counts toward that module's context: read it to understand the flow, but do not write it here (step 1). Keep the list of entry points and how deep each one went for the report.
|
|
24
|
+
4. **Mine.** Read the code you traced and its tests. Look for decisions the business would recognize: validations, limits, states and transitions, calculations, permissions, deadlines. Use `git log` on the files for intent (commit messages, ticket keys); treat it as a hint, not proof.
|
|
25
|
+
5. **Sort each candidate** into one of the kinds below. One rule per outcome the business would tell apart: two failures with different consequences are two rules; two causes of the same outcome are one.
|
|
26
|
+
- a business rule, which you write;
|
|
27
|
+
- a technical detail (retries, caching, formats, framework behavior), which you skip;
|
|
28
|
+
- a doubt (it may be a bug or a technical value), which you write with the doubt as a question.
|
|
29
|
+
6. **Deduplicate.** For each candidate run `rules_search` once per key word (it needs every word to match, so one word at a time finds more), and compare with the context's `rules_list`. If a rule already states it, do not write it; report "covered by R-…". If a rule states something different from what the code does, do not touch it; report the difference.
|
|
30
|
+
7. **Stay within budget.** Write at most `contextBudget` minus the rules the context already has. Report the rest as pending, with a suggested split of the context.
|
|
31
|
+
8. **Write.** Run `new` for each rule and fill the file it prints: a `summary` in business language, `entities`, narrow `anchors`, the rule text, at least one Gherkin scenario taken from the code or its tests, and a "Questions for the business" list. The template has no `origin` or `evidence`: add `origin: extracted` and an `evidence` list to the frontmatter, after `summary`, with every place in code and tests as `path symbol` (for example `src/clients/create.ts validateDocument`), never with line numbers. Ask only where a value or branch might be technical or a bug rather than intended, for example "Is the limit of 3 attempts a business rule or a technical value?"; at most three questions per rule, and none when the code and its tests leave no doubt. If `new` created the context, fill its `_context.md` summary and body too.
|
|
32
|
+
9. **Tag existing tests.** When a test in a file matched by `testGlobs` already verifies a rule, add a `@rule <ID>` comment on its own line above the test and an `@endrule <ID>` comment on its own line after it, in that file's comment syntax (a marker after code on the same line does not count). Change nothing else in the test. If you are not sure a test covers the rule, or the test file is not matched by `testGlobs`, list it as a suggestion instead; never edit `testGlobs` or the config.
|
|
33
|
+
10. **Suggest regions, mark only on request.** The `evidence` entries already link each rule to its code: `rulebookguard show` resolves them to lines. Do not edit production code by default; for each rule, find the lines that enforce it and list them in the report as suggested regions. Only when the request asks you to mark the code, wrap the code that enforces each rule you wrote between an `@implements <ID>` comment on its own line and an `@endimplements <ID>` comment on its own line, in that file's comment syntax and in a file the rule anchors (widen the rule's `anchors` if needed). Add only those two lines: never change the code itself.
|
|
34
|
+
11. **Lint.** Run the `lint` tool. Fix every error that is not in the baseline and run it again. You are not done while one remains. Report the baseline errors, without fixing them.
|
|
35
|
+
|
|
36
|
+
## Report
|
|
37
|
+
|
|
38
|
+
End with this report and nothing after it:
|
|
39
|
+
|
|
40
|
+
1. **Flows traced:** one line per entry point: `path symbol`, the deepest place reached (`path symbol`), and where and why the trace stopped (left the context, technical plumbing). Then the entry points you did not trace and why.
|
|
41
|
+
2. **Rules proposed:** one line per rule: id, summary, evidence, regions as `path:line-endLine` (suggested, or "marked" when you added the `@implements` markers), tagged tests (or "none"). Then the suggested tags you did not apply, each with its test `path:line` and the reason.
|
|
42
|
+
3. **Skipped:** each candidate you did not write and why (technical detail, covered by R-…, no evidence, over budget).
|
|
43
|
+
4. **Differences with existing rules:** rule id, what it says, what the code does, with `path:line`.
|
|
44
|
+
5. **Questions for the business:** grouped by rule id.
|
|
45
|
+
6. **Lint:** result of the last run.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
|
+
"name": "rulebookguard",
|
|
4
|
+
"version": "0.13.0",
|
|
5
|
+
"description": "Business rules as the source of truth: follow, look up and write the rules anchored to your code.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Juan Carlos Jimenez"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/JuanCJR/rulebookguard#kiro",
|
|
10
|
+
"repository": "https://github.com/JuanCJR/rulebookguard",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"keywords": ["business rules", "business rule", "rulebook", "rulebookguard"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rule-writing
|
|
3
|
+
description: How to write a rulebookguard business rule so every rule reads the same, whoever writes it. Use when writing, extracting, proposing or reviewing a rule file, or turning a feature, ticket or existing code into rules.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Writing a business rule (rulebookguard)
|
|
7
|
+
|
|
8
|
+
Follow these rules for every rule file, whether a person or an agent writes it, and whether it comes from a feature or from existing code.
|
|
9
|
+
|
|
10
|
+
## One rule, one behavior
|
|
11
|
+
|
|
12
|
+
- A rule states one business behavior. If its text needs "and also", split it into two rules.
|
|
13
|
+
- Write it in the language of the business: what a client, an invoice or an order may or may not do. No class names, functions, tables or HTTP codes in the summary or the rule statement.
|
|
14
|
+
- It must be verifiable: a person can write a test that passes when the rule holds and fails when it does not. "Fast", "secure" or "user friendly" are not rules.
|
|
15
|
+
|
|
16
|
+
## The fields
|
|
17
|
+
|
|
18
|
+
- `summary`: one line, a full sentence, stating the behavior itself ("A client document number is unique per country."), not its topic ("Document numbers").
|
|
19
|
+
- `entities`: the domain nouns the rule talks about, in singular (`Client`, `Invoice`).
|
|
20
|
+
- `anchors`: globs of the files that implement the rule. Point at the narrowest files that decide the behavior, not whole directories by default.
|
|
21
|
+
- `status`: always `proposed`. Only a person sets `active`, after confirming the rule and tagging a test.
|
|
22
|
+
- `supersedes`: the id of the rule this one replaces; set the old one to `deprecated`.
|
|
23
|
+
- `dependsOn`: rules that must hold for this one to make sense. `conflictsWith`: rules that cannot be active together with this one.
|
|
24
|
+
- `origin`: `extracted` when the rule was lifted from existing code, `authored` when written from a feature or a decision.
|
|
25
|
+
- `evidence`: for extracted rules, every place in code or tests where the behavior shows, as `path symbol` (the function, method or constant), never `path:line`: line numbers go stale when the code moves. Write a test name as a quoted string (`test/clients/create.test.ts "rejects a duplicate document"`) and a method as `Class.method`. `rulebookguard show` resolves each entry to the lines it names, and `lint` warns with `dead-evidence` when the file or the symbol is gone. Optional `@implements` regions mark exact lines when a symbol is too coarse. No evidence, no extracted rule.
|
|
26
|
+
|
|
27
|
+
## The body
|
|
28
|
+
|
|
29
|
+
1. A `#` title of a few words.
|
|
30
|
+
2. The full rule in plain sentences: the behavior, its limits and its exceptions.
|
|
31
|
+
3. At least one example as a ` ```gherkin ` scenario (`Given` / `When` / `Then`) with concrete values, or a table. Cover the edge the rule names (the limit, the duplicate, the expired case).
|
|
32
|
+
4. For an extracted rule, a final "Questions for the business" list: what the code does that might be a bug or a technical detail rather than intent, for example "Is the limit of 3 attempts a business rule or a technical value?".
|
|
33
|
+
|
|
34
|
+
## Before you finish
|
|
35
|
+
|
|
36
|
+
- Search existing rules first (`rules_search`, or `rulebookguard search <words>`); change or supersede a rule instead of writing a duplicate.
|
|
37
|
+
- Create files only with `rulebookguard new <context>`, then fill them in.
|
|
38
|
+
- Run `rulebookguard lint` (or the `lint` tool). If it reports an error, the rule is not done.
|