rulebookguard 0.5.1 → 0.6.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 CHANGED
@@ -41,7 +41,7 @@ language: es # default: en; language of the text that rulebookguard generates
41
41
 
42
42
  `schema` and `testGlobs` are required (`testGlobs: []` is valid but finds no tags). Unknown fields are rejected.
43
43
 
44
- `language` (`en` or `es`) sets the language of the text rulebookguard writes for you, such as the template of `new`. Rule content is free text and is never checked for language, so rules can be written in any language whatever this setting says. `language` needs rulebookguard 0.1.1 or later; older versions reject it as an unknown field. The JSON Schemas ship with the package under `rulebookguard/schemas/v1/`.
44
+ `language` (`en` or `es`) sets the language of the text rulebookguard writes for you: the template of `new`, the hook context, the site, and every message printed once the config is loaded (lint findings, errors, warnings and headers, also the `message` of `--json` output). Lint codes, JSON keys and values such as `active` stay in English, and so do `--help`, argument errors and the findings of an invalid config file, which come before the config is read. Rule content is free text and is never checked for language, so rules can be written in any language whatever this setting says. `language` needs rulebookguard 0.1.1 or later; older versions reject it as an unknown field. The JSON Schemas ship with the package under `rulebookguard/schemas/v1/`.
45
45
 
46
46
  Inside a git work tree, files are listed with `git ls-files --cached --others --exclude-standard`, so anything in `.gitignore` (`dist/`, `coverage/`, caches) is never matched by anchors or test globs; files inside submodules are listed too. A submodule path that is a symlink, or that resolves outside the rulebook root, is skipped. A test file that is a symlink is scanned for `@rule` tags only when its target lies inside the rulebook root. If one submodule cannot be listed, commands print `warning: could not list files of submodule "<path>" (<reason>); skipping it.` and keep the rest of the git listing. Outside git, or when the rulebook root itself is git-ignored, the whole tree is walked, skipping only `.git` and `node_modules`. If git fails (for example, git is missing from `PATH` or the repository has dubious ownership), commands print one warning on stderr and walk the tree instead.
47
47
 
@@ -184,7 +184,7 @@ $ rulebookguard list --context clients --status active
184
184
  R-CLI-001 [active] A client document number is unique per country.
185
185
  ```
186
186
 
187
- Exits 1 for an unknown context and 2 for an invalid status.
187
+ Prints `No rules found.` when no rule matches, and `contexts` says so when the catalog has no context yet. Exits 1 for an unknown context and 2 for an invalid status.
188
188
 
189
189
  ### `show <id>` (L2)
190
190
 
@@ -194,7 +194,9 @@ R-CLI-001: A client document number is unique per country.
194
194
  context: clients
195
195
  status: active
196
196
  entities: Client
197
- anchors: src/clients/**/*.ts, src/api/clients.controller.ts
197
+ anchors:
198
+ src/clients/**/*.ts
199
+ src/api/clients.controller.ts
198
200
  supersedes: R-CLI-000
199
201
  file: docs/rules/clients/R-CLI-001.md
200
202
 
@@ -231,7 +233,7 @@ Tests tagging R-CLI-001 (1):
231
233
 
232
234
  Exits 1 for an unknown id.
233
235
 
234
- ### `new <context>`
236
+ ### `new <context> [--prefix <P>]`
235
237
 
236
238
  Scaffolds the next free id of a context as a `proposed` rule and prints its path. Ids are never reused, and an existing file is never overwritten. The placeholder text of the new rule follows `language` in the config.
237
239
 
@@ -240,7 +242,15 @@ $ rulebookguard new clients
240
242
  docs/rules/clients/R-CLI-004.md
241
243
  ```
242
244
 
243
- Exits 1 for an unknown context or when the context has no free id left.
245
+ When the context does not exist yet, `new` creates its `_context.md` first and prints both paths. The prefix defaults to the first three letters and digits of the name, upper-cased; `--prefix` sets another one.
246
+
247
+ ```sh
248
+ $ rulebookguard new inventory
249
+ docs/rules/inventory/_context.md
250
+ docs/rules/inventory/R-INV-001.md
251
+ ```
252
+
253
+ Exits 1, writing nothing, when the context has no free id left, when the name is not lowercase letters, digits, `-` or `_`, when the prefix is invalid or already used by another context, or when `--prefix` differs from the prefix of an existing context.
244
254
 
245
255
  ### `hook`
246
256
 
@@ -308,7 +318,7 @@ Generated text and the interface follow `language` in the config. The site is a
308
318
 
309
319
  ### `lint [--base <ref>] [--json]`
310
320
 
311
- Checks the whole catalog. Each finding is printed as `<severity> <code> <file>[:line] <message>`, followed by a count.
321
+ Checks the whole catalog. Each finding is printed as `<severity> <code> <file>[:line] <message>`, with a `hint:` line under it that says how to fix it, followed by a count.
312
322
 
313
323
  | Severity | Code | Meaning |
314
324
  | -------- | ------------------------ | ---------------------------------------------------------------- |
@@ -329,7 +339,9 @@ Checks the whole catalog. Each finding is printed as `<severity> <code> <file>[:
329
339
  ```sh
330
340
  $ rulebookguard lint
331
341
  error untested-rule docs/rules/billing/R-BIL-002.md active rule R-BIL-002 has no test tagging it
342
+ hint: add "@rule R-BIL-002" to a test in a file matched by testGlobs, or set the rule's status to proposed
332
343
  warning context-over-budget docs/rules/clients/_context.md context "clients" has 31 rules, more than the contextBudget of 30
344
+ hint: split the context into smaller ones, or raise contextBudget in rulebookguard.config.yaml
333
345
  1 error, 1 warning
334
346
  ```
335
347
 
@@ -340,10 +352,11 @@ With `--base <ref>`, lint also compares `<ref>` with the working tree (`git diff
340
352
  ```sh
341
353
  $ rulebookguard lint --base origin/main
342
354
  error pending-implementation docs/rules/clients/R-CLI-001.md rule R-CLI-001 changed since origin/main but none of the tests tagging it changed
355
+ hint: update or add a test tagging R-CLI-001 that covers the changed rule
343
356
  1 error, 0 warnings
344
357
  ```
345
358
 
346
- `--json` prints `{ "findings": [{ "severity", "code", "file", "line"?, "message" }], "errors", "warnings" }`.
359
+ `--json` prints `{ "findings": [{ "severity", "code", "file", "line"?, "message", "hint" }], "errors", "warnings" }`.
347
360
 
348
361
  ## CI recipe
349
362
 
@@ -1,4 +1,4 @@
1
- import { h as createGlobMatcher, v as toRepoPath } from "./lint-C1yUeLOq.mjs";
1
+ import { h as createGlobMatcher, v as toRepoPath } from "./lint-D63Q2SM0.mjs";
2
2
  // src/catalog/anchors.ts
