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>
59 lines
2.1 KiB
JavaScript
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 };
|