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 +20 -7
- package/dist/{anchors-BFJCm1NU.mjs → anchors-e6T9BNoV.mjs} +1 -1
- package/dist/cli.mjs +99 -36
- package/dist/index.d.mts +12 -6
- package/dist/index.mjs +2 -2
- package/dist/{lint-C1yUeLOq.mjs → lint-D63Q2SM0.mjs} +222 -66
- package/dist/{site-BiiOPVEe.mjs → site-DEdtLk6J.mjs} +2 -2
- package/package.json +3 -3
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/dist/cli.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { M as
|
|
3
|
-
import { t as rulesForPath } from "./anchors-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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(
|
|
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),
|
|
189
|
+
await writeFile(join(catalog.root, file), content, { flag: "wx" });
|
|
152
190
|
} catch (error) {
|
|
153
|
-
if (error.code === "EEXIST") throw new CliError(
|
|
191
|
+
if (error.code === "EEXIST") throw new CliError(text.alreadyExists(file));
|
|
154
192
|
throw error;
|
|
155
193
|
}
|
|
156
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
if (
|
|
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
|
-
|
|
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
|
|
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, [
|
|
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
|
|
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
|
-
|
|
289
|
-
...indent(files),
|
|
290
|
-
|
|
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(
|
|
295
|
-
const path = toRepoPath(relative(root, absolute));
|
|
296
|
-
if (path === "" || path === ".." || path.startsWith("../") || isAbsolute(path)) throw new CliError(
|
|
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 ? [
|
|
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>
|
|
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
|
|
329
|
-
and prints the rules anchored to
|
|
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-
|
|
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,
|
|
2
|
-
import { t as rulesForPath } from "./anchors-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
315
|
+
message: text.notAllowed
|
|
168
316
|
};
|
|
169
|
-
case "enum": return withField(file, base,
|
|
170
|
-
case "const": return withField(file, base,
|
|
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,
|
|
173
|
-
return withField(file, base,
|
|
174
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
666
|
-
const
|
|
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, [
|
|
682
|
-
else writeLines(io, [...sorted.
|
|
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 :
|
|
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
|
|
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,
|
|
696
|
-
for (const anchor of rule.anchors) if (!files.some(createGlobMatcher([anchor]))) findings.push(error("unmatched-anchor", rule.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,
|
|
858
|
+
...error("unknown-rule-tag", file, text.unknownRuleTag(id), text.unknownRuleTagHint),
|
|
703
859
|
line
|
|
704
860
|
} : {
|
|
705
|
-
...error("deprecated-rule-tag", file,
|
|
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:
|
|
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,
|
|
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(
|
|
727
|
-
if (!await isCommitRef(root, base)) throw new CliError(
|
|
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(
|
|
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,
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
46
|
+
"@rulebookguard/site": "^0.6.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"@rulebookguard/site": {
|