rulereceipt 0.1.26 → 0.1.28
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 +121 -69
- package/dist/browser/analyze.js +3 -3
- package/dist/checks/classify.js +70 -0
- package/dist/checks/doctor.d.ts +7 -0
- package/dist/checks/doctor.js +8 -1
- package/dist/checks/hookCoverage.d.ts +59 -0
- package/dist/checks/hookCoverage.js +96 -0
- package/dist/cli.js +212 -3
- package/dist/overrides.d.ts +62 -0
- package/dist/overrides.js +85 -0
- package/dist/parsers/claudeMdParser.js +72 -11
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -9,29 +9,18 @@
|
|
|
9
9
|
Checks whether a Claude Code session actually followed the rules in your
|
|
10
10
|
CLAUDE.md / AGENTS.md — with evidence, not just a vibe.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
a specific commit. No publishing token exists to be stolen. Check it
|
|
16
|
-
yourself with `npm audit signatures` after installing.
|
|
12
|
+
Runs entirely on your machine. Plain `rulereceipt check` makes zero network
|
|
13
|
+
calls — [Trust, privacy and licensing](#trust-privacy-and-licensing) has the
|
|
14
|
+
full detail, including the three off-by-default opt-ins.
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
[NOTICE.md](./NOTICE.md) before reusing this code.
|
|
16
|
+
## See it in 10 seconds
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
counts are knowable, nothing else. None of them fire unless you explicitly
|
|
28
|
-
pass the flag, and `DO_NOT_TRACK=1` / `RULERECEIPT_NO_TELEMETRY=1` forces
|
|
29
|
-
telemetry off even if you do.
|
|
30
|
-
|
|
31
|
-
**Security note:** RuleReceipt never modifies `.claude/settings.json` and
|
|
32
|
-
installs no hooks without explicit action. It only ever reads your
|
|
33
|
-
CLAUDE.md/AGENTS.md and session transcripts — read-only, manual invocation
|
|
34
|
-
only (`rulereceipt check`). No automatic hooks, ever, in v1.
|
|
18
|
+
```bash
|
|
19
|
+
npx rulereceipt demo
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
No install, no config, no API key, no real session needed — prints a sample
|
|
23
|
+
report so you can see the output shape immediately.
|
|
35
24
|
|
|
36
25
|
## Status
|
|
37
26
|
|
|
@@ -59,7 +48,19 @@ Published and live on npm, actively developed.
|
|
|
59
48
|
Claude key; without it they report UNCLEAR rather than guessing.
|
|
60
49
|
- Lines containing no instruction at all — directory listings,
|
|
61
50
|
reference tables, examples — aren't rules, and are reported as such
|
|
62
|
-
instead of being checked.
|
|
51
|
+
instead of being checked. This step is a heuristic over English
|
|
52
|
+
instruction words, so it can be wrong in both directions: run
|
|
53
|
+
`rulereceipt check --show-skipped` once on your rules file to see
|
|
54
|
+
exactly what it excluded. A rule phrased unusually, or written in
|
|
55
|
+
another language, can land there — and a rule dropped silently is
|
|
56
|
+
worse than one reported wrongly.
|
|
57
|
+
|
|
58
|
+
When it gets one wrong, `rulereceipt rules --include <handle>` fixes it
|
|
59
|
+
permanently. The handle is a hash of the rule's own text, not its
|
|
60
|
+
position, so the correction survives edits elsewhere in the file. That
|
|
61
|
+
matters more than making the classifier smarter: imperative verbs are
|
|
62
|
+
not a closed class and the word list is English-only, so it will keep
|
|
63
|
+
being wrong — it just needs to be correctable.
|
|
63
64
|
4. Prints a report — terminal table by default, `--markdown` for pasting
|
|
64
65
|
into a PR or Slack message, or `--html` for a shareable single file —
|
|
65
66
|
showing what passed, what failed, and a quoted line of evidence for
|
|
@@ -68,30 +69,59 @@ Published and live on npm, actively developed.
|
|
|
68
69
|
that exact file. (It proves the report matches the file, not that the
|
|
69
70
|
file is an unmodified record — see SECURITY.md.)
|
|
70
71
|
|
|
71
|
-
##
|
|
72
|
+
## Usage
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
```bash
|
|
75
|
+
rulereceipt check # check the latest session in this project
|
|
76
|
+
rulereceipt check --markdown # same, formatted for pasting into a PR/Slack
|
|
77
|
+
rulereceipt check --html # write a shareable single-file HTML report you can send
|
|
78
|
+
rulereceipt check --html report.html # ...to a specific path
|
|
79
|
+
rulereceipt check --show-skipped # list what was treated as documentation and not checked
|
|
80
|
+
rulereceipt check --require-session # fail if there's no session, instead of passing silently
|
|
81
|
+
rulereceipt check --exit-zero # report failures without failing the build
|
|
82
|
+
rulereceipt check --llm # opt-in: grade judgment rules with your own Claude key
|
|
83
|
+
rulereceipt check --share # opt-in: send anonymous pass/fail/unclear counts
|
|
84
|
+
rulereceipt check --telemetry # opt-in: send one random per-machine ID
|
|
85
|
+
rulereceipt check --transcript <path> # check a specific session file
|
|
86
|
+
rulereceipt rules # show corrections you've made to what counts as a rule
|
|
87
|
+
rulereceipt rules --include <handle> # "this IS a rule" — check it from now on
|
|
88
|
+
rulereceipt rules --exclude <handle> # "this isn't" — stop reporting it
|
|
89
|
+
rulereceipt rules --coverage # which rules a configured hook might actually enforce
|
|
90
|
+
rulereceipt doctor # list hooks/auto-run tasks configured on this machine
|
|
91
|
+
rulereceipt lint # find contradictions between CLAUDE.md and AGENTS.md
|
|
92
|
+
rulereceipt digest # summarise recent checks; --email to send it
|
|
93
|
+
rulereceipt config # set up email sending (stays on your machine)
|
|
94
|
+
rulereceipt demo # sample output, no setup needed
|
|
95
|
+
rulereceipt demo --markdown
|
|
96
|
+
rulereceipt --version # print the installed version
|
|
97
|
+
rulereceipt verify <session-file> <hash> # spot-check a report you received against the real session file
|
|
98
|
+
```
|
|
77
99
|
|
|
78
|
-
|
|
79
|
-
does the opposite and is the one to use anywhere automated: it fails when
|
|
80
|
-
there is no session, or an empty one, instead of reporting a pass for a
|
|
81
|
-
check that never actually ran.
|
|
100
|
+
`verify` isn't a routine check — trust your team day to day, same as any status update. It's there for the rare case it actually matters (a dispute, an incident review): give it the session file and the hash printed in the report, and it confirms whether they really match.
|
|
82
101
|
|
|
83
|
-
|
|
102
|
+
## Which rules actually have teeth
|
|
84
103
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
deliberately make that transcript available to the job. See
|
|
89
|
-
[templates/rulereceipt-ci.yml](./templates/rulereceipt-ci.yml), which
|
|
90
|
-
explains the options and, if you use it, fails loudly rather than passing
|
|
91
|
-
on a session it never found.
|
|
104
|
+
A rule in a file and a rule with a `PreToolUse` hook behind it look identical
|
|
105
|
+
when you read them, and behave completely differently when they're ignored.
|
|
106
|
+
One fails loudly; the other doesn't fail at all.
|
|
92
107
|
|
|
93
|
-
|
|
94
|
-
|
|
108
|
+
```bash
|
|
109
|
+
rulereceipt rules --coverage
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
This lists your rules against the hooks configured on this machine and in the
|
|
113
|
+
project, and tells you which rules name something a *blocking* hook also
|
|
114
|
+
names. Hooks on events that can't refuse anything — `SessionStart`,
|
|
115
|
+
`PostToolUse` — are counted separately, because they can log or inject
|
|
116
|
+
context but can't make a rule fail.
|
|
117
|
+
|
|
118
|
+
**It reports a possible backing, never a proof, and says so in its own
|
|
119
|
+
output.** A hook's command is usually a path to a script this tool doesn't
|
|
120
|
+
read, so the only evidence available is the event, the matcher, and literal
|
|
121
|
+
text in the command. Both mistakes are possible: a hook can guard a rule
|
|
122
|
+
while sharing no wording with it, and shared wording doesn't mean the hook
|
|
123
|
+
guards it. Treat the links as somewhere to look, and everything else as prose
|
|
124
|
+
until you've checked.
|
|
95
125
|
|
|
96
126
|
## Sharing a report
|
|
97
127
|
|
|
@@ -110,39 +140,30 @@ independently confirm it describes the session it claims to.
|
|
|
110
140
|
Nothing is uploaded. The file is written to your working directory and
|
|
111
141
|
goes wherever you choose to send it.
|
|
112
142
|
|
|
113
|
-
##
|
|
143
|
+
## Exit codes
|
|
114
144
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
145
|
+
`check` exits **1** when a rule was actually broken, and **0** otherwise,
|
|
146
|
+
so CI can gate on it. Rules that need human judgment report UNCLEAR and
|
|
147
|
+
never affect the exit code — most rules in a real CLAUDE.md need judgment,
|
|
148
|
+
and gating on those would make every build red on day one.
|
|
118
149
|
|
|
119
|
-
|
|
120
|
-
|
|
150
|
+
`--exit-zero` prints the report without failing the build. `--require-session`
|
|
151
|
+
does the opposite and is the one to use anywhere automated: it fails when
|
|
152
|
+
there is no session, or an empty one, instead of reporting a pass for a
|
|
153
|
+
check that never actually ran.
|
|
121
154
|
|
|
122
|
-
|
|
155
|
+
### A limit worth knowing before you wire this into CI
|
|
123
156
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
rulereceipt
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
rulereceipt check --llm # opt-in: grade judgment rules with your own Claude key
|
|
132
|
-
rulereceipt check --share # opt-in: send anonymous pass/fail/unclear counts
|
|
133
|
-
rulereceipt check --telemetry # opt-in: send one random per-machine ID
|
|
134
|
-
rulereceipt check --transcript <path> # check a specific session file
|
|
135
|
-
rulereceipt doctor # list hooks/auto-run tasks configured on this machine
|
|
136
|
-
rulereceipt lint # find contradictions between CLAUDE.md and AGENTS.md
|
|
137
|
-
rulereceipt digest # summarise recent checks; --email to send it
|
|
138
|
-
rulereceipt config # set up email sending (stays on your machine)
|
|
139
|
-
rulereceipt demo # sample output, no setup needed
|
|
140
|
-
rulereceipt demo --markdown
|
|
141
|
-
rulereceipt --version # print the installed version
|
|
142
|
-
rulereceipt verify <session-file> <hash> # spot-check a report you received against the real session file
|
|
143
|
-
```
|
|
157
|
+
Claude Code writes its session transcript to the machine the agent ran on
|
|
158
|
+
— your laptop. A CI runner is a fresh machine that has never seen it, so a
|
|
159
|
+
CI job cannot check a session that happened on your laptop unless you
|
|
160
|
+
deliberately make that transcript available to the job. See
|
|
161
|
+
[templates/rulereceipt-ci.yml](./templates/rulereceipt-ci.yml), which
|
|
162
|
+
explains the options and, if you use it, fails loudly rather than passing
|
|
163
|
+
on a session it never found.
|
|
144
164
|
|
|
145
|
-
|
|
165
|
+
For most people the honest answer is simpler: run `rulereceipt check --html`
|
|
166
|
+
locally and attach the report to the PR.
|
|
146
167
|
|
|
147
168
|
## Install
|
|
148
169
|
|
|
@@ -161,6 +182,37 @@ npm test
|
|
|
161
182
|
npx tsx src/cli.ts demo
|
|
162
183
|
```
|
|
163
184
|
|
|
185
|
+
## Trust, privacy and licensing
|
|
186
|
+
|
|
187
|
+
**Nothing leaves your machine unless you ask.** Your code, rules, and
|
|
188
|
+
session content never leave your computer, ever. Plain `rulereceipt check`
|
|
189
|
+
makes zero network calls. `--llm`, `--share`, and `--telemetry` are all
|
|
190
|
+
separate, off-by-default opt-ins: `--llm` calls the Claude API using your
|
|
191
|
+
own Anthropic key for rules that need judgment; `--share` sends aggregate
|
|
192
|
+
pass/fail/unclear counts; `--telemetry` sends one random per-machine ID so
|
|
193
|
+
real distinct-install counts are knowable, nothing else. None of them fire
|
|
194
|
+
unless you explicitly pass the flag, and `DO_NOT_TRACK=1` /
|
|
195
|
+
`RULERECEIPT_NO_TELEMETRY=1` forces telemetry off even if you do.
|
|
196
|
+
|
|
197
|
+
**Never writes anything you didn't ask for.** RuleReceipt never modifies
|
|
198
|
+
`.claude/settings.json` and installs no hooks. No automatic hooks, ever, in
|
|
199
|
+
v1 — it runs only when you type the command.
|
|
200
|
+
|
|
201
|
+
Two commands write, both only when you invoke them: `check --html` writes the
|
|
202
|
+
report to the path you name, and `rules --include/--exclude` records a
|
|
203
|
+
correction in `.rulereceipt/overrides.json`. Plain `rulereceipt check` writes
|
|
204
|
+
nothing and makes no network calls.
|
|
205
|
+
|
|
206
|
+
**You can verify the package came from this source.** Every release from
|
|
207
|
+
0.1.19 on is built and published by GitHub Actions and signed with
|
|
208
|
+
[npm provenance](https://docs.npmjs.com/generating-provenance-statements),
|
|
209
|
+
so you can verify the published package was built from this repository at
|
|
210
|
+
a specific commit. No publishing token exists to be stolen. Check it
|
|
211
|
+
yourself with `npm audit signatures` after installing.
|
|
212
|
+
|
|
213
|
+
**Licence.** Source-available software — see [LICENSE](./LICENSE) and
|
|
214
|
+
[NOTICE.md](./NOTICE.md) before reusing this code.
|
|
215
|
+
|
|
164
216
|
## Contact
|
|
165
217
|
|
|
166
218
|
Questions, bugs, or anything else — hello@rulereceipt.dev.
|
package/dist/browser/analyze.js
CHANGED
|
@@ -3,9 +3,9 @@ import { classifyRules } from "../checks/classify.js";
|
|
|
3
3
|
/** Measured across 559 real public rules files. Shown for comparison. */
|
|
4
4
|
export const CORPUS = {
|
|
5
5
|
files: 559,
|
|
6
|
-
parsed:
|
|
7
|
-
notRulesPct:
|
|
8
|
-
checkablePct:
|
|
6
|
+
parsed: 21986,
|
|
7
|
+
notRulesPct: 63.4,
|
|
8
|
+
checkablePct: 44.2,
|
|
9
9
|
};
|
|
10
10
|
function pct(part, whole) {
|
|
11
11
|
return whole === 0 ? 0 : Math.round((part / whole) * 1000) / 10;
|
package/dist/checks/classify.js
CHANGED
|
@@ -68,12 +68,82 @@ function isEventRecord(rule) {
|
|
|
68
68
|
return false;
|
|
69
69
|
return EVENT_RECORD_TITLE.test(rule.title);
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* A line whose SUBJECT is a command-line option, i.e. the documentation of
|
|
73
|
+
* a flag rather than an instruction to the agent. "`--file=<path>, -f`: Use
|
|
74
|
+
* alternative tasks.json file" describes what the flag does; the imperative
|
|
75
|
+
* belongs to the flag, not to the reader.
|
|
76
|
+
*
|
|
77
|
+
* This is why the directive test alone cannot separate these: command
|
|
78
|
+
* documentation is written with the same verbs as a command, because it is
|
|
79
|
+
* describing a command. The distinguishing property is grammatical — what
|
|
80
|
+
* the sentence is ABOUT — and it is announced by the line beginning with an
|
|
81
|
+
* option token and a separator.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately narrow. It requires the option token to be the first thing on
|
|
84
|
+
* the line and to be followed by a colon, so a rule that merely mentions a
|
|
85
|
+
* flag ("Always pass `--frozen-lockfile` when installing") is untouched.
|
|
86
|
+
*/
|
|
87
|
+
const FLAG_DOCUMENTATION = /^\s*`?\s*-{1,2}[A-Za-z0-9][\w-]*(?:=[^\s,`]*)?(?:\s*,\s*`?\s*-{1,2}[\w-]+`?(?:=[^\s,`]*)?)*\s*`?\s*:/;
|
|
88
|
+
/**
|
|
89
|
+
* English prose almost always contains at least one function word. A shell
|
|
90
|
+
* invocation contains none, and contains at least one token carrying a path
|
|
91
|
+
* separator, a flag prefix, an internal dot, or an assignment.
|
|
92
|
+
*
|
|
93
|
+
* Both conditions are required. "Use modular architecture" has no function
|
|
94
|
+
* word either, but no command-shaped token, so it stays a rule. "Run `npm
|
|
95
|
+
* test` before every push" has command-shaped tokens but also has function
|
|
96
|
+
* words, so it stays a rule too.
|
|
97
|
+
*
|
|
98
|
+
* This is a property of the language, not of the file format — the same
|
|
99
|
+
* reasoning as DIRECTIVE_LANGUAGE above, which is why it is expressed as a
|
|
100
|
+
* test on the text rather than as a list of documentation conventions.
|
|
101
|
+
*/
|
|
102
|
+
const FUNCTION_WORD = /\b(a|an|the|is|are|was|were|be|being|been|to|of|in|on|for|with|when|if|that|this|these|those|and|or|but|not|no|do|does|you|your|it|its|as|at|by|from|before|after|until|unless|any|every|all|each|must|should|never|always|only|via|per|than|then|so|such|into|onto|over|under|about)\b/i;
|
|
103
|
+
const COMMAND_SHAPED_TOKEN = /(?:^|\s)(?:\S*[/\\]\S*|-{1,2}[A-Za-z]\S*|\S+\.\S+|\S+=\S+)/;
|
|
104
|
+
function looksLikeBareCommand(text) {
|
|
105
|
+
const withoutCode = text
|
|
106
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
107
|
+
.replace(/`[^`]*`/g, " ")
|
|
108
|
+
.trim();
|
|
109
|
+
// If stripping code leaves nothing, the body WAS only code.
|
|
110
|
+
const body = withoutCode.length > 0 ? withoutCode : text.replace(/[`\n]+/g, " ").trim();
|
|
111
|
+
if (!body)
|
|
112
|
+
return false;
|
|
113
|
+
// Long enough to be prose, whatever it contains.
|
|
114
|
+
if (body.split(/\s+/).length > 12)
|
|
115
|
+
return false;
|
|
116
|
+
if (FUNCTION_WORD.test(body))
|
|
117
|
+
return false;
|
|
118
|
+
return COMMAND_SHAPED_TOKEN.test(body);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A heading that labels a command, with the command as its whole body:
|
|
122
|
+
* "Build release APK" over `.\gradlew assembleRelease`. The title reads as
|
|
123
|
+
* an imperative, but nothing here constrains the agent — it is a how-to, and
|
|
124
|
+
* there is no compliance to check.
|
|
125
|
+
*
|
|
126
|
+
* Guarded by TITLE_OPENS_WITH_DIRECTIVE so a genuine prohibition whose body
|
|
127
|
+
* is the forbidden command ("Never run: `rm -rf /`") is still a rule.
|
|
128
|
+
*/
|
|
129
|
+
function isCommandDocumentation(rule) {
|
|
130
|
+
if (TITLE_OPENS_WITH_DIRECTIVE.test(rule.title))
|
|
131
|
+
return false;
|
|
132
|
+
if (FLAG_DOCUMENTATION.test(rule.text) || FLAG_DOCUMENTATION.test(rule.title))
|
|
133
|
+
return true;
|
|
134
|
+
return looksLikeBareCommand(rule.text);
|
|
135
|
+
}
|
|
71
136
|
function isNotARule(rule) {
|
|
72
137
|
// Checked before the directive test on purpose: an incident note that
|
|
73
138
|
// ends with its lesson contains a real directive, and would otherwise
|
|
74
139
|
// be enforced as though the history itself were the rule.
|
|
75
140
|
if (isEventRecord(rule))
|
|
76
141
|
return true;
|
|
142
|
+
// Same reason as above: command documentation carries real imperatives
|
|
143
|
+
// ("Use alternative tasks.json file"), so the directive test below would
|
|
144
|
+
// otherwise accept it as a rule to check compliance against.
|
|
145
|
+
if (isCommandDocumentation(rule))
|
|
146
|
+
return true;
|
|
77
147
|
const combined = `${rule.title} ${rule.text}`;
|
|
78
148
|
if (DIRECTIVE_LANGUAGE.test(combined))
|
|
79
149
|
return false;
|
package/dist/checks/doctor.d.ts
CHANGED
|
@@ -3,6 +3,13 @@ export interface HookEntry {
|
|
|
3
3
|
event: string;
|
|
4
4
|
command: string;
|
|
5
5
|
flags: string[];
|
|
6
|
+
/**
|
|
7
|
+
* The matcher the hook is registered under — which tool it fires on, for
|
|
8
|
+
* tool-call events. Kept because it is the only machine-readable statement
|
|
9
|
+
* of what a hook watches; the command itself is usually a script path whose
|
|
10
|
+
* contents this tool does not read.
|
|
11
|
+
*/
|
|
12
|
+
matcher?: string;
|
|
6
13
|
}
|
|
7
14
|
export interface DoctorResult {
|
|
8
15
|
filesScanned: string[];
|
package/dist/checks/doctor.js
CHANGED
|
@@ -38,10 +38,17 @@ function extractHooksFromSettings(filePath) {
|
|
|
38
38
|
const hookList = matcher?.hooks;
|
|
39
39
|
if (!Array.isArray(hookList))
|
|
40
40
|
continue;
|
|
41
|
+
const matcherName = matcher?.matcher;
|
|
41
42
|
for (const h of hookList) {
|
|
42
43
|
const command = h?.command;
|
|
43
44
|
if (typeof command === "string") {
|
|
44
|
-
entries.push({
|
|
45
|
+
entries.push({
|
|
46
|
+
sourceFile: filePath,
|
|
47
|
+
event,
|
|
48
|
+
command,
|
|
49
|
+
flags: flagCommand(command),
|
|
50
|
+
matcher: typeof matcherName === "string" ? matcherName : undefined,
|
|
51
|
+
});
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
54
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Rule } from "../types.js";
|
|
2
|
+
import type { HookEntry } from "./doctor.js";
|
|
3
|
+
/**
|
|
4
|
+
* Which of your rules has anything behind it?
|
|
5
|
+
*
|
|
6
|
+
* Two people arrived at this question independently within a week. From a
|
|
7
|
+
* dev.to thread: "does the parser separate a rule that's only a sentence from
|
|
8
|
+
* the same rule with a hook behind it? In a file they look identical, and
|
|
9
|
+
* only the hooked one fails loudly when it's ignored." And from
|
|
10
|
+
* anthropics/claude-code#90542, someone with a hook-heavy setup reporting
|
|
11
|
+
* 13 out of 13 held where enforcement existed, and 1 failure out of 1
|
|
12
|
+
* opportunity where the same decision was written as prose.
|
|
13
|
+
*
|
|
14
|
+
* The tool could already list hooks (`doctor`) and list rules (`check`).
|
|
15
|
+
* Nothing joined them, so it could tell you what your hooks were and what
|
|
16
|
+
* your rules were, and not which rules were load-bearing.
|
|
17
|
+
*
|
|
18
|
+
* WHAT THIS CANNOT DO, STATED UP FRONT
|
|
19
|
+
*
|
|
20
|
+
* It cannot prove a hook enforces a rule. A hook's command is usually a path
|
|
21
|
+
* to a script this tool does not read, so the only machine-readable evidence
|
|
22
|
+
* available is the event it fires on, the matcher it is registered under, and
|
|
23
|
+
* whatever literal text sits in the command string.
|
|
24
|
+
*
|
|
25
|
+
* That means both errors are possible and neither is rare:
|
|
26
|
+
* - a hook can enforce a rule while sharing no text with it at all
|
|
27
|
+
* - sharing text proves nothing about whether the hook actually guards it
|
|
28
|
+
*
|
|
29
|
+
* So this reports a POSSIBLE backing and nothing stronger. It is a starting
|
|
30
|
+
* point for a person who knows their own setup, in the same spirit as
|
|
31
|
+
* `--show-skipped`: surface what the tool can see, and let the judgement sit
|
|
32
|
+
* with whoever can actually make it.
|
|
33
|
+
*/
|
|
34
|
+
export type Backing = "possiblyGuarded" | "noHookFound";
|
|
35
|
+
export interface RuleCoverage {
|
|
36
|
+
rule: Rule;
|
|
37
|
+
backing: Backing;
|
|
38
|
+
/** Hooks sharing a literal token with this rule. Empty when noHookFound. */
|
|
39
|
+
matchedHooks: HookEntry[];
|
|
40
|
+
/** The tokens that matched, so the user can see WHY it was linked. */
|
|
41
|
+
sharedTokens: string[];
|
|
42
|
+
}
|
|
43
|
+
export declare function isBlockingEvent(event: string): boolean;
|
|
44
|
+
export declare function distinctiveTokens(text: string): string[];
|
|
45
|
+
/**
|
|
46
|
+
* Links rules to hooks by shared literal text.
|
|
47
|
+
*
|
|
48
|
+
* Only hooks on blocking events are considered: a rule "backed" by a hook
|
|
49
|
+
* that cannot refuse anything is not backed in the sense being asked about.
|
|
50
|
+
*/
|
|
51
|
+
export declare function correlate(rules: Rule[], hooks: HookEntry[]): RuleCoverage[];
|
|
52
|
+
export interface CoverageSummary {
|
|
53
|
+
totalRules: number;
|
|
54
|
+
possiblyGuarded: number;
|
|
55
|
+
noHookFound: number;
|
|
56
|
+
blockingHooks: number;
|
|
57
|
+
nonBlockingHooks: number;
|
|
58
|
+
}
|
|
59
|
+
export declare function summarise(coverage: RuleCoverage[], hooks: HookEntry[]): CoverageSummary;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Events that can actually refuse something. A hook on an event that cannot
|
|
3
|
+
* block may still be useful — logging, injecting context — but it cannot make
|
|
4
|
+
* a rule fail loudly, which is the question being asked here.
|
|
5
|
+
*
|
|
6
|
+
* Taken from the documented hook reference. PostToolUse is deliberately
|
|
7
|
+
* absent: it runs after the call has already succeeded.
|
|
8
|
+
*/
|
|
9
|
+
const BLOCKING_EVENTS = new Set([
|
|
10
|
+
"PreToolUse",
|
|
11
|
+
"UserPromptSubmit",
|
|
12
|
+
"UserPromptExpansion",
|
|
13
|
+
"Stop",
|
|
14
|
+
"SubagentStop",
|
|
15
|
+
"PostToolBatch",
|
|
16
|
+
"TeammateIdle",
|
|
17
|
+
"TaskCreated",
|
|
18
|
+
"TaskCompleted",
|
|
19
|
+
"ConfigChange",
|
|
20
|
+
"WorktreeCreate",
|
|
21
|
+
"PreModelSwitch",
|
|
22
|
+
]);
|
|
23
|
+
export function isBlockingEvent(event) {
|
|
24
|
+
return BLOCKING_EVENTS.has(event);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Distinctive literals a rule names: backtick-quoted spans, and bare tokens
|
|
28
|
+
* that look like a command, path or flag.
|
|
29
|
+
*
|
|
30
|
+
* Short and common tokens are dropped. Linking a rule to a hook because both
|
|
31
|
+
* contain "the" or "test" would produce confident-looking nonsense, which is
|
|
32
|
+
* worse than reporting nothing — the same reason a text match in this tool
|
|
33
|
+
* can never produce a confident failure.
|
|
34
|
+
*/
|
|
35
|
+
const MIN_TOKEN_LENGTH = 4;
|
|
36
|
+
const TOO_COMMON = new Set([
|
|
37
|
+
"test", "tests", "file", "files", "code", "main", "true", "false", "null",
|
|
38
|
+
"type", "types", "name", "path", "line", "lines", "data", "when", "then",
|
|
39
|
+
"with", "this", "that", "from", "into", "must", "should", "never", "always",
|
|
40
|
+
"bash", "node", "json", "yaml", "text", "user", "call", "tool", "hook",
|
|
41
|
+
]);
|
|
42
|
+
export function distinctiveTokens(text) {
|
|
43
|
+
const found = new Set();
|
|
44
|
+
for (const [, span] of text.matchAll(/`([^`]{2,80})`/g)) {
|
|
45
|
+
const cleaned = span.trim().toLowerCase();
|
|
46
|
+
if (cleaned.length >= MIN_TOKEN_LENGTH && !TOO_COMMON.has(cleaned))
|
|
47
|
+
found.add(cleaned);
|
|
48
|
+
}
|
|
49
|
+
// Bare tokens shaped like a command, path or flag.
|
|
50
|
+
for (const [, tok] of text.matchAll(/(?:^|\s)(--?[a-z][\w-]{2,}|[\w.-]*\/[\w./*-]+|[\w-]+\.[a-z]{2,4})\b/gi)) {
|
|
51
|
+
const cleaned = tok.trim().toLowerCase();
|
|
52
|
+
if (cleaned.length >= MIN_TOKEN_LENGTH && !TOO_COMMON.has(cleaned))
|
|
53
|
+
found.add(cleaned);
|
|
54
|
+
}
|
|
55
|
+
return [...found];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Links rules to hooks by shared literal text.
|
|
59
|
+
*
|
|
60
|
+
* Only hooks on blocking events are considered: a rule "backed" by a hook
|
|
61
|
+
* that cannot refuse anything is not backed in the sense being asked about.
|
|
62
|
+
*/
|
|
63
|
+
export function correlate(rules, hooks) {
|
|
64
|
+
const blocking = hooks.filter((h) => isBlockingEvent(h.event));
|
|
65
|
+
const hookText = blocking.map((h) => ({
|
|
66
|
+
hook: h,
|
|
67
|
+
haystack: `${h.command} ${h.matcher ?? ""}`.toLowerCase(),
|
|
68
|
+
}));
|
|
69
|
+
return rules.map((rule) => {
|
|
70
|
+
const tokens = distinctiveTokens(`${rule.title} ${rule.text ?? ""}`);
|
|
71
|
+
const matchedHooks = [];
|
|
72
|
+
const sharedTokens = new Set();
|
|
73
|
+
for (const { hook, haystack } of hookText) {
|
|
74
|
+
const hits = tokens.filter((t) => haystack.includes(t));
|
|
75
|
+
if (hits.length > 0) {
|
|
76
|
+
matchedHooks.push(hook);
|
|
77
|
+
hits.forEach((h) => sharedTokens.add(h));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
rule,
|
|
82
|
+
backing: matchedHooks.length > 0 ? "possiblyGuarded" : "noHookFound",
|
|
83
|
+
matchedHooks,
|
|
84
|
+
sharedTokens: [...sharedTokens],
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
export function summarise(coverage, hooks) {
|
|
89
|
+
return {
|
|
90
|
+
totalRules: coverage.length,
|
|
91
|
+
possiblyGuarded: coverage.filter((c) => c.backing === "possiblyGuarded").length,
|
|
92
|
+
noHookFound: coverage.filter((c) => c.backing === "noHookFound").length,
|
|
93
|
+
blockingHooks: hooks.filter((h) => isBlockingEvent(h.event)).length,
|
|
94
|
+
nonBlockingHooks: hooks.filter((h) => !isBlockingEvent(h.event)).length,
|
|
95
|
+
};
|
|
96
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { parseClaudeMd } from "./parsers/readClaudeMd.js";
|
|
|
9
9
|
import { readLatestTranscript, readTranscriptFromFile, findLatestSessionFile } from "./parsers/transcriptParser.js";
|
|
10
10
|
import { loadRules } from "./rules.js";
|
|
11
11
|
import { classifyRules } from "./checks/classify.js";
|
|
12
|
+
import { loadOverrides, saveOverride, clearOverride, staleOverrides, ruleFingerprint, OVERRIDES_PATH } from "./overrides.js";
|
|
12
13
|
import { runDeterministicChecks } from "./checks/deterministicChecks.js";
|
|
13
14
|
import { runIfEditThenTestChecks } from "./checks/ifEditThenTest.js";
|
|
14
15
|
import { runGitBranchPolicyChecks } from "./checks/gitBranchPolicy.js";
|
|
@@ -25,6 +26,7 @@ import { generateDigest } from "./digest.js";
|
|
|
25
26
|
import { enableSchedule, disableSchedule, scheduleStatus } from "./schedule.js";
|
|
26
27
|
import { findSplitBrainConflicts } from "./checks/splitBrain.js";
|
|
27
28
|
import { runDoctor } from "./checks/doctor.js";
|
|
29
|
+
import { correlate, summarise } from "./checks/hookCoverage.js";
|
|
28
30
|
import { sendTelemetryPing, isTelemetryEnabled } from "./telemetry.js";
|
|
29
31
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
32
|
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
@@ -132,7 +134,7 @@ function writeHtmlReport(results, meta, cwd, target) {
|
|
|
132
134
|
}
|
|
133
135
|
}
|
|
134
136
|
async function runCheck(opts) {
|
|
135
|
-
const { markdown, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, transcriptOverride } = opts;
|
|
137
|
+
const { markdown, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, showSkipped, transcriptOverride } = opts;
|
|
136
138
|
const cwd = process.cwd();
|
|
137
139
|
const rules = loadRules(cwd);
|
|
138
140
|
if (rules.length === 0) {
|
|
@@ -178,7 +180,33 @@ async function runCheck(opts) {
|
|
|
178
180
|
return;
|
|
179
181
|
}
|
|
180
182
|
}
|
|
181
|
-
|
|
183
|
+
/**
|
|
184
|
+
* The classifier's guess is corrected here, before anything is checked.
|
|
185
|
+
*
|
|
186
|
+
* It looks for a list of English instruction words and is wrong in both
|
|
187
|
+
* directions: imperative verbs are not a closed class, and the list cannot
|
|
188
|
+
* match a rule written in another language. This project's own Rule 5 was
|
|
189
|
+
* filed as documentation and never checked, because it says "say so
|
|
190
|
+
* explicitly" and `say` is not on the list.
|
|
191
|
+
*
|
|
192
|
+
* A rule the user re-includes becomes a judgment result rather than a
|
|
193
|
+
* confident verdict. Knowing it IS a rule says nothing about which check
|
|
194
|
+
* can settle it, and guessing would be exactly the behaviour this tool
|
|
195
|
+
* exists to avoid — so it reports as needing a person, which is honest and
|
|
196
|
+
* strictly better than being dropped in silence.
|
|
197
|
+
*/
|
|
198
|
+
const overrides = loadOverrides(cwd);
|
|
199
|
+
const classifications = classifyRules(rules).map((c) => {
|
|
200
|
+
const decision = overrides.get(ruleFingerprint(c.rule))?.decision;
|
|
201
|
+
if (!decision)
|
|
202
|
+
return c;
|
|
203
|
+
if (decision === "notARule")
|
|
204
|
+
return { kind: "notARule", rule: c.rule };
|
|
205
|
+
return c.kind === "notARule" ? { kind: "judgment", rule: c.rule } : c;
|
|
206
|
+
});
|
|
207
|
+
// An override that stopped matching usually means the rule was reworded.
|
|
208
|
+
// Saying so beats letting someone assume a correction is still in force.
|
|
209
|
+
const stale = staleOverrides(overrides, rules);
|
|
182
210
|
const deterministic = classifications.filter((c) => c.kind === "deterministic");
|
|
183
211
|
const ifEditThenTest = classifications.filter((c) => c.kind === "ifEditThenTest");
|
|
184
212
|
const gitBranchPolicy = classifications.filter((c) => c.kind === "gitBranchPolicy");
|
|
@@ -229,8 +257,48 @@ async function runCheck(opts) {
|
|
|
229
257
|
if (html !== false) {
|
|
230
258
|
writeHtmlReport(results, { sessionFilePath, ruleCount: results.length }, cwd, html);
|
|
231
259
|
}
|
|
260
|
+
// A count alone is not enough. The classifier is a heuristic over English
|
|
261
|
+
// verbs: measured across 559 public rules files it drops non-English
|
|
262
|
+
// content at 97.5% against a 64.5% baseline, and any imperative verb
|
|
263
|
+
// outside its list is invisible to it. Neither gap closes by extending the
|
|
264
|
+
// list — imperative verbs are not a closed class, and the list is
|
|
265
|
+
// English-only by construction.
|
|
266
|
+
//
|
|
267
|
+
// What does close is the silence. "12 items were documentation" reads as
|
|
268
|
+
// reassurance; it is the one place this tool still guesses without saying
|
|
269
|
+
// so, and a rule dropped here never appears in the report at all. Listing
|
|
270
|
+
// them needs no key, works in any language, and lets the person who wrote
|
|
271
|
+
// the rule be the one who decides.
|
|
232
272
|
if (notARule.length > 0) {
|
|
233
|
-
|
|
273
|
+
const n = notARule.length;
|
|
274
|
+
const plural = n === 1 ? "" : "s";
|
|
275
|
+
console.log(`\n(${n} item${plural} in your rules file ${n === 1 ? "was" : "were"} treated as documentation and not checked — directory listings, reference tables, examples.)`);
|
|
276
|
+
if (showSkipped) {
|
|
277
|
+
console.log(`\nSkipped as documentation:\n`);
|
|
278
|
+
for (const { rule } of notARule) {
|
|
279
|
+
const label = rule.title.replace(/\s+/g, " ").trim();
|
|
280
|
+
// The handle is a content hash, never the rule id: ids are positional
|
|
281
|
+
// and every edit above a rule renumbers it, so a correction keyed on
|
|
282
|
+
// one would silently reattach itself to a different rule.
|
|
283
|
+
//
|
|
284
|
+
// The source is shown because rules are read from the global file as
|
|
285
|
+
// well as the project's. Without it, someone editing their project
|
|
286
|
+
// CLAUDE.md to fix an item that came from ~/.claude/CLAUDE.md gets no
|
|
287
|
+
// explanation for why nothing changed.
|
|
288
|
+
const where = rule.source === "global" ? " (global)" : "";
|
|
289
|
+
console.log(` [${ruleFingerprint(rule)}]${where} ${label.slice(0, 100)}`);
|
|
290
|
+
}
|
|
291
|
+
console.log(`\nIf any of those is actually a rule, the classifier was wrong. It looks for` +
|
|
292
|
+
`\nEnglish instruction words, so a rule written another way — or in another` +
|
|
293
|
+
`\nlanguage — can land here. Worth a look; you know your rules, it doesn't.` +
|
|
294
|
+
`\n\nTo fix one permanently: rulereceipt rules --include <handle>`);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
console.log(`Run with --show-skipped to see them.`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (stale.length > 0) {
|
|
301
|
+
console.log(`\n(${stale.length} saved correction${stale.length === 1 ? "" : "s"} no longer match any rule in this project — the rule was probably reworded. Run \`rulereceipt rules --list\` to see them.)`);
|
|
234
302
|
}
|
|
235
303
|
appendHistory(results, sessionFilePath);
|
|
236
304
|
if (share) {
|
|
@@ -288,6 +356,7 @@ program
|
|
|
288
356
|
.option("--html [path]", `write a shareable single-file HTML report you can email, attach to a ticket, or print to PDF. Defaults to ./${DEFAULT_HTML_REPORT_NAME}. Written locally — nothing is uploaded.`)
|
|
289
357
|
.option("--exit-zero", "always exit 0, even when a rule was broken. Without this, `check` exits 1 on any FAIL so CI can gate on it (rules needing human judgment report UNCLEAR and never affect the exit code).")
|
|
290
358
|
.option("--require-session", "fail (exit 1) if no session is found, or the session is empty, instead of reporting a pass for a check that never actually ran. Use this anywhere automated.")
|
|
359
|
+
.option("--show-skipped", "list the items that were treated as documentation and not checked. Worth running once on any rules file: the classifier is a heuristic over English verbs, so a rule it does not recognise is otherwise dropped without you seeing it.")
|
|
291
360
|
.option("--transcript <path>", "manual override: check this exact .jsonl session file instead of auto-detecting one. Useful if your Claude Code session lives somewhere non-standard that auto-detection doesn't cover.")
|
|
292
361
|
.action((opts) => {
|
|
293
362
|
runCheck({
|
|
@@ -301,12 +370,138 @@ program
|
|
|
301
370
|
html: opts.html ?? false,
|
|
302
371
|
exitZero: Boolean(opts.exitZero),
|
|
303
372
|
requireSession: Boolean(opts.requireSession),
|
|
373
|
+
showSkipped: Boolean(opts.showSkipped),
|
|
304
374
|
transcriptOverride: opts.transcript,
|
|
305
375
|
}).catch((err) => {
|
|
306
376
|
console.error("Something went wrong:", err instanceof Error ? err.message : err);
|
|
307
377
|
process.exitCode = 1;
|
|
308
378
|
});
|
|
309
379
|
});
|
|
380
|
+
/**
|
|
381
|
+
* Corrections to what the classifier thinks is a rule.
|
|
382
|
+
*
|
|
383
|
+
* Writes, deliberately and only from here. `check` never writes anything —
|
|
384
|
+
* a tool that puts files in someone's project without being asked is one
|
|
385
|
+
* people stop trusting, and that guarantee is worth more than the
|
|
386
|
+
* convenience of auto-saving a correction.
|
|
387
|
+
*/
|
|
388
|
+
/**
|
|
389
|
+
* Which rules have a hook behind them, and — said plainly — which of that is
|
|
390
|
+
* guesswork.
|
|
391
|
+
*
|
|
392
|
+
* A hook's command is normally a path to a script this tool does not read, so
|
|
393
|
+
* the only evidence available is the event, the matcher, and literal text in
|
|
394
|
+
* the command string. Both error directions are live: a hook can guard a rule
|
|
395
|
+
* while sharing no wording with it, and shared wording proves nothing. The
|
|
396
|
+
* output says so every time, because it reads like an audit and people
|
|
397
|
+
* believe audits.
|
|
398
|
+
*/
|
|
399
|
+
function runCoverage() {
|
|
400
|
+
const cwd = process.cwd();
|
|
401
|
+
const rules = loadRules(cwd);
|
|
402
|
+
if (rules.length === 0) {
|
|
403
|
+
console.log("No CLAUDE.md or AGENTS.md found, so there are no rules to check hooks against.");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const hooks = runDoctor(cwd).hooks;
|
|
407
|
+
const coverage = correlate(rules, hooks);
|
|
408
|
+
const s = summarise(coverage, hooks);
|
|
409
|
+
console.log(`Rule coverage against configured hooks\n`);
|
|
410
|
+
console.log(` ${hooks.length} hook${hooks.length === 1 ? "" : "s"} configured · ${s.blockingHooks} can refuse something · ${s.nonBlockingHooks} cannot`);
|
|
411
|
+
console.log(` ${s.possiblyGuarded} of ${s.totalRules} rules name something a blocking hook also names`);
|
|
412
|
+
console.log(` ${s.noHookFound} of ${s.totalRules} have no hook this can connect them to\n`);
|
|
413
|
+
const guarded = coverage.filter((c) => c.backing === "possiblyGuarded");
|
|
414
|
+
if (guarded.length > 0) {
|
|
415
|
+
console.log(`Possibly guarded:\n`);
|
|
416
|
+
for (const c of guarded) {
|
|
417
|
+
console.log(` ${c.rule.title.replace(/\s+/g, " ").trim().slice(0, 80)}`);
|
|
418
|
+
for (const h of c.matchedHooks) {
|
|
419
|
+
// Relative to the working directory when possible: an absolute path
|
|
420
|
+
// is noise in a report someone may paste elsewhere, and can leak a
|
|
421
|
+
// home directory name.
|
|
422
|
+
const where = h.sourceFile.startsWith(cwd) ? h.sourceFile.slice(cwd.length + 1) : h.sourceFile;
|
|
423
|
+
console.log(` ${h.event} hook in ${where}${h.matcher ? ` (matcher: ${h.matcher})` : ""}`);
|
|
424
|
+
}
|
|
425
|
+
console.log(` linked on: ${c.sharedTokens.map((t) => `"${t}"`).join(", ")}`);
|
|
426
|
+
console.log("");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// Project rules first. Someone running this inside a project can act on
|
|
430
|
+
// their own rules; the ones from ~/.claude/CLAUDE.md apply everywhere and
|
|
431
|
+
// are usually not what they came here to look at. Listing global rules
|
|
432
|
+
// first buried every project rule behind them.
|
|
433
|
+
const unbacked = coverage
|
|
434
|
+
.filter((c) => c.backing === "noHookFound")
|
|
435
|
+
.sort((a, b) => Number(a.rule.source === "global") - Number(b.rule.source === "global"));
|
|
436
|
+
if (unbacked.length > 0) {
|
|
437
|
+
const projectCount = unbacked.filter((c) => c.rule.source !== "global").length;
|
|
438
|
+
console.log(`No hook found for ${unbacked.length} rule${unbacked.length === 1 ? "" : "s"} (${projectCount} in this project), including:\n`);
|
|
439
|
+
for (const c of unbacked.slice(0, 10)) {
|
|
440
|
+
const where = c.rule.source === "global" ? " (global)" : "";
|
|
441
|
+
console.log(` ${c.rule.title.replace(/\s+/g, " ").trim().slice(0, 76)}${where}`);
|
|
442
|
+
}
|
|
443
|
+
if (unbacked.length > 10)
|
|
444
|
+
console.log(` ...and ${unbacked.length - 10} more`);
|
|
445
|
+
console.log("");
|
|
446
|
+
}
|
|
447
|
+
console.log(`This is text overlap, and it is not proof. A hook can guard a rule without`);
|
|
448
|
+
console.log(`sharing any wording with it, and shared wording does not mean the hook`);
|
|
449
|
+
console.log(`guards it — the command is usually a script this tool does not read. Treat`);
|
|
450
|
+
console.log(`the links above as somewhere to look, and the rest as prose until you have`);
|
|
451
|
+
console.log(`checked otherwise.`);
|
|
452
|
+
if (s.nonBlockingHooks > 0) {
|
|
453
|
+
console.log(`\nHooks on events that cannot refuse anything were not counted. They can`);
|
|
454
|
+
console.log(`log or inject context, but they cannot make a rule fail when it is ignored.`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async function runRules(opts) {
|
|
458
|
+
if (opts.coverage) {
|
|
459
|
+
runCoverage();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const cwd = process.cwd();
|
|
463
|
+
const rules = loadRules(cwd);
|
|
464
|
+
const overrides = loadOverrides(cwd);
|
|
465
|
+
const findRule = (handle) => rules.find((r) => ruleFingerprint(r) === handle);
|
|
466
|
+
if (opts.include || opts.exclude) {
|
|
467
|
+
const handle = (opts.include ?? opts.exclude);
|
|
468
|
+
const decision = opts.include ? "rule" : "notARule";
|
|
469
|
+
const rule = findRule(handle);
|
|
470
|
+
if (!rule) {
|
|
471
|
+
console.error(`No rule in this project has the handle ${handle}.`);
|
|
472
|
+
console.error(`Handles come from \`rulereceipt check --show-skipped\`, and change if the rule's wording changes.`);
|
|
473
|
+
process.exitCode = 1;
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
saveOverride(cwd, { hash: handle, decision, title: rule.title.replace(/\s+/g, " ").trim().slice(0, 200) });
|
|
477
|
+
const verb = opts.include ? "will now be checked" : "will no longer be checked";
|
|
478
|
+
console.log(`Saved to ${OVERRIDES_PATH}.`);
|
|
479
|
+
console.log(` "${rule.title.replace(/\s+/g, " ").trim().slice(0, 90)}" ${verb}.`);
|
|
480
|
+
if (opts.include) {
|
|
481
|
+
console.log(`\nIt will report as needing your judgment. Knowing it is a rule says nothing`);
|
|
482
|
+
console.log(`about which check can settle it, and guessing is what this tool avoids.`);
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (opts.clear) {
|
|
487
|
+
console.log(clearOverride(cwd, opts.clear) ? `Removed the correction for ${opts.clear}.` : `No correction stored for ${opts.clear}.`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (overrides.size === 0) {
|
|
491
|
+
console.log(`No corrections stored for this project.`);
|
|
492
|
+
console.log(`\nRun \`rulereceipt check --show-skipped\` to see what the classifier excluded,`);
|
|
493
|
+
console.log(`then \`rulereceipt rules --include <handle>\` for anything that is really a rule.`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const stale = new Set(staleOverrides(overrides, rules).map((o) => o.hash));
|
|
497
|
+
console.log(`Corrections in ${OVERRIDES_PATH}:\n`);
|
|
498
|
+
for (const o of overrides.values()) {
|
|
499
|
+
const mark = o.decision === "rule" ? "checked" : "ignored";
|
|
500
|
+
const note = stale.has(o.hash) ? " (no longer matches any rule — reworded?)" : "";
|
|
501
|
+
console.log(` ${o.hash} ${mark.padEnd(8)} ${o.title.slice(0, 70)}${note}`);
|
|
502
|
+
}
|
|
503
|
+
console.log(`\nRemove one with: rulereceipt rules --clear <handle>`);
|
|
504
|
+
}
|
|
310
505
|
async function runLint(markdown, llm) {
|
|
311
506
|
const cwd = process.cwd();
|
|
312
507
|
const claudeMdPath = join(cwd, "CLAUDE.md");
|
|
@@ -387,6 +582,20 @@ program
|
|
|
387
582
|
process.exitCode = 1;
|
|
388
583
|
}
|
|
389
584
|
});
|
|
585
|
+
program
|
|
586
|
+
.command("rules")
|
|
587
|
+
.description("correct what the classifier treats as a rule. Handles come from `check --show-skipped`.")
|
|
588
|
+
.option("--include <handle>", "treat this item as a real rule and check it from now on")
|
|
589
|
+
.option("--exclude <handle>", "treat this item as documentation and stop reporting it")
|
|
590
|
+
.option("--clear <handle>", "remove a stored correction")
|
|
591
|
+
.option("--list", "show stored corrections (the default when no other flag is given)")
|
|
592
|
+
.option("--coverage", "show which rules a configured hook might actually be enforcing, and which are prose only")
|
|
593
|
+
.action((opts) => {
|
|
594
|
+
runRules(opts).catch((err) => {
|
|
595
|
+
console.error("Something went wrong:", err instanceof Error ? err.message : err);
|
|
596
|
+
process.exitCode = 1;
|
|
597
|
+
});
|
|
598
|
+
});
|
|
390
599
|
program
|
|
391
600
|
.command("lint")
|
|
392
601
|
.description("Find contradictions between this project's CLAUDE.md and AGENTS.md")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Rule } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* User corrections to the tool's guess about what counts as a rule.
|
|
4
|
+
*
|
|
5
|
+
* The classifier decides which lines in a rules file are genuine instructions
|
|
6
|
+
* and which are documentation, using a list of English instruction words. It
|
|
7
|
+
* is wrong in both directions and cannot stop being wrong: imperative verbs
|
|
8
|
+
* are not a closed class, and an English word list cannot match a rule
|
|
9
|
+
* written in another language — measured across 559 public files, non-English
|
|
10
|
+
* content is dropped at 97.5% against a 63.4% baseline.
|
|
11
|
+
*
|
|
12
|
+
* This project's own Rule 5, "No silent scope changes", was filed as
|
|
13
|
+
* documentation and never checked in any report the tool produced, because
|
|
14
|
+
* the rule says "say so explicitly" and `say` is not on that list.
|
|
15
|
+
*
|
|
16
|
+
* So the classifier does not need to be right. It needs to be CORRECTABLE,
|
|
17
|
+
* and to stay corrected. `--show-skipped` makes a mistake visible; this makes
|
|
18
|
+
* the fix permanent. That combination has no coverage gap — it needs no API
|
|
19
|
+
* key, works in any language and any phrasing, and leaves the judgement with
|
|
20
|
+
* whoever wrote the rules.
|
|
21
|
+
*/
|
|
22
|
+
export type Decision = "rule" | "notARule";
|
|
23
|
+
export interface Override {
|
|
24
|
+
/** Content hash of the rule — see ruleFingerprint. */
|
|
25
|
+
hash: string;
|
|
26
|
+
decision: Decision;
|
|
27
|
+
/** Stored for humans reading the file, and to explain a stale entry. */
|
|
28
|
+
title: string;
|
|
29
|
+
}
|
|
30
|
+
export declare const OVERRIDES_PATH: string;
|
|
31
|
+
/**
|
|
32
|
+
* Identifies a rule by its CONTENT, never by its id or position.
|
|
33
|
+
*
|
|
34
|
+
* Rule ids are positional. Editing a rules file renumbers everything after
|
|
35
|
+
* the edit, and the same is true of any upstream change: a parser fix on
|
|
36
|
+
* 2026-09-04 shifted the corpus enough that a seeded sample redrawn at the
|
|
37
|
+
* same seed returned 92 different rules out of 100. An override keyed on
|
|
38
|
+
* position would silently reattach a decision to a rule nobody ever judged,
|
|
39
|
+
* which is the exact failure this feature exists to prevent.
|
|
40
|
+
*
|
|
41
|
+
* Whitespace is normalised so reflowing a paragraph doesn't drop the
|
|
42
|
+
* override, but wording is not: if the rule's words change, it is a different
|
|
43
|
+
* rule and deserves a fresh look.
|
|
44
|
+
*/
|
|
45
|
+
export declare function ruleFingerprint(rule: Rule): string;
|
|
46
|
+
/** Reads overrides for a project. Absent or unreadable file means none. */
|
|
47
|
+
export declare function loadOverrides(cwd: string): Map<string, Override>;
|
|
48
|
+
/**
|
|
49
|
+
* Writes an override. Only ever called from an explicit command — `check`
|
|
50
|
+
* never writes anything, which is a stated guarantee of this tool.
|
|
51
|
+
*/
|
|
52
|
+
export declare function saveOverride(cwd: string, entry: Override): void;
|
|
53
|
+
/** Removes an override, returning whether one was actually there. */
|
|
54
|
+
export declare function clearOverride(cwd: string, hash: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Overrides recorded against rules that are no longer present.
|
|
57
|
+
*
|
|
58
|
+
* Surfaced rather than silently ignored: an override that stopped matching
|
|
59
|
+
* usually means the rule was reworded, and the user should know their
|
|
60
|
+
* correction is no longer being applied instead of assuming it still is.
|
|
61
|
+
*/
|
|
62
|
+
export declare function staleOverrides(overrides: Map<string, Override>, rules: Rule[]): Override[];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export const OVERRIDES_PATH = join(".rulereceipt", "overrides.json");
|
|
5
|
+
/**
|
|
6
|
+
* Identifies a rule by its CONTENT, never by its id or position.
|
|
7
|
+
*
|
|
8
|
+
* Rule ids are positional. Editing a rules file renumbers everything after
|
|
9
|
+
* the edit, and the same is true of any upstream change: a parser fix on
|
|
10
|
+
* 2026-09-04 shifted the corpus enough that a seeded sample redrawn at the
|
|
11
|
+
* same seed returned 92 different rules out of 100. An override keyed on
|
|
12
|
+
* position would silently reattach a decision to a rule nobody ever judged,
|
|
13
|
+
* which is the exact failure this feature exists to prevent.
|
|
14
|
+
*
|
|
15
|
+
* Whitespace is normalised so reflowing a paragraph doesn't drop the
|
|
16
|
+
* override, but wording is not: if the rule's words change, it is a different
|
|
17
|
+
* rule and deserves a fresh look.
|
|
18
|
+
*/
|
|
19
|
+
export function ruleFingerprint(rule) {
|
|
20
|
+
const normalised = `${rule.title}\n${rule.text ?? ""}`.replace(/\s+/g, " ").trim();
|
|
21
|
+
return createHash("sha256").update(normalised).digest("hex").slice(0, 12);
|
|
22
|
+
}
|
|
23
|
+
/** Reads overrides for a project. Absent or unreadable file means none. */
|
|
24
|
+
export function loadOverrides(cwd) {
|
|
25
|
+
const path = join(cwd, OVERRIDES_PATH);
|
|
26
|
+
const map = new Map();
|
|
27
|
+
if (!existsSync(path))
|
|
28
|
+
return map;
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
31
|
+
if (!Array.isArray(parsed?.overrides))
|
|
32
|
+
return map;
|
|
33
|
+
for (const o of parsed.overrides) {
|
|
34
|
+
if (typeof o?.hash === "string" && (o.decision === "rule" || o.decision === "notARule")) {
|
|
35
|
+
map.set(o.hash, { hash: o.hash, decision: o.decision, title: String(o.title ?? "") });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// A corrupt overrides file must not take the whole check down. The user
|
|
41
|
+
// loses their corrections for this run, which is visible in the report,
|
|
42
|
+
// rather than losing the report entirely.
|
|
43
|
+
}
|
|
44
|
+
return map;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Writes an override. Only ever called from an explicit command — `check`
|
|
48
|
+
* never writes anything, which is a stated guarantee of this tool.
|
|
49
|
+
*/
|
|
50
|
+
export function saveOverride(cwd, entry) {
|
|
51
|
+
const path = join(cwd, OVERRIDES_PATH);
|
|
52
|
+
const existing = loadOverrides(cwd);
|
|
53
|
+
existing.set(entry.hash, entry);
|
|
54
|
+
const out = {
|
|
55
|
+
version: 1,
|
|
56
|
+
overrides: [...existing.values()].sort((a, b) => a.hash.localeCompare(b.hash)),
|
|
57
|
+
};
|
|
58
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
59
|
+
writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`);
|
|
60
|
+
}
|
|
61
|
+
/** Removes an override, returning whether one was actually there. */
|
|
62
|
+
export function clearOverride(cwd, hash) {
|
|
63
|
+
const existing = loadOverrides(cwd);
|
|
64
|
+
if (!existing.delete(hash))
|
|
65
|
+
return false;
|
|
66
|
+
const out = {
|
|
67
|
+
version: 1,
|
|
68
|
+
overrides: [...existing.values()].sort((a, b) => a.hash.localeCompare(b.hash)),
|
|
69
|
+
};
|
|
70
|
+
const path = join(cwd, OVERRIDES_PATH);
|
|
71
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
72
|
+
writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Overrides recorded against rules that are no longer present.
|
|
77
|
+
*
|
|
78
|
+
* Surfaced rather than silently ignored: an override that stopped matching
|
|
79
|
+
* usually means the rule was reworded, and the user should know their
|
|
80
|
+
* correction is no longer being applied instead of assuming it still is.
|
|
81
|
+
*/
|
|
82
|
+
export function staleOverrides(overrides, rules) {
|
|
83
|
+
const live = new Set(rules.map(ruleFingerprint));
|
|
84
|
+
return [...overrides.values()].filter((o) => !live.has(o.hash));
|
|
85
|
+
}
|
|
@@ -42,9 +42,45 @@ const PLAIN_HEADER = /^#{1,6}\s+(.+)$/;
|
|
|
42
42
|
const BULLET_ITEM = /^\s*[-*+]\s+(.+)$/;
|
|
43
43
|
const SETEXT_H1_UNDERLINE = /^=+\s*$/;
|
|
44
44
|
const SETEXT_H2_UNDERLINE = /^-{2,}\s*$/;
|
|
45
|
+
/**
|
|
46
|
+
* A fenced code block is content, not structure.
|
|
47
|
+
*
|
|
48
|
+
* Found by auditing 559 real rules files: 588 rules came out holding an
|
|
49
|
+
* unclosed fence, meaning the parser had split them mid-block. Markdown
|
|
50
|
+
* headings and bullets appear inside code samples constantly — a shell
|
|
51
|
+
* comment starts with `#`, a YAML list item starts with `-` — and treating
|
|
52
|
+
* those as rule boundaries does three things at once. It truncates the real
|
|
53
|
+
* rule at the fence, it fabricates rules out of the sample's contents, and
|
|
54
|
+
* it lifts commands out of a "here is what NOT to do" example into a rule
|
|
55
|
+
* body, where the structured checks can then read them as though they were
|
|
56
|
+
* the rule itself.
|
|
57
|
+
*
|
|
58
|
+
* Matches both fence styles CommonMark allows, and requires the closing
|
|
59
|
+
* fence to use the same character as the opening one, so a ``` inside a
|
|
60
|
+
* ~~~ block does not close it.
|
|
61
|
+
*/
|
|
62
|
+
const FENCE_LINE = /^\s*(`{3,}|~{3,})/;
|
|
45
63
|
function normalizeSetextHeaders(lines) {
|
|
46
64
|
const out = [...lines];
|
|
65
|
+
// Fence-aware for the same reason as the main pass: a row of dashes inside
|
|
66
|
+
// a code sample is not a setext underline.
|
|
67
|
+
let inFence = false;
|
|
68
|
+
let fenceChar = "";
|
|
47
69
|
for (let i = 0; i < out.length - 1; i++) {
|
|
70
|
+
const fence = out[i].match(FENCE_LINE);
|
|
71
|
+
if (fence) {
|
|
72
|
+
const ch = fence[1][0];
|
|
73
|
+
if (!inFence) {
|
|
74
|
+
inFence = true;
|
|
75
|
+
fenceChar = ch;
|
|
76
|
+
}
|
|
77
|
+
else if (ch === fenceChar) {
|
|
78
|
+
inFence = false;
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (inFence)
|
|
83
|
+
continue;
|
|
48
84
|
const title = out[i];
|
|
49
85
|
if (title.trim() === "")
|
|
50
86
|
continue;
|
|
@@ -101,6 +137,8 @@ export function parseClaudeMdText(raw, source) {
|
|
|
101
137
|
let sectionCount = 0;
|
|
102
138
|
let sectionId = "S0"; // "S0" before any header is seen; null while a header is pending its first rule
|
|
103
139
|
let bulletIndex = 0;
|
|
140
|
+
let inFence = false;
|
|
141
|
+
let fenceChar = "";
|
|
104
142
|
const flush = () => {
|
|
105
143
|
if (current) {
|
|
106
144
|
current.text = bodyLines.join("\n").trim();
|
|
@@ -116,7 +154,40 @@ export function parseClaudeMdText(raw, source) {
|
|
|
116
154
|
}
|
|
117
155
|
return sectionId;
|
|
118
156
|
};
|
|
157
|
+
/** Adds a line to whatever rule is open, promoting a pending section if needed. */
|
|
158
|
+
const appendBody = (line) => {
|
|
159
|
+
if (currentIsMarkedRule) {
|
|
160
|
+
bodyLines.push(line);
|
|
161
|
+
}
|
|
162
|
+
else if (pendingSectionTitle !== null && line.trim() !== "") {
|
|
163
|
+
current = { id: `${assignSectionId()}.0`, title: pendingSectionTitle, text: "", source };
|
|
164
|
+
bodyLines = [line];
|
|
165
|
+
pendingSectionTitle = null;
|
|
166
|
+
}
|
|
167
|
+
else if (current) {
|
|
168
|
+
bodyLines.push(line);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
119
171
|
for (const line of lines) {
|
|
172
|
+
// Fence state is tracked before any structural match, so nothing inside a
|
|
173
|
+
// code block is ever read as a heading, a bullet, or a rule marker.
|
|
174
|
+
const fence = line.match(FENCE_LINE);
|
|
175
|
+
if (fence) {
|
|
176
|
+
const ch = fence[1][0];
|
|
177
|
+
if (!inFence) {
|
|
178
|
+
inFence = true;
|
|
179
|
+
fenceChar = ch;
|
|
180
|
+
}
|
|
181
|
+
else if (ch === fenceChar) {
|
|
182
|
+
inFence = false;
|
|
183
|
+
}
|
|
184
|
+
appendBody(line);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (inFence) {
|
|
188
|
+
appendBody(line);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
120
191
|
const numbered = line.match(NUMBERED_HEADER);
|
|
121
192
|
if (numbered) {
|
|
122
193
|
flush();
|
|
@@ -154,17 +225,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
154
225
|
}
|
|
155
226
|
continue;
|
|
156
227
|
}
|
|
157
|
-
|
|
158
|
-
bodyLines.push(line);
|
|
159
|
-
}
|
|
160
|
-
else if (pendingSectionTitle !== null && line.trim() !== "") {
|
|
161
|
-
current = { id: `${assignSectionId()}.0`, title: pendingSectionTitle, text: "", source };
|
|
162
|
-
bodyLines = [line];
|
|
163
|
-
pendingSectionTitle = null;
|
|
164
|
-
}
|
|
165
|
-
else if (current) {
|
|
166
|
-
bodyLines.push(line);
|
|
167
|
-
}
|
|
228
|
+
appendBody(line);
|
|
168
229
|
}
|
|
169
230
|
flush();
|
|
170
231
|
if (rules.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "Checks whether a Claude Code session actually followed your CLAUDE.md / AGENTS.md rules, with evidence.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
"test": "vitest run",
|
|
38
38
|
"test:watch": "vitest",
|
|
39
39
|
"lint": "eslint src tests",
|
|
40
|
-
"prepublishOnly": "npm run build && npm test"
|
|
40
|
+
"prepublishOnly": "npm run build && npm test",
|
|
41
|
+
"build:checker": "esbuild src/browser/analyze.ts --bundle --format=esm --minify --outfile=landing/checker.js",
|
|
42
|
+
"verify": "npm run lint && npm run typecheck && npm test"
|
|
41
43
|
},
|
|
42
44
|
"engines": {
|
|
43
45
|
"node": ">=18"
|