rulebookguard 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Juan Carlos Jimenez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,311 @@
1
- # Temporary Holding Version
1
+ # rulebookguard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Keep business rules as the single source of truth, readable by humans and AI, and bound to the code and tests that implement them.
4
+
5
+ Business rules usually end up scattered across code, tickets and people's memory. Neither a developer's memory nor an AI model's reading of the code is deterministic. `rulebookguard` keeps each rule in its own small Markdown file, links it to the code it governs (anchors) and to the tests that prove it (`@rule` tags), and fails CI when they drift apart.
6
+
7
+ - **Atomic rules.** One file per rule, with YAML frontmatter validated against a versioned JSON Schema.
8
+ - **Progressive disclosure.** Contexts map (L0), `id + summary` per context (L1), the full rule (L2), so neither people nor AI models have to read the whole catalog.
9
+ - **Inverse index.** Given a file path, list the active rules anchored to it. Deterministic glob matching, no embeddings.
10
+ - **Linter.** Untested rules, tags pointing at missing or deprecated rules, dead anchors, invalid files, and rules changed without a test change.
11
+
12
+ It is language-agnostic: rules are Markdown, and tests in any language link to them through a comment.
13
+
14
+ ## Install
15
+
16
+ Requires Node.js 22 or later.
17
+
18
+ ```sh
19
+ npx rulebookguard lint
20
+ ```
21
+
22
+ Or add it to a project:
23
+
24
+ ```sh
25
+ npm install --save-dev rulebookguard
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ Put `rulebookguard.config.yaml` at the repository root. Commands find it by walking up from the current directory, and every path in the catalog is relative to the directory that holds it.
31
+
32
+ ```yaml
33
+ schema: 1
34
+ rulesDir: docs/rules # default: docs/rules
35
+ testGlobs: # required: files scanned for @rule tags
36
+ - 'src/**/*.test.ts'
37
+ - 'tests/**/test_*.py'
38
+ contextBudget: 30 # default: 30; lint warns when a context holds more rules
39
+ language: es # default: en; language of the text that rulebookguard generates
40
+ ```
41
+
42
+ `schema` and `testGlobs` are required (`testGlobs: []` is valid but finds no tags). Unknown fields are rejected.
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/`.
45
+
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
+
48
+ ## Catalog layout
49
+
50
+ Rules are grouped by context, one directory per context:
51
+
52
+ ```text
53
+ docs/rules/
54
+ clients/
55
+ _context.md # the context: name, id prefix, summary
56
+ R-CLI-001.md # one rule per file, named after its id
57
+ R-CLI-002.md
58
+ billing/
59
+ _context.md
60
+ R-BIL-001.md
61
+ ```
62
+
63
+ `_context.md`:
64
+
65
+ ```markdown
66
+ ---
67
+ context: clients
68
+ prefix: CLI
69
+ summary: Customers that receive installation services.
70
+ ---
71
+
72
+ The clients context owns customer identity and contact data.
73
+ ```
74
+
75
+ The `context` field must match the directory name, and every rule id in the directory must start with `R-<prefix>-`.
76
+
77
+ ## Rule file format
78
+
79
+ ````markdown
80
+ ---
81
+ id: R-CLI-001
82
+ context: clients
83
+ entities: [Client]
84
+ status: active
85
+ supersedes: R-CLI-000
86
+ anchors:
87
+ - 'src/clients/**/*.ts'
88
+ - 'src/api/clients.controller.ts'
89
+ summary: A client document number is unique per country.
90
+ ---
91
+
92
+ # Unique document number
93
+
94
+ Two clients in the same country cannot share a document number.
95
+ The check ignores letter case and leading zeros.
96
+
97
+ ```gherkin
98
+ Scenario: Duplicate document in the same country
99
+ Given a client with document "00123" in "VE"
100
+ When another client is created with document "123" in "VE"
101
+ Then the creation is rejected with "document already registered"
102
+ ```
103
+ ````
104
+
105
+ | Field | Required | Meaning |
106
+ | ------------ | -------- | ---------------------------------------------------------------- |
107
+ | `id` | yes | `R-<PREFIX>-<NNN>`, three digits, unique, equal to the file name |
108
+ | `context` | yes | the context directory the rule lives in |
109
+ | `entities` | yes | domain entities the rule talks about |
110
+ | `status` | yes | `proposed`, `active` or `deprecated` |
111
+ | `supersedes` | no | id of the rule this one replaces |
112
+ | `anchors` | yes | repo-relative globs of the code that implements the rule |
113
+ | `summary` | yes | one line; this is what `list` shows |
114
+
115
+ The Markdown body holds the full rule and its examples (Gherkin scenarios or tables). Rules do not list their tests: the link lives only in the tests, through `@rule` tags.
116
+
117
+ ## The `@rule` tag
118
+
119
+ A test links to a rule with `@rule <ID>` inside any comment, in any file matched by `testGlobs`. One comment can carry several tags.
120
+
121
+ TypeScript or JavaScript:
122
+
123
+ ```ts
124
+ // @rule R-CLI-001
125
+ it('rejects a duplicate document in the same country', () => {
126
+ /* ... */
127
+ });
128
+ ```
129
+
130
+ Go:
131
+
132
+ ```go
133
+ // @rule R-CLI-001 @rule R-BIL-001
134
+ func TestCreateClient(t *testing.T) {}
135
+ ```
136
+
137
+ Python:
138
+
139
+ ```python
140
+ # @rule R-CLI-001
141
+ def test_duplicate_document():
142
+ ...
143
+ ```
144
+
145
+ Java or Kotlin:
146
+
147
+ ```java
148
+ /** @rule R-CLI-001 */
149
+ @Test
150
+ void rejectsDuplicateDocument() {}
151
+ ```
152
+
153
+ SQL:
154
+
155
+ ```sql
156
+ -- @rule R-BIL-002
157
+ SELECT count(*) FROM invoices WHERE number IS NULL;
158
+ ```
159
+
160
+ ## Commands
161
+
162
+ `rulebookguard --help` prints the command list. Exit codes: `0` success, `1` failure (unknown id or context, missing config, lint errors, git errors), `2` usage error (unknown command or option, missing or extra argument).
163
+
164
+ The read commands (`contexts`, `list`, `show`, `for`, `impact`) accept `--json` for machine consumers. When the catalog has problems they still run, and print one warning on stderr pointing at `lint`.
165
+
166
+ ### `contexts` (L0)
167
+
168
+ ```sh
169
+ $ rulebookguard contexts
170
+ billing (BIL, 2 rules): Invoices and taxes charged to clients.
171
+ clients (CLI, 3 rules): Customers that receive installation services.
172
+ ```
173
+
174
+ ### `list [--context <c>] [--status <s>]` (L1)
175
+
176
+ ```sh
177
+ $ rulebookguard list --context clients --status active
178
+ R-CLI-001 [active] A client document number is unique per country.
179
+ ```
180
+
181
+ Exits 1 for an unknown context and 2 for an invalid status.
182
+
183
+ ### `show <id>` (L2)
184
+
185
+ ```sh
186
+ $ rulebookguard show R-CLI-001
187
+ R-CLI-001: A client document number is unique per country.
188
+ context: clients
189
+ status: active
190
+ entities: Client
191
+ anchors: src/clients/**/*.ts, src/api/clients.controller.ts
192
+ supersedes: R-CLI-000
193
+ file: docs/rules/clients/R-CLI-001.md
194
+
195
+ # Unique document number
196
+ ...
197
+ ```
198
+
199
+ Exits 1 for an unknown id.
200
+
201
+ ### `for <path>`
202
+
203
+ Active rules whose anchors match a path, relative to the current directory. This is what an editor hook or an AI agent asks before touching a file.
204
+
205
+ ```sh
206
+ $ rulebookguard for src/clients/create.ts
207
+ R-CLI-001 (clients): A client document number is unique per country.
208
+ ```
209
+
210
+ Exits 1 when the path is outside the rulebook root.
211
+
212
+ ### `impact <id>`
213
+
214
+ Files matched by a rule's anchors and the tests tagging it.
215
+
216
+ ```sh
217
+ $ rulebookguard impact R-CLI-001
218
+ R-CLI-001: A client document number is unique per country.
219
+ Files matched by anchors (2):
220
+ src/clients/create.ts
221
+ src/clients/update.ts
222
+ Tests tagging R-CLI-001 (1):
223
+ src/clients/create.test.ts:12
224
+ ```
225
+
226
+ Exits 1 for an unknown id.
227
+
228
+ ### `new <context>`
229
+
230
+ 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.
231
+
232
+ ```sh
233
+ $ rulebookguard new clients
234
+ docs/rules/clients/R-CLI-004.md
235
+ ```
236
+
237
+ Exits 1 for an unknown context or when the context has no free id left.
238
+
239
+ ### `lint [--base <ref>] [--json]`
240
+
241
+ Checks the whole catalog. Each finding is printed as `<severity> <code> <file>[:line] <message>`, followed by a count.
242
+
243
+ | Severity | Code | Meaning |
244
+ | -------- | ------------------------ | ---------------------------------------------------------------- |
245
+ | error | `untested-rule` | an active rule has no test tagging it |
246
+ | error | `unknown-rule-tag` | a test tags a rule id that does not exist |
247
+ | error | `deprecated-rule-tag` | a test tags a deprecated rule |
248
+ | error | `unmatched-anchor` | an anchor glob matches no file |
249
+ | error | `schema` | a config, context or rule file breaks the JSON Schema |
250
+ | error | `duplicate-id` | two rule files share an id |
251
+ | error | `file-name-mismatch` | a rule file is not named `<id>.md` |
252
+ | error | `id-prefix-mismatch` | a rule id does not use its context prefix |
253
+ | error | `context-mismatch` | a `context` field does not match its directory |
254
+ | error | `missing-context-file` | a context directory has no `_context.md` |
255
+ | error | `missing-rules-dir` | `rulesDir` does not exist |
256
+ | error | `pending-implementation` | with `--base`: an active rule changed, but none of its tests did |
257
+ | warning | `context-over-budget` | a context holds more rules than `contextBudget` |
258
+
259
+ ```sh
260
+ $ rulebookguard lint
261
+ error untested-rule docs/rules/billing/R-BIL-002.md active rule R-BIL-002 has no test tagging it
262
+ warning context-over-budget docs/rules/clients/_context.md context "clients" has 31 rules, more than the contextBudget of 30
263
+ 1 error, 1 warning
264
+ ```
265
+
266
+ Exits 1 when there is any error, and 0 when there are only warnings or nothing at all (`No issues found.`). An invalid `rulebookguard.config.yaml` is reported as `schema` findings too; tags of a rule file that fails the schema are not reported again as `unknown-rule-tag`.
267
+
268
+ With `--base <ref>`, lint also compares `<ref>` with the working tree (`git diff --name-only <ref>` plus untracked files, so uncommitted work counts). A rule whose file changed must come with a change in at least one test that tags it, otherwise it is reported as `pending-implementation`; an active rule that no test tags at all is reported only as `untested-rule`. Only rules whose status is `active` after the change are checked: new active rules are checked, `proposed` and `deprecated` rules are not. Outside a git work tree, with a ref git cannot resolve, or when git fails while listing the changes, lint fails with exit code 1.
269
+
270
+ ```sh
271
+ $ rulebookguard lint --base origin/main
272
+ 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
273
+ 1 error, 0 warnings
274
+ ```
275
+
276
+ `--json` prints `{ "findings": [{ "severity", "code", "file", "line"?, "message" }], "errors", "warnings" }`.
277
+
278
+ ## CI recipe
279
+
280
+ Run the linter on every pull request, comparing against the main branch. The full history is needed so that `origin/main` exists.
281
+
282
+ ```yaml
283
+ name: rules
284
+ on: pull_request
285
+
286
+ jobs:
287
+ rulebookguard:
288
+ runs-on: ubuntu-latest
289
+ steps:
290
+ - uses: actions/checkout@v7.0.1
291
+ with:
292
+ fetch-depth: 0
293
+ - run: npx --yes rulebookguard@0.1.0 lint --base origin/main
294
+ ```
295
+
296
+ Pin the version, or add rulebookguard as a dev dependency and run it from the lockfile, so a new release never changes CI results by itself.
297
+
298
+ ## Releasing
299
+
300
+ Releases are published to npm from GitHub Actions with [trusted publishing](https://docs.npmjs.com/trusted-publishers), so no npm token is stored and every version carries a provenance attestation. To release, bump `version` in `package.json`, merge it to `main`, then push a matching tag:
301
+
302
+ ```sh
303
+ git tag v0.1.0
304
+ git push origin v0.1.0
305
+ ```
306
+
307
+ The `Release` workflow fails without publishing when the tag does not match the package version, or when `vp check`, `vp test` or `vp pack` fails.
308
+
309
+ ## License
310
+
311
+ MIT
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/dist/cli.mjs ADDED
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+ import { M as FormatError, N as formatIssue, _ as expandGlobs, a as CliError, c as openCatalog, d as scanTestTags, h as rulesForPath, l as writeJson, o as findRule, r as lintCommand, s as gitFallbackWarning, u as writeLines, y as toRepoPath } from "./lint-DtD_Ekei.mjs";
3
+ import { readdir, writeFile } from "node:fs/promises";
4
+ import { isAbsolute, join, relative, resolve } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ // src/commands/new.ts
7
+ const MAX_NUMBER = 999;
8
+ async function newCommand(catalog, io, name) {
9
+ const context = catalog.contexts.find((item) => item.context === name);
10
+ if (context === void 0) throw new CliError(`Unknown context "${name}". Run "rulebookguard contexts" to list them.`);
11
+ const directory = context.file.slice(0, context.file.lastIndexOf("/"));
12
+ const idPattern = new RegExp(`^R-${context.prefix}-([0-9]{3})$`);
13
+ const fileNames = (await readdir(join(catalog.root, directory))).filter((file) => file.endsWith(".md")).map((file) => file.slice(0, -3));
14
+ const taken = [...catalog.rules.map((rule) => rule.id), ...fileNames].map((id) => idPattern.exec(id)?.[1]).filter((digits) => digits !== void 0).map(Number);
15
+ const last = Math.max(0, ...taken);
16
+ if (last >= MAX_NUMBER) throw new CliError(`No free rule id left in context "${name}" (R-${context.prefix}-${MAX_NUMBER} is taken).`);
17
+ const id = `R-${context.prefix}-${String(last + 1).padStart(3, "0")}`;
18
+ const file = `${directory}/${id}.md`;
19
+ try {
20
+ await writeFile(join(catalog.root, file), template(id, context.context, catalog.config.language ?? "en"), { flag: "wx" });
21
+ } catch (error) {
22
+ if (error.code === "EEXIST") throw new CliError(`${file} already exists; nothing was written.`);
23
+ throw error;
24
+ }
25
+ writeLines(io, [file]);
26
+ }
27
+ const TEMPLATE_TEXT = {
28
+ en: {
29
+ summary: "TODO: one-line summary of the rule.",
30
+ title: "TODO: rule title",
31
+ intro: "TODO: state the rule in full, then add examples as Gherkin scenarios or tables.",
32
+ scenario: `Scenario: TODO
33
+ Given TODO
34
+ When TODO
35
+ Then TODO`
36
+ },
37
+ es: {
38
+ summary: "TODO: resumen de la regla en una línea.",
39
+ title: "TODO: título de la regla",
40
+ intro: "TODO: describe la regla completa y agrega ejemplos como escenarios Gherkin o tablas.",
41
+ scenario: `# language: es
42
+ Escenario: TODO
43
+ Dado TODO
44
+ Cuando TODO
45
+ Entonces TODO`
46
+ }
47
+ };
48
+ function template(id, context, language) {
49
+ const text = TEMPLATE_TEXT[language];
50
+ return `---
51
+ id: ${id}
52
+ context: ${context}
53
+ entities: []
54
+ status: proposed
55
+ anchors: []
56
+ summary: '${text.summary}'
57
+ ---
58
+
59
+ # ${text.title}
60
+
61
+ ${text.intro}
62
+
63
+ \`\`\`gherkin
64
+ ${text.scenario}
65
+ \`\`\`
66
+ `;
67
+ }
68
+ // src/commands/read.ts
69
+ const RULE_STATUSES = [
70
+ "proposed",
71
+ "active",
72
+ "deprecated"
73
+ ];
74
+ function contextsCommand(catalog, io, json) {
75
+ const contexts = [...catalog.contexts].sort((a, b) => compare(a.context, b.context)).map(({ context, prefix, summary, file }) => ({
76
+ context,
77
+ prefix,
78
+ summary,
79
+ rules: catalog.rules.filter((rule) => rule.context === context).length,
80
+ file
81
+ }));
82
+ if (json) {
83
+ writeJson(io, { contexts });
84
+ return;
85
+ }
86
+ writeLines(io, contexts.map((item) => `${item.context} (${item.prefix}, ${item.rules} ${item.rules === 1 ? "rule" : "rules"}): ${item.summary}`));
87
+ }
88
+ function listCommand(catalog, io, options) {
89
+ const { context, status } = options;
90
+ if (status !== void 0 && !RULE_STATUSES.includes(status)) throw new CliError(`Invalid status "${status}": expected one of ${RULE_STATUSES.join(", ")}.`, 2);
91
+ if (context !== void 0 && !catalog.contexts.some((item) => item.context === context)) throw new CliError(`Unknown context "${context}". Run "rulebookguard contexts" to list them.`);
92
+ const rules = sortById(catalog.rules).filter((rule) => context === void 0 || rule.context === context).filter((rule) => status === void 0 || rule.status === status).map(toSummary);
93
+ if (options.json) {
94
+ writeJson(io, { rules });
95
+ return;
96
+ }
97
+ writeLines(io, rules.map((rule) => `${rule.id} [${rule.status}] ${rule.summary}`));
98
+ }
99
+ function showCommand(catalog, io, id, json) {
100
+ const rule = findRule(catalog, id);
101
+ if (json) {
102
+ writeJson(io, { rule: {
103
+ ...toSummary(rule),
104
+ entities: rule.entities,
105
+ anchors: rule.anchors,
106
+ supersedes: rule.supersedes ?? null,
107
+ file: rule.file,
108
+ body: rule.body
109
+ } });
110
+ return;
111
+ }
112
+ const header = [
113
+ `${rule.id}: ${rule.summary}`,
114
+ `context: ${rule.context}`,
115
+ `status: ${rule.status}`,
116
+ `entities: ${rule.entities.join(", ")}`,
117
+ `anchors: ${rule.anchors.join(", ")}`,
118
+ ...rule.supersedes === void 0 ? [] : [`supersedes: ${rule.supersedes}`],
119
+ `file: ${rule.file}`
120
+ ];
121
+ io.stdout(`${header.join("\n")}\n\n${rule.body}`);
122
+ }
123
+ function forCommand(catalog, io, input, json) {
124
+ const path = toRootPath(catalog.root, resolve(io.cwd, input), input);
125
+ const rules = rulesForPath(catalog.rules, path).filter((rule) => rule.status === "active").map((rule) => ({
126
+ ...toSummary(rule),
127
+ file: rule.file
128
+ }));
129
+ if (json) {
130
+ writeJson(io, {
131
+ path,
132
+ rules
133
+ });
134
+ return;
135
+ }
136
+ if (rules.length === 0) {
137
+ writeLines(io, [`No active rules anchored to ${path}.`]);
138
+ return;
139
+ }
140
+ writeLines(io, rules.map((rule) => `${rule.id} (${rule.context}): ${rule.summary}`));
141
+ }
142
+ async function impactCommand(catalog, io, id, json) {
143
+ const rule = findRule(catalog, id);
144
+ const warn = gitFallbackWarning(io);
145
+ const files = await expandGlobs(catalog.root, rule.anchors, warn);
146
+ const tests = (await scanTestTags(catalog.root, catalog.config.testGlobs, warn)).get(id) ?? [];
147
+ if (json) {
148
+ writeJson(io, {
149
+ id,
150
+ files,
151
+ tests
152
+ });
153
+ return;
154
+ }
155
+ writeLines(io, [
156
+ `${rule.id}: ${rule.summary}`,
157
+ `Files matched by anchors (${files.length}):`,
158
+ ...indent(files),
159
+ `Tests tagging ${rule.id} (${tests.length}):`,
160
+ ...indent(tests.map((test) => `${test.file}:${test.line}`))
161
+ ]);
162
+ }
163
+ function toRootPath(root, absolute, input) {
164
+ const path = toRepoPath(relative(root, absolute));
165
+ if (path === "" || path === ".." || path.startsWith("../") || isAbsolute(path)) throw new CliError(`Path "${input}" is outside the rulebook root ${root}.`);
166
+ return path;
167
+ }
168
+ function toSummary(rule) {
169
+ return {
170
+ id: rule.id,
171
+ context: rule.context,
172
+ status: rule.status,
173
+ summary: rule.summary
174
+ };
175
+ }
176
+ function indent(lines) {
177
+ return lines.length === 0 ? [" (none)"] : lines.map((line) => ` ${line}`);
178
+ }
179
+ function sortById(rules) {
180
+ return [...rules].sort((a, b) => compare(a.id, b.id));
181
+ }
182
+ function compare(a, b) {
183
+ return a < b ? -1 : a > b ? 1 : 0;
184
+ }
185
+ // src/commands/run.ts
186
+ const USAGE = `Usage: rulebookguard <command> [options]
187
+
188
+ Commands:
189
+ contexts [--json] L0: list the contexts
190
+ list [--context <c>] [--status <s>] [--json] L1: id and summary of each rule
191
+ show <id> [--json] L2: the full rule
192
+ for <path> [--json] active rules anchored to a path
193
+ impact <id> [--json] files matched by a rule's anchors and tests tagging it
194
+ new <context> scaffold the next free rule id as a proposed rule
195
+ lint [--base <ref>] [--json] check the catalog, anchors and test tags;
196
+ --base: rules changed since <ref> need a changed test
197
+
198
+ Exit codes: 0 success, 1 failure (lint: any error), 2 usage error.
199
+ `;
200
+ const JSON_OPTION = { json: { type: "boolean" } };
201
+ const COMMANDS = {
202
+ contexts: {
203
+ usage: "rulebookguard contexts [--json]",
204
+ options: JSON_OPTION
205
+ },
206
+ list: {
207
+ usage: "rulebookguard list [--context <c>] [--status <s>] [--json]",
208
+ options: {
209
+ ...JSON_OPTION,
210
+ context: { type: "string" },
211
+ status: { type: "string" }
212
+ }
213
+ },
214
+ show: {
215
+ usage: "rulebookguard show <id> [--json]",
216
+ argument: "id",
217
+ options: JSON_OPTION
218
+ },
219
+ for: {
220
+ usage: "rulebookguard for <path> [--json]",
221
+ argument: "path",
222
+ options: JSON_OPTION
223
+ },
224
+ impact: {
225
+ usage: "rulebookguard impact <id> [--json]",
226
+ argument: "id",
227
+ options: JSON_OPTION
228
+ },
229
+ new: {
230
+ usage: "rulebookguard new <context>",
231
+ argument: "context",
232
+ options: {}
233
+ },
234
+ lint: {
235
+ usage: "rulebookguard lint [--base <ref>] [--json]",
236
+ options: {
237
+ ...JSON_OPTION,
238
+ base: { type: "string" }
239
+ }
240
+ }
241
+ };
242
+ async function runCli(argv, io) {
243
+ try {
244
+ return await dispatch(argv, io);
245
+ } catch (error) {
246
+ if (error instanceof CliError) {
247
+ io.stderr(`${error.message}\n`);
248
+ return error.exitCode;
249
+ }
250
+ if (error instanceof FormatError) {
251
+ io.stderr(`${error.issues.map(formatIssue).join("\n")}\n`);
252
+ return 1;
253
+ }
254
+ throw error;
255
+ }
256
+ }
257
+ async function dispatch(argv, io) {
258
+ const [name, ...rest] = argv;
259
+ if (name === void 0 || name === "--help" || name === "-h" || name === "help") {
260
+ io.stdout(USAGE);
261
+ return 0;
262
+ }
263
+ const spec = COMMANDS[name];
264
+ if (spec === void 0) throw new CliError(`Unknown command "${name}".\n${USAGE.trimEnd()}`, 2);
265
+ const { values, argument } = parse(spec, rest);
266
+ const json = values.json === true;
267
+ if (name === "lint") return lintCommand(io, {
268
+ base: values.base,
269
+ json
270
+ });
271
+ const catalog = await openCatalog(io);
272
+ switch (name) {
273
+ case "contexts":
274
+ contextsCommand(catalog, io, json);
275
+ break;
276
+ case "list":
277
+ listCommand(catalog, io, {
278
+ context: values.context,
279
+ status: values.status,
280
+ json
281
+ });
282
+ break;
283
+ case "show":
284
+ showCommand(catalog, io, argument, json);
285
+ break;
286
+ case "for":
287
+ forCommand(catalog, io, argument, json);
288
+ break;
289
+ case "impact":
290
+ await impactCommand(catalog, io, argument, json);
291
+ break;
292
+ case "new": await newCommand(catalog, io, argument);
293
+ }
294
+ return 0;
295
+ }
296
+ function parse(spec, args) {
297
+ let parsed;
298
+ try {
299
+ parsed = parseArgs({
300
+ args,
301
+ options: spec.options,
302
+ allowPositionals: true,
303
+ strict: true
304
+ });
305
+ } catch (error) {
306
+ throw new CliError(`${error.message}\nUsage: ${spec.usage}`, 2);
307
+ }
308
+ const expected = spec.argument === void 0 ? 0 : 1;
309
+ if (parsed.positionals.length < expected) throw new CliError(`Missing argument <${spec.argument}>. Usage: ${spec.usage}`, 2);
310
+ if (parsed.positionals.length > expected) throw new CliError(`Unexpected argument "${parsed.positionals[expected]}". Usage: ${spec.usage}`, 2);
311
+ return {
312
+ values: parsed.values,
313
+ argument: parsed.positionals[0] ?? ""
314
+ };
315
+ }
316
+ // src/cli.ts
317
+ process.exitCode = await runCli(process.argv.slice(2), {
318
+ cwd: process.cwd(),
319
+ stdout: (text) => process.stdout.write(text),
320
+ stderr: (text) => process.stderr.write(text)
321
+ });
322
+ export {};