import importlib.util import json import subprocess import sys import tempfile import unittest from pathlib import Path from unittest.mock import patch def load_execute(): module_path = Path(__file__).resolve().parent / "execute.py" spec = importlib.util.spec_from_file_location("execute", module_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def write_phase(root: Path, phase_dir: str = "0-mvp", phase_name: str = "0-mvp", steps=None): phase_path = root / "phases" / phase_dir phase_path.mkdir(parents=True) if steps is None: steps = [ { "step": 1, "name": "Docs", "status": "pending", "summary": "", "allowed_paths": ["docs/*.md"], } ] (phase_path / "index.json").write_text( json.dumps({"project": "FESA", "phase": phase_name, "steps": steps}, indent=2), encoding="utf-8", ) (phase_path / "step1.md").write_text("# Step 1\n", encoding="utf-8") return phase_path def make_executor(execute, root: Path, phase_dir: str = "0-mvp"): with patch.object(execute, "ROOT", root): return execute.StepExecutor(phase_dir) class ExecuteRunnerSafetyTests(unittest.TestCase): def test_scaffold_loads_execute_module(self): execute = load_execute() self.assertTrue(hasattr(execute, "StepExecutor")) def test_branch_name_uses_codex_prefix_and_sanitized_phase(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root, phase_name="linear truss/1d") executor = make_executor(execute, root) self.assertEqual(executor._branch_name(), "codex/linear-truss-1d") def test_finalize_push_uses_codex_branch_name(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root, phase_name="0-mvp") executor = make_executor(execute, root) executor._auto_push = True calls = [] def fake_git(*args): calls.append(args) if args == ("diff", "--cached", "--quiet"): return subprocess.CompletedProcess(args, 0, "", "") return subprocess.CompletedProcess(args, 0, "", "") with patch.object(executor, "_run_git", side_effect=fake_git): with patch.object(executor, "_validate_before_commit", create=True): with patch("builtins.print"): executor._finalize() self.assertIn(("push", "-u", "origin", "codex/0-mvp"), calls) def test_finalize_stages_only_phase_indexes(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) (root / "phases" / "index.json").write_text('{"phases":[]}', encoding="utf-8") executor = make_executor(execute, root) calls = [] def fake_git(*args): calls.append(args) if args == ("diff", "--cached", "--quiet"): return subprocess.CompletedProcess(args, 1, "", "") return subprocess.CompletedProcess(args, 0, "", "") with patch.object(executor, "_run_git", side_effect=fake_git): with patch.object(executor, "_validate_before_commit"): with patch("builtins.print"): executor._finalize() self.assertNotIn(("add", "-A"), calls) self.assertIn(("add", "--", "phases/0-mvp/index.json", "phases/index.json"), calls) def test_assert_clean_worktree_exits_when_git_status_has_changes(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) with patch.object( executor, "_run_git", return_value=subprocess.CompletedProcess([], 0, " M AGENTS.md\n?? scratch.txt\n", ""), ): with patch("builtins.print"): with self.assertRaises(SystemExit) as cm: executor._assert_clean_worktree("before checkout") self.assertEqual(cm.exception.code, 1) def test_run_checks_clean_worktree_before_checkout(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) calls = [] def record(name): def inner(*args, **kwargs): calls.append(name) return inner with patch.object(executor, "_assert_clean_worktree", side_effect=record("clean")): with patch.object(executor, "_checkout_branch", side_effect=record("checkout")): with patch.object(executor, "_print_header"): with patch.object(executor, "_check_blockers"): with patch.object(executor, "_load_guardrails", return_value=""): with patch.object(executor, "_ensure_created_at"): with patch.object(executor, "_execute_all_steps"): with patch.object(executor, "_finalize"): executor.run() self.assertLess(calls.index("clean"), calls.index("checkout")) def test_step_allowlist_accepts_exact_prefix_and_glob_paths(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) patterns = ["AGENTS.md", "docs/", "scripts/*.py"] self.assertTrue(executor._path_allowed("AGENTS.md", patterns)) self.assertTrue(executor._path_allowed("docs/PRD.md", patterns)) self.assertTrue(executor._path_allowed("scripts/execute.py", patterns)) self.assertFalse(executor._path_allowed(".codex/hooks.json", patterns)) def test_step_without_allowed_paths_is_rejected_before_codex_invocation(self): execute = load_execute() steps = [{"step": 1, "name": "Unsafe", "status": "pending", "summary": ""}] with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root, steps=steps) executor = make_executor(execute, root) with patch("builtins.print"): with self.assertRaises(SystemExit) as cm: executor._validate_step_allowlist(steps[0]) self.assertEqual(cm.exception.code, 1) def test_classify_step_changes_splits_allowed_housekeeping_and_disallowed_paths(self): execute = load_execute() step = { "step": 1, "name": "Docs", "status": "completed", "summary": "", "allowed_paths": ["docs/*.md"], } with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) changed = [ "docs/PRD.md", "phases/0-mvp/index.json", "phases/0-mvp/step1-output.json", "scripts/execute.py", ] allowed, housekeeping, disallowed = executor._classify_step_changes(1, step, changed) self.assertEqual(allowed, ["docs/PRD.md"]) self.assertEqual(housekeeping, ["phases/0-mvp/index.json", "phases/0-mvp/step1-output.json"]) self.assertEqual(disallowed, ["scripts/execute.py"]) def test_commit_step_stages_only_explicit_allowed_and_housekeeping_paths(self): execute = load_execute() step = { "step": 1, "name": "Docs", "status": "completed", "summary": "", "allowed_paths": ["docs/*.md"], } with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) calls = [] def fake_git(*args): calls.append(args) if args in { ("diff", "--quiet", "--cached", "--"), ("diff", "--cached", "--quiet"), }: return subprocess.CompletedProcess(args, 1, "", "") return subprocess.CompletedProcess(args, 0, "", "") with patch.object( executor, "_changed_paths", return_value=[ "docs/PRD.md", "phases/0-mvp/index.json", "phases/0-mvp/step1-output.json", ], ): with patch.object(executor, "_run_git", side_effect=fake_git): with patch.object(executor, "_validate_before_commit", create=True): with patch("builtins.print"): executor._commit_step(step, "Docs") self.assertNotIn(("add", "-A"), calls) self.assertIn(("add", "--", "docs/PRD.md"), calls) self.assertIn(("add", "--", "phases/0-mvp/index.json", "phases/0-mvp/step1-output.json"), calls) def test_validate_before_commit_runs_python_selftest_then_workspace_validation(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) commands = [] def fake_run(cmd, **kwargs): commands.append(cmd) return subprocess.CompletedProcess(cmd, 0, "ok", "") with patch.object(execute.subprocess, "run", side_effect=fake_run): with patch("builtins.print"): executor._validate_before_commit("feat(0-mvp): step 1") self.assertEqual( commands, [ [sys.executable, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py"], [sys.executable, "scripts/validate_workspace.py"], ], ) def test_validate_before_commit_exits_before_commit_when_validation_fails(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) def fake_run(cmd, **kwargs): return subprocess.CompletedProcess(cmd, 1, "bad", "failed") with patch.object(execute.subprocess, "run", side_effect=fake_run): with patch("builtins.print"): with self.assertRaises(SystemExit) as cm: executor._validate_before_commit("feat(0-mvp): step 1") self.assertEqual(cm.exception.code, 1) if __name__ == "__main__": unittest.main()