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 +21 -0
- package/README.md +310 -2
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +322 -0
- package/dist/index.d.mts +152 -0
- package/dist/index.mjs +1 -0
- package/dist/lint-DtD_Ekei.mjs +763 -0
- package/package.json +42 -4
- package/schemas/v1/config.schema.json +15 -0
- package/schemas/v1/context.schema.json +13 -0
- package/schemas/v1/rule.schema.json +20 -0
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
|
-
#
|
|
1
|
+
# rulebookguard
|
|
2
2
|
|
|
3
|
-
|
|
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 {};
|