Skip to content
Merged
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
6 changes: 6 additions & 0 deletions scripts/bash/check-prerequisites.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions scripts/bash/setup-plan.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,24 +43,28 @@ 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
echo "Copied plan template to $IMPL_PLAN"
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

Expand Down
11 changes: 10 additions & 1 deletion scripts/powershell/check-prerequisites.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
22 changes: 12 additions & 10 deletions scripts/powershell/setup-plan.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}

Expand Down
10 changes: 10 additions & 0 deletions scripts/python/check_prerequisites.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
21 changes: 19 additions & 2 deletions scripts/python/setup_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
64 changes: 64 additions & 0 deletions tests/test_check_prerequisites_python_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 8 additions & 8 deletions tests/test_setup_plan_no_overwrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
118 changes: 116 additions & 2 deletions tests/test_setup_plan_python_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down
Loading