rulereceipt 0.1.59 → 0.1.60
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 +24 -0
- package/dist/audit.d.ts +29 -0
- package/dist/audit.js +203 -0
- package/dist/checks/approvalGate.d.ts +42 -1
- package/dist/checks/approvalGate.js +128 -84
- package/dist/checks/classify.d.ts +13 -0
- package/dist/checks/classify.js +43 -14
- package/dist/cli.js +74 -77
- package/dist/evaluate.js +33 -3
- package/dist/guard.d.ts +10 -2
- package/dist/guard.js +45 -5
- package/dist/parsers/claudeMdParser.js +19 -1
- package/dist/parsers/transcriptParser.js +27 -1
- package/dist/report/generateHtmlReport.js +9 -4
- package/dist/rules.d.ts +49 -0
- package/dist/rules.js +138 -52
- package/dist/types.d.ts +8 -0
- package/dist/wrong.d.ts +57 -0
- package/dist/wrong.js +156 -0
- package/package.json +1 -1
package/dist/rules.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { dirname, join, parse, resolve } from "node:path";
|
|
3
|
-
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { parseClaudeMd } from "./parsers/readClaudeMd.js";
|
|
5
5
|
import { findClaudeHomeDirNames } from "./parsers/transcriptParser.js";
|
|
6
6
|
import { loadMemoryRules } from "./parsers/readMemory.js";
|
|
@@ -41,65 +41,105 @@ function markdownFilesIn(dir, exts = [".md"]) {
|
|
|
41
41
|
return [];
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Every candidate rules file at one directory level, in documented load order,
|
|
46
|
+
* each tagged loaded or shadowed. This is the single source of truth for both
|
|
47
|
+
* what gets checked (`ruleFilesAtLevel`, the loaded subset) and the load graph
|
|
48
|
+
* (`describeRuleSources`) — so the report can never claim a file was loaded
|
|
49
|
+
* that the checker skipped, or vice versa.
|
|
50
|
+
*/
|
|
51
|
+
function ruleSourcesAtLevel(dir) {
|
|
52
|
+
const out = [];
|
|
52
53
|
const has = (rel) => existsSync(join(dir, rel));
|
|
54
|
+
const loaded = (rel, format) => {
|
|
55
|
+
if (has(rel))
|
|
56
|
+
out.push({ path: join(dir, rel), status: "loaded", format });
|
|
57
|
+
};
|
|
58
|
+
const shadowed = (rel, format, note) => {
|
|
59
|
+
if (has(rel))
|
|
60
|
+
out.push({ path: join(dir, rel), status: "shadowed", format, note });
|
|
61
|
+
};
|
|
53
62
|
// CLAUDE.md shadows AGENTS.md at the same level: as of 2026-09-19 Claude
|
|
54
63
|
// Code loads AGENTS.md ONLY when that level has no CLAUDE.md, and silently
|
|
55
|
-
// ignores it otherwise.
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (!has("CLAUDE.md")) {
|
|
68
|
-
push("AGENTS.md");
|
|
69
|
-
push("AGENT.md");
|
|
64
|
+
// ignores it otherwise. `init` separately WARNS about the shadowed file (see
|
|
65
|
+
// shadowedAgents.ts). Mirrored for the `.claude/` subdir pair.
|
|
66
|
+
if (has(join(".claude", "CLAUDE.md"))) {
|
|
67
|
+
loaded(join(".claude", "CLAUDE.md"), "Claude (.claude/CLAUDE.md)");
|
|
68
|
+
shadowed(join(".claude", "AGENTS.md"), "AGENTS (.claude/AGENTS.md)", "a CLAUDE.md at the same level wins");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
loaded(join(".claude", "AGENTS.md"), "AGENTS (.claude/AGENTS.md)");
|
|
72
|
+
}
|
|
73
|
+
for (const rel of RULE_DIRS) {
|
|
74
|
+
for (const f of markdownFilesIn(join(dir, rel)))
|
|
75
|
+
out.push({ path: f, status: "loaded", format: ".claude/rules" });
|
|
70
76
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
if (has("CLAUDE.md")) {
|
|
78
|
+
loaded("CLAUDE.md", "Claude (CLAUDE.md)");
|
|
79
|
+
// AGENTS.md (and the singular AGENT.md some tools use) are ignored when
|
|
80
|
+
// there's a CLAUDE.md at this level, mirroring Claude Code's shadow rule.
|
|
81
|
+
shadowed("AGENTS.md", "AGENTS.md", "a CLAUDE.md at the same level wins");
|
|
82
|
+
shadowed("AGENT.md", "AGENT.md", "a CLAUDE.md at the same level wins");
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
loaded("AGENTS.md", "AGENTS.md");
|
|
86
|
+
loaded("AGENT.md", "AGENT.md");
|
|
87
|
+
}
|
|
88
|
+
// .local variants: precedence relative to the base files is not documented,
|
|
89
|
+
// so both are kept rather than guessing at a shadow rule.
|
|
90
|
+
loaded("CLAUDE.local.md", "Claude (CLAUDE.local.md)");
|
|
91
|
+
loaded("AGENTS.local.md", "AGENTS (AGENTS.local.md)");
|
|
75
92
|
// Non-Claude rule-file conventions (added 2026-09-26 for multi-tool
|
|
76
|
-
// support)
|
|
77
|
-
//
|
|
78
|
-
// wrote is a rule to check, and silently ignoring one is the "clean report
|
|
79
|
-
// on rules never opened" failure this module already guards against. The
|
|
80
|
-
// engine (classify.ts) is agent-neutral, so it does not matter which tool a
|
|
81
|
-
// rule was authored for. Precedence is FIXED and documented so rule ids stay
|
|
93
|
+
// support), read IN ADDITION to Claude's files when present. The engine
|
|
94
|
+
// (classify.ts) is agent-neutral. Precedence is FIXED so rule ids stay
|
|
82
95
|
// deterministic: Claude family (above), then Cursor, Copilot, Windsurf.
|
|
83
96
|
//
|
|
84
97
|
// Cursor: the modern `.cursor/rules/*.mdc|.md` directory SHADOWS the legacy
|
|
85
|
-
// single `.cursorrules` file — Cursor
|
|
86
|
-
//
|
|
87
|
-
// shadow shape as CLAUDE.md over AGENTS.md above.
|
|
98
|
+
// single `.cursorrules` file — Cursor deprecated `.cursorrules` in favour of
|
|
99
|
+
// the directory, so reading both would double-count.
|
|
88
100
|
const cursorRules = markdownFilesIn(join(dir, ".cursor", "rules"), [".mdc", ".md"]);
|
|
89
|
-
if (cursorRules.length > 0)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
if (cursorRules.length > 0) {
|
|
102
|
+
for (const f of cursorRules)
|
|
103
|
+
out.push({ path: f, status: "loaded", format: "Cursor (.cursor/rules)" });
|
|
104
|
+
shadowed(".cursorrules", "Cursor (.cursorrules)", "the .cursor/rules/ directory supersedes the legacy file");
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
loaded(".cursorrules", "Cursor (.cursorrules)");
|
|
108
|
+
}
|
|
93
109
|
// GitHub Copilot: repo-level custom instructions.
|
|
94
|
-
|
|
110
|
+
loaded(join(".github", "copilot-instructions.md"), "Copilot");
|
|
95
111
|
// Windsurf (Codeium): single rules file.
|
|
96
|
-
|
|
97
|
-
// Google's newer agent convention
|
|
98
|
-
//
|
|
99
|
-
|
|
112
|
+
loaded(".windsurfrules", "Windsurf");
|
|
113
|
+
// Google's newer agent convention / "agents rules": .agents/rules/*.md, each
|
|
114
|
+
// with a `trigger:` frontmatter block. The trigger decides whether the agent
|
|
115
|
+
// auto-loads the file, so it decides whether we may check against it:
|
|
116
|
+
// - always_on / glob (or no trigger) → loaded, checked
|
|
117
|
+
// - manual / model_decision → not auto-loaded → shadowed
|
|
118
|
+
// README fragments in the directory are notes, not rules, so they are skipped
|
|
119
|
+
// entirely (not even reported — they were never a candidate rule).
|
|
120
|
+
for (const file of markdownFilesIn(join(dir, ".agents", "rules"), [".md"])) {
|
|
121
|
+
if (/(^|[/\\])readme\.md$/i.test(file))
|
|
122
|
+
continue;
|
|
123
|
+
let head = "";
|
|
124
|
+
try {
|
|
125
|
+
head = readFileSync(file, "utf-8").slice(0, 600);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (/^---[\s\S]*?^\s*trigger:\s*(?:manual|model_decision)\b/m.test(head)) {
|
|
131
|
+
out.push({ path: file, status: "shadowed", format: ".agents/rules", note: "trigger is manual/model_decision — the agent does not auto-load it" });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
out.push({ path: file, status: "loaded", format: ".agents/rules" });
|
|
135
|
+
}
|
|
100
136
|
// Gemini CLI: single rules file (its AGENTS.md equivalent).
|
|
101
|
-
|
|
102
|
-
return
|
|
137
|
+
loaded("GEMINI.md", "Gemini");
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
/** Every rules file at one directory level that the agent actually loads. */
|
|
141
|
+
function ruleFilesAtLevel(dir) {
|
|
142
|
+
return ruleSourcesAtLevel(dir).filter((s) => s.status === "loaded").map((s) => s.path);
|
|
103
143
|
}
|
|
104
144
|
/**
|
|
105
145
|
* Walks from the working directory up toward the repository root,
|
|
@@ -116,8 +156,8 @@ function ruleFilesAtLevel(dir) {
|
|
|
116
156
|
* or the home directory — is never pulled into an unrelated project.
|
|
117
157
|
* Global rules are handled separately, deliberately, below.
|
|
118
158
|
*/
|
|
119
|
-
function
|
|
120
|
-
const
|
|
159
|
+
function projectLevels(cwd) {
|
|
160
|
+
const levels = [];
|
|
121
161
|
const { root } = parse(cwd);
|
|
122
162
|
const home = homedir();
|
|
123
163
|
let dir = cwd;
|
|
@@ -131,7 +171,7 @@ function findProjectRuleFiles(cwd) {
|
|
|
131
171
|
// it; loadRules dedupes that against the global pass.
|
|
132
172
|
if (dir === home && dir !== cwd)
|
|
133
173
|
break;
|
|
134
|
-
|
|
174
|
+
levels.push(dir);
|
|
135
175
|
// stop AT the repo root (inclusive) — its rules do apply
|
|
136
176
|
if (existsSync(join(dir, ".git")))
|
|
137
177
|
break;
|
|
@@ -142,7 +182,10 @@ function findProjectRuleFiles(cwd) {
|
|
|
142
182
|
break;
|
|
143
183
|
dir = parent;
|
|
144
184
|
}
|
|
145
|
-
return
|
|
185
|
+
return levels;
|
|
186
|
+
}
|
|
187
|
+
function findProjectRuleFiles(cwd) {
|
|
188
|
+
return projectLevels(cwd).flatMap((dir) => ruleFilesAtLevel(dir));
|
|
146
189
|
}
|
|
147
190
|
/**
|
|
148
191
|
* Global rules come from every .claude*-prefixed home dir found, not just
|
|
@@ -189,3 +232,46 @@ export function loadRules(cwd) {
|
|
|
189
232
|
rules.push(...loadMemoryRules(cwd));
|
|
190
233
|
return rules;
|
|
191
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* The load graph: every candidate rules file the discovery walk sees, in the
|
|
237
|
+
* same order and with the same dedup as `loadRules`, tagged loaded or shadowed
|
|
238
|
+
* and counted. This is what `audit` prints so "why isn't my rule firing?" has
|
|
239
|
+
* an honest, file-level answer — built on the SAME `ruleSourcesAtLevel` the
|
|
240
|
+
* checker's own discovery uses, so the graph can never claim a file was loaded
|
|
241
|
+
* that the checker skipped.
|
|
242
|
+
*
|
|
243
|
+
* Memory rules are deliberately NOT listed here: this graph is about files on
|
|
244
|
+
* disk a user can point at, and memory is summarised separately by the caller.
|
|
245
|
+
*/
|
|
246
|
+
export function describeRuleSources(cwd) {
|
|
247
|
+
const entries = [];
|
|
248
|
+
const seen = new Set();
|
|
249
|
+
const add = (src, scope) => {
|
|
250
|
+
const key = resolve(src.path);
|
|
251
|
+
if (seen.has(key))
|
|
252
|
+
return;
|
|
253
|
+
seen.add(key);
|
|
254
|
+
let ruleCount = 0;
|
|
255
|
+
try {
|
|
256
|
+
ruleCount = parseClaudeMd(src.path, scope).length;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
/* unreadable: reported with count 0 rather than dropped */
|
|
260
|
+
}
|
|
261
|
+
entries.push({ path: src.path, scope, status: src.status, format: src.format, note: src.note, ruleCount });
|
|
262
|
+
};
|
|
263
|
+
// Globals first, so a file reachable both ways keeps its "global" label —
|
|
264
|
+
// mirrors loadRules' dedup order exactly.
|
|
265
|
+
for (const dirName of findClaudeHomeDirNames()) {
|
|
266
|
+
const base = join(homedir(), dirName);
|
|
267
|
+
if (existsSync(join(base, "CLAUDE.md")))
|
|
268
|
+
add({ path: join(base, "CLAUDE.md"), status: "loaded", format: "Claude (global CLAUDE.md)" }, "global");
|
|
269
|
+
for (const file of markdownFilesIn(join(base, "rules")))
|
|
270
|
+
add({ path: file, status: "loaded", format: "Claude (global rules)" }, "global");
|
|
271
|
+
}
|
|
272
|
+
for (const dir of projectLevels(cwd)) {
|
|
273
|
+
for (const src of ruleSourcesAtLevel(dir))
|
|
274
|
+
add(src, "project");
|
|
275
|
+
}
|
|
276
|
+
return entries;
|
|
277
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -35,6 +35,14 @@ export interface TranscriptToolUseEvent {
|
|
|
35
35
|
* the test run that a completion claim depended on.
|
|
36
36
|
*/
|
|
37
37
|
toolUseId?: string;
|
|
38
|
+
/**
|
|
39
|
+
* The Claude Code permission mode in force when this call was made, when the
|
|
40
|
+
* transcript records one (`permissionMode` on user turns). Added 2026-09-28
|
|
41
|
+
* for the approval check: in `default`/`acceptEdits`/`plan` a shell command
|
|
42
|
+
* may have been approved in the permission prompt, which leaves no trace in
|
|
43
|
+
* the transcript; in `bypassPermissions`/`dontAsk`/`auto` no person was asked.
|
|
44
|
+
*/
|
|
45
|
+
permissionMode?: string;
|
|
38
46
|
}
|
|
39
47
|
export interface TranscriptToolResultEvent {
|
|
40
48
|
role: "user";
|
package/dist/wrong.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CheckResult, Rule, TranscriptEvent } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* `rulereceipt wrong <rule>` — turn "this verdict is wrong" into a report
|
|
4
|
+
* someone can actually file, without sending anything anywhere.
|
|
5
|
+
*
|
|
6
|
+
* Added 2026-09-28. The "A result looks wrong" issue template already asked the
|
|
7
|
+
* right questions, but nothing in the tool pointed to it, and filling it meant
|
|
8
|
+
* copying the rule, the verdict and the evidence by hand. A user who sees a
|
|
9
|
+
* wrong verdict and has to do that mostly uninstalls instead, and the project
|
|
10
|
+
* loses the one kind of report every accuracy fix has come from.
|
|
11
|
+
*
|
|
12
|
+
* Privacy: nothing is sent. The report is written to a local file and shown
|
|
13
|
+
* first; the issue link is printed for the user to open themselves. Obvious
|
|
14
|
+
* secrets, the home directory and email addresses are masked before either is
|
|
15
|
+
* produced, and the user is told to read it before sharing.
|
|
16
|
+
*/
|
|
17
|
+
export declare const ISSUE_BASE = "https://github.com/rulereceipt/rulereceipt/issues/new";
|
|
18
|
+
/** The dropdown options of .github/ISSUE_TEMPLATE/wrong-result.yml, verbatim. */
|
|
19
|
+
export declare function reportedLabel(r: CheckResult): string;
|
|
20
|
+
/** Masks the things most likely to be private. Not a guarantee — the user is told to read it. */
|
|
21
|
+
export declare function redact(text: string, home?: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* A few events around the one the evidence quotes, so the reader can see what
|
|
24
|
+
* happened just before and after. Found by the longest quoted fragment of the
|
|
25
|
+
* evidence; if nothing matches, no excerpt (never a guessed one).
|
|
26
|
+
*/
|
|
27
|
+
export declare function excerptAround(events: TranscriptEvent[], evidence: string, radius?: number): string[];
|
|
28
|
+
export interface WrongReportInput {
|
|
29
|
+
version: string;
|
|
30
|
+
rule: Rule;
|
|
31
|
+
result: CheckResult;
|
|
32
|
+
events: TranscriptEvent[];
|
|
33
|
+
withContext?: boolean;
|
|
34
|
+
home?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface WrongReport {
|
|
37
|
+
handle: string;
|
|
38
|
+
markdown: string;
|
|
39
|
+
issueUrl: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function buildWrongReport(input: WrongReportInput): WrongReport;
|
|
42
|
+
/**
|
|
43
|
+
* The link for a shared report (HTML). No rule text and no evidence in it: that
|
|
44
|
+
* report is often sent to someone else, and a link should not leak what the
|
|
45
|
+
* page deliberately shows only to its reader.
|
|
46
|
+
*/
|
|
47
|
+
export declare function minimalIssueUrl(result: CheckResult, version: string): string;
|
|
48
|
+
/** Finds the result a user means: by 12-char handle, or by id when that id is unique. */
|
|
49
|
+
export declare function findTarget(query: string, rules: Rule[], results: CheckResult[]): {
|
|
50
|
+
rule: Rule;
|
|
51
|
+
result: CheckResult;
|
|
52
|
+
} | {
|
|
53
|
+
ambiguous: Array<{
|
|
54
|
+
handle: string;
|
|
55
|
+
title: string;
|
|
56
|
+
}>;
|
|
57
|
+
} | null;
|
package/dist/wrong.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { ruleFingerprint } from "./overrides.js";
|
|
3
|
+
/**
|
|
4
|
+
* `rulereceipt wrong <rule>` — turn "this verdict is wrong" into a report
|
|
5
|
+
* someone can actually file, without sending anything anywhere.
|
|
6
|
+
*
|
|
7
|
+
* Added 2026-09-28. The "A result looks wrong" issue template already asked the
|
|
8
|
+
* right questions, but nothing in the tool pointed to it, and filling it meant
|
|
9
|
+
* copying the rule, the verdict and the evidence by hand. A user who sees a
|
|
10
|
+
* wrong verdict and has to do that mostly uninstalls instead, and the project
|
|
11
|
+
* loses the one kind of report every accuracy fix has come from.
|
|
12
|
+
*
|
|
13
|
+
* Privacy: nothing is sent. The report is written to a local file and shown
|
|
14
|
+
* first; the issue link is printed for the user to open themselves. Obvious
|
|
15
|
+
* secrets, the home directory and email addresses are masked before either is
|
|
16
|
+
* produced, and the user is told to read it before sharing.
|
|
17
|
+
*/
|
|
18
|
+
export const ISSUE_BASE = "https://github.com/rulereceipt/rulereceipt/issues/new";
|
|
19
|
+
/** The dropdown options of .github/ISSUE_TEMPLATE/wrong-result.yml, verbatim. */
|
|
20
|
+
export function reportedLabel(r) {
|
|
21
|
+
if (r.status === "FAIL")
|
|
22
|
+
return "Not followed";
|
|
23
|
+
if (r.status === "PASS")
|
|
24
|
+
return "Followed";
|
|
25
|
+
if (r.needsHuman || r.outcome === "not_run")
|
|
26
|
+
return "Needs your judgment";
|
|
27
|
+
return "Couldn't tell";
|
|
28
|
+
}
|
|
29
|
+
const SECRET_PATTERNS = [
|
|
30
|
+
[/\bsk-[A-Za-z0-9_-]{16,}/g, "<redacted-key>"],
|
|
31
|
+
[/\b(?:ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{16,}/g, "<redacted-token>"],
|
|
32
|
+
[/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "<redacted-token>"],
|
|
33
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, "<redacted-aws-key>"],
|
|
34
|
+
[/\b(?:Bearer|token|apikey|api_key|password|passwd|secret)(\s*[:=]\s*|\s+)["']?[^\s"']{6,}/gi, "$1<redacted>"],
|
|
35
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "<redacted-private-key>"],
|
|
36
|
+
[/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "<email>"],
|
|
37
|
+
];
|
|
38
|
+
/** Masks the things most likely to be private. Not a guarantee — the user is told to read it. */
|
|
39
|
+
export function redact(text, home = homedir()) {
|
|
40
|
+
let out = text;
|
|
41
|
+
if (home && home.length > 1)
|
|
42
|
+
out = out.split(home).join("~");
|
|
43
|
+
for (const [re, rep] of SECRET_PATTERNS)
|
|
44
|
+
out = out.replace(re, rep);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
function eventLine(e) {
|
|
48
|
+
if (e.kind === "text")
|
|
49
|
+
return `${e.role}: ${e.text}`;
|
|
50
|
+
if (e.kind === "tool_use") {
|
|
51
|
+
const input = e.input;
|
|
52
|
+
const main = input?.command ?? input?.file_path ?? input?.notebook_path ?? JSON.stringify(input ?? {});
|
|
53
|
+
return `assistant ran ${e.toolName}: ${String(main)}`;
|
|
54
|
+
}
|
|
55
|
+
return `tool result${e.isError ? " (error)" : ""}: ${e.content}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A few events around the one the evidence quotes, so the reader can see what
|
|
59
|
+
* happened just before and after. Found by the longest quoted fragment of the
|
|
60
|
+
* evidence; if nothing matches, no excerpt (never a guessed one).
|
|
61
|
+
*/
|
|
62
|
+
export function excerptAround(events, evidence, radius = 2) {
|
|
63
|
+
const quoted = [...evidence.matchAll(/"([^"]{6,})"/g)].map((m) => m[1]);
|
|
64
|
+
const after = evidence.split(/:\s/).slice(1).join(": ");
|
|
65
|
+
const needles = [...quoted, after].map((s) => s.trim().slice(0, 60)).filter((s) => s.length >= 6).sort((a, b) => b.length - a.length);
|
|
66
|
+
if (needles.length === 0)
|
|
67
|
+
return [];
|
|
68
|
+
// Evidence strings collapse whitespace; the session keeps newlines. Compare both the same way.
|
|
69
|
+
const flat = (t) => t.replace(/\s+/g, " ");
|
|
70
|
+
const idx = events.findIndex((e) => needles.some((n) => flat(eventLine(e)).includes(flat(n))));
|
|
71
|
+
if (idx === -1)
|
|
72
|
+
return [];
|
|
73
|
+
return events.slice(Math.max(0, idx - radius), idx + radius + 1).map((e) => eventLine(e).replace(/\s+/g, " ").slice(0, 240));
|
|
74
|
+
}
|
|
75
|
+
const MAX_URL = 7500;
|
|
76
|
+
export function buildWrongReport(input) {
|
|
77
|
+
const { version, rule, result, events } = input;
|
|
78
|
+
const r = (s) => redact(s, input.home);
|
|
79
|
+
const handle = ruleFingerprint(rule);
|
|
80
|
+
const ruleText = r(rule.text && rule.text !== rule.title ? `${rule.title}\n${rule.text}` : rule.title);
|
|
81
|
+
const reported = reportedLabel(result);
|
|
82
|
+
const excerpt = input.withContext === false ? [] : excerptAround(events, result.evidence).map(r);
|
|
83
|
+
const how = [
|
|
84
|
+
result.method ? `method: ${result.method}` : "",
|
|
85
|
+
result.outcome ? `outcome: ${result.outcome}` : "",
|
|
86
|
+
result.reason ? `reason: ${result.reason}` : "",
|
|
87
|
+
result.ceiling ? `limits: ${r(result.ceiling)}` : "",
|
|
88
|
+
].filter(Boolean);
|
|
89
|
+
const markdown = [
|
|
90
|
+
`# Wrong verdict report (rulereceipt ${version})`,
|
|
91
|
+
"",
|
|
92
|
+
"> Read this before sharing. Obvious secrets, your home path and email addresses were masked,",
|
|
93
|
+
"> but rule text and session lines are quoted as they are. Edit anything private.",
|
|
94
|
+
"",
|
|
95
|
+
`**Rule handle:** \`${handle}\` (id ${result.ruleId}, ${result.ruleSource})`,
|
|
96
|
+
"",
|
|
97
|
+
"## The rule",
|
|
98
|
+
"```",
|
|
99
|
+
ruleText,
|
|
100
|
+
"```",
|
|
101
|
+
"",
|
|
102
|
+
`## What RuleReceipt reported: ${reported}`,
|
|
103
|
+
"",
|
|
104
|
+
"```",
|
|
105
|
+
r(result.evidence),
|
|
106
|
+
"```",
|
|
107
|
+
...(how.length ? ["", ...how.map((h) => `- ${h}`)] : []),
|
|
108
|
+
...(excerpt.length ? ["", "## Session lines around it", "```", ...excerpt, "```"] : []),
|
|
109
|
+
"",
|
|
110
|
+
"## What you expected instead",
|
|
111
|
+
"",
|
|
112
|
+
"_(write it here)_",
|
|
113
|
+
"",
|
|
114
|
+
].join("\n");
|
|
115
|
+
const params = new URLSearchParams({
|
|
116
|
+
template: "wrong-result.yml",
|
|
117
|
+
title: `Wrong verdict: ${reported} on "${r(rule.title).slice(0, 60)}"`,
|
|
118
|
+
rule: ruleText,
|
|
119
|
+
reported,
|
|
120
|
+
evidence: r(result.evidence),
|
|
121
|
+
version,
|
|
122
|
+
});
|
|
123
|
+
let issueUrl = `${ISSUE_BASE}?${params.toString()}`;
|
|
124
|
+
if (issueUrl.length > MAX_URL) {
|
|
125
|
+
params.set("rule", ruleText.slice(0, 1500));
|
|
126
|
+
params.set("evidence", r(result.evidence).slice(0, 1500));
|
|
127
|
+
issueUrl = `${ISSUE_BASE}?${params.toString()}`;
|
|
128
|
+
}
|
|
129
|
+
return { handle, markdown, issueUrl };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The link for a shared report (HTML). No rule text and no evidence in it: that
|
|
133
|
+
* report is often sent to someone else, and a link should not leak what the
|
|
134
|
+
* page deliberately shows only to its reader.
|
|
135
|
+
*/
|
|
136
|
+
export function minimalIssueUrl(result, version) {
|
|
137
|
+
const params = new URLSearchParams({ template: "wrong-result.yml", reported: reportedLabel(result), version });
|
|
138
|
+
return `${ISSUE_BASE}?${params.toString()}`;
|
|
139
|
+
}
|
|
140
|
+
/** Finds the result a user means: by 12-char handle, or by id when that id is unique. */
|
|
141
|
+
export function findTarget(query, rules, results) {
|
|
142
|
+
const pairs = results
|
|
143
|
+
.map((result) => ({ result, rule: rules.find((ru) => ru.source === result.ruleSource && ru.id === result.ruleId && ru.title === result.ruleTitle) }))
|
|
144
|
+
.filter((p) => Boolean(p.rule));
|
|
145
|
+
const q = query.trim();
|
|
146
|
+
const byHandle = pairs.filter((p) => ruleFingerprint(p.rule) === q || (q.length >= 6 && ruleFingerprint(p.rule).startsWith(q)));
|
|
147
|
+
if (byHandle.length === 1)
|
|
148
|
+
return byHandle[0];
|
|
149
|
+
const byId = pairs.filter((p) => p.result.ruleId === q);
|
|
150
|
+
if (byId.length === 1)
|
|
151
|
+
return byId[0];
|
|
152
|
+
const many = byHandle.length > 1 ? byHandle : byId;
|
|
153
|
+
if (many.length > 1)
|
|
154
|
+
return { ambiguous: many.map((p) => ({ handle: ruleFingerprint(p.rule), title: p.rule.title })) };
|
|
155
|
+
return null;
|
|
156
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.60",
|
|
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",
|