From fa8b183b1ccf49d153eeff38e6dcfe9260d02fc0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:13:22 +0000 Subject: [PATCH 1/2] Fix missing-plan-template: reject empty plan setup Apply the remediation from the bug assessment on issue #4961. Refs #4961 Assisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/bash/check-prerequisites.sh | 5 +++- scripts/bash/setup-plan.sh | 8 ++----- scripts/powershell/check-prerequisites.ps1 | 10 +++++++- scripts/powershell/setup-plan.ps1 | 14 ++++------- scripts/python/check_prerequisites.py | 8 ++++++- scripts/python/setup_plan.py | 7 ++++-- .../test_check_prerequisites_python_parity.py | 24 +++++++++++++++++++ tests/test_setup_plan_no_overwrite.py | 13 ++++------ tests/test_setup_plan_python_parity.py | 18 ++++++++++++-- 9 files changed, 76 insertions(+), 31 deletions(-) diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 7d6dba1353..ecb3ffbd66 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -142,8 +142,11 @@ if [[ ! -d "$FEATURE_DIR" ]]; then exit 1 fi -if [[ ! -f "$IMPL_PLAN" ]]; then +if [[ ! -f "$IMPL_PLAN" || ! -s "$IMPL_PLAN" ]]; then echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + if [[ -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md is empty in $FEATURE_DIR; populate it or rerun plan setup." >&2 + fi echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2 exit 1 fi diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index aa394183cf..c25d9ed3a6 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -55,12 +55,8 @@ else if [ "$resolve_status" -ne 1 ]; then exit "$resolve_status" fi - if $JSON_MODE; then - echo "Warning: Plan template not found" >&2 - else - echo "Warning: Plan template not found" - fi - touch "$IMPL_PLAN" + echo "ERROR: Plan template not found; cannot create plan.md" >&2 + exit 1 fi fi diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index 0e9434d063..6c6637743e 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -101,8 +101,16 @@ if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { exit 1 } -if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { +if ( + -not (Test-Path $paths.IMPL_PLAN -PathType Leaf) -or + (Get-Item -LiteralPath $paths.IMPL_PLAN).Length -eq 0 +) { [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)") + if (Test-Path $paths.IMPL_PLAN -PathType Leaf) { + [Console]::Error.WriteLine( + "ERROR: plan.md is empty in $($paths.FEATURE_DIR); populate it or rerun plan setup." + ) + } $planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT [Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.") exit 1 diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index 0d888584a7..07714af166 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -58,16 +58,10 @@ if (Test-Path -LiteralPath $paths.IMPL_PLAN -PathType Leaf) { Write-Output "Copied plan template to $($paths.IMPL_PLAN)" } } else { - # Match the bash twin's wording and stream routing (stderr in -Json so - # stdout stays pure JSON, stdout otherwise), consistent with the sibling - # "Copied plan template" message above. - if ($Json) { - [Console]::Error.WriteLine("Warning: Plan template not found") - } else { - Write-Output "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 + [Console]::Error.WriteLine( + "ERROR: Plan template not found; cannot create plan.md" + ) + exit 1 } } diff --git a/scripts/python/check_prerequisites.py b/scripts/python/check_prerequisites.py index e025b4d672..c470b269d5 100644 --- a/scripts/python/check_prerequisites.py +++ b/scripts/python/check_prerequisites.py @@ -228,8 +228,14 @@ def main(argv: list[str] | None = None) -> int: ) return 1 - if not paths.impl_plan.is_file(): + if not paths.impl_plan.is_file() or paths.impl_plan.stat().st_size == 0: print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr) + if paths.impl_plan.is_file(): + print( + f"ERROR: plan.md is empty in {paths.feature_dir}; " + "populate it or rerun plan setup.", + file=sys.stderr, + ) print( f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.", file=sys.stderr, diff --git a/scripts/python/setup_plan.py b/scripts/python/setup_plan.py index 721eed8f39..87c38c3898 100644 --- a/scripts/python/setup_plan.py +++ b/scripts/python/setup_plan.py @@ -73,8 +73,11 @@ def main(argv: list[str] | None = None) -> int: paths.impl_plan.write_bytes(template_content.encode("utf-8")) print(f"Copied plan template to {paths.impl_plan}", file=status_stream) else: - print("Warning: Plan template not found", file=status_stream) - paths.impl_plan.touch() + print( + "ERROR: Plan template not found; cannot create plan.md", + file=sys.stderr, + ) + return 1 if json_mode: sys.stdout.write( diff --git a/tests/test_check_prerequisites_python_parity.py b/tests/test_check_prerequisites_python_parity.py index 69372d3f17..fda6b8393a 100644 --- a/tests/test_check_prerequisites_python_parity.py +++ b/tests/test_check_prerequisites_python_parity.py @@ -562,6 +562,30 @@ def test_python_negative_errors_are_stderr_only( assert py.stdout.strip() == "" +@requires_bash +def test_all_variants_reject_empty_plan(tmp_path: Path) -> None: + repo = tmp_path / "proj" + repo.mkdir() + _git_init(repo) + (repo / ".specify").mkdir() + _install_scripts(repo) + _write_feature_json(repo) + feature = repo / "specs" / "001-my-feature" + feature.mkdir(parents=True) + (feature / "plan.md").touch() + + results = [ + _run(_bash_cmd(repo, "--json", "--require-spec"), repo), + _run(_py_cmd(repo, "--json", "--require-spec"), repo), + ] + if HAS_PWSH or _WINDOWS_POWERSHELL: + results.append(_run(_ps_cmd(repo, "-Json", "-RequireSpec"), repo)) + + assert all(result.returncode == 1 for result in results) + assert all(result.stdout == "" for result in results) + assert all("plan.md is empty" in result.stderr for result in results) + + def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) -> None: (prereq_repo / "specs" / "001-my-feature").mkdir(parents=True) _write_feature_json(prereq_repo) diff --git a/tests/test_setup_plan_no_overwrite.py b/tests/test_setup_plan_no_overwrite.py index c92f038f25..9a5249497b 100644 --- a/tests/test_setup_plan_no_overwrite.py +++ b/tests/test_setup_plan_no_overwrite.py @@ -281,10 +281,7 @@ def test_ps_setup_plan_copied_message_on_stderr_in_json_mode(plan_repo: Path) -> @pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") def test_ps_setup_plan_template_not_found_warning_matches_bash(plan_repo: Path) -> None: - """When no plan template resolves, -Json mode must emit 'Warning: Plan template - not found' on stderr (matching the bash twin's wording and stream routing) while - keeping stdout pure JSON. Before the fix the PowerShell script used Write-Warning, - producing a different 'WARNING:' prefix on the warning stream instead.""" + """When no plan template resolves, setup must fail without creating plan.md.""" # Remove the template the fixture installs so resolution finds nothing. (plan_repo / ".specify" / "templates" / "plan-template.md").unlink() script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" @@ -297,7 +294,7 @@ def test_ps_setup_plan_template_not_found_warning_matches_bash(plan_repo: Path) check=False, env=_clean_env(), ) - assert result.returncode == 0, result.stderr - data = json.loads(result.stdout) - assert "IMPL_PLAN" in data - assert "Warning: Plan template not found" in result.stderr + assert result.returncode == 1 + assert result.stdout == "" + assert "ERROR: Plan template not found; cannot create plan.md" in result.stderr + assert not (plan_repo / "specs" / "001-my-feature" / "plan.md").exists() diff --git a/tests/test_setup_plan_python_parity.py b/tests/test_setup_plan_python_parity.py index 09356a142f..e6ae84af28 100644 --- a/tests/test_setup_plan_python_parity.py +++ b/tests/test_setup_plan_python_parity.py @@ -162,13 +162,27 @@ def test_python_missing_template_matches_bash(tmp_path: Path) -> None: bash = run(bash_cmd(repo_a, SCRIPT, "--json"), repo_a) py = run(py_cmd(repo_b, SCRIPT, "--json"), repo_b) - assert py.returncode == bash.returncode == 0 + assert py.returncode == bash.returncode == 1 + assert py.stdout == bash.stdout == "" assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths( bash.stderr, repo_a ) for repo in (repo_a, repo_b): plan = repo / "specs" / "001-my-feature" / "plan.md" - assert plan.read_text(encoding="utf-8") == "" + assert not plan.exists() + + for repo in (repo_a, repo_b): + templates = repo / ".specify" / "templates" + templates.mkdir(parents=True) + (templates / "plan-template.md").write_text(TEMPLATE_BODY, encoding="utf-8") + + bash_retry = run(bash_cmd(repo_a, SCRIPT, "--json"), repo_a) + py_retry = run(py_cmd(repo_b, SCRIPT, "--json"), repo_b) + assert py_retry.returncode == bash_retry.returncode == 0 + for repo in (repo_a, repo_b): + assert ( + repo / "specs" / "001-my-feature" / "plan.md" + ).read_text(encoding="utf-8") == TEMPLATE_BODY @requires_bash From 9c7cc2221231913023ed6fd8b7bf4dc734b42900 Mon Sep 17 00:00:00 2001 From: Ken Schlobohm Date: Wed, 14 Oct 2026 12:40:31 -0500 Subject: [PATCH 2/2] Reject empty plan templates and clarify recovery guidance Preserve existing plans and distinguish missing plans from zero-byte plans across Bash, PowerShell, and Python. Add text and JSON regression coverage for template failures, retry, and plan preservation. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e12bb4c-662b-4247-a0d0-83a16254948f --- scripts/bash/check-prerequisites.sh | 11 +- scripts/bash/setup-plan.sh | 14 ++- scripts/powershell/check-prerequisites.ps1 | 19 ++-- scripts/powershell/setup-plan.ps1 | 10 +- scripts/python/check_prerequisites.py | 18 ++-- scripts/python/setup_plan.py | 16 ++- .../test_check_prerequisites_python_parity.py | 68 +++++++++--- tests/test_setup_plan_no_overwrite.py | 5 +- tests/test_setup_plan_python_parity.py | 100 ++++++++++++++++++ 9 files changed, 221 insertions(+), 40 deletions(-) diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index ecb3ffbd66..1eadd991b2 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -142,15 +142,18 @@ if [[ ! -d "$FEATURE_DIR" ]]; then exit 1 fi -if [[ ! -f "$IMPL_PLAN" || ! -s "$IMPL_PLAN" ]]; then +if [[ ! -f "$IMPL_PLAN" ]]; then echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 - if [[ -f "$IMPL_PLAN" ]]; then - echo "ERROR: plan.md is empty in $FEATURE_DIR; populate it or rerun plan setup." >&2 - fi echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2 exit 1 fi +if [[ ! -s "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md is empty in $FEATURE_DIR" >&2 + echo "Populate it, or remove the empty file and rerun $(format_speckit_command plan "$REPO_ROOT") with a valid plan template." >&2 + exit 1 +fi + # Check for spec.md if required if $REQUIRE_SPEC && [[ ! -f "$FEATURE_SPEC" ]]; then echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index c25d9ed3a6..9c8d163409 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -43,7 +43,15 @@ if [[ -f "$IMPL_PLAN" ]]; then echo "Plan already exists at $IMPL_PLAN, skipping template copy" fi else - if resolve_template_content "plan-template" "$REPO_ROOT" > "$IMPL_PLAN"; then + template_file=$(mktemp) + trap 'rm -f "$template_file"' EXIT + if resolve_template_content "plan-template" "$REPO_ROOT" > "$template_file"; then + if [[ ! -s "$template_file" ]]; then + echo "ERROR: Plan template is empty; no new plan.md was created." >&2 + echo "Restore a valid plan template and rerun plan setup." >&2 + exit 1 + fi + cat "$template_file" > "$IMPL_PLAN" if $JSON_MODE; then echo "Copied plan template to $IMPL_PLAN" >&2 else @@ -51,11 +59,11 @@ else fi else resolve_status=$? - rm -f "$IMPL_PLAN" if [ "$resolve_status" -ne 1 ]; then exit "$resolve_status" fi - echo "ERROR: Plan template not found; cannot create plan.md" >&2 + echo "ERROR: Plan template not found; no new plan.md was created." >&2 + echo "Restore a valid plan template and rerun plan setup." >&2 exit 1 fi fi diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index 6c6637743e..04814e6283 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -101,21 +101,22 @@ if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { exit 1 } -if ( - -not (Test-Path $paths.IMPL_PLAN -PathType Leaf) -or - (Get-Item -LiteralPath $paths.IMPL_PLAN).Length -eq 0 -) { +if (-not (Test-Path -LiteralPath $paths.IMPL_PLAN -PathType Leaf)) { [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)") - if (Test-Path $paths.IMPL_PLAN -PathType Leaf) { - [Console]::Error.WriteLine( - "ERROR: plan.md is empty in $($paths.FEATURE_DIR); populate it or rerun plan setup." - ) - } $planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT [Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.") exit 1 } +if ((Get-Item -LiteralPath $paths.IMPL_PLAN).Length -eq 0) { + [Console]::Error.WriteLine("ERROR: plan.md is empty in $($paths.FEATURE_DIR)") + $planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT + [Console]::Error.WriteLine( + "Populate it, or remove the empty file and rerun $planCommand with a valid plan template." + ) + exit 1 +} + # Check for spec.md if required if ($RequireSpec -and -not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) { [Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)") diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index 07714af166..88b342c8bb 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -48,6 +48,13 @@ if (Test-Path -LiteralPath $paths.IMPL_PLAN -PathType Leaf) { } else { $content = Resolve-TemplateContent -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT if ($null -ne $content) { + if ($content.Length -eq 0) { + [Console]::Error.WriteLine( + "ERROR: Plan template is empty; no new plan.md was created." + ) + [Console]::Error.WriteLine("Restore a valid plan template and rerun plan setup.") + exit 1 + } $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom) # Emit the copy status like the bash twin (setup-plan.sh); route to stderr @@ -59,8 +66,9 @@ if (Test-Path -LiteralPath $paths.IMPL_PLAN -PathType Leaf) { } } else { [Console]::Error.WriteLine( - "ERROR: Plan template not found; cannot create plan.md" + "ERROR: Plan template not found; no new plan.md was created." ) + [Console]::Error.WriteLine("Restore a valid plan template and rerun plan setup.") exit 1 } } diff --git a/scripts/python/check_prerequisites.py b/scripts/python/check_prerequisites.py index c470b269d5..c7834fe456 100644 --- a/scripts/python/check_prerequisites.py +++ b/scripts/python/check_prerequisites.py @@ -228,20 +228,24 @@ def main(argv: list[str] | None = None) -> int: ) return 1 - if not paths.impl_plan.is_file() or paths.impl_plan.stat().st_size == 0: + if not paths.impl_plan.is_file(): print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr) - if paths.impl_plan.is_file(): - print( - f"ERROR: plan.md is empty in {paths.feature_dir}; " - "populate it or rerun plan setup.", - file=sys.stderr, - ) print( f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.", file=sys.stderr, ) return 1 + if paths.impl_plan.stat().st_size == 0: + print(f"ERROR: plan.md is empty in {paths.feature_dir}", file=sys.stderr) + print( + "Populate it, or remove the empty file and rerun " + f"{format_speckit_command('plan', paths.repo_root)} " + "with a valid plan template.", + file=sys.stderr, + ) + return 1 + if args.require_spec and not paths.feature_spec.is_file(): print(f"ERROR: spec.md not found in {paths.feature_dir}", file=sys.stderr) print( diff --git a/scripts/python/setup_plan.py b/scripts/python/setup_plan.py index 87c38c3898..3eaa9f4a3f 100644 --- a/scripts/python/setup_plan.py +++ b/scripts/python/setup_plan.py @@ -70,11 +70,25 @@ def main(argv: list[str] | None = None) -> int: print(f"Error: {exc}", file=sys.stderr) return 1 if template_content is not None: + if template_content == "": + print( + "ERROR: Plan template is empty; no new plan.md was created.", + file=sys.stderr, + ) + print( + "Restore a valid plan template and rerun plan setup.", + file=sys.stderr, + ) + return 1 paths.impl_plan.write_bytes(template_content.encode("utf-8")) print(f"Copied plan template to {paths.impl_plan}", file=status_stream) else: print( - "ERROR: Plan template not found; cannot create plan.md", + "ERROR: Plan template not found; no new plan.md was created.", + file=sys.stderr, + ) + print( + "Restore a valid plan template and rerun plan setup.", file=sys.stderr, ) return 1 diff --git a/tests/test_check_prerequisites_python_parity.py b/tests/test_check_prerequisites_python_parity.py index fda6b8393a..3a53887ff8 100644 --- a/tests/test_check_prerequisites_python_parity.py +++ b/tests/test_check_prerequisites_python_parity.py @@ -562,8 +562,24 @@ def test_python_negative_errors_are_stderr_only( assert py.stdout.strip() == "" -@requires_bash -def test_all_variants_reject_empty_plan(tmp_path: Path) -> None: +@pytest.mark.parametrize( + "port", + [ + pytest.param("bash", marks=requires_bash), + pytest.param( + "powershell", + marks=pytest.mark.skipif( + not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available" + ), + ), + pytest.param("python"), + ], +) +@pytest.mark.parametrize("json_mode", [True, False], ids=["json", "text"]) +@pytest.mark.parametrize("plan_state", ["missing", "empty", "nonempty", "whitespace"]) +def test_plan_prerequisite_diagnostics( + tmp_path: Path, port: str, json_mode: bool, plan_state: str +) -> None: repo = tmp_path / "proj" repo.mkdir() _git_init(repo) @@ -572,18 +588,42 @@ def test_all_variants_reject_empty_plan(tmp_path: Path) -> None: _write_feature_json(repo) feature = repo / "specs" / "001-my-feature" feature.mkdir(parents=True) - (feature / "plan.md").touch() - - results = [ - _run(_bash_cmd(repo, "--json", "--require-spec"), repo), - _run(_py_cmd(repo, "--json", "--require-spec"), repo), - ] - if HAS_PWSH or _WINDOWS_POWERSHELL: - results.append(_run(_ps_cmd(repo, "-Json", "-RequireSpec"), repo)) - - assert all(result.returncode == 1 for result in results) - assert all(result.stdout == "" for result in results) - assert all("plan.md is empty" in result.stderr for result in results) + plan = feature / "plan.md" + bodies = {"empty": b"", "nonempty": b"# Plan\n", "whitespace": b" \t\n"} + if plan_state != "missing": + plan.write_bytes(bodies[plan_state]) + before = plan.stat().st_mtime_ns + if port == "powershell": + command = _ps_cmd(repo, *(("-Json",) if json_mode else ())) + else: + command_fn = _bash_cmd if port == "bash" else _py_cmd + command = command_fn(repo, *(("--json",) if json_mode else ())) + + result = _run(command, repo) + + if plan_state in {"missing", "empty"}: + assert result.returncode == 1 + assert result.stdout == "" + lines = result.stderr.replace("\\", "/").splitlines() + diagnostic = "not found" if plan_state == "missing" else "is empty" + assert lines[0].startswith(f"ERROR: plan.md {diagnostic} in ") + assert lines[0].endswith("/specs/001-my-feature") + guidance = ( + "Run /speckit.plan first to create the implementation plan." + if plan_state == "missing" + else "Populate it, or remove the empty file and rerun /speckit.plan " + "with a valid plan template." + ) + assert lines[1:] == [guidance] + else: + assert result.returncode == 0, result.stderr + if json_mode: + assert "FEATURE_DIR" in _json_stdout(result) + if plan_state == "missing": + assert not plan.exists() + else: + assert plan.read_bytes() == bodies[plan_state] + assert plan.stat().st_mtime_ns == before def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) -> None: diff --git a/tests/test_setup_plan_no_overwrite.py b/tests/test_setup_plan_no_overwrite.py index 9a5249497b..2f4734ca0d 100644 --- a/tests/test_setup_plan_no_overwrite.py +++ b/tests/test_setup_plan_no_overwrite.py @@ -296,5 +296,8 @@ def test_ps_setup_plan_template_not_found_warning_matches_bash(plan_repo: Path) ) assert result.returncode == 1 assert result.stdout == "" - assert "ERROR: Plan template not found; cannot create plan.md" in result.stderr + assert result.stderr == ( + "ERROR: Plan template not found; no new plan.md was created.\n" + "Restore a valid plan template and rerun plan setup.\n" + ) assert not (plan_repo / "specs" / "001-my-feature" / "plan.md").exists() diff --git a/tests/test_setup_plan_python_parity.py b/tests/test_setup_plan_python_parity.py index e6ae84af28..0ac08cf626 100644 --- a/tests/test_setup_plan_python_parity.py +++ b/tests/test_setup_plan_python_parity.py @@ -26,6 +26,21 @@ SCRIPT = "setup-plan" TEMPLATE_BODY = "# Plan Template\n\nBody.\n" +PORTS = [ + pytest.param("bash", marks=requires_bash), + pytest.param( + "powershell", + marks=pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available"), + ), + pytest.param("python"), +] + + +def _command(repo: Path, port: str, json_mode: bool) -> list[str]: + if port == "powershell": + return ps_cmd(repo, SCRIPT, *(("-Json",) if json_mode else ())) + command = bash_cmd if port == "bash" else py_cmd + return command(repo, SCRIPT, *(("--json",) if json_mode else ())) def _setup_repo(tmp_path: Path, name: str = "proj", template: bool = True) -> Path: @@ -45,6 +60,91 @@ def repo(tmp_path: Path) -> Path: return _setup_repo(tmp_path) +@pytest.mark.parametrize("port", PORTS) +@pytest.mark.parametrize("json_mode", [True, False], ids=["json", "text"]) +@pytest.mark.parametrize("template_state", ["missing", "empty", "empty_override"]) +def test_plan_template_failure_and_retry( + tmp_path: Path, port: str, json_mode: bool, template_state: str +) -> None: + repo = _setup_repo(tmp_path, template=False) + template = repo / ".specify" / "templates" / "plan-template.md" + if template_state != "missing": + template.parent.mkdir(parents=True) + template.write_bytes(b"") + if template_state == "empty_override": + template.write_bytes(TEMPLATE_BODY.encode("utf-8")) + template = template.parent / "overrides" / template.name + template.parent.mkdir() + template.write_bytes(b"") + plan = repo / "specs" / "001-my-feature" / "plan.md" + + result = run(_command(repo, port, json_mode), repo) + + diagnostic = "not found" if template_state == "missing" else "is empty" + assert result.returncode == 1 + assert result.stdout == "" + assert result.stderr == ( + f"ERROR: Plan template {diagnostic}; no new plan.md was created.\n" + "Restore a valid plan template and rerun plan setup.\n" + ) + assert not plan.exists() + + template.parent.mkdir(parents=True, exist_ok=True) + template.write_bytes(TEMPLATE_BODY.encode("utf-8")) + retry = run(_command(repo, port, json_mode), repo) + + assert retry.returncode == 0, retry.stderr + assert plan.read_bytes() == TEMPLATE_BODY.encode("utf-8") + if json_mode: + assert sorted(json_stdout(retry)) == [ + "BRANCH", "FEATURE_DIR", "FEATURE_SPEC", "IMPL_PLAN" + ] + else: + assert "IMPL_PLAN: " in retry.stdout + + +@pytest.mark.parametrize("port", PORTS) +@pytest.mark.parametrize("json_mode", [True, False], ids=["json", "text"]) +@pytest.mark.parametrize("template_state", ["missing", "empty", "valid"]) +@pytest.mark.parametrize("body", [b"", b"# Existing plan\n"], ids=["empty", "nonempty"]) +def test_existing_plan_is_preserved( + tmp_path: Path, port: str, json_mode: bool, template_state: str, body: bytes +) -> None: + repo = _setup_repo(tmp_path, template=False) + if template_state != "missing": + template = repo / ".specify" / "templates" / "plan-template.md" + template.parent.mkdir(parents=True) + template.write_bytes(b"" if template_state == "empty" else TEMPLATE_BODY.encode()) + plan = repo / "specs" / "001-my-feature" / "plan.md" + plan.write_bytes(body) + before = plan.stat().st_mtime_ns + + result = run(_command(repo, port, json_mode), repo) + + assert result.returncode == 0, result.stderr + assert plan.read_bytes() == body + assert plan.stat().st_mtime_ns == before + status = result.stderr if json_mode else result.stdout + assert "already exists" in status + assert "skipping template copy" in status + if json_mode: + assert "IMPL_PLAN" in json_stdout(result) + + +@pytest.mark.parametrize("port", PORTS) +@pytest.mark.parametrize("json_mode", [True, False], ids=["json", "text"]) +def test_whitespace_plan_template_is_not_empty( + repo: Path, port: str, json_mode: bool +) -> None: + body = b" \t\n\n" + (repo / ".specify" / "templates" / "plan-template.md").write_bytes(body) + + result = run(_command(repo, port, json_mode), repo) + + assert result.returncode == 0, result.stderr + assert (repo / "specs" / "001-my-feature" / "plan.md").read_bytes() == body + + @requires_bash def test_python_fresh_copy_matches_bash(tmp_path: Path) -> None: repo_a = _setup_repo(tmp_path, "proj-a")