diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 7d6dba1353..1eadd991b2 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -148,6 +148,12 @@ if [[ ! -f "$IMPL_PLAN" ]]; then 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 aa394183cf..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,16 +59,12 @@ else fi else resolve_status=$? - rm -f "$IMPL_PLAN" 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; 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 0e9434d063..04814e6283 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -101,13 +101,22 @@ if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { exit 1 } -if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { +if (-not (Test-Path -LiteralPath $paths.IMPL_PLAN -PathType Leaf)) { [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)") $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 0d888584a7..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 @@ -58,16 +65,11 @@ 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; 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 e025b4d672..c7834fe456 100644 --- a/scripts/python/check_prerequisites.py +++ b/scripts/python/check_prerequisites.py @@ -236,6 +236,16 @@ def main(argv: list[str] | None = None) -> int: ) 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 721eed8f39..3eaa9f4a3f 100644 --- a/scripts/python/setup_plan.py +++ b/scripts/python/setup_plan.py @@ -70,11 +70,28 @@ 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("Warning: Plan template not found", file=status_stream) - paths.impl_plan.touch() + print( + "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 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..3a53887ff8 100644 --- a/tests/test_check_prerequisites_python_parity.py +++ b/tests/test_check_prerequisites_python_parity.py @@ -562,6 +562,70 @@ def test_python_negative_errors_are_stderr_only( assert py.stdout.strip() == "" +@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) + (repo / ".specify").mkdir() + _install_scripts(repo) + _write_feature_json(repo) + feature = repo / "specs" / "001-my-feature" + feature.mkdir(parents=True) + 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: (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..2f4734ca0d 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,10 @@ 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 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 09356a142f..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") @@ -162,13 +262,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