3
3
  function rulesForPath(rules, path) {
4
4
  const target = toRepoPath(path);
package/dist/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { M as FormatError, N as formatIssue, a as CliError, c as openCatalog, d as scanTestTags, f as findRulebookRoot, g as expandGlobs, l as writeJson, o as findRule, p as loadCatalog, r as lintCommand, s as gitFallbackWarning, u as writeLines, v as toRepoPath } from "./lint-C1yUeLOq.mjs";
3
- import { t as rulesForPath } from "./anchors-BFJCm1NU.mjs";
4
- import { readdir, writeFile } from "node:fs/promises";
2
+ import { M as messages, N as FormatError, P as formatIssue, T as CONTEXT_FILE_NAME, a as CliError, c as openCatalog, d as scanTestTags, f as findRulebookRoot, g as expandGlobs, l as writeJson, o as findRule, p as loadCatalog, r as lintCommand, s as gitFallbackWarning, u as writeLines, v as toRepoPath } from "./lint-D63Q2SM0.mjs";
3
+ import { t as rulesForPath } from "./anchors-e6T9BNoV.mjs";
4
+ import { mkdir, 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";
@@ -80,7 +80,7 @@ function render(path, rules, text) {
80
80
  return parts.join("\n\n");
81
81
  }
82
82
  // package.json
83
- var version = "0.5.1";
83
+ var version = "0.6.0";
84
84
  // src/mcp/server.ts
85
85
  function createRulesServer(projectDir) {
86
86
  const server = new McpServer({
@@ -136,24 +136,71 @@ async function mcpCommand(io, project) {
136
136
  }
137
137
  // src/commands/new.ts
138
138
  const MAX_NUMBER = 999;
139
- async function newCommand(catalog, io, name) {
139
+ const CONTEXT_NAME = /^[a-z0-9][a-z0-9_-]*$/;
140
+ const PREFIX = /^[A-Z][A-Z0-9]*$/;
141
+ async function newCommand(catalog, io, name, prefix) {
142
+ const text = messages(catalog.config.language);
140
143
  const context = catalog.contexts.find((item) => item.context === name);
141
- if (context === void 0) throw new CliError(`Unknown context "${name}". Run "rulebookguard contexts" to list them.`);
144
+ if (context !== void 0) {
145
+ if (prefix !== void 0 && prefix !== context.prefix) throw new CliError(text.prefixMismatch(name, context.prefix, prefix));
146
+ writeLines(io, [await writeNextRule(catalog, context, text)]);
147
+ return;
148
+ }
149
+ const created = await createContext(catalog, name, prefix, text);
150
+ try {
151
+ writeLines(io, [created.file, await writeNextRule(catalog, created, text)]);
152
+ } catch (error) {
153
+ await rm(join(catalog.root, created.file), { force: true });
154
+ throw error;
155
+ }
156
+ }
157
+ async function createContext(catalog, name, option, text) {
158
+ if (!CONTEXT_NAME.test(name)) throw new CliError(text.invalidContextName(name));
159
+ const prefix = option ?? name.replace(/[^a-z0-9]/g, "").slice(0, 3).toUpperCase();
160
+ if (!PREFIX.test(prefix)) throw new CliError(text.invalidPrefix(prefix));
161
+ const owner = catalog.contexts.find((item) => item.prefix === prefix);
162
+ if (owner !== void 0) throw new CliError(text.prefixTaken(prefix, owner.context));
163
+ const directory = `${toRepoPath(catalog.config.rulesDir).replace(/\/+$/, "")}/${name}`;
164
+ const file = `${directory}/${CONTEXT_FILE_NAME}`;
165
+ await mkdir(join(catalog.root, directory), { recursive: true });
166
+ await writeNew(catalog, file, contextTemplate(name, prefix, text), text);
167
+ return {
168
+ file,
169
+ context: name,
170
+ prefix,
171
+ summary: "",
172
+ body: ""
173
+ };
174
+ }
175
+ async function writeNextRule(catalog, context, text) {
142
176
  const directory = context.file.slice(0, context.file.lastIndexOf("/"));
143
177
  const idPattern = new RegExp(`^R-${context.prefix}-([0-9]{3})$`);
144
178
  const fileNames = (await readdir(join(catalog.root, directory))).filter((file) => file.endsWith(".md")).map((file) => file.slice(0, -3));
145
179
  const taken = [...catalog.rules.map((rule) => rule.id), ...fileNames].map((id) => idPattern.exec(id)?.[1]).filter((digits) => digits !== void 0).map(Number);
146
180
  const last = Math.max(0, ...taken);
147
- if (last >= MAX_NUMBER) throw new CliError(`No free rule id left in context "${name}" (R-${context.prefix}-${MAX_NUMBER} is taken).`);
181
+ if (last >= MAX_NUMBER) throw new CliError(text.noFreeId(context.context, `R-${context.prefix}-${MAX_NUMBER}`));
148
182
  const id = `R-${context.prefix}-${String(last + 1).padStart(3, "0")}`;
149
183
  const file = `${directory}/${id}.md`;
184
+ await writeNew(catalog, file, ruleTemplate(id, context.context, catalog.config.language ?? "en"), text);
185
+ return file;
186
+ }
187
+ async function writeNew(catalog, file, content, text) {
150
188
  try {
151
- await writeFile(join(catalog.root, file), template(id, context.context, catalog.config.language ?? "en"), { flag: "wx" });
189
+ await writeFile(join(catalog.root, file), content, { flag: "wx" });
152
190
  } catch (error) {
153
- if (error.code === "EEXIST") throw new CliError(`${file} already exists; nothing was written.`);
191
+ if (error.code === "EEXIST") throw new CliError(text.alreadyExists(file));
154
192
  throw error;
155
193
  }
156
- writeLines(io, [file]);
194
+ }
195
+ function contextTemplate(name, prefix, text) {
196
+ return `---
197
+ context: ${name}
198
+ prefix: ${prefix}
199
+ summary: '${text.contextTemplateSummary}'
200
+ ---
201
+
202
+ ${text.contextTemplateBody}
203
+ `;
157
204
  }
158
205
  const TEMPLATE_TEXT = {
159
206
  en: {
@@ -176,7 +223,7 @@ Escenario: TODO
176
223
  Entonces TODO`
177
224
  }
178
225
  };
179
- function template(id, context, language) {
226
+ function ruleTemplate(id, context, language) {
180
227
  const text = TEMPLATE_TEXT[language];
181
228
  return `---
182
229
  id: ${id}
@@ -214,17 +261,26 @@ function contextsCommand(catalog, io, json) {
214
261
  writeJson(io, { contexts });
215
262
  return;
216
263
  }
217
- writeLines(io, contexts.map((item) => `${item.context} (${item.prefix}, ${item.rules} ${item.rules === 1 ? "rule" : "rules"}): ${item.summary}`));
264
+ if (contexts.length === 0) {
265
+ writeLines(io, [messages(catalog.config.language).noContexts]);
266
+ return;
267
+ }
268
+ writeLines(io, contexts.map((item) => `${item.context} (${item.prefix}, ${messages(catalog.config.language).ruleCount(item.rules)}): ${item.summary}`));
218
269
  }
219
270
  function listCommand(catalog, io, options) {
220
271
  const { context, status } = options;
221
- if (status !== void 0 && !RULE_STATUSES.includes(status)) throw new CliError(`Invalid status "${status}": expected one of ${RULE_STATUSES.join(", ")}.`, 2);
222
- if (context !== void 0 && !catalog.contexts.some((item) => item.context === context)) throw new CliError(`Unknown context "${context}". Run "rulebookguard contexts" to list them.`);
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));
223
275
  const rules = sortById(catalog.rules).filter((rule) => context === void 0 || rule.context === context).filter((rule) => status === void 0 || rule.status === status).map(toSummary);
224
276
  if (options.json) {
225
277
  writeJson(io, { rules });
226
278
  return;
227
279
  }
280
+ if (rules.length === 0) {
281
+ writeLines(io, [text.noRules]);
282
+ return;
283
+ }
228
284
  writeLines(io, rules.map((rule) => `${rule.id} [${rule.status}] ${rule.summary}`));
229
285
  }
230
286
  function showCommand(catalog, io, id, json) {
@@ -240,19 +296,22 @@ function showCommand(catalog, io, id, json) {
240
296
  } });
241
297
  return;
242
298
  }
299
+ const none = messages(catalog.config.language).none;
243
300
  const header = [
244
301
  `${rule.id}: ${rule.summary}`,
245
302
  `context: ${rule.context}`,
246
303
  `status: ${rule.status}`,
247
- `entities: ${rule.entities.join(", ")}`,
248
- `anchors: ${rule.anchors.join(", ")}`,
304
+ `entities: ${rule.entities.length === 0 ? none : rule.entities.join(", ")}`,
305
+ "anchors:",
306
+ ...indent(rule.anchors, none),
249
307
  ...rule.supersedes === void 0 ? [] : [`supersedes: ${rule.supersedes}`],
250
308
  `file: ${rule.file}`
251
309
  ];
252
310
  io.stdout(`${header.join("\n")}\n\n${rule.body}`);
253
311
  }
254
312
  function forCommand(catalog, io, input, json) {
255
- const path = toRootPath(catalog.root, resolve(io.cwd, input), input);
313
+ const text = messages(catalog.config.language);
314
+ const path = toRootPath(catalog, resolve(io.cwd, input), input);
256
315
  const rules = rulesForPath(catalog.rules, path).filter((rule) => rule.status === "active").map((rule) => ({
257
316
  ...toSummary(rule),
258
317
  file: rule.file
@@ -265,14 +324,15 @@ function forCommand(catalog, io, input, json) {
265
324
  return;
266
325
  }
267
326
  if (rules.length === 0) {
268
- writeLines(io, [`No active rules anchored to ${path}.`]);
327
+ writeLines(io, [text.noActiveRulesFor(path)]);
269
328
  return;
270
329
  }
271
330
  writeLines(io, rules.map((rule) => `${rule.id} (${rule.context}): ${rule.summary}`));
272
331
  }
273
332
  async function impactCommand(catalog, io, id, json) {
274
333
  const rule = findRule(catalog, id);
275
- const warn = gitFallbackWarning(io);
334
+ const text = messages(catalog.config.language);
335
+ const warn = gitFallbackWarning(io, catalog.config.language);
276
336
  const files = await expandGlobs(catalog.root, rule.anchors, warn);
277
337
  const tests = (await scanTestTags(catalog.root, catalog.config.testGlobs, warn)).get(id) ?? [];
278
338
  if (json) {
@@ -285,15 +345,15 @@ async function impactCommand(catalog, io, id, json) {
285
345
  }
286
346
  writeLines(io, [
287
347
  `${rule.id}: ${rule.summary}`,
288
- `Files matched by anchors (${files.length}):`,
289
- ...indent(files),
290
- `Tests tagging ${rule.id} (${tests.length}):`,
291
- ...indent(tests.map((test) => `${test.file}:${test.line}`))
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)
292
352
  ]);
293
353
  }
294
- function toRootPath(root, absolute, input) {
295
- const path = toRepoPath(relative(root, absolute));
296
- if (path === "" || path === ".." || path.startsWith("../") || isAbsolute(path)) throw new CliError(`Path "${input}" is outside the rulebook root ${root}.`);
354
+ function toRootPath(catalog, absolute, input) {
355
+ const path = toRepoPath(relative(catalog.root, absolute));
356
+ if (path === "" || path === ".." || path.startsWith("../") || isAbsolute(path)) throw new CliError(messages(catalog.config.language).outsideRoot(input, catalog.root));
297
357
  return path;
298
358
  }
299
359
  function toSummary(rule) {
@@ -304,8 +364,8 @@ function toSummary(rule) {
304
364
  summary: rule.summary
305
365
  };
306
366
  }
307
- function indent(lines) {
308
- return lines.length === 0 ? [" (none)"] : lines.map((line) => ` ${line}`);
367
+ function indent(lines, none) {
368
+ return lines.length === 0 ? [` ${none}`] : lines.map((line) => ` ${line}`);
309
369
  }
310
370
  function sortById(rules) {
311
371
  return [...rules].sort((a, b) => compare(a.id, b.id));
@@ -322,11 +382,14 @@ Commands:
322
382
  show <id> [--json] L2: the full rule
323
383
  for <path> [--json] active rules anchored to a path
324
384
  impact <id> [--json] files matched by a rule's anchors and tests tagging it
325
- new <context> scaffold the next free rule id as a proposed rule
385
+ new <context> [--prefix <P>] scaffold the next free rule id as a proposed rule;
386
+ creates the context first when it does not exist,
387
+ --prefix: its id prefix (default: first 3 letters)
326
388
  lint [--base <ref>] [--json] check the catalog, anchors and test tags;
327
389
  --base: rules changed since <ref> need a changed test
328
- hook Claude Code PreToolUse hook: reads the event on stdin
329
- and prints the rules anchored to the edited file
390
+ hook Claude Code hook for PreToolUse and PostToolUse: reads
391
+ the event on stdin and prints the rules anchored to
392
+ the file of the tool (Read, Edit, Write...)
330
393
  mcp [--project <dir>] MCP server over stdio with rules_for and rules_show;
331
394
  --project: directory to find the rulebook from (default: cwd)
332
395
  site:generate [--out <dir>] [--base <path>] build the read-only static site of the rulebook (alias: site);
@@ -372,9 +435,9 @@ const COMMANDS = {
372
435
  options: JSON_OPTION
373
436
  },
374
437
  new: {
375
- usage: "rulebookguard new <context>",
438
+ usage: "rulebookguard new <context> [--prefix <P>]",
376
439
  argument: "context",
377
- options: {}
440
+ options: { prefix: { type: "string" } }
378
441
  },
379
442
  hook: {
380
443
  usage: "rulebookguard hook",
@@ -416,7 +479,7 @@ async function runCli(argv, io) {
416
479
  return error.exitCode;
417
480
  }
418
481
  if (error instanceof FormatError) {
419
- io.stderr(`${error.issues.map(formatIssue).join("\n")}\n`);
482
+ io.stderr(`${error.issues.map((issue) => formatIssue(issue)).join("\n")}\n`);
420
483
  return 1;
421
484
  }
422
485
  throw error;
@@ -441,7 +504,7 @@ async function dispatch(argv, io) {
441
504
  return 0;
442
505
  }
443
506
  if (name === "site" || name === "site:generate" || name === "site:run") {
444
- const { siteCommand } = await import("./site-BiiOPVEe.mjs");
507
+ const { siteCommand } = await import("./site-DEdtLk6J.mjs");
445
508
  await siteCommand(io, {
446
509
  out: values.out,
447
510
  base: values.base,
@@ -475,7 +538,7 @@ async function dispatch(argv, io) {
475
538
  case "impact":
476
539
  await impactCommand(catalog, io, argument, json);
477
540
  break;
478
- case "new": await newCommand(catalog, io, argument);
541
+ case "new": await newCommand(catalog, io, argument, values.prefix);
479
542
  }
480
543
  return 0;
481
544
  }
package/dist/index.d.mts CHANGED
@@ -1,5 +1,9 @@
1
1
  type RuleStatus = "proposed" | "active" | "deprecated";
2
2
  type RuleLanguage = "en" | "es";
3
+ interface ParseOptions {
4
+ /** Language of the issue messages; `en` when unset. */
5
+ language?: RuleLanguage | undefined;
6
+ }
3
7
  interface RulebookConfig {
4
8
  schema: 1;
5
9
  rulesDir: string;
@@ -34,8 +38,8 @@ export declare const CONFIG_FILE_NAME = "rulebookguard.config.yaml";
34
38
  export declare function parseConfig(source: string, file: string): RulebookConfig;
35
39
  export declare function loadConfig(path: string): Promise<RulebookConfig>;
36
40
  export declare const CONTEXT_FILE_NAME = "_context.md";
37
- export declare function parseContext(source: string, file: string): ContextDoc;
38
- export declare function loadContext(path: string): Promise<ContextDoc>;
41
+ export declare function parseContext(source: string, file: string, { language }?: ParseOptions): ContextDoc;
42
+ export declare function loadContext(path: string, options?: ParseOptions): Promise<ContextDoc>;
39
43
  interface FormatIssue {
40
44
  file: string;
41
45
  field?: string;
@@ -50,10 +54,10 @@ interface Frontmatter {
50
54
  data: unknown;
51
55
  body: string;
52
56
  }
53
- export declare function splitFrontmatter(source: string, file: string): Frontmatter;
57
+ export declare function splitFrontmatter(source: string, file: string, { language }?: ParseOptions): Frontmatter;
54
58
  export declare function isRuleId(value: string): boolean;
55
- export declare function parseRule(source: string, file: string): RuleDoc;
56
- export declare function loadRule(path: string): Promise<RuleDoc>;
59
+ export declare function parseRule(source: string, file: string, { language }?: ParseOptions): RuleDoc;
60
+ export declare function loadRule(path: string, options?: ParseOptions): Promise<RuleDoc>;
57
61
  interface RuleTag {
58
62
  id: string;
59
63
  line: number;
@@ -136,6 +140,8 @@ interface LintFinding {
136
140
  file: string;
137
141
  line?: number;
138
142
  message: string;
143
+ /** One line on how to fix the finding. */
144
+ hint: string;
139
145
  }
140
146
  interface LintJson {
141
147
  findings: LintFinding[];
@@ -149,4 +155,4 @@ interface LintOptions {
149
155
  export declare function lintCatalog(catalog: Catalog, tags?: TagIndex, onGitFallback?: GitFallbackListener): Promise<LintFinding[]>;
150
156
  export declare function pendingImplementation(catalog: Catalog, tags: TagIndex, base: string, changed: string[]): LintFinding[];
151
157
  export declare function formatFinding(finding: LintFinding): string;
152
- export type { Catalog, CatalogIssue, CatalogIssueCode, ContextDoc, ContextFrontmatter, ContextsJson, ForJson, FormatIssue, Frontmatter, GitFallbackListener, ImpactJson, LintFinding, LintJson, LintOptions, LintSeverity, ListJson, RuleDoc, RuleFrontmatter, RuleLanguage, RuleStatus, RuleSummaryJson, RuleTag, RulebookConfig, ShowJson, TagIndex, TagLocation };
158
+ export type { Catalog, CatalogIssue, CatalogIssueCode, ContextDoc, ContextFrontmatter, ContextsJson, ForJson, FormatIssue, Frontmatter, GitFallbackListener, ImpactJson, LintFinding, LintJson, LintOptions, LintSeverity, ListJson, ParseOptions, RuleDoc, RuleFrontmatter, RuleLanguage, RuleStatus, RuleSummaryJson, RuleTag, RulebookConfig, ShowJson, TagIndex, TagLocation };
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { A as loadConfig, C as loadRule, D as parseContext, E as loadContext, M as FormatError, N as formatIssue, O as splitFrontmatter, S as isRuleId, T as CONTEXT_FILE_NAME, _ as listRepoFiles, b as RULE_TAG_PATTERN, d as scanTestTags, f as findRulebookRoot, g as expandGlobs, h as createGlobMatcher, i as pendingImplementation, j as parseConfig, k as CONFIG_FILE_NAME, m as relativeToRoot, n as lintCatalog, p as loadCatalog, t as formatFinding, v as toRepoPath, w as parseRule, x as findRuleTags } from "./lint-C1yUeLOq.mjs";
2
- import { t as rulesForPath } from "./anchors-BFJCm1NU.mjs";
1
+ import { A as loadConfig, C as loadRule, D as parseContext, E as loadContext, N as FormatError, O as splitFrontmatter, P as formatIssue, S as isRuleId, T as CONTEXT_FILE_NAME, _ as listRepoFiles, b as RULE_TAG_PATTERN, d as scanTestTags, f as findRulebookRoot, g as expandGlobs, h as createGlobMatcher, i as pendingImplementation, j as parseConfig, k as CONFIG_FILE_NAME, m as relativeToRoot, n as lintCatalog, p as loadCatalog, t as formatFinding, v as toRepoPath, w as parseRule, x as findRuleTags } from "./lint-D63Q2SM0.mjs";
2
+ import { t as rulesForPath } from "./anchors-e6T9BNoV.mjs";
3
3
  export { CONFIG_FILE_NAME, CONTEXT_FILE_NAME, FormatError, RULE_TAG_PATTERN, createGlobMatcher, expandGlobs, findRuleTags, findRulebookRoot, formatFinding, formatIssue, isRuleId, lintCatalog, listRepoFiles, loadCatalog, loadConfig, loadContext, loadRule, parseConfig, parseContext, parseRule, pendingImplementation, relativeToRoot, rulesForPath, scanTestTags, splitFrontmatter, toRepoPath };
@@ -138,6 +138,152 @@ var rule_schema_default = {
138
138
  ],
139
139
  additionalProperties: false
140
140
  };
141
+ // src/messages.ts
142
+ const MESSAGES = {
143
+ en: {
144
+ field: (name) => `field "${name}"`,
145
+ missingFrontmatter: "missing YAML frontmatter delimited by \"---\" lines",
146
+ unclosedFrontmatter: "frontmatter is not closed by a \"---\" line",
147
+ invalidYaml: (detail) => `invalid YAML: ${detail}`,
148
+ required: "is required",
149
+ notAllowed: "is not allowed",
150
+ oneOf: (values) => `must be one of: ${values}`,
151
+ equalTo: (value) => `must be equal to ${value}`,
152
+ singleLine: "must be a single line",
153
+ ofType: () => void 0,
154
+ minLength: () => void 0,
155
+ minimum: () => void 0,
156
+ pattern: () => void 0,
157
+ missingRulesDir: "directory does not exist",
158
+ missingContextFile: "has no _context.md",
159
+ contextMismatch: (name) => `must match its directory name "${name}"`,
160
+ fileNameMismatch: (baseName) => `must match the file name "${baseName}"`,
161
+ idPrefixMismatch: (prefix) => `must use the context prefix "${prefix}"`,
162
+ duplicateId: (first) => `duplicates the id of ${first}`,
163
+ catalogWarning: (count) => `warning: the catalog has ${count} format ${count === 1 ? "issue" : "issues"}; run "rulebookguard lint" to see them and to check anchors and test tags.`,
164
+ unknownRule: (id) => `Unknown rule id "${id}". Run "rulebookguard list" to list them.`,
165
+ unknownContext: (name) => `Unknown context "${name}". Run "rulebookguard contexts" to list them.`,
166
+ invalidStatus: (status, allowed) => `Invalid status "${status}": expected one of ${allowed}.`,
167
+ outsideRoot: (input, root) => `Path "${input}" is outside the rulebook root ${root}.`,
168
+ gitFallback: (reason) => `warning: git could not list files (${reason}); walking the directory instead.`,
169
+ submoduleSkipped: (submodule, reason) => `warning: could not list files of submodule "${submodule}" (${reason}); skipping it.`,
170
+ ruleCount: (count) => `${count} ${count === 1 ? "rule" : "rules"}`,
171
+ noContexts: "No contexts found. Create one with \"rulebookguard new <context>\".",
172
+ noRules: "No rules found.",
173
+ noActiveRulesFor: (path) => `No active rules anchored to ${path}.`,
174
+ filesMatched: (count) => `Files matched by anchors (${count}):`,
175
+ testsTagging: (id, count) => `Tests tagging ${id} (${count}):`,
176
+ none: "(none)",
177
+ noFreeId: (context, lastId) => `No free rule id left in context "${context}" (${lastId} is taken).`,
178
+ alreadyExists: (file) => `${file} already exists; nothing was written.`,
179
+ invalidContextName: (name) => `Invalid context name "${name}": use lowercase letters, digits, "-" or "_", starting with a letter or digit.`,
180
+ invalidPrefix: (prefix) => `Invalid prefix "${prefix}": use uppercase letters and digits, starting with a letter. Pass one with --prefix.`,
181
+ prefixTaken: (prefix, context) => `Prefix "${prefix}" is already used by context "${context}". Pass another one with --prefix.`,
182
+ prefixMismatch: (context, prefix, given) => `Context "${context}" already exists with prefix "${prefix}", not "${given}".`,
183
+ contextTemplateSummary: "TODO: one-line summary of the context.",
184
+ contextTemplateBody: "TODO: describe what this context covers and what it leaves out.",
185
+ untestedRule: (id) => `active rule ${id} has no test tagging it`,
186
+ unmatchedAnchor: (anchor) => `anchor "${anchor}" matches no file`,
187
+ unknownRuleTag: (id) => `tags ${id}, which does not exist`,
188
+ deprecatedRuleTag: (id) => `tags ${id}, which is deprecated`,
189
+ contextOverBudget: (context, count, budget) => `context "${context}" has ${count} rules, more than the contextBudget of ${budget}`,
190
+ pendingImplementation: (id, base) => `rule ${id} changed since ${base} but none of the tests tagging it changed`,
191
+ noIssues: "No issues found.",
192
+ hintLabel: "hint",
193
+ catalogHints: {
194
+ schema: "fix the file as the message says; the JSON Schemas ship under rulebookguard/schemas/v1/",
195
+ "missing-rules-dir": "create the directory, or set rulesDir in rulebookguard.config.yaml",
196
+ "missing-context-file": "add a _context.md with context, prefix and summary",
197
+ "context-mismatch": "make the context field equal to the directory name, or move the file to the matching directory",
198
+ "file-name-mismatch": "rename the file after the id, or change the id",
199
+ "id-prefix-mismatch": "change the id to use the context prefix, or move the rule to the context that owns its prefix",
200
+ "duplicate-id": "give one of the rules another id; \"rulebookguard new <context>\" picks the next free one"
201
+ },
202
+ untestedRuleHint: (id) => `add "@rule ${id}" to a test in a file matched by testGlobs, or set the rule's status to proposed`,
203
+ unmatchedAnchorHint: "fix the glob so it matches the files that implement the rule, or remove it",
204
+ unknownRuleTagHint: "fix the id in the tag, or create the rule it refers to",
205
+ deprecatedRuleTagHint: (id, successor) => successor === void 0 ? `tag the rule that replaces ${id}, or remove the tag` : `tag ${successor}, which supersedes ${id}, instead`,
206
+ contextOverBudgetHint: "split the context into smaller ones, or raise contextBudget in rulebookguard.config.yaml",
207
+ pendingImplementationHint: (id) => `update or add a test tagging ${id} that covers the changed rule`,
208
+ lintSummary: (errors, warnings) => `${errors} ${errors === 1 ? "error" : "errors"}, ${warnings} ${warnings === 1 ? "warning" : "warnings"}`,
209
+ needsWorkTree: (root) => `lint --base needs a git work tree, but ${root} is not inside one.`,
210
+ unknownRef: (ref) => `Unknown git ref "${ref}".`,
211
+ cannotListChanged: (ref, reason) => `Cannot list the files changed since "${ref}": ${reason}`
212
+ },
213
+ es: {
214
+ field: (name) => `el campo "${name}"`,
215
+ missingFrontmatter: "falta el frontmatter YAML delimitado por líneas \"---\"",
216
+ unclosedFrontmatter: "el frontmatter no se cierra con una línea \"---\"",
217
+ invalidYaml: (detail) => `YAML inválido: ${detail}`,
218
+ required: "es obligatorio",
219
+ notAllowed: "no está permitido",
220
+ oneOf: (values) => `debe ser uno de: ${values}`,
221
+ equalTo: (value) => `debe ser igual a ${value}`,
222
+ singleLine: "debe ocupar una sola línea",
223
+ ofType: (type) => `debe ser de tipo ${type}`,
224
+ minLength: (limit) => limit === 1 ? "no puede estar vacío" : `debe tener al menos ${limit} caracteres`,
225
+ minimum: (limit) => `debe ser mayor o igual que ${limit}`,
226
+ pattern: (pattern) => `debe cumplir el patrón "${pattern}"`,
227
+ missingRulesDir: "el directorio no existe",
228
+ missingContextFile: "no tiene _context.md",
229
+ contextMismatch: (name) => `debe coincidir con el nombre de su directorio "${name}"`,
230
+ fileNameMismatch: (baseName) => `debe coincidir con el nombre del archivo "${baseName}"`,
231
+ idPrefixMismatch: (prefix) => `debe usar el prefijo del contexto "${prefix}"`,
232
+ duplicateId: (first) => `repite el id de ${first}`,
233
+ catalogWarning: (count) => `aviso: el catálogo tiene ${count} ${count === 1 ? "problema" : "problemas"} de formato; ejecuta "rulebookguard lint" para verlos y para revisar anchors y etiquetas de test.`,
234
+ unknownRule: (id) => `No existe la regla "${id}". Ejecuta "rulebookguard list" para ver las reglas.`,
235
+ unknownContext: (name) => `No existe el contexto "${name}". Ejecuta "rulebookguard contexts" para ver los contextos.`,
236
+ invalidStatus: (status, allowed) => `Estado "${status}" inválido: debe ser uno de ${allowed}.`,
237
+ outsideRoot: (input, root) => `La ruta "${input}" está fuera de la raíz del rulebook ${root}.`,
238
+ gitFallback: (reason) => `aviso: git no pudo listar los archivos (${reason}); se recorre el directorio en su lugar.`,
239
+ submoduleSkipped: (submodule, reason) => `aviso: no se pudieron listar los archivos del submódulo "${submodule}" (${reason}); se omite.`,
240
+ ruleCount: (count) => `${count} ${count === 1 ? "regla" : "reglas"}`,
241
+ noContexts: "No se encontraron contextos. Crea uno con \"rulebookguard new <contexto>\".",
242
+ noRules: "No se encontraron reglas.",
243
+ noActiveRulesFor: (path) => `No hay reglas activas ancladas a ${path}.`,
244
+ filesMatched: (count) => `Archivos que cubren los anchors (${count}):`,
245
+ testsTagging: (id, count) => `Tests que etiquetan ${id} (${count}):`,
246
+ none: "(ninguno)",
247
+ noFreeId: (context, lastId) => `No quedan ids libres en el contexto "${context}" (${lastId} ya existe).`,
248
+ alreadyExists: (file) => `${file} ya existe; no se escribió nada.`,
249
+ invalidContextName: (name) => `Nombre de contexto "${name}" inválido: usa minúsculas, dígitos, "-" o "_", empezando por una letra o un dígito.`,
250
+ invalidPrefix: (prefix) => `Prefijo "${prefix}" inválido: usa mayúsculas y dígitos, empezando por una letra. Indica uno con --prefix.`,
251
+ prefixTaken: (prefix, context) => `El prefijo "${prefix}" ya lo usa el contexto "${context}". Indica otro con --prefix.`,
252
+ prefixMismatch: (context, prefix, given) => `El contexto "${context}" ya existe con el prefijo "${prefix}", no "${given}".`,
253
+ contextTemplateSummary: "TODO: resumen del contexto en una línea.",
254
+ contextTemplateBody: "TODO: describe qué cubre este contexto y qué queda fuera.",
255
+ untestedRule: (id) => `la regla activa ${id} no tiene ningún test que la etiquete`,
256
+ unmatchedAnchor: (anchor) => `el anchor "${anchor}" no coincide con ningún archivo`,
257
+ unknownRuleTag: (id) => `etiqueta ${id}, que no existe`,
258
+ deprecatedRuleTag: (id) => `etiqueta ${id}, que está deprecada`,
259
+ contextOverBudget: (context, count, budget) => `el contexto "${context}" tiene ${count} reglas, más que el contextBudget de ${budget}`,
260
+ pendingImplementation: (id, base) => `la regla ${id} cambió desde ${base} pero no cambió ninguno de los tests que la etiquetan`,
261
+ noIssues: "No se encontraron problemas.",
262
+ hintLabel: "sugerencia",
263
+ catalogHints: {
264
+ schema: "corrige el archivo según el mensaje; los JSON Schemas vienen en rulebookguard/schemas/v1/",
265
+ "missing-rules-dir": "crea el directorio o cambia rulesDir en rulebookguard.config.yaml",
266
+ "missing-context-file": "agrega un _context.md con context, prefix y summary",
267
+ "context-mismatch": "iguala el campo context al nombre del directorio o mueve el archivo al directorio que corresponde",
268
+ "file-name-mismatch": "renombra el archivo según el id o cambia el id",
269
+ "id-prefix-mismatch": "cambia el id para usar el prefijo del contexto o mueve la regla al contexto dueño de ese prefijo",
270
+ "duplicate-id": "dale otro id a una de las reglas; \"rulebookguard new <contexto>\" elige el siguiente libre"
271
+ },
272
+ untestedRuleHint: (id) => `agrega "@rule ${id}" a un test en un archivo que coincida con testGlobs, o cambia el estado de la regla a proposed`,
273
+ unmatchedAnchorHint: "corrige el glob para que coincida con los archivos que implementan la regla, o quítalo",
274
+ unknownRuleTagHint: "corrige el id de la etiqueta o crea la regla a la que se refiere",
275
+ deprecatedRuleTagHint: (id, successor) => successor === void 0 ? `etiqueta la regla que reemplaza a ${id} o quita la etiqueta` : `etiqueta ${successor}, que reemplaza a ${id}`,
276
+ contextOverBudgetHint: "divide el contexto en otros más chicos o sube contextBudget en rulebookguard.config.yaml",
277
+ pendingImplementationHint: (id) => `actualiza o agrega un test que etiquete ${id} y cubra el cambio de la regla`,
278
+ lintSummary: (errors, warnings) => `${errors} ${errors === 1 ? "error" : "errores"}, ${warnings} ${warnings === 1 ? "aviso" : "avisos"}`,
279
+ needsWorkTree: (root) => `lint --base necesita un work tree de git, pero ${root} no está en uno.`,
280
+ unknownRef: (ref) => `La referencia de git "${ref}" no existe.`,
281
+ cannotListChanged: (ref, reason) => `No se pudieron listar los archivos cambiados desde "${ref}": ${reason}`
282
+ }
283
+ };
284
+ function messages(language) {
285
+ return MESSAGES[language ?? "en"];
286
+ }
141
287
  // src/format/validate.ts
142
288
  const ajv = new Ajv2020({
143
289
  allErrors: true,
@@ -148,30 +294,35 @@ const validators = {
148
294
  context: ajv.compile(context_schema_default),
149
295
  rule: ajv.compile(rule_schema_default)
150
296
  };
151
- function validate(validator, data, file) {
297
+ function validate(validator, data, file, { language } = {}) {
152
298
  if (validator(data)) return data;
153
- throw new FormatError((validator.errors ?? []).map((error) => toIssue(error, file)));
299
+ const text = messages(language);
300
+ throw new FormatError((validator.errors ?? []).map((error) => toIssue(error, file, text)));
154
301
  }
155
- function toIssue(error, file) {
302
+ function toIssue(error, file, text) {
156
303
  const base = toFieldPath(error.instancePath);
157
304
  const params = error.params;
305
+ const fallback = error.message ?? "is invalid";
158
306
  switch (error.keyword) {
159
307
  case "required": return {
160
308
  file,
161
309
  field: join$1(base, String(params.missingProperty)),
162
- message: "is required"
310
+ message: text.required
163
311
  };
164
312
  case "additionalProperties": return {
165
313
  file,
166
314
  field: join$1(base, String(params.additionalProperty)),
167
- message: "is not allowed"
315
+ message: text.notAllowed
168
316
  };
169
- case "enum": return withField(file, base, `must be one of: ${params.allowedValues.join(", ")}`);
170
- case "const": return withField(file, base, `must be equal to ${JSON.stringify(params.allowedValue)}`);
317
+ case "enum": return withField(file, base, text.oneOf(params.allowedValues.join(", ")));
318
+ case "const": return withField(file, base, text.equalTo(JSON.stringify(params.allowedValue)));
171
319
  case "pattern":
172
- if (params.pattern === "^[^\\r\\n]*$") return withField(file, base, "must be a single line");
173
- return withField(file, base, error.message ?? "is invalid");
174
- default: return withField(file, base, error.message ?? "is invalid");
320
+ if (params.pattern === "^[^\\r\\n]*$") return withField(file, base, text.singleLine);
321
+ return withField(file, base, text.pattern(String(params.pattern)) ?? fallback);
322
+ case "type": return withField(file, base, text.ofType(String(params.type)) ?? fallback);
323
+ case "minLength": return withField(file, base, text.minLength(Number(params.limit)) ?? fallback);
324
+ case "minimum": return withField(file, base, text.minimum(Number(params.limit)) ?? fallback);
325
+ default: return withField(file, base, fallback);
175
326
  }
176
327
  }
177
328
  function withField(file, field, message) {
@@ -191,13 +342,13 @@ function toFieldPath(instancePath) {
191
342
  return instancePath.split("/").slice(1).map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")).reduce((path, segment) => /^\d+$/.test(segment) ? `${path}[${segment}]` : join$1(path, segment), "");
192
343
  }
193
344
  // src/format/yaml.ts
194
- function parseYaml(source, file) {
345
+ function parseYaml(source, file, { language } = {}) {
195
346
  try {
196
347
  return parse(source);
197
348
  } catch (error) {
198
349
  if (error instanceof YAMLParseError) throw new FormatError([{
199
350
  file,
200
- message: `invalid YAML: ${singleLine(error)}`
351
+ message: messages(language).invalidYaml(singleLine(error))
201
352
  }]);
202
353
  throw error;
203
354
  }
@@ -219,53 +370,54 @@ async function loadConfig(path) {
219
370
  // src/format/frontmatter.ts
220
371
  const OPENING = /^---\r?\n/;
221
372
  const CLOSING = /^---[ \t]*(?:\r?\n|$)/m;
222
- function splitFrontmatter(source, file) {
373
+ function splitFrontmatter(source, file, { language } = {}) {
374
+ const text = messages(language);
223
375
  const opening = OPENING.exec(source);
224
376
  if (opening === null) throw new FormatError([{
225
377
  file,
226
- message: "missing YAML frontmatter delimited by \"---\" lines"
378
+ message: text.missingFrontmatter
227
379
  }]);
228
380
  const rest = source.slice(opening[0].length);
229
381
  const closing = CLOSING.exec(rest);
230
382
  if (closing === null) throw new FormatError([{
231
383
  file,
232
- message: "frontmatter is not closed by a \"---\" line"
384
+ message: text.unclosedFrontmatter
233
385
  }]);
234
386
  const yaml = rest.slice(0, closing.index);
235
387
  const body = rest.slice(closing.index + closing[0].length).replace(/^(?:\r?\n)+/, "");
236
388
  return {
237
- data: parseYaml(yaml, file),
389
+ data: parseYaml(yaml, file, { language }),
238
390
  body
239
391
  };
240
392
  }
241
393
  // src/format/context.ts
242
394
  const CONTEXT_FILE_NAME = "_context.md";
243
- function parseContext(source, file) {
244
- const { data, body } = splitFrontmatter(source, file);
395
+ function parseContext(source, file, { language } = {}) {
396
+ const { data, body } = splitFrontmatter(source, file, { language });
245
397
  return {
246
398
  file,
247
- ...validate(validators.context, data, file),
399
+ ...validate(validators.context, data, file, { language }),
248
400
  body
249
401
  };
250
402
  }
251
- async function loadContext(path) {
252
- return parseContext(await readSource(path), path);
403
+ async function loadContext(path, options) {
404
+ return parseContext(await readSource(path), path, options);
253
405
  }
254
406
  // src/format/rule.ts
255
407
  const RULE_ID = /^R-[A-Z][A-Z0-9]*-[0-9]{3}$/;
256
408
  function isRuleId(value) {
257
409
  return RULE_ID.test(value);
258
410
  }
259
- function parseRule(source, file) {
260
- const { data, body } = splitFrontmatter(source, file);
411
+ function parseRule(source, file, { language } = {}) {
412
+ const { data, body } = splitFrontmatter(source, file, { language });
261
413
  return {
262
414
  file,
263
- ...validate(validators.rule, data, file),
415
+ ...validate(validators.rule, data, file, { language }),
264
416
  body
265
417
  };
266
418
  }
267
- async function loadRule(path) {
268
- return parseRule(await readSource(path), path);
419
+ async function loadRule(path, options) {
420
+ return parseRule(await readSource(path), path, options);
269
421
  }
270
422
  // src/format/tag.ts
271
423
  const RULE_TAG_PATTERN = /(?<![\w@.-])@rule\s+(R-[A-Z][A-Z0-9]*-[0-9]{3})(?![\w-])/g;
@@ -478,12 +630,12 @@ async function loadCatalog(root) {
478
630
  catalog.issues.push({
479
631
  code: "missing-rules-dir",
480
632
  file: rulesDir,
481
- message: "directory does not exist"
633
+ message: messages(config.language).missingRulesDir
482
634
  });
483
635
  return catalog;
484
636
  }
485
637
  for (const entry of directories.filter((item) => item.isDirectory())) await loadContextDirectory(catalog, `${rulesDir}/${entry.name}`, entry.name);
486
- catalog.issues.push(...findDuplicateIds(catalog.rules));
638
+ catalog.issues.push(...findDuplicateIds(catalog.rules, config.language));
487
639
  return catalog;
488
640
  }
489
641
  async function loadContextDirectory(catalog, dir, name) {
@@ -498,7 +650,7 @@ async function loadContextDirectory(catalog, dir, name) {
498
650
  } else catalog.issues.push({
499
651
  code: "missing-context-file",
500
652
  file: dir,
501
- message: "has no _context.md"
653
+ message: messages(catalog.config.language).missingContextFile
502
654
  });
503
655
  for (const file of files.filter((item) => item !== CONTEXT_FILE_NAME)) {
504
656
  const rule = await load(catalog, `${dir}/${file}`, loadRule);
@@ -513,7 +665,7 @@ async function loadContextDirectory(catalog, dir, name) {
513
665
  async function load(catalog, file, loader) {
514
666
  try {
515
667
  return {
516
- ...await loader(join(catalog.root, file)),
668
+ ...await loader(join(catalog.root, file), { language: catalog.config.language }),
517
669
  file
518
670
  };
519
671
  } catch (error) {
@@ -531,7 +683,7 @@ function checkContextName(catalog, doc, name) {
531
683
  code: "context-mismatch",
532
684
  file: doc.file,
533
685
  field: "context",
534
- message: `must match its directory name "${name}"`
686
+ message: messages(catalog.config.language).contextMismatch(name)
535
687
  });
536
688
  }
537
689
  function checkRule(catalog, rule, directory, baseName, context) {
@@ -539,17 +691,17 @@ function checkRule(catalog, rule, directory, baseName, context) {
539
691
  code: "file-name-mismatch",
540
692
  file: rule.file,
541
693
  field: "id",
542
- message: `must match the file name "${baseName}"`
694
+ message: messages(catalog.config.language).fileNameMismatch(baseName)
543
695
  });
544
696
  if (context !== void 0 && !rule.id.startsWith(`R-${context.prefix}-`)) catalog.issues.push({
545
697
  code: "id-prefix-mismatch",
546
698
  file: rule.file,
547
699
  field: "id",
548
- message: `must use the context prefix "${context.prefix}"`
700
+ message: messages(catalog.config.language).idPrefixMismatch(context.prefix)
549
701
  });
550
702
  checkContextName(catalog, rule, directory);
551
703
  }
552
- function findDuplicateIds(rules) {
704
+ function findDuplicateIds(rules, language) {
553
705
  const firstFile = /* @__PURE__ */ new Map();
554
706
  const issues = [];
555
707
  for (const rule of rules) {
@@ -559,7 +711,7 @@ function findDuplicateIds(rules) {
559
711
  code: "duplicate-id",
560
712
  file: rule.file,
561
713
  field: "id",
562
- message: `duplicates the id of ${first}`
714
+ message: messages(language).duplicateId(first)
563
715
  });
564
716
  }
565
717
  return issues;
@@ -618,12 +770,12 @@ async function requireRulebookRoot(io) {
618
770
  async function openCatalog(io) {
619
771
  const catalog = await loadCatalog(await requireRulebookRoot(io));
620
772
  const count = catalog.issues.length;
621
- if (count > 0) io.stderr(`warning: the catalog has ${count} ${count === 1 ? "issue" : "issues"}; run "rulebookguard lint" for details.\n`);
773
+ if (count > 0) io.stderr(`${messages(catalog.config.language).catalogWarning(count)}\n`);
622
774
  return catalog;
623
775
  }
624
776
  function findRule(catalog, id) {
625
777
  const rule = catalog.rules.find((item) => item.id === id);
626
- if (rule === void 0) throw new CliError(`Unknown rule id "${id}". Run "rulebookguard list" to list them.`);
778
+ if (rule === void 0) throw new CliError(messages(catalog.config.language).unknownRule(id));
627
779
  return rule;
628
780
  }
629
781
  function writeJson(io, value) {
@@ -632,20 +784,21 @@ function writeJson(io, value) {
632
784
  function writeLines(io, lines) {
633
785
  io.stdout(lines.map((line) => `${line}\n`).join(""));
634
786
  }
635
- function gitFallbackWarning(io) {
787
+ function gitFallbackWarning(io, language) {
788
+ const text = messages(language);
636
789
  let warned = false;
637
790
  const skipped = /* @__PURE__ */ new Set();
638
791
  return (reason, submodule) => {
639
792
  if (submodule !== void 0) {
640
793
  if (!skipped.has(submodule)) {
641
794
  skipped.add(submodule);
642
- io.stderr(`warning: could not list files of submodule "${submodule}" (${reason}); skipping it.\n`);
795
+ io.stderr(`${text.submoduleSkipped(submodule, reason)}\n`);
643
796
  }
644
797
  return;
645
798
  }
646
799
  if (!warned) {
647
800
  warned = true;
648
- io.stderr(`warning: git could not list files (${reason}); walking the directory instead.\n`);
801
+ io.stderr(`${text.gitFallback(reason)}\n`);
649
802
  }
650
803
  };
651
804
  }
@@ -660,16 +813,17 @@ async function lintCommand(io, options) {
660
813
  return report(io, error.issues.map((issue) => issueFinding("schema", {
661
814
  ...issue,
662
815
  file: relativeToRoot(root, issue.file)
663
- })), options.json);
816
+ }, messages("en"))), options.json, messages("en"));
664
817
  }
665
- const changed = options.base === void 0 ? void 0 : await changedFiles(catalog.root, options.base);
666
- const warn = gitFallbackWarning(io);
818
+ const text = messages(catalog.config.language);
819
+ const changed = options.base === void 0 ? void 0 : await changedFiles(catalog.root, options.base, text);
820
+ const warn = gitFallbackWarning(io, catalog.config.language);
667
821
  const tags = await scanTestTags(catalog.root, catalog.config.testGlobs, warn);
668
822
  const findings = await lintCatalog(catalog, tags, warn);
669
823
  if (options.base !== void 0 && changed !== void 0) findings.push(...pendingImplementation(catalog, tags, options.base, changed));
670
- return report(io, findings, options.json);
824
+ return report(io, findings, options.json, text);
671
825
  }
672
- function report(io, findings, json) {
826
+ function report(io, findings, json, text) {
673
827
  const sorted = sortFindings(findings);
674
828
  const errors = sorted.filter((finding) => finding.severity === "error").length;
675
829
  const warnings = sorted.length - errors;
@@ -678,31 +832,33 @@ function report(io, findings, json) {
678
832
  errors,
679
833
  warnings
680
834
  });
681
- else if (sorted.length === 0) writeLines(io, ["No issues found."]);
682
- else writeLines(io, [...sorted.map(formatFinding), `${plural(errors, "error")}, ${plural(warnings, "warning")}`]);
835
+ else if (sorted.length === 0) writeLines(io, [text.noIssues]);
836
+ else writeLines(io, [...sorted.flatMap((finding) => [formatFinding(finding), ` ${text.hintLabel}: ${finding.hint}`]), text.lintSummary(errors, warnings)]);
683
837
  return errors > 0 ? 1 : 0;
684
838
  }
685
- function issueFinding(code, { file, field, message }) {
686
- return error(code, file, field === void 0 ? message : `field "${field}" ${message}`);
839
+ function issueFinding(code, { file, field, message }, text) {
840
+ return error(code, file, field === void 0 ? message : `${text.field(field)} ${message}`, text.catalogHints[code]);
687
841
  }
688
842
  async function lintCatalog(catalog, tags, onGitFallback) {
689
843
  const tagIndex = tags ?? await scanTestTags(catalog.root, catalog.config.testGlobs, onGitFallback);
690
- const findings = catalog.issues.map((issue) => issueFinding(issue.code, issue));
844
+ const text = messages(catalog.config.language);
845
+ const findings = catalog.issues.map((issue) => issueFinding(issue.code, issue, text));
691
846
  const files = await listRepoFiles(catalog.root, onGitFallback);
692
847
  const status = new Map(catalog.rules.map((rule) => [rule.id, rule.status]));
693
848
  const invalid = new Set(catalog.invalidRuleIds);
849
+ const successors = new Map(catalog.rules.filter((rule) => rule.supersedes !== void 0 && rule.status !== "deprecated").map((rule) => [rule.supersedes, rule.id]));
694
850
  for (const rule of catalog.rules) {
695
- if (rule.status === "active" && !tagIndex.has(rule.id)) findings.push(error("untested-rule", rule.file, `active rule ${rule.id} has no test tagging it`));
696
- for (const anchor of rule.anchors) if (!files.some(createGlobMatcher([anchor]))) findings.push(error("unmatched-anchor", rule.file, `anchor "${anchor}" matches no file`));
851
+ if (rule.status === "active" && !tagIndex.has(rule.id)) findings.push(error("untested-rule", rule.file, text.untestedRule(rule.id), text.untestedRuleHint(rule.id)));
852
+ for (const anchor of rule.anchors) if (!files.some(createGlobMatcher([anchor]))) findings.push(error("unmatched-anchor", rule.file, text.unmatchedAnchor(anchor), text.unmatchedAnchorHint));
697
853
  }
698
854
  for (const [id, locations] of tagIndex) {
699
855
  const ruleStatus = status.get(id);
700
856
  if (ruleStatus !== void 0 && ruleStatus !== "deprecated" || invalid.has(id)) continue;
701
857
  for (const { file, line } of locations) findings.push(ruleStatus === void 0 ? {
702
- ...error("unknown-rule-tag", file, `tags ${id}, which does not exist`),
858
+ ...error("unknown-rule-tag", file, text.unknownRuleTag(id), text.unknownRuleTagHint),
703
859
  line
704
860
  } : {
705
- ...error("deprecated-rule-tag", file, `tags ${id}, which is deprecated`),
861
+ ...error("deprecated-rule-tag", file, text.deprecatedRuleTag(id), text.deprecatedRuleTagHint(id, successors.get(id))),
706
862
  line
707
863
  });
708
864
  }
@@ -713,43 +869,43 @@ async function lintCatalog(catalog, tags, onGitFallback) {
713
869
  severity: "warning",
714
870
  code: "context-over-budget",
715
871
  file: context.file,
716
- message: `context "${context.context}" has ${count} rules, more than the contextBudget of ${budget}`
872
+ message: text.contextOverBudget(context.context, count, budget),
873
+ hint: text.contextOverBudgetHint
717
874
  });
718
875
  }
719
876
  return findings;
720
877
  }
721
878
  function pendingImplementation(catalog, tags, base, changed) {
879
+ const text = messages(catalog.config.language);
722
880
  const changedSet = new Set(changed);
723
- return catalog.rules.filter((rule) => rule.status === "active" && changedSet.has(rule.file)).filter((rule) => (tags.get(rule.id) ?? []).length > 0).filter((rule) => !(tags.get(rule.id) ?? []).some(({ file }) => changedSet.has(file))).map((rule) => error("pending-implementation", rule.file, `rule ${rule.id} changed since ${base} but none of the tests tagging it changed`));
881
+ return catalog.rules.filter((rule) => rule.status === "active" && changedSet.has(rule.file)).filter((rule) => (tags.get(rule.id) ?? []).length > 0).filter((rule) => !(tags.get(rule.id) ?? []).some(({ file }) => changedSet.has(file))).map((rule) => error("pending-implementation", rule.file, text.pendingImplementation(rule.id, base), text.pendingImplementationHint(rule.id)));
724
882
  }
725
- async function changedFiles(root, base) {
726
- if (!await isInsideWorkTree(root)) throw new CliError(`lint --base needs a git work tree, but ${root} is not inside one.`);
727
- if (!await isCommitRef(root, base)) throw new CliError(`Unknown git ref "${base}".`);
883
+ async function changedFiles(root, base, text) {
884
+ if (!await isInsideWorkTree(root)) throw new CliError(text.needsWorkTree(root));
885
+ if (!await isCommitRef(root, base)) throw new CliError(text.unknownRef(base));
728
886
  try {
729
887
  return await changedSince(root, base);
730
888
  } catch (error) {
731
- throw new CliError(`Cannot list the files changed since "${base}": ${gitFailureReason(error)}`);
889
+ throw new CliError(text.cannotListChanged(base, gitFailureReason(error)));
732
890
  }
733
891
  }
734
892
  function formatFinding(finding) {
735
893
  const location = finding.line === void 0 ? finding.file : `${finding.file}:${finding.line}`;
736
894
  return `${finding.severity} ${finding.code} ${location} ${finding.message}`;
737
895
  }
738
- function error(code, file, message) {
896
+ function error(code, file, message, hint) {
739
897
  return {
740
898
  severity: "error",
741
899
  code,
742
900
  file,
743
- message
901
+ message,
902
+ hint
744
903
  };
745
904
  }
746
905
  function sortFindings(findings) {
747
906
  return [...findings].sort((a, b) => compare(a.file, b.file) || (a.line ?? 0) - (b.line ?? 0) || compare(a.code, b.code));
748
907
  }
749
- function plural(count, noun) {
750
- return `${count} ${noun}${count === 1 ? "" : "s"}`;
751
- }
752
908
  function compare(a, b) {
753
909
  return a < b ? -1 : a > b ? 1 : 0;
754
910
  }
755
- export { loadConfig as A, loadRule as C, parseContext as D, loadContext as E, FormatError as M, formatIssue as N, splitFrontmatter as O, isRuleId as S, CONTEXT_FILE_NAME as T, listRepoFiles as _, CliError as a, RULE_TAG_PATTERN as b, openCatalog as c, scanTestTags as d, findRulebookRoot as f, expandGlobs as g, createGlobMatcher as h, pendingImplementation as i, parseConfig as j, CONFIG_FILE_NAME as k, writeJson as l, relativeToRoot as m, lintCatalog as n, findRule as o, loadCatalog as p, lintCommand as r, gitFallbackWarning as s, formatFinding as t, writeLines as u, toRepoPath as v, parseRule as w, findRuleTags as x, runGit as y };
911
+ export { loadConfig as A, loadRule as C, parseContext as D, loadContext as E, messages as M, FormatError as N, splitFrontmatter as O, formatIssue as P, isRuleId as S, CONTEXT_FILE_NAME as T, listRepoFiles as _, CliError as a, RULE_TAG_PATTERN as b, openCatalog as c, scanTestTags as d, findRulebookRoot as f, expandGlobs as g, createGlobMatcher as h, pendingImplementation as i, parseConfig as j, CONFIG_FILE_NAME as k, writeJson as l, relativeToRoot as m, lintCatalog as n, findRule as o, loadCatalog as p, lintCommand as r, gitFallbackWarning as s, formatFinding as t, writeLines as u, toRepoPath as v, parseRule as w, findRuleTags as x, runGit as y };
@@ -1,4 +1,4 @@
1
- import { a as CliError, c as openCatalog, d as scanTestTags, g as expandGlobs, n as lintCatalog, s as gitFallbackWarning, u as writeLines, y as runGit } from "./lint-C1yUeLOq.mjs";
1
+ import { a as CliError, c as openCatalog, d as scanTestTags, g as expandGlobs, n as lintCatalog, s as gitFallbackWarning, u as writeLines, y as runGit } from "./lint-D63Q2SM0.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { readFile, readdir, stat, writeFile } from "node:fs/promises";
4
4
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -440,7 +440,7 @@ async function importSitePackage(from) {
440
440
  throw new CliError(`The site commands need the ${SITE_PACKAGE} package. Install it next to rulebookguard: npm install --save-dev ${SITE_PACKAGE}`);
441
441
  }
442
442
  async function buildSite(site, catalog, out, language, base, io) {
443
- const files = sitePages(await siteData(catalog, gitFallbackWarning(io)), {
443
+ const files = sitePages(await siteData(catalog, gitFallbackWarning(io, language)), {
444
444
  language,
445
445
  base
446
446
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulebookguard",
3
- "version": "0.5.1",
3
+ "version": "0.6.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": {
@@ -36,14 +36,14 @@
36
36
  },
37
37
  "devDependencies": {
38
38
  "@modelcontextprotocol/client": "2.3.1",
39
- "@rulebookguard/site": "0.5.1",
39
+ "@rulebookguard/site": "0.6.0",
40
40
  "@types/node": "^20.19.43",
41
41
  "@types/picomatch": "^4.0.3",
42
42
  "typescript": "^7.0.2",
43
43
  "vite-plus": "^1.4.0"
44
44
  },
45
45
  "peerDependencies": {
46
- "@rulebookguard/site": "^0.5.1"
46
+ "@rulebookguard/site": "^0.6.0"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "@rulebookguard/site": {