rulereceipt 0.1.65 → 0.1.67
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 +6 -0
- package/dist/audit.d.ts +2 -1
- package/dist/audit.js +84 -6
- package/dist/browser/analyze.d.ts +1 -0
- package/dist/browser/analyze.js +5 -0
- package/dist/card.js +13 -4
- package/dist/checkability.d.ts +2 -0
- package/dist/checkability.js +11 -1
- package/dist/checks/approvalGate.js +5 -2
- package/dist/checks/doctor.d.ts +14 -0
- package/dist/checks/doctor.js +18 -1
- package/dist/checks/shellCommand.d.ts +12 -0
- package/dist/checks/shellCommand.js +16 -0
- package/dist/cli.js +25 -0
- package/dist/evaluate.d.ts +1 -0
- package/dist/evaluate.js +27 -1
- package/dist/guard.js +19 -1
- package/dist/listSessions.d.ts +18 -0
- package/dist/listSessions.js +60 -0
- package/dist/parsers/claudeMdParser.js +35 -12
- package/dist/parsers/readClaudeMd.js +25 -1
- package/dist/report/generateReport.js +28 -1
- package/dist/selftest.d.ts +11 -0
- package/dist/selftest.js +85 -0
- package/dist/types.d.ts +17 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -431,6 +431,12 @@ npx tsx src/cli.ts demo
|
|
|
431
431
|
|
|
432
432
|
## Trust, privacy and licensing
|
|
433
433
|
|
|
434
|
+
**Verify it yourself.** `npx rulereceipt selftest` runs a set of bundled
|
|
435
|
+
golden fixtures on your machine and reports how many verdicts are correct, with
|
|
436
|
+
**zero network calls** — watch it with `lsof` or Little Snitch if you like. The
|
|
437
|
+
same fixtures are the project's regression suite, so "all correct" is a promise
|
|
438
|
+
the build enforces, not a claim.
|
|
439
|
+
|
|
434
440
|
**What it can't see, it says so.** [KNOWN-GAPS.md](KNOWN-GAPS.md) lists
|
|
435
441
|
exactly where the evidence runs out — commands in another terminal, clicks on
|
|
436
442
|
the permission prompt, `rm`/delete not bound to a rule's subject, edited
|
package/dist/audit.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface RulesAudit {
|
|
|
27
27
|
topFixes: {
|
|
28
28
|
title: string;
|
|
29
29
|
suggestion: string;
|
|
30
|
+
handle?: string;
|
|
30
31
|
}[];
|
|
31
32
|
}
|
|
32
33
|
export declare function auditRules(rules: Rule[]): RulesAudit;
|
|
@@ -39,7 +40,7 @@ export declare function renderAudit(a: RulesAudit, md?: boolean): string;
|
|
|
39
40
|
* cannot know without a transcript.
|
|
40
41
|
*/
|
|
41
42
|
export interface Diagnostic {
|
|
42
|
-
id: "no-rules-file" | "empty-or-pointer" | "zero-rules" | "docs-heavy" | "shadowed-file" | "size-warn" | "template-text" | "broken-import";
|
|
43
|
+
id: "no-rules-file" | "empty-or-pointer" | "zero-rules" | "docs-heavy" | "shadowed-file" | "size-warn" | "template-text" | "broken-import" | "hook-config" | "dead-globs";
|
|
43
44
|
severity: "info" | "warn";
|
|
44
45
|
message: string;
|
|
45
46
|
}
|
package/dist/audit.js
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { classifyRules } from "./checks/classify.js";
|
|
4
4
|
import { adviseRules } from "./checkability.js";
|
|
5
|
+
import { ruleWasLoaded } from "./checks/pathScope.js";
|
|
5
6
|
import { describeRuleSources, loadRules } from "./rules.js";
|
|
7
|
+
/** Files in the repo, for checking whether a path-scoped rule matches anything. */
|
|
8
|
+
function repoFiles(cwd, cap = 4000) {
|
|
9
|
+
const out = [];
|
|
10
|
+
const skip = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "coverage", ".rulereceipt", ".vercel", ".turbo", "vendor"]);
|
|
11
|
+
const walk = (dir) => {
|
|
12
|
+
if (out.length >= cap)
|
|
13
|
+
return;
|
|
14
|
+
let entries;
|
|
15
|
+
try {
|
|
16
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
for (const e of entries) {
|
|
22
|
+
if (out.length >= cap)
|
|
23
|
+
return;
|
|
24
|
+
const full = join(dir, e.name);
|
|
25
|
+
if (e.isDirectory()) {
|
|
26
|
+
if (!skip.has(e.name))
|
|
27
|
+
walk(full);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
out.push(full);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
walk(cwd);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
6
37
|
export function auditRules(rules) {
|
|
7
38
|
let checkable = 0;
|
|
8
39
|
let judgment = 0;
|
|
@@ -19,7 +50,7 @@ export function auditRules(rules) {
|
|
|
19
50
|
const topFixes = adviseRules(rules)
|
|
20
51
|
.filter((a) => a.actionable)
|
|
21
52
|
.slice(0, 5)
|
|
22
|
-
.map((a) => ({ title: a.ruleTitle, suggestion: a.suggestion }));
|
|
53
|
+
.map((a) => ({ title: a.ruleTitle, suggestion: a.suggestion, handle: a.handle }));
|
|
23
54
|
return {
|
|
24
55
|
total: checkable + judgment + skipped,
|
|
25
56
|
checkable,
|
|
@@ -60,6 +91,30 @@ export function renderAudit(a, md = false) {
|
|
|
60
91
|
out.push("Full advice, rule by rule: rulereceipt rules --advise");
|
|
61
92
|
return out.join("\n");
|
|
62
93
|
}
|
|
94
|
+
/** Hook events Claude Code recognises. A hook under any other name never fires. */
|
|
95
|
+
const KNOWN_HOOK_EVENTS = new Set([
|
|
96
|
+
"PreToolUse", "PostToolUse", "Stop", "SubagentStop", "UserPromptSubmit",
|
|
97
|
+
"SessionStart", "SessionEnd", "Notification", "PreCompact", "PostCompact",
|
|
98
|
+
"PermissionRequest", "PermissionDenied", "InstructionsLoaded",
|
|
99
|
+
]);
|
|
100
|
+
/** Hook event names in the project/user settings that Claude Code won't recognise. */
|
|
101
|
+
function unknownHookEvents(cwd) {
|
|
102
|
+
const bad = new Set();
|
|
103
|
+
for (const p of [join(cwd, ".claude", "settings.json"), join(cwd, ".claude", "settings.local.json")]) {
|
|
104
|
+
try {
|
|
105
|
+
const hooks = JSON.parse(readFileSync(p, "utf-8")).hooks;
|
|
106
|
+
if (hooks && typeof hooks === "object") {
|
|
107
|
+
for (const name of Object.keys(hooks))
|
|
108
|
+
if (!KNOWN_HOOK_EVENTS.has(name))
|
|
109
|
+
bad.add(name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* absent or unreadable */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [...bad];
|
|
117
|
+
}
|
|
63
118
|
/**
|
|
64
119
|
* Claude Code reads a bounded prefix of a rules file; past it the rest is
|
|
65
120
|
* silently dropped, so a rule below the cut never loads. The figure is a
|
|
@@ -104,7 +159,7 @@ function isPointerFile(path) {
|
|
|
104
159
|
return false;
|
|
105
160
|
}
|
|
106
161
|
}
|
|
107
|
-
function buildDiagnostics(cwd, graph, a) {
|
|
162
|
+
function buildDiagnostics(cwd, graph, a, rules) {
|
|
108
163
|
const diags = [];
|
|
109
164
|
const loaded = graph.filter((g) => g.status === "loaded");
|
|
110
165
|
const shadowed = graph.filter((g) => g.status === "shadowed");
|
|
@@ -187,6 +242,29 @@ function buildDiagnostics(cwd, graph, a) {
|
|
|
187
242
|
});
|
|
188
243
|
}
|
|
189
244
|
}
|
|
245
|
+
// A path-scoped rule whose globs match no file in the repo never loads.
|
|
246
|
+
const scoped = rules.filter((r) => r.paths && r.paths.length > 0);
|
|
247
|
+
if (scoped.length > 0) {
|
|
248
|
+
const files = repoFiles(cwd);
|
|
249
|
+
for (const r of scoped) {
|
|
250
|
+
if (!ruleWasLoaded(r.paths, files)) {
|
|
251
|
+
diags.push({
|
|
252
|
+
id: "dead-globs",
|
|
253
|
+
severity: "warn",
|
|
254
|
+
message: `"${r.title.replace(/\s+/g, " ").trim().slice(0, 50)}" is scoped to ${r.paths.join(", ")}, which matches no file in this repo — so it never loads and governs nothing. Fix the glob.`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// A hook wired under a misspelled/unknown event name never fires — silently.
|
|
260
|
+
const badHooks = unknownHookEvents(cwd);
|
|
261
|
+
if (badHooks.length > 0) {
|
|
262
|
+
diags.push({
|
|
263
|
+
id: "hook-config",
|
|
264
|
+
severity: "warn",
|
|
265
|
+
message: `.claude/settings.json has hook${badHooks.length === 1 ? "" : "s"} under ${badHooks.map((h) => `"${h}"`).join(", ")}, which ${badHooks.length === 1 ? "is not a" : "are not"} Claude Code hook event${badHooks.length === 1 ? "" : "s"} — ${badHooks.length === 1 ? "it never fires" : "they never fire"}. Check the spelling (e.g. PreToolUse, PostToolUse, Stop).`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
190
268
|
// A handbook, not a policy: mostly documentation, little to enforce.
|
|
191
269
|
if (a.total >= 8 && a.skipped / a.total > 0.7) {
|
|
192
270
|
diags.push({
|
|
@@ -206,7 +284,7 @@ export function auditProject(cwd) {
|
|
|
206
284
|
const rules = loadRules(cwd);
|
|
207
285
|
const base = auditRules(rules);
|
|
208
286
|
const loadGraph = describeRuleSources(cwd);
|
|
209
|
-
const diagnostics = buildDiagnostics(cwd, loadGraph, base);
|
|
287
|
+
const diagnostics = buildDiagnostics(cwd, loadGraph, base, rules);
|
|
210
288
|
const memoryRules = rules.filter((r) => r.id.startsWith("memory:")).length;
|
|
211
289
|
return { ...base, loadGraph, diagnostics, memoryRules };
|
|
212
290
|
}
|
|
@@ -251,7 +329,7 @@ export function renderProjectAudit(pa, md = false) {
|
|
|
251
329
|
if (pa.topFixes.length > 0) {
|
|
252
330
|
out.push(H("Top fixes to unlock more checks"));
|
|
253
331
|
for (const f of pa.topFixes) {
|
|
254
|
-
out.push(` • ${f.title.replace(/\s+/g, " ").trim().slice(0, 60)}`);
|
|
332
|
+
out.push(` • ${f.title.replace(/\s+/g, " ").trim().slice(0, 60)}${f.handle ? ` [${f.handle}]` : ""}`);
|
|
255
333
|
out.push(` ${f.suggestion}`);
|
|
256
334
|
}
|
|
257
335
|
out.push("");
|
|
@@ -44,3 +44,4 @@ export declare const CORPUS: {
|
|
|
44
44
|
};
|
|
45
45
|
export declare function analyze(text: string): AnalysisResult;
|
|
46
46
|
export { evaluateBrowserSession, checkSessionInBrowser, type BrowserSessionSummary } from "./evaluateBrowser.js";
|
|
47
|
+
export { shareText, shareLinks, cardSvg, type CardData } from "../card.js";
|
package/dist/browser/analyze.js
CHANGED
|
@@ -50,3 +50,8 @@ export function analyze(text) {
|
|
|
50
50
|
// Client-side SESSION check for the browser demo: drop a session .jsonl +
|
|
51
51
|
// paste rules, get per-rule verdicts, nothing uploaded. Same checkers as the CLI.
|
|
52
52
|
export { evaluateBrowserSession, checkSessionInBrowser } from "./evaluateBrowser.js";
|
|
53
|
+
// Share the browser result: caption (counts only), compose links, and a
|
|
54
|
+
// self-contained SVG card. card.ts is pure string work — no Node, no network —
|
|
55
|
+
// so it can run in the page without breaking the "nothing leaves this page"
|
|
56
|
+
// promise. Counts only, never rule text or the session.
|
|
57
|
+
export { shareText, shareLinks, cardSvg } from "../card.js";
|
package/dist/card.js
CHANGED
|
@@ -12,9 +12,16 @@
|
|
|
12
12
|
const SITE = "rulereceipt.dev";
|
|
13
13
|
/** The share caption. Counts only, unless showRules adds the broken rule names. */
|
|
14
14
|
export function shareText(d, showRules = false) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
// A single dropped session (the browser demo) has no "over N days" span, so
|
|
16
|
+
// it gets a "this session" caption rather than the history-mode one.
|
|
17
|
+
const single = d.sessions === 1;
|
|
18
|
+
const base = single
|
|
19
|
+
? d.broken > 0
|
|
20
|
+
? `${d.who} broke my written rules ${d.broken} time${d.broken === 1 ? "" : "s"} in this session — now it can't.`
|
|
21
|
+
: `RuleReceipt checked one of my agent's sessions against my written rules: ${d.broken} broken.`
|
|
22
|
+
: d.broken > 0
|
|
23
|
+
? `${d.who} broke my written rules ${d.broken} time${d.broken === 1 ? "" : "s"} in ${d.days} days (${d.sessions} sessions) — now it can't.`
|
|
24
|
+
: `RuleReceipt checked ${d.sessions} of my agent sessions over ${d.days} days against my written rules: ${d.broken} broken.`;
|
|
18
25
|
const tail = `Checked with RuleReceipt — runs locally, nothing uploaded. ${SITE}`;
|
|
19
26
|
if (showRules && d.broken > 0 && d.brokenTitles && d.brokenTitles.length > 0) {
|
|
20
27
|
const list = d.brokenTitles.slice(0, 3).map((t) => `“${t.replace(/\s+/g, " ").trim().slice(0, 50)}”`).join(", ");
|
|
@@ -41,7 +48,9 @@ function esc(s) {
|
|
|
41
48
|
/** A self-contained SVG card. Counts only — never rule text, paths or code. */
|
|
42
49
|
export function cardSvg(d) {
|
|
43
50
|
const headline = d.broken > 0 ? `${d.who} broke your rules ${d.broken}×` : `0 rules broken`;
|
|
44
|
-
const sub =
|
|
51
|
+
const sub = d.days > 0
|
|
52
|
+
? `${d.sessions} session${d.sessions === 1 ? "" : "s"} · last ${d.days} days`
|
|
53
|
+
: `${d.sessions} session${d.sessions === 1 ? "" : "s"}`;
|
|
45
54
|
const stats = `${d.followed} followed · ${d.judgment} need judgment`;
|
|
46
55
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="418" viewBox="0 0 800 418" role="img" aria-label="RuleReceipt summary">
|
|
47
56
|
<rect width="800" height="418" fill="#0b0d10"/>
|
package/dist/checkability.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface RuleAdvice {
|
|
|
30
30
|
* actionable ones as the top fixes.
|
|
31
31
|
*/
|
|
32
32
|
actionable?: boolean;
|
|
33
|
+
/** Stable content-hash handle for `rules --include/--exclude`. */
|
|
34
|
+
handle?: string;
|
|
33
35
|
}
|
|
34
36
|
/**
|
|
35
37
|
* Advice for one rule, or null when the rule is already mechanically checked.
|
package/dist/checkability.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { classifyRule } from "./checks/classify.js";
|
|
2
|
+
import { ruleFingerprint } from "./overrides.js";
|
|
2
3
|
/** A concrete action the rule is plausibly about, so we can name what to quote. */
|
|
3
4
|
const CONCRETE_SUBJECT = /\b(?:push(?:es|ed|ing)?|commit(?:s|ted|ting)?|merge[ds]?|rebase|delet\w*|remov\w*|\brm\b|drop|truncate|deploy\w*|migrat\w*|branch|tag|force[- ]?push|test|lint|build|install|env|secret|token|password|key|\.env|database|table|file|path|directory|endpoint|api)\b/i;
|
|
4
5
|
/** A rule that is qualitative by nature — no literal makes it mechanical. */
|
|
@@ -65,5 +66,14 @@ export function adviseRule(rule) {
|
|
|
65
66
|
}
|
|
66
67
|
/** Advice for every rule that isn't already mechanically checked. */
|
|
67
68
|
export function adviseRules(rules) {
|
|
68
|
-
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const rule of rules) {
|
|
71
|
+
const a = adviseRule(rule);
|
|
72
|
+
// Attach the stable content-hash handle so `audit`'s top fixes can be acted
|
|
73
|
+
// on with `rules --include/--exclude <handle>` (ids are positional and
|
|
74
|
+
// renumber; the handle survives edits above the rule).
|
|
75
|
+
if (a)
|
|
76
|
+
out.push({ ...a, handle: ruleFingerprint(rule) });
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
69
79
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { violation } from "../types.js";
|
|
2
|
-
import { withoutHeredocs } from "./shellCommand.js";
|
|
2
|
+
import { withoutHeredocs, withoutQuotedMentions } from "./shellCommand.js";
|
|
3
3
|
const IN_COMMAND = {
|
|
4
4
|
push: /\bgit\s+(?:\S+\s+){0,4}?push(?![\w-])/,
|
|
5
5
|
commit: /\bgit\s+(?:\S+\s+){0,4}?commit(?![\w-])/,
|
|
@@ -29,7 +29,10 @@ function commandOf(e) {
|
|
|
29
29
|
const c = e.input?.command;
|
|
30
30
|
// A heredoc that WRITES "git push" into a file is not a push. Strip heredoc
|
|
31
31
|
// bodies so only the commands actually invoked are inspected.
|
|
32
|
-
|
|
32
|
+
// Strip heredoc bodies (a heredoc that WRITES "git push" is not a push) and
|
|
33
|
+
// blank quoted/commented mentions (`echo "git push"`, `# git push`), so only
|
|
34
|
+
// a command actually being run is matched.
|
|
35
|
+
return typeof c === "string" ? withoutQuotedMentions(withoutHeredocs(c)) : "";
|
|
33
36
|
}
|
|
34
37
|
/** The result for the call at `i`: matched by id when present, else the next result. */
|
|
35
38
|
function resultOf(events, i) {
|
package/dist/checks/doctor.d.ts
CHANGED
|
@@ -11,11 +11,25 @@ export interface HookEntry {
|
|
|
11
11
|
*/
|
|
12
12
|
matcher?: string;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* A hook command registered more than once on the same event within a single
|
|
16
|
+
* settings file — so it fires that many times per event. Cross-file repetition
|
|
17
|
+
* (a global and a project settings file both registering it) is legitimate
|
|
18
|
+
* layering and is NOT reported: only a genuinely redundant in-file duplicate is,
|
|
19
|
+
* to avoid a false "you have a problem" on a normal setup.
|
|
20
|
+
*/
|
|
21
|
+
export interface DuplicateHook {
|
|
22
|
+
sourceFile: string;
|
|
23
|
+
event: string;
|
|
24
|
+
command: string;
|
|
25
|
+
count: number;
|
|
26
|
+
}
|
|
14
27
|
export interface DoctorResult {
|
|
15
28
|
filesScanned: string[];
|
|
16
29
|
filesFound: string[];
|
|
17
30
|
hooks: HookEntry[];
|
|
18
31
|
newSinceLastRun: HookEntry[];
|
|
32
|
+
duplicates: DuplicateHook[];
|
|
19
33
|
}
|
|
20
34
|
/**
|
|
21
35
|
* Lists every Claude Code hook and VS Code folderOpen task this machine
|
package/dist/checks/doctor.js
CHANGED
|
@@ -140,5 +140,22 @@ export function runDoctor(cwd) {
|
|
|
140
140
|
const previousKeys = new Set(previous.map((h) => `${h.sourceFile}|${h.event}|${h.command}`));
|
|
141
141
|
const newSinceLastRun = hooks.filter((h) => !previousKeys.has(`${h.sourceFile}|${h.event}|${h.command}`));
|
|
142
142
|
saveSnapshot(cwd, hooks);
|
|
143
|
-
return { filesScanned, filesFound, hooks, newSinceLastRun };
|
|
143
|
+
return { filesScanned, filesFound, hooks, newSinceLastRun, duplicates: findDuplicates(hooks) };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Same command on the same event within ONE file, counted. Keyed by
|
|
147
|
+
* file+event+command so the global-plus-project case never registers as a
|
|
148
|
+
* duplicate — that is intended layering, not a misconfiguration.
|
|
149
|
+
*/
|
|
150
|
+
function findDuplicates(hooks) {
|
|
151
|
+
const counts = new Map();
|
|
152
|
+
for (const h of hooks) {
|
|
153
|
+
const key = `${h.sourceFile}|${h.event}|${h.command}`;
|
|
154
|
+
const existing = counts.get(key);
|
|
155
|
+
if (existing)
|
|
156
|
+
existing.count += 1;
|
|
157
|
+
else
|
|
158
|
+
counts.set(key, { sourceFile: h.sourceFile, event: h.event, command: h.command, count: 1 });
|
|
159
|
+
}
|
|
160
|
+
return [...counts.values()].filter((d) => d.count > 1);
|
|
144
161
|
}
|
|
@@ -44,3 +44,15 @@ export declare function leadingCommand(segment: string): string;
|
|
|
44
44
|
* false-matched on commit-message text — findings 2026-09-26).
|
|
45
45
|
*/
|
|
46
46
|
export declare function withoutCommitMessage(command: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Blanks out quoted-string CONTENTS and drops #-comments, so a command MENTION
|
|
49
|
+
* inside a quote or a comment is not read as the command running.
|
|
50
|
+
*
|
|
51
|
+
* Found 2026-09-28: `echo "git push"` and `cat notes.md # git push` were both
|
|
52
|
+
* flagged as an unapproved push, because the matcher saw "git push" anywhere in
|
|
53
|
+
* the string. Blanking quote contents keeps the real verb visible (`git commit
|
|
54
|
+
* -m "msg"` still reads as a commit) while removing the mention. Use this only
|
|
55
|
+
* where a MENTION must not count as an action; checks that need the quoted text
|
|
56
|
+
* (attribution's commit-message trailer) must not use it.
|
|
57
|
+
*/
|
|
58
|
+
export declare function withoutQuotedMentions(command: string): string;
|
|
@@ -104,3 +104,19 @@ export function leadingCommand(segment) {
|
|
|
104
104
|
export function withoutCommitMessage(command) {
|
|
105
105
|
return command.replace(/(-m|--message)(=|\s+)(['"])(?:\\.|(?!\3)[\s\S])*\3/g, "$1 <message>");
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Blanks out quoted-string CONTENTS and drops #-comments, so a command MENTION
|
|
109
|
+
* inside a quote or a comment is not read as the command running.
|
|
110
|
+
*
|
|
111
|
+
* Found 2026-09-28: `echo "git push"` and `cat notes.md # git push` were both
|
|
112
|
+
* flagged as an unapproved push, because the matcher saw "git push" anywhere in
|
|
113
|
+
* the string. Blanking quote contents keeps the real verb visible (`git commit
|
|
114
|
+
* -m "msg"` still reads as a commit) while removing the mention. Use this only
|
|
115
|
+
* where a MENTION must not count as an action; checks that need the quoted text
|
|
116
|
+
* (attribution's commit-message trailer) must not use it.
|
|
117
|
+
*/
|
|
118
|
+
export function withoutQuotedMentions(command) {
|
|
119
|
+
const noQuotes = command.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
|
|
120
|
+
// A `#` that begins a word (start or after whitespace) starts a comment.
|
|
121
|
+
return noQuotes.replace(/(^|\s)#[^\n]*/g, "$1");
|
|
122
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -18,6 +18,8 @@ import { buildWrongReport, findTarget } from "./wrong.js";
|
|
|
18
18
|
import { detectSelfEditedRuleFiles } from "./checks/selfEditedRules.js";
|
|
19
19
|
import { scanHistory, renderHistory } from "./historyReport.js";
|
|
20
20
|
import { observeSessions, renderNoRules, draftRulesFromHistory } from "./sessionObserve.js";
|
|
21
|
+
import { listSessionRows, renderSessionList } from "./listSessions.js";
|
|
22
|
+
import { runSelfTestChecks, renderSelfTest } from "./selftest.js";
|
|
21
23
|
import { planProtect, applyProtect, undoProtect } from "./protect.js";
|
|
22
24
|
import { cardSvg, renderCardShare } from "./card.js";
|
|
23
25
|
import { createInterface } from "node:readline";
|
|
@@ -411,7 +413,13 @@ program
|
|
|
411
413
|
.option("--require-session", "fail (exit 1) if no session is found, or the session is empty, instead of reporting a pass for a check that never actually ran. Use this anywhere automated.")
|
|
412
414
|
.option("--show-skipped", "list the items that were treated as documentation and not checked. Worth running once on any rules file: the classifier is a heuristic over English verbs, so a rule it does not recognise is otherwise dropped without you seeing it.")
|
|
413
415
|
.option("--transcript <path>", "manual override: check this exact .jsonl session file instead of auto-detecting one. Useful if your Claude Code session lives somewhere non-standard that auto-detection doesn't cover.")
|
|
416
|
+
.option("--list-sessions", "list recent sessions for this project (tool, time, first prompt) so you can pick one for --transcript, instead of checking.")
|
|
414
417
|
.action((opts) => {
|
|
418
|
+
if (opts.listSessions) {
|
|
419
|
+
const cwd = process.cwd();
|
|
420
|
+
console.log(renderSessionList(listSessionRows(cwd), cwd));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
415
423
|
runCheck({
|
|
416
424
|
markdown: Boolean(opts.markdown),
|
|
417
425
|
json: Boolean(opts.json),
|
|
@@ -742,6 +750,14 @@ function runDoctorCommand() {
|
|
|
742
750
|
if (result.newSinceLastRun.length > 0) {
|
|
743
751
|
console.log(`${result.newSinceLastRun.length} of these are new since the last time doctor ran here.`);
|
|
744
752
|
}
|
|
753
|
+
if (result.duplicates.length > 0) {
|
|
754
|
+
console.log("");
|
|
755
|
+
console.log(`⚠ ${result.duplicates.length} duplicate hook${result.duplicates.length === 1 ? "" : "s"} — the same command is registered more than once on one event, so it runs that many times:`);
|
|
756
|
+
for (const d of result.duplicates) {
|
|
757
|
+
console.log(` ${d.event} — ${d.command} (×${d.count})`);
|
|
758
|
+
console.log(` in ${d.sourceFile} — remove the extra copy so it fires once.`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
745
761
|
}
|
|
746
762
|
program
|
|
747
763
|
.command("hook")
|
|
@@ -831,6 +847,15 @@ program
|
|
|
831
847
|
shadowedAgents: shadowedAgentsMd(cwd).map((s) => s.agents),
|
|
832
848
|
}));
|
|
833
849
|
});
|
|
850
|
+
program
|
|
851
|
+
.command("selftest")
|
|
852
|
+
.description("Run bundled golden fixtures on your machine and report how many verdicts are correct — proof the checkers work, with zero network calls (watch it with lsof if you like). Exits non-zero if any is wrong.")
|
|
853
|
+
.action(() => {
|
|
854
|
+
const r = runSelfTestChecks();
|
|
855
|
+
console.log(renderSelfTest(r));
|
|
856
|
+
if (r.failures.length > 0)
|
|
857
|
+
process.exitCode = 1;
|
|
858
|
+
});
|
|
834
859
|
program
|
|
835
860
|
.command("demo")
|
|
836
861
|
.description("See a sample report — no setup, no API key needed")
|
package/dist/evaluate.d.ts
CHANGED
|
@@ -20,3 +20,4 @@ export interface Evaluation {
|
|
|
20
20
|
* a transcript comes from, and a hook is handed one it must not second-guess.
|
|
21
21
|
*/
|
|
22
22
|
export declare function evaluateSession(cwd: string, rules: Rule[], events: TranscriptEvent[], llm: boolean, needsLlmResult: (rule: Rule) => CheckResult): Promise<Evaluation>;
|
|
23
|
+
export declare function attachSourceLocation(results: CheckResult[], rules: Rule[]): CheckResult[];
|
package/dist/evaluate.js
CHANGED
|
@@ -98,9 +98,35 @@ export async function evaluateSession(cwd, rules, events, llm, needsLlmResult) {
|
|
|
98
98
|
const judgmentResults = llm
|
|
99
99
|
? await runJudgmentChecks(judgment, events)
|
|
100
100
|
: judgment.map(({ rule }) => needsLlmResult(rule));
|
|
101
|
+
const results = [...deterministicResults, ...judgmentResults, ...scopeResults, ...future.map(futureResult)];
|
|
101
102
|
return {
|
|
102
|
-
results:
|
|
103
|
+
results: attachSourceLocation(results, rules),
|
|
103
104
|
notARule: of("notARule"),
|
|
104
105
|
stale: staleOverrides(overrides, rules),
|
|
105
106
|
};
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Copies each rule's source file/line onto its verdict — but ONLY when the
|
|
110
|
+
* (source, id, title) triple maps to exactly one loaded rule. Rule ids are
|
|
111
|
+
* positional and two files can legitimately reuse "1" or "S1.1", so a blind
|
|
112
|
+
* id-match could point a report at the wrong line. A wrong "CLAUDE.md:42" is
|
|
113
|
+
* worse than none, so an ambiguous or unlocated rule simply carries no line.
|
|
114
|
+
*/
|
|
115
|
+
function locationKey(source, id, title) {
|
|
116
|
+
return `${source}\u0000${id}\u0000${title}`;
|
|
117
|
+
}
|
|
118
|
+
export function attachSourceLocation(results, rules) {
|
|
119
|
+
const byKey = new Map();
|
|
120
|
+
for (const rule of rules) {
|
|
121
|
+
if (rule.sourcePath === undefined)
|
|
122
|
+
continue;
|
|
123
|
+
const key = locationKey(rule.source, rule.id, rule.title);
|
|
124
|
+
byKey.set(key, byKey.has(key) ? null : rule); // second hit => ambiguous => null
|
|
125
|
+
}
|
|
126
|
+
return results.map((r) => {
|
|
127
|
+
const rule = byKey.get(locationKey(r.ruleSource, r.ruleId, r.ruleTitle));
|
|
128
|
+
if (!rule)
|
|
129
|
+
return r;
|
|
130
|
+
return { ...r, sourcePath: rule.sourcePath, sourceLine: rule.sourceLine };
|
|
131
|
+
});
|
|
132
|
+
}
|
package/dist/guard.js
CHANGED
|
@@ -168,8 +168,26 @@ function ratifiedLiteralBlocks(cwd, command) {
|
|
|
168
168
|
* rules may block — not a cleverer guess. That is a design question for
|
|
169
169
|
* whoever asks for it, not a default.
|
|
170
170
|
*/
|
|
171
|
+
/**
|
|
172
|
+
* What to do instead — every refusal names a concrete next step, so the model
|
|
173
|
+
* corrects rather than just retrying (Failproof's "corrective context" idea,
|
|
174
|
+
* built our own way). Inferred from the rule and the reason; falls back to
|
|
175
|
+
* "ask the user", which is always safe.
|
|
176
|
+
*/
|
|
177
|
+
function suggest(b) {
|
|
178
|
+
const t = `${b.rule.title} ${b.why}`.toLowerCase();
|
|
179
|
+
if (/co-?authored|generated with|attribution|trailer/.test(t))
|
|
180
|
+
return "Instead: make the commit without the AI trailer (no `Co-Authored-By` / `Generated with` line).";
|
|
181
|
+
if (/\bpush\b|\bbranch\b|\bmain\b|\bmaster\b/.test(t))
|
|
182
|
+
return "Instead: work on a feature branch (`git switch -c <name>`) and open a PR, or ask the user before pushing.";
|
|
183
|
+
if (/\.env|secret|credential|\btoken\b|\bkey\b|password/.test(t))
|
|
184
|
+
return "Instead: leave that file as it is; if it genuinely must change, ask the user first.";
|
|
185
|
+
if (/delet|remov|\bdrop\b|truncat|wipe|\brm\b/.test(t))
|
|
186
|
+
return "Instead: don't delete it; if it should be removed, confirm with the user first.";
|
|
187
|
+
return "Instead: ask the user before doing this, or explain why the rule shouldn't apply and let them decide.";
|
|
188
|
+
}
|
|
171
189
|
function reason(blocks) {
|
|
172
|
-
const lines = blocks.map((b) => ` • Rule ${b.rule.id} — ${b.rule.title}\n ${b.why}`);
|
|
190
|
+
const lines = blocks.map((b) => ` • Rule ${b.rule.id} — ${b.rule.title}\n ${b.why}\n ${suggest(b)}`);
|
|
173
191
|
const n = blocks.length;
|
|
174
192
|
return (`RuleReceipt blocked this: it breaks ${n === 1 ? "a rule" : `${n} rules`} in CLAUDE.md.\n\n` +
|
|
175
193
|
lines.join("\n\n") +
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rulereceipt check --list-sessions` — a readable list of recent sessions so
|
|
3
|
+
* `--transcript <path>` is easy to pick. Without this, choosing a session means
|
|
4
|
+
* staring at a directory of UUID filenames. Each row shows the tool, how long
|
|
5
|
+
* ago, the first thing the user said (truncated and redacted), and the path to
|
|
6
|
+
* pass to `--transcript`.
|
|
7
|
+
*/
|
|
8
|
+
export interface SessionRow {
|
|
9
|
+
file: string;
|
|
10
|
+
tool: string;
|
|
11
|
+
mtimeMs: number;
|
|
12
|
+
firstPrompt: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function listSessionRows(cwd: string, limit?: number, sessions?: {
|
|
15
|
+
adapter: import("./adapters/index.js").SessionAdapter;
|
|
16
|
+
file: string;
|
|
17
|
+
}[]): SessionRow[];
|
|
18
|
+
export declare function renderSessionList(rows: SessionRow[], cwd: string, now?: number): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import { listAllSessions } from "./adapters/index.js";
|
|
4
|
+
import { redact } from "./wrong.js";
|
|
5
|
+
function firstUserText(events) {
|
|
6
|
+
for (const e of events) {
|
|
7
|
+
if (e.kind === "text" && e.role === "user" && e.text.trim())
|
|
8
|
+
return e.text.trim();
|
|
9
|
+
}
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
const toolLabel = (t) => (t === "claude-code" ? "Claude Code" : t === "codex" ? "Codex" : t);
|
|
13
|
+
export function listSessionRows(cwd, limit = 15, sessions = listAllSessions(cwd)) {
|
|
14
|
+
const rows = [];
|
|
15
|
+
for (const { adapter, file } of sessions.slice(0, limit)) {
|
|
16
|
+
let mtimeMs;
|
|
17
|
+
try {
|
|
18
|
+
mtimeMs = statSync(file).mtimeMs;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
let prompt = "";
|
|
24
|
+
try {
|
|
25
|
+
prompt = firstUserText(adapter.parse(file));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
/* unreadable session: still list it, just without a prompt */
|
|
29
|
+
}
|
|
30
|
+
rows.push({ file, tool: adapter.tool, mtimeMs, firstPrompt: redact(prompt).replace(/\s+/g, " ").trim().slice(0, 70) });
|
|
31
|
+
}
|
|
32
|
+
return rows;
|
|
33
|
+
}
|
|
34
|
+
function ago(ms, now = Date.now()) {
|
|
35
|
+
const s = Math.max(0, Math.round((now - ms) / 1000));
|
|
36
|
+
if (s < 90)
|
|
37
|
+
return `${s}s ago`;
|
|
38
|
+
const m = Math.round(s / 60);
|
|
39
|
+
if (m < 90)
|
|
40
|
+
return `${m}m ago`;
|
|
41
|
+
const h = Math.round(m / 60);
|
|
42
|
+
if (h < 36)
|
|
43
|
+
return `${h}h ago`;
|
|
44
|
+
return `${Math.round(h / 24)}d ago`;
|
|
45
|
+
}
|
|
46
|
+
export function renderSessionList(rows, cwd, now = Date.now()) {
|
|
47
|
+
if (rows.length === 0) {
|
|
48
|
+
return "No coding-agent sessions found for this project. Run Claude Code (or Codex) here first.";
|
|
49
|
+
}
|
|
50
|
+
const out = [];
|
|
51
|
+
out.push(`Recent sessions for this project (newest first). Check one with: rulereceipt check --transcript <path>`);
|
|
52
|
+
out.push("");
|
|
53
|
+
rows.forEach((r, i) => {
|
|
54
|
+
const rel = relative(cwd, r.file);
|
|
55
|
+
const path = rel && !rel.startsWith("..") ? rel : r.file;
|
|
56
|
+
out.push(` ${String(i + 1).padStart(2)}. ${toolLabel(r.tool).padEnd(11)} ${ago(r.mtimeMs, now).padEnd(8)} ${r.firstPrompt || "(no user message)"}`);
|
|
57
|
+
out.push(` ${path}`);
|
|
58
|
+
});
|
|
59
|
+
return out.join("\n");
|
|
60
|
+
}
|
|
@@ -75,7 +75,12 @@ function stripHtmlComments(raw) {
|
|
|
75
75
|
spans.push(m);
|
|
76
76
|
return `\u0000CODE${spans.length - 1}\u0000`;
|
|
77
77
|
});
|
|
78
|
-
|
|
78
|
+
// A removed comment is replaced by the SAME number of newlines it spanned,
|
|
79
|
+
// not by nothing. Rule source lines are tracked by line index (2026-09-29),
|
|
80
|
+
// so a multi-line comment that collapsed to nothing would shift every rule
|
|
81
|
+
// below it and make the reported "CLAUDE.md:42" wrong. Blank lines left in a
|
|
82
|
+
// rule body are trimmed at its edges and harmless within it.
|
|
83
|
+
const stripped = masked.replace(/<!--[\s\S]*?-->/g, (m) => "\n".repeat((m.match(/\n/g) ?? []).length));
|
|
79
84
|
return stripped.replace(/\u0000CODE(\d+)\u0000/g, (_, i) => spans[Number(i)]);
|
|
80
85
|
}
|
|
81
86
|
function normalizeSetextHeaders(lines) {
|
|
@@ -152,6 +157,8 @@ export function parseClaudeMdText(raw, source) {
|
|
|
152
157
|
let currentIsMarkedRule = false; // true for numbered/bold rules: bullets in their body stay as body text
|
|
153
158
|
let bodyLines = [];
|
|
154
159
|
let pendingSectionTitle = null;
|
|
160
|
+
let pendingSectionLine = 0; // 1-based line of the plain header awaiting its prose rule
|
|
161
|
+
let lineNo = 0; // 1-based index of the line currently being read
|
|
155
162
|
let sectionCount = 0;
|
|
156
163
|
let sectionId = "S0"; // "S0" before any header is seen; null while a header is pending its first rule
|
|
157
164
|
let bulletIndex = 0;
|
|
@@ -178,7 +185,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
178
185
|
bodyLines.push(line);
|
|
179
186
|
}
|
|
180
187
|
else if (pendingSectionTitle !== null && line.trim() !== "") {
|
|
181
|
-
current = { id: `${assignSectionId()}.0`, title: pendingSectionTitle, text: "", source };
|
|
188
|
+
current = { id: `${assignSectionId()}.0`, title: pendingSectionTitle, text: "", source, sourceLine: pendingSectionLine };
|
|
182
189
|
bodyLines = [line];
|
|
183
190
|
pendingSectionTitle = null;
|
|
184
191
|
}
|
|
@@ -187,6 +194,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
187
194
|
}
|
|
188
195
|
};
|
|
189
196
|
for (const line of lines) {
|
|
197
|
+
lineNo += 1;
|
|
190
198
|
// Fence state is tracked before any structural match, so nothing inside a
|
|
191
199
|
// code block is ever read as a heading, a bullet, or a rule marker.
|
|
192
200
|
const fence = line.match(FENCE_LINE);
|
|
@@ -210,7 +218,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
210
218
|
if (numbered) {
|
|
211
219
|
flush();
|
|
212
220
|
pendingSectionTitle = null;
|
|
213
|
-
current = { id: numbered[1], title: numbered[2].trim(), text: "", source };
|
|
221
|
+
current = { id: numbered[1], title: numbered[2].trim(), text: "", source, sourceLine: lineNo };
|
|
214
222
|
currentIsMarkedRule = true;
|
|
215
223
|
continue;
|
|
216
224
|
}
|
|
@@ -218,7 +226,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
218
226
|
if (bold) {
|
|
219
227
|
flush();
|
|
220
228
|
pendingSectionTitle = null;
|
|
221
|
-
current = { id: bold[1], title: bold[2].trim(), text: "", source };
|
|
229
|
+
current = { id: bold[1], title: bold[2].trim(), text: "", source, sourceLine: lineNo };
|
|
222
230
|
currentIsMarkedRule = true;
|
|
223
231
|
continue;
|
|
224
232
|
}
|
|
@@ -228,6 +236,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
228
236
|
sectionId = null;
|
|
229
237
|
bulletIndex = 0;
|
|
230
238
|
pendingSectionTitle = plain[1].trim();
|
|
239
|
+
pendingSectionLine = lineNo;
|
|
231
240
|
currentIsMarkedRule = false;
|
|
232
241
|
continue;
|
|
233
242
|
}
|
|
@@ -239,7 +248,7 @@ export function parseClaudeMdText(raw, source) {
|
|
|
239
248
|
else {
|
|
240
249
|
bulletIndex += 1;
|
|
241
250
|
const text = bullet[1].trim();
|
|
242
|
-
rules.push({ id: `${assignSectionId()}.${bulletIndex}`, title: text, text, source });
|
|
251
|
+
rules.push({ id: `${assignSectionId()}.${bulletIndex}`, title: text, text, source, sourceLine: lineNo });
|
|
243
252
|
}
|
|
244
253
|
continue;
|
|
245
254
|
}
|
|
@@ -253,13 +262,27 @@ export function parseClaudeMdText(raw, source) {
|
|
|
253
262
|
// headers, no bullets, no bold-rule markers) is split one rule per
|
|
254
263
|
// blank-line-separated paragraph, so a genuinely unstructured file
|
|
255
264
|
// still yields checkable rules instead of silently returning nothing.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
265
|
+
// Track the line each paragraph starts on so the fallback rules carry a
|
|
266
|
+
// source line too. Split on the raw text and walk cumulative line counts.
|
|
267
|
+
const chunks = raw.split(/\n\s*\n/);
|
|
268
|
+
const out = [];
|
|
269
|
+
let lineCursor = 1;
|
|
270
|
+
let n = 0;
|
|
271
|
+
for (const chunk of chunks) {
|
|
272
|
+
const startLine = lineCursor;
|
|
273
|
+
lineCursor += (chunk.match(/\n/g) ?? []).length; // lines consumed by this chunk
|
|
274
|
+
// account for the blank separator the split removed (one newline minimum)
|
|
275
|
+
lineCursor += 1;
|
|
276
|
+
const p = chunk.trim();
|
|
277
|
+
if (p.length === 0)
|
|
278
|
+
continue;
|
|
279
|
+
n += 1;
|
|
261
280
|
const firstLine = p.split("\n")[0].trim();
|
|
262
281
|
const title = firstLine.length > 100 ? `${firstLine.slice(0, 100).trim()}…` : firstLine;
|
|
263
|
-
|
|
264
|
-
|
|
282
|
+
// Best-effort line for the freeform fallback: any leading blank lines in the
|
|
283
|
+
// chunk push the real first line down.
|
|
284
|
+
const leadBlank = (chunk.match(/^(?:[ \t]*\n)*/)?.[0].match(/\n/g) ?? []).length;
|
|
285
|
+
out.push({ id: String(n), title, text: p, source, sourceLine: startLine + leadBlank });
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
265
288
|
}
|
|
@@ -81,6 +81,24 @@ export function readPathScope(raw) {
|
|
|
81
81
|
}
|
|
82
82
|
return undefined;
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* How many leading lines `stripFrontmatter` removes, so a source line computed
|
|
86
|
+
* on the stripped text can be mapped back to the real file line. Zero when
|
|
87
|
+
* there is no frontmatter block.
|
|
88
|
+
*/
|
|
89
|
+
function frontmatterLineOffset(raw) {
|
|
90
|
+
if (!/^---\r?\n/.test(raw))
|
|
91
|
+
return 0;
|
|
92
|
+
const end = raw.indexOf("\n---", 3);
|
|
93
|
+
if (end === -1)
|
|
94
|
+
return 0;
|
|
95
|
+
const after = raw.indexOf("\n", end + 1);
|
|
96
|
+
if (after === -1)
|
|
97
|
+
return 0;
|
|
98
|
+
// stripFrontmatter returns raw.slice(after + 1): everything up to and
|
|
99
|
+
// including that newline is gone. Count the newlines removed.
|
|
100
|
+
return (raw.slice(0, after + 1).match(/\n/g) ?? []).length;
|
|
101
|
+
}
|
|
84
102
|
export function parseClaudeMd(filePath, source) {
|
|
85
103
|
let raw;
|
|
86
104
|
try {
|
|
@@ -89,7 +107,13 @@ export function parseClaudeMd(filePath, source) {
|
|
|
89
107
|
catch {
|
|
90
108
|
return [];
|
|
91
109
|
}
|
|
110
|
+
const offset = frontmatterLineOffset(raw);
|
|
92
111
|
const rules = parseClaudeMdText(stripFrontmatter(raw), source);
|
|
93
112
|
const paths = readPathScope(raw);
|
|
94
|
-
return
|
|
113
|
+
return rules.map((r) => ({
|
|
114
|
+
...r,
|
|
115
|
+
sourcePath: filePath,
|
|
116
|
+
sourceLine: r.sourceLine === undefined ? undefined : r.sourceLine + offset,
|
|
117
|
+
...(paths ? { paths } : {}),
|
|
118
|
+
}));
|
|
95
119
|
}
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
/**
|
|
5
|
+
* Where a rule lives, for the report — "~/proj/CLAUDE.md:42", or just the path
|
|
6
|
+
* when the parser could not place a line, or "" when the rule's location was
|
|
7
|
+
* ambiguous and deliberately omitted (see attachSourceLocation). The home dir
|
|
8
|
+
* is compressed to ~ so the line stays readable; the JSON report keeps the full
|
|
9
|
+
* absolute path.
|
|
10
|
+
*/
|
|
11
|
+
function locationOf(r) {
|
|
12
|
+
if (!r.sourcePath)
|
|
13
|
+
return "";
|
|
14
|
+
const home = homedir();
|
|
15
|
+
const path = r.sourcePath.startsWith(home) ? `~${r.sourcePath.slice(home.length)}` : r.sourcePath;
|
|
16
|
+
return r.sourceLine ? `${path}:${r.sourceLine}` : path;
|
|
17
|
+
}
|
|
3
18
|
const MARK = { PASS: "✓", FAIL: "✕", UNCLEAR: "?" };
|
|
4
19
|
/**
|
|
5
20
|
* A CLAUDE.md rule title, or evidence text pulled from session content, is
|
|
@@ -196,6 +211,9 @@ export function generateReport(results, meta) {
|
|
|
196
211
|
lines.push("");
|
|
197
212
|
for (const r of inBucket) {
|
|
198
213
|
lines.push(` ${ruleLabel(r, clean)}`);
|
|
214
|
+
const loc = locationOf(r);
|
|
215
|
+
if (loc)
|
|
216
|
+
lines.push(` ↳ ${loc}`);
|
|
199
217
|
// An entry that does not share the hoisted text still says its own
|
|
200
218
|
// piece — that difference is the only per-rule information there is.
|
|
201
219
|
if (r.evidence && r.evidence !== shared)
|
|
@@ -207,6 +225,9 @@ export function generateReport(results, meta) {
|
|
|
207
225
|
}
|
|
208
226
|
for (const r of inBucket) {
|
|
209
227
|
lines.push(`${MARK[r.status]} ${r.status.padEnd(7)} ${ruleLabel(r, clean)}`);
|
|
228
|
+
const loc = locationOf(r);
|
|
229
|
+
if (loc)
|
|
230
|
+
lines.push(` ↳ ${loc}`);
|
|
210
231
|
if (r.evidence)
|
|
211
232
|
lines.push(` evidence: ${r.evidence}`);
|
|
212
233
|
// What this method was ALLOWED to conclude, travelling with the
|
|
@@ -259,7 +280,9 @@ export function generateMarkdownReport(results, meta) {
|
|
|
259
280
|
lines.push("|---|---|---|");
|
|
260
281
|
for (const r of clean) {
|
|
261
282
|
const evidence = escapeMarkdownCell(r.evidence || "");
|
|
262
|
-
|
|
283
|
+
const loc = locationOf(r);
|
|
284
|
+
const label = loc ? `${ruleLabel(r, clean)}<br>\`${loc}\`` : ruleLabel(r, clean);
|
|
285
|
+
lines.push(`| ${MARK[r.status]} ${r.status} | ${escapeMarkdownCell(label)} | ${evidence} |`);
|
|
263
286
|
}
|
|
264
287
|
lines.push("");
|
|
265
288
|
const hash = computeTranscriptHash(meta.sessionFilePath);
|
|
@@ -302,6 +325,10 @@ export function generateJsonReport(results, meta, toolVersion, editedRuleFiles =
|
|
|
302
325
|
ruleId: r.ruleId,
|
|
303
326
|
ruleTitle: r.ruleTitle,
|
|
304
327
|
ruleSource: r.ruleSource,
|
|
328
|
+
// Absolute path + 1-based line of the rule's heading, when unambiguous
|
|
329
|
+
// (see attachSourceLocation). A consumer can jump straight to the rule.
|
|
330
|
+
sourcePath: r.sourcePath ?? null,
|
|
331
|
+
sourceLine: r.sourceLine ?? null,
|
|
305
332
|
status: r.status,
|
|
306
333
|
outcome: r.outcome ?? null,
|
|
307
334
|
method: r.method ?? null,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface SelfTestResult {
|
|
2
|
+
total: number;
|
|
3
|
+
passed: number;
|
|
4
|
+
failures: {
|
|
5
|
+
name: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
}[];
|
|
8
|
+
}
|
|
9
|
+
export declare function runSelfTestChecks(): SelfTestResult;
|
|
10
|
+
/** The human-facing selftest output. */
|
|
11
|
+
export declare function renderSelfTest(r: SelfTestResult): string;
|
package/dist/selftest.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { checkSessionInBrowser } from "./browser/evaluateBrowser.js";
|
|
2
|
+
function sessionOf(lines) {
|
|
3
|
+
return lines.map((l) => JSON.stringify(l)).join("\n");
|
|
4
|
+
}
|
|
5
|
+
const asst = (content) => ({ type: "assistant", timestamp: "t", message: { role: "assistant", content } });
|
|
6
|
+
const user = (content, permissionMode) => ({ type: "user", timestamp: "t", permissionMode, message: { role: "user", content } });
|
|
7
|
+
const bash = (command, id = "x") => ({ type: "tool_use", id, name: "Bash", input: { command } });
|
|
8
|
+
const edit = (file_path) => ({ type: "tool_use", id: "e", name: "Edit", input: { file_path, old_string: "a", new_string: "b" } });
|
|
9
|
+
const GOLDENS = [
|
|
10
|
+
{
|
|
11
|
+
name: "push with no approval in a no-prompt mode is Broken",
|
|
12
|
+
rules: "## 1. Never push without asking\nNever push without explicit user instruction.\n",
|
|
13
|
+
session: sessionOf([user("fix the page", "bypassPermissions"), asst([bash("git push origin main", "a")])]),
|
|
14
|
+
expect: [{ title: /never push/i, want: "FAIL" }],
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "push the user asked for is Followed",
|
|
18
|
+
rules: "## 1. Never push without asking\nNever push without explicit user instruction.\n",
|
|
19
|
+
session: sessionOf([user("fix it and push it", "bypassPermissions"), asst([bash("git push origin main", "a")])]),
|
|
20
|
+
expect: [{ title: /never push/i, want: "PASS" }],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "push in default mode is Can't-tell (a prompt may have been approved)",
|
|
24
|
+
rules: "## 1. Never push without asking\nNever push without explicit user instruction.\n",
|
|
25
|
+
session: sessionOf([user("fix the page", "default"), asst([bash("git push origin main", "a")])]),
|
|
26
|
+
expect: [{ title: /never push/i, want: "not-fail" }],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "console.log written into a file is Broken",
|
|
30
|
+
rules: "## 1. No debug logging\nNever leave a `console.log(` call in committed code.\n",
|
|
31
|
+
session: sessionOf([asst([{ type: "tool_use", id: "w", name: "Write", input: { file_path: "app.ts", content: "console.log(1)" } }])]),
|
|
32
|
+
expect: [{ title: /debug logging/i, want: "FAIL" }],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "editing .env is Broken",
|
|
36
|
+
rules: "## 1. Never edit `.env`\nNever edit `.env`.\n",
|
|
37
|
+
session: sessionOf([asst([edit(".env")])]),
|
|
38
|
+
expect: [{ title: /never edit/i, want: "FAIL" }],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "a Co-Authored-By trailer is Broken",
|
|
42
|
+
rules: "## 1. No AI attribution\nNever add a `Co-Authored-By: Claude` trailer to git commits.\n",
|
|
43
|
+
session: sessionOf([asst([bash("git commit -m 'x\n\nCo-Authored-By: Claude <noreply@anthropic.com>'", "c")])]),
|
|
44
|
+
expect: [{ title: /attribution/i, want: "FAIL" }],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "a temp-dir rm is NOT Broken against a 'never wipe databases' rule",
|
|
48
|
+
rules: "## 1. Never wipe databases\nBefore any delete on the database, wait for explicit confirmation.\n",
|
|
49
|
+
session: sessionOf([asst([bash("rm -rf /tmp/scratch-xyz", "r")])]),
|
|
50
|
+
expect: [{ title: /wipe databases/i, want: "not-fail" }],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "a judgment rule is Can't-tell, never Broken",
|
|
54
|
+
rules: "## 1. Keep changes small\nAlways keep changes small and focused.\n",
|
|
55
|
+
session: sessionOf([asst([bash("git commit -m x", "c")])]),
|
|
56
|
+
expect: [{ title: /keep changes small/i, want: "UNCLEAR" }],
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
export function runSelfTestChecks() {
|
|
60
|
+
const failures = [];
|
|
61
|
+
let total = 0;
|
|
62
|
+
for (const g of GOLDENS) {
|
|
63
|
+
const results = checkSessionInBrowser(g.rules, g.session).results;
|
|
64
|
+
for (const e of g.expect) {
|
|
65
|
+
total++;
|
|
66
|
+
const r = results.find((x) => e.title.test(x.ruleTitle));
|
|
67
|
+
const got = r?.status ?? "MISSING";
|
|
68
|
+
const ok = e.want === "not-fail" ? got !== "FAIL" && got !== "MISSING" : got === e.want;
|
|
69
|
+
if (!ok)
|
|
70
|
+
failures.push({ name: g.name, detail: `expected ${e.want}, got ${got}` });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { total, passed: total - failures.length, failures };
|
|
74
|
+
}
|
|
75
|
+
/** The human-facing selftest output. */
|
|
76
|
+
export function renderSelfTest(r) {
|
|
77
|
+
if (r.failures.length === 0) {
|
|
78
|
+
return (`rulereceipt selftest\n\n` +
|
|
79
|
+
` ${r.total} checks, all correct.\n` +
|
|
80
|
+
` 0 network calls — this ran entirely on your machine (watch it with lsof / Little Snitch if you like).\n\n` +
|
|
81
|
+
`The same checkers ran here as on your real sessions. If any of these were ever wrong, this would say so.`);
|
|
82
|
+
}
|
|
83
|
+
const lines = r.failures.map((f) => ` ✗ ${f.name} — ${f.detail}`);
|
|
84
|
+
return `rulereceipt selftest\n\n ${r.passed}/${r.total} correct, ${r.failures.length} WRONG:\n` + lines.join("\n") + `\n\nThis is a bug — please report it with the version (rulereceipt --version).`;
|
|
85
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -11,6 +11,15 @@ export interface Rule {
|
|
|
11
11
|
* against a rule it was never shown. Absent = always loaded.
|
|
12
12
|
*/
|
|
13
13
|
paths?: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Absolute path of the rules file this rule was read from, and the 1-based
|
|
16
|
+
* line where its heading/marker sits. Added 2026-09-29 so a report can say
|
|
17
|
+
* exactly where a rule lives ("CLAUDE.md:42 — Never push to main"): a verdict
|
|
18
|
+
* you can walk to the source of is a verdict you can trust. Optional — memory
|
|
19
|
+
* rules and the freeform-paragraph fallback carry a path but may omit a line.
|
|
20
|
+
*/
|
|
21
|
+
sourcePath?: string;
|
|
22
|
+
sourceLine?: number;
|
|
14
23
|
}
|
|
15
24
|
export interface TranscriptTextEvent {
|
|
16
25
|
role: "user" | "assistant";
|
|
@@ -149,6 +158,14 @@ export interface CheckResult {
|
|
|
149
158
|
* anthropics/claude-code#90542.
|
|
150
159
|
*/
|
|
151
160
|
polarityInferred?: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Where the checked rule lives — the rules file's absolute path and the
|
|
163
|
+
* 1-based line of its heading. Copied from the rule in `evaluateSession`,
|
|
164
|
+
* and ONLY when the (source, id, title) triple maps to exactly one loaded
|
|
165
|
+
* rule, so a report never points at the wrong line. Absent otherwise.
|
|
166
|
+
*/
|
|
167
|
+
sourcePath?: string;
|
|
168
|
+
sourceLine?: number;
|
|
152
169
|
}
|
|
153
170
|
/**
|
|
154
171
|
* A FAIL may only be constructed from a forbidding rule.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.67",
|
|
4
4
|
"description": "Checks whether your AI coding agent followed your rules, with evidence. Works with Claude Code (Codex in testing); reads CLAUDE.md, AGENTS.md, Cursor, Copilot and Windsurf rules.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|