Files
82a8250a12 feat(auto-patch): enforce proposal editScope + run npm test before cherry-pick (#19)
Two safety rails on the unattended self-improvement loop:

1. scripts/edit-scope.js + .test.js: pure helpers that parse
   `editScope: [...]` out of a proposal frontmatter (the field Phase 6
   started writing) and validate a list of changed files against it.
   13 tests covering null/missing/malformed frontmatter, directory
   prefix matching, exact-file matching, default-scope fallback.

2. scripts/auto-patch.js:
   - reads editScope from the proposal (falls back to ["runtime/"])
   - injects the allowed paths into the Pi prompt so Pi knows the
     boundaries up front
   - validates the diff against scope + auto-allows any
     runtime/**/*.test.js files Pi added
   - runs `npm test` on the patched branch BEFORE cherry-picking;
     refuses to land a patch that breaks the suite

Closes the "Smoke checks run before applying patch" item from
plans/autonomous-survival-bot-prd.md §7 Phase 6. npm test now 92/92.

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:39:18 +03:00

59 lines
2.1 KiB
JavaScript

// 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 };