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
16 changes: 8 additions & 8 deletions scripts/powershell/check-prerequisites.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ if ($PathsOnly) {
}

# Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
if (-not (Test-Path -LiteralPath $paths.FEATURE_DIR -PathType Container)) {
[Console]::Error.WriteLine("ERROR: Feature directory not found: $($paths.FEATURE_DIR)")
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
Expand All @@ -118,15 +118,15 @@ if ((Get-Item -LiteralPath $paths.IMPL_PLAN).Length -eq 0) {
}

# Check for spec.md if required
if ($RequireSpec -and -not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
if ($RequireSpec -and -not (Test-Path -LiteralPath $paths.FEATURE_SPEC -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature specification.")
exit 1
}

# Check for tasks.md if required
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
if ($RequireTasks -and -not (Test-Path -LiteralPath $paths.TASKS -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)")
$tasksCommand = Format-SpecKitCommand -CommandName 'tasks' -RepoRoot $paths.REPO_ROOT
[Console]::Error.WriteLine("Run $tasksCommand first to create the task list.")
Expand All @@ -137,18 +137,18 @@ if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
$docs = @()

# Always check these optional docs
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
if (Test-Path -LiteralPath $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path -LiteralPath $paths.DATA_MODEL) { $docs += 'data-model.md' }

# Check contracts directory (only if it exists and has files)
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
if ((Test-Path -LiteralPath $paths.CONTRACTS_DIR) -and (Get-ChildItem -LiteralPath $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
}

if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
if (Test-Path -LiteralPath $paths.QUICKSTART) { $docs += 'quickstart.md' }

# Include tasks.md if requested and it exists
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
if ($IncludeTasks -and (Test-Path -LiteralPath $paths.TASKS)) {
$docs += 'tasks.md'
}

Expand Down
4 changes: 2 additions & 2 deletions scripts/powershell/common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ function Get-FeaturePathsEnv {

function Test-FileExists {
param([string]$Path, [string]$Description)
if (Test-Path -Path $Path -PathType Leaf) {
if (Test-Path -LiteralPath $Path -PathType Leaf) {
Write-Output " [OK] $Description"
return $true
} else {
Expand All @@ -263,7 +263,7 @@ function Test-DirHasFiles {
# only contents are subdirectories (e.g. contracts/v1/openapi.yaml) as
# non-empty like bash check_dir. Filtering out subdirectories would
# mis-report such a directory as empty.
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) {
if ((Test-Path -LiteralPath $Path -PathType Container) -and (Get-ChildItem -LiteralPath $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) {
Write-Output " [OK] $Description"
return $true
} else {
Expand Down
12 changes: 6 additions & 6 deletions scripts/powershell/setup-tasks.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ if (-not $paths) {
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 (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
if (-not (Test-Path -LiteralPath $paths.FEATURE_SPEC -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
Expand All @@ -48,12 +48,12 @@ if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {

# Build available docs list
$docs = @()
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
if (Test-Path -LiteralPath $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path -LiteralPath $paths.DATA_MODEL) { $docs += 'data-model.md' }
if ((Test-Path -LiteralPath $paths.CONTRACTS_DIR) -and (Get-ChildItem -LiteralPath $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
}
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
if (Test-Path -LiteralPath $paths.QUICKSTART) { $docs += 'quickstart.md' }

# Resolve tasks template through override stack
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
Expand Down
76 changes: 76 additions & 0 deletions tests/test_check_prerequisites_python_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,82 @@ def test_python_json_output_matches_powershell(
assert _json_stdout(py) == _json_stdout(ps)


@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_powershell_handles_literal_bracketed_feature_directory(
prereq_repo: Path,
) -> None:
"""JSON and text output must use the literal feature directory."""
feature_name = "001-my-feature [v2]"
feat = prereq_repo / "specs" / feature_name
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
(feat / "research.md").write_text("# research\n", encoding="utf-8")
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
(feat / "contracts" / "v1").mkdir(parents=True)
_write_feature_json(prereq_repo, f"specs/{feature_name}")

json_result = _run(
_ps_cmd(
prereq_repo,
"-Json",
"-RequireSpec",
"-RequireTasks",
"-IncludeTasks",
),
prereq_repo,
)
text_result = _run(
_ps_cmd(prereq_repo, "-IncludeTasks"),
prereq_repo,
)
paths_only = _run(
_ps_cmd(prereq_repo, "-Json", "-PathsOnly"),
prereq_repo,
)

assert json_result.returncode == 0, json_result.stderr + json_result.stdout
data = _json_stdout(json_result)
assert Path(data["FEATURE_DIR"]) == feat
assert data["AVAILABLE_DOCS"] == [
"research.md",
"data-model.md",
"contracts/",
"quickstart.md",
"tasks.md",
]
assert text_result.returncode == 0, text_result.stderr + text_result.stdout
for document in (
"research.md",
"data-model.md",
"contracts/",
"quickstart.md",
"tasks.md",
):
assert f"[OK] {document}" in _normalize_status_text(text_result.stdout)
assert paths_only.returncode == 0, paths_only.stderr
assert Path(_json_stdout(paths_only)["FEATURE_DIR"]) == feat


@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_powershell_does_not_match_wildcard_shaped_feature_neighbor(
prereq_repo: Path,
) -> None:
"""A bracketed feature directory must not resolve to a matching neighbor."""
feature_name = "001-my-feature [v2]"
neighbor = prereq_repo / "specs" / "001-my-feature v"
neighbor.mkdir(parents=True)
(neighbor / "plan.md").write_text("# neighbor plan\n", encoding="utf-8")
_write_feature_json(prereq_repo, f"specs/{feature_name}")

result = _run(_ps_cmd(prereq_repo, "-Json"), prereq_repo)

assert result.returncode != 0
assert "Feature directory not found" in result.stderr


def test_python_repo_copy_script_file_fallback_finds_repo_root(tmp_path: Path) -> None:
repo = tmp_path / "proj"
outside = tmp_path / "outside"
Expand Down
113 changes: 92 additions & 21 deletions tests/test_setup_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,35 @@ def _run_powershell_format_command(repo: Path, command_name: str) -> subprocess.
)


def _run_powershell_json_utf8(repo: Path) -> subprocess.CompletedProcess:
"""Run setup-tasks.ps1 -Json with a process-local UTF-8 stdout encoding."""
script = repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
return subprocess.run(
[
exe,
"-NoProfile",
"-Command",
(
"& { param($scriptPath) "
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); "
"& $scriptPath -Json; "
"$scriptSucceeded = $?; "
"$scriptExitCode = $LASTEXITCODE; "
"if ($scriptSucceeded) { exit 0 }; "
"if ($null -ne $scriptExitCode -and $scriptExitCode -ne 0) { exit $scriptExitCode }; "
"exit 1 }"
),
str(script),
],
cwd=repo,
capture_output=True,
encoding="utf-8",
check=False,
env=_clean_env(),
)


def _git_init(repo: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
Expand Down Expand Up @@ -712,17 +741,8 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
TASKS_TEMPLATE path.
"""
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL

result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
encoding="utf-8",
check=False,
env=_clean_env(),
)
result = _run_powershell_json_utf8(tasks_repo)

assert result.returncode == 0, result.stderr + result.stdout

Expand Down Expand Up @@ -829,6 +849,67 @@ def test_setup_tasks_ps_text_output_lists_available_docs(tasks_repo: Path) -> No
assert "[FAIL] data-model.md" in normalized, normalized


@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_handles_literal_bracketed_feature_directory(
tasks_repo: Path,
) -> None:
"""Feature paths containing brackets must not be treated as wildcards."""
feature_name = "001-my-feature [v2]"
feat = tasks_repo / "specs" / feature_name
feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "research.md").write_text("# research\n", encoding="utf-8")
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
(feat / "contracts" / "v1").mkdir(parents=True)
_write_feature_json(tasks_repo, f"specs/{feature_name}")

script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
json_result = _run_powershell_json_utf8(tasks_repo)
text_result = subprocess.run(
[exe, "-NoProfile", "-File", str(script)],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)

assert json_result.returncode == 0, json_result.stderr + json_result.stdout
data = json.loads(json_result.stdout)
assert Path(data["FEATURE_DIR"]) == feat
assert "⚠️" in data["TASKS_TEMPLATE_CONTENT"]
assert data["AVAILABLE_DOCS"] == [
"research.md",
"data-model.md",
"contracts/",
"quickstart.md",
]
assert text_result.returncode == 0, text_result.stderr + text_result.stdout
for document in ("research.md", "data-model.md", "contracts/", "quickstart.md"):
assert f"[OK] {document}" in text_result.stdout, text_result.stdout


@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_does_not_match_wildcard_shaped_neighbor(
tasks_repo: Path,
) -> None:
"""A bracketed feature path must not accept a neighboring feature's plan."""
feature_name = "001-my-feature [v2]"
neighbor = tasks_repo / "specs" / "001-my-feature v"
neighbor.mkdir(parents=True)
(neighbor / "spec.md").write_text("# neighbor spec\n", encoding="utf-8")
(neighbor / "plan.md").write_text("# neighbor plan\n", encoding="utf-8")
_write_feature_json(tasks_repo, f"specs/{feature_name}")

result = _run_powershell_json_utf8(tasks_repo)

assert result.returncode == 1
assert "plan.md not found" in result.stderr


@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_powershell_command_hint_normalizes_mixed_separators(
tasks_repo: Path,
Expand Down Expand Up @@ -949,17 +1030,7 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)

script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL

result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
result = _run_powershell_json_utf8(tasks_repo)

assert result.returncode == 0, result.stderr + result.stdout

Expand Down