diff --git a/src/resources/extensions/sf/tests/verification-gate-discovery.test.mjs b/src/resources/extensions/sf/tests/verification-gate-discovery.test.mjs new file mode 100644 index 000000000..a45ab59d4 --- /dev/null +++ b/src/resources/extensions/sf/tests/verification-gate-discovery.test.mjs @@ -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, + ); +}); diff --git a/src/resources/extensions/sf/verification-gate.js b/src/resources/extensions/sf/verification-gate.js index 900bd51cf..56bc4bbbc 100644 --- a/src/resources/extensions/sf/verification-gate.js +++ b/src/resources/extensions/sf/verification-gate.js @@ -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" };