rulereceipt 0.1.30 → 0.1.32

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.
@@ -18,6 +18,45 @@ function eventSearchText(event) {
18
18
  }
19
19
  return "";
20
20
  }
21
+ /**
22
+ * Standard short spellings of destructive flags, canonicalised before
23
+ * matching.
24
+ *
25
+ * Without this, a prohibition returned PASS whenever its exact literal was
26
+ * absent — so a rule banning `git push --force` printed "no occurrence
27
+ * found" for a session that ran `git push -f`. Found 2026-09-08. That is
28
+ * the inverse of the false-FAIL problem in the postmortem and the more
29
+ * dangerous direction: a wrong FAIL sends someone to check the evidence, a
30
+ * wrong PASS stops them looking.
31
+ *
32
+ * Deliberately three entries, not an open-ended table. These are stable
33
+ * decades-old CLI conventions for the specific destructive operations rules
34
+ * actually ban. Expansion is gated on the COMMAND CONTEXT, because a bare
35
+ * `-f` means something different everywhere: `grep -f patterns.txt` is not
36
+ * forcing anything, and rewriting it would invent a violation.
37
+ *
38
+ * `--force-with-lease` must keep passing a `--force` ban — it is the safe
39
+ * command, and the trailing word boundary in matchesPattern already keeps
40
+ * the two apart.
41
+ */
42
+ const FLAG_ALIASES = [
43
+ { context: /\bgit\s+push\b/, short: /(?:^|\s)-[A-Za-z]*f/, canonical: "git push --force" },
44
+ { context: /\bgit\s+commit\b/, short: /(?:^|\s)-[A-Za-z]*n/, canonical: "git commit --no-verify" },
45
+ { context: /\brm\b/, short: /(?:^|\s)-(?:[A-Za-z]*r[A-Za-z]*f|[A-Za-z]*f[A-Za-z]*r)/, canonical: "rm -rf" },
46
+ ];
47
+ /**
48
+ * The text a pattern is matched against: the event's own text, plus the
49
+ * canonical spelling of any aliased flag it used. Kept separate from the
50
+ * text quoted back as evidence, so a report always shows what the session
51
+ * actually ran, never a canonical form it never typed.
52
+ */
53
+ function searchHaystack(event) {
54
+ const raw = eventSearchText(event);
55
+ if (event.kind !== "tool_use")
56
+ return raw;
57
+ const extra = FLAG_ALIASES.filter((a) => a.context.test(raw) && a.short.test(raw)).map((a) => a.canonical);
58
+ return extra.length > 0 ? `${raw} ${extra.join(" ")}` : raw;
59
+ }
21
60
  function escapeRegex(literal) {
22
61
  return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23
62
  }
