rulereceipt 0.1.73 → 0.1.74
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/checks/approvalGate.d.ts +2 -0
- package/dist/checks/approvalGate.js +9 -3
- package/dist/guard.js +18 -1
- package/dist/historyReport.js +14 -2
- package/package.json +1 -1
|
@@ -46,6 +46,8 @@ export interface ApprovalOptions {
|
|
|
46
46
|
allow?: string[];
|
|
47
47
|
/** When set, a `push` action is only gated if it targets this branch (or its target is unknown). */
|
|
48
48
|
scopedBranch?: string;
|
|
49
|
+
/** The current git branch (guard only), so a bare `git push` from a feature branch is not gated by a "push to main" rule. */
|
|
50
|
+
currentBranch?: string;
|
|
49
51
|
}
|
|
50
52
|
interface Occurrence {
|
|
51
53
|
action: Action;
|
|
@@ -75,7 +75,7 @@ export function approvalScopedBranch(rule) {
|
|
|
75
75
|
* push to a DIFFERENT branch returns false, so a feature-branch push is not
|
|
76
76
|
* gated by a rule that names main.
|
|
77
77
|
*/
|
|
78
|
-
function pushTargetsBranch(command, branch) {
|
|
78
|
+
function pushTargetsBranch(command, branch, currentBranch) {
|
|
79
79
|
const m = command.match(/\bgit\s+(?:\S+\s+){0,4}?push\b(.*)/i);
|
|
80
80
|
if (!m)
|
|
81
81
|
return true;
|
|
@@ -88,7 +88,13 @@ function pushTargetsBranch(command, branch) {
|
|
|
88
88
|
const b = tokens[tokens.length - 1];
|
|
89
89
|
return b === branch || b.endsWith(`/${branch}`);
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
// Bare `git push` / `git push origin` — the target is the CURRENT branch. When
|
|
92
|
+
// the guard can tell us that branch, gate only if it is the scoped branch (a
|
|
93
|
+
// bare push from a feature branch is not a push to main). When it is unknown
|
|
94
|
+
// (the check path, or git unavailable), gate to be safe.
|
|
95
|
+
if (currentBranch)
|
|
96
|
+
return currentBranch === branch || currentBranch.endsWith(`/${branch}`);
|
|
97
|
+
return true;
|
|
92
98
|
}
|
|
93
99
|
/** The result for the call at `i`: matched by id when present, else the next result. */
|
|
94
100
|
function resultOf(events, i) {
|
|
@@ -128,7 +134,7 @@ export function approvalOccurrences(events, actions, opts = {}) {
|
|
|
128
134
|
continue;
|
|
129
135
|
// A branch-scoped push rule ("push to main") does not gate a push to a
|
|
130
136
|
// different branch — only main (or a bare push whose target is unknown).
|
|
131
|
-
if (action === "push" && opts.scopedBranch && !pushTargetsBranch(command, opts.scopedBranch))
|
|
137
|
+
if (action === "push" && opts.scopedBranch && !pushTargetsBranch(command, opts.scopedBranch, opts.currentBranch))
|
|
132
138
|
continue;
|
|
133
139
|
const res = resultOf(events, i);
|
|
134
140
|
if (res && res.kind === "tool_result" && res.isError)
|
package/dist/guard.js
CHANGED
|
@@ -8,6 +8,23 @@ import { loadOverrides, ruleFingerprint, ratifiedForbids } from "./overrides.js"
|
|
|
8
8
|
import { commandRunsLiteral } from "./checks/proposedAction.js";
|
|
9
9
|
import { approvalOccurrences, allowListed, approvalCommandShort, approvalScopedBranch } from "./checks/approvalGate.js";
|
|
10
10
|
import { readTranscriptFromFile } from "./parsers/transcriptParser.js";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
/**
|
|
13
|
+
* The current git branch in `cwd`, or undefined if it can't be determined —
|
|
14
|
+
* so a bare `git push` from a feature branch is not gated by a "push to main"
|
|
15
|
+
* rule. Fails open (never throws, never blocks) if git is unavailable.
|
|
16
|
+
*/
|
|
17
|
+
function gitCurrentBranch(cwd) {
|
|
18
|
+
try {
|
|
19
|
+
// `symbolic-ref --short HEAD` gives the branch name even on an unborn branch
|
|
20
|
+
// (no commits yet); `rev-parse --abbrev-ref` returns "HEAD" there.
|
|
21
|
+
const b = execFileSync("git", ["symbolic-ref", "--short", "HEAD"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).trim();
|
|
22
|
+
return b && b !== "HEAD" ? b : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
11
28
|
import { readFileSync } from "node:fs";
|
|
12
29
|
import { homedir } from "node:os";
|
|
13
30
|
import { join } from "node:path";
|
|
@@ -204,7 +221,7 @@ function unapprovedGate(cwd, command, events) {
|
|
|
204
221
|
const gates = classifyRules(loadRules(cwd)).filter((c) => c.kind === "approvalGate");
|
|
205
222
|
for (const { rule, actions } of gates) {
|
|
206
223
|
const proposed = { role: "assistant", kind: "tool_use", toolName: "Bash", input: { command }, timestamp: "", permissionMode: "dontAsk" };
|
|
207
|
-
const occ = approvalOccurrences([...events, proposed], actions, { scopedBranch: approvalScopedBranch(rule) });
|
|
224
|
+
const occ = approvalOccurrences([...events, proposed], actions, { scopedBranch: approvalScopedBranch(rule), currentBranch: gitCurrentBranch(cwd) });
|
|
208
225
|
const last = occ[occ.length - 1];
|
|
209
226
|
// Compare against the SAME canonical short the occurrence uses (mention
|
|
210
227
|
// segments dropped, `sh -c` unwrapped) — a raw-string compare missed a
|
package/dist/historyReport.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { listAllSessions } from "./adapters/index.js";
|
|
3
3
|
import { evaluateSession } from "./evaluate.js";
|
|
4
|
+
/**
|
|
5
|
+
* One-line clip that ends on a whole word with an ellipsis, never mid-sentence.
|
|
6
|
+
* Found by a real test 2026-09-29: a break quote was cut as "...so no prompt was".
|
|
7
|
+
*/
|
|
8
|
+
function clip(s, n) {
|
|
9
|
+
const one = s.replace(/\s+/g, " ").trim();
|
|
10
|
+
if (one.length <= n)
|
|
11
|
+
return one;
|
|
12
|
+
const cut = one.slice(0, n);
|
|
13
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
14
|
+
return `${(lastSpace > n * 0.6 ? cut.slice(0, lastSpace) : cut).replace(/[\s,;:.—-]+$/, "")}…`;
|
|
15
|
+
}
|
|
4
16
|
function needsLlmResult(rule) {
|
|
5
17
|
return { ruleId: rule.id, ruleTitle: rule.title, ruleSource: rule.source, status: "UNCLEAR", needsHuman: true, evidence: "" };
|
|
6
18
|
}
|
|
@@ -125,11 +137,11 @@ export function renderHistory(s, projectName, now = Date.now()) {
|
|
|
125
137
|
out.push(`${who} broke your rules ${s.totalBrokenCount} time${s.totalBrokenCount === 1 ? "" : "s"}.`);
|
|
126
138
|
out.push("");
|
|
127
139
|
for (const b of s.breaks.slice(0, 10)) {
|
|
128
|
-
const title = b.ruleTitle
|
|
140
|
+
const title = clip(b.ruleTitle, 60);
|
|
129
141
|
const when = relDate(b.lastMs, now);
|
|
130
142
|
out.push(` x ${title} ${b.count} time${b.count === 1 ? "" : "s"} last: ${when}`);
|
|
131
143
|
if (b.quote)
|
|
132
|
-
out.push(` ${b.quote
|
|
144
|
+
out.push(` ${clip(b.quote, 100)}`);
|
|
133
145
|
}
|
|
134
146
|
}
|
|
135
147
|
out.push("");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.74",
|
|
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",
|