replume-cli 0.2.1 → 0.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replume-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "replume": "dist/cli.js"
@@ -10,7 +10,8 @@
10
10
  "test": "vitest run",
11
11
  "typecheck": "tsc --noEmit",
12
12
  "check-context": "node scripts/check-context.mjs",
13
- "prepublishOnly": "npm run build && npm run typecheck && npm run check-context && npm test"
13
+ "prepublishOnly": "npm run build && npm run typecheck && npm run check-context && npm test",
14
+ "eval": "node eval/run.mjs"
14
15
  },
15
16
  "dependencies": {
16
17
  "commander": "^12.1.0"
@@ -34,6 +35,8 @@
34
35
  },
35
36
  "files": [
36
37
  "dist/cli.js",
38
+ "templates/pr-check.yml",
39
+ "templates/pr-check-publish.mjs",
37
40
  "README.md",
38
41
  "LICENSE",
39
42
  "THIRD-PARTY-NOTICES.md"
@@ -0,0 +1,125 @@
1
+ // Copy beside the workflow as .github/replume-pr-check-publish.mjs on the trusted base branch.
2
+ // The publisher reads the verify artifact; --check-policy reads committed policy from the PR base.
3
+ import { execFileSync } from "node:child_process";
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+
7
+ const marker = "<!-- replume-pr-check -->";
8
+ const text = (value) => String(value ?? "").replace(/[\r\n\t]+/g, " ").replace(/[<>`]/g, "").slice(0, 350);
9
+ const items = (entries) => entries.length ? entries.map((entry) => `- ${entry}`).join("\n") : "- None recorded.";
10
+ const contractPath = ".replume/contract.yaml";
11
+
12
+ // Replume's contract writer emits a flow or indented list. Fail closed if the base has no non-empty protect list.
13
+ function hasProtectedTests(source) {
14
+ const lines = source.split(/\r?\n/);
15
+ const index = lines.findIndex((line) => /^protect:(?:\s|$)/.test(line));
16
+ if (index < 0) return false;
17
+ const value = lines[index].slice("protect:".length).replace(/\s+#.*$/, "").trim();
18
+ if (value) return /^\[.*\]$/.test(value) && value.slice(1, -1).trim().length > 0;
19
+ for (const line of lines.slice(index + 1)) {
20
+ const content = line.replace(/\s+#.*$/, "").trimEnd();
21
+ if (!content.trim()) continue;
22
+ if (!/^\s/.test(content)) break;
23
+ if (/^\s+-\s+\S/.test(content)) return true;
24
+ }
25
+ return false;
26
+ }
27
+
28
+ export function checkBasePolicy(baseSha, repo = process.cwd()) {
29
+ if (!/^[0-9a-f]{40}$/i.test(baseSha ?? "")) throw new Error("PR base SHA must be a full commit hash");
30
+ let source;
31
+ try {
32
+ source = execFileSync("git", ["show", `${baseSha}:${contractPath}`], { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
33
+ } catch {
34
+ return `Commit ${contractPath} with a confirmed task and non-empty protect test globs to the PR base branch.`;
35
+ }
36
+ return hasProtectedTests(source) ? null : `Commit non-empty protect test globs in ${contractPath} on the PR base branch.`;
37
+ }
38
+
39
+ function result(reportPath, exitPath, policyPath = join(dirname(reportPath), "policy.txt")) {
40
+ let policyNote = "";
41
+ try { if (existsSync(policyPath)) policyNote = readFileSync(policyPath, "utf8").trim().split(/\r?\n/)[0]; } catch { /* incomplete below */ }
42
+ if (policyNote) return { report: null, verdict: "INCOMPLETE", reason: policyNote };
43
+ let report = null;
44
+ try { if (existsSync(reportPath)) report = JSON.parse(readFileSync(reportPath, "utf8")); } catch { /* incomplete below */ }
45
+ let exit = "";
46
+ try { if (existsSync(exitPath)) exit = readFileSync(exitPath, "utf8").trim(); } catch { /* incomplete below */ }
47
+ const expected = report?.verdict === "pass" ? "0" : report?.verdict === "fail" ? "1" : report?.verdict === "incomplete" ? "2" : "";
48
+ const verdict = expected && exit === expected ? report.verdict.toUpperCase() : "INCOMPLETE";
49
+ return { report: report && typeof report === "object" ? report : null, verdict,
50
+ reason: verdict === "INCOMPLETE" && expected !== exit ? "Verification did not produce a usable, matching result." : "" };
51
+ }
52
+
53
+ export function commentBody(reportPath, exitPath, policyPath) {
54
+ const { report, verdict, reason } = result(reportPath, exitPath, policyPath);
55
+ const done = Array.isArray(report?.done) ? report.done : [];
56
+ const failed = Array.isArray(report?.failed) ? report.failed : [];
57
+ const extra = Array.isArray(report?.extra) ? report.extra : [];
58
+ const tampering = Array.isArray(report?.tampering) ? report.tampering : [];
59
+ const notChecked = Array.isArray(report?.notChecked) ? report.notChecked : [];
60
+ const verified = done.map((entry) => `${text(entry.id)}: ${text(entry.evidence)}`);
61
+ const failures = failed.map((entry) => `${text(entry.id)}: ${text(entry.evidence)}`);
62
+ const beyond = [
63
+ ...extra.map((entry) => `${text(entry.path)}: ${text(entry.reason)}${entry.informational ? " (information only)" : ""}`),
64
+ ...tampering.map((entry) => `${text(entry.path)}: ${text(entry.detail)}`),
65
+ ];
66
+ const unchecked = [
67
+ ...notChecked.map((entry) => `${text(entry.path)}: ${text(entry.reason)}`),
68
+ "CI did not observe agent steps; this result covers the commit diff and checks run here.",
69
+ ];
70
+ if (reason) unchecked.unshift(reason);
71
+ return [marker, `**Replume: ${verdict}${reason ? ` — ${text(reason)}` : ""}**`, "",
72
+ "## Verified", items(verified), "",
73
+ ...(failures.length ? ["## Failed", items(failures), ""] : []),
74
+ "## Changed beyond the task", items(beyond), "",
75
+ "## Not checked", items(unchecked), "",
76
+ "_Tests ran in an isolated working copy. This comment was made from the saved CI result; it did not rerun verification._",
77
+ ].join("\n");
78
+ }
79
+
80
+ async function api(path, method, token, body) {
81
+ const base = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, "");
82
+ const response = await fetch(`${base}${path}`, {
83
+ method, signal: AbortSignal.timeout(15_000),
84
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json" },
85
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
86
+ });
87
+ if (!response.ok) throw new Error(`GitHub ${method} ${path} returned HTTP ${response.status}`);
88
+ return response.json();
89
+ }
90
+
91
+ export async function publish(env = process.env) {
92
+ const repo = env.GITHUB_REPOSITORY, pr = Number(env.PR_NUMBER), sha = env.HEAD_SHA, token = env.GITHUB_TOKEN;
93
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo ?? "") || !Number.isSafeInteger(pr) || pr < 1 || !/^[a-fA-F0-9]{40}$/.test(sha ?? "") || !token)
94
+ throw new Error("Missing or invalid GitHub repository, PR, head SHA, or token");
95
+ const reportPath = env.REPLUME_REPORT || "replume-ci/report.json";
96
+ const exitPath = env.REPLUME_VERIFY_EXIT_FILE || "replume-ci/exit.txt";
97
+ const policyPath = env.REPLUME_POLICY_NOTE || join(dirname(reportPath), "policy.txt");
98
+ const body = commentBody(reportPath, exitPath, policyPath);
99
+ const verdict = result(reportPath, exitPath, policyPath).verdict;
100
+ let existing = null;
101
+ for (let page = 1; page <= 20; page++) {
102
+ const comments = await api(`/repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`, "GET", token);
103
+ if (!Array.isArray(comments)) throw new Error("GitHub comments response was not a list");
104
+ existing = comments.find((entry) => entry?.body?.startsWith(marker) && entry?.user?.login === "github-actions[bot]") ?? null;
105
+ if (existing || comments.length < 100) break;
106
+ }
107
+ if (existing) await api(`/repos/${repo}/issues/comments/${existing.id}`, "PATCH", token, { body });
108
+ else await api(`/repos/${repo}/issues/${pr}/comments`, "POST", token, { body });
109
+ const conclusion = verdict === "PASS" ? "success" : verdict === "FAIL" ? "failure" : "neutral";
110
+ await api(`/repos/${repo}/check-runs`, "POST", token, {
111
+ name: "Replume / task", head_sha: sha, status: "completed", conclusion,
112
+ output: { title: `Replume: ${verdict}`, summary: body.slice(0, 60000) },
113
+ });
114
+ process.stdout.write(`Replume: ${verdict}; ${existing ? "updated" : "posted"} one PR comment; check ${conclusion}.\n`);
115
+ }
116
+
117
+ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
118
+ if (process.argv[2] === "--check-policy") {
119
+ try {
120
+ const reason = checkBasePolicy(process.argv[3]);
121
+ writeFileSync(process.argv[4], reason ? `${reason}\n` : "");
122
+ if (reason) { process.stdout.write(`Replume: INCOMPLETE — ${reason}\n`); process.exitCode = 2; }
123
+ } catch (error) { process.stderr.write(`replume-pr-check: ${error.message}\n`); process.exitCode = 2; }
124
+ } else publish().catch((error) => { process.stderr.write(`replume-pr-check: ${error.message}\n`); process.exitCode = 1; });
125
+ }
@@ -0,0 +1,94 @@
1
+ name: Replume PR check
2
+ on: pull_request
3
+
4
+ concurrency:
5
+ group: replume-pr-${{ github.event.pull_request.number }}
6
+ cancel-in-progress: true
7
+
8
+ jobs:
9
+ verify:
10
+ name: Replume / verify
11
+ runs-on: ubuntu-latest
12
+ timeout-minutes: 15
13
+ permissions:
14
+ contents: read
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ fetch-depth: 0
19
+ persist-credentials: false
20
+ - uses: actions/setup-node@v4
21
+ with:
22
+ node-version: '20'
23
+ - run: mkdir -p "$RUNNER_TEMP/replume-ci"
24
+ - run: npm install -g replume-cli@0.2.3
25
+ - name: Install project dependencies
26
+ if: ${{ hashFiles('package-lock.json') != '' }}
27
+ run: npm ci
28
+ - name: Verify the PR merge commit against base policy
29
+ shell: bash
30
+ env:
31
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
32
+ run: |
33
+ set +e
34
+ if ! git show "$BASE_SHA:.github/replume-pr-check-publish.mjs" > "$RUNNER_TEMP/replume-base-publisher.mjs"; then
35
+ printf '%s\n' 'Commit .github/replume-pr-check-publish.mjs to the PR base branch.' > "$RUNNER_TEMP/replume-ci/policy.txt"
36
+ printf '2\n' > "$RUNNER_TEMP/replume-ci/exit.txt"
37
+ exit 0
38
+ fi
39
+ node "$RUNNER_TEMP/replume-base-publisher.mjs" --check-policy "$BASE_SHA" "$RUNNER_TEMP/replume-ci/policy.txt"
40
+ if [ "$?" -ne 0 ]; then
41
+ printf '2\n' > "$RUNNER_TEMP/replume-ci/exit.txt"
42
+ exit 0
43
+ fi
44
+ replume verify --commit HEAD --trusted-base "$BASE_SHA" --isolation worktree --json > "$RUNNER_TEMP/replume-ci/report.json" 2> "$RUNNER_TEMP/replume-ci/verify.log"
45
+ code=$?
46
+ printf '%s\n' "$code" > "$RUNNER_TEMP/replume-ci/exit.txt"
47
+ cat "$RUNNER_TEMP/replume-ci/verify.log"
48
+ exit 0
49
+ - uses: actions/upload-artifact@v4
50
+ if: always()
51
+ with:
52
+ name: replume-pr-result
53
+ path: ${{ runner.temp }}/replume-ci/
54
+ if-no-files-found: error
55
+ - name: Require Replume PASS
56
+ if: always()
57
+ shell: bash
58
+ run: |
59
+ if [ ! -f "$RUNNER_TEMP/replume-ci/exit.txt" ]; then exit 2; fi
60
+ exit "$(cat "$RUNNER_TEMP/replume-ci/exit.txt")"
61
+
62
+ publish:
63
+ name: Replume / PR result
64
+ if: ${{ always() && github.event.pull_request.head.repo.full_name == github.repository }}
65
+ needs: verify
66
+ runs-on: ubuntu-latest
67
+ timeout-minutes: 5
68
+ permissions:
69
+ contents: read
70
+ pull-requests: write
71
+ checks: write
72
+ steps:
73
+ - uses: actions/checkout@v4
74
+ with:
75
+ ref: ${{ github.event.pull_request.base.sha }}
76
+ persist-credentials: false
77
+ - uses: actions/setup-node@v4
78
+ with:
79
+ node-version: '20'
80
+ - uses: actions/download-artifact@v4
81
+ continue-on-error: true
82
+ with:
83
+ name: replume-pr-result
84
+ path: replume-ci/
85
+ - name: Update one PR comment and the Replume check
86
+ env:
87
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
88
+ GITHUB_REPOSITORY: ${{ github.repository }}
89
+ PR_NUMBER: ${{ github.event.pull_request.number }}
90
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
91
+ REPLUME_REPORT: replume-ci/report.json
92
+ REPLUME_VERIFY_EXIT_FILE: replume-ci/exit.txt
93
+ REPLUME_POLICY_NOTE: replume-ci/policy.txt
94
+ run: node .github/replume-pr-check-publish.mjs