rulereceipt 0.1.45 → 0.1.47

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/cli.js CHANGED
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { parseClaudeMd } from "./parsers/readClaudeMd.js";
9
9
  import { readLatestTranscript, readTranscriptFromFile, findLatestSessionFile } from "./parsers/transcriptParser.js";
10
10
  import { loadRules } from "./rules.js";
11
+ import { adviseRules } from "./checkability.js";
11
12
  import { classifyRules } from "./checks/classify.js";
12
13
  import { loadOverrides, saveOverride, clearOverride, staleOverrides, ruleFingerprint, OVERRIDES_PATH } from "./overrides.js";
13
14
  import { runDeterministicChecks } from "./checks/deterministicChecks.js";
@@ -20,7 +21,7 @@ import { runEmojiChecks } from "./checks/emojiOutput.js";
20
21
  import { runHook } from "./hook.js";
21
22
  import { runGuard } from "./guard.js";
22
23
  import { runJudgmentChecks } from "./checks/judgmentChecks.js";
23
- import { generateReport, generateMarkdownReport } from "./report/generateReport.js";
24
+ import { generateReport, generateMarkdownReport, generateJsonReport, computeTranscriptHash } from "./report/generateReport.js";
24
25
  import { gateOffer, hookIsInstalled } from "./report/gateOffer.js";
25
26
  import { generateHtmlReport } from "./report/generateHtmlReport.js";
26
27
  import { verifySessionHash } from "./verifyHash.js";
@@ -28,6 +29,11 @@ import { saveEmailConfig, loadEmailConfig, detectSmtpHost, isValidEmail } from "
28
29
  import { sendReportEmail } from "./sendReport.js";
29
30
  import { appendHistory, readHistorySince } from "./history.js";
30
31
  import { maybeShowWhatsNew } from "./whatsNew.js";
32
+ import { verifyReceipt, parseReceipt } from "./receipt.js";
33
+ import { buildBadge } from "./badge.js";
34
+ import { buildInitGuidance } from "./init.js";
35
+ import { loadProjectConfig, handleMap, blockingFailures, warningFailures, PROJECT_CONFIG_PATH } from "./projectConfig.js";
36
+ import { maybeCheckUpdates, isUpdateCheckEnabled } from "./updateCheck.js";
31
37
  import { generateDigest } from "./digest.js";
32
38
  import { enableSchedule, disableSchedule, scheduleStatus } from "./schedule.js";
33
39
  import { findSplitBrainConflicts } from "./checks/splitBrain.js";
@@ -140,7 +146,7 @@ function writeHtmlReport(results, meta, cwd, target) {
140
146
  }
141
147
  }
