From addb3b8d66664c2237f97a106337a966df3cf70e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 21 May 2026 19:10:20 -0500 Subject: [PATCH 1/4] fix: paths-only skips branch validation, setup-plan preserves existing plan (#2653) - check-prerequisites.sh/ps1: move branch validation after --paths-only early exit so --paths-only returns paths without requiring a spec branch - setup-plan.sh/ps1: skip template copy when plan.md already exists to prevent overwriting user-authored plans on reruns - setup-plan.sh: send status messages to stderr in --json mode so stdout remains parseable JSON - Add tests for both fixes (bash + PowerShell) --- scripts/bash/check-prerequisites.sh | 8 +- scripts/bash/setup-plan.sh | 32 ++- scripts/powershell/check-prerequisites.ps1 | 13 +- scripts/powershell/setup-plan.ps1 | 24 ++- tests/test_check_prerequisites_paths_only.py | 205 ++++++++++++++++++ tests/test_setup_plan_no_overwrite.py | 211 +++++++++++++++++++ 6 files changed, 466 insertions(+), 27 deletions(-) create mode 100644 tests/test_check_prerequisites_paths_only.py create mode 100644 tests/test_setup_plan_no_overwrite.py diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 88a5559460..b244ea7a5b 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -78,13 +78,12 @@ done SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -# Get feature paths and validate branch +# Get feature paths _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } eval "$_paths_output" unset _paths_output -check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 -# If paths-only mode, output paths and exit (support JSON + paths-only combined) +# If paths-only mode, output paths and exit (no validation) if $PATHS_ONLY; then if $JSON_MODE; then # Minimal JSON paths payload (no validation performed) @@ -112,6 +111,9 @@ if $PATHS_ONLY; then exit 0 fi +# Validate branch name +check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 + # Validate required directories and files if [[ ! -d "$FEATURE_DIR" ]]; then echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index f2d2f6e6fc..945385c643 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -40,15 +40,31 @@ fi # Ensure the feature directory exists mkdir -p "$FEATURE_DIR" -# Copy plan template if it exists -TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true -if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then - cp "$TEMPLATE" "$IMPL_PLAN" - echo "Copied plan template to $IMPL_PLAN" +# Copy plan template if plan doesn't already exist +if [[ -f "$IMPL_PLAN" ]]; then + if $JSON_MODE; then + echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2 + else + echo "Plan already exists at $IMPL_PLAN, skipping template copy" + fi else - echo "Warning: Plan template not found" - # Create a basic plan file if template doesn't exist - touch "$IMPL_PLAN" + TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true + if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then + cp "$TEMPLATE" "$IMPL_PLAN" + if $JSON_MODE; then + echo "Copied plan template to $IMPL_PLAN" >&2 + else + echo "Copied plan template to $IMPL_PLAN" + fi + else + if $JSON_MODE; then + echo "Warning: Plan template not found" >&2 + else + echo "Warning: Plan template not found" + fi + # Create a basic plan file if template doesn't exist + touch "$IMPL_PLAN" + fi fi # Output results diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index 91667e9ef1..46fac57611 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -56,14 +56,10 @@ EXAMPLES: # Source common functions . "$PSScriptRoot/common.ps1" -# Get feature paths and validate branch +# Get feature paths $paths = Get-FeaturePathsEnv -if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { - exit 1 -} - -# If paths-only mode, output paths and exit (support combined -Json -PathsOnly) +# If paths-only mode, output paths and exit (no validation) if ($PathsOnly) { if ($Json) { [PSCustomObject]@{ @@ -85,6 +81,11 @@ if ($PathsOnly) { exit 0 } +# Validate branch name +if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { + exit 1 +} + # Validate required directories and files if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)" diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index cd9cf8c426..29a97624c1 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -33,17 +33,21 @@ if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFe # Ensure the feature directory exists New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null -# Copy plan template if it exists, otherwise note it or create empty file -$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT -if ($template -and (Test-Path $template)) { - # Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM - $content = [System.IO.File]::ReadAllText($template) - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom) +# Copy plan template if plan doesn't already exist +if (Test-Path $paths.IMPL_PLAN) { + Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy" } else { - Write-Warning "Plan template not found" - # Create a basic plan file if template doesn't exist - New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null + $template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT + if ($template -and (Test-Path $template)) { + # Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM + $content = [System.IO.File]::ReadAllText($template) + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom) + } else { + Write-Warning "Plan template not found" + # Create a basic plan file if template doesn't exist + New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null + } } # Output results diff --git a/tests/test_check_prerequisites_paths_only.py b/tests/test_check_prerequisites_paths_only.py new file mode 100644 index 0000000000..2e03028001 --- /dev/null +++ b/tests/test_check_prerequisites_paths_only.py @@ -0,0 +1,205 @@ +"""Tests for check-prerequisites --paths-only skipping branch validation (#2653).""" + +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_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh" +COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" +CHECK_PREREQS_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_PREREQS_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_PREREQS_PS, d / "check-prerequisites.ps1") + + +def _clean_env() -> dict[str, str]: + 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 + ) + + +@pytest.fixture +def prereq_repo(tmp_path: Path) -> Path: + repo = tmp_path / "proj" + repo.mkdir() + _git_init(repo) + (repo / ".specify").mkdir() + _install_bash_scripts(repo) + _install_ps_scripts(repo) + return repo + + +# ── Bash tests ──────────────────────────────────────────────────────────── + + +@requires_bash +def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None: + """--paths-only must return paths without branch validation (main branch).""" + script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + result = subprocess.run( + ["bash", str(script), "--json", "--paths-only"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "REPO_ROOT" in data + assert "BRANCH" in data + assert "FEATURE_DIR" in data + + +@requires_bash +def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None: + """--paths-only must also work on a properly named spec branch.""" + subprocess.run( + ["git", "checkout", "-q", "-b", "001-my-feature"], + cwd=prereq_repo, + check=True, + ) + script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + result = subprocess.run( + ["bash", str(script), "--json", "--paths-only"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "FEATURE_DIR" in data + assert "001-my-feature" in data.get("BRANCH", "") + + +@requires_bash +def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None: + """--paths-only without --json must return text paths on a non-spec branch.""" + script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + result = subprocess.run( + ["bash", str(script), "--paths-only"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + assert "REPO_ROOT:" in result.stdout + assert "FEATURE_DIR:" in result.stdout + + +@requires_bash +def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None: + """Without --paths-only, branch validation must still fail on main.""" + script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr + + +# ── PowerShell tests ────────────────────────────────────────────────────── + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None: + """-PathsOnly must return paths without branch validation (main branch).""" + script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "REPO_ROOT" in data + assert "BRANCH" in data + assert "FEATURE_DIR" in data + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None: + """-PathsOnly must also work on a properly named spec branch.""" + subprocess.run( + ["git", "checkout", "-q", "-b", "001-my-feature"], + cwd=prereq_repo, + check=True, + ) + script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "FEATURE_DIR" in data + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None: + """Without -PathsOnly, branch validation must still fail on main.""" + script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr diff --git a/tests/test_setup_plan_no_overwrite.py b/tests/test_setup_plan_no_overwrite.py new file mode 100644 index 0000000000..c14aa43d83 --- /dev/null +++ b/tests/test_setup_plan_no_overwrite.py @@ -0,0 +1,211 @@ +"""Tests for setup-plan preserving existing plan.md (#2653).""" + +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" +SETUP_PLAN_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-plan.sh" +COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" +SETUP_PLAN_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-plan.ps1" +PLAN_TEMPLATE = PROJECT_ROOT / "templates" / "plan-template.md" + +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(SETUP_PLAN_SH, d / "setup-plan.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(SETUP_PLAN_PS, d / "setup-plan.ps1") + + +def _minimal_templates(repo: Path) -> None: + tdir = repo / ".specify" / "templates" + tdir.mkdir(parents=True, exist_ok=True) + shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md") + + +def _clean_env() -> dict[str, str]: + 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 + ) + + +@pytest.fixture +def plan_repo(tmp_path: Path) -> Path: + repo = tmp_path / "proj" + repo.mkdir() + _git_init(repo) + subprocess.run( + ["git", "checkout", "-q", "-b", "001-my-feature"], + cwd=repo, + check=True, + ) + (repo / ".specify").mkdir() + _minimal_templates(repo) + _install_bash_scripts(repo) + _install_ps_scripts(repo) + return repo + + +# ── Bash tests ──────────────────────────────────────────────────────────── + + +@requires_bash +def test_setup_plan_creates_plan_when_missing(plan_repo: Path) -> None: + """First run must create plan.md from the template.""" + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + plan_path = Path(data["IMPL_PLAN"]) + assert plan_path.is_file() + # Template content should be present + content = plan_path.read_text(encoding="utf-8") + assert len(content) > 0 + + +@requires_bash +def test_setup_plan_preserves_existing_plan(plan_repo: Path) -> None: + """Rerun must not overwrite an existing plan.md.""" + feat = plan_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True) + existing_content = "# My carefully authored plan\n\nDo not overwrite me.\n" + (feat / "plan.md").write_text(existing_content, encoding="utf-8") + + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + # Plan must be unchanged + assert (feat / "plan.md").read_text(encoding="utf-8") == existing_content + + +@requires_bash +def test_setup_plan_skip_message_on_stderr_in_json_mode(plan_repo: Path) -> None: + """In --json mode, status messages must go to stderr, not stdout.""" + feat = plan_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True) + (feat / "plan.md").write_text("# existing\n", encoding="utf-8") + + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + # stdout must be valid JSON (no status messages mixed in) + data = json.loads(result.stdout) + assert "IMPL_PLAN" in data + # The skip message should be on stderr + assert "already exists" in result.stderr + + +@requires_bash +def test_setup_plan_json_parseable_on_first_run(plan_repo: Path) -> None: + """In --json mode, first-run stdout must be parseable JSON (no status on stdout).""" + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "IMPL_PLAN" in data + assert "Copied plan template" in result.stderr + + +# ── PowerShell tests ────────────────────────────────────────────────────── + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_setup_plan_creates_plan_when_missing(plan_repo: Path) -> None: + """First run must create plan.md from the template.""" + script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + plan_path = Path(data["IMPL_PLAN"]) + assert plan_path.is_file() + content = plan_path.read_text(encoding="utf-8") + assert len(content) > 0 + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_ps_setup_plan_preserves_existing_plan(plan_repo: Path) -> None: + """Rerun must not overwrite an existing plan.md.""" + feat = plan_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True) + existing_content = "# My carefully authored plan\n\nDo not overwrite me.\n" + (feat / "plan.md").write_text(existing_content, encoding="utf-8") + + script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + assert (feat / "plan.md").read_text(encoding="utf-8") == existing_content From be8632810d452a960b2a5189c2007c830cdd0e8a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 21 May 2026 19:14:25 -0500 Subject: [PATCH 2/4] fix: remove trailing whitespace in PowerShell scripts --- scripts/powershell/check-prerequisites.ps1 | 4 ++-- scripts/powershell/setup-plan.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index 46fac57611..0620fa0e70 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -82,8 +82,8 @@ if ($PathsOnly) { } # Validate branch name -if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { - exit 1 +if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { + exit 1 } # Validate required directories and files diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index 29a97624c1..ff2d151fe7 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -38,7 +38,7 @@ if (Test-Path $paths.IMPL_PLAN) { Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy" } else { $template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT - if ($template -and (Test-Path $template)) { + if ($template -and (Test-Path $template)) { # Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM $content = [System.IO.File]::ReadAllText($template) $utf8NoBom = New-Object System.Text.UTF8Encoding($false) From e2c1a63db7ce832c79ebac6783b00dec46b0c758 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 21 May 2026 19:18:02 -0500 Subject: [PATCH 3/4] fix: route PS skip message to stderr in -Json mode, add PS JSON assertions Address review: setup-plan.ps1 Write-Output polluted stdout in -Json mode when plan.md already existed. Use [Console]::Error.WriteLine() when -Json is set. Add json.loads + stderr assertions to the PS rerun test to catch regressions. --- scripts/powershell/setup-plan.ps1 | 6 +++++- tests/test_setup_plan_no_overwrite.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index ff2d151fe7..ea3afa24af 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -35,7 +35,11 @@ New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null # Copy plan template if plan doesn't already exist if (Test-Path $paths.IMPL_PLAN) { - Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy" + if ($Json) { + [Console]::Error.WriteLine("Plan already exists at $($paths.IMPL_PLAN), skipping template copy") + } else { + Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy" + } } else { $template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT if ($template -and (Test-Path $template)) { diff --git a/tests/test_setup_plan_no_overwrite.py b/tests/test_setup_plan_no_overwrite.py index c14aa43d83..f29a629294 100644 --- a/tests/test_setup_plan_no_overwrite.py +++ b/tests/test_setup_plan_no_overwrite.py @@ -209,3 +209,8 @@ def test_ps_setup_plan_preserves_existing_plan(plan_repo: Path) -> None: ) assert result.returncode == 0, result.stderr assert (feat / "plan.md").read_text(encoding="utf-8") == existing_content + # stdout must be valid JSON (no status messages mixed in) + data = json.loads(result.stdout) + assert "IMPL_PLAN" in data + # The skip message should be on stderr + assert "already exists" in result.stderr From d305a014926b86c9a09631278bfcd49dc87122f3 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 21 May 2026 19:31:58 -0500 Subject: [PATCH 4/4] fix: use Test-Path -PathType Leaf for plan existence check Bare Test-Path matches directories too, which would silently skip plan creation if a directory existed at the plan.md path. --- scripts/powershell/setup-plan.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index ea3afa24af..d9eecdf75c 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -34,7 +34,7 @@ if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFe New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null # Copy plan template if plan doesn't already exist -if (Test-Path $paths.IMPL_PLAN) { +if (Test-Path $paths.IMPL_PLAN -PathType Leaf) { if ($Json) { [Console]::Error.WriteLine("Plan already exists at $($paths.IMPL_PLAN), skipping template copy") } else {