fix(verify): ignore stale repo verification commands
This commit is contained in:
parent
50383eb2bf
commit
7e2f62ead3
2 changed files with 156 additions and 31 deletions
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
@ -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" };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue