rulereceipt 0.1.57 → 0.1.59
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 +1 -0
- package/dist/audit.d.ts +5 -0
- package/dist/audit.js +16 -1
- package/dist/checkability.d.ts +8 -0
- package/dist/checkability.js +1 -0
- package/dist/checks/pathScope.d.ts +5 -0
- package/dist/checks/pathScope.js +93 -0
- package/dist/evaluate.js +21 -2
- package/dist/parsers/readClaudeMd.d.ts +11 -0
- package/dist/parsers/readClaudeMd.js +48 -1
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ Published and live on npm, actively developed.
|
|
|
76
76
|
## Usage
|
|
77
77
|
|
|
78
78
|
```bash
|
|
79
|
+
rulereceipt audit # score your rules file for checkability — NO session needed
|
|
79
80
|
rulereceipt check # check the latest session in this project
|
|
80
81
|
rulereceipt check --markdown # same, formatted for pasting into a PR/Slack
|
|
81
82
|
rulereceipt check --html # write a shareable single-file HTML report you can send
|
package/dist/audit.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export interface RulesAudit {
|
|
|
22
22
|
skipped: number;
|
|
23
23
|
/** checkable / (checkable + judgment), whole %, 0 when there are no rules. */
|
|
24
24
|
percentCheckable: number;
|
|
25
|
+
/** The highest-leverage rewrites — a concrete rule missing only its literal. */
|
|
26
|
+
topFixes: {
|
|
27
|
+
title: string;
|
|
28
|
+
suggestion: string;
|
|
29
|
+
}[];
|
|
25
30
|
}
|
|
26
31
|
export declare function auditRules(rules: Rule[]): RulesAudit;
|
|
27
32
|
/** A short, readable audit. Never says "compliant" — it measures the file, not a session. */
|
package/dist/audit.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { classifyRules } from "./checks/classify.js";
|
|
2
|
+
import { adviseRules } from "./checkability.js";
|
|
2
3
|
export function auditRules(rules) {
|
|
3
4
|
let checkable = 0;
|
|
4
5
|
let judgment = 0;
|
|
@@ -12,12 +13,17 @@ export function auditRules(rules) {
|
|
|
12
13
|
checkable++;
|
|
13
14
|
}
|
|
14
15
|
const decided = checkable + judgment;
|
|
16
|
+
const topFixes = adviseRules(rules)
|
|
17
|
+
.filter((a) => a.actionable)
|
|
18
|
+
.slice(0, 5)
|
|
19
|
+
.map((a) => ({ title: a.ruleTitle, suggestion: a.suggestion }));
|
|
15
20
|
return {
|
|
16
21
|
total: checkable + judgment + skipped,
|
|
17
22
|
checkable,
|
|
18
23
|
judgment,
|
|
19
24
|
skipped,
|
|
20
25
|
percentCheckable: decided > 0 ? Math.round((checkable / decided) * 100) : 0,
|
|
26
|
+
topFixes,
|
|
21
27
|
};
|
|
22
28
|
}
|
|
23
29
|
/** A short, readable audit. Never says "compliant" — it measures the file, not a session. */
|
|
@@ -39,6 +45,15 @@ export function renderAudit(a, md = false) {
|
|
|
39
45
|
out.push(` ${String(a.skipped).padStart(4)} documentation — structure/notes, not scored as rules`);
|
|
40
46
|
out.push("");
|
|
41
47
|
out.push(`${a.percentCheckable}% of your rules can be checked mechanically.`);
|
|
42
|
-
out.push("
|
|
48
|
+
out.push("");
|
|
49
|
+
if (a.topFixes.length > 0) {
|
|
50
|
+
out.push(H("Top fixes to unlock more checks"));
|
|
51
|
+
for (const f of a.topFixes) {
|
|
52
|
+
out.push(` • ${f.title.replace(/\s+/g, " ").trim().slice(0, 60)}`);
|
|
53
|
+
out.push(` ${f.suggestion}`);
|
|
54
|
+
}
|
|
55
|
+
out.push("");
|
|
56
|
+
}
|
|
57
|
+
out.push("Full advice, rule by rule: rulereceipt rules --advise");
|
|
43
58
|
return out.join("\n");
|
|
44
59
|
}
|
package/dist/checkability.d.ts
CHANGED
|
@@ -22,6 +22,14 @@ export interface RuleAdvice {
|
|
|
22
22
|
kind: "judgment" | "notARule";
|
|
23
23
|
/** One line: what is missing and the smallest edit that fixes it. */
|
|
24
24
|
suggestion: string;
|
|
25
|
+
/**
|
|
26
|
+
* True when there is a concrete, high-leverage rewrite (name the command/
|
|
27
|
+
* file/branch in backticks) that would turn this into a real check — as
|
|
28
|
+
* opposed to a genuine judgment call or plain documentation, where the
|
|
29
|
+
* honest answer is "no edit makes it mechanical." `audit` surfaces the
|
|
30
|
+
* actionable ones as the top fixes.
|
|
31
|
+
*/
|
|
32
|
+
actionable?: boolean;
|
|
25
33
|
}
|
|
26
34
|
/**
|
|
27
35
|
* Advice for one rule, or null when the rule is already mechanically checked.
|
package/dist/checkability.js
CHANGED
|
@@ -53,6 +53,7 @@ export function adviseRule(rule) {
|
|
|
53
53
|
return {
|
|
54
54
|
ruleTitle: rule.title,
|
|
55
55
|
kind,
|
|
56
|
+
actionable: true, // a concrete, high-leverage rewrite: just add the literal
|
|
56
57
|
suggestion: `mentions ${subject} but names no exact term to match. Put the concrete command, file or branch in backticks (e.g. ${exampleFor(subject)}) and it becomes a mechanical check.`,
|
|
57
58
|
};
|
|
58
59
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TranscriptEvent } from "../types.js";
|
|
2
|
+
export declare function touchedPaths(events: TranscriptEvent[]): string[];
|
|
3
|
+
export declare function globToRegExp(glob: string): RegExp;
|
|
4
|
+
/** True when any touched file matches any of the rule's patterns. */
|
|
5
|
+
export declare function ruleWasLoaded(patterns: string[], touched: string[]): boolean;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path-scoped rules: when does Claude actually see them?
|
|
3
|
+
*
|
|
4
|
+
* Claude Code loads a `.claude/rules/*.md` file carrying `paths:` frontmatter
|
|
5
|
+
* only when the session works on a file matching one of its patterns. Cursor
|
|
6
|
+
* `.mdc` rules with `globs:` (and agy `.agents/rules` with `trigger: glob`)
|
|
7
|
+
* behave the same way. A session that never touched a matching file never
|
|
8
|
+
* had the rule in context, so no verdict about it can be fair.
|
|
9
|
+
*
|
|
10
|
+
* Direction of error, deliberately: patterns are matched against every
|
|
11
|
+
* trailing segment of the absolute path, because the transcript records
|
|
12
|
+
* absolute paths and the project root is not always known. That can
|
|
13
|
+
* over-match (treat a rule as loaded when it was not), which falls back to
|
|
14
|
+
* today's behaviour. It cannot under-match a real file, which is the side
|
|
15
|
+
* that would hide a real violation.
|
|
16
|
+
*/
|
|
17
|
+
const FILE_TOOLS = {
|
|
18
|
+
Read: "file_path",
|
|
19
|
+
Edit: "file_path",
|
|
20
|
+
MultiEdit: "file_path",
|
|
21
|
+
Write: "file_path",
|
|
22
|
+
NotebookEdit: "notebook_path",
|
|
23
|
+
};
|
|
24
|
+
export function touchedPaths(events) {
|
|
25
|
+
const out = new Set();
|
|
26
|
+
for (const e of events) {
|
|
27
|
+
if (e.kind !== "tool_use")
|
|
28
|
+
continue;
|
|
29
|
+
const field = FILE_TOOLS[e.toolName];
|
|
30
|
+
if (!field)
|
|
31
|
+
continue;
|
|
32
|
+
const v = e.input?.[field];
|
|
33
|
+
if (typeof v === "string" && v.length > 0)
|
|
34
|
+
out.add(v.replace(/\\/g, "/"));
|
|
35
|
+
}
|
|
36
|
+
return [...out];
|
|
37
|
+
}
|
|
38
|
+
function expandBraces(glob) {
|
|
39
|
+
const m = glob.match(/\{([^{}]*)\}/);
|
|
40
|
+
if (!m || m.index === undefined)
|
|
41
|
+
return [glob];
|
|
42
|
+
const head = glob.slice(0, m.index);
|
|
43
|
+
const tail = glob.slice(m.index + m[0].length);
|
|
44
|
+
return m[1].split(",").flatMap((alt) => expandBraces(head + alt + tail));
|
|
45
|
+
}
|
|
46
|
+
export function globToRegExp(glob) {
|
|
47
|
+
let g = glob.trim().replace(/^\.\//, "");
|
|
48
|
+
if (g.startsWith("/"))
|
|
49
|
+
g = g.slice(1);
|
|
50
|
+
if (g.endsWith("/"))
|
|
51
|
+
g += "**";
|
|
52
|
+
let re = "";
|
|
53
|
+
for (let i = 0; i < g.length; i++) {
|
|
54
|
+
const c = g[i];
|
|
55
|
+
if (c === "*") {
|
|
56
|
+
if (g[i + 1] === "*") {
|
|
57
|
+
const slashAfter = g[i + 2] === "/";
|
|
58
|
+
re += slashAfter ? "(?:.*/)?" : ".*";
|
|
59
|
+
i += slashAfter ? 2 : 1;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
re += "[^/]*";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else if (c === "?") {
|
|
66
|
+
re += "[^/]";
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return new RegExp(`^${re}$`);
|
|
73
|
+
}
|
|
74
|
+
function suffixes(path) {
|
|
75
|
+
const parts = path.split("/").filter(Boolean);
|
|
76
|
+
const out = [];
|
|
77
|
+
for (let i = parts.length - 1; i >= 0; i--)
|
|
78
|
+
out.push(parts.slice(i).join("/"));
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/** True when any touched file matches any of the rule's patterns. */
|
|
82
|
+
export function ruleWasLoaded(patterns, touched) {
|
|
83
|
+
const regexes = patterns.flatMap(expandBraces).filter((p) => p.trim().length > 0).map(globToRegExp);
|
|
84
|
+
if (regexes.length === 0)
|
|
85
|
+
return true; // an empty scope is no scope: treat as always loaded
|
|
86
|
+
for (const path of touched) {
|
|
87
|
+
for (const s of suffixes(path)) {
|
|
88
|
+
if (regexes.some((r) => r.test(s)))
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
package/dist/evaluate.js
CHANGED
|
@@ -9,6 +9,7 @@ import { runEmojiChecks } from "./checks/emojiOutput.js";
|
|
|
9
9
|
import { runAttributionChecks } from "./checks/attribution.js";
|
|
10
10
|
import { runApprovalGateChecks } from "./checks/approvalGate.js";
|
|
11
11
|
import { runJudgmentChecks } from "./checks/judgmentChecks.js";
|
|
12
|
+
import { touchedPaths, ruleWasLoaded } from "./checks/pathScope.js";
|
|
12
13
|
import { loadOverrides, ruleFingerprint, staleOverrides } from "./overrides.js";
|
|
13
14
|
/**
|
|
14
15
|
* Rules in, verdicts out — the whole pipeline, with no printing in it.
|
|
@@ -25,7 +26,8 @@ import { loadOverrides, ruleFingerprint, staleOverrides } from "./overrides.js";
|
|
|
25
26
|
*/
|
|
26
27
|
export async function evaluateSession(cwd, rules, events, llm, needsLlmResult) {
|
|
27
28
|
const overrides = loadOverrides(cwd);
|
|
28
|
-
const
|
|
29
|
+
const touched = touchedPaths(events);
|
|
30
|
+
const classified = classifyRules(rules).map((c) => {
|
|
29
31
|
const decision = overrides.get(ruleFingerprint(c.rule))?.decision;
|
|
30
32
|
if (!decision)
|
|
31
33
|
return c;
|
|
@@ -33,6 +35,23 @@ export async function evaluateSession(cwd, rules, events, llm, needsLlmResult) {
|
|
|
33
35
|
return { kind: "notARule", rule: c.rule };
|
|
34
36
|
return c.kind === "notARule" ? { kind: "judgment", rule: c.rule } : c;
|
|
35
37
|
});
|
|
38
|
+
// A path-scoped rule (paths:/globs: frontmatter) is only loaded by the agent
|
|
39
|
+
// once the session touches a matching file. One the session never touched is
|
|
40
|
+
// reported not_applicable with the reason — never judged, so the report
|
|
41
|
+
// still lists every rule. Over-matches toward "loaded" (see pathScope.ts),
|
|
42
|
+
// so it can only ever REMOVE a false accusation, never hide a real one.
|
|
43
|
+
const notLoaded = classified.filter((c) => c.kind !== "notARule" && c.rule.paths && !ruleWasLoaded(c.rule.paths, touched));
|
|
44
|
+
const classifications = classified.filter((c) => !notLoaded.includes(c));
|
|
45
|
+
const scopeResults = notLoaded.map(({ rule }) => ({
|
|
46
|
+
ruleId: rule.id,
|
|
47
|
+
ruleTitle: rule.title,
|
|
48
|
+
ruleSource: rule.source,
|
|
49
|
+
status: "UNCLEAR",
|
|
50
|
+
outcome: "not_applicable",
|
|
51
|
+
method: "file_events",
|
|
52
|
+
reason: "path_scope_not_loaded",
|
|
53
|
+
evidence: `path-scoped rule (${rule.paths.join(", ")}): this session touched no matching file, so Claude never loaded it`,
|
|
54
|
+
}));
|
|
36
55
|
const of = (kind) => classifications.filter((c) => c.kind === kind);
|
|
37
56
|
const deterministicResults = [
|
|
38
57
|
...runDeterministicChecks(of("deterministic"), events),
|
|
@@ -50,7 +69,7 @@ export async function evaluateSession(cwd, rules, events, llm, needsLlmResult) {
|
|
|
50
69
|
? await runJudgmentChecks(judgment, events)
|
|
51
70
|
: judgment.map(({ rule }) => needsLlmResult(rule));
|
|
52
71
|
return {
|
|
53
|
-
results: [...deterministicResults, ...judgmentResults],
|
|
72
|
+
results: [...deterministicResults, ...judgmentResults, ...scopeResults],
|
|
54
73
|
notARule: of("notARule"),
|
|
55
74
|
stale: staleOverrides(overrides, rules),
|
|
56
75
|
};
|
|
@@ -1,2 +1,13 @@
|
|
|
1
1
|
import type { Rule } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reads the path scope out of a leading frontmatter block, if it has one.
|
|
4
|
+
*
|
|
5
|
+
* Shapes seen in 343 real rule files (2026-09-28): `paths:` as a YAML list
|
|
6
|
+
* or inline array (Claude Code), `globs:` as inline array, comma list, bare
|
|
7
|
+
* scalar or YAML list (Cursor, agy). `alwaysApply: true` (Cursor) and
|
|
8
|
+
* `trigger: always_on` (agy) mean the file is always loaded whatever its
|
|
9
|
+
* globs say, so they clear the scope. Anything unreadable returns undefined,
|
|
10
|
+
* which means "always loaded": the pre-existing behaviour.
|
|
11
|
+
*/
|
|
12
|
+
export declare function readPathScope(raw: string): string[] | undefined;
|
|
2
13
|
export declare function parseClaudeMd(filePath: string, source: "global" | "project"): Rule[];
|
|
@@ -36,6 +36,51 @@ function stripFrontmatter(raw) {
|
|
|
36
36
|
const after = raw.indexOf("\n", end + 1);
|
|
37
37
|
return after === -1 ? "" : raw.slice(after + 1);
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Reads the path scope out of a leading frontmatter block, if it has one.
|
|
41
|
+
*
|
|
42
|
+
* Shapes seen in 343 real rule files (2026-09-28): `paths:` as a YAML list
|
|
43
|
+
* or inline array (Claude Code), `globs:` as inline array, comma list, bare
|
|
44
|
+
* scalar or YAML list (Cursor, agy). `alwaysApply: true` (Cursor) and
|
|
45
|
+
* `trigger: always_on` (agy) mean the file is always loaded whatever its
|
|
46
|
+
* globs say, so they clear the scope. Anything unreadable returns undefined,
|
|
47
|
+
* which means "always loaded": the pre-existing behaviour.
|
|
48
|
+
*/
|
|
49
|
+
export function readPathScope(raw) {
|
|
50
|
+
if (!/^---\r?\n/.test(raw))
|
|
51
|
+
return undefined;
|
|
52
|
+
const end = raw.indexOf("\n---", 3);
|
|
53
|
+
if (end === -1)
|
|
54
|
+
return undefined;
|
|
55
|
+
const lines = raw.slice(raw.indexOf("\n") + 1, end).split(/\r?\n/);
|
|
56
|
+
if (lines.some((l) => /^alwaysApply:\s*true\b/i.test(l) || /^trigger:\s*always_on\b/i.test(l)))
|
|
57
|
+
return undefined;
|
|
58
|
+
const unquote = (v) => v.trim().replace(/^["']|["']$/g, "").trim();
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
const m = lines[i].match(/^(paths|globs):\s*(.*)$/);
|
|
61
|
+
if (!m)
|
|
62
|
+
continue;
|
|
63
|
+
const value = m[2].trim();
|
|
64
|
+
let items = [];
|
|
65
|
+
if (value.startsWith("[")) {
|
|
66
|
+
items = value.replace(/^\[|\]$/g, "").split(",").map(unquote);
|
|
67
|
+
}
|
|
68
|
+
else if (value.length > 0) {
|
|
69
|
+
items = value.split(",").map(unquote);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
for (let j = i + 1; j < lines.length && /^\s*-\s+/.test(lines[j]); j++) {
|
|
73
|
+
items.push(unquote(lines[j].replace(/^\s*-\s+/, "")));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
items = items.filter((x) => x.length > 0);
|
|
77
|
+
// `**/*` or `*` scopes to everything: same as no scope.
|
|
78
|
+
if (items.length === 0 || items.some((x) => x === "**/*" || x === "**" || x === "*"))
|
|
79
|
+
return undefined;
|
|
80
|
+
return items;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
39
84
|
export function parseClaudeMd(filePath, source) {
|
|
40
85
|
let raw;
|
|
41
86
|
try {
|
|
@@ -44,5 +89,7 @@ export function parseClaudeMd(filePath, source) {
|
|
|
44
89
|
catch {
|
|
45
90
|
return [];
|
|
46
91
|
}
|
|
47
|
-
|
|
92
|
+
const rules = parseClaudeMdText(stripFrontmatter(raw), source);
|
|
93
|
+
const paths = readPathScope(raw);
|
|
94
|
+
return paths ? rules.map((r) => ({ ...r, paths })) : rules;
|
|
48
95
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -3,6 +3,14 @@ export interface Rule {
|
|
|
3
3
|
title: string;
|
|
4
4
|
text: string;
|
|
5
5
|
source: "global" | "project";
|
|
6
|
+
/**
|
|
7
|
+
* Path scope from the rules file's frontmatter (`paths:` in Claude Code
|
|
8
|
+
* `.claude/rules/*.md`, `globs:` in Cursor `.mdc` / agy `.agents/rules`).
|
|
9
|
+
* Claude Code only loads a path-scoped rule once the session touches a
|
|
10
|
+
* matching file, so judging it in a session that never did checks Claude
|
|
11
|
+
* against a rule it was never shown. Absent = always loaded.
|
|
12
|
+
*/
|
|
13
|
+
paths?: string[];
|
|
6
14
|
}
|
|
7
15
|
export interface TranscriptTextEvent {
|
|
8
16
|
role: "user" | "assistant";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.59",
|
|
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",
|