259 lines
9.7 KiB
JavaScript
259 lines
9.7 KiB
JavaScript
#!/usr/bin/env node
|
||
// scripts/check/check-pr-evidence.mjs
|
||
// Gate: Hard Rule #18 — "evidence before assertions".
|
||
//
|
||
// When a PR body makes claims about test/validation success (e.g. "tests pass",
|
||
// "all green", "fixed", "added endpoint") it MUST include a block of command
|
||
// output as proof. A PR body with claims but no attached evidence => FAIL.
|
||
//
|
||
// Skip policy: when not running in a PR context (no PR_BODY env var and `gh pr
|
||
// view` is unavailable), exits 0 silently so the gate never blocks local dev.
|
||
//
|
||
// Conservative by design (two-signal requirement):
|
||
// - A PR body with NO claim-trigger terms always passes.
|
||
// - A PR body with a claim but also an evidence block always passes.
|
||
// - Only the combination of claim(s) + NO evidence block => FAIL.
|
||
//
|
||
// What counts as a TRIGGER (claim term)?
|
||
// See CLAIM_TRIGGERS below. We require at least ONE strong trigger OR two
|
||
// weak triggers to fire (reduces false positives from casual phrases).
|
||
//
|
||
// What counts as EVIDENCE?
|
||
// - A fenced code block (``` ... ```) that contains ≥1 line of output
|
||
// characters (not just whitespace/backticks). The block must look like
|
||
// terminal/command output: numbers, colons, path separators, status words.
|
||
// - OR an explicit "Evidence" / "Validation" / "Test output" / "Output"
|
||
// section header (##/### prefix) followed by non-empty content.
|
||
// - OR an inline `code span` that matches common test-runner patterns
|
||
// (e.g. "passing", "failed 0", "✓", "PASS", "ok").
|
||
|
||
import { execFileSync, spawnSync } from "node:child_process";
|
||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||
import path from "node:path";
|
||
import { pathToFileURL } from "node:url";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Claim triggers
|
||
// ---------------------------------------------------------------------------
|
||
// STRONG triggers: single occurrence is sufficient to fire.
|
||
// These are explicit outcome/validation assertions.
|
||
const STRONG_TRIGGERS = [
|
||
/\ball\s+(?:tests?\s+)?(?:pass(?:ed|ing)?|green)\b/i,
|
||
/\btests?\s+(?:pass(?:ed|ing)?|are\s+(?:green|passing))\b/i,
|
||
/\b(?:typecheck|lint|build)\s+(?:pass(?:ed|ing)?|(?:is\s+)?clean|(?:is\s+)?green|(?:is\s+)?ok)\b/i,
|
||
/\bvalidated\s+(?:on|in|via|with|against|locally|on\s+vps)\b/i,
|
||
/\b(?:zero|0)\s+(?:errors?|failures?|regressions?)\b/i,
|
||
/\btdd\b.*\b(?:pass(?:ed|ing)?|green)\b/i,
|
||
/\bfixed\b.*\band\s+(?:verified|confirmed|tested)\b/i,
|
||
/\bproof\s*:/i,
|
||
];
|
||
|
||
// WEAK triggers: need at least 2 present to fire.
|
||
// These are common "seems fine" phrases that alone don't warrant evidence.
|
||
const WEAK_TRIGGERS = [
|
||
/\b(?:added|implemented|created)\s+(?:a\s+)?(?:new\s+)?(?:endpoint|route|test|handler|check)\b/i,
|
||
/\bfixed\b/i,
|
||
/\bresolves?\s+#\d+/i,
|
||
/\bshould\s+(?:work|pass|be\s+(?:fine|ok|green))\b/i,
|
||
/\b(?:verified|confirmed|validated)\b/i,
|
||
/\b(?:all|no)\s+(?:regressions?|breaking\s+changes?)\b/i,
|
||
/\blooks?\s+(?:good|fine|ok)\b/i,
|
||
/\bworks?\s+(?:correctly|fine|as\s+expected)\b/i,
|
||
];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Evidence patterns
|
||
// ---------------------------------------------------------------------------
|
||
// 1. A fenced code block with "command-output-like" content.
|
||
// The block content must have at least one line that looks like output
|
||
// (contains digit sequences, colons, path separators, or known status tokens).
|
||
const FENCED_BLOCK_RE = /```[^\n]*\n([\s\S]*?)```/g;
|
||
const OUTPUT_LINE_RE =
|
||
/(?:\d+\s+(?:pass(?:ing)?|fail(?:ing)?|pending)|[✓✗ו]\s|\bPASS\b|\bFAIL\b|\bok\b|\berror\b.*\d|\bwarning\b.*\d|\d+\s+(?:test|spec|suite)|exit\s+code\s*[0-9]|at\s+\S+:\d+|[A-Za-z]+:\s*\d+|^\s*\d+\s+\w)/im;
|
||
|
||
// 2. An explicit evidence/validation section header followed by non-blank content.
|
||
const EVIDENCE_SECTION_RE =
|
||
/^#{1,4}\s+(?:evidence|validation|test\s+(?:output|results?|run)|output|verified|proof)\b[^\n]*/im;
|
||
|
||
// 3. An inline code span matching common test-runner result tokens.
|
||
const INLINE_RESULT_RE =
|
||
/`[^`]*(?:\d+\s+(?:pass(?:ing)?|fail(?:ing)?|test)|✓|✗|PASS(?:ED)?|FAIL(?:ED)?|all\s+\d+)[^`]*`/i;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pure evaluation function (exported for tests)
|
||
// ---------------------------------------------------------------------------
|
||
/**
|
||
* Evaluate a PR body string.
|
||
*
|
||
* @param {string} body
|
||
* @returns {{ result: "pass" | "fail" | "skip"; reason: string }}
|
||
*/
|
||
export function evaluatePrBody(body) {
|
||
if (!body || body.trim().length === 0) {
|
||
return { result: "skip", reason: "PR body is empty — nothing to evaluate." };
|
||
}
|
||
|
||
// --- 1. Detect claims ---
|
||
const strongMatches = STRONG_TRIGGERS.filter((re) => re.test(body));
|
||
const weakMatches = WEAK_TRIGGERS.filter((re) => re.test(body));
|
||
const hasClaim = strongMatches.length >= 1 || weakMatches.length >= 2;
|
||
|
||
if (!hasClaim) {
|
||
return { result: "pass", reason: "No outcome-claim terms detected — no evidence required." };
|
||
}
|
||
|
||
// --- 2. Detect evidence ---
|
||
const hasEvidence = hasEvidenceBlock(body);
|
||
|
||
if (hasEvidence) {
|
||
return {
|
||
result: "pass",
|
||
reason: "Outcome claim detected and evidence block found.",
|
||
};
|
||
}
|
||
|
||
// Build a helpful message listing which triggers fired.
|
||
const firedStrong = strongMatches.map((re) => re.source);
|
||
const firedWeak = weakMatches.map((re) => re.source);
|
||
return {
|
||
result: "fail",
|
||
reason:
|
||
"PR body contains outcome claims but no evidence block (command output, Evidence section, or inline result span).\n" +
|
||
(firedStrong.length > 0 ? ` Strong triggers matched: ${firedStrong.join(", ")}\n` : "") +
|
||
(firedWeak.length >= 2 ? ` Weak triggers matched (≥2): ${firedWeak.join(", ")}\n` : "") +
|
||
"\n" +
|
||
"Hard Rule #18 requires proof that the fix works:\n" +
|
||
" a) Add a fenced code block (```) containing test-runner or command output, OR\n" +
|
||
" b) Add a section headed '## Evidence', '## Validation', '## Test output', etc., OR\n" +
|
||
" c) Add an inline code span with a result token (e.g. `42 passing`, `PASSED`).\n" +
|
||
"See CLAUDE.md → Hard Rule #18.",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Returns true if the body contains at least one recognised evidence block.
|
||
* @param {string} body
|
||
* @returns {boolean}
|
||
*/
|
||
function hasEvidenceBlock(body) {
|
||
// Check fenced code blocks for output-like content.
|
||
FENCED_BLOCK_RE.lastIndex = 0;
|
||
let match;
|
||
while ((match = FENCED_BLOCK_RE.exec(body)) !== null) {
|
||
const blockContent = match[1] ?? "";
|
||
if (blockContent.trim().length > 0 && OUTPUT_LINE_RE.test(blockContent)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Check for explicit evidence/validation section header with non-empty body.
|
||
if (EVIDENCE_SECTION_RE.test(body)) {
|
||
// Ensure there's actual content after the header.
|
||
const afterHeader = body.replace(EVIDENCE_SECTION_RE, "").trim();
|
||
if (afterHeader.length > 20) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Check for inline code span containing result tokens.
|
||
if (INLINE_RESULT_RE.test(body)) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CLI entry point
|
||
// ---------------------------------------------------------------------------
|
||
function getArg(name, fallbackValue = "") {
|
||
const index = process.argv.indexOf(name);
|
||
if (index === -1 || index === process.argv.length - 1) return fallbackValue;
|
||
return process.argv[index + 1];
|
||
}
|
||
|
||
function buildReport(lines) {
|
||
return `${lines.join("\n")}\n`;
|
||
}
|
||
|
||
// Only run as CLI entry point.
|
||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||
|
||
if (isMain) {
|
||
const summaryFile = getArg("--summary-file", "");
|
||
|
||
// --- Resolve PR body ---
|
||
let prBody = null;
|
||
|
||
// Priority 1: PR_BODY env var (set by CI / manual invocation).
|
||
if (typeof process.env.PR_BODY === "string") {
|
||
prBody = process.env.PR_BODY;
|
||
}
|
||
|
||
// Priority 2: --body-file argument.
|
||
const bodyFile = getArg("--body-file", "");
|
||
if (prBody === null && bodyFile && existsSync(bodyFile)) {
|
||
prBody = readFileSync(bodyFile, "utf8");
|
||
}
|
||
|
||
// Priority 3: `gh pr view` (only available inside a PR context).
|
||
if (prBody === null) {
|
||
const ghResult = spawnSync("gh", ["pr", "view", "--json", "body", "--jq", ".body"], {
|
||
encoding: "utf8",
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
});
|
||
if (ghResult.status === 0 && ghResult.stdout.trim()) {
|
||
prBody = ghResult.stdout.trim();
|
||
}
|
||
}
|
||
|
||
// No PR context available — skip silently.
|
||
if (prBody === null) {
|
||
const report = buildReport([
|
||
"## PR Evidence Gate",
|
||
"",
|
||
"Skipped: no PR body available (PR_BODY not set, --body-file not provided, gh pr view unavailable).",
|
||
]);
|
||
if (summaryFile) {
|
||
mkdirSync(path.dirname(summaryFile), { recursive: true });
|
||
writeFileSync(summaryFile, report);
|
||
}
|
||
process.stdout.write(report);
|
||
process.exit(0);
|
||
}
|
||
|
||
const { result, reason } = evaluatePrBody(prBody);
|
||
|
||
const reportLines = ["## PR Evidence Gate", ""];
|
||
|
||
if (result === "skip") {
|
||
reportLines.push("Result: SKIP", "", reason);
|
||
} else if (result === "pass") {
|
||
reportLines.push("Result: PASS", "", reason);
|
||
} else {
|
||
reportLines.push(
|
||
"Result: FAIL",
|
||
"",
|
||
reason,
|
||
"",
|
||
"> ℹ️ Editing the PR body to add the evidence does NOT re-run this gate — `ci.yml` " +
|
||
"does not listen to the `edited` event. Add the `## Evidence` block, then **push a " +
|
||
"commit** (or re-run this job) to re-validate. For releases, put the Evidence block in " +
|
||
"the body BEFORE the first push (see the generate-release skill, Phase 0)."
|
||
);
|
||
}
|
||
|
||
const report = buildReport(reportLines);
|
||
|
||
if (summaryFile) {
|
||
mkdirSync(path.dirname(summaryFile), { recursive: true });
|
||
writeFileSync(summaryFile, report);
|
||
}
|
||
|
||
process.stdout.write(report);
|
||
|
||
if (result === "fail") {
|
||
process.exit(1);
|
||
}
|
||
}
|