rulereceipt 0.1.24 → 0.1.26
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 +0 -33
- package/dist/browser/analyze.d.ts +45 -0
- package/dist/browser/analyze.js +49 -0
- package/dist/cli.js +2 -21
- package/dist/parsers/claudeMdParser.d.ts +10 -1
- package/dist/parsers/claudeMdParser.js +10 -9
- package/dist/parsers/readClaudeMd.d.ts +19 -0
- package/dist/parsers/readClaudeMd.js +29 -0
- package/dist/report/generateHtmlReport.js +1 -11
- package/dist/report/generateReport.js +0 -19
- package/dist/rules.d.ts +10 -0
- package/dist/rules.js +75 -9
- package/dist/types.d.ts +0 -9
- package/package.json +1 -1
- package/dist/overrides.d.ts +0 -82
- package/dist/overrides.js +0 -134
package/README.md
CHANGED
|
@@ -93,38 +93,6 @@ 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
|
-
|
|
128
96
|
## Sharing a report
|
|
129
97
|
|
|
130
98
|
`rulereceipt check --html` writes one self-contained HTML file. No
|
|
@@ -160,7 +128,6 @@ rulereceipt check --html # write a shareable single-file HTML report you c
|
|
|
160
128
|
rulereceipt check --html report.html # ...to a specific path
|
|
161
129
|
rulereceipt check --require-session # fail if there's no session, instead of passing silently
|
|
162
130
|
rulereceipt check --exit-zero # report failures without failing the build
|
|
163
|
-
# .rulereceipt.json # mark a misclassified item as not-a-rule (see below)
|
|
164
131
|
rulereceipt check --llm # opt-in: grade judgment rules with your own Claude key
|
|
165
132
|
rulereceipt check --share # opt-in: send anonymous pass/fail/unclear counts
|
|
166
133
|
rulereceipt check --telemetry # opt-in: send one random per-machine ID
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry point for the in-browser checker on the site.
|
|
3
|
+
*
|
|
4
|
+
* The privacy claim on that page — "your file never leaves this page" — is
|
|
5
|
+
* only true because everything reachable from here is pure string work.
|
|
6
|
+
* parseClaudeMdText and classifyRules touch no Node API, no network, and
|
|
7
|
+
* no storage. If anything in this dependency chain ever gains an import
|
|
8
|
+
* that does, the page's claim becomes false and must change with it.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately returns counts and a small sample, never the file: nothing
|
|
11
|
+
* here should make it easy to accidentally send the text somewhere.
|
|
12
|
+
*/
|
|
13
|
+
export interface Breakdown {
|
|
14
|
+
kind: string;
|
|
15
|
+
count: number;
|
|
16
|
+
}
|
|
17
|
+
export interface AnalysisResult {
|
|
18
|
+
/** Everything the parser pulled out of the file, rules and non-rules alike. */
|
|
19
|
+
parsed: number;
|
|
20
|
+
/** Items carrying no instruction — documentation, tables, listings. */
|
|
21
|
+
notRules: number;
|
|
22
|
+
/** Items that are genuinely directives. */
|
|
23
|
+
realRules: number;
|
|
24
|
+
/** Real rules a check can answer by looking at what ran. */
|
|
25
|
+
checkable: number;
|
|
26
|
+
/** Real rules that need a person. */
|
|
27
|
+
judgment: number;
|
|
28
|
+
/** Share of the whole file that isn't an instruction, 0-100. */
|
|
29
|
+
notRulesPct: number;
|
|
30
|
+
/** Share of REAL rules that are mechanically answerable, 0-100. */
|
|
31
|
+
checkablePct: number;
|
|
32
|
+
byKind: Breakdown[];
|
|
33
|
+
/** A few non-rule titles, so the number is inspectable rather than asserted. */
|
|
34
|
+
notRuleSamples: string[];
|
|
35
|
+
/** A few judgment-call titles, same reason. */
|
|
36
|
+
judgmentSamples: string[];
|
|
37
|
+
}
|
|
38
|
+
/** Measured across 559 real public rules files. Shown for comparison. */
|
|
39
|
+
export declare const CORPUS: {
|
|
40
|
+
files: number;
|
|
41
|
+
parsed: number;
|
|
42
|
+
notRulesPct: number;
|
|
43
|
+
checkablePct: number;
|
|
44
|
+
};
|
|
45
|
+
export declare function analyze(text: string): AnalysisResult;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { parseClaudeMdText } from "../parsers/claudeMdParser.js";
|
|
2
|
+
import { classifyRules } from "../checks/classify.js";
|
|
3
|
+
/** Measured across 559 real public rules files. Shown for comparison. */
|
|
4
|
+
export const CORPUS = {
|
|
5
|
+
files: 559,
|
|
6
|
+
parsed: 23704,
|
|
7
|
+
notRulesPct: 62.9,
|
|
8
|
+
checkablePct: 43.5,
|
|
9
|
+
};
|
|
10
|
+
function pct(part, whole) {
|
|
11
|
+
return whole === 0 ? 0 : Math.round((part / whole) * 1000) / 10;
|
|
12
|
+
}
|
|
13
|
+
export function analyze(text) {
|
|
14
|
+
const rules = parseClaudeMdText(text, "project");
|
|
15
|
+
const classified = classifyRules(rules);
|
|
16
|
+
const counts = new Map();
|
|
17
|
+
const notRuleSamples = [];
|
|
18
|
+
const judgmentSamples = [];
|
|
19
|
+
for (const c of classified) {
|
|
20
|
+
counts.set(c.kind, (counts.get(c.kind) ?? 0) + 1);
|
|
21
|
+
const title = c.rule.title.trim();
|
|
22
|
+
if (!title)
|
|
23
|
+
continue;
|
|
24
|
+
if (c.kind === "notARule" && notRuleSamples.length < 4)
|
|
25
|
+
notRuleSamples.push(title);
|
|
26
|
+
if (c.kind === "judgment" && judgmentSamples.length < 4)
|
|
27
|
+
judgmentSamples.push(title);
|
|
28
|
+
}
|
|
29
|
+
const parsed = classified.length;
|
|
30
|
+
const notRules = counts.get("notARule") ?? 0;
|
|
31
|
+
const judgment = counts.get("judgment") ?? 0;
|
|
32
|
+
const realRules = parsed - notRules;
|
|
33
|
+
const checkable = realRules - judgment;
|
|
34
|
+
const byKind = [...counts.entries()]
|
|
35
|
+
.map(([kind, count]) => ({ kind, count }))
|
|
36
|
+
.sort((a, b) => b.count - a.count);
|
|
37
|
+
return {
|
|
38
|
+
parsed,
|
|
39
|
+
notRules,
|
|
40
|
+
realRules,
|
|
41
|
+
checkable,
|
|
42
|
+
judgment,
|
|
43
|
+
notRulesPct: pct(notRules, parsed),
|
|
44
|
+
checkablePct: pct(checkable, realRules),
|
|
45
|
+
byKind,
|
|
46
|
+
notRuleSamples,
|
|
47
|
+
judgmentSamples,
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Command } from "commander";
|
|
|
5
5
|
import { join, dirname, resolve, isAbsolute } from "node:path";
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { parseClaudeMd } from "./parsers/
|
|
8
|
+
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";
|
|
@@ -26,7 +26,6 @@ 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";
|
|
30
29
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
31
30
|
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
32
31
|
const SHARE_ENDPOINT = "https://rulereceipt.dev/api/share";
|
|
@@ -221,22 +220,7 @@ async function runCheck(opts) {
|
|
|
221
220
|
// sends only a random install ID, never rule text or transcript content,
|
|
222
221
|
// regardless of --llm.
|
|
223
222
|
const judgmentResults = llm ? await runJudgmentChecks(judgment, events) : judgment.map(({ rule }) => needsLlmResult(rule));
|
|
224
|
-
const
|
|
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
|
-
}
|
|
223
|
+
const results = [...deterministicResults, ...judgmentResults];
|
|
240
224
|
const meta = { sessionFilePath, ruleCount: results.length };
|
|
241
225
|
const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta);
|
|
242
226
|
console.log(reportText);
|
|
@@ -278,9 +262,6 @@ async function runCheck(opts) {
|
|
|
278
262
|
// rules in a real CLAUDE.md need judgment, so without --llm they
|
|
279
263
|
// legitimately report UNCLEAR. Gating on those would make every build
|
|
280
264
|
// 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.
|
|
284
265
|
if (!exitZero && results.some((r) => r.status === "FAIL")) {
|
|
285
266
|
process.exitCode = 1;
|
|
286
267
|
}
|
|
@@ -1,2 +1,11 @@
|
|
|
1
1
|
import type { Rule } from "../types.js";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The actual parser, taking text rather than a path.
|
|
4
|
+
*
|
|
5
|
+
* Split out from parseClaudeMd so the parsing logic carries no filesystem
|
|
6
|
+
* dependency and can run anywhere a string can — including a browser, for
|
|
7
|
+
* the client-side checker on the site. That checker's privacy claim
|
|
8
|
+
* ("your file never leaves the page") is only true because nothing in
|
|
9
|
+
* this function or in classify.ts touches Node APIs; keep it that way.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseClaudeMdText(raw: string, source: "global" | "project"): Rule[];
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
1
|
/**
|
|
3
2
|
* Real CLAUDE.md/AGENTS.md files use several different conventions for
|
|
4
3
|
* organizing rules. Verified against real files and templates: Anthropic's
|
|
@@ -67,14 +66,16 @@ function normalizeSetextHeaders(lines) {
|
|
|
67
66
|
}
|
|
68
67
|
return out;
|
|
69
68
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The actual parser, taking text rather than a path.
|
|
71
|
+
*
|
|
72
|
+
* Split out from parseClaudeMd so the parsing logic carries no filesystem
|
|
73
|
+
* dependency and can run anywhere a string can — including a browser, for
|
|
74
|
+
* the client-side checker on the site. That checker's privacy claim
|
|
75
|
+
* ("your file never leaves the page") is only true because nothing in
|
|
76
|
+
* this function or in classify.ts touches Node APIs; keep it that way.
|
|
77
|
+
*/
|
|
78
|
+
export function parseClaudeMdText(raw, source) {
|
|
78
79
|
const lines = normalizeSetextHeaders(raw.split("\n"));
|
|
79
80
|
const rules = [];
|
|
80
81
|
// `current` accumulates a numbered-header rule, a bold-rule-header rule,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Rule } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The filesystem half of rules-file parsing, deliberately kept in its own
|
|
4
|
+
* module.
|
|
5
|
+
*
|
|
6
|
+
* claudeMdParser.ts must stay free of Node imports so it can be bundled
|
|
7
|
+
* for the browser — the in-page checker on the site claims the pasted file
|
|
8
|
+
* never leaves the page, and that is only true while nothing reachable
|
|
9
|
+
* from the parser can perform I/O. Keeping the one readFileSync here
|
|
10
|
+
* means a bundler cannot pull `node:fs` in behind it, and a future import
|
|
11
|
+
* that breaks the guarantee has to be added here, visibly, rather than
|
|
12
|
+
* appearing by accident in the parser.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Reads a rules file from disk. Thin wrapper: an unreadable file is an
|
|
16
|
+
* empty rule list, never a throw, because a missing global CLAUDE.md is a
|
|
17
|
+
* normal state rather than an error.
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseClaudeMd(filePath: string, source: "global" | "project"): Rule[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parseClaudeMdText } from "./claudeMdParser.js";
|
|
3
|
+
/**
|
|
4
|
+
* The filesystem half of rules-file parsing, deliberately kept in its own
|
|
5
|
+
* module.
|
|
6
|
+
*
|
|
7
|
+
* claudeMdParser.ts must stay free of Node imports so it can be bundled
|
|
8
|
+
* for the browser — the in-page checker on the site claims the pasted file
|
|
9
|
+
* never leaves the page, and that is only true while nothing reachable
|
|
10
|
+
* from the parser can perform I/O. Keeping the one readFileSync here
|
|
11
|
+
* means a bundler cannot pull `node:fs` in behind it, and a future import
|
|
12
|
+
* that breaks the guarantee has to be added here, visibly, rather than
|
|
13
|
+
* appearing by accident in the parser.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Reads a rules file from disk. Thin wrapper: an unreadable file is an
|
|
17
|
+
* empty rule list, never a throw, because a missing global CLAUDE.md is a
|
|
18
|
+
* normal state rather than an error.
|
|
19
|
+
*/
|
|
20
|
+
export function parseClaudeMd(filePath, source) {
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = readFileSync(filePath, "utf-8");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return parseClaudeMdText(raw, source);
|
|
29
|
+
}
|
|
@@ -103,13 +103,6 @@ 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 “not a rule for this project”${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
|
-
: ""}
|
|
113
106
|
</article>`;
|
|
114
107
|
}
|
|
115
108
|
function renderSection(bucket, results, all) {
|
|
@@ -209,9 +202,6 @@ export function generateHtmlReport(results, meta) {
|
|
|
209
202
|
.result--judgment { border-left-color: #6b6f76; }
|
|
210
203
|
.badge--judgment { background: #f2f3f5; color: #4a4e55; }
|
|
211
204
|
.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; }
|
|
215
205
|
.result__id { font-size: 12px; color: var(--muted); }
|
|
216
206
|
.result__title { font-size: 15px; margin: 0 0 6px; font-weight: 600; }
|
|
217
207
|
.result__evidence { margin: 0; font-size: 14px; color: var(--muted); white-space: pre-wrap; }
|
|
@@ -242,7 +232,7 @@ export function generateHtmlReport(results, meta) {
|
|
|
242
232
|
|
|
243
233
|
<div class="verdict verdict--${v.cls}">
|
|
244
234
|
<strong>${clean(v.text)}</strong>
|
|
245
|
-
<span>${countBy(results, "PASS")} followed · ${countBy(results, "FAIL")} not followed · ${results.filter((r) => bucketOf(r) === "UNCLEAR_EVIDENCE").length} couldn't tell · ${results.filter((r) => bucketOf(r) === "UNCLEAR_JUDGMENT").length} need your judgment
|
|
235
|
+
<span>${countBy(results, "PASS")} followed · ${countBy(results, "FAIL")} not followed · ${results.filter((r) => bucketOf(r) === "UNCLEAR_EVIDENCE").length} couldn't tell · ${results.filter((r) => bucketOf(r) === "UNCLEAR_JUDGMENT").length} need your judgment</span>
|
|
246
236
|
</div>
|
|
247
237
|
|
|
248
238
|
<table class="facts">
|
|
@@ -64,21 +64,11 @@ 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;
|
|
69
67
|
const parts = [`${pass} followed`, `${fail} not followed`];
|
|
70
68
|
if (couldntTell > 0)
|
|
71
69
|
parts.push(`${couldntTell} couldn't tell`);
|
|
72
70
|
if (needsHuman > 0)
|
|
73
71
|
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
|
-
}
|
|
82
72
|
return parts.join(" · ");
|
|
83
73
|
}
|
|
84
74
|
export function generateReport(results, meta) {
|
|
@@ -90,15 +80,6 @@ export function generateReport(results, meta) {
|
|
|
90
80
|
lines.push(`${MARK[r.status]} ${r.status.padEnd(7)} ${ruleLabel(r, clean)}`);
|
|
91
81
|
if (r.evidence)
|
|
92
82
|
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
|
-
}
|
|
102
83
|
}
|
|
103
84
|
lines.push("─".repeat(40));
|
|
104
85
|
lines.push(summaryLine(clean));
|
package/dist/rules.d.ts
CHANGED
|
@@ -5,5 +5,15 @@ import type { Rule } from "./types.js";
|
|
|
5
5
|
* global CLAUDE.md under its own home dir (e.g. ~/.claude-office/CLAUDE.md).
|
|
6
6
|
* Real gap found 2026-08-30, same root cause as the transcript-lookup fix
|
|
7
7
|
* in transcriptParser.ts: hardcoding one home-dir name misses any variant.
|
|
8
|
+
*
|
|
9
|
+
* Also reads ~/.claude/rules/*.md, the documented location for personal
|
|
10
|
+
* rules that apply across every project.
|
|
11
|
+
*
|
|
12
|
+
* NOT covered, and stated rather than left silent: machine-wide managed
|
|
13
|
+
* enterprise policy files (/Library/Application Support/ClaudeCode,
|
|
14
|
+
* /etc/claude-code, C:\Program Files\ClaudeCode). Those are deployed by
|
|
15
|
+
* IT, exist on no development machine this can be tested against, and
|
|
16
|
+
* guessing at their location would be the kind of unverified assumption
|
|
17
|
+
* this project has already been bitten by twice.
|
|
8
18
|
*/
|
|
9
19
|
export declare function loadRules(cwd: string): Rule[];
|
package/dist/rules.js
CHANGED
|
@@ -1,9 +1,62 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { dirname, join, parse } from "node:path";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
import { parseClaudeMd } from "./parsers/
|
|
3
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { parseClaudeMd } from "./parsers/readClaudeMd.js";
|
|
5
5
|
import { findClaudeHomeDirNames } from "./parsers/transcriptParser.js";
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Every place Claude Code actually reads a rule from, at one directory
|
|
8
|
+
* level. Order mirrors the documented load order, broadest first.
|
|
9
|
+
*
|
|
10
|
+
* Real gap found 2026-09-02: only the two bare filenames were read. A
|
|
11
|
+
* project with four rules files reported "1 rules checked · all passed" —
|
|
12
|
+
* three files invisible, with nothing saying so. A clean report on rules
|
|
13
|
+
* the tool never opened is the most misleading result this can produce,
|
|
14
|
+
* worse than no report, because it looks like evidence.
|
|
15
|
+
*/
|
|
16
|
+
const RULE_FILE_NAMES = ["CLAUDE.md", "AGENTS.md", "CLAUDE.local.md", "AGENTS.local.md"];
|
|
17
|
+
const RULE_SUBDIR_FILES = [join(".claude", "CLAUDE.md"), join(".claude", "AGENTS.md")];
|
|
18
|
+
const RULE_DIRS = [join(".claude", "rules")];
|
|
19
|
+
/**
|
|
20
|
+
* Lists the markdown files in a rules directory, if it exists.
|
|
21
|
+
*
|
|
22
|
+
* Sorted so the same project always produces the same rule order — rule
|
|
23
|
+
* ids are positional, and an unstable order would renumber rules between
|
|
24
|
+
* runs on different machines, making two reports of the same session
|
|
25
|
+
* impossible to compare. Non-markdown files are skipped: a rules
|
|
26
|
+
* directory legitimately holds README fragments and notes.
|
|
27
|
+
*/
|
|
28
|
+
function markdownFilesIn(dir) {
|
|
29
|
+
if (!existsSync(dir))
|
|
30
|
+
return [];
|
|
31
|
+
try {
|
|
32
|
+
if (!statSync(dir).isDirectory())
|
|
33
|
+
return [];
|
|
34
|
+
return readdirSync(dir)
|
|
35
|
+
.filter((f) => f.toLowerCase().endsWith(".md"))
|
|
36
|
+
.sort()
|
|
37
|
+
.map((f) => join(dir, f));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Every rules file at one directory level, in documented load order. */
|
|
44
|
+
function ruleFilesAtLevel(dir) {
|
|
45
|
+
const found = [];
|
|
46
|
+
for (const rel of RULE_SUBDIR_FILES) {
|
|
47
|
+
const p = join(dir, rel);
|
|
48
|
+
if (existsSync(p))
|
|
49
|
+
found.push(p);
|
|
50
|
+
}
|
|
51
|
+
for (const rel of RULE_DIRS)
|
|
52
|
+
found.push(...markdownFilesIn(join(dir, rel)));
|
|
53
|
+
for (const name of RULE_FILE_NAMES) {
|
|
54
|
+
const p = join(dir, name);
|
|
55
|
+
if (existsSync(p))
|
|
56
|
+
found.push(p);
|
|
57
|
+
}
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
7
60
|
/**
|
|
8
61
|
* Walks from the working directory up toward the repository root,
|
|
9
62
|
* collecting rules files at every level.
|
|
@@ -25,11 +78,7 @@ function findProjectRuleFiles(cwd) {
|
|
|
25
78
|
const home = homedir();
|
|
26
79
|
let dir = cwd;
|
|
27
80
|
for (;;) {
|
|
28
|
-
|
|
29
|
-
const p = join(dir, name);
|
|
30
|
-
if (existsSync(p))
|
|
31
|
-
found.push(p);
|
|
32
|
-
}
|
|
81
|
+
found.push(...ruleFilesAtLevel(dir));
|
|
33
82
|
// stop AT the repo root (inclusive) — its rules do apply
|
|
34
83
|
if (existsSync(join(dir, ".git")))
|
|
35
84
|
break;
|
|
@@ -48,9 +97,26 @@ function findProjectRuleFiles(cwd) {
|
|
|
48
97
|
* global CLAUDE.md under its own home dir (e.g. ~/.claude-office/CLAUDE.md).
|
|
49
98
|
* Real gap found 2026-08-30, same root cause as the transcript-lookup fix
|
|
50
99
|
* in transcriptParser.ts: hardcoding one home-dir name misses any variant.
|
|
100
|
+
*
|
|
101
|
+
* Also reads ~/.claude/rules/*.md, the documented location for personal
|
|
102
|
+
* rules that apply across every project.
|
|
103
|
+
*
|
|
104
|
+
* NOT covered, and stated rather than left silent: machine-wide managed
|
|
105
|
+
* enterprise policy files (/Library/Application Support/ClaudeCode,
|
|
106
|
+
* /etc/claude-code, C:\Program Files\ClaudeCode). Those are deployed by
|
|
107
|
+
* IT, exist on no development machine this can be tested against, and
|
|
108
|
+
* guessing at their location would be the kind of unverified assumption
|
|
109
|
+
* this project has already been bitten by twice.
|
|
51
110
|
*/
|
|
52
111
|
export function loadRules(cwd) {
|
|
53
|
-
const rules =
|
|
112
|
+
const rules = [];
|
|
113
|
+
for (const dirName of findClaudeHomeDirNames()) {
|
|
114
|
+
const base = join(homedir(), dirName);
|
|
115
|
+
rules.push(...parseClaudeMd(join(base, "CLAUDE.md"), "global"));
|
|
116
|
+
for (const file of markdownFilesIn(join(base, "rules"))) {
|
|
117
|
+
rules.push(...parseClaudeMd(file, "global"));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
54
120
|
for (const path of findProjectRuleFiles(cwd)) {
|
|
55
121
|
rules.push(...parseClaudeMd(path, "project"));
|
|
56
122
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -32,15 +32,6 @@ 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;
|
|
44
35
|
/**
|
|
45
36
|
* True when this rule was never mechanically answerable — a judgment
|
|
46
37
|
* call like "surface bad news first", which has no command to inspect.
|
package/package.json
CHANGED
package/dist/overrides.d.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
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;
|
package/dist/overrides.js
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
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
|
-
}
|