@@ -56,7 +95,7 @@ export function runDeterministicChecks(classifications, events) {
56
95
  let foundEvent;
57
96
  let foundPattern;
58
97
  for (const event of events) {
59
- const haystack = eventSearchText(event);
98
+ const haystack = searchHaystack(event);
60
99
  for (const pattern of patterns) {
61
100
  if (matchesPattern(haystack, pattern)) {
62
101
  foundEvent = event;
@@ -96,7 +135,7 @@ export function runDeterministicChecks(classifications, events) {
96
135
  ruleTitle: rule.title,
97
136
  ruleSource: rule.source,
98
137
  status: "PASS",
99
- evidence: `no occurrence of ${patterns.map((p) => `"${p}"`).join(" or ")} found in this session`,
138
+ evidence: `no occurrence of ${patterns.map((p) => `"${p}"`).join(" or ")} in the commands and messages recorded this session — this is a text scan of the transcript, so it is evidence rather than proof: a spelling this checker does not know would not be caught`,
100
139
  };
101
140
  }
102
141
  // polarity === "require": absence is the failure, not presence. But
@@ -92,9 +92,12 @@ function ruleLabel(result, all) {
92
92
  function countBy(results, status) {
93
93
  return results.filter((r) => r.status === status).length;
94
94
  }
95
- function renderResultRow(result, all) {
95
+ function renderResultRow(result, all, hideEvidence) {
96
96
  const bucket = bucketOf(result);
97
97
  const cls = BUCKET_CLASS[bucket];
98
+ const evidence = !hideEvidence && result.evidence
99
+ ? `<p class="result__evidence">${clean(result.evidence)}</p>`
100
+ : "";
98
101
  return `
99
102
  <article class="result result--${cls}">
100
103
  <div class="result__head">
@@ -102,9 +105,30 @@ function renderResultRow(result, all) {
102
105
  <span class="result__id">${clean(ruleLabel(result, all))}</span>
103
106
  </div>
104
107
  <h3 class="result__title">${clean(result.ruleTitle)}</h3>
105
- ${result.evidence ? `<p class="result__evidence">${clean(result.evidence)}</p>` : ""}
108
+ ${evidence}
106
109
  </article>`;
107
110
  }
111
+ /**
112
+ * The explanation every rule in a section shares, or null when they differ.
113
+ *
114
+ * Judgment rules all carry the same sentence, because the reason is the
115
+ * same one every time. Rendered per-rule that produced thirteen copies of
116
+ * one 300-character paragraph on a real 14-rule file. Fixed in the terminal
117
+ * report first and missed here, which was the wrong half to miss — this is
118
+ * the report that gets sent to someone else.
119
+ *
120
+ * Conditional on the text being byte-identical: `--llm` gives each judgment
121
+ * rule its own model opinion, and hoisting those would delete the only
122
+ * per-rule content the section has.
123
+ */
124
+ function sharedEvidence(rs) {
125
+ if (rs.length < 2)
126
+ return null;
127
+ const first = rs[0].evidence;
128
+ if (!first)
129
+ return null;
130
+ return rs.every((r) => r.evidence === first) ? first : null;
131
+ }
108
132
  function renderSection(bucket, results, all) {
109
133
  const inSection = results.filter((r) => bucketOf(r) === bucket);
110
134
  if (inSection.length === 0)
@@ -112,11 +136,14 @@ function renderSection(bucket, results, all) {
112
136
  const note = bucket === "UNCLEAR_JUDGMENT"
113
137
  ? `<p class="section__note">These were never questions a tool could settle — they need someone to read the session and decide. That is expected, not a gap in the check.</p>`
114
138
  : "";
139
+ const shared = sharedEvidence(inSection);
140
+ const sharedBlock = shared ? `<p class="section__shared">${clean(shared)}</p>` : "";
115
141
  return `
116
142
  <section class="section">
117
143
  <h2 class="section__title">${clean(BUCKET_LABEL[bucket])} <span class="section__count">${inSection.length}</span></h2>
118
144
  ${note}
119
- ${inSection.map((r) => renderResultRow(r, all)).join("")}
145
+ ${sharedBlock}
146
+ ${inSection.map((r) => renderResultRow(r, all, shared !== null)).join("")}
120
147
  </section>`;
121
148
  }
122
149
  /**
@@ -202,6 +229,7 @@ export function generateHtmlReport(results, meta) {
202
229
  .result--judgment { border-left-color: #6b6f76; }
203
230
  .badge--judgment { background: #f2f3f5; color: #4a4e55; }
204
231
  .section__note { font-size: 13px; color: var(--muted); margin: -4px 0 12px; }
232
+ .section__shared { font-size: 13px; color: var(--muted); margin: 0 0 14px; padding: 10px 12px; border-left: 2px solid var(--line); background: var(--panel); border-radius: 0 6px 6px 0; white-space: pre-wrap; }
205
233
  .result__id { font-size: 12px; color: var(--muted); }
206
234
  .result__title { font-size: 15px; margin: 0 0 6px; font-weight: 600; }
207
235
  .result__evidence { margin: 0; font-size: 14px; color: var(--muted); white-space: pre-wrap; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "Checks whether a Claude Code session actually followed your CLAUDE.md / AGENTS.md rules, with evidence.",
5
5
  "repository": {
6
6
  "type": "git",