Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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; } \
Expand Down
51 changes: 51 additions & 0 deletions tests/test_setup_plan_feature_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down