rulereceipt 0.1.58 → 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/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.58",
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",