rulereceipt 0.1.23 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +93,38 @@ on a session it never found.
93
93
  For most people the honest answer is simpler: run `rulereceipt check --html`
94
94
  locally and attach the report to the PR.
95
95
 
96
+ ## Correcting a misclassification
97
+
98
+ If the tool treats something in your rules file as a rule when it isn't,
99
+ create `.rulereceipt.json` in your project:
100
+
101
+ ```json
102
+ {
103
+ "overrides": [
104
+ { "rule": "12", "reason": "changelog entry, not a rule", "date": "2026-08-31" }
105
+ ]
106
+ }
107
+ ```
108
+
109
+ Commit it. An override should be visible in code review, not just in the
110
+ report.
111
+
112
+ **An override cannot hide a violation, by design.** The check still runs,
113
+ keeps its real result, and the override only adds a label. So the report
114
+ shows the rule, its true result, your reason, and a line saying what the
115
+ result would have been without the override. The headline count includes
116
+ overridden failures, and the exit code still fails on them — otherwise CI
117
+ would be the loophole.
118
+
119
+ The worst an override can do is draw a labelled box around a real
120
+ violation and sign it with your reason. That leaves a reader better
121
+ informed than a plain failure would, not worse.
122
+
123
+ A reason is required; an override without one is refused rather than
124
+ applied quietly. If a rule id exists in both your global and project
125
+ rules files, write `"project:12"` or `"global:12"` — a bare id that
126
+ matches both is refused rather than silently disabling both.
127
+
96
128
  ## Sharing a report
97
129
 
98
130
  `rulereceipt check --html` writes one self-contained HTML file. No
@@ -128,6 +160,7 @@ rulereceipt check --html # write a shareable single-file HTML report you c
128
160
  rulereceipt check --html report.html # ...to a specific path
129
161
  rulereceipt check --require-session # fail if there's no session, instead of passing silently
130
162
  rulereceipt check --exit-zero # report failures without failing the build
163
+ # .rulereceipt.json # mark a misclassified item as not-a-rule (see below)
131
164
  rulereceipt check --llm # opt-in: grade judgment rules with your own Claude key
132
165
  rulereceipt check --share # opt-in: send anonymous pass/fail/unclear counts
133
166
  rulereceipt check --telemetry # opt-in: send one random per-machine ID
package/dist/cli.js CHANGED
@@ -26,6 +26,7 @@ import { enableSchedule, disableSchedule, scheduleStatus } from "./schedule.js";
26
26
  import { findSplitBrainConflicts } from "./checks/splitBrain.js";
27
27
  import { runDoctor } from "./checks/doctor.js";
28
28
  import { sendTelemetryPing, isTelemetryEnabled } from "./telemetry.js";
29
+ import { loadOverrides, resolveOverrides } from "./overrides.js";
29
30
  const __dirname = dirname(fileURLToPath(import.meta.url));
30
31
  const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
31
32
  const SHARE_ENDPOINT = "https://rulereceipt.dev/api/share";
@@ -220,7 +221,22 @@ async function runCheck(opts) {
220
221
  // sends only a random install ID, never rule text or transcript content,
221
222
  // regardless of --llm.
222
223
  const judgmentResults = llm ? await runJudgmentChecks(judgment, events) : judgment.map(({ rule }) => needsLlmResult(rule));
223
- const results = [...deterministicResults, ...judgmentResults];
224
+ const computed = [...deterministicResults, ...judgmentResults];
225
+ // Applied AFTER every check has run, and it only attaches a label.
226
+ // Nothing is skipped and no status is changed: an override that could
227
+ // suppress a check would let anyone delete their own violations, which
228
+ // is precisely what this design refuses to allow. See src/overrides.ts.
229
+ // Resolved against the rules actually present, and scoped by source: a
230
+ // global and a project rules file can both define "Rule 1", and a bare
231
+ // id would otherwise silently disable both.
232
+ const { bySourceAndId: overrides, problems: overrideProblems } = resolveOverrides(loadOverrides(cwd), computed.map((r) => ({ ruleId: r.ruleId, ruleSource: r.ruleSource })));
233
+ const results = computed.map((r) => {
234
+ const o = overrides.get(`${r.ruleSource}:${r.ruleId}`);
235
+ return o ? { ...r, overriddenReason: o.reason, overriddenDate: o.date } : r;
236
+ });
237
+ for (const problem of overrideProblems) {
238
+ console.log(`\n(${problem})`);
239
+ }
224
240
  const meta = { sessionFilePath, ruleCount: results.length };
225
241
  const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta);
