diff --git a/src/resources/extensions/sf/tests/verification-gate-discovery.test.mjs b/src/resources/extensions/sf/tests/verification-gate-discovery.test.mjs index a45ab59d4..c8f73f7f3 100644 --- a/src/resources/extensions/sf/tests/verification-gate-discovery.test.mjs +++ b/src/resources/extensions/sf/tests/verification-gate-discovery.test.mjs @@ -26,7 +26,7 @@ test("discoverCommands_when_preferences_reference_missing_repo_dirs_falls_back_t 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'"; + 'bash -c \'set -e; for d in "scanner" "supervisor"; do (cd "$d" && cargo check); done\''; const result = discoverCommands({ cwd: root, @@ -44,7 +44,7 @@ test("discoverCommands_when_for_loop_dirs_exist_keeps_preference_commands", () = 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'"; + 'bash -c \'set -e; for d in "scanner" "supervisor"; do (cd "$d" && cargo check); done\''; const result = discoverCommands({ cwd: root, @@ -57,13 +57,56 @@ test("discoverCommands_when_for_loop_dirs_exist_keeps_preference_commands", () = }); }); +test("discoverCommands_when_broad_cargo_preferences_conflict_with_task_plan_uses_task_plan", () => { + const root = makeProject(); + mkdirSync(join(root, "scanner")); + mkdirSync(join(root, "supervisor")); + writeFileSync(join(root, "pyproject.toml"), "[project]\nname = 'demo'\n"); + const command = + 'bash -c \'set -e; for d in "scanner" "supervisor"; do (cd "$d" && cargo check); done\''; + + const result = discoverCommands({ + cwd: root, + preferenceCommands: [command, "uv run pytest -x"], + taskPlanVerify: "make pytest", + }); + + assert.deepEqual(result, { + commands: ["make pytest"], + source: "task-plan", + }); +}); + +test("discoverCommands_when_broad_cargo_preferences_conflict_with_makefile_uses_python_project", () => { + const root = makeProject(); + mkdirSync(join(root, "scanner")); + mkdirSync(join(root, "supervisor")); + writeFileSync(join(root, "pyproject.toml"), "[project]\nname = 'demo'\n"); + writeFileSync( + join(root, "Makefile"), + "verify-python:\n\tpython -m pytest -q\n", + ); + const command = + 'bash -c \'set -e; for d in "scanner" "supervisor"; do (cd "$d" && cargo check); done\''; + + const result = discoverCommands({ + cwd: root, + preferenceCommands: [command, "uv run pytest -x"], + }); + + assert.deepEqual(result, { + commands: ["python -m pytest -q"], + source: "python-project", + }); +}); + 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'", + '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 56bc4bbbc..3093396b5 100644 --- a/src/resources/extensions/sf/verification-gate.js +++ b/src/resources/extensions/sf/verification-gate.js @@ -22,7 +22,9 @@ function truncate(value, maxBytes) { 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); + 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 = []; @@ -51,10 +53,42 @@ export function hasMissingProjectPathReferences(cwd, command) { return dirs.some((dir) => !existsSync(join(cwd, dir))); } -function normalizeConfiguredCommands(cwd, commands) { +function isBroadMultiProjectCargoCommand(command) { + const dirs = extractForLoopCdDirs(command); + if (!dirs || dirs.length < 2) return false; + return /\bcargo\s+(fmt|check|test|clippy)\b/.test(command); +} + +function hasMakefileVerifyTarget(cwd) { + const makefilePath = join(cwd, "Makefile"); + if (!existsSync(makefilePath)) return false; + try { + return /^verify[-\w]*:/m.test(readFileSync(makefilePath, "utf-8")); + } catch { + return false; + } +} + +function hasProjectLocalVerificationSignal(cwd, taskPlanVerify) { + if (taskPlanVerify?.trim()) return true; + if (hasMakefileVerifyTarget(cwd)) return true; + return ( + existsSync(join(cwd, "package.json")) || + existsSync(join(cwd, "pyproject.toml")) + ); +} + +function looksLikeStalePreferenceSet(cwd, commands, taskPlanVerify) { + if (!hasProjectLocalVerificationSignal(cwd, taskPlanVerify)) return false; + return commands.some((cmd) => isBroadMultiProjectCargoCommand(cmd)); +} + +function normalizeConfiguredCommands(cwd, commands, taskPlanVerify = "") { 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)); + const stale = + filtered.some((cmd) => hasMissingProjectPathReferences(cwd, cmd)) || + looksLikeStalePreferenceSet(cwd, filtered, taskPlanVerify); return { commands: stale ? [] : filtered, stale }; } @@ -102,6 +136,7 @@ export function discoverCommands(options) { const configured = normalizeConfiguredCommands( options.cwd, options.preferenceCommands, + options.taskPlanVerify, ); if (configured.commands.length > 0) { return { commands: configured.commands, source: "preference" }; @@ -114,8 +149,9 @@ export function discoverCommands(options) { .map((c) => c.trim()) .filter(Boolean); const configured = normalizeConfiguredCommands(options.cwd, rawCommands); - const commands = configured.commands - .filter((c) => sanitizeCommand(c) !== null); + const commands = configured.commands.filter( + (c) => sanitizeCommand(c) !== null, + ); if (commands.length > 0) { return { commands, source: "task-plan" }; }