rulebookguard 0.6.0 → 0.8.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 CHANGED
@@ -1,17 +1,42 @@
1
+ <!-- prettier-ignore -->
2
+ <div align="center">
3
+
1
4
  # rulebookguard
2
5
 
6
+ [![npm version](https://img.shields.io/npm/v/rulebookguard?style=flat-square)](https://www.npmjs.com/package/rulebookguard)
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/JuanCJR/rulebookguard/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/JuanCJR/rulebookguard/actions/workflows/ci.yml)
8
+ ![Node version](https://img.shields.io/node/v/rulebookguard?style=flat-square)
9
+ [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
10
+ [![License](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](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
- 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.
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 test change.
11
-
12
- It is language-agnostic: rules are Markdown, and tests in any language link to them through a comment.
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.
13
38
 
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. Set up the rulebook: `npx rulebookguard init clients --ci` writes `rulebookguard.config.yaml`, the `clients` context and a CI workflow (see [Configuration](#configuration) to adjust `testGlobs`).
56
+ 2. Scaffold a rule: `npx rulebookguard new clients` creates `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.
43
77
 
44
- `language` (`en` or `es`) sets the language of the text rulebookguard writes for you: the template of `new`, the hook context, the site, and every message printed once the config is loaded (lint findings, errors, warnings and headers, also the `message` of `--json` output). Lint codes, JSON keys and values such as `active` stay in English, and so do `--help`, argument errors and the findings of an invalid config file, which come before the config is read. Rule content is free text and is never checked for language, so rules can be written in any language whatever this setting says. `language` needs rulebookguard 0.1.1 or later; older versions reject it as an unknown field. The JSON Schemas ship with the package under `rulebookguard/schemas/v1/`.
78
+ > [!NOTE]
79
+ > `language` needs rulebookguard 0.1.1 or later; older versions reject it as an unknown field.
80
+
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
- ## Catalog layout
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
- ## Rule file format
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
- ## The `@rule` tag
159
+ ### Linking tests with `@rule` … `@endrule`
118
160
 
119
- A test links to a rule with `@rule <ID>` anywhere in a file matched by `testGlobs`. One line can carry several tags. A comment is the usual place, but the tag is matched as text, so it also counts inside a test name or a string:
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
- ```ts
122
- it('rejects a duplicate document @rule R-CLI-001', () => {});
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,14 +206,45 @@ 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
+ | [`init <context>`](#init-context---prefix-p---language-enes---ci) | sets up the config, a first context and optionally CI |
219
+ | [`contexts`](#contexts-l0) | the contexts map (L0) |
220
+ | [`list`](#list---context-c---status-s-l1) | rule ids and summaries (L1) |
221
+ | [`show <id>`](#show-id-l2) | the full rule (L2) |
222
+ | [`for <path>`](#for-path) | the active rules anchored to a file |
223
+ | [`impact <id>`](#impact-id) | the files and tests a rule touches |
224
+ | [`new <context>`](#new-context---prefix-p) | scaffolds the next rule of a context |
225
+ | [`lint`](#lint---base-ref---json) | checks the whole catalog, and optionally changes since a ref |
226
+ | [`hook`](#hook) | Claude Code hook that injects the rules of a file |
227
+ | [`mcp`](#mcp---project-dir) | MCP server for AI agents |
228
+ | [`site:generate`](#sitegenerate---out-dir---base-path) | builds a static site of the rulebook |
229
+ | [`site:run`](#siterun---out-dir---base-path---port-n) | builds the site and serves it locally |
230
+
170
231
  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
232
 
233
+ ### `init <context> [--prefix <P>] [--language <en|es>] [--ci]`
234
+
235
+ Sets up a rulebook in the current directory and prints the paths it wrote: `rulebookguard.config.yaml`, then the context, the same one `new` would create (`--prefix` works the same way).
236
+
237
+ ```sh
238
+ $ rulebookguard init clients --ci
239
+ rulebookguard.config.yaml
240
+ docs/rules/clients/_context.md
241
+ .github/workflows/rulebookguard.yml
242
+ ```
243
+
244
+ The config uses `rulesDir: docs/rules` and these `testGlobs`, which you can narrow afterwards: `**/*.test.*`, `**/*.spec.*`, `**/test_*.py` and `**/*_test.go`. `--language` writes `language` into the config, so the context template and later messages follow it. `--ci` also writes the GitHub Actions workflow of the [CI recipe](#ci-recipe), pinned to the running version.
245
+
246
+ Exits 1, writing nothing, when `rulebookguard.config.yaml` (or, with `--ci`, the workflow) already exists in the current directory, or when the context name or prefix is invalid. Exits 2 for a `--language` other than `en` or `es`.
247
+
172
248
  ### `contexts` (L0)
173
249
 
174
250
  ```sh
@@ -252,9 +328,53 @@ docs/rules/inventory/R-INV-001.md
252
328
 
253
329
  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
330
 
331
+ ### `lint [--base <ref>] [--json]`
332
+
333
+ 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.
334
+
335
+ | Severity | Code | Meaning |
336
+ | -------- | ------------------------ | ------------------------------------------------------------------ |
337
+ | error | `untested-rule` | an active rule has no test tagging it |
338
+ | error | `unknown-rule-tag` | a test tags a rule id that does not exist |
339
+ | error | `deprecated-rule-tag` | a test tags a deprecated rule |
340
+ | error | `unclosed-rule-tag` | a `@rule` has no matching `@endrule` |
341
+ | error | `unmatched-endrule-tag` | an `@endrule` has no open `@rule` of the same id |
342
+ | error | `unmatched-anchor` | an anchor glob matches no file |
343
+ | error | `schema` | a config, context or rule file breaks the JSON Schema |
344
+ | error | `duplicate-id` | two rule files share an id |
345
+ | error | `file-name-mismatch` | a rule file is not named `<id>.md` |
346
+ | error | `id-prefix-mismatch` | a rule id does not use its context prefix |
347
+ | error | `context-mismatch` | a `context` field does not match its directory |
348
+ | error | `missing-context-file` | a context directory has no `_context.md` |
349
+ | error | `missing-rules-dir` | `rulesDir` does not exist |
350
+ | error | `pending-implementation` | with `--base`: an active rule changed, but none of its regions did |
351
+ | warning | `context-over-budget` | a context holds more rules than `contextBudget` |
352
+
353
+ ```sh
354
+ $ rulebookguard lint
355
+ error untested-rule docs/rules/billing/R-BIL-002.md active rule R-BIL-002 has no test tagging it
356
+ 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
357
+ warning context-over-budget docs/rules/clients/_context.md context "clients" has 31 rules, more than the contextBudget of 30
358
+ hint: split the context into smaller ones, or raise contextBudget in rulebookguard.config.yaml
359
+ 1 error, 1 warning
360
+ ```
361
+
362
+ 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`.
363
+
364
+ 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.
365
+
366
+ ```sh
367
+ $ rulebookguard lint --base origin/main
368
+ 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
369
+ hint: update or add a test inside an "@rule R-CLI-001" … "@endrule R-CLI-001" region that covers the changed rule
370
+ 1 error, 0 warnings
371
+ ```
372
+
373
+ `--json` prints `{ "findings": [{ "severity", "code", "file", "line"?, "message", "hint" }], "errors", "warnings" }`.
374
+
255
375
  ### `hook`
256
376
 
257
- A Claude Code hook for `PreToolUse` and `PostToolUse` events of tools that take a `file_path` (the Claude Code plugin below 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`:
377
+ 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
378
 
259
379
  ```sh
260
380
  $ echo '{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":"/repo/src/billing/invoice.go"}}' | rulebookguard hook
@@ -263,7 +383,10 @@ $ echo '{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_
263
383
 
264
384
  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
385
 
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. 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.
386
+ 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.
387
+
388
+ > [!NOTE]
389
+ > 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
390
 
268
391
  ### `mcp [--project <dir>]`
269
392
 
@@ -278,11 +401,12 @@ The rulebook is found from `--project` (default: the current directory). A comma
278
401
 
279
402
  ### `site:generate [--out <dir>] [--base <path>]`
280
403
 
281
- 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):
282
-
283
- ```sh
284
- npm install --save-dev rulebookguard @rulebookguard/site
285
- ```
404
+ > [!IMPORTANT]
405
+ > 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):
406
+ >
407
+ > ```sh
408
+ > npm install --save-dev rulebookguard @rulebookguard/site
409
+ > ```
286
410
 
287
411
  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
412
 
@@ -314,53 +438,14 @@ The site has:
314
438
  - **Health:** the `lint` findings when the site was built.
315
439
  - Full-text search (Pagefind).
316
440
 
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. `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.
441
+ 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
442
 
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` |
338
-
339
- ```sh
340
- $ rulebookguard lint
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" }`.
443
+ > [!WARNING]
444
+ > `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
445
 
361
446
  ## CI recipe
362
447
 
363
- Run the linter on every pull request, comparing against the main branch. The full history is needed so that `origin/main` exists.
448
+ Run the linter on every pull request, comparing against the branch it targets. The full history is needed so that the base branch exists. `rulebookguard init --ci` writes this file as `.github/workflows/rulebookguard.yml`.
364
449
 
365
450
  ```yaml
366
451
  name: rules
@@ -373,10 +458,14 @@ jobs:
373
458
  - uses: actions/checkout@v7.0.1
374
459
  with:
375
460
  fetch-depth: 0
376
- - run: npx --yes rulebookguard@0.1.0 lint --base origin/main
461
+ - uses: actions/setup-node@v6
462
+ with:
463
+ node-version: 22
464
+ - run: npx --yes rulebookguard@0.8.0 lint --base origin/${{ github.base_ref }}
377
465
  ```
378
466
 
379
- 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.
467
+ > [!TIP]
468
+ > 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
469
 
381
470
  ## Claude Code plugin
382
471
 
@@ -395,19 +484,4 @@ claude plugin install rulebookguard@rulebookguard
395
484
 
396
485
  Or, inside Claude Code 2.1.275 or later: `/plugin install rulebookguard --marketplace JuanCJR/rulebookguard`.
397
486
 
398
- 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
487
+ 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. In a checkout of rulebookguard itself it runs the checkout's own built CLI (`dist/cli.mjs`, from `vp pack`), whatever its version. The hooks never block a tool: any failure, including a timeout, lets it go ahead without rules.
@@ -1,4 +1,4 @@
1
- import { h as createGlobMatcher, v as toRepoPath } from "./lint-D63Q2SM0.mjs";
1
+ import { g as createGlobMatcher, y as toRepoPath } from "./lint-DfibQDW3.mjs";
2
2
  // src/catalog/anchors.ts
3
3
  function rulesForPath(rules, path) {
4
4
  const target = toRepoPath(path);
package/dist/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { M as messages, N as FormatError, P as formatIssue, T as CONTEXT_FILE_NAME, a as CliError, c as openCatalog, d as scanTestTags, f as findRulebookRoot, g as expandGlobs, l as writeJson, o as findRule, p as loadCatalog, r as lintCommand, s as gitFallbackWarning, u as writeLines, v as toRepoPath } from "./lint-D63Q2SM0.mjs";
3
- import { t as rulesForPath } from "./anchors-e6T9BNoV.mjs";
4
- import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { F as messages, I as FormatError, L as formatIssue, M as CONFIG_FILE_NAME, 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
+ import { access, mkdir, readdir, rm, writeFile } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { parseArgs } from "node:util";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
@@ -80,60 +80,7 @@ function render(path, rules, text) {
80
80
  return parts.join("\n\n");
81
81
  }
82
82
  // package.json
83
- var version = "0.6.0";
84
- // src/mcp/server.ts
85
- function createRulesServer(projectDir) {
86
- const server = new McpServer({
87
- name: "rulebookguard",
88
- version
89
- });
90
- server.registerTool("rules_for", {
91
- description: "Active business rules anchored to a file (id, context, summary, rule file). Call it before changing a file; the rules are the source of truth for its behavior.",
92
- inputSchema: z.object({ path: z.string().describe("File path, absolute or relative to the project directory") }),
93
- annotations: { readOnlyHint: true }
94
- }, ({ path }) => cli(projectDir, [
95
- "for",
96
- path,
97
- "--json"
98
- ]));
99
- server.registerTool("rules_show", {
100
- description: "Full text of one business rule: summary, anchors, entities, supersedes and the rule body with its examples.",
101
- inputSchema: z.object({ id: z.string().describe("Rule id, for example R-CLI-001") }),
102
- annotations: { readOnlyHint: true }
103
- }, ({ id }) => cli(projectDir, [
104
- "show",
105
- id,
106
- "--json"
107
- ]));
108
- return server;
109
- }
110
- async function cli(cwd, args) {
111
- let stdout = "";
112
- let stderr = "";
113
- if (await runCli(args, {
114
- cwd,
115
- stdout: (text) => {
116
- stdout += text;
117
- },
118
- stderr: (text) => {
119
- stderr += text;
120
- }
121
- }) === 0) return { content: [{
122
- type: "text",
123
- text: stdout
124
- }] };
125
- return {
126
- content: [{
127
- type: "text",
128
- text: stderr.trim()
129
- }],
130
- isError: true
131
- };
132
- }
133
- // src/commands/mcp.ts
134
- async function mcpCommand(io, project) {
135
- await createRulesServer(resolve(io.cwd, project ?? ".")).connect(new StdioServerTransport());
136
- }
83
+ var version = "0.8.0";
137
84
  // src/commands/new.ts
138
85
  const MAX_NUMBER = 999;
139
86
  const CONTEXT_NAME = /^[a-z0-9][a-z0-9_-]*$/;
@@ -163,7 +110,7 @@ async function createContext(catalog, name, option, text) {
163
110
  const directory = `${toRepoPath(catalog.config.rulesDir).replace(/\/+$/, "")}/${name}`;
164
111
  const file = `${directory}/${CONTEXT_FILE_NAME}`;
165
112
  await mkdir(join(catalog.root, directory), { recursive: true });
166
- await writeNew(catalog, file, contextTemplate(name, prefix, text), text);
113
+ await writeNew$1(catalog, file, contextTemplate(name, prefix, text), text);
167
114
  return {
168
115
  file,
169
116
  context: name,
@@ -181,10 +128,10 @@ async function writeNextRule(catalog, context, text) {
181
128
  if (last >= MAX_NUMBER) throw new CliError(text.noFreeId(context.context, `R-${context.prefix}-${MAX_NUMBER}`));
182
129
  const id = `R-${context.prefix}-${String(last + 1).padStart(3, "0")}`;
183
130
  const file = `${directory}/${id}.md`;
184
- await writeNew(catalog, file, ruleTemplate(id, context.context, catalog.config.language ?? "en"), text);
131
+ await writeNew$1(catalog, file, ruleTemplate(id, context.context, catalog.config.language ?? "en"), text);
185
132
  return file;
186
133
  }
187
- async function writeNew(catalog, file, content, text) {
134
+ async function writeNew$1(catalog, file, content, text) {
188
135
  try {
189
136
  await writeFile(join(catalog.root, file), content, { flag: "wx" });
190
137
  } catch (error) {
@@ -243,6 +190,116 @@ ${text.scenario}
243
190
  \`\`\`
244
191
  `;
245
192
  }
193
+ // src/commands/init.ts
194
+ const WORKFLOW_FILE = ".github/workflows/rulebookguard.yml";
195
+ const TEST_GLOBS = [
196
+ "**/*.test.*",
197
+ "**/*.spec.*",
198
+ "**/test_*.py",
199
+ "**/*_test.go"
200
+ ];
201
+ async function initCommand(io, name, options) {
202
+ const root = io.cwd;
203
+ const text = messages(options.language);
204
+ const files = options.ci ? [CONFIG_FILE_NAME, WORKFLOW_FILE] : [CONFIG_FILE_NAME];
205
+ for (const file of files) if (await exists(join(root, file))) throw new CliError(text.alreadyExists(file));
206
+ const written = [];
207
+ try {
208
+ await writeNew(root, CONFIG_FILE_NAME, configTemplate(options.language), written);
209
+ const context = await createContext(await loadCatalog(root), name, options.prefix, text);
210
+ written.push(context.file);
211
+ if (options.ci) await writeNew(root, WORKFLOW_FILE, WORKFLOW, written);
212
+ } catch (error) {
213
+ await Promise.all(written.map((file) => rm(join(root, file), { force: true })));
214
+ if (error.code === "EEXIST") throw new CliError(text.alreadyExists(written.at(-1) ?? "rulebookguard.config.yaml"));
215
+ throw error;
216
+ }
217
+ writeLines(io, written);
218
+ }
219
+ async function writeNew(root, file, content, written) {
220
+ await mkdir(dirname(join(root, file)), { recursive: true });
221
+ await writeFile(join(root, file), content, { flag: "wx" });
222
+ written.push(file);
223
+ }
224
+ async function exists(path) {
225
+ try {
226
+ await access(path);
227
+ return true;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+ function configTemplate(language) {
233
+ return `schema: 1\nrulesDir: docs/rules\ntestGlobs:\n${TEST_GLOBS.map((glob) => ` - '${glob}'\n`).join("")}${language === void 0 ? "" : `language: ${language}\n`}`;
234
+ }
235
+ const WORKFLOW = `name: rules
236
+ on: pull_request
237
+
238
+ jobs:
239
+ rulebookguard:
240
+ runs-on: ubuntu-latest
241
+ steps:
242
+ - uses: actions/checkout@v7.0.1
243
+ with:
244
+ fetch-depth: 0
245
+ - uses: actions/setup-node@v6
246
+ with:
247
+ node-version: 22
248
+ - run: npx --yes rulebookguard@${version} lint --base origin/\${{ github.base_ref }}
249
+ `;
250
+ // src/mcp/server.ts
251
+ function createRulesServer(projectDir) {
252
+ const server = new McpServer({
253
+ name: "rulebookguard",
254
+ version
255
+ });
256
+ server.registerTool("rules_for", {
257
+ description: "Active business rules anchored to a file (id, context, summary, rule file). Call it before changing a file; the rules are the source of truth for its behavior.",
258
+ inputSchema: z.object({ path: z.string().describe("File path, absolute or relative to the project directory") }),
259
+ annotations: { readOnlyHint: true }
260
+ }, ({ path }) => cli(projectDir, [
261
+ "for",
262
+ path,
263
+ "--json"
264
+ ]));
265
+ server.registerTool("rules_show", {
266
+ description: "Full text of one business rule: summary, anchors, entities, supersedes and the rule body with its examples.",
267
+ inputSchema: z.object({ id: z.string().describe("Rule id, for example R-CLI-001") }),
268
+ annotations: { readOnlyHint: true }
269
+ }, ({ id }) => cli(projectDir, [
270
+ "show",
271
+ id,
272
+ "--json"
273
+ ]));
274
+ return server;
275
+ }
276
+ async function cli(cwd, args) {
277
+ let stdout = "";
278
+ let stderr = "";
279
+ if (await runCli(args, {
280
+ cwd,
281
+ stdout: (text) => {
282
+ stdout += text;
283
+ },
284
+ stderr: (text) => {
285
+ stderr += text;
286
+ }
287
+ }) === 0) return { content: [{
288
+ type: "text",
289
+ text: stdout
290
+ }] };
291
+ return {
292
+ content: [{
293
+ type: "text",
294
+ text: stderr.trim()
295
+ }],
296
+ isError: true
297
+ };
298
+ }
299
+ // src/commands/mcp.ts
300
+ async function mcpCommand(io, project) {
301
+ await createRulesServer(resolve(io.cwd, project ?? ".")).connect(new StdioServerTransport());
302
+ }
246
303
  // src/commands/read.ts
247
304
  const RULE_STATUSES = [
248
305
  "proposed",
@@ -377,6 +434,11 @@ function compare(a, b) {
377
434
  const USAGE = `Usage: rulebookguard <command> [options]
378
435
 
379
436
  Commands:
437
+ init <context> [--prefix <P>] [--language <en|es>] [--ci]
438
+ create rulebookguard.config.yaml and a first context in
439
+ the current directory; --language: language of generated
440
+ text (default: en), --ci: also a GitHub Actions workflow
441
+ that runs lint --base on pull requests
380
442
  contexts [--json] L0: list the contexts
381
443
  list [--context <c>] [--status <s>] [--json] L1: id and summary of each rule
382
444
  show <id> [--json] L2: the full rule
@@ -407,6 +469,15 @@ const SITE_OPTIONS = {
407
469
  base: { type: "string" }
408
470
  };
409
471
  const COMMANDS = {
472
+ init: {
473
+ usage: "rulebookguard init <context> [--prefix <P>] [--language <en|es>] [--ci]",
474
+ argument: "context",
475
+ options: {
476
+ prefix: { type: "string" },
477
+ language: { type: "string" },
478
+ ci: { type: "boolean" }
479
+ }
480
+ },
410
481
  contexts: {
411
482
  usage: "rulebookguard contexts [--json]",
412
483
  options: JSON_OPTION
@@ -504,7 +575,7 @@ async function dispatch(argv, io) {
504
575
  return 0;
505
576
  }
506
577
  if (name === "site" || name === "site:generate" || name === "site:run") {
507
- const { siteCommand } = await import("./site-DEdtLk6J.mjs");
578
+ const { siteCommand } = await import("./site-DmY5YVte.mjs");
508
579
  await siteCommand(io, {
509
580
  out: values.out,
510
581
  base: values.base,
@@ -513,6 +584,14 @@ async function dispatch(argv, io) {
513
584
  });
514
585
  return 0;
515
586
  }
587
+ if (name === "init") {
588
+ await initCommand(io, argument, {
589
+ prefix: values.prefix,
590
+ language: initLanguage(values.language, spec.usage),
591
+ ci: values.ci === true
592
+ });
593
+ return 0;
594
+ }
516
595
  if (name === "lint") return lintCommand(io, {
517
596
  base: values.base,
518
597
  json
@@ -542,6 +621,10 @@ async function dispatch(argv, io) {
542
621
  }
543
622
  return 0;
544
623
  }
624
+ function initLanguage(value, usage) {
625
+ if (value === void 0 || value === "en" || value === "es") return value;
626
+ throw new CliError(`--language must be en or es.\nUsage: ${usage}`, 2);
627
+ }
545
628
  function sitePort(value, usage) {
546
629
  if (value === void 0) return;
547
630
  const port = Number(value);
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, tags?: TagIndex, onGitFallback?: GitFallbackListener): Promise<LintFinding[]>;
156
- export declare function pendingImplementation(catalog: Catalog, tags: TagIndex, base: string, changed: string[]): LintFinding[];
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 loadConfig, C as loadRule, D as parseContext, E as loadContext, N as FormatError, O as splitFrontmatter, P as formatIssue, S as isRuleId, T as CONTEXT_FILE_NAME, _ as listRepoFiles, b as RULE_TAG_PATTERN, d as scanTestTags, f as findRulebookRoot, g as expandGlobs, h as createGlobMatcher, i as pendingImplementation, j as parseConfig, k as CONFIG_FILE_NAME, m as relativeToRoot, n as lintCatalog, p as loadCatalog, t as formatFinding, v as toRepoPath, w as parseRule, x as findRuleTags } from "./lint-D63Q2SM0.mjs";
2
- import { t as rulesForPath } from "./anchors-e6T9BNoV.mjs";
3
- 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) => `add "@rule ${id}" to a test in a file matched by testGlobs, or set the rule's status to proposed`,
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 tagging ${id} that covers the changed rule`,
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) => `agrega "@rule ${id}" a un test en un archivo que coincida con testGlobs, o cambia el estado de la regla a proposed`,
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 que etiquete ${id} y cubra el cambio de la regla`,
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 [.../* @__PURE__ */ new Set([...changed, ...untracked])].sort();
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
- const index = /* @__PURE__ */ new Map();
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
- const locations = index.get(id) ?? [];
748
- locations.push({
749
- file,
750
- line
751
- });
752
- index.set(id, locations);
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 tags = await scanTestTags(catalog.root, catalog.config.testGlobs, warn);
822
- const findings = await lintCatalog(catalog, tags, warn);
823
- if (options.base !== void 0 && changed !== void 0) findings.push(...pendingImplementation(catalog, tags, options.base, changed));
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, tags, onGitFallback) {
843
- const tagIndex = tags ?? await scanTestTags(catalog.root, catalog.config.testGlobs, onGitFallback);
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, tags, base, changed) {
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)).filter((rule) => (tags.get(rule.id) ?? []).length > 0).filter((rule) => !(tags.get(rule.id) ?? []).some(({ file }) => changedSet.has(file))).map((rule) => error("pending-implementation", rule.file, text.pendingImplementation(rule.id, base), text.pendingImplementationHint(rule.id)));
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 { loadConfig as A, loadRule as C, parseContext as D, loadContext as E, messages as M, FormatError as N, splitFrontmatter as O, formatIssue as P, isRuleId as S, CONTEXT_FILE_NAME as T, listRepoFiles as _, CliError as a, RULE_TAG_PATTERN as b, openCatalog as c, scanTestTags as d, findRulebookRoot as f, expandGlobs as g, createGlobMatcher as h, pendingImplementation as i, parseConfig as j, CONFIG_FILE_NAME as k, writeJson as l, relativeToRoot as m, lintCatalog as n, findRule as o, loadCatalog as p, lintCommand as r, gitFallbackWarning as s, formatFinding as t, writeLines as u, toRepoPath as v, parseRule as w, findRuleTags as x, runGit as y };
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, c as openCatalog, d as scanTestTags, g as expandGlobs, n as lintCatalog, s as gitFallbackWarning, u as writeLines, y as runGit } from "./lint-D63Q2SM0.mjs";
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 tags = await scanTestTags(catalog.root, catalog.config.testGlobs, onGitFallback);
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, tags, onGitFallback)
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.6.0",
3
+ "version": "0.8.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.6.0",
39
+ "@rulebookguard/site": "0.8.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.6.0"
46
+ "@rulebookguard/site": "^0.8.0"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "@rulebookguard/site": {