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
8 changes: 7 additions & 1 deletion scripts/bash/check-prerequisites.sh
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,13 @@ if [[ ! -f "$IMPL_PLAN" ]]; then
exit 1
fi

if [[ ! -s "$IMPL_PLAN" ]]; then
if [[ ! -r "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md is not readable in $FEATURE_DIR" >&2
echo "Check its file permissions and rerun the prerequisite check." >&2
exit 1
fi

if ! file_has_non_whitespace_content "$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
Expand Down
7 changes: 7 additions & 0 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
#!/usr/bin/env bash
# Common functions and variables for all scripts

# Match Unicode White_Space, as PowerShell does, even under LC_ALL=C.
# Remove only a file-leading UTF-8 BOM for the check; never rewrite the file.
file_has_non_whitespace_content() {
LC_ALL=C sed $'1s/^\357\273\277//' "$1" |
LC_ALL=C grep -aEv $'^([[:space:]]|\302[\205\240]|\341\232\200|\342\200[\200-\212\250\251\257]|\342\201\237|\343\200\200)*$' >/dev/null
}

# Find repository root by searching upward for .specify directory
# This is the primary marker for spec-kit projects
find_specify_root() {
Expand Down
2 changes: 1 addition & 1 deletion scripts/bash/setup-plan.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ else
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
if ! file_has_non_whitespace_content "$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
Expand Down
10 changes: 9 additions & 1 deletion scripts/powershell/check-prerequisites.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,15 @@ if (-not (Test-Path -LiteralPath $paths.IMPL_PLAN -PathType Leaf)) {
exit 1
}

if ((Get-Item -LiteralPath $paths.IMPL_PLAN).Length -eq 0) {
try {
$planContent = [System.IO.File]::ReadAllText($paths.IMPL_PLAN)
} catch {
[Console]::Error.WriteLine("ERROR: plan.md is not readable in $($paths.FEATURE_DIR)")
[Console]::Error.WriteLine("Check its file permissions and rerun the prerequisite check.")
exit 1
}

if ([string]::IsNullOrWhiteSpace($planContent)) {
[Console]::Error.WriteLine("ERROR: plan.md is empty in $($paths.FEATURE_DIR)")
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
[Console]::Error.WriteLine(
Expand Down
2 changes: 1 addition & 1 deletion scripts/powershell/setup-plan.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ 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) {
if ([string]::IsNullOrWhiteSpace($content)) {
[Console]::Error.WriteLine(
"ERROR: Plan template is empty; no new plan.md was created."
)
Expand Down
14 changes: 13 additions & 1 deletion scripts/python/check_prerequisites.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
TemplateResolutionError,
format_speckit_command,
get_feature_paths,
has_non_whitespace_content,
resolve_template_content,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
Expand All @@ -23,6 +24,7 @@
TemplateResolutionError,
format_speckit_command,
get_feature_paths,
has_non_whitespace_content,
resolve_template_content,
)

Expand Down Expand Up @@ -236,7 +238,17 @@ def main(argv: list[str] | None = None) -> int:
)
return 1

if paths.impl_plan.stat().st_size == 0:
try:
plan_content = paths.impl_plan.read_text(encoding="utf-8", errors="replace")
except OSError:
print(f"ERROR: plan.md is not readable in {paths.feature_dir}", file=sys.stderr)
print(
"Check its file permissions and rerun the prerequisite check.",
file=sys.stderr,
)
return 1

if not has_non_whitespace_content(plan_content):
print(f"ERROR: plan.md is empty in {paths.feature_dir}", file=sys.stderr)
print(
"Populate it, or remove the empty file and rerun "
Expand Down
9 changes: 9 additions & 0 deletions scripts/python/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
from pathlib import Path


def has_non_whitespace_content(content: str) -> bool:
"""Check Unicode White_Space like PowerShell, ignoring one leading BOM."""
whitespace = (
"\t\n\v\f\r \u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005"
"\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"
)
return bool(content.removeprefix("\ufeff").strip(whitespace))


def _trim_trailing_separators(value: Path) -> str:
text = str(value)
while len(text) > 1 and text.endswith((os.sep, "/")):
Expand Down
4 changes: 3 additions & 1 deletion scripts/python/setup_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
from common import (
TemplateResolutionError,
get_feature_paths,
has_non_whitespace_content,
resolve_template_content,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import (
TemplateResolutionError,
get_feature_paths,
has_non_whitespace_content,
resolve_template_content,
)

Expand Down Expand Up @@ -70,7 +72,7 @@ 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 == "":
if not has_non_whitespace_content(template_content):
print(
"ERROR: Plan template is empty; no new plan.md was created.",
file=sys.stderr,
Expand Down
148 changes: 144 additions & 4 deletions tests/test_check_prerequisites_python_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import shutil
import subprocess
import sys
from contextlib import contextmanager
from pathlib import Path

import pytest
Expand Down Expand Up @@ -118,6 +119,66 @@ def _run(
)


@contextmanager
def _deny_plan_read(plan: Path):
if os.name != "nt":
mode = plan.stat().st_mode
try:
plan.chmod(0)
if os.access(plan, os.R_OK):
pytest.skip("current user can read files with no read permissions")
yield
finally:
plan.chmod(mode)
return

env = {**_clean_env(), "TEST_PLAN_PATH": str(plan)}
command = [_WINDOWS_POWERSHELL, "-NoProfile", "-Command"]
saved = _run(
[
*command,
"([System.IO.File]::GetAccessControl($env:TEST_PLAN_PATH)).Sddl",
],
plan.parent,
env=env,
)
assert saved.returncode == 0, saved.stderr
env["TEST_PLAN_SDDL"] = saved.stdout.strip()
try:
denied = _run(
[
*command,
"$ErrorActionPreference = 'Stop'; "
"$acl = [System.IO.File]::GetAccessControl($env:TEST_PLAN_PATH); "
"$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User; "
"$rule = New-Object System.Security.AccessControl.FileSystemAccessRule("
"$sid, 'ReadData', 'Deny'); "
"$acl.AddAccessRule($rule); "
"[System.IO.File]::SetAccessControl($env:TEST_PLAN_PATH, $acl)",
],
plan.parent,
env=env,
)
assert denied.returncode == 0, denied.stderr
with pytest.raises(PermissionError):
plan.read_bytes()
yield
finally:
restored = _run(
[
*command,
"$ErrorActionPreference = 'Stop'; "
"$acl = [System.IO.File]::GetAccessControl($env:TEST_PLAN_PATH); "
"$acl.SetSecurityDescriptorSddlForm($env:TEST_PLAN_SDDL, "
"[System.Security.AccessControl.AccessControlSections]::Access); "
"[System.IO.File]::SetAccessControl($env:TEST_PLAN_PATH, $acl)",
],
plan.parent,
env=env,
)
assert restored.returncode == 0, restored.stderr


def _json_stdout(result: subprocess.CompletedProcess[str]) -> object:
return json.loads(result.stdout)

Expand Down Expand Up @@ -576,7 +637,16 @@ def test_python_negative_errors_are_stderr_only(
],
)
@pytest.mark.parametrize("json_mode", [True, False], ids=["json", "text"])
@pytest.mark.parametrize("plan_state", ["missing", "empty", "nonempty", "whitespace"])
@pytest.mark.parametrize(
"plan_state",
[
"missing", "empty", "nonempty", "padded",
"whitespace", "whitespace_crlf", "whitespace_controls",
"whitespace_nbsp", "whitespace_bom", "whitespace_bom_nbsp",
"whitespace_unicode", "bom_content", "utf16_content", "cp1252_content",
"non_whitespace_control", "internal_bom", "double_bom",
],
)
def test_plan_prerequisite_diagnostics(
tmp_path: Path, port: str, json_mode: bool, plan_state: str
) -> None:
Expand All @@ -589,7 +659,27 @@ def test_plan_prerequisite_diagnostics(
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"}
bodies = {
"empty": b"",
"nonempty": b"# Plan\n",
"padded": b" \t\n# Plan\n \t\r\n",
"whitespace": b" \t\n",
"whitespace_crlf": b" \t\r\n\r\n",
"whitespace_controls": b"\v\f\n",
"whitespace_nbsp": "\u00a0\n".encode(),
"whitespace_bom": b"\xef\xbb\xbf \t\r\n",
"whitespace_bom_nbsp": "\ufeff\u00a0\n".encode(),
"whitespace_unicode": (
"\u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005"
"\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\n"
).encode(),
"bom_content": b"\xef\xbb\xbf \t# Plan\n",
"utf16_content": "# Plan\n".encode("utf-16"),
"cp1252_content": b"# \x93Plan\x94\n",
"non_whitespace_control": b"\x1c",
"internal_bom": b"\n\xef\xbb\xbf\n",
"double_bom": b"\xef\xbb\xbf\xef\xbb\xbf\n",
}
if plan_state != "missing":
plan.write_bytes(bodies[plan_state])
before = plan.stat().st_mtime_ns
Expand All @@ -599,9 +689,9 @@ def test_plan_prerequisite_diagnostics(
command_fn = _bash_cmd if port == "bash" else _py_cmd
command = command_fn(repo, *(("--json",) if json_mode else ()))

result = _run(command, repo)
result = _run(command, repo, env={**_clean_env(), "LC_ALL": "C"})

if plan_state in {"missing", "empty"}:
if plan_state in {"missing", "empty"} or plan_state.startswith("whitespace"):
assert result.returncode == 1
assert result.stdout == ""
lines = result.stderr.replace("\\", "/").splitlines()
Expand All @@ -626,6 +716,56 @@ def test_plan_prerequisite_diagnostics(
assert plan.stat().st_mtime_ns == before


@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"])
def test_unreadable_plan_is_not_reported_as_empty(
prereq_repo: Path, port: str, json_mode: bool
) -> None:
_write_feature_json(prereq_repo)
feature = prereq_repo / "specs" / "001-my-feature"
feature.mkdir(parents=True)
plan = feature / "plan.md"
content = b"# Existing implementation plan\n"
plan.write_bytes(content)
before = plan.stat().st_mtime_ns
if port == "powershell":
command_fn = _ps_cmd
args = ("-Json",) if json_mode else ()
paths_flag = "-PathsOnly"
else:
command_fn = _bash_cmd if port == "bash" else _py_cmd
args = ("--json",) if json_mode else ()
paths_flag = "--paths-only"

with _deny_plan_read(plan):
result = _run(command_fn(prereq_repo, *args), prereq_repo)
paths_only = _run(command_fn(prereq_repo, *args, paths_flag), prereq_repo)

assert result.returncode == 1
assert result.stdout == ""
assert result.stderr.splitlines() == [
f"ERROR: plan.md is not readable in {feature}",
"Check its file permissions and rerun the prerequisite check.",
]
assert paths_only.returncode == 0, paths_only.stderr
assert plan.read_bytes() == content
assert plan.stat().st_mtime_ns == before
readable = _run(command_fn(prereq_repo, *args), prereq_repo)
assert readable.returncode == 0, readable.stderr


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
Loading