rulebookguard 0.0.0-stage → 0.1.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/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,308 @@
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
+ ```
40
+
41
+ `schema` and `testGlobs` are required (`testGlobs: []` is valid but finds no tags). Unknown fields are rejected. The JSON Schemas ship with the package under `rulebookguard/schemas/v1/`.
42
+
43
+ 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.
44
+
45
+ ## Catalog layout
46
+
47
+ Rules are grouped by context, one directory per context:
48
+
49
+ ```text
50
+ docs/rules/
51
+ clients/
52
+ _context.md # the context: name, id prefix, summary
53
+ R-CLI-001.md # one rule per file, named after its id
54
+ R-CLI-002.md
55
+ billing/
56
+ _context.md
57
+ R-BIL-001.md
58
+ ```
59
+
60
+ `_context.md`:
61
+
62
+ ```markdown
63
+ ---
64
+ context: clients
65
+ prefix: CLI
66
+ summary: Customers that receive installation services.
67
+ ---
68
+
69
+ The clients context owns customer identity and contact data.
70
+ ```
71
+
72
+ The `context` field must match the directory name, and every rule id in the directory must start with `R-<prefix>-`.
73
+
74
+ ## Rule file format
75
+
76
+ ````markdown
77
+ ---
78
+ id: R-CLI-001
79
+ context: clients
80
+ entities: [Client]
81
+ status: active
82
+ supersedes: R-CLI-000
83
+ anchors:
84
+ - 'src/clients/**/*.ts'
85
+ - 'src/api/clients.controller.ts'
86
+ summary: A client document number is unique per country.
87
+ ---
88
+
89
+ # Unique document number
90
+
91
+ Two clients in the same country cannot share a document number.
92
+ The check ignores letter case and leading zeros.
93
+
94
+ ```gherkin
95
+ Scenario: Duplicate document in the same country
96
+ Given a client with document "00123" in "VE"
97
+ When another client is created with document "123" in "VE"
98
+ Then the creation is rejected with "document already registered"
99
+ ```
100
+ ````
101
+
102
+ | Field | Required | Meaning |
103
+ | ------------ | -------- | ---------------------------------------------------------------- |
104
+ | `id` | yes | `R-<PREFIX>-<NNN>`, three digits, unique, equal to the file name |
105
+ | `context` | yes | the context directory the rule lives in |
106
+ | `entities` | yes | domain entities the rule talks about |
107
+ | `status` | yes | `proposed`, `active` or `deprecated` |
108
+ | `supersedes` | no | id of the rule this one replaces |
109
+ | `anchors` | yes | repo-relative globs of the code that implements the rule |
110
+ | `summary` | yes | one line; this is what `list` shows |
111
+
112
+ 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.
113
+
114
+ ## The `@rule` tag
115
+
116
+ A test links to a rule with `@rule <ID>` inside any comment, in any file matched by `testGlobs`. One comment can carry several tags.
117
+
118
+ TypeScript or JavaScript:
119
+
120
+ ```ts
121
+ // @rule R-CLI-001
122
+ it('rejects a duplicate document in the same country', () => {
123
+ /* ... */
124
+ });
125
+ ```
126
+
127
+ Go:
128
+
129
+ ```go
130
+ // @rule R-CLI-001 @rule R-BIL-001
131
+ func TestCreateClient(t *testing.T) {}
132
+ ```
133
+
134
+ Python:
135
+
136
+ ```python
137
+ # @rule R-CLI-001
138
+ def test_duplicate_document():
139
+ ...
140
+ ```
141
+
142
+ Java or Kotlin:
143
+
144
+ ```java
145
+ /** @rule R-CLI-001 */
146
+ @Test
147
+ void rejectsDuplicateDocument() {}
148
+ ```
149
+
150
+ SQL:
151
+
152
+ ```sql
153
+ -- @rule R-BIL-002
154
+ SELECT count(*) FROM invoices WHERE number IS NULL;
155
+ ```
156
+
157
+ ## Commands
158
+
159
+ `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).
160
+
161
+ 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`.
162
+
163
+ ### `contexts` (L0)
164
+
165
+ ```sh
166
+ $ rulebookguard contexts
167
+ billing (BIL, 2 rules): Invoices and taxes charged to clients.
168
+ clients (CLI, 3 rules): Customers that receive installation services.
169
+ ```
170
+
171
+ ### `list [--context <c>] [--status <s>]` (L1)
172
+
173
+ ```sh
174
+ $ rulebookguard list --context clients --status active
175
+ R-CLI-001 [active] A client document number is unique per country.
176
+ ```
177
+
178
+ Exits 1 for an unknown context and 2 for an invalid status.
179
+
180
+ ### `show <id>` (L2)
181
+
182
+ ```sh
183
+ $ rulebookguard show R-CLI-001
184
+ R-CLI-001: A client document number is unique per country.
185
+ context: clients
186
+ status: active
187
+ entities: Client
188
+ anchors: src/clients/**/*.ts, src/api/clients.controller.ts
189
+ supersedes: R-CLI-000
190
+ file: docs/rules/clients/R-CLI-001.md
191
+
192
+ # Unique document number
193
+ ...
194
+ ```
195
+
196
+ Exits 1 for an unknown id.
197
+
198
+ ### `for <path>`
199
+
200
+ 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.
201
+
202
+ ```sh
203
+ $ rulebookguard for src/clients/create.ts
204
+ R-CLI-001 (clients): A client document number is unique per country.
205
+ ```
206
+
207
+ Exits 1 when the path is outside the rulebook root.
208
+
209
+ ### `impact <id>`
210
+
211
+ Files matched by a rule's anchors and the tests tagging it.
212
+
213
+ ```sh
214
+ $ rulebookguard impact R-CLI-001
215
+ R-CLI-001: A client document number is unique per country.
216
+ Files matched by anchors (2):
217
+ src/clients/create.ts
218
+ src/clients/update.ts
219
+ Tests tagging R-CLI-001 (1):
220
+ src/clients/create.test.ts:12
221
+ ```
222
+
223
+ Exits 1 for an unknown id.
224
+
225
+ ### `new <context>`
226
+
227
+ 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.
228
+
229
+ ```sh
230
+ $ rulebookguard new clients
231
+ docs/rules/clients/R-CLI-004.md
232
+ ```
233
+
234
+ Exits 1 for an unknown context or when the context has no free id left.
235
+
236
+ ### `lint [--base <ref>] [--json]`
237
+
238
+ Checks the whole catalog. Each finding is printed as `<severity> <code> <file>[:line] <message>`, followed by a count.
239
+
240
+ | Severity | Code | Meaning |
241
+ | -------- | ------------------------ | ---------------------------------------------------------------- |
242
+ | error | `untested-rule` | an active rule has no test tagging it |
243
+ | error | `unknown-rule-tag` | a test tags a rule id that does not exist |
244
+ | error | `deprecated-rule-tag` | a test tags a deprecated rule |
245
+ | error | `unmatched-anchor` | an anchor glob matches no file |
246
+ | error | `schema` | a config, context or rule file breaks the JSON Schema |
247
+ | error | `duplicate-id` | two rule files share an id |
248
+ | error | `file-name-mismatch` | a rule file is not named `<id>.md` |
249
+ | error | `id-prefix-mismatch` | a rule id does not use its context prefix |
250
+ | error | `context-mismatch` | a `context` field does not match its directory |
251
+ | error | `missing-context-file` | a context directory has no `_context.md` |
252
+ | error | `missing-rules-dir` | `rulesDir` does not exist |
253
+ | error | `pending-implementation` | with `--base`: an active rule changed, but none of its tests did |
254
+ | warning | `context-over-budget` | a context holds more rules than `contextBudget` |
255
+
256
+ ```sh
257
+ $ rulebookguard lint
258
+ error untested-rule docs/rules/billing/R-BIL-002.md active rule R-BIL-002 has no test tagging it
259
+ warning context-over-budget docs/rules/clients/_context.md context "clients" has 31 rules, more than the contextBudget of 30
260
+ 1 error, 1 warning
261
+ ```
262
+
263
+ 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`.
264
+
265
+ 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.
266
+
267
+ ```sh
268
+ $ rulebookguard lint --base origin/main
269
+ 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
270
+ 1 error, 0 warnings
271
+ ```
272
+
273
+ `--json` prints `{ "findings": [{ "severity", "code", "file", "line"?, "message" }], "errors", "warnings" }`.
274
+
275
+ ## CI recipe
276
+
277
+ Run the linter on every pull request, comparing against the main branch. The full history is needed so that `origin/main` exists.
278
+
279
+ ```yaml
280
+ name: rules
281
+ on: pull_request
282
+
283
+ jobs:
284
+ rulebookguard:
285
+ runs-on: ubuntu-latest
286
+ steps:
287
+ - uses: actions/checkout@v7.0.1
288
+ with:
289
+ fetch-depth: 0
290
+ - run: npx --yes rulebookguard@0.1.0 lint --base origin/main
291
+ ```
292
+
293
+ 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.
294
+
295
+ ## Releasing
296
+
297
+ 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:
298
+
299
+ ```sh
300
+ git tag v0.1.0
301
+ git push origin v0.1.0
302
+ ```
303
+
304
+ 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.
305
+
306
+ ## License
307
+
308
+ MIT
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/dist/cli.mjs ADDED
@@ -0,0 +1,303 @@
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-V-Uw5ueZ.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), { 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
+ function template(id, context) {
28
+ return `---
29
+ id: ${id}
30
+ context: ${context}
31
+ entities: []
32
+ status: proposed
33
+ anchors: []
34
+ summary: 'TODO: one-line summary of the rule.'
35
+ ---
36
+
37
+ # TODO: rule title
38
+
39
+ TODO: state the rule in full, then add examples as Gherkin scenarios or tables.
40
+
41
+ \`\`\`gherkin
42
+ Scenario: TODO
43
+ Given TODO
44
+ When TODO
45
+ Then TODO
46
+ \`\`\`
47
+ `;
48
+ }
49
+ // src/commands/read.ts
50
+ const RULE_STATUSES = [
51
+ "proposed",
52
+ "active",
53
+ "deprecated"
54
+ ];
55
+ function contextsCommand(catalog, io, json) {
56
+ const contexts = [...catalog.contexts].sort((a, b) => compare(a.context, b.context)).map(({ context, prefix, summary, file }) => ({
57
+ context,
58
+ prefix,
59
+ summary,
60
+ rules: catalog.rules.filter((rule) => rule.context === context).length,
61
+ file
62
+ }));
63
+ if (json) {
64
+ writeJson(io, { contexts });
65
+ return;
66
+ }
67
+ writeLines(io, contexts.map((item) => `${item.context} (${item.prefix}, ${item.rules} ${item.rules === 1 ? "rule" : "rules"}): ${item.summary}`));
68
+ }
69
+ function listCommand(catalog, io, options) {
70
+ const { context, status } = options;
71
+ if (status !== void 0 && !RULE_STATUSES.includes(status)) throw new CliError(`Invalid status "${status}": expected one of ${RULE_STATUSES.join(", ")}.`, 2);
72
+ if (context !== void 0 && !catalog.contexts.some((item) => item.context === context)) throw new CliError(`Unknown context "${context}". Run "rulebookguard contexts" to list them.`);
73
+ const rules = sortById(catalog.rules).filter((rule) => context === void 0 || rule.context === context).filter((rule) => status === void 0 || rule.status === status).map(toSummary);
74
+ if (options.json) {
75
+ writeJson(io, { rules });
76
+ return;
77
+ }
78
+ writeLines(io, rules.map((rule) => `${rule.id} [${rule.status}] ${rule.summary}`));
79
+ }
80
+ function showCommand(catalog, io, id, json) {
81
+ const rule = findRule(catalog, id);
82
+ if (json) {
83
+ writeJson(io, { rule: {
84
+ ...toSummary(rule),
85
+ entities: rule.entities,
86
+ anchors: rule.anchors,
87
+ supersedes: rule.supersedes ?? null,
88
+ file: rule.file,
89
+ body: rule.body
90
+ } });
91
+ return;
92
+ }
93
+ const header = [
94
+ `${rule.id}: ${rule.summary}`,
95
+ `context: ${rule.context}`,
96
+ `status: ${rule.status}`,
97
+ `entities: ${rule.entities.join(", ")}`,
98
+ `anchors: ${rule.anchors.join(", ")}`,
99
+ ...rule.supersedes === void 0 ? [] : [`supersedes: ${rule.supersedes}`],
100
+ `file: ${rule.file}`
101
+ ];
102
+ io.stdout(`${header.join("\n")}\n\n${rule.body}`);
103
+ }
104
+ function forCommand(catalog, io, input, json) {
105
+ const path = toRootPath(catalog.root, resolve(io.cwd, input), input);
106
+ const rules = rulesForPath(catalog.rules, path).filter((rule) => rule.status === "active").map((rule) => ({
107
+ ...toSummary(rule),
108
+ file: rule.file
109
+ }));
110
+ if (json) {
111
+ writeJson(io, {
112
+ path,
113
+ rules
114
+ });
115
+ return;
116
+ }
117
+ if (rules.length === 0) {
118
+ writeLines(io, [`No active rules anchored to ${path}.`]);
119
+ return;
120
+ }
121
+ writeLines(io, rules.map((rule) => `${rule.id} (${rule.context}): ${rule.summary}`));
122
+ }
123
+ async function impactCommand(catalog, io, id, json) {
124
+ const rule = findRule(catalog, id);
125
+ const warn = gitFallbackWarning(io);
126
+ const files = await expandGlobs(catalog.root, rule.anchors, warn);
127
+ const tests = (await scanTestTags(catalog.root, catalog.config.testGlobs, warn)).get(id) ?? [];
128
+ if (json) {
129
+ writeJson(io, {
130
+ id,
131
+ files,
132
+ tests
133
+ });
134
+ return;
135
+ }
136
+ writeLines(io, [
137
+ `${rule.id}: ${rule.summary}`,
138
+ `Files matched by anchors (${files.length}):`,
139
+ ...indent(files),
140
+ `Tests tagging ${rule.id} (${tests.length}):`,
141
+ ...indent(tests.map((test) => `${test.file}:${test.line}`))
142
+ ]);
143
+ }
144
+ function toRootPath(root, absolute, input) {
145
+ const path = toRepoPath(relative(root, absolute));
146
+ if (path === "" || path === ".." || path.startsWith("../") || isAbsolute(path)) throw new CliError(`Path "${input}" is outside the rulebook root ${root}.`);
147
+ return path;
148
+ }
149
+ function toSummary(rule) {
150
+ return {
151
+ id: rule.id,
152
+ context: rule.context,
153
+ status: rule.status,
154
+ summary: rule.summary
155
+ };
156
+ }
157
+ function indent(lines) {
158
+ return lines.length === 0 ? [" (none)"] : lines.map((line) => ` ${line}`);
159
+ }
160
+ function sortById(rules) {
161
+ return [...rules].sort((a, b) => compare(a.id, b.id));
162
+ }
163
+ function compare(a, b) {
164
+ return a < b ? -1 : a > b ? 1 : 0;
165
+ }
166
+ // src/commands/run.ts
167
+ const USAGE = `Usage: rulebookguard <command> [options]
168
+
169
+ Commands:
170
+ contexts [--json] L0: list the contexts
171
+ list [--context <c>] [--status <s>] [--json] L1: id and summary of each rule
172
+ show <id> [--json] L2: the full rule
173
+ for <path> [--json] active rules anchored to a path
174
+ impact <id> [--json] files matched by a rule's anchors and tests tagging it
175
+ new <context> scaffold the next free rule id as a proposed rule
176
+ lint [--base <ref>] [--json] check the catalog, anchors and test tags;
177
+ --base: rules changed since <ref> need a changed test
178
+
179
+ Exit codes: 0 success, 1 failure (lint: any error), 2 usage error.
180
+ `;
181
+ const JSON_OPTION = { json: { type: "boolean" } };
182
+ const COMMANDS = {
183
+ contexts: {
184
+ usage: "rulebookguard contexts [--json]",
185
+ options: JSON_OPTION
186
+ },
187
+ list: {
188
+ usage: "rulebookguard list [--context <c>] [--status <s>] [--json]",
189
+ options: {
190
+ ...JSON_OPTION,
191
+ context: { type: "string" },
192
+ status: { type: "string" }
193
+ }
194
+ },
195
+ show: {
196
+ usage: "rulebookguard show <id> [--json]",
197
+ argument: "id",
198
+ options: JSON_OPTION
199
+ },
200
+ for: {
201
+ usage: "rulebookguard for <path> [--json]",
202
+ argument: "path",
203
+ options: JSON_OPTION
204
+ },
205
+ impact: {
206
+ usage: "rulebookguard impact <id> [--json]",
207
+ argument: "id",
208
+ options: JSON_OPTION
209
+ },
210
+ new: {
211
+ usage: "rulebookguard new <context>",
212
+ argument: "context",
213
+ options: {}
214
+ },
215
+ lint: {
216
+ usage: "rulebookguard lint [--base <ref>] [--json]",
217
+ options: {
218
+ ...JSON_OPTION,
219
+ base: { type: "string" }
220
+ }
221
+ }
222
+ };
223
+ async function runCli(argv, io) {
224
+ try {
225
+ return await dispatch(argv, io);
226
+ } catch (error) {
227
+ if (error instanceof CliError) {
228
+ io.stderr(`${error.message}\n`);
229
+ return error.exitCode;
230
+ }
231
+ if (error instanceof FormatError) {
232
+ io.stderr(`${error.issues.map(formatIssue).join("\n")}\n`);
233
+ return 1;
234
+ }
235
+ throw error;
236
+ }
237
+ }
238
+ async function dispatch(argv, io) {
239
+ const [name, ...rest] = argv;
240
+ if (name === void 0 || name === "--help" || name === "-h" || name === "help") {
241
+ io.stdout(USAGE);
242
+ return 0;
243
+ }
244
+ const spec = COMMANDS[name];
245
+ if (spec === void 0) throw new CliError(`Unknown command "${name}".\n${USAGE.trimEnd()}`, 2);
246
+ const { values, argument } = parse(spec, rest);
247
+ const json = values.json === true;
248
+ if (name === "lint") return lintCommand(io, {
249
+ base: values.base,
250
+ json
251
+ });
252
+ const catalog = await openCatalog(io);
253
+ switch (name) {
254
+ case "contexts":
255
+ contextsCommand(catalog, io, json);
256
+ break;
257
+ case "list":
258
+ listCommand(catalog, io, {
259
+ context: values.context,
260
+ status: values.status,
261
+ json
262
+ });
263
+ break;
264
+ case "show":
265
+ showCommand(catalog, io, argument, json);
266
+ break;
267
+ case "for":
268
+ forCommand(catalog, io, argument, json);
269
+ break;
270
+ case "impact":
271
+ await impactCommand(catalog, io, argument, json);
272
+ break;
273
+ case "new": await newCommand(catalog, io, argument);
274
+ }
275
+ return 0;
276
+ }
277
+ function parse(spec, args) {
278
+ let parsed;
279
+ try {
280
+ parsed = parseArgs({
281
+ args,
282
+ options: spec.options,
283
+ allowPositionals: true,
284
+ strict: true
285
+ });
286
+ } catch (error) {
287
+ throw new CliError(`${error.message}\nUsage: ${spec.usage}`, 2);
288
+ }
289
+ const expected = spec.argument === void 0 ? 0 : 1;
290
+ if (parsed.positionals.length < expected) throw new CliError(`Missing argument <${spec.argument}>. Usage: ${spec.usage}`, 2);
291
+ if (parsed.positionals.length > expected) throw new CliError(`Unexpected argument "${parsed.positionals[expected]}". Usage: ${spec.usage}`, 2);
292
+ return {
293
+ values: parsed.values,
294
+ argument: parsed.positionals[0] ?? ""
295
+ };
296
+ }
297
+ // src/cli.ts
298
+ process.exitCode = await runCli(process.argv.slice(2), {
299
+ cwd: process.cwd(),
300
+ stdout: (text) => process.stdout.write(text),
301
+ stderr: (text) => process.stderr.write(text)
302
+ });
303
+ export {};