rulereceipt 0.1.27 → 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 +43 -5
- package/dist/browser/analyze.js +3 -3
- 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 +183 -3
- package/dist/overrides.d.ts +62 -0
- package/dist/overrides.js +85 -0
- package/dist/parsers/claudeMdParser.js +72 -11
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -54,6 +54,13 @@ Published and live on npm, actively developed.
|
|
|
54
54
|
exactly what it excluded. A rule phrased unusually, or written in
|
|
55
55
|
another language, can land there — and a rule dropped silently is
|
|
56
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.
|
|
57
64
|
4. Prints a report — terminal table by default, `--markdown` for pasting
|
|
58
65
|
into a PR or Slack message, or `--html` for a shareable single file —
|
|
59
66
|
showing what passed, what failed, and a quoted line of evidence for
|
|
@@ -76,6 +83,10 @@ rulereceipt check --llm # opt-in: grade judgment rules with your own Clau
|
|
|
76
83
|
rulereceipt check --share # opt-in: send anonymous pass/fail/unclear counts
|
|
77
84
|
rulereceipt check --telemetry # opt-in: send one random per-machine ID
|
|
78
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
|
|
79
90
|
rulereceipt doctor # list hooks/auto-run tasks configured on this machine
|
|
80
91
|
rulereceipt lint # find contradictions between CLAUDE.md and AGENTS.md
|
|
81
92
|
rulereceipt digest # summarise recent checks; --email to send it
|
|
@@ -88,6 +99,30 @@ rulereceipt verify <session-file> <hash> # spot-check a report you received ag
|
|
|
88
99
|
|
|
89
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.
|
|
90
101
|
|
|
102
|
+
## Which rules actually have teeth
|
|
103
|
+
|
|
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.
|
|
107
|
+
|
|
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.
|
|
125
|
+
|
|
91
126
|
## Sharing a report
|
|
92
127
|
|
|
93
128
|
`rulereceipt check --html` writes one self-contained HTML file. No
|
|
@@ -159,11 +194,14 @@ real distinct-install counts are knowable, nothing else. None of them fire
|
|
|
159
194
|
unless you explicitly pass the flag, and `DO_NOT_TRACK=1` /
|
|
160
195
|
`RULERECEIPT_NO_TELEMETRY=1` forces telemetry off even if you do.
|
|
161
196
|
|
|
162
|
-
**
|
|
163
|
-
`.claude/settings.json` and installs no hooks
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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.
|
|
167
205
|
|
|
168
206
|
**You can verify the package came from this source.** Every release from
|
|
169
207
|
0.1.19 on is built and published by GitHub Actions and signed with
|
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/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"));
|
|
@@ -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");
|
|
@@ -249,16 +277,29 @@ async function runCheck(opts) {
|
|
|
249
277
|
console.log(`\nSkipped as documentation:\n`);
|
|
250
278
|
for (const { rule } of notARule) {
|
|
251
279
|
const label = rule.title.replace(/\s+/g, " ").trim();
|
|
252
|
-
|
|
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)}`);
|
|
253
290
|
}
|
|
254
291
|
console.log(`\nIf any of those is actually a rule, the classifier was wrong. It looks for` +
|
|
255
292
|
`\nEnglish instruction words, so a rule written another way — or in another` +
|
|
256
|
-
`\nlanguage — can land here. Worth a look; you know your rules, it doesn't.`
|
|
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>`);
|
|
257
295
|
}
|
|
258
296
|
else {
|
|
259
297
|
console.log(`Run with --show-skipped to see them.`);
|
|
260
298
|
}
|
|
261
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.)`);
|
|
302
|
+
}
|
|
262
303
|
appendHistory(results, sessionFilePath);
|
|
263
304
|
if (share) {
|
|
264
305
|
await shareResults(results);
|
|
@@ -336,6 +377,131 @@ program
|
|
|
336
377
|
process.exitCode = 1;
|
|
337
378
|
});
|
|
338
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
|
+
}
|
|
339
505
|
async function runLint(markdown, llm) {
|
|
340
506
|
const cwd = process.cwd();
|
|
341
507
|
const claudeMdPath = join(cwd, "CLAUDE.md");
|
|
@@ -416,6 +582,20 @@ program
|
|
|
416
582
|
process.exitCode = 1;
|
|
417
583
|
}
|
|
418
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
|
+
});
|
|
419
599
|
program
|
|
420
600
|
.command("lint")
|
|
421
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",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"test:watch": "vitest",
|
|
39
39
|
"lint": "eslint src tests",
|
|
40
40
|
"prepublishOnly": "npm run build && npm test",
|
|
41
|
-
"build:checker": "esbuild src/browser/analyze.ts --bundle --format=esm --minify --outfile=landing/checker.js"
|
|
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"
|
|
42
43
|
},
|
|
43
44
|
"engines": {
|
|
44
45
|
"node": ">=18"
|