142
148
  async function runCheck(opts) {
143
- const { markdown, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, showSkipped, transcriptOverride } = opts;
149
+ const { markdown, json, checkUpdates, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, showSkipped, transcriptOverride } = opts;
144
150
  const cwd = process.cwd();
145
151
  const rules = loadRules(cwd);
146
152
  if (rules.length === 0) {
@@ -258,12 +264,26 @@ async function runCheck(opts) {
258
264
  // regardless of --llm.
259
265
  const judgmentResults = llm ? await runJudgmentChecks(judgment, events) : judgment.map(({ rule }) => needsLlmResult(rule));
260
266
  const results = [...deterministicResults, ...judgmentResults];
267
+ // Severity: rules a team marked as warnings in .rulereceipt/config.json are
268
+ // still reported but do not fail the build. handleFor maps a result back to
269
+ // its stable handle so the mark survives edits that renumber rule ids.
270
+ const projectConfig = loadProjectConfig(cwd);
271
+ const handleFor = handleMap(rules);
272
+ const blockingFails = blockingFailures(results, projectConfig, handleFor);
273
+ const warnedFails = warningFailures(results, projectConfig, handleFor);
261
274
  const meta = { sessionFilePath, ruleCount: results.length };
275
+ // Kept in human/markdown form for --email and any other reader below, even
276
+ // when stdout is JSON — a manager gets a readable report, not raw JSON.
262
277
  const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta);
263
- console.log(reportText);
278
+ if (json) {
279
+ console.log(generateJsonReport(results, meta, pkg.version));
280
+ }
281
+ else {
282
+ console.log(reportText);
283
+ }
264
284
  // Shown only to someone who has just read their own broken rules, and only
265
285
  // if they have not already wired it up. See report/gateOffer.ts.
266
- if (!markdown) {
286
+ if (!markdown && !json) {
267
287
  const offer = gateOffer({
268
288
  failures: results.filter((r) => r.status === "FAIL").length,
269
289
  hookInstalled: hookIsInstalled(cwd),
@@ -288,7 +308,7 @@ async function runCheck(opts) {
288
308
  // so, and a rule dropped here never appears in the report at all. Listing
289
309
  // them needs no key, works in any language, and lets the person who wrote
290
310
  // the rule be the one who decides.
291
- if (notARule.length > 0) {
311
+ if (!json && notARule.length > 0) {
292
312
  const n = notARule.length;
293
313
  const plural = n === 1 ? "" : "s";
294
314
  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 +336,21 @@ async function runCheck(opts) {
316
336
  console.log(`Run with --show-skipped to see them.`);
317
337
  }
318
338
  }
319
- if (stale.length > 0) {
339
+ if (!json && stale.length > 0) {
320
340
  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
341
  }
342
+ if (!json && !markdown && warnedFails.length > 0) {
343
+ 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.)`);
344
+ }
322
345
  appendHistory(results, sessionFilePath);
323
346
  // A once-per-update footer so a returning user sees the tool improved and
324
347
  // 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
- if (!markdown) {
348
+ // on --markdown (that output is meant to be pasted into a PR/Slack) or
349
+ // --json (that output must be a single parseable object, nothing else).
350
+ if (!markdown && !json) {
327
351
  maybeShowWhatsNew(pkg.version);
352
+ // Opt-in only; makes no network call unless enabled. Fails open.
353
+ await maybeCheckUpdates(pkg.version, isUpdateCheckEnabled(checkUpdates));
328
354
  }
329
355
  if (share) {
330
356
  await shareResults(results);
@@ -355,7 +381,9 @@ async function runCheck(opts) {
355
381
  // rules in a real CLAUDE.md need judgment, so without --llm they
356
382
  // legitimately report UNCLEAR. Gating on those would make every build
357
383
  // red on day one and the check would be deleted within a week.
358
- if (!exitZero && results.some((r) => r.status === "FAIL")) {
384
+ // Gate on BLOCKING failures only — a rule marked warning in the project
385
+ // config is reported but does not fail the build.
386
+ if (!exitZero && blockingFails.length > 0) {
359
387
  process.exitCode = 1;
360
388
  }
361
389
  }
@@ -373,6 +401,8 @@ program
373
401
  .command("check", { isDefault: true })
374
402
  .description("Check the current project's latest Claude Code session against CLAUDE.md/AGENTS.md")
375
403
  .option("--markdown", "output as markdown, for pasting into a PR or Slack")
404
+ .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.")
405
+ .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
406
  .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
407
  .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
408
  .option("--email-always", "used with --email: send every time, even when nothing failed")
@@ -386,6 +416,8 @@ program
386
416
  .action((opts) => {
387
417
  runCheck({
388
418
  markdown: Boolean(opts.markdown),
419
+ json: Boolean(opts.json),
420
+ checkUpdates: Boolean(opts.checkUpdates),
389
421
  share: Boolean(opts.share),
390
422
  email: Boolean(opts.email),
391
423
  emailAlways: Boolean(opts.emailAlways),
@@ -479,11 +511,50 @@ function runCoverage() {
479
511
  console.log(`log or inject context, but they cannot make a rule fail when it is ignored.`);
480
512
  }
481
513
  }
514
+ /**
515
+ * `rules --advise`: for every rule the classifier can't check mechanically,
516
+ * one line saying why and the smallest edit that would fix it. The other
517
+ * half of `--coverage` — that says which rules a hook might guard; this says
518
+ * which rules can't be checked at all, and how to change that.
519
+ */
520
+ function runAdvise() {
521
+ const cwd = process.cwd();
522
+ const rules = loadRules(cwd);
523
+ if (rules.length === 0) {
524
+ console.log("No CLAUDE.md or AGENTS.md found, so there are no rules to advise on.");
525
+ return;
526
+ }
527
+ const advice = adviseRules(rules);
528
+ const checkable = rules.length - advice.length;
529
+ console.log(`Rule checkability\n`);
530
+ console.log(` ${checkable} of ${rules.length} rule${rules.length === 1 ? "" : "s"} can be checked mechanically as written.`);
531
+ if (advice.length === 0) {
532
+ console.log(`\n Every rule names something a check can bind to. Nothing to fix.`);
533
+ return;
534
+ }
535
+ console.log(` ${advice.length} cannot yet — here is what each one needs:\n`);
536
+ // Project rules first: those are the ones the reader can act on today.
537
+ const ordered = advice
538
+ .map((a, i) => ({ a, source: rules.find((r) => r.title === a.ruleTitle)?.source }))
539
+ .sort((x, y) => Number(x.source === "global") - Number(y.source === "global"))
540
+ .map((x) => x.a);
541
+ for (const a of ordered) {
542
+ const tag = a.kind === "notARule" ? "not a rule?" : "judgment";
543
+ console.log(` [${tag}] ${a.ruleTitle.replace(/\s+/g, " ").trim().slice(0, 76)}`);
544
+ console.log(` -> ${a.suggestion}\n`);
545
+ }
546
+ console.log(`Naming the exact command, file or branch a rule is about — in backticks —`);
547
+ console.log(`is what turns a "wish list" line into one this tool can hold to account.`);
548
+ }
482
549
  async function runRules(opts) {
483
550
  if (opts.coverage) {
484
551
  runCoverage();
485
552
  return;
486
553
  }
554
+ if (opts.advise) {
555
+ runAdvise();
556
+ return;
557
+ }
487
558
  const cwd = process.cwd();
488
559
  const rules = loadRules(cwd);
489
560
  const overrides = loadOverrides(cwd);
@@ -629,6 +700,11 @@ async function runLint(markdown, llm) {
629
700
  console.log("No contradictions found between CLAUDE.md and AGENTS.md.");
630
701
  return;
631
702
  }
703
+ // A contradiction between the two rule files is a real defect, not just
704
+ // information: it means the agent is being given conflicting instructions.
705
+ // Exit non-zero so CI (and the GitHub Action) can gate on it, the same way
706
+ // `check` exits 1 on a FAIL.
707
+ process.exitCode = 1;
632
708
  if (markdown) {
633
709
  const lines = ["## CLAUDE.md vs AGENTS.md — contradictions found", ""];
634
710
  for (const c of result.conflicts) {
@@ -704,6 +780,7 @@ program
704
780
  .option("--clear <handle>", "remove a stored correction")
705
781
  .option("--list", "show stored corrections (the default when no other flag is given)")
706
782
  .option("--coverage", "show which rules a configured hook might actually be enforcing, and which are prose only")
783
+ .option("--advise", "for each rule that can't be checked mechanically, show why and the smallest edit that would fix it")
707
784
  .action((opts) => {
708
785
  runRules(opts).catch((err) => {
709
786
  console.error("Something went wrong:", err instanceof Error ? err.message : err);
@@ -721,6 +798,18 @@ program
721
798
  process.exitCode = 1;
722
799
  });
723
800
  });
801
+ program
802
+ .command("init")
803
+ .description("Guided setup: shows what's configured and the exact next steps. Read-only — writes nothing.")
804
+ .action(() => {
805
+ const cwd = process.cwd();
806
+ console.log(buildInitGuidance({
807
+ hasClaudeMd: existsSync(join(cwd, "CLAUDE.md")),
808
+ hasAgentsMd: existsSync(join(cwd, "AGENTS.md")),
809
+ hookInstalled: hookIsInstalled(cwd),
810
+ hasApiKey: Boolean(process.env.ANTHROPIC_API_KEY),
811
+ }));
812
+ });
724
813
  program
725
814
  .command("demo")
726
815
  .description("See a sample report — no setup, no API key needed")
@@ -821,4 +910,64 @@ program
821
910
  process.exitCode = 1;
822
911
  }
823
912
  });
913
+ program
914
+ .command("verify-receipt <path>")
915
+ .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.")
916
+ .option("--max-age-days <n>", "reject a receipt older than N days (freshness gate)")
917
+ .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.")
918
+ .action((path, opts) => {
919
+ let text;
920
+ try {
921
+ text = readFileSync(path, "utf-8");
922
+ }
923
+ catch {
924
+ console.error(`Could not read receipt file: ${path}`);
925
+ process.exitCode = 1;
926
+ return;
927
+ }
928
+ const maxAgeDays = opts.maxAgeDays !== undefined ? Number(opts.maxAgeDays) : undefined;
929
+ if (maxAgeDays !== undefined && !Number.isFinite(maxAgeDays)) {
930
+ console.error(`--max-age-days must be a number, got: ${opts.maxAgeDays}`);
931
+ process.exitCode = 1;
932
+ return;
933
+ }
934
+ // Only pass sessionHash when a session was actually requested; null (path
935
+ // given but unreadable) is a rejection inside verifyReceipt.
936
+ const sessionHash = opts.session !== undefined ? computeTranscriptHash(opts.session) : undefined;
937
+ const res = verifyReceipt(text, { maxAgeDays, sessionHash });
938
+ if (res.ok && res.receipt) {
939
+ const r = res.receipt;
940
+ const trust = res.sessionVerified
941
+ ? "re-verified against the session (no trust needed)"
942
+ : "trusted (no session provided to re-verify against)";
943
+ 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}`);
944
+ }
945
+ else {
946
+ console.error("✕ receipt rejected:");
947
+ for (const p of res.problems)
948
+ console.error(` - ${p}`);
949
+ process.exitCode = 1;
950
+ }
951
+ });
952
+ program
953
+ .command("badge <receiptPath>")
954
+ .description("Emit a shields.io endpoint JSON from a receipt (from `check --json`), for a README badge. Commit the output and reference it: ![rules](https://img.shields.io/endpoint?url=<raw-url>)")
955
+ .action((receiptPath) => {
956
+ let text;
957
+ try {
958
+ text = readFileSync(receiptPath, "utf-8");
959
+ }
960
+ catch {
961
+ console.error(`Could not read receipt file: ${receiptPath}`);
962
+ process.exitCode = 1;
963
+ return;
964
+ }
965
+ const parsed = parseReceipt(text);
966
+ if (parsed.error || !parsed.receipt) {
967
+ console.error(`Not a valid receipt: ${parsed.error}`);
968
+ process.exitCode = 1;
969
+ return;
970
+ }
971
+ console.log(JSON.stringify(buildBadge(parsed.receipt.summary), null, 2));
972
+ });
824
973
  program.parse();
package/dist/evaluate.js CHANGED
@@ -6,6 +6,8 @@ import { runCodeContentChecks } from "./checks/codeContent.js";
6
6
  import { runFileLifecycleChecks } from "./checks/fileLifecycle.js";
7
7
  import { runClaimEvidenceChecks } from "./checks/claimEvidence.js";
8
8
  import { runEmojiChecks } from "./checks/emojiOutput.js";
9
+ import { runAttributionChecks } from "./checks/attribution.js";
10
+ import { runApprovalGateChecks } from "./checks/approvalGate.js";
9
11
  import { runJudgmentChecks } from "./checks/judgmentChecks.js";
10
12
  import { loadOverrides, ruleFingerprint, staleOverrides } from "./overrides.js";
11
13
  /**
@@ -40,6 +42,8 @@ export async function evaluateSession(cwd, rules, events, llm, needsLlmResult) {
40
42
  ...runFileLifecycleChecks(of("fileLifecycle"), events),
41
43
  ...runClaimEvidenceChecks(of("claimEvidence"), events),
42
44
  ...runEmojiChecks(of("emojiOutput"), events),
45
+ ...runAttributionChecks(of("attribution"), events),
46
+ ...runApprovalGateChecks(of("approvalGate"), events),
43
47
  ];
44
48
  const judgment = of("judgment");
45
49
  const judgmentResults = llm
package/dist/guard.js CHANGED
@@ -3,6 +3,7 @@ import { classifyRules } from "./checks/classify.js";
3
3
  import { runCodeContentChecks } from "./checks/codeContent.js";
4
4
  import { runFileLifecycleChecks } from "./checks/fileLifecycle.js";
5
5
  import { runGitBranchPolicyChecks } from "./checks/gitBranchPolicy.js";
6
+ import { runAttributionChecks } from "./checks/attribution.js";
6
7
  import { loadOverrides, ruleFingerprint, ratifiedForbids } from "./overrides.js";
7
8
  import { commandRunsLiteral } from "./checks/proposedAction.js";
8
9
  function readStdin() {
@@ -48,6 +49,11 @@ function structuredBlocks(cwd, event) {
48
49
  ...runCodeContentChecks(of("codeContent"), [event]),
49
50
  ...runFileLifecycleChecks(of("fileLifecycle"), [event]),
50
51
  ...runGitBranchPolicyChecks(of("gitBranchPolicy"), [event]),
52
+ // Prevention for the attribution rule: a commit/PR carrying a
53
+ // `Co-Authored-By: Claude` / "Generated with Claude Code" trailer is
54
+ // refused before it is made, not just reported after. Reuses the exact
55
+ // detection the report uses, so the two cannot disagree.
56
+ ...runAttributionChecks(of("attribution"), [event]),
51
57
  ];
52
58
  return results
53
59
  .filter((r) => r.status === "FAIL")
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;
@@ -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;