diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 609729cbfa..fd2f05f827 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -97,17 +97,26 @@ read_feature_json_feature_directory() { local fj="$repo_root/.specify/feature.json" [[ -f "$fj" ]] || { printf '%s' ''; return 0; } + # Try parsers in order (jq -> python3 -> grep/sed), falling through on + # failure. Selection is by *parse success*, not mere availability: on + # Windows `python3` commonly resolves to the Microsoft Store App Execution + # Alias stub, which passes `command -v` but fails at runtime (exit 49), so + # an availability-gated `elif` would pick python3, swallow its failure, and + # never reach the grep/sed fallback -- leaving feature.json unreadable even + # though it is valid (issue #3304). local _fd='' if command -v jq >/dev/null 2>&1; then if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then _fd='' fi - elif command -v python3 >/dev/null 2>&1; then + fi + if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then # Use Python so pretty-printed/multi-line JSON still parses correctly. if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then _fd='' fi - else + fi + if [[ -z "$_fd" ]]; then # Last-resort single-line grep/sed fallback. The `|| true` guards against # grep returning 1 (no match) aborting under `set -e` / `pipefail`. _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ diff --git a/tests/test_setup_plan_feature_json.py b/tests/test_setup_plan_feature_json.py index 09710e5d18..aaea17511e 100644 --- a/tests/test_setup_plan_feature_json.py +++ b/tests/test_setup_plan_feature_json.py @@ -126,6 +126,57 @@ def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None: assert "Feature directory not found" in result.stderr +@requires_bash +def test_setup_plan_survives_broken_python3_stub(plan_repo: Path) -> None: + """A `python3` on PATH that exists but fails at runtime must not defeat + feature.json parsing. + + On Windows `python3` typically resolves to the Microsoft Store App Execution + Alias stub: it satisfies `command -v python3` yet exits non-zero at runtime. + The parser must fall through to the grep/sed fallback on that failure instead + of selecting python3 by mere availability and swallowing its error (#3304). + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/my-feature-branch"], + cwd=plan_repo, + check=True, + ) + feat = plan_repo / "specs" / "001-tiny-notes-app" + feat.mkdir(parents=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + _write_feature_json(plan_repo, "specs/001-tiny-notes-app") + + # A stub python3 that mimics the Windows Store alias: on PATH, exits 49. + stub_dir = plan_repo / "_stubbin" + stub_dir.mkdir() + stub = stub_dir / "python3" + stub.write_text( + "#!/bin/sh\n" + 'echo "Python was not found; run without arguments to install from the ' + 'Microsoft Store" >&2\n' + "exit 49\n", + encoding="utf-8", + ) + stub.chmod(0o755) + + env = _clean_env() + # Prepend the stub dir; also drop jq so the chain must reach python3 then + # fall through to grep/sed. PATH still needs the real bash utilities. + env["PATH"] = f"{stub_dir}{os.pathsep}{env.get('PATH', '')}" + + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script)], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=env, + ) + assert result.returncode == 0, result.stderr + result.stdout + assert (feat / "plan.md").is_file() + + @requires_bash def test_setup_plan_numbered_branch_works_with_feature_json( plan_repo: Path,