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 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.
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
- It is language-agnostic: rules are Markdown, and tests in any language link to them through a comment.
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
- `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/`.
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,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 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`:
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. 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.
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
- 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
- ```
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. `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.
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
- ```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" }`.
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
- 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.
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
@@ -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,6 +1,6 @@
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";
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.6.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-DEdtLk6J.mjs");
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, 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.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.6.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.6.0"
46
+ "@rulebookguard/site": "^0.7.0"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "@rulebookguard/site": {