226
242
  console.log(reportText);
@@ -262,6 +278,9 @@ async function runCheck(opts) {
262
278
  // rules in a real CLAUDE.md need judgment, so without --llm they
263
279
  // legitimately report UNCLEAR. Gating on those would make every build
264
280
  // red on day one and the check would be deleted within a week.
281
+ // Deliberately reads .status, which an override never changes. If an
282
+ // overridden failure exited 0, CI would become the loophole this whole
283
+ // design exists to close: mark the rule, get a green build, done.
265
284
  if (!exitZero && results.some((r) => r.status === "FAIL")) {
266
285
  process.exitCode = 1;
267
286
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Per-project overrides: a way to say "this item in my rules file isn't
3
+ * actually a rule for my project" without waiting for an upstream release.
4
+ *
5
+ * THE SECURITY PROPERTY THAT MAKES THIS SAFE
6
+ *
7
+ * An override never changes whether a check runs, and never changes its
8
+ * result. The check executes exactly as it would have, keeps its real
9
+ * status, and the override only changes how the result is PRESENTED.
10
+ *
11
+ * That distinction is the whole design. The obvious version of this
12
+ * feature — "let the user mark a rule as not-a-rule, and skip it" — is
13
+ * not safe, and restricting the direction of the override does not make
14
+ * it safe:
15
+ *
16
+ * Rule: "Never commit directly to main"
17
+ * Agent: commits directly to main -> FAIL
18
+ * User: marks it "not a rule" -> not checked
19
+ * Report: the violation is gone
20
+ *
21
+ * Nobody had to claim a pass. They deleted the question instead. So this
22
+ * implementation refuses to delete questions. The worst an override can
23
+ * do is draw a labelled box around a real violation and sign it with a
24
+ * reason and a date — which leaves a reader BETTER informed than a plain
25
+ * failure would, not worse.
26
+ *
27
+ * Consequences enforced elsewhere, and deliberately not weakened:
28
+ * - the exit code still fails on an overridden violation (cli.ts), or
29
+ * CI becomes the loophole this whole design exists to close;
30
+ * - the report's headline verdict counts overridden failures, or the
31
+ * one line everyone reads would be the one line that lies.
32
+ *
33
+ * A reason is mandatory. An override without one is refused, not applied
34
+ * silently: it costs a sentence to write, and it is the part a reviewer
35
+ * actually reads.
36
+ *
37
+ * AMBIGUOUS IDS FAIL CLOSED. A global CLAUDE.md and a project one can
38
+ * legitimately both contain a "Rule 1" — the report already disambiguates
39
+ * those on collision. An override written as `"rule": "1"` when two rules
40
+ * share that id would silently disable BOTH, including one the user never
41
+ * meant to touch. Found while testing this feature against a real machine
42
+ * that has a global rules file. So a bare id is applied only when it is
43
+ * unambiguous; when it is not, the override is refused and the user is
44
+ * told to write `"project:1"` or `"global:1"` instead.
45
+ */
46
+ export declare const OVERRIDES_FILE = ".rulereceipt.json";
47
+ export interface RuleOverride {
48
+ /** Rule id as it appears in the report, e.g. "12" or "S7.0". */
49
+ rule: string;
50
+ /** Why this isn't a rule for this project. Required — never optional. */
51
+ reason: string;
52
+ /** Optional ISO date, shown in the report so a reader can judge staleness. */
53
+ date?: string;
54
+ }
55
+ export interface LoadedOverrides {
56
+ /** Keyed by the raw id as written by the user ("1" or "project:1"). */
57
+ byRuleId: Map<string, RuleOverride>;
58
+ /** Problems worth telling the user about — malformed entries, missing reasons. */
59
+ problems: string[];
60
+ }
61
+ /** A rule as the report identifies it, used to resolve an override target. */
62
+ export interface OverrideTarget {
63
+ ruleId: string;
64
+ ruleSource: "global" | "project";
65
+ }
66
+ /**
67
+ * Resolves override entries against the rules actually present, and
68
+ * refuses anything ambiguous rather than guessing which rule was meant.
69
+ * Returns a lookup keyed by `${source}:${id}`, plus any new problems.
70
+ */
71
+ export declare function resolveOverrides(loaded: LoadedOverrides, targets: OverrideTarget[]): {
72
+ bySourceAndId: Map<string, RuleOverride>;
73
+ problems: string[];
74
+ };
75
+ /**
76
+ * Reads and validates the override file. Never throws: a broken override
77
+ * file must not take down a check that would otherwise have worked, and
78
+ * an unreadable file means "no overrides", never "override everything".
79
+ * Every rejection is reported rather than swallowed, so a user whose
80
+ * override isn't working finds out why.
81
+ */
82
+ export declare function loadOverrides(cwd: string): LoadedOverrides;
@@ -0,0 +1,134 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Per-project overrides: a way to say "this item in my rules file isn't
5
+ * actually a rule for my project" without waiting for an upstream release.
6
+ *
7
+ * THE SECURITY PROPERTY THAT MAKES THIS SAFE
8
+ *
9
+ * An override never changes whether a check runs, and never changes its
10
+ * result. The check executes exactly as it would have, keeps its real
11
+ * status, and the override only changes how the result is PRESENTED.
12
+ *
13
+ * That distinction is the whole design. The obvious version of this
14
+ * feature — "let the user mark a rule as not-a-rule, and skip it" — is
15
+ * not safe, and restricting the direction of the override does not make
16
+ * it safe:
17
+ *
18
+ * Rule: "Never commit directly to main"
19
+ * Agent: commits directly to main -> FAIL
20
+ * User: marks it "not a rule" -> not checked
21
+ * Report: the violation is gone
22
+ *
23
+ * Nobody had to claim a pass. They deleted the question instead. So this
24
+ * implementation refuses to delete questions. The worst an override can
25
+ * do is draw a labelled box around a real violation and sign it with a
26
+ * reason and a date — which leaves a reader BETTER informed than a plain
27
+ * failure would, not worse.
28
+ *
29
+ * Consequences enforced elsewhere, and deliberately not weakened:
30
+ * - the exit code still fails on an overridden violation (cli.ts), or
31
+ * CI becomes the loophole this whole design exists to close;
32
+ * - the report's headline verdict counts overridden failures, or the
33
+ * one line everyone reads would be the one line that lies.
34
+ *
35
+ * A reason is mandatory. An override without one is refused, not applied
36
+ * silently: it costs a sentence to write, and it is the part a reviewer
37
+ * actually reads.
38
+ *
39
+ * AMBIGUOUS IDS FAIL CLOSED. A global CLAUDE.md and a project one can
40
+ * legitimately both contain a "Rule 1" — the report already disambiguates
41
+ * those on collision. An override written as `"rule": "1"` when two rules
42
+ * share that id would silently disable BOTH, including one the user never
43
+ * meant to touch. Found while testing this feature against a real machine
44
+ * that has a global rules file. So a bare id is applied only when it is
45
+ * unambiguous; when it is not, the override is refused and the user is
46
+ * told to write `"project:1"` or `"global:1"` instead.
47
+ */
48
+ export const OVERRIDES_FILE = ".rulereceipt.json";
49
+ /**
50
+ * Resolves override entries against the rules actually present, and
51
+ * refuses anything ambiguous rather than guessing which rule was meant.
52
+ * Returns a lookup keyed by `${source}:${id}`, plus any new problems.
53
+ */
54
+ export function resolveOverrides(loaded, targets) {
55
+ const bySourceAndId = new Map();
56
+ const problems = [...loaded.problems];
57
+ for (const [written, entry] of loaded.byRuleId) {
58
+ const scoped = /^(global|project):(.+)$/i.exec(written);
59
+ if (scoped) {
60
+ const source = scoped[1].toLowerCase();
61
+ const id = scoped[2].trim();
62
+ const hit = targets.find((t) => t.ruleId === id && t.ruleSource === source);
63
+ if (!hit) {
64
+ problems.push(`${OVERRIDES_FILE}: no ${source} rule with id "${id}" was found, so that override did nothing.`);
65
+ continue;
66
+ }
67
+ bySourceAndId.set(`${source}:${id}`, entry);
68
+ continue;
69
+ }
70
+ const matches = targets.filter((t) => t.ruleId === written);
71
+ if (matches.length === 0) {
72
+ problems.push(`${OVERRIDES_FILE}: no rule with id "${written}" was found, so that override did nothing.`);
73
+ continue;
74
+ }
75
+ const sources = new Set(matches.map((m) => m.ruleSource));
76
+ if (sources.size > 1) {
77
+ problems.push(`${OVERRIDES_FILE}: rule id "${written}" exists in BOTH your global and project rules, so the override was NOT applied — it would have silently disabled both. Write "project:${written}" or "global:${written}" instead.`);
78
+ continue;
79
+ }
80
+ bySourceAndId.set(`${[...sources][0]}:${written}`, entry);
81
+ }
82
+ return { bySourceAndId, problems };
83
+ }
84
+ function isNonEmptyString(v) {
85
+ return typeof v === "string" && v.trim().length > 0;
86
+ }
87
+ /**
88
+ * Reads and validates the override file. Never throws: a broken override
89
+ * file must not take down a check that would otherwise have worked, and
90
+ * an unreadable file means "no overrides", never "override everything".
91
+ * Every rejection is reported rather than swallowed, so a user whose
92
+ * override isn't working finds out why.
93
+ */
94
+ export function loadOverrides(cwd) {
95
+ const byRuleId = new Map();
96
+ const problems = [];
97
+ const path = join(cwd, OVERRIDES_FILE);
98
+ if (!existsSync(path))
99
+ return { byRuleId, problems };
100
+ let parsed;
101
+ try {
102
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
103
+ }
104
+ catch (err) {
105
+ problems.push(`${OVERRIDES_FILE} isn't valid JSON, so no overrides were applied: ${err instanceof Error ? err.message : String(err)}`);
106
+ return { byRuleId, problems };
107
+ }
108
+ const raw = parsed?.overrides;
109
+ if (raw === undefined)
110
+ return { byRuleId, problems };
111
+ if (!Array.isArray(raw)) {
112
+ problems.push(`${OVERRIDES_FILE}: "overrides" must be an array, so no overrides were applied.`);
113
+ return { byRuleId, problems };
114
+ }
115
+ for (const [i, entry] of raw.entries()) {
116
+ const e = entry;
117
+ if (!isNonEmptyString(e?.rule)) {
118
+ problems.push(`${OVERRIDES_FILE} entry ${i + 1}: missing a "rule" id, so it was ignored.`);
119
+ continue;
120
+ }
121
+ // Fails closed, on purpose. An override with no stated reason is the
122
+ // exact shape of one added to make a number go away.
123
+ if (!isNonEmptyString(e?.reason)) {
124
+ problems.push(`${OVERRIDES_FILE} entry for rule ${e.rule}: no "reason" given, so it was NOT applied. Every override needs a reason a reviewer can read.`);
125
+ continue;
126
+ }
127
+ byRuleId.set(e.rule.trim(), {
128
+ rule: e.rule.trim(),
129
+ reason: e.reason.trim(),
130
+ date: isNonEmptyString(e.date) ? e.date.trim() : undefined,
131
+ });
132
+ }
133
+ return { byRuleId, problems };
134
+ }
@@ -103,6 +103,13 @@ function renderResultRow(result, all) {
103
103
  </div>
104
104
  <h3 class="result__title">${clean(result.ruleTitle)}</h3>
105
105
  ${result.evidence ? `<p class="result__evidence">${clean(result.evidence)}</p>` : ""}
106
+ ${result.overriddenReason
107
+ ? `<div class="override">
108
+ <strong>Marked by the developer as &ldquo;not a rule for this project&rdquo;${result.overriddenDate ? ` on ${clean(result.overriddenDate)}` : ""}.</strong>
109
+ Reason given: ${clean(result.overriddenReason)}
110
+ <span class="override__truth">Without this override, the result is: <strong>${clean(BUCKET_LABEL[bucketOf({ ...result, overriddenReason: undefined })])}</strong></span>
111
+ </div>`
112
+ : ""}
106
113
  </article>`;
107
114
  }
108
115
  function renderSection(bucket, results, all) {
@@ -202,6 +209,9 @@ export function generateHtmlReport(results, meta) {
202
209
  .result--judgment { border-left-color: #6b6f76; }
203
210
  .badge--judgment { background: #f2f3f5; color: #4a4e55; }
204
211
  .section__note { font-size: 13px; color: var(--muted); margin: -4px 0 12px; }
212
+ .override { margin-top: 10px; padding: 10px 12px; border-radius: 6px; background: var(--unclear-bg); border: 1px solid var(--unclear-line); font-size: 13.5px; color: var(--ink-soft); }
213
+ .override strong { color: var(--ink); }
214
+ .override__truth { display: block; margin-top: 6px; }
205
215
  .result__id { font-size: 12px; color: var(--muted); }
206
216
  .result__title { font-size: 15px; margin: 0 0 6px; font-weight: 600; }
207
217
  .result__evidence { margin: 0; font-size: 14px; color: var(--muted); white-space: pre-wrap; }
@@ -232,7 +242,7 @@ export function generateHtmlReport(results, meta) {
232
242
 
233
243
  <div class="verdict verdict--${v.cls}">
234
244
  <strong>${clean(v.text)}</strong>
235
- <span>${countBy(results, "PASS")} followed · ${countBy(results, "FAIL")} not followed · ${results.filter((r) => bucketOf(r) === "UNCLEAR_EVIDENCE").length} couldn&#39;t tell · ${results.filter((r) => bucketOf(r) === "UNCLEAR_JUDGMENT").length} need your judgment</span>
245
+ <span>${countBy(results, "PASS")} followed · ${countBy(results, "FAIL")} not followed · ${results.filter((r) => bucketOf(r) === "UNCLEAR_EVIDENCE").length} couldn&#39;t tell · ${results.filter((r) => bucketOf(r) === "UNCLEAR_JUDGMENT").length} need your judgment${results.filter((r) => r.overriddenReason).length > 0 ? ` · ${results.filter((r) => r.overriddenReason).length} developer-overridden` : ""}</span>
236
246
  </div>
237
247
 
238
248
  <table class="facts">
@@ -64,11 +64,21 @@ function summaryLine(results) {
64
64
  const fail = results.filter((r) => r.status === "FAIL").length;
65
65
  const needsHuman = results.filter((r) => r.status === "UNCLEAR" && r.needsHuman).length;
66
66
  const couldntTell = results.filter((r) => r.status === "UNCLEAR" && !r.needsHuman).length;
67
+ const overriddenFails = results.filter((r) => r.status === "FAIL" && r.overriddenReason).length;
68
+ const overridden = results.filter((r) => r.overriddenReason).length;
67
69
  const parts = [`${pass} followed`, `${fail} not followed`];
68
70
  if (couldntTell > 0)
69
71
  parts.push(`${couldntTell} couldn't tell`);
70
72
  if (needsHuman > 0)
71
73
  parts.push(`${needsHuman} need your judgment`);
74
+ // Named in the one line everyone reads. A headline that quietly folded
75
+ // overridden failures into a clean total would be the single most
76
+ // misleading thing this tool could print.
77
+ if (overridden > 0) {
78
+ parts.push(overriddenFails > 0
79
+ ? `${overridden} user-overridden (${overriddenFails} still not followed)`
80
+ : `${overridden} user-overridden`);
81
+ }
72
82
  return parts.join(" · ");
73
83
  }
74
84
  export function generateReport(results, meta) {
@@ -80,6 +90,15 @@ export function generateReport(results, meta) {
80
90
  lines.push(`${MARK[r.status]} ${r.status.padEnd(7)} ${ruleLabel(r, clean)}`);
81
91
  if (r.evidence)
82
92
  lines.push(` evidence: ${r.evidence}`);
93
+ // The true status is printed above, unchanged. This line adds the
94
+ // override on top of it rather than replacing it — a reader must be
95
+ // able to see what the result would have been without the override.
96
+ if (r.overriddenReason) {
97
+ lines.push(` USER-OVERRIDDEN as "not a rule for this project"${r.overriddenDate ? ` on ${r.overriddenDate}` : ""} — reason: ${r.overriddenReason}`);
98
+ if (r.status === "FAIL") {
99
+ lines.push(" NOTE: this rule was NOT FOLLOWED. The override does not change that.");
100
+ }
101
+ }
83
102
  }
84
103
  lines.push("─".repeat(40));
85
104
  lines.push(summaryLine(clean));
package/dist/types.d.ts CHANGED
@@ -32,6 +32,15 @@ export interface CheckResult {
32
32
  ruleSource: "global" | "project";
33
33
  status: CheckStatus;
34
34
  evidence: string;
35
+ /**
36
+ * Set when the user marked this rule as "not a rule for my project" in
37
+ * .rulereceipt.json. The status above is STILL the real, computed
38
+ * result — an override changes presentation only, never the answer.
39
+ * See src/overrides.ts for why that distinction is the whole design.
40
+ */
41
+ overriddenReason?: string;
42
+ /** Optional date from the override entry, so a reader can judge staleness. */
43
+ overriddenDate?: string;
35
44
  /**
36
45
  * True when this rule was never mechanically answerable — a judgment
37
46
  * call like "surface bad news first", which has no command to inspect.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
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",