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.
@@ -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 { loadRule as A, FormatError as B, runGit as C, findRuleRegions as D, RULE_TAG_PATTERN as E, splitFrontmatter as F, CONFIG_FILE_NAME as I, loadConfig as L, CONTEXT_FILE_NAME as M, loadContext as N, findRuleTags as O, parseContext as P, parseConfig as R, toRepoPath as S, RULE_MARKER_PATTERN as T, formatIssue as V, loadCatalog as _, pendingImplementation as a, expandGlobs as b, gitFallbackWarning as c, writeJson as d, writeLines as f, findRulebookRoot as g, scanTestTags as h, lintCommand as i, parseRule as j, isRuleId as k, openCatalog as l, scanTestRegions as m, formatFinding as n, CliError as o, scanImplementations as p, lintCatalog as r, findRule as s, changedFiles as t, shownPath as u, relativeToRoot as v, IMPLEMENTS_MARKER_PATTERN as w, listRepoFiles as x, createGlobMatcher as y, messages as z };
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, S as toRepoPath, b as expandGlobs, c as gitFallbackWarning, f as writeLines, l as openCatalog, m as scanTestRegions, o as CliError, r as lintCatalog } from "./lint-BPdk6XxH.mjs";
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((item) => `\`${item}\``), text.none),
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
- for (const directory of from) {
445
- let entry;
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.11.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": "0.11.0",
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.11.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.