feat(auto-patch): enforce editScope + npm test smoke gate #19

Merged
halofourteen merged 1 commits from feat/auto-patch-edit-scope into main 2026-05-26 10:39:18 +03:00
4 changed files with 187 additions and 11 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
"tui": "tsx tui/tui.tsx",
"propose:apply": "node scripts/propose-apply.js",
"stop": "bash scripts/stop.sh",
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/stuck-incident.test.js runtime/compat.test.js"
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/stuck-incident.test.js runtime/compat.test.js scripts/edit-scope.test.js"
},
"dependencies": {
"dotenv": "^16.4.5",
+46 -10
View File
@@ -21,6 +21,8 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseEditScope, validateChangedFiles, effectiveScope } from "./edit-scope.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..");
@@ -76,6 +78,13 @@ if (proposal.status === "pending") {
const proposalText = fs.readFileSync(proposal.path, "utf8");
// Each proposal may declare an editScope in its frontmatter so the patch is
// kept to the specific module(s) the operator (or the stuck detector) marked
// as relevant. Older proposals without editScope fall back to ["runtime/"],
// matching the historical default.
const scope = effectiveScope(parseEditScope(proposalText));
log("info", `edit scope: ${JSON.stringify(scope)}`);
// Capture current main HEAD so we can roll back to it if cherry-pick fails.
const baseSha = git(["rev-parse", "HEAD"]).stdout.trim();
@@ -91,6 +100,7 @@ git(["branch", "-D", branch]); // ignore error if absent
const checkout = git(["checkout", "-b", branch]);
if (checkout.status !== 0) exit(2, `cannot create branch ${branch}: ${checkout.stderr}`);
const scopeBullet = scope.map((p) => ` - \`${p}\``).join("\n");
const prompt = [
"You are patching the pepa-pi-bot repo to address an automatically-detected failure.",
"This is an UNATTENDED run — no operator will review your output before it lands on main.",
@@ -102,12 +112,15 @@ const prompt = [
"",
"## Hard rules (non-negotiable)",
"",
"1. Touch only files under `runtime/`. Do NOT modify `tui/`, `extensions/`, `scripts/`, `docs/`, `package.json`, or anything in `state/`.",
"2. Do not introduce npm dependencies.",
"3. Do not push, do not open a PR. Commit on the current branch only.",
"4. Use a conventional commit message: `fix(runtime/<file>): <one-line summary>`.",
"5. If you can't safely fix the issue, write a short comment in the relevant runtime file explaining why and stop — do NOT make a speculative change.",
"6. Make exactly ONE commit. If you find multiple issues, focus on the one the proposal describes.",
"1. Touch ONLY files matching the edit scope below. Any other path will be rejected after you commit and the patch will be discarded:",
scopeBullet,
"2. You MAY add or update test files under `runtime/**/*.test.js` even if not listed above — tests for the fix are encouraged.",
"3. Do not introduce npm dependencies. Do not modify `package.json`, `tui/`, `extensions/`, `scripts/`, `docs/`, `.env`, or anything in `state/`.",
"4. Do not push, do not open a PR. Commit on the current branch only.",
"5. Use a conventional commit message: `fix(runtime/<file>): <one-line summary>`.",
"6. Run `npm test` mentally before committing — your patch must keep all existing tests green; the auto-patcher will run `npm test` and discard the patch if it fails.",
"7. If you can't safely fix the issue, write a short comment in the relevant runtime file explaining why and stop — do NOT make a speculative change.",
"8. Make exactly ONE commit. If you find multiple issues, focus on the one the proposal describes.",
"",
"After you commit, your job is done.",
].join("\n");
@@ -152,16 +165,39 @@ pi.on("exit", (code) => {
exit(1, "pi made no commit");
}
// Verify the commit touched only runtime/.
// Verify the commit only touched files inside the declared edit scope.
// Test files under runtime/**/*.test.js are always allowed — they're how
// Pi proves the fix is safe and how the smoke gate below checks pass.
const filesChanged = git(["diff", "--name-only", `${baseSha}..HEAD`]).stdout.trim().split("\n").filter(Boolean);
const outside = filesChanged.filter((f) => !f.startsWith("runtime/"));
if (outside.length > 0) {
log("error", `commit touched files outside runtime/: ${outside.join(", ")} — discarding`);
const testFiles = filesChanged.filter((f) => /^runtime\/.*\.test\.js$/.test(f));
const scopeWithTests = [...scope, ...testFiles];
const validation = validateChangedFiles(filesChanged, scopeWithTests);
if (!validation.ok) {
log("error", `commit touched files outside scope ${JSON.stringify(scope)}: ${validation.outsideFiles.join(", ")} — discarding`);
git(["checkout", "main"]);
git(["branch", "-D", branch]);
exit(2, "patch touched off-limits files");
}
// Smoke gate: run `npm test` on the patched branch BEFORE cherry-picking.
// Anything that turns the suite red gets thrown away — even if Pi thinks
// the change is correct.
log("info", "running npm test smoke gate (timeout 5 min)");
const smoke = spawnSync("npm", ["test"], {
cwd: REPO_ROOT,
encoding: "utf8",
env: { ...process.env, CI: "1" },
timeout: 5 * 60 * 1000,
});
if (smoke.status !== 0) {
const tail = ((smoke.stdout || "") + "\n" + (smoke.stderr || "")).split("\n").slice(-10).join("\n");
log("error", `npm test FAILED on patched branch — discarding\n${tail}`);
git(["checkout", "main"]);
git(["branch", "-D", branch]);
exit(2, "patch failed smoke (npm test)");
}
log("info", "smoke gate passed");
// Cherry-pick onto main.
git(["checkout", "main"]);
const cherry = git(["cherry-pick", newHead]);
+58
View File
@@ -0,0 +1,58 @@
// Helpers for auto-patch.js — kept separate so they can be unit-tested
// without spawning git/pi subprocesses.
const DEFAULT_SCOPE = ["runtime/"];
// Parse `editScope: [...]` out of a proposal markdown frontmatter block.
// Returns the array of path prefixes, or null if absent or malformed.
// Callers should fall back to DEFAULT_SCOPE when null.
export function parseEditScope(proposalText) {
if (!proposalText) return null;
const m = String(proposalText).match(/^---\n([\s\S]*?)\n---/);
if (!m) return null;
const scopeLine = m[1].split("\n").find((l) => l.trim().startsWith("editScope:"));
if (!scopeLine) return null;
const raw = scopeLine.slice(scopeLine.indexOf(":") + 1).trim();
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
const cleaned = parsed
.filter((p) => typeof p === "string" && p.length > 0)
.map((p) => p.replace(/^\.?\/+/, "")); // strip leading ./ or /
return cleaned.length > 0 ? cleaned : null;
} catch {
return null;
}
}
// A changed file is in-scope when it matches any entry in `scope`.
// An entry ending with `/` is a directory prefix; anything else is an
// exact-match file path. The match is case-sensitive (we're on POSIX
// repos).
export function isFileInScope(file, scope) {
for (const entry of scope) {
if (!entry) continue;
if (entry.endsWith("/")) {
if (file === entry.slice(0, -1)) return true; // edge case
if (file.startsWith(entry)) return true;
} else if (file === entry) {
return true;
} else if (file.startsWith(`${entry}/`)) {
// allow "runtime/skills" to cover "runtime/skills/foo.js"
return true;
}
}
return false;
}
export function validateChangedFiles(changedFiles, scope) {
const effective = Array.isArray(scope) && scope.length ? scope : DEFAULT_SCOPE;
const outside = (changedFiles ?? []).filter((f) => !isFileInScope(f, effective));
return { ok: outside.length === 0, outsideFiles: outside, effectiveScope: effective };
}
export function effectiveScope(scope) {
return Array.isArray(scope) && scope.length ? scope : DEFAULT_SCOPE.slice();
}
export const _internal = { DEFAULT_SCOPE };
+82
View File
@@ -0,0 +1,82 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseEditScope, isFileInScope, validateChangedFiles, effectiveScope } from "./edit-scope.js";
test("parseEditScope returns null when no frontmatter present", () => {
assert.equal(parseEditScope(""), null);
assert.equal(parseEditScope("# no frontmatter\nbody"), null);
});
test("parseEditScope returns null when frontmatter has no editScope key", () => {
const text = "---\nkind: test\napproved: false\n---\nbody";
assert.equal(parseEditScope(text), null);
});
test("parseEditScope reads a JSON array", () => {
const text = '---\nkind: test\neditScope: ["runtime/skills/eat.js","runtime/skills/"]\n---\nbody';
assert.deepEqual(parseEditScope(text), ["runtime/skills/eat.js", "runtime/skills/"]);
});
test("parseEditScope strips leading ./ and / from entries", () => {
const text = '---\neditScope: ["./runtime/", "/scripts/edit-scope.js"]\n---\n';
assert.deepEqual(parseEditScope(text), ["runtime/", "scripts/edit-scope.js"]);
});
test("parseEditScope returns null for malformed JSON", () => {
const text = "---\neditScope: not-json\n---\n";
assert.equal(parseEditScope(text), null);
});
test("isFileInScope: directory prefix matches descendants", () => {
assert.equal(isFileInScope("runtime/skills/eat.js", ["runtime/skills/"]), true);
assert.equal(isFileInScope("runtime/bot.js", ["runtime/skills/"]), false);
});
test("isFileInScope: bare directory name matches as prefix", () => {
// "runtime/skills" should still cover "runtime/skills/foo.js"
assert.equal(isFileInScope("runtime/skills/foo.js", ["runtime/skills"]), true);
// but not "runtime/skillsX/foo.js"
assert.equal(isFileInScope("runtime/skillsX/foo.js", ["runtime/skills"]), false);
});
test("isFileInScope: exact file match", () => {
assert.equal(isFileInScope("runtime/bot.js", ["runtime/bot.js"]), true);
assert.equal(isFileInScope("runtime/bot.js.bak", ["runtime/bot.js"]), false);
});
test("validateChangedFiles: all in-scope → ok", () => {
const out = validateChangedFiles(
["runtime/skills/eat.js", "runtime/skills/groups.js"],
["runtime/skills/"],
);
assert.equal(out.ok, true);
assert.deepEqual(out.outsideFiles, []);
});
test("validateChangedFiles: any out-of-scope → not ok, list returned", () => {
const out = validateChangedFiles(
["runtime/skills/eat.js", "package.json", ".env"],
["runtime/skills/"],
);
assert.equal(out.ok, false);
assert.deepEqual(out.outsideFiles.sort(), [".env", "package.json"]);
});
test("validateChangedFiles: falls back to default scope when empty", () => {
const out = validateChangedFiles(["runtime/bot.js"], null);
assert.equal(out.ok, true);
assert.deepEqual(out.effectiveScope, ["runtime/"]);
});
test("validateChangedFiles: default scope rejects scripts/", () => {
const out = validateChangedFiles(["scripts/auto-patch.js"], []);
assert.equal(out.ok, false);
assert.deepEqual(out.outsideFiles, ["scripts/auto-patch.js"]);
});
test("effectiveScope mirrors fallback", () => {
assert.deepEqual(effectiveScope(null), ["runtime/"]);
assert.deepEqual(effectiveScope([]), ["runtime/"]);
assert.deepEqual(effectiveScope(["x/"]), ["x/"]);
});