fix(verify): ignore stale repo verification commands

This commit is contained in:
Mikael Hugo 2026-05-15 06:11:57 +02:00
parent 50383eb2bf
commit 7e2f62ead3
2 changed files with 156 additions and 31 deletions

View file

@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, test } from "vitest";
import {
discoverCommands,
hasMissingProjectPathReferences,
} from "../verification-gate.js";
const tmpRoots = [];
afterEach(() => {
for (const dir of tmpRoots.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeProject() {
const root = mkdtempSync(join(tmpdir(), "sf-verify-discovery-"));
tmpRoots.push(root);
return root;
}
test("discoverCommands_when_preferences_reference_missing_repo_dirs_falls_back_to_python_project", () => {
const root = makeProject();
writeFileSync(join(root, "pyproject.toml"), "[project]\nname = 'demo'\n");
const staleCommand =
"bash -c 'set -e; for d in \"scanner\" \"supervisor\"; do (cd \"$d\" && cargo check); done'";
const result = discoverCommands({
cwd: root,
preferenceCommands: [staleCommand, "uv run pytest -x"],
});
assert.deepEqual(result, {
commands: ["python -m pytest -q"],
source: "python-project",
});
});
test("discoverCommands_when_for_loop_dirs_exist_keeps_preference_commands", () => {
const root = makeProject();
mkdirSync(join(root, "scanner"));
mkdirSync(join(root, "supervisor"));
const command =
"bash -c 'set -e; for d in \"scanner\" \"supervisor\"; do (cd \"$d\" && cargo check); done'";
const result = discoverCommands({
cwd: root,
preferenceCommands: [command],
});
assert.deepEqual(result, {
commands: [command],
source: "preference",
});
});
test("hasMissingProjectPathReferences_detects_absent_for_loop_cd_targets", () => {
const root = makeProject();
assert.equal(
hasMissingProjectPathReferences(
root,
"bash -c 'for d in \"scanner\"; do (cd \"$d\" && cargo test); done'",
),
true,
);
});

View file

@ -20,6 +20,75 @@ function truncate(value, maxBytes) {
}
/** Package.json script keys to probe, in order. */
const PACKAGE_SCRIPT_KEYS = ["typecheck", "lint", "test"];
function extractForLoopCdDirs(command) {
const match = command.match(/for\s+d\s+in\s+(.+?);\s*do\s*\(\s*cd\s+["']?\$d["']?/s);
if (!match) return null;
const segment = match[1];
const dirs = [];
const quoted = segment.matchAll(/"([^"]+)"|'([^']+)'/g);
for (const m of quoted) {
const value = (m[1] ?? m[2] ?? "").trim();
if (value) dirs.push(value);
}
return dirs.length > 0 ? dirs : null;
}
/**
* Return true when a verification command obviously belongs to another repo.
*
* Purpose: prevent DB/runtime preferences copied from a different checkout from
* blocking autonomous mode with irrelevant commands. Commands that enumerate
* directories and then `cd "$d"` are repo-specific; if any listed directory is
* absent in the active project, the whole command set is stale and discovery
* should fall through to project-local signals.
*
* Consumer: discoverCommands before preference/task-plan commands win.
*/
export function hasMissingProjectPathReferences(cwd, command) {
const dirs = extractForLoopCdDirs(command);
if (!dirs) return false;
return dirs.some((dir) => !existsSync(join(cwd, dir)));
}
function normalizeConfiguredCommands(cwd, commands) {
const filtered = (commands ?? []).map((c) => c.trim()).filter(Boolean);
if (filtered.length === 0) return { commands: [], stale: false };
const stale = filtered.some((cmd) => hasMissingProjectPathReferences(cwd, cmd));
return { commands: stale ? [] : filtered, stale };
}
function discoverPackageJsonCommands(cwd) {
const pkgPath = join(cwd, "package.json");
if (!existsSync(pkgPath)) return [];
try {
const raw = readFileSync(pkgPath, "utf-8");
const pkg = JSON.parse(raw);
if (
pkg &&
typeof pkg === "object" &&
pkg.scripts &&
typeof pkg.scripts === "object"
) {
const commands = [];
for (const key of PACKAGE_SCRIPT_KEYS) {
if (typeof pkg.scripts[key] === "string") {
commands.push(`npm run ${key}`);
}
}
return commands;
}
} catch {
// Malformed package.json — fall through.
}
return [];
}
function discoverPythonCommands(cwd) {
if (!existsSync(join(cwd, "pyproject.toml"))) return [];
return ["python -m pytest -q"];
}
/**
* Discover verification commands using the first-non-empty-wins strategy (D003):
* 1. Explicit preference commands
@ -30,49 +99,35 @@ const PACKAGE_SCRIPT_KEYS = ["typecheck", "lint", "test"];
export function discoverCommands(options) {
// 1. Preference commands
if (options.preferenceCommands && options.preferenceCommands.length > 0) {
const filtered = options.preferenceCommands
.map((c) => c.trim())
.filter(Boolean);
if (filtered.length > 0) {
return { commands: filtered, source: "preference" };
const configured = normalizeConfiguredCommands(
options.cwd,
options.preferenceCommands,
);
if (configured.commands.length > 0) {
return { commands: configured.commands, source: "preference" };
}
}
// 2. Task plan verify field (commands are untrusted — sanitize)
if (options.taskPlanVerify && options.taskPlanVerify.trim()) {
const commands = options.taskPlanVerify
const rawCommands = options.taskPlanVerify
.split("&&")
.map((c) => c.trim())
.filter(Boolean)
.filter(Boolean);
const configured = normalizeConfiguredCommands(options.cwd, rawCommands);
const commands = configured.commands
.filter((c) => sanitizeCommand(c) !== null);
if (commands.length > 0) {
return { commands, source: "task-plan" };
}
}
// 3. package.json scripts
const pkgPath = join(options.cwd, "package.json");
if (existsSync(pkgPath)) {
try {
const raw = readFileSync(pkgPath, "utf-8");
const pkg = JSON.parse(raw);
if (
pkg &&
typeof pkg === "object" &&
pkg.scripts &&
typeof pkg.scripts === "object"
) {
const commands = [];
for (const key of PACKAGE_SCRIPT_KEYS) {
if (typeof pkg.scripts[key] === "string") {
commands.push(`npm run ${key}`);
}
}
if (commands.length > 0) {
return { commands, source: "package-json" };
}
}
} catch {
// Malformed package.json — fall through to "none"
}
const packageCommands = discoverPackageJsonCommands(options.cwd);
if (packageCommands.length > 0) {
return { commands: packageCommands, source: "package-json" };
}
const pythonCommands = discoverPythonCommands(options.cwd);
if (pythonCommands.length > 0) {
return { commands: pythonCommands, source: "python-project" };
}
// 4. Nothing found
return { commands: [], source: "none" };