rulereceipt 0.1.45 → 0.1.46
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 +46 -0
- package/dist/badge.d.ts +22 -0
- package/dist/badge.js +22 -0
- package/dist/cli.js +117 -9
- package/dist/init.d.ts +15 -0
- package/dist/init.js +55 -0
- package/dist/projectConfig.d.ts +25 -0
- package/dist/projectConfig.js +31 -0
- package/dist/receipt.d.ts +68 -0
- package/dist/receipt.js +91 -0
- package/dist/report/generateReport.d.ts +12 -0
- package/dist/report/generateReport.js +42 -0
- package/dist/updateCheck.d.ts +11 -0
- package/dist/updateCheck.js +92 -0
- package/dist/whatsNew.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -309,6 +309,52 @@ on a session it never found.
|
|
|
309
309
|
For most people the honest answer is simpler: run `rulereceipt check --html`
|
|
310
310
|
locally and attach the report to the PR.
|
|
311
311
|
|
|
312
|
+
### The GitHub Action and the receipt flow
|
|
313
|
+
|
|
314
|
+
The concrete way to gate in CI: produce a **receipt** where the session
|
|
315
|
+
lives, verify it where it doesn't.
|
|
316
|
+
|
|
317
|
+
Locally (the session is on your machine), produce and commit a receipt:
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
rulereceipt check --json > .rulereceipt/receipt.json # commit this file
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
In CI (no session), verify the committed receipt with the Action:
|
|
324
|
+
|
|
325
|
+
```yaml
|
|
326
|
+
- uses: rulereceipt/rulereceipt@main # pin to a release tag once one is cut
|
|
327
|
+
with:
|
|
328
|
+
receipt: .rulereceipt/receipt.json
|
|
329
|
+
max-age-days: "7" # optional: reject a stale receipt
|
|
330
|
+
# anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} # optional: also fail on CLAUDE.md↔AGENTS.md contradictions
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
The build **fails** unless the receipt is a real, current, passing
|
|
334
|
+
RuleReceipt receipt. The Action also prints a session-independent audit of
|
|
335
|
+
your CLAUDE.md (`rules --coverage`), and — only if you pass an API key —
|
|
336
|
+
fails on a CLAUDE.md-vs-AGENTS.md contradiction.
|
|
337
|
+
|
|
338
|
+
Or run the pieces directly:
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
rulereceipt verify-receipt .rulereceipt/receipt.json --max-age-days 7
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
**Honest trust boundary:** with no session, CI trusts the receipt you
|
|
345
|
+
committed. But if the session *is* available — agentic CI, or you upload the
|
|
346
|
+
transcript — pass it and CI re-derives instead of trusting:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
rulereceipt verify-receipt .rulereceipt/receipt.json --session path/to/session.jsonl
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
That re-hashes the session and **rejects a receipt that doesn't match it**
|
|
353
|
+
(forged, tampered, or the wrong session) — no trust required. For the
|
|
354
|
+
no-session case, trust remains until signed/attested receipts land; a
|
|
355
|
+
self-signed receipt would not help (the author holds the key), so the honest
|
|
356
|
+
closure is session re-verification where the session exists.
|
|
357
|
+
|
|
312
358
|
## Install
|
|
313
359
|
|
|
314
360
|
```bash
|
package/dist/badge.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shields.io "endpoint" badge, derived from a receipt.
|
|
3
|
+
*
|
|
4
|
+
* The user commits the receipt (from `check --json`), runs `rulereceipt badge
|
|
5
|
+
* <receipt>` to emit this JSON, commits that too, and references it:
|
|
6
|
+
* 
|
|
7
|
+
*
|
|
8
|
+
* Honest by construction: the badge only ever says what the receipt says.
|
|
9
|
+
* "passing" means no rule FAILED — UNCLEAR (needs judgment / no key) is not a
|
|
10
|
+
* failure, same as everywhere else in the tool.
|
|
11
|
+
*/
|
|
12
|
+
export interface Badge {
|
|
13
|
+
schemaVersion: 1;
|
|
14
|
+
label: string;
|
|
15
|
+
message: string;
|
|
16
|
+
color: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function buildBadge(summary: {
|
|
19
|
+
pass: number;
|
|
20
|
+
fail: number;
|
|
21
|
+
unclear: number;
|
|
22
|
+
}): Badge;
|
package/dist/badge.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shields.io "endpoint" badge, derived from a receipt.
|
|
3
|
+
*
|
|
4
|
+
* The user commits the receipt (from `check --json`), runs `rulereceipt badge
|
|
5
|
+
* <receipt>` to emit this JSON, commits that too, and references it:
|
|
6
|
+
* 
|
|
7
|
+
*
|
|
8
|
+
* Honest by construction: the badge only ever says what the receipt says.
|
|
9
|
+
* "passing" means no rule FAILED — UNCLEAR (needs judgment / no key) is not a
|
|
10
|
+
* failure, same as everywhere else in the tool.
|
|
11
|
+
*/
|
|
12
|
+
export function buildBadge(summary) {
|
|
13
|
+
if (summary.fail > 0) {
|
|
14
|
+
return { schemaVersion: 1, label: "rules", message: `${summary.fail} failing`, color: "red" };
|
|
15
|
+
}
|
|
16
|
+
if (summary.pass > 0) {
|
|
17
|
+
return { schemaVersion: 1, label: "rules", message: "passing", color: "brightgreen" };
|
|
18
|
+
}
|
|
19
|
+
// Nothing failed and nothing deterministically passed — only judgment rules
|
|
20
|
+
// with nothing to grade. Not green (that would overstate), not red.
|
|
21
|
+
return { schemaVersion: 1, label: "rules", message: "unclear", color: "lightgrey" };
|
|
22
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -20,7 +20,7 @@ import { runEmojiChecks } from "./checks/emojiOutput.js";
|
|
|
20
20
|
import { runHook } from "./hook.js";
|
|
21
21
|
import { runGuard } from "./guard.js";
|
|
22
22
|
import { runJudgmentChecks } from "./checks/judgmentChecks.js";
|
|
23
|
-
import { generateReport, generateMarkdownReport } from "./report/generateReport.js";
|
|
23
|
+
import { generateReport, generateMarkdownReport, generateJsonReport, computeTranscriptHash } from "./report/generateReport.js";
|
|
24
24
|
import { gateOffer, hookIsInstalled } from "./report/gateOffer.js";
|
|
25
25
|
import { generateHtmlReport } from "./report/generateHtmlReport.js";
|
|
26
26
|
import { verifySessionHash } from "./verifyHash.js";
|
|
@@ -28,6 +28,11 @@ import { saveEmailConfig, loadEmailConfig, detectSmtpHost, isValidEmail } from "
|
|
|
28
28
|
import { sendReportEmail } from "./sendReport.js";
|
|
29
29
|
import { appendHistory, readHistorySince } from "./history.js";
|
|
30
30
|
import { maybeShowWhatsNew } from "./whatsNew.js";
|
|
31
|
+
import { verifyReceipt, parseReceipt } from "./receipt.js";
|
|
32
|
+
import { buildBadge } from "./badge.js";
|
|
33
|
+
import { buildInitGuidance } from "./init.js";
|
|
34
|
+
import { loadProjectConfig, handleMap, blockingFailures, warningFailures, PROJECT_CONFIG_PATH } from "./projectConfig.js";
|
|
35
|
+
import { maybeCheckUpdates, isUpdateCheckEnabled } from "./updateCheck.js";
|
|
31
36
|
import { generateDigest } from "./digest.js";
|
|
32
37
|
import { enableSchedule, disableSchedule, scheduleStatus } from "./schedule.js";
|
|
33
38
|
import { findSplitBrainConflicts } from "./checks/splitBrain.js";
|
|
@@ -140,7 +145,7 @@ function writeHtmlReport(results, meta, cwd, target) {
|
|
|
140
145
|
}
|
|
141
146
|
}
|
|
142
147
|
async function runCheck(opts) {
|
|
143
|
-
const { markdown, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, showSkipped, transcriptOverride } = opts;
|
|
148
|
+
const { markdown, json, checkUpdates, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, showSkipped, transcriptOverride } = opts;
|
|
144
149
|
const cwd = process.cwd();
|
|
145
150
|
const rules = loadRules(cwd);
|
|
146
151
|
if (rules.length === 0) {
|
|
@@ -258,12 +263,26 @@ async function runCheck(opts) {
|
|
|
258
263
|
// regardless of --llm.
|
|
259
264
|
const judgmentResults = llm ? await runJudgmentChecks(judgment, events) : judgment.map(({ rule }) => needsLlmResult(rule));
|
|
260
265
|
const results = [...deterministicResults, ...judgmentResults];
|
|
266
|
+
// Severity: rules a team marked as warnings in .rulereceipt/config.json are
|
|
267
|
+
// still reported but do not fail the build. handleFor maps a result back to
|
|
268
|
+
// its stable handle so the mark survives edits that renumber rule ids.
|
|
269
|
+
const projectConfig = loadProjectConfig(cwd);
|
|
270
|
+
const handleFor = handleMap(rules);
|
|
271
|
+
const blockingFails = blockingFailures(results, projectConfig, handleFor);
|
|
272
|
+
const warnedFails = warningFailures(results, projectConfig, handleFor);
|
|
261
273
|
const meta = { sessionFilePath, ruleCount: results.length };
|
|
274
|
+
// Kept in human/markdown form for --email and any other reader below, even
|
|
275
|
+
// when stdout is JSON — a manager gets a readable report, not raw JSON.
|
|
262
276
|
const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta);
|
|
263
|
-
|
|
277
|
+
if (json) {
|
|
278
|
+
console.log(generateJsonReport(results, meta, pkg.version));
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
console.log(reportText);
|
|
282
|
+
}
|
|
264
283
|
// Shown only to someone who has just read their own broken rules, and only
|
|
265
284
|
// if they have not already wired it up. See report/gateOffer.ts.
|
|
266
|
-
if (!markdown) {
|
|
285
|
+
if (!markdown && !json) {
|
|
267
286
|
const offer = gateOffer({
|
|
268
287
|
failures: results.filter((r) => r.status === "FAIL").length,
|
|
269
288
|
hookInstalled: hookIsInstalled(cwd),
|
|
@@ -288,7 +307,7 @@ async function runCheck(opts) {
|
|
|
288
307
|
// so, and a rule dropped here never appears in the report at all. Listing
|
|
289
308
|
// them needs no key, works in any language, and lets the person who wrote
|
|
290
309
|
// the rule be the one who decides.
|
|
291
|
-
if (notARule.length > 0) {
|
|
310
|
+
if (!json && notARule.length > 0) {
|
|
292
311
|
const n = notARule.length;
|
|
293
312
|
const plural = n === 1 ? "" : "s";
|
|
294
313
|
console.log(`\n(${n} item${plural} in your rules file ${n === 1 ? "was" : "were"} treated as documentation and not checked — directory listings, reference tables, examples.)`);
|
|
@@ -316,15 +335,21 @@ async function runCheck(opts) {
|
|
|
316
335
|
console.log(`Run with --show-skipped to see them.`);
|
|
317
336
|
}
|
|
318
337
|
}
|
|
319
|
-
if (stale.length > 0) {
|
|
338
|
+
if (!json && stale.length > 0) {
|
|
320
339
|
console.log(`\n(${stale.length} saved correction${stale.length === 1 ? "" : "s"} no longer match any rule in this project — the rule was probably reworded. Run \`rulereceipt rules --list\` to see them.)`);
|
|
321
340
|
}
|
|
341
|
+
if (!json && !markdown && warnedFails.length > 0) {
|
|
342
|
+
console.log(`\n(${warnedFails.length} failing rule${warnedFails.length === 1 ? "" : "s"} ${warnedFails.length === 1 ? "is" : "are"} set to warning in ${PROJECT_CONFIG_PATH} and did not fail the build.)`);
|
|
343
|
+
}
|
|
322
344
|
appendHistory(results, sessionFilePath);
|
|
323
345
|
// A once-per-update footer so a returning user sees the tool improved and
|
|
324
346
|
// comes back. Offline (notes ship in the package), fails open, and never
|
|
325
|
-
// on --markdown (that output is meant to be pasted into a PR/Slack)
|
|
326
|
-
|
|
347
|
+
// on --markdown (that output is meant to be pasted into a PR/Slack) or
|
|
348
|
+
// --json (that output must be a single parseable object, nothing else).
|
|
349
|
+
if (!markdown && !json) {
|
|
327
350
|
maybeShowWhatsNew(pkg.version);
|
|
351
|
+
// Opt-in only; makes no network call unless enabled. Fails open.
|
|
352
|
+
await maybeCheckUpdates(pkg.version, isUpdateCheckEnabled(checkUpdates));
|
|
328
353
|
}
|
|
329
354
|
if (share) {
|
|
330
355
|
await shareResults(results);
|
|
@@ -355,7 +380,9 @@ async function runCheck(opts) {
|
|
|
355
380
|
// rules in a real CLAUDE.md need judgment, so without --llm they
|
|
356
381
|
// legitimately report UNCLEAR. Gating on those would make every build
|
|
357
382
|
// red on day one and the check would be deleted within a week.
|
|
358
|
-
|
|
383
|
+
// Gate on BLOCKING failures only — a rule marked warning in the project
|
|
384
|
+
// config is reported but does not fail the build.
|
|
385
|
+
if (!exitZero && blockingFails.length > 0) {
|
|
359
386
|
process.exitCode = 1;
|
|
360
387
|
}
|
|
361
388
|
}
|
|
@@ -373,6 +400,8 @@ program
|
|
|
373
400
|
.command("check", { isDefault: true })
|
|
374
401
|
.description("Check the current project's latest Claude Code session against CLAUDE.md/AGENTS.md")
|
|
375
402
|
.option("--markdown", "output as markdown, for pasting into a PR or Slack")
|
|
403
|
+
.option("--json", "output a machine-readable JSON report instead of text — for CI, a GitHub Action, or any other consumer. Suppresses all human-only output; exit code is unchanged.")
|
|
404
|
+
.option("--check-updates", "opt-in: check npm for a newer rulereceipt and print a one-line nudge if there is one (at most once a day). Off by default; RULERECEIPT_CHECK_UPDATES=1 also enables it.")
|
|
376
405
|
.option("--share", "opt-in: send anonymous pass/fail/unclear counts only (no rule text, no file paths, no session content). Off by default — no network call happens without this flag.")
|
|
377
406
|
.option("--email", "opt-in: send this report directly from your own email (configured via `rulereceipt config`) to your configured manager email — but only when something actually failed. A manager doesn't need an email for every clean run. RuleReceipt's servers are never involved — sends straight from your machine via your own SMTP credentials.")
|
|
378
407
|
.option("--email-always", "used with --email: send every time, even when nothing failed")
|
|
@@ -386,6 +415,8 @@ program
|
|
|
386
415
|
.action((opts) => {
|
|
387
416
|
runCheck({
|
|
388
417
|
markdown: Boolean(opts.markdown),
|
|
418
|
+
json: Boolean(opts.json),
|
|
419
|
+
checkUpdates: Boolean(opts.checkUpdates),
|
|
389
420
|
share: Boolean(opts.share),
|
|
390
421
|
email: Boolean(opts.email),
|
|
391
422
|
emailAlways: Boolean(opts.emailAlways),
|
|
@@ -629,6 +660,11 @@ async function runLint(markdown, llm) {
|
|
|
629
660
|
console.log("No contradictions found between CLAUDE.md and AGENTS.md.");
|
|
630
661
|
return;
|
|
631
662
|
}
|
|
663
|
+
// A contradiction between the two rule files is a real defect, not just
|
|
664
|
+
// information: it means the agent is being given conflicting instructions.
|
|
665
|
+
// Exit non-zero so CI (and the GitHub Action) can gate on it, the same way
|
|
666
|
+
// `check` exits 1 on a FAIL.
|
|
667
|
+
process.exitCode = 1;
|
|
632
668
|
if (markdown) {
|
|
633
669
|
const lines = ["## CLAUDE.md vs AGENTS.md — contradictions found", ""];
|
|
634
670
|
for (const c of result.conflicts) {
|
|
@@ -721,6 +757,18 @@ program
|
|
|
721
757
|
process.exitCode = 1;
|
|
722
758
|
});
|
|
723
759
|
});
|
|
760
|
+
program
|
|
761
|
+
.command("init")
|
|
762
|
+
.description("Guided setup: shows what's configured and the exact next steps. Read-only — writes nothing.")
|
|
763
|
+
.action(() => {
|
|
764
|
+
const cwd = process.cwd();
|
|
765
|
+
console.log(buildInitGuidance({
|
|
766
|
+
hasClaudeMd: existsSync(join(cwd, "CLAUDE.md")),
|
|
767
|
+
hasAgentsMd: existsSync(join(cwd, "AGENTS.md")),
|
|
768
|
+
hookInstalled: hookIsInstalled(cwd),
|
|
769
|
+
hasApiKey: Boolean(process.env.ANTHROPIC_API_KEY),
|
|
770
|
+
}));
|
|
771
|
+
});
|
|
724
772
|
program
|
|
725
773
|
.command("demo")
|
|
726
774
|
.description("See a sample report — no setup, no API key needed")
|
|
@@ -821,4 +869,64 @@ program
|
|
|
821
869
|
process.exitCode = 1;
|
|
822
870
|
}
|
|
823
871
|
});
|
|
872
|
+
program
|
|
873
|
+
.command("verify-receipt <path>")
|
|
874
|
+
.description("CI gate: verify a receipt (produced locally with `check --json` and committed) — that it is a real, current, passing RuleReceipt receipt. No session needed. Exits non-zero if invalid, stale, or anything FAILED.")
|
|
875
|
+
.option("--max-age-days <n>", "reject a receipt older than N days (freshness gate)")
|
|
876
|
+
.option("--session <path>", "if the session transcript is available (agentic CI, or you uploaded it), re-hash it and confirm the receipt was produced from THAT session. This is the only check that needs no trust — a mismatch is rejected.")
|
|
877
|
+
.action((path, opts) => {
|
|
878
|
+
let text;
|
|
879
|
+
try {
|
|
880
|
+
text = readFileSync(path, "utf-8");
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
console.error(`Could not read receipt file: ${path}`);
|
|
884
|
+
process.exitCode = 1;
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
const maxAgeDays = opts.maxAgeDays !== undefined ? Number(opts.maxAgeDays) : undefined;
|
|
888
|
+
if (maxAgeDays !== undefined && !Number.isFinite(maxAgeDays)) {
|
|
889
|
+
console.error(`--max-age-days must be a number, got: ${opts.maxAgeDays}`);
|
|
890
|
+
process.exitCode = 1;
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
// Only pass sessionHash when a session was actually requested; null (path
|
|
894
|
+
// given but unreadable) is a rejection inside verifyReceipt.
|
|
895
|
+
const sessionHash = opts.session !== undefined ? computeTranscriptHash(opts.session) : undefined;
|
|
896
|
+
const res = verifyReceipt(text, { maxAgeDays, sessionHash });
|
|
897
|
+
if (res.ok && res.receipt) {
|
|
898
|
+
const r = res.receipt;
|
|
899
|
+
const trust = res.sessionVerified
|
|
900
|
+
? "re-verified against the session (no trust needed)"
|
|
901
|
+
: "trusted (no session provided to re-verify against)";
|
|
902
|
+
console.log(`✓ receipt OK — rulereceipt v${r.version}, ${r.summary.pass} passed / ${r.summary.fail} failed / ${r.summary.unclear} unclear, generated ${r.generatedAt}\n ${trust}`);
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
console.error("✕ receipt rejected:");
|
|
906
|
+
for (const p of res.problems)
|
|
907
|
+
console.error(` - ${p}`);
|
|
908
|
+
process.exitCode = 1;
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
program
|
|
912
|
+
.command("badge <receiptPath>")
|
|
913
|
+
.description("Emit a shields.io endpoint JSON from a receipt (from `check --json`), for a README badge. Commit the output and reference it: ")
|
|
914
|
+
.action((receiptPath) => {
|
|
915
|
+
let text;
|
|
916
|
+
try {
|
|
917
|
+
text = readFileSync(receiptPath, "utf-8");
|
|
918
|
+
}
|
|
919
|
+
catch {
|
|
920
|
+
console.error(`Could not read receipt file: ${receiptPath}`);
|
|
921
|
+
process.exitCode = 1;
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const parsed = parseReceipt(text);
|
|
925
|
+
if (parsed.error || !parsed.receipt) {
|
|
926
|
+
console.error(`Not a valid receipt: ${parsed.error}`);
|
|
927
|
+
process.exitCode = 1;
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
console.log(JSON.stringify(buildBadge(parsed.receipt.summary), null, 2));
|
|
931
|
+
});
|
|
824
932
|
program.parse();
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rulereceipt init` — a guided setup that tells you exactly where you are and
|
|
3
|
+
* what to do next. Read-only on purpose: RuleReceipt audits OTHER tools for
|
|
4
|
+
* silently writing to .claude/settings.json, so it will not do that itself.
|
|
5
|
+
* It shows you the snippet to paste; you paste it.
|
|
6
|
+
*/
|
|
7
|
+
export interface InitState {
|
|
8
|
+
hasClaudeMd: boolean;
|
|
9
|
+
hasAgentsMd: boolean;
|
|
10
|
+
hookInstalled: boolean;
|
|
11
|
+
hasApiKey: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** The PreToolUse guard hook, as it goes into .claude/settings.json. */
|
|
14
|
+
export declare const GUARD_HOOK_SNIPPET = "{\n \"hooks\": {\n \"PreToolUse\": [\n { \"hooks\": [ { \"type\": \"command\", \"command\": \"rulereceipt guard\" } ] }\n ]\n }\n}";
|
|
15
|
+
export declare function buildInitGuidance(state: InitState): string;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rulereceipt init` — a guided setup that tells you exactly where you are and
|
|
3
|
+
* what to do next. Read-only on purpose: RuleReceipt audits OTHER tools for
|
|
4
|
+
* silently writing to .claude/settings.json, so it will not do that itself.
|
|
5
|
+
* It shows you the snippet to paste; you paste it.
|
|
6
|
+
*/
|
|
7
|
+
/** The PreToolUse guard hook, as it goes into .claude/settings.json. */
|
|
8
|
+
export const GUARD_HOOK_SNIPPET = `{
|
|
9
|
+
"hooks": {
|
|
10
|
+
"PreToolUse": [
|
|
11
|
+
{ "hooks": [ { "type": "command", "command": "rulereceipt guard" } ] }
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}`;
|
|
15
|
+
function line(done, label) {
|
|
16
|
+
return ` ${done ? "✓" : "✗"} ${label}`;
|
|
17
|
+
}
|
|
18
|
+
export function buildInitGuidance(state) {
|
|
19
|
+
const out = [];
|
|
20
|
+
out.push("RuleReceipt setup");
|
|
21
|
+
out.push("");
|
|
22
|
+
out.push("Where you are:");
|
|
23
|
+
out.push(line(state.hasClaudeMd || state.hasAgentsMd, "a rules file (CLAUDE.md or AGENTS.md) in this directory"));
|
|
24
|
+
out.push(line(state.hookInstalled, "a RuleReceipt hook wired into Claude Code (enforcement)"));
|
|
25
|
+
out.push(line(state.hasApiKey, "ANTHROPIC_API_KEY set (for rules that need judgment)"));
|
|
26
|
+
out.push("");
|
|
27
|
+
const steps = [];
|
|
28
|
+
if (!state.hasClaudeMd && !state.hasAgentsMd) {
|
|
29
|
+
steps.push("Write a CLAUDE.md in this directory with your rules, one per line or per heading.\n" +
|
|
30
|
+
' Even a few lines work — e.g. "Never commit to main" and "Run the tests before committing".');
|
|
31
|
+
}
|
|
32
|
+
if (!state.hookInstalled) {
|
|
33
|
+
steps.push("Turn on enforcement (optional but recommended). Add this to .claude/settings.json,\n" +
|
|
34
|
+
" then start a NEW Claude Code session so the hook loads:\n\n" +
|
|
35
|
+
GUARD_HOOK_SNIPPET.split("\n").map((l) => ` ${l}`).join("\n") +
|
|
36
|
+
"\n\n It refuses a command that breaks a file/branch rule before it runs, and fails\n" +
|
|
37
|
+
" open on any error. Needs `npm i -g rulereceipt` (or use `npx rulereceipt guard`).");
|
|
38
|
+
}
|
|
39
|
+
if (!state.hasApiKey) {
|
|
40
|
+
steps.push("Set ANTHROPIC_API_KEY (the same key Claude Code uses) if you want rules that need\n" +
|
|
41
|
+
" judgment graded. Without it those report UNCLEAR — deterministic checks run regardless,\n" +
|
|
42
|
+
" and nothing is ever sent without the --llm flag.");
|
|
43
|
+
}
|
|
44
|
+
if (steps.length === 0) {
|
|
45
|
+
out.push("You're set up. Run: rulereceipt check");
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
out.push("Next steps:");
|
|
49
|
+
steps.forEach((s, i) => out.push(`${i + 1}. ${s}`));
|
|
50
|
+
}
|
|
51
|
+
out.push("");
|
|
52
|
+
out.push("See it right now with no setup: rulereceipt demo");
|
|
53
|
+
out.push("Check your last real session: rulereceipt check");
|
|
54
|
+
return out.join("\n");
|
|
55
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CheckResult, Rule } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A committed, team-shared config at .rulereceipt/config.json.
|
|
4
|
+
*
|
|
5
|
+
* Today it holds one thing: rule handles to treat as WARNINGS — a broken
|
|
6
|
+
* "warning" rule is still reported, but it does not fail the build. This is
|
|
7
|
+
* the honest version of "severity": a team marks the must-not-break rules as
|
|
8
|
+
* errors (the default) and the nice-to-have ones as warnings, so CI gates on
|
|
9
|
+
* what actually matters instead of going red on day one.
|
|
10
|
+
*
|
|
11
|
+
* Handles, not rule ids: an id is positional and renumbers when the file is
|
|
12
|
+
* edited above it; a handle is a content hash, so it survives edits. Get one
|
|
13
|
+
* from `rulereceipt rules --list`.
|
|
14
|
+
*/
|
|
15
|
+
export interface ProjectConfig {
|
|
16
|
+
warn: string[];
|
|
17
|
+
}
|
|
18
|
+
export declare const PROJECT_CONFIG_PATH: string;
|
|
19
|
+
export declare function loadProjectConfig(cwd: string): ProjectConfig;
|
|
20
|
+
/** A lookup from a result back to its stable rule handle, built from the loaded rules. */
|
|
21
|
+
export declare function handleMap(rules: Rule[]): (r: CheckResult) => string;
|
|
22
|
+
/** FAILs that are NOT configured as warnings — these fail the build. */
|
|
23
|
+
export declare function blockingFailures(results: CheckResult[], config: ProjectConfig, handleFor: (r: CheckResult) => string): CheckResult[];
|
|
24
|
+
/** FAILs that ARE configured as warnings — shown, but they do not fail the build. */
|
|
25
|
+
export declare function warningFailures(results: CheckResult[], config: ProjectConfig, handleFor: (r: CheckResult) => string): CheckResult[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ruleFingerprint } from "./overrides.js";
|
|
4
|
+
export const PROJECT_CONFIG_PATH = join(".rulereceipt", "config.json");
|
|
5
|
+
export function loadProjectConfig(cwd) {
|
|
6
|
+
try {
|
|
7
|
+
const parsed = JSON.parse(readFileSync(join(cwd, PROJECT_CONFIG_PATH), "utf-8"));
|
|
8
|
+
const warn = parsed?.warn;
|
|
9
|
+
return { warn: Array.isArray(warn) ? warn.filter((x) => typeof x === "string") : [] };
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// Missing or malformed config means no severities configured, never an
|
|
13
|
+
// error — same fail-open discipline as the rest of the tool.
|
|
14
|
+
return { warn: [] };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** A lookup from a result back to its stable rule handle, built from the loaded rules. */
|
|
18
|
+
export function handleMap(rules) {
|
|
19
|
+
const m = new Map();
|
|
20
|
+
for (const rule of rules)
|
|
21
|
+
m.set(`${rule.source}:${rule.id}`, ruleFingerprint(rule));
|
|
22
|
+
return (r) => m.get(`${r.ruleSource}:${r.ruleId}`) ?? "";
|
|
23
|
+
}
|
|
24
|
+
/** FAILs that are NOT configured as warnings — these fail the build. */
|
|
25
|
+
export function blockingFailures(results, config, handleFor) {
|
|
26
|
+
return results.filter((r) => r.status === "FAIL" && !config.warn.includes(handleFor(r)));
|
|
27
|
+
}
|
|
28
|
+
/** FAILs that ARE configured as warnings — shown, but they do not fail the build. */
|
|
29
|
+
export function warningFailures(results, config, handleFor) {
|
|
30
|
+
return results.filter((r) => r.status === "FAIL" && config.warn.includes(handleFor(r)));
|
|
31
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CI side of RuleReceipt.
|
|
3
|
+
*
|
|
4
|
+
* The check itself needs the local Claude Code session transcript, which a CI
|
|
5
|
+
* runner does not have. So CI does not re-run the check — it verifies a
|
|
6
|
+
* RECEIPT the developer produced locally with `check --json` and committed:
|
|
7
|
+
* that it is a real RuleReceipt receipt, a schema this tool understands, not
|
|
8
|
+
* stale, and that nothing FAILED.
|
|
9
|
+
*
|
|
10
|
+
* Honest trust boundary: the receipt commits to the session via its sha256,
|
|
11
|
+
* but CI has no session to re-hash, so CI is trusting the committed receipt.
|
|
12
|
+
* A signed/attested receipt closes that gap and is the documented next step;
|
|
13
|
+
* until then, "verify-receipt" means "this receipt is well-formed, current,
|
|
14
|
+
* and passing", not "CI independently re-derived it from the session".
|
|
15
|
+
*/
|
|
16
|
+
export interface Receipt {
|
|
17
|
+
tool: string;
|
|
18
|
+
schema: number;
|
|
19
|
+
version: string;
|
|
20
|
+
generatedAt: string;
|
|
21
|
+
session: {
|
|
22
|
+
path: string | null;
|
|
23
|
+
sha256: string | null;
|
|
24
|
+
};
|
|
25
|
+
summary: {
|
|
26
|
+
total: number;
|
|
27
|
+
pass: number;
|
|
28
|
+
fail: number;
|
|
29
|
+
unclear: number;
|
|
30
|
+
};
|
|
31
|
+
results: unknown[];
|
|
32
|
+
}
|
|
33
|
+
/** The receipt schema this tool understands. Bumped in generateJsonReport on any breaking shape change. */
|
|
34
|
+
export declare const KNOWN_SCHEMA = 1;
|
|
35
|
+
export interface VerifyReceiptOptions {
|
|
36
|
+
/** Reject a receipt whose generatedAt is older than this many days. */
|
|
37
|
+
maxAgeDays?: number;
|
|
38
|
+
/**
|
|
39
|
+
* The sha256 of the actual session file, if it is available to the verifier
|
|
40
|
+
* (agentic CI, or a developer who uploaded the transcript). When set, the
|
|
41
|
+
* receipt is re-verified against it: a mismatch is rejected. This is the
|
|
42
|
+
* only path that closes the trust boundary — CI re-derives instead of
|
|
43
|
+
* trusting. `null` means "a session was named but could not be read".
|
|
44
|
+
* `undefined` means "no session provided" (the normal, trust-the-receipt case).
|
|
45
|
+
*/
|
|
46
|
+
sessionHash?: string | null;
|
|
47
|
+
}
|
|
48
|
+
export interface VerifyResult {
|
|
49
|
+
ok: boolean;
|
|
50
|
+
problems: string[];
|
|
51
|
+
receipt?: Receipt;
|
|
52
|
+
/** True only when a session was provided AND its hash matched the receipt — i.e. CI re-derived, not trusted. */
|
|
53
|
+
sessionVerified?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Structural validation only — is this a RuleReceipt receipt at all, with the
|
|
57
|
+
* fields the verifier relies on. Returns a typed receipt or a reason.
|
|
58
|
+
*/
|
|
59
|
+
export declare function parseReceipt(text: string): {
|
|
60
|
+
receipt?: Receipt;
|
|
61
|
+
error?: string;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* The CI gate. ok=false with a reason list means fail the build. UNCLEAR
|
|
65
|
+
* never fails on its own — same rule as `check`'s exit code: a rule that
|
|
66
|
+
* needs human judgment is not a violation.
|
|
67
|
+
*/
|
|
68
|
+
export declare function verifyReceipt(text: string, opts?: VerifyReceiptOptions): VerifyResult;
|
package/dist/receipt.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CI side of RuleReceipt.
|
|
3
|
+
*
|
|
4
|
+
* The check itself needs the local Claude Code session transcript, which a CI
|
|
5
|
+
* runner does not have. So CI does not re-run the check — it verifies a
|
|
6
|
+
* RECEIPT the developer produced locally with `check --json` and committed:
|
|
7
|
+
* that it is a real RuleReceipt receipt, a schema this tool understands, not
|
|
8
|
+
* stale, and that nothing FAILED.
|
|
9
|
+
*
|
|
10
|
+
* Honest trust boundary: the receipt commits to the session via its sha256,
|
|
11
|
+
* but CI has no session to re-hash, so CI is trusting the committed receipt.
|
|
12
|
+
* A signed/attested receipt closes that gap and is the documented next step;
|
|
13
|
+
* until then, "verify-receipt" means "this receipt is well-formed, current,
|
|
14
|
+
* and passing", not "CI independently re-derived it from the session".
|
|
15
|
+
*/
|
|
16
|
+
/** The receipt schema this tool understands. Bumped in generateJsonReport on any breaking shape change. */
|
|
17
|
+
export const KNOWN_SCHEMA = 1;
|
|
18
|
+
/**
|
|
19
|
+
* Structural validation only — is this a RuleReceipt receipt at all, with the
|
|
20
|
+
* fields the verifier relies on. Returns a typed receipt or a reason.
|
|
21
|
+
*/
|
|
22
|
+
export function parseReceipt(text) {
|
|
23
|
+
let obj;
|
|
24
|
+
try {
|
|
25
|
+
obj = JSON.parse(text);
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
return { error: `not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
|
|
29
|
+
}
|
|
30
|
+
if (obj === null || typeof obj !== "object")
|
|
31
|
+
return { error: "receipt is not a JSON object" };
|
|
32
|
+
const o = obj;
|
|
33
|
+
if (o.tool !== "rulereceipt")
|
|
34
|
+
return { error: `not a rulereceipt receipt (tool=${JSON.stringify(o.tool)})` };
|
|
35
|
+
if (typeof o.schema !== "number")
|
|
36
|
+
return { error: "receipt has no numeric 'schema'" };
|
|
37
|
+
if (typeof o.generatedAt !== "string")
|
|
38
|
+
return { error: "receipt has no 'generatedAt' timestamp" };
|
|
39
|
+
const s = o.summary;
|
|
40
|
+
if (!s || typeof s.fail !== "number" || typeof s.pass !== "number" || typeof s.unclear !== "number") {
|
|
41
|
+
return { error: "receipt has no valid 'summary' counts" };
|
|
42
|
+
}
|
|
43
|
+
return { receipt: o };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The CI gate. ok=false with a reason list means fail the build. UNCLEAR
|
|
47
|
+
* never fails on its own — same rule as `check`'s exit code: a rule that
|
|
48
|
+
* needs human judgment is not a violation.
|
|
49
|
+
*/
|
|
50
|
+
export function verifyReceipt(text, opts = {}) {
|
|
51
|
+
const parsed = parseReceipt(text);
|
|
52
|
+
if (parsed.error)
|
|
53
|
+
return { ok: false, problems: [parsed.error] };
|
|
54
|
+
const r = parsed.receipt;
|
|
55
|
+
const problems = [];
|
|
56
|
+
if (r.schema > KNOWN_SCHEMA) {
|
|
57
|
+
problems.push(`receipt schema ${r.schema} is newer than this tool understands (${KNOWN_SCHEMA}) — upgrade rulereceipt`);
|
|
58
|
+
}
|
|
59
|
+
if (r.summary.fail > 0) {
|
|
60
|
+
problems.push(`${r.summary.fail} rule${r.summary.fail === 1 ? "" : "s"} FAILED in this receipt`);
|
|
61
|
+
}
|
|
62
|
+
if (opts.maxAgeDays !== undefined) {
|
|
63
|
+
const ms = Date.now() - new Date(r.generatedAt).getTime();
|
|
64
|
+
const days = ms / 86_400_000;
|
|
65
|
+
if (!Number.isFinite(days)) {
|
|
66
|
+
problems.push(`receipt generatedAt is not a valid date: ${r.generatedAt}`);
|
|
67
|
+
}
|
|
68
|
+
else if (days > opts.maxAgeDays) {
|
|
69
|
+
problems.push(`receipt is ${Math.floor(days)} days old, older than the ${opts.maxAgeDays}-day limit`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Session re-verification: the only check that does not require trust. When
|
|
73
|
+
// the actual session is available, re-derive its hash and confirm the
|
|
74
|
+
// receipt was produced from THAT session — a mismatch means forged or wrong.
|
|
75
|
+
let sessionVerified;
|
|
76
|
+
if (opts.sessionHash !== undefined) {
|
|
77
|
+
if (opts.sessionHash === null) {
|
|
78
|
+
problems.push("a session file was named but could not be read");
|
|
79
|
+
}
|
|
80
|
+
else if (r.session.sha256 === null) {
|
|
81
|
+
problems.push("receipt has no session hash (demo data?), so it cannot be re-verified against a session");
|
|
82
|
+
}
|
|
83
|
+
else if (opts.sessionHash !== r.session.sha256) {
|
|
84
|
+
problems.push("receipt does NOT match the provided session (sha256 mismatch) — forged, tampered, or the wrong session");
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
sessionVerified = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { ok: problems.length === 0, problems, receipt: r, sessionVerified };
|
|
91
|
+
}
|
|
@@ -13,3 +13,15 @@ export interface ReportMeta {
|
|
|
13
13
|
export declare function computeTranscriptHash(sessionFilePath: string | null): string | null;
|
|
14
14
|
export declare function generateReport(results: CheckResult[], meta: ReportMeta): string;
|
|
15
15
|
export declare function generateMarkdownReport(results: CheckResult[], meta: ReportMeta): string;
|
|
16
|
+
/**
|
|
17
|
+
* Machine-readable output for CI, a GitHub Action, or any other consumer.
|
|
18
|
+
*
|
|
19
|
+
* `schema` is versioned deliberately: this is a contract other tools will
|
|
20
|
+
* parse, so a breaking shape change must bump it rather than silently move
|
|
21
|
+
* fields under callers. The full (untruncated) sha256 is included so a
|
|
22
|
+
* consumer can `rulereceipt verify` the session independently — the human
|
|
23
|
+
* reports only show a prefix. Rule text and evidence are sanitized the same
|
|
24
|
+
* way as every other output: a hostile CLAUDE.md does not get to smuggle
|
|
25
|
+
* control characters through the JSON either.
|
|
26
|
+
*/
|
|
27
|
+
export declare function generateJsonReport(results: CheckResult[], meta: ReportMeta, toolVersion: string): string;
|
|
@@ -266,3 +266,45 @@ export function generateMarkdownReport(results, meta) {
|
|
|
266
266
|
lines.push(hash ? `\`verify: sha256:${hash.slice(0, 16)}...\` · checked ${new Date().toISOString()}` : `demo data · checked ${new Date().toISOString()}`);
|
|
267
267
|
return lines.join("\n");
|
|
268
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Machine-readable output for CI, a GitHub Action, or any other consumer.
|
|
271
|
+
*
|
|
272
|
+
* `schema` is versioned deliberately: this is a contract other tools will
|
|
273
|
+
* parse, so a breaking shape change must bump it rather than silently move
|
|
274
|
+
* fields under callers. The full (untruncated) sha256 is included so a
|
|
275
|
+
* consumer can `rulereceipt verify` the session independently — the human
|
|
276
|
+
* reports only show a prefix. Rule text and evidence are sanitized the same
|
|
277
|
+
* way as every other output: a hostile CLAUDE.md does not get to smuggle
|
|
278
|
+
* control characters through the JSON either.
|
|
279
|
+
*/
|
|
280
|
+
export function generateJsonReport(results, meta, toolVersion) {
|
|
281
|
+
const clean = results.map(sanitize);
|
|
282
|
+
const count = (s) => clean.filter((r) => r.status === s).length;
|
|
283
|
+
const report = {
|
|
284
|
+
tool: "rulereceipt",
|
|
285
|
+
schema: 1,
|
|
286
|
+
version: toolVersion,
|
|
287
|
+
generatedAt: new Date().toISOString(),
|
|
288
|
+
session: {
|
|
289
|
+
path: meta.sessionFilePath,
|
|
290
|
+
sha256: computeTranscriptHash(meta.sessionFilePath),
|
|
291
|
+
},
|
|
292
|
+
summary: {
|
|
293
|
+
total: clean.length,
|
|
294
|
+
pass: count("PASS"),
|
|
295
|
+
fail: count("FAIL"),
|
|
296
|
+
unclear: count("UNCLEAR"),
|
|
297
|
+
},
|
|
298
|
+
results: clean.map((r) => ({
|
|
299
|
+
ruleId: r.ruleId,
|
|
300
|
+
ruleTitle: r.ruleTitle,
|
|
301
|
+
ruleSource: r.ruleSource,
|
|
302
|
+
status: r.status,
|
|
303
|
+
outcome: r.outcome ?? null,
|
|
304
|
+
method: r.method ?? null,
|
|
305
|
+
needsHuman: r.needsHuman ?? false,
|
|
306
|
+
evidence: r.evidence,
|
|
307
|
+
})),
|
|
308
|
+
};
|
|
309
|
+
return JSON.stringify(report, null, 2);
|
|
310
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function isUpdateCheckEnabled(flag: boolean): boolean;
|
|
2
|
+
/** True if enough time has passed since the last check (or there was none). */
|
|
3
|
+
export declare function shouldCheckNow(now: number, lastCheck: number | null, intervalMs?: number): boolean;
|
|
4
|
+
/** The nudge, or null if the current version is already latest (or newer). */
|
|
5
|
+
export declare function renderUpdateNudge(current: string, latest: string): string | null;
|
|
6
|
+
/**
|
|
7
|
+
* Orchestration: only when enabled, only when due, fetch the latest version
|
|
8
|
+
* and nudge if newer. Records the check time even on failure so a flaky
|
|
9
|
+
* network does not turn into a ping on every single run.
|
|
10
|
+
*/
|
|
11
|
+
export declare function maybeCheckUpdates(current: string, enabled: boolean, log?: (s: string) => void): Promise<void>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { compareVersions } from "./whatsNew.js";
|
|
5
|
+
/**
|
|
6
|
+
* The online complement to the offline "what's new" footer: tell someone on
|
|
7
|
+
* an old global install that a newer version exists.
|
|
8
|
+
*
|
|
9
|
+
* OPT-IN, never by default. RuleReceipt's whole promise is that it makes no
|
|
10
|
+
* network call you did not ask for — telemetry is opt-in for the same reason,
|
|
11
|
+
* and an auditing tool that quietly phones a registry is the hypocrisy it
|
|
12
|
+
* exists to catch. Enabled only by --check-updates or RULERECEIPT_CHECK_UPDATES=1,
|
|
13
|
+
* cached so it pings at most once a day, and fails open on any error.
|
|
14
|
+
*/
|
|
15
|
+
const REGISTRY = "https://registry.npmjs.org/rulereceipt/latest";
|
|
16
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
function stampPath() {
|
|
18
|
+
return join(homedir(), ".rulereceipt", "last-update-check");
|
|
19
|
+
}
|
|
20
|
+
export function isUpdateCheckEnabled(flag) {
|
|
21
|
+
if (flag)
|
|
22
|
+
return true;
|
|
23
|
+
const env = process.env.RULERECEIPT_CHECK_UPDATES;
|
|
24
|
+
return env === "1" || env === "true";
|
|
25
|
+
}
|
|
26
|
+
/** True if enough time has passed since the last check (or there was none). */
|
|
27
|
+
export function shouldCheckNow(now, lastCheck, intervalMs = CHECK_INTERVAL_MS) {
|
|
28
|
+
if (lastCheck === null)
|
|
29
|
+
return true;
|
|
30
|
+
return now - lastCheck >= intervalMs;
|
|
31
|
+
}
|
|
32
|
+
function readLastCheck() {
|
|
33
|
+
try {
|
|
34
|
+
const n = parseInt(readFileSync(stampPath(), "utf-8").trim(), 10);
|
|
35
|
+
return Number.isFinite(n) ? n : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function writeLastCheck(now) {
|
|
42
|
+
try {
|
|
43
|
+
const dir = join(homedir(), ".rulereceipt");
|
|
44
|
+
if (!existsSync(dir))
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
writeFileSync(stampPath(), String(now), "utf-8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// best-effort
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** The nudge, or null if the current version is already latest (or newer). */
|
|
53
|
+
export function renderUpdateNudge(current, latest) {
|
|
54
|
+
if (compareVersions(latest, current) <= 0)
|
|
55
|
+
return null;
|
|
56
|
+
return `\nA newer rulereceipt is available: v${latest} (you have v${current}). Update with: npx rulereceipt@latest`;
|
|
57
|
+
}
|
|
58
|
+
async function fetchLatestVersion() {
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(REGISTRY, { signal: AbortSignal.timeout(2000) });
|
|
61
|
+
if (!res.ok)
|
|
62
|
+
return null;
|
|
63
|
+
const body = (await res.json());
|
|
64
|
+
return typeof body.version === "string" ? body.version : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Orchestration: only when enabled, only when due, fetch the latest version
|
|
72
|
+
* and nudge if newer. Records the check time even on failure so a flaky
|
|
73
|
+
* network does not turn into a ping on every single run.
|
|
74
|
+
*/
|
|
75
|
+
export async function maybeCheckUpdates(current, enabled, log = console.log) {
|
|
76
|
+
try {
|
|
77
|
+
if (!enabled)
|
|
78
|
+
return;
|
|
79
|
+
if (!shouldCheckNow(Date.now(), readLastCheck()))
|
|
80
|
+
return;
|
|
81
|
+
writeLastCheck(Date.now());
|
|
82
|
+
const latest = await fetchLatestVersion();
|
|
83
|
+
if (!latest)
|
|
84
|
+
return;
|
|
85
|
+
const nudge = renderUpdateNudge(current, latest);
|
|
86
|
+
if (nudge)
|
|
87
|
+
log(nudge);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// never let an update check affect the run
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/whatsNew.js
CHANGED
|
@@ -14,6 +14,14 @@ import { join } from "node:path";
|
|
|
14
14
|
* overstates is the exact failure this tool exists to catch.
|
|
15
15
|
*/
|
|
16
16
|
export const RELEASES = [
|
|
17
|
+
{
|
|
18
|
+
version: "0.1.46",
|
|
19
|
+
highlights: [
|
|
20
|
+
"Run RuleReceipt in CI: `--json` output, `verify-receipt`, and a GitHub Action (uses: rulereceipt/rulereceipt).",
|
|
21
|
+
"`rulereceipt init` for guided setup, and per-rule warnings via .rulereceipt/config.json so CI gates on what matters.",
|
|
22
|
+
"A README status badge (`rulereceipt badge`), and opt-in `--check-updates`.",
|
|
23
|
+
],
|
|
24
|
+
},
|
|
17
25
|
{ version: "0.1.45", highlights: ["The tool now shows what's improved since you last ran it, like this note."] },
|
|
18
26
|
{ version: "0.1.44", highlights: ["The report now offers to install enforcement, but only when a rule was actually broken."] },
|
|
19
27
|
{ version: "0.1.43", highlights: ["New check: a claim to have read or verified something, with nothing in the session behind it."] },
|