From 58de5e408c5e5a013d001357b183fe940f99e82a Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 6 Jun 2026 22:55:49 +0200 Subject: [PATCH 1/2] fix(scripts): honor feature.json branch bypass in check-prerequisites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-plan and setup-tasks skip the feature-branch check when .specify/feature.json pins an existing feature directory, but check-prerequisites did not — so on the same branch with the same feature.json, /speckit.plan and /speckit.tasks succeeded while /speckit.checklist, /speckit.analyze, /speckit.implement and /speckit.taskstoissues failed with "Not on a feature branch". Wrap the branch check in check-prerequisites.sh and check-prerequisites.ps1 with the same feature_json_matches_feature_dir / Test-FeatureJsonMatchesFeatureDir guard the setup scripts use. The bypass only skips the branch-name check, not the plan.md / tasks.md existence checks. Add tests covering both languages. Fixes #2887 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/bash/check-prerequisites.sh | 7 +- scripts/powershell/check-prerequisites.ps1 | 9 +- .../test_check_prerequisites_feature_json.py | 158 ++++++++++++++++++ 3 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 tests/test_check_prerequisites_feature_json.py diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 95e7344d80..3915e7e276 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -111,8 +111,11 @@ if $PATHS_ONLY; then exit 0 fi -# Validate branch name -check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 +# Validate branch name (skipped when feature.json pins an existing feature dir, +# matching setup-plan.sh / setup-tasks.sh so the command set stays consistent) +if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then + check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 +fi # Validate required directories and files if [[ ! -d "$FEATURE_DIR" ]]; then diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index bcf3aa46c4..eb385b5d58 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -81,9 +81,12 @@ if ($PathsOnly) { exit 0 } -# Validate branch name -if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { - exit 1 +# Validate branch name (skipped when feature.json pins an existing feature dir, +# matching setup-plan.ps1 / setup-tasks.ps1 so the command set stays consistent) +if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) { + if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { + exit 1 + } } # Validate required directories and files diff --git a/tests/test_check_prerequisites_feature_json.py b/tests/test_check_prerequisites_feature_json.py new file mode 100644 index 0000000000..79fc794c65 --- /dev/null +++ b/tests/test_check_prerequisites_feature_json.py @@ -0,0 +1,158 @@ +"""check-prerequisites honors feature.json the same way setup-plan/-tasks do. + +Regression guard for the inconsistency where setup-plan.sh / setup-tasks.sh +skipped the feature-branch check when .specify/feature.json pinned an existing +feature directory, but check-prerequisites.{sh,ps1} did not — so half the +spec-kit commands succeeded and half failed on the same branch. +""" + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +CHECK_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh" +COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" +CHECK_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1" + +HAS_PWSH = shutil.which("pwsh") is not None +_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell") + + +def _install_bash_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "bash" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_SH, d / "common.sh") + shutil.copy(CHECK_SH, d / "check-prerequisites.sh") + + +def _install_ps_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "powershell" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_PS, d / "common.ps1") + shutil.copy(CHECK_PS, d / "check-prerequisites.ps1") + + +def _clean_env() -> dict[str, str]: + """Strip SPECIFY_* vars so each case relies purely on branch + feature.json.""" + env = os.environ.copy() + for key in list(env): + if key.startswith("SPECIFY_"): + env.pop(key) + return env + + +def _git_init(repo: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True) + subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True) + + +def _populate_feature(repo: Path, *, with_tasks: bool = False) -> Path: + feat = repo / "specs" / "001-tiny-notes-app" + feat.mkdir(parents=True, exist_ok=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + if with_tasks: + (feat / "tasks.md").write_text("# tasks\n", encoding="utf-8") + return feat + + +def _pin_feature_json(repo: Path) -> None: + (repo / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": "specs/001-tiny-notes-app"}), + encoding="utf-8", + ) + + +@pytest.fixture +def prereq_repo(tmp_path: Path) -> Path: + repo = tmp_path / "proj" + repo.mkdir() + _git_init(repo) + (repo / ".specify").mkdir(exist_ok=True) + _install_bash_scripts(repo) + _install_ps_scripts(repo) + subprocess.run( + ["git", "checkout", "-q", "-b", "chore/not-a-feature-branch"], cwd=repo, check=True + ) + return repo + + +def _run_bash(repo: Path, *args: str) -> subprocess.CompletedProcess: + script = repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + return subprocess.run( + ["bash", str(script), *args], + cwd=repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + +def _run_ps(repo: Path, *args: str) -> subprocess.CompletedProcess: + script = repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + return subprocess.run( + [exe, "-NoProfile", "-File", str(script), *args], + cwd=repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + +@requires_bash +def test_bash_json_passes_custom_branch_when_feature_json_valid(prereq_repo: Path) -> None: + _populate_feature(prereq_repo) + _pin_feature_json(prereq_repo) + result = _run_bash(prereq_repo, "--json") + assert result.returncode == 0, result.stderr + result.stdout + + +@requires_bash +def test_bash_require_tasks_passes_when_feature_json_valid(prereq_repo: Path) -> None: + _populate_feature(prereq_repo, with_tasks=True) + _pin_feature_json(prereq_repo) + result = _run_bash(prereq_repo, "--json", "--require-tasks", "--include-tasks") + assert result.returncode == 0, result.stderr + result.stdout + + +@requires_bash +def test_bash_json_fails_custom_branch_without_feature_json(prereq_repo: Path) -> None: + _populate_feature(prereq_repo) + result = _run_bash(prereq_repo, "--json") + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr + + +@requires_bash +def test_bash_paths_only_always_succeeds(prereq_repo: Path) -> None: + # --paths-only performs no validation and must succeed regardless of branch. + result = _run_bash(prereq_repo, "--json", "--paths-only") + assert result.returncode == 0, result.stderr + result.stdout + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_json_passes_custom_branch_when_feature_json_valid(prereq_repo: Path) -> None: + _populate_feature(prereq_repo) + _pin_feature_json(prereq_repo) + result = _run_ps(prereq_repo, "-Json") + assert result.returncode == 0, result.stderr + result.stdout + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_json_fails_custom_branch_without_feature_json(prereq_repo: Path) -> None: + _populate_feature(prereq_repo) + result = _run_ps(prereq_repo, "-Json") + assert result.returncode != 0 From 2d3332378472af2151b8d5227e400d24a6747d46 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 7 Jun 2026 00:14:00 +0200 Subject: [PATCH 2/2] test(scripts): harden check-prerequisites feature.json coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review fixes: - The PS "fails without feature.json" test only asserted a non-zero exit, which the later "feature dir not found" check also produces — it would stay green even if the branch guard were reverted. Assert the "Not on a feature branch" message so the branch check is the proven failure cause (matches the bash test and the sibling setup-plan/setup-tasks tests). - Add safety tests: feature.json pinning a non-existent dir, and malformed feature.json — both must fail safe (enforce the branch check), pinning the property that the bypass only triggers on a real match. - Align the PS branch call to the space-bound `-HasGit` used by the sibling scripts this change mirrors. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/powershell/check-prerequisites.ps1 | 2 +- .../test_check_prerequisites_feature_json.py | 37 ++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index eb385b5d58..074712dffb 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -84,7 +84,7 @@ if ($PathsOnly) { # Validate branch name (skipped when feature.json pins an existing feature dir, # matching setup-plan.ps1 / setup-tasks.ps1 so the command set stays consistent) if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) { - if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { + if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) { exit 1 } } diff --git a/tests/test_check_prerequisites_feature_json.py b/tests/test_check_prerequisites_feature_json.py index 79fc794c65..f1613e0936 100644 --- a/tests/test_check_prerequisites_feature_json.py +++ b/tests/test_check_prerequisites_feature_json.py @@ -66,9 +66,9 @@ def _populate_feature(repo: Path, *, with_tasks: bool = False) -> Path: return feat -def _pin_feature_json(repo: Path) -> None: +def _pin_feature_json(repo: Path, feature_directory: str = "specs/001-tiny-notes-app") -> None: (repo / ".specify" / "feature.json").write_text( - json.dumps({"feature_directory": "specs/001-tiny-notes-app"}), + json.dumps({"feature_directory": feature_directory}), encoding="utf-8", ) @@ -136,6 +136,27 @@ def test_bash_json_fails_custom_branch_without_feature_json(prereq_repo: Path) - assert "Not on a feature branch" in result.stderr +@requires_bash +def test_bash_json_enforces_branch_when_feature_json_pins_missing_dir(prereq_repo: Path) -> None: + # The bypass must only trigger when feature.json matches an EXISTING dir. + # A bogus pin must NOT bypass the branch check. + _populate_feature(prereq_repo) + _pin_feature_json(prereq_repo, feature_directory="specs/999-does-not-exist") + result = _run_bash(prereq_repo, "--json") + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr + + +@requires_bash +def test_bash_json_enforces_branch_when_feature_json_malformed(prereq_repo: Path) -> None: + # Malformed feature.json must fail safe (enforce the branch check), not bypass it. + _populate_feature(prereq_repo) + (prereq_repo / ".specify" / "feature.json").write_text("{ not json", encoding="utf-8") + result = _run_bash(prereq_repo, "--json") + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr + + @requires_bash def test_bash_paths_only_always_succeeds(prereq_repo: Path) -> None: # --paths-only performs no validation and must succeed regardless of branch. @@ -156,3 +177,15 @@ def test_ps_json_fails_custom_branch_without_feature_json(prereq_repo: Path) -> _populate_feature(prereq_repo) result = _run_ps(prereq_repo, "-Json") assert result.returncode != 0 + # Assert the branch check is the failure cause — not the later "feature dir + # not found" check, which would also exit 1 and mask a broken guard. + assert "Not on a feature branch" in result.stderr + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_json_enforces_branch_when_feature_json_pins_missing_dir(prereq_repo: Path) -> None: + _populate_feature(prereq_repo) + _pin_feature_json(prereq_repo, feature_directory="specs/999-does-not-exist") + result = _run_ps(prereq_repo, "-Json") + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr