rulebookguard 0.6.0 → 0.7.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 +135 -80
- package/dist/{anchors-e6T9BNoV.mjs → anchors-C2j9c5MI.mjs} +1 -1
- package/dist/cli.mjs +4 -4
- package/dist/index.d.mts +32 -3
- package/dist/index.mjs +3 -3
- package/dist/{lint-D63Q2SM0.mjs → lint-DfibQDW3.mjs} +153 -22
- package/dist/{site-DEdtLk6J.mjs → site-DmY5YVte.mjs} +4 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,17 +1,42 @@
|
|
|
1
|
+
<!-- prettier-ignore -->
|
|
2
|
+
<div align="center">
|
|
3
|
+
|
|
1
4
|
# rulebookguard
|
|
2
5
|
|
|
6
|
+
[](https://www.npmjs.com/package/rulebookguard)
|
|
7
|
+
[](https://github.com/JuanCJR/rulebookguard/actions/workflows/ci.yml)
|
|
8
|
+

|
|
9
|
+
[](https://www.typescriptlang.org)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
|
|
3
12
|
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
13
|
|
|
5
|
-
|
|
14
|
+
[Overview](#overview) • [Get started](#getting-started) • [Writing rules](#writing-rules) • [Commands](#commands) • [CI recipe](#ci-recipe) • [Claude Code plugin](#claude-code-plugin)
|
|
15
|
+
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
## Overview
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
`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` … `@endrule` regions**), and fails CI when they drift apart. It is language-agnostic: rules are Markdown, and tests in any language link to them through a comment.
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
docs/rules/clients/R-CLI-001.md ── anchors ──▶ src/clients/**/*.ts
|
|
26
|
+
▲
|
|
27
|
+
└── @rule R-CLI-001 … @endrule R-CLI-001 in src/clients/create.test.ts
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Features
|
|
6
31
|
|
|
7
32
|
- **Atomic rules.** One file per rule, with YAML frontmatter validated against a versioned JSON Schema.
|
|
8
33
|
- **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
34
|
- **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
|
|
35
|
+
- **Linter.** Untested rules, tags pointing at missing or deprecated rules, dead anchors, invalid files, and rules changed without a change in their tests.
|
|
36
|
+
- **AI-ready.** A Claude Code hook, an MCP server and a skill hand the rules of a file to an agent before it edits it.
|
|
37
|
+
- **Static site.** A read-only, searchable view of the rulebook for the whole team.
|
|
11
38
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
## Install
|
|
39
|
+
## Getting started
|
|
15
40
|
|
|
16
41
|
Requires Node.js 22 or later.
|
|
17
42
|
|
|
@@ -25,6 +50,13 @@ Or add it to a project:
|
|
|
25
50
|
npm install --save-dev rulebookguard
|
|
26
51
|
```
|
|
27
52
|
|
|
53
|
+
A first rule in four steps:
|
|
54
|
+
|
|
55
|
+
1. Create `rulebookguard.config.yaml` at the repository root (see [Configuration](#configuration)).
|
|
56
|
+
2. Scaffold a rule: `npx rulebookguard new clients` creates the context and `docs/rules/clients/R-CLI-001.md` as `proposed`.
|
|
57
|
+
3. Write the rule, add its `anchors`, set `status: active`, and wrap the test that proves it with `@rule R-CLI-001` and `@endrule R-CLI-001`.
|
|
58
|
+
4. Run `npx rulebookguard lint`, and add it to CI with the [CI recipe](#ci-recipe).
|
|
59
|
+
|
|
28
60
|
## Configuration
|
|
29
61
|
|
|
30
62
|
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.
|
|
@@ -39,13 +71,23 @@ contextBudget: 30 # default: 30; lint warns when a context holds more rules
|
|
|
39
71
|
language: es # default: en; language of the text that rulebookguard generates
|
|
40
72
|
```
|
|
41
73
|
|
|
42
|
-
`schema` and `testGlobs` are required (`testGlobs: []` is valid but finds no tags). Unknown fields are rejected.
|
|
74
|
+
`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/`.
|
|
75
|
+
|
|
76
|
+
`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.
|
|
77
|
+
|
|
78
|
+
> [!NOTE]
|
|
79
|
+
> `language` needs rulebookguard 0.1.1 or later; older versions reject it as an unknown field.
|
|
43
80
|
|
|
44
|
-
|
|
81
|
+
<details>
|
|
82
|
+
<summary>How files are listed</summary>
|
|
45
83
|
|
|
46
84
|
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
85
|
|
|
48
|
-
|
|
86
|
+
</details>
|
|
87
|
+
|
|
88
|
+
## Writing rules
|
|
89
|
+
|
|
90
|
+
### Catalog layout
|
|
49
91
|
|
|
50
92
|
Rules are grouped by context, one directory per context:
|
|
51
93
|
|
|
@@ -74,7 +116,7 @@ The clients context owns customer identity and contact data.
|
|
|
74
116
|
|
|
75
117
|
The `context` field must match the directory name, and every rule id in the directory must start with `R-<prefix>-`.
|
|
76
118
|
|
|
77
|
-
|
|
119
|
+
### Rule file format
|
|
78
120
|
|
|
79
121
|
````markdown
|
|
80
122
|
---
|
|
@@ -114,15 +156,14 @@ Scenario: Duplicate document in the same country
|
|
|
114
156
|
|
|
115
157
|
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
158
|
|
|
117
|
-
|
|
159
|
+
### Linking tests with `@rule` … `@endrule`
|
|
118
160
|
|
|
119
|
-
A test links to a rule with `@rule <ID>`
|
|
161
|
+
A test links to a rule with a region: `@rule <ID>` opens it and `@endrule <ID>` closes it, in a file matched by `testGlobs`. Both lines belong to the region. Regions may nest (a suite tagged with one rule holding tests tagged with others) as long as they close in order. One line can carry several markers.
|
|
120
162
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
```
|
|
163
|
+
> [!IMPORTANT]
|
|
164
|
+
> Every `@rule` needs its `@endrule`, otherwise `lint` reports `unclosed-rule-tag`; an `@endrule` with no open `@rule` of the same id is reported as `unmatched-endrule-tag`. The region tells `lint --base` which lines belong to the rule: a changed rule needs a change inside one of its regions, not anywhere in the test file.
|
|
124
165
|
|
|
125
|
-
To mention a rule in a test file without linking to it, write the id without the `@rule` prefix.
|
|
166
|
+
A comment is the usual place, but the markers are matched as text, so they also count inside a test name or a string. To mention a rule in a test file without linking to it, write the id without the `@rule` prefix.
|
|
126
167
|
|
|
127
168
|
TypeScript or JavaScript:
|
|
128
169
|
|
|
@@ -131,6 +172,7 @@ TypeScript or JavaScript:
|
|
|
131
172
|
it('rejects a duplicate document in the same country', () => {
|
|
132
173
|
/* ... */
|
|
133
174
|
});
|
|
175
|
+
// @endrule R-CLI-001
|
|
134
176
|
```
|
|
135
177
|
|
|
136
178
|
Go:
|
|
@@ -138,6 +180,7 @@ Go:
|
|
|
138
180
|
```go
|
|
139
181
|
// @rule R-CLI-001 @rule R-BIL-001
|
|
140
182
|
func TestCreateClient(t *testing.T) {}
|
|
183
|
+
// @endrule R-BIL-001 @endrule R-CLI-001
|
|
141
184
|
```
|
|
142
185
|
|
|
143
186
|
Python:
|
|
@@ -146,6 +189,7 @@ Python:
|
|
|
146
189
|
# @rule R-CLI-001
|
|
147
190
|
def test_duplicate_document():
|
|
148
191
|
...
|
|
192
|
+
# @endrule R-CLI-001
|
|
149
193
|
```
|
|
150
194
|
|
|
151
195
|
Java or Kotlin:
|
|
@@ -154,6 +198,7 @@ Java or Kotlin:
|
|
|
154
198
|
/** @rule R-CLI-001 */
|
|
155
199
|
@Test
|
|
156
200
|
void rejectsDuplicateDocument() {}
|
|
201
|
+
/** @endrule R-CLI-001 */
|
|
157
202
|
```
|
|
158
203
|
|
|
159
204
|
SQL:
|
|
@@ -161,12 +206,27 @@ SQL:
|
|
|
161
206
|
```sql
|
|
162
207
|
-- @rule R-BIL-002
|
|
163
208
|
SELECT count(*) FROM invoices WHERE number IS NULL;
|
|
209
|
+
-- @endrule R-BIL-002
|
|
164
210
|
```
|
|
165
211
|
|
|
166
212
|
## Commands
|
|
167
213
|
|
|
168
214
|
`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).
|
|
169
215
|
|
|
216
|
+
| Command | What it does |
|
|
217
|
+
| ------------------------------------------------------ | ------------------------------------------------------------ |
|
|
218
|
+
| [`contexts`](#contexts-l0) | the contexts map (L0) |
|
|
219
|
+
| [`list`](#list---context-c---status-s-l1) | rule ids and summaries (L1) |
|
|
220
|
+
| [`show <id>`](#show-id-l2) | the full rule (L2) |
|
|
221
|
+
| [`for <path>`](#for-path) | the active rules anchored to a file |
|
|
222
|
+
| [`impact <id>`](#impact-id) | the files and tests a rule touches |
|
|
223
|
+
| [`new <context>`](#new-context---prefix-p) | scaffolds the next rule of a context |
|
|
224
|
+
| [`lint`](#lint---base-ref---json) | checks the whole catalog, and optionally changes since a ref |
|
|
225
|
+
| [`hook`](#hook) | Claude Code hook that injects the rules of a file |
|
|
226
|
+
| [`mcp`](#mcp---project-dir) | MCP server for AI agents |
|
|
227
|
+
| [`site:generate`](#sitegenerate---out-dir---base-path) | builds a static site of the rulebook |
|
|
228
|
+
| [`site:run`](#siterun---out-dir---base-path---port-n) | builds the site and serves it locally |
|
|
229
|
+
|
|
170
230
|
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`.
|
|
171
231
|
|
|
172
232
|
### `contexts` (L0)
|
|
@@ -252,9 +312,53 @@ docs/rules/inventory/R-INV-001.md
|
|
|
252
312
|
|
|
253
313
|
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.
|
|
254
314
|
|
|
315
|
+
### `lint [--base <ref>] [--json]`
|
|
316
|
+
|
|
317
|
+
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.
|
|
318
|
+
|
|
319
|
+
| Severity | Code | Meaning |
|
|
320
|
+
| -------- | ------------------------ | ------------------------------------------------------------------ |
|
|
321
|
+
| error | `untested-rule` | an active rule has no test tagging it |
|
|
322
|
+
| error | `unknown-rule-tag` | a test tags a rule id that does not exist |
|
|
323
|
+
| error | `deprecated-rule-tag` | a test tags a deprecated rule |
|
|
324
|
+
| error | `unclosed-rule-tag` | a `@rule` has no matching `@endrule` |
|
|
325
|
+
| error | `unmatched-endrule-tag` | an `@endrule` has no open `@rule` of the same id |
|
|
326
|
+
| error | `unmatched-anchor` | an anchor glob matches no file |
|
|
327
|
+
| error | `schema` | a config, context or rule file breaks the JSON Schema |
|
|
328
|
+
| error | `duplicate-id` | two rule files share an id |
|
|
329
|
+
| error | `file-name-mismatch` | a rule file is not named `<id>.md` |
|
|
330
|
+
| error | `id-prefix-mismatch` | a rule id does not use its context prefix |
|
|
331
|
+
| error | `context-mismatch` | a `context` field does not match its directory |
|
|
332
|
+
| error | `missing-context-file` | a context directory has no `_context.md` |
|
|
333
|
+
| error | `missing-rules-dir` | `rulesDir` does not exist |
|
|
334
|
+
| error | `pending-implementation` | with `--base`: an active rule changed, but none of its regions did |
|
|
335
|
+
| warning | `context-over-budget` | a context holds more rules than `contextBudget` |
|
|
336
|
+
|
|
337
|
+
```sh
|
|
338
|
+
$ rulebookguard lint
|
|
339
|
+
error untested-rule docs/rules/billing/R-BIL-002.md active rule R-BIL-002 has no test tagging it
|
|
340
|
+
hint: wrap a test in a file matched by testGlobs with "@rule R-BIL-002" and "@endrule R-BIL-002", or set the rule's status to proposed
|
|
341
|
+
warning context-over-budget docs/rules/clients/_context.md context "clients" has 31 rules, more than the contextBudget of 30
|
|
342
|
+
hint: split the context into smaller ones, or raise contextBudget in rulebookguard.config.yaml
|
|
343
|
+
1 error, 1 warning
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
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`.
|
|
347
|
+
|
|
348
|
+
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 inside at least one of its `@rule` … `@endrule` regions (`git diff -U0 <ref>`; a test file that is still untracked counts whole, and a deletion counts when it falls strictly inside the region), otherwise it is reported as `pending-implementation`. A change elsewhere in the same test file, such as another test or a blank line between regions, does not count; 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.
|
|
349
|
+
|
|
350
|
+
```sh
|
|
351
|
+
$ rulebookguard lint --base origin/main
|
|
352
|
+
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
|
|
353
|
+
hint: update or add a test inside an "@rule R-CLI-001" … "@endrule R-CLI-001" region that covers the changed rule
|
|
354
|
+
1 error, 0 warnings
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`--json` prints `{ "findings": [{ "severity", "code", "file", "line"?, "message", "hint" }], "errors", "warnings" }`.
|
|
358
|
+
|
|
255
359
|
### `hook`
|
|
256
360
|
|
|
257
|
-
A Claude Code hook for `PreToolUse` and `PostToolUse` events of tools that take a `file_path` (the Claude Code plugin
|
|
361
|
+
A Claude Code hook for `PreToolUse` and `PostToolUse` events of tools that take a `file_path` (the [Claude Code plugin](#claude-code-plugin) wires it up). It reads the hook event as JSON on stdin, takes `tool_input.file_path` (relative paths resolve against the event's `cwd`), finds `rulebookguard.config.yaml` by walking up from that file, and prints the active rules anchored to it as additional context for Claude, under the same `hookEventName`:
|
|
258
362
|
|
|
259
363
|
```sh
|
|
260
364
|
$ echo '{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":"/repo/src/billing/invoice.go"}}' | rulebookguard hook
|
|
@@ -263,7 +367,10 @@ $ echo '{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_
|
|
|
263
367
|
|
|
264
368
|
Claude Code adds the context next to the tool result, so a `PostToolUse` hook on `Read` delivers the rules together with the file contents, before Claude writes a change (Claude Code requires reading a file before editing it).
|
|
265
369
|
|
|
266
|
-
Each rule comes with its id, context, summary, rule file and full text; when the texts add up to more than 10,000 characters only the summaries are listed, with a pointer to `rulebookguard show <id>`. The header follows `language` in the config.
|
|
370
|
+
Each rule comes with its id, context, summary, rule file and full text; when the texts add up to more than 10,000 characters only the summaries are listed, with a pointer to `rulebookguard show <id>`. The header follows `language` in the config.
|
|
371
|
+
|
|
372
|
+
> [!NOTE]
|
|
373
|
+
> The hook never blocks a tool and never answers the permission prompt: with no config, a path outside the rulebook root, no active rule, another event, unreadable input or any error, it prints nothing and exits 0.
|
|
267
374
|
|
|
268
375
|
### `mcp [--project <dir>]`
|
|
269
376
|
|
|
@@ -278,11 +385,12 @@ The rulebook is found from `--project` (default: the current directory). A comma
|
|
|
278
385
|
|
|
279
386
|
### `site:generate [--out <dir>] [--base <path>]`
|
|
280
387
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
388
|
+
> [!IMPORTANT]
|
|
389
|
+
> The site commands build with Astro, which lives in a separate package so that installing the linter does not download it. Install it next to `rulebookguard` (it requires Node.js 22.23.4 or later):
|
|
390
|
+
>
|
|
391
|
+
> ```sh
|
|
392
|
+
> npm install --save-dev rulebookguard @rulebookguard/site
|
|
393
|
+
> ```
|
|
286
394
|
|
|
287
395
|
Without it, the site commands fail, exit 1, with `The site commands need the @rulebookguard/site package. Install it next to rulebookguard: npm install --save-dev @rulebookguard/site`. One-off, without installing: `npx -p rulebookguard -p @rulebookguard/site rulebookguard site:run`.
|
|
288
396
|
|
|
@@ -314,49 +422,10 @@ The site has:
|
|
|
314
422
|
- **Health:** the `lint` findings when the site was built.
|
|
315
423
|
- Full-text search (Pagefind).
|
|
316
424
|
|
|
317
|
-
Generated text and the interface follow `language` in the config. The site is a view: rules are still edited in their files, through pull requests.
|
|
318
|
-
|
|
319
|
-
### `lint [--base <ref>] [--json]`
|
|
320
|
-
|
|
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.
|
|
322
|
-
|
|
323
|
-
| Severity | Code | Meaning |
|
|
324
|
-
| -------- | ------------------------ | ---------------------------------------------------------------- |
|
|
325
|
-
| error | `untested-rule` | an active rule has no test tagging it |
|
|
326
|
-
| error | `unknown-rule-tag` | a test tags a rule id that does not exist |
|
|
327
|
-
| error | `deprecated-rule-tag` | a test tags a deprecated rule |
|
|
328
|
-
| error | `unmatched-anchor` | an anchor glob matches no file |
|
|
329
|
-
| error | `schema` | a config, context or rule file breaks the JSON Schema |
|
|
330
|
-
| error | `duplicate-id` | two rule files share an id |
|
|
331
|
-
| error | `file-name-mismatch` | a rule file is not named `<id>.md` |
|
|
332
|
-
| error | `id-prefix-mismatch` | a rule id does not use its context prefix |
|
|
333
|
-
| error | `context-mismatch` | a `context` field does not match its directory |
|
|
334
|
-
| error | `missing-context-file` | a context directory has no `_context.md` |
|
|
335
|
-
| error | `missing-rules-dir` | `rulesDir` does not exist |
|
|
336
|
-
| error | `pending-implementation` | with `--base`: an active rule changed, but none of its tests did |
|
|
337
|
-
| warning | `context-over-budget` | a context holds more rules than `contextBudget` |
|
|
425
|
+
Generated text and the interface follow `language` in the config. The site is a view: rules are still edited in their files, through pull requests.
|
|
338
426
|
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
|
345
|
-
1 error, 1 warning
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
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`.
|
|
349
|
-
|
|
350
|
-
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.
|
|
351
|
-
|
|
352
|
-
```sh
|
|
353
|
-
$ rulebookguard lint --base origin/main
|
|
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
|
|
356
|
-
1 error, 0 warnings
|
|
357
|
-
```
|
|
358
|
-
|
|
359
|
-
`--json` prints `{ "findings": [{ "severity", "code", "file", "line"?, "message", "hint" }], "errors", "warnings" }`.
|
|
427
|
+
> [!WARNING]
|
|
428
|
+
> `site` replaces `--out` only when it is empty or was written by an earlier `site`; any other non-empty directory makes it fail, exit 1, without touching it. Add the output directory to `.gitignore` unless you mean to commit it.
|
|
360
429
|
|
|
361
430
|
## CI recipe
|
|
362
431
|
|
|
@@ -376,7 +445,8 @@ jobs:
|
|
|
376
445
|
- run: npx --yes rulebookguard@0.1.0 lint --base origin/main
|
|
377
446
|
```
|
|
378
447
|
|
|
379
|
-
|
|
448
|
+
> [!TIP]
|
|
449
|
+
> 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.
|
|
380
450
|
|
|
381
451
|
## Claude Code plugin
|
|
382
452
|
|
|
@@ -396,18 +466,3 @@ claude plugin install rulebookguard@rulebookguard
|
|
|
396
466
|
Or, inside Claude Code 2.1.275 or later: `/plugin install rulebookguard --marketplace JuanCJR/rulebookguard`.
|
|
397
467
|
|
|
398
468
|
The plugin runs the project's own rulebookguard (`node_modules/rulebookguard`, in the project directory or a parent) when its version is at least the plugin's; otherwise it runs `npx rulebookguard@<plugin version>`, which needs Node.js and network access the first time. The hooks never block a tool: any failure, including a timeout, lets it go ahead without rules.
|
|
399
|
-
|
|
400
|
-
## Releasing
|
|
401
|
-
|
|
402
|
-
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` and in `plugin/.claude-plugin/plugin.json` (a test keeps them equal), merge it to `main`, then push a matching tag:
|
|
403
|
-
|
|
404
|
-
```sh
|
|
405
|
-
git tag v0.1.0
|
|
406
|
-
git push origin v0.1.0
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
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.
|
|
410
|
-
|
|
411
|
-
## License
|
|
412
|
-
|
|
413
|
-
MIT
|
package/dist/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { t as rulesForPath } from "./anchors-
|
|
2
|
+
import { F as messages, I as FormatError, L as formatIssue, O as CONTEXT_FILE_NAME, _ as expandGlobs, a as CliError, c as openCatalog, f as scanTestTags, l as writeJson, m as loadCatalog, o as findRule, p as findRulebookRoot, r as lintCommand, s as gitFallbackWarning, u as writeLines, y as toRepoPath } from "./lint-DfibQDW3.mjs";
|
|
3
|
+
import { t as rulesForPath } from "./anchors-C2j9c5MI.mjs";
|
|
4
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";
|
|
@@ -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.7.0";
|
|
84
84
|
// src/mcp/server.ts
|
|
85
85
|
function createRulesServer(projectDir) {
|
|
86
86
|
const server = new McpServer({
|
|
@@ -504,7 +504,7 @@ async function dispatch(argv, io) {
|
|
|
504
504
|
return 0;
|
|
505
505
|
}
|
|
506
506
|
if (name === "site" || name === "site:generate" || name === "site:run") {
|
|
507
|
-
const { siteCommand } = await import("./site-
|
|
507
|
+
const { siteCommand } = await import("./site-DmY5YVte.mjs");
|
|
508
508
|
await siteCommand(io, {
|
|
509
509
|
out: values.out,
|
|
510
510
|
base: values.base,
|
package/dist/index.d.mts
CHANGED
|
@@ -62,8 +62,21 @@ interface RuleTag {
|
|
|
62
62
|
id: string;
|
|
63
63
|
line: number;
|
|
64
64
|
}
|
|
65
|
+
interface RuleRegion extends RuleTag {
|
|
66
|
+
endLine: number;
|
|
67
|
+
}
|
|
68
|
+
type RuleTagProblemCode = "unclosed-rule-tag" | "unmatched-endrule-tag";
|
|
69
|
+
interface RuleTagProblem extends RuleTag {
|
|
70
|
+
code: RuleTagProblemCode;
|
|
71
|
+
}
|
|
72
|
+
interface RuleRegions {
|
|
73
|
+
regions: RuleRegion[];
|
|
74
|
+
problems: RuleTagProblem[];
|
|
75
|
+
}
|
|
65
76
|
export declare const RULE_TAG_PATTERN: RegExp;
|
|
77
|
+
export declare const RULE_MARKER_PATTERN: RegExp;
|
|
66
78
|
export declare function findRuleTags(source: string): RuleTag[];
|
|
79
|
+
export declare function findRuleRegions(source: string): RuleRegions;
|
|
67
80
|
export declare function rulesForPath(rules: RuleDoc[], path: string): RuleDoc[];
|
|
68
81
|
export declare function toRepoPath(path: string): string;
|
|
69
82
|
export declare function createGlobMatcher(globs: string[]): (path: string) => boolean;
|
|
@@ -93,8 +106,22 @@ interface TagLocation {
|
|
|
93
106
|
file: string;
|
|
94
107
|
line: number;
|
|
95
108
|
}
|
|
109
|
+
interface TagRegion extends TagLocation {
|
|
110
|
+
endLine: number;
|
|
111
|
+
}
|
|
112
|
+
interface TagProblem extends TagLocation {
|
|
113
|
+
code: RuleTagProblemCode;
|
|
114
|
+
id: string;
|
|
115
|
+
}
|
|
96
116
|
type TagIndex = Map<string, TagLocation[]>;
|
|
117
|
+
type RegionIndex = Map<string, TagRegion[]>;
|
|
118
|
+
interface TagScan {
|
|
119
|
+
tags: TagIndex;
|
|
120
|
+
regions: RegionIndex;
|
|
121
|
+
problems: TagProblem[];
|
|
122
|
+
}
|
|
97
123
|
export declare function scanTestTags(root: string, testGlobs: string[], onGitFallback?: GitFallbackListener): Promise<TagIndex>;
|
|
124
|
+
export declare function scanTestRegions(root: string, testGlobs: string[], onGitFallback?: GitFallbackListener): Promise<TagScan>;
|
|
98
125
|
interface ContextsJson {
|
|
99
126
|
contexts: {
|
|
100
127
|
context: string;
|
|
@@ -133,6 +160,8 @@ interface ImpactJson {
|
|
|
133
160
|
files: string[];
|
|
134
161
|
tests: TagLocation[];
|
|
135
162
|
}
|
|
163
|
+
/** Changed spans in the working tree; a pure deletion sits between two lines, as `n + 0.5`. */
|
|
164
|
+
type LineSpan = [start: number, end: number];
|
|
136
165
|
type LintSeverity = "error" | "warning";
|
|
137
166
|
interface LintFinding {
|
|
138
167
|
severity: LintSeverity;
|
|
@@ -152,7 +181,7 @@ interface LintOptions {
|
|
|
152
181
|
base?: string | undefined;
|
|
153
182
|
json: boolean;
|
|
154
183
|
}
|
|
155
|
-
export declare function lintCatalog(catalog: Catalog,
|
|
156
|
-
export declare function pendingImplementation(catalog: Catalog,
|
|
184
|
+
export declare function lintCatalog(catalog: Catalog, scan?: TagScan, onGitFallback?: GitFallbackListener): Promise<LintFinding[]>;
|
|
185
|
+
export declare function pendingImplementation(catalog: Catalog, scan: TagScan, base: string, changed: string[], lines: Map<string, LineSpan[]>): LintFinding[];
|
|
157
186
|
export declare function formatFinding(finding: LintFinding): string;
|
|
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 };
|
|
187
|
+
export type { Catalog, CatalogIssue, CatalogIssueCode, ContextDoc, ContextFrontmatter, ContextsJson, ForJson, FormatIssue, Frontmatter, GitFallbackListener, ImpactJson, LintFinding, LintJson, LintOptions, LintSeverity, ListJson, ParseOptions, RegionIndex, RuleDoc, RuleFrontmatter, RuleLanguage, RuleRegion, RuleRegions, RuleStatus, RuleSummaryJson, RuleTag, RuleTagProblem, RuleTagProblemCode, RulebookConfig, ShowJson, TagIndex, TagLocation, TagProblem, TagRegion, TagScan };
|
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
import { t as rulesForPath } from "./anchors-
|
|
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 };
|
|
1
|
+
import { A as parseContext, C as findRuleRegions, D as parseRule, E as loadRule, I as FormatError, L as formatIssue, M as CONFIG_FILE_NAME, N as loadConfig, O as CONTEXT_FILE_NAME, P as parseConfig, S as RULE_TAG_PATTERN, T as isRuleId, _ as expandGlobs, d as scanTestRegions, f as scanTestTags, g as createGlobMatcher, h as relativeToRoot, i as pendingImplementation, j as splitFrontmatter, k as loadContext, m as loadCatalog, n as lintCatalog, p as findRulebookRoot, t as formatFinding, v as listRepoFiles, w as findRuleTags, x as RULE_MARKER_PATTERN, y as toRepoPath } from "./lint-DfibQDW3.mjs";
|
|
2
|
+
import { t as rulesForPath } from "./anchors-C2j9c5MI.mjs";
|
|
3
|
+
export { CONFIG_FILE_NAME, CONTEXT_FILE_NAME, FormatError, RULE_MARKER_PATTERN, RULE_TAG_PATTERN, createGlobMatcher, expandGlobs, findRuleRegions, findRuleTags, findRulebookRoot, formatFinding, formatIssue, isRuleId, lintCatalog, listRepoFiles, loadCatalog, loadConfig, loadContext, loadRule, parseConfig, parseContext, parseRule, pendingImplementation, relativeToRoot, rulesForPath, scanTestRegions, scanTestTags, splitFrontmatter, toRepoPath };
|
|
@@ -186,6 +186,8 @@ const MESSAGES = {
|
|
|
186
186
|
unmatchedAnchor: (anchor) => `anchor "${anchor}" matches no file`,
|
|
187
187
|
unknownRuleTag: (id) => `tags ${id}, which does not exist`,
|
|
188
188
|
deprecatedRuleTag: (id) => `tags ${id}, which is deprecated`,
|
|
189
|
+
unclosedRuleTag: (id) => `@rule ${id} has no matching @endrule ${id}`,
|
|
190
|
+
unmatchedEndruleTag: (id) => `@endrule ${id} has no open @rule ${id} before it`,
|
|
189
191
|
contextOverBudget: (context, count, budget) => `context "${context}" has ${count} rules, more than the contextBudget of ${budget}`,
|
|
190
192
|
pendingImplementation: (id, base) => `rule ${id} changed since ${base} but none of the tests tagging it changed`,
|
|
191
193
|
noIssues: "No issues found.",
|
|
@@ -199,12 +201,14 @@ const MESSAGES = {
|
|
|
199
201
|
"id-prefix-mismatch": "change the id to use the context prefix, or move the rule to the context that owns its prefix",
|
|
200
202
|
"duplicate-id": "give one of the rules another id; \"rulebookguard new <context>\" picks the next free one"
|
|
201
203
|
},
|
|
202
|
-
untestedRuleHint: (id) => `
|
|
204
|
+
untestedRuleHint: (id) => `wrap a test in a file matched by testGlobs with "@rule ${id}" and "@endrule ${id}", or set the rule's status to proposed`,
|
|
203
205
|
unmatchedAnchorHint: "fix the glob so it matches the files that implement the rule, or remove it",
|
|
204
206
|
unknownRuleTagHint: "fix the id in the tag, or create the rule it refers to",
|
|
205
207
|
deprecatedRuleTagHint: (id, successor) => successor === void 0 ? `tag the rule that replaces ${id}, or remove the tag` : `tag ${successor}, which supersedes ${id}, instead`,
|
|
208
|
+
unclosedRuleTagHint: (id) => `add "@endrule ${id}" after the last line of the test, or close the inner regions first`,
|
|
209
|
+
unmatchedEndruleTagHint: (id) => `add "@rule ${id}" before the test, or remove the stray @endrule`,
|
|
206
210
|
contextOverBudgetHint: "split the context into smaller ones, or raise contextBudget in rulebookguard.config.yaml",
|
|
207
|
-
pendingImplementationHint: (id) => `update or add a test
|
|
211
|
+
pendingImplementationHint: (id) => `update or add a test inside an "@rule ${id}" … "@endrule ${id}" region that covers the changed rule`,
|
|
208
212
|
lintSummary: (errors, warnings) => `${errors} ${errors === 1 ? "error" : "errors"}, ${warnings} ${warnings === 1 ? "warning" : "warnings"}`,
|
|
209
213
|
needsWorkTree: (root) => `lint --base needs a git work tree, but ${root} is not inside one.`,
|
|
210
214
|
unknownRef: (ref) => `Unknown git ref "${ref}".`,
|
|
@@ -256,6 +260,8 @@ const MESSAGES = {
|
|
|
256
260
|
unmatchedAnchor: (anchor) => `el anchor "${anchor}" no coincide con ningún archivo`,
|
|
257
261
|
unknownRuleTag: (id) => `etiqueta ${id}, que no existe`,
|
|
258
262
|
deprecatedRuleTag: (id) => `etiqueta ${id}, que está deprecada`,
|
|
263
|
+
unclosedRuleTag: (id) => `@rule ${id} no tiene su @endrule ${id}`,
|
|
264
|
+
unmatchedEndruleTag: (id) => `@endrule ${id} no tiene un @rule ${id} abierto antes`,
|
|
259
265
|
contextOverBudget: (context, count, budget) => `el contexto "${context}" tiene ${count} reglas, más que el contextBudget de ${budget}`,
|
|
260
266
|
pendingImplementation: (id, base) => `la regla ${id} cambió desde ${base} pero no cambió ninguno de los tests que la etiquetan`,
|
|
261
267
|
noIssues: "No se encontraron problemas.",
|
|
@@ -269,12 +275,14 @@ const MESSAGES = {
|
|
|
269
275
|
"id-prefix-mismatch": "cambia el id para usar el prefijo del contexto o mueve la regla al contexto dueño de ese prefijo",
|
|
270
276
|
"duplicate-id": "dale otro id a una de las reglas; \"rulebookguard new <contexto>\" elige el siguiente libre"
|
|
271
277
|
},
|
|
272
|
-
untestedRuleHint: (id) => `
|
|
278
|
+
untestedRuleHint: (id) => `envuelve un test de un archivo que coincida con testGlobs con "@rule ${id}" y "@endrule ${id}", o cambia el estado de la regla a proposed`,
|
|
273
279
|
unmatchedAnchorHint: "corrige el glob para que coincida con los archivos que implementan la regla, o quítalo",
|
|
274
280
|
unknownRuleTagHint: "corrige el id de la etiqueta o crea la regla a la que se refiere",
|
|
275
281
|
deprecatedRuleTagHint: (id, successor) => successor === void 0 ? `etiqueta la regla que reemplaza a ${id} o quita la etiqueta` : `etiqueta ${successor}, que reemplaza a ${id}`,
|
|
282
|
+
unclosedRuleTagHint: (id) => `agrega "@endrule ${id}" después de la última línea del test, o cierra antes las regiones internas`,
|
|
283
|
+
unmatchedEndruleTagHint: (id) => `agrega "@rule ${id}" antes del test, o quita el @endrule que sobra`,
|
|
276
284
|
contextOverBudgetHint: "divide el contexto en otros más chicos o sube contextBudget en rulebookguard.config.yaml",
|
|
277
|
-
pendingImplementationHint: (id) => `actualiza o agrega un test
|
|
285
|
+
pendingImplementationHint: (id) => `actualiza o agrega un test dentro de una región "@rule ${id}" … "@endrule ${id}" que cubra el cambio de la regla`,
|
|
278
286
|
lintSummary: (errors, warnings) => `${errors} ${errors === 1 ? "error" : "errores"}, ${warnings} ${warnings === 1 ? "aviso" : "avisos"}`,
|
|
279
287
|
needsWorkTree: (root) => `lint --base necesita un work tree de git, pero ${root} no está en uno.`,
|
|
280
288
|
unknownRef: (ref) => `La referencia de git "${ref}" no existe.`,
|
|
@@ -421,6 +429,7 @@ async function loadRule(path, options) {
|
|
|
421
429
|
}
|
|
422
430
|
// src/format/tag.ts
|
|
423
431
|
const RULE_TAG_PATTERN = /(?<![\w@.-])@rule\s+(R-[A-Z][A-Z0-9]*-[0-9]{3})(?![\w-])/g;
|
|
432
|
+
const RULE_MARKER_PATTERN = /(?<![\w@.-])@(rule|endrule)\s+(R-[A-Z][A-Z0-9]*-[0-9]{3})(?![\w-])/g;
|
|
424
433
|
function findRuleTags(source) {
|
|
425
434
|
const tags = [];
|
|
426
435
|
source.split(/\r?\n/).forEach((text, index) => {
|
|
@@ -431,6 +440,51 @@ function findRuleTags(source) {
|
|
|
431
440
|
});
|
|
432
441
|
return tags;
|
|
433
442
|
}
|
|
443
|
+
function findRuleRegions(source) {
|
|
444
|
+
const regions = [];
|
|
445
|
+
const problems = [];
|
|
446
|
+
const open = [];
|
|
447
|
+
source.split(/\r?\n/).forEach((text, index) => {
|
|
448
|
+
const line = index + 1;
|
|
449
|
+
for (const match of text.matchAll(RULE_MARKER_PATTERN)) {
|
|
450
|
+
const id = match[2];
|
|
451
|
+
if (match[1] === "rule") {
|
|
452
|
+
open.push({
|
|
453
|
+
id,
|
|
454
|
+
line
|
|
455
|
+
});
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const at = open.map((tag) => tag.id).lastIndexOf(id);
|
|
459
|
+
if (at === -1) {
|
|
460
|
+
problems.push({
|
|
461
|
+
code: "unmatched-endrule-tag",
|
|
462
|
+
id,
|
|
463
|
+
line
|
|
464
|
+
});
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
for (const inner of open.splice(at + 1)) problems.push({
|
|
468
|
+
code: "unclosed-rule-tag",
|
|
469
|
+
...inner
|
|
470
|
+
});
|
|
471
|
+
const start = open.pop();
|
|
472
|
+
regions.push({
|
|
473
|
+
...start,
|
|
474
|
+
endLine: line
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
for (const tag of open) problems.push({
|
|
479
|
+
code: "unclosed-rule-tag",
|
|
480
|
+
...tag
|
|
481
|
+
});
|
|
482
|
+
problems.sort((a, b) => a.line - b.line);
|
|
483
|
+
return {
|
|
484
|
+
regions,
|
|
485
|
+
problems
|
|
486
|
+
};
|
|
487
|
+
}
|
|
434
488
|
// src/catalog/git.ts
|
|
435
489
|
const execFileAsync = promisify(execFile);
|
|
436
490
|
const SAFE_CONFIG = ["-c", "core.fsmonitor=false"];
|
|
@@ -477,7 +531,31 @@ async function changedSince(cwd, ref) {
|
|
|
477
531
|
"--"
|
|
478
532
|
]);
|
|
479
533
|
const untracked = await gitPaths(cwd, "ls-files", ["--others", "--exclude-standard"]);
|
|
480
|
-
return
|
|
534
|
+
return {
|
|
535
|
+
files: [.../* @__PURE__ */ new Set([...changed, ...untracked])].sort(),
|
|
536
|
+
untracked: new Set(untracked)
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
async function changedLines(cwd, ref, file) {
|
|
540
|
+
const diff = await runGit(cwd, [
|
|
541
|
+
"--literal-pathspecs",
|
|
542
|
+
"diff",
|
|
543
|
+
"-U0",
|
|
544
|
+
"--no-color",
|
|
545
|
+
"--no-ext-diff",
|
|
546
|
+
"--no-textconv",
|
|
547
|
+
"--relative",
|
|
548
|
+
ref,
|
|
549
|
+
"--",
|
|
550
|
+
file
|
|
551
|
+
]);
|
|
552
|
+
const spans = [];
|
|
553
|
+
for (const match of diff.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
554
|
+
const start = Number(match[1]);
|
|
555
|
+
const count = match[2] === void 0 ? 1 : Number(match[2]);
|
|
556
|
+
spans.push(count === 0 ? [start + .5, start + .5] : [start, start + count - 1]);
|
|
557
|
+
}
|
|
558
|
+
return spans;
|
|
481
559
|
}
|
|
482
560
|
function gitFailureReason(error) {
|
|
483
561
|
const { code, stderr, message } = error;
|
|
@@ -737,21 +815,44 @@ function relativeToRoot(root, path) {
|
|
|
737
815
|
}
|
|
738
816
|
// src/catalog/tags.ts
|
|
739
817
|
async function scanTestTags(root, testGlobs, onGitFallback) {
|
|
740
|
-
|
|
818
|
+
return (await scanTestRegions(root, testGlobs, onGitFallback)).tags;
|
|
819
|
+
}
|
|
820
|
+
async function scanTestRegions(root, testGlobs, onGitFallback) {
|
|
821
|
+
const tags = /* @__PURE__ */ new Map();
|
|
822
|
+
const regions = /* @__PURE__ */ new Map();
|
|
823
|
+
const problems = [];
|
|
741
824
|
const realRoot = await realpath(root);
|
|
742
825
|
for (const file of await expandGlobs(root, testGlobs, onGitFallback)) {
|
|
743
826
|
const path = join(root, file);
|
|
744
827
|
if (!await resolvesInside(path, realRoot)) continue;
|
|
745
828
|
const source = await readFile(path, "utf8");
|
|
746
|
-
for (const { id, line } of findRuleTags(source)) {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
829
|
+
for (const { id, line } of findRuleTags(source)) push(tags, id, {
|
|
830
|
+
file,
|
|
831
|
+
line
|
|
832
|
+
});
|
|
833
|
+
const found = findRuleRegions(source);
|
|
834
|
+
for (const { id, line, endLine } of found.regions) push(regions, id, {
|
|
835
|
+
file,
|
|
836
|
+
line,
|
|
837
|
+
endLine
|
|
838
|
+
});
|
|
839
|
+
problems.push(...found.problems.map((problem) => ({
|
|
840
|
+
...problem,
|
|
841
|
+
file
|
|
842
|
+
})));
|
|
754
843
|
}
|
|
844
|
+
return {
|
|
845
|
+
tags: sortById(tags),
|
|
846
|
+
regions: sortById(regions),
|
|
847
|
+
problems
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function push(index, id, value) {
|
|
851
|
+
const values = index.get(id) ?? [];
|
|
852
|
+
values.push(value);
|
|
853
|
+
index.set(id, values);
|
|
854
|
+
}
|
|
855
|
+
function sortById(index) {
|
|
755
856
|
return new Map([...index].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
756
857
|
}
|
|
757
858
|
var CliError = class extends Error {
|
|
@@ -818,9 +919,12 @@ async function lintCommand(io, options) {
|
|
|
818
919
|
const text = messages(catalog.config.language);
|
|
819
920
|
const changed = options.base === void 0 ? void 0 : await changedFiles(catalog.root, options.base, text);
|
|
820
921
|
const warn = gitFallbackWarning(io, catalog.config.language);
|
|
821
|
-
const
|
|
822
|
-
const findings = await lintCatalog(catalog,
|
|
823
|
-
if (options.base !== void 0 && changed !== void 0)
|
|
922
|
+
const scan = await scanTestRegions(catalog.root, catalog.config.testGlobs, warn);
|
|
923
|
+
const findings = await lintCatalog(catalog, scan, warn);
|
|
924
|
+
if (options.base !== void 0 && changed !== void 0) {
|
|
925
|
+
const lines = await testLineChanges(catalog, scan, options.base, changed, text);
|
|
926
|
+
findings.push(...pendingImplementation(catalog, scan, options.base, changed.files, lines));
|
|
927
|
+
}
|
|
824
928
|
return report(io, findings, options.json, text);
|
|
825
929
|
}
|
|
826
930
|
function report(io, findings, json, text) {
|
|
@@ -839,10 +943,17 @@ function report(io, findings, json, text) {
|
|
|
839
943
|
function issueFinding(code, { file, field, message }, text) {
|
|
840
944
|
return error(code, file, field === void 0 ? message : `${text.field(field)} ${message}`, text.catalogHints[code]);
|
|
841
945
|
}
|
|
842
|
-
async function lintCatalog(catalog,
|
|
843
|
-
const tagIndex =
|
|
946
|
+
async function lintCatalog(catalog, scan, onGitFallback) {
|
|
947
|
+
const { tags: tagIndex, problems } = scan ?? await scanTestRegions(catalog.root, catalog.config.testGlobs, onGitFallback);
|
|
844
948
|
const text = messages(catalog.config.language);
|
|
845
949
|
const findings = catalog.issues.map((issue) => issueFinding(issue.code, issue, text));
|
|
950
|
+
for (const { code, id, file, line } of problems) findings.push(code === "unclosed-rule-tag" ? {
|
|
951
|
+
...error(code, file, text.unclosedRuleTag(id), text.unclosedRuleTagHint(id)),
|
|
952
|
+
line
|
|
953
|
+
} : {
|
|
954
|
+
...error(code, file, text.unmatchedEndruleTag(id), text.unmatchedEndruleTagHint(id)),
|
|
955
|
+
line
|
|
956
|
+
});
|
|
846
957
|
const files = await listRepoFiles(catalog.root, onGitFallback);
|
|
847
958
|
const status = new Map(catalog.rules.map((rule) => [rule.id, rule.status]));
|
|
848
959
|
const invalid = new Set(catalog.invalidRuleIds);
|
|
@@ -875,10 +986,30 @@ async function lintCatalog(catalog, tags, onGitFallback) {
|
|
|
875
986
|
}
|
|
876
987
|
return findings;
|
|
877
988
|
}
|
|
878
|
-
function pendingImplementation(catalog,
|
|
989
|
+
function pendingImplementation(catalog, scan, base, changed, lines) {
|
|
879
990
|
const text = messages(catalog.config.language);
|
|
991
|
+
return changedActiveRules(catalog, scan, changed).filter((rule) => !(scan.regions.get(rule.id) ?? []).some((region) => (lines.get(region.file) ?? []).some(([start, end]) => start <= region.endLine && end >= region.line))).map((rule) => error("pending-implementation", rule.file, text.pendingImplementation(rule.id, base), text.pendingImplementationHint(rule.id)));
|
|
992
|
+
}
|
|
993
|
+
function changedActiveRules(catalog, scan, changed) {
|
|
880
994
|
const changedSet = new Set(changed);
|
|
881
|
-
return catalog.rules.filter((rule) => rule.status === "active" && changedSet.has(rule.file)
|
|
995
|
+
return catalog.rules.filter((rule) => rule.status === "active" && changedSet.has(rule.file) && (scan.tags.get(rule.id) ?? []).length > 0);
|
|
996
|
+
}
|
|
997
|
+
async function testLineChanges(catalog, scan, base, changed, text) {
|
|
998
|
+
const changedSet = new Set(changed.files);
|
|
999
|
+
const files = new Set(changedActiveRules(catalog, scan, changed.files).flatMap((rule) => scan.regions.get(rule.id) ?? []).map((region) => region.file).filter((file) => changedSet.has(file)));
|
|
1000
|
+
const lines = /* @__PURE__ */ new Map();
|
|
1001
|
+
for (const file of files) {
|
|
1002
|
+
if (changed.untracked.has(file)) {
|
|
1003
|
+
lines.set(file, [[1, Number.POSITIVE_INFINITY]]);
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
try {
|
|
1007
|
+
lines.set(file, await changedLines(catalog.root, base, file));
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
throw new CliError(text.cannotListChanged(base, gitFailureReason(error)));
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
return lines;
|
|
882
1013
|
}
|
|
883
1014
|
async function changedFiles(root, base, text) {
|
|
884
1015
|
if (!await isInsideWorkTree(root)) throw new CliError(text.needsWorkTree(root));
|
|
@@ -908,4 +1039,4 @@ function sortFindings(findings) {
|
|
|
908
1039
|
function compare(a, b) {
|
|
909
1040
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
910
1041
|
}
|
|
911
|
-
export {
|
|
1042
|
+
export { parseContext as A, findRuleRegions as C, parseRule as D, loadRule as E, messages as F, FormatError as I, formatIssue as L, CONFIG_FILE_NAME as M, loadConfig as N, CONTEXT_FILE_NAME as O, parseConfig as P, RULE_TAG_PATTERN as S, isRuleId as T, expandGlobs as _, CliError as a, runGit as b, openCatalog as c, scanTestRegions as d, scanTestTags as f, createGlobMatcher as g, relativeToRoot as h, pendingImplementation as i, splitFrontmatter as j, loadContext as k, writeJson as l, loadCatalog as m, lintCatalog as n, findRule as o, findRulebookRoot as p, lintCommand as r, gitFallbackWarning as s, formatFinding as t, writeLines as u, listRepoFiles as v, findRuleTags as w, RULE_MARKER_PATTERN as x, toRepoPath as y };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as CliError,
|
|
1
|
+
import { _ as expandGlobs, a as CliError, b as runGit, c as openCatalog, d as scanTestRegions, n as lintCatalog, s as gitFallbackWarning, u as writeLines } from "./lint-DfibQDW3.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";
|
|
@@ -7,7 +7,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
7
7
|
// src/site/data.ts
|
|
8
8
|
const FIELD = "";
|
|
9
9
|
async function siteData(catalog, onGitFallback) {
|
|
10
|
-
const
|
|
10
|
+
const scan = await scanTestRegions(catalog.root, catalog.config.testGlobs, onGitFallback);
|
|
11
11
|
const byId = new Map(catalog.rules.map((rule) => [rule.id, rule]));
|
|
12
12
|
const rules = [...catalog.rules].sort((a, b) => compare(a.id, b.id));
|
|
13
13
|
return {
|
|
@@ -15,12 +15,12 @@ async function siteData(catalog, onGitFallback) {
|
|
|
15
15
|
rules: await Promise.all(rules.map(async (rule) => ({
|
|
16
16
|
rule,
|
|
17
17
|
files: await expandGlobs(catalog.root, rule.anchors, onGitFallback),
|
|
18
|
-
tests: tags.get(rule.id) ?? [],
|
|
18
|
+
tests: scan.tags.get(rule.id) ?? [],
|
|
19
19
|
history: await history(catalog.root, rule.file),
|
|
20
20
|
supersedes: chain(rule, byId),
|
|
21
21
|
supersededBy: rules.find((other) => other.supersedes === rule.id)?.id
|
|
22
22
|
}))),
|
|
23
|
-
findings: await lintCatalog(catalog,
|
|
23
|
+
findings: await lintCatalog(catalog, scan, onGitFallback)
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
function chain(rule, byId) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulebookguard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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.7.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.7.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"@rulebookguard/site": {
|