diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index 04814e6283..e9ab65195f 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -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.") @@ -118,7 +118,7 @@ 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.") @@ -126,7 +126,7 @@ if ($RequireSpec -and -not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) { } # 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.") @@ -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' } diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index c90308ef9c..c0aa9154b5 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -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 { @@ -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 { diff --git a/scripts/powershell/setup-tasks.ps1 b/scripts/powershell/setup-tasks.ps1 index 4adbbc4b93..c0fbb159e8 100644 --- a/scripts/powershell/setup-tasks.ps1 +++ b/scripts/powershell/setup-tasks.ps1 @@ -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.") @@ -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 diff --git a/tests/test_check_prerequisites_python_parity.py b/tests/test_check_prerequisites_python_parity.py index 3a53887ff8..39d1a6af49 100644 --- a/tests/test_check_prerequisites_python_parity.py +++ b/tests/test_check_prerequisites_python_parity.py @@ -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" diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index 56a8eae854..3deb6ab17a 100644 --- a/tests/test_setup_tasks.py +++ b/tests/test_setup_tasks.py @@ -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( @@ -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 @@ -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, @@ -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