From b0f4fdfda87e3af65fff2a85007e0c2f50488d2c Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:52:46 +0800 Subject: [PATCH 1/3] fix(powershell): treat bracketed feature paths literally Assisted-by: Codex CLI (model: gpt-5.6-terra, autonomous) --- scripts/powershell/check-prerequisites.ps1 | 16 ++-- scripts/powershell/common.ps1 | 4 +- scripts/powershell/setup-tasks.ps1 | 12 +-- .../test_check_prerequisites_python_parity.py | 76 +++++++++++++++++++ tests/test_setup_tasks.py | 76 +++++++++++++++++++ 5 files changed, 168 insertions(+), 16 deletions(-) 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..43c06c4148 100644 --- a/tests/test_setup_tasks.py +++ b/tests/test_setup_tasks.py @@ -829,6 +829,82 @@ 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 = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + 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 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}") + + 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(), + ) + + assert result.returncode != 0 + 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, From 81a6f3f9ffbc7f0b19f22c3916fc6942174b0d48 Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:13:11 +0800 Subject: [PATCH 2/3] test(powershell): stabilize setup-tasks JSON encoding Assisted-by: Codex CLI (model: gpt-5.6-terra, autonomous) --- tests/test_setup_tasks.py | 75 ++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 40 deletions(-) diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index 43c06c4148..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 @@ -847,14 +867,7 @@ def test_setup_tasks_ps_handles_literal_bracketed_feature_directory( script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL - json_result = subprocess.run( - [exe, "-NoProfile", "-File", str(script), "-Json"], - cwd=tasks_repo, - capture_output=True, - text=True, - check=False, - env=_clean_env(), - ) + json_result = _run_powershell_json_utf8(tasks_repo) text_result = subprocess.run( [exe, "-NoProfile", "-File", str(script)], cwd=tasks_repo, @@ -867,6 +880,7 @@ def test_setup_tasks_ps_handles_literal_bracketed_feature_directory( 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", @@ -890,18 +904,9 @@ def test_setup_tasks_ps_does_not_match_wildcard_shaped_neighbor( (neighbor / "plan.md").write_text("# neighbor plan\n", encoding="utf-8") _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 - 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 + assert result.returncode == 1 assert "plan.md not found" in result.stderr @@ -1025,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 From 987a2a011a6a85cbbc84497806420b1af4d5e21d Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:11:46 +0800 Subject: [PATCH 3/3] test(powershell): quote command helper arguments --- tests/test_setup_tasks.py | 111 +++++++++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 7 deletions(-) diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index 3deb6ab17a..58a90cc3cc 100644 --- a/tests/test_setup_tasks.py +++ b/tests/test_setup_tasks.py @@ -116,6 +116,11 @@ def _run_bash_format_command(repo: Path, command_name: str) -> subprocess.Comple ) +def _powershell_literal(value: str | Path) -> str: + """Quote a value for PowerShell source passed to -Command.""" + return "'" + str(value).replace("'", "''") + "'" + + def _run_powershell_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess: script = repo / ".specify" / "scripts" / "powershell" / "common.ps1" exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL @@ -124,9 +129,8 @@ def _run_powershell_format_command(repo: Path, command_name: str) -> subprocess. exe, "-NoProfile", "-Command", - '& { param($common, $commandName) . $common; Format-SpecKitCommand -CommandName $commandName -RepoRoot (Get-Location).Path }', - str(script), - command_name, + '& { param($common, $commandName) . $common; Format-SpecKitCommand -CommandName $commandName -RepoRoot (Get-Location).Path } ' + f"{_powershell_literal(script)} {_powershell_literal(command_name)}", ], cwd=repo, capture_output=True, @@ -153,9 +157,9 @@ def _run_powershell_json_utf8(repo: Path) -> subprocess.CompletedProcess: "$scriptExitCode = $LASTEXITCODE; " "if ($scriptSucceeded) { exit 0 }; " "if ($null -ne $scriptExitCode -and $scriptExitCode -ne 0) { exit $scriptExitCode }; " - "exit 1 }" + "exit 1 } " + f"{_powershell_literal(script)}" ), - str(script), ], cwd=repo, capture_output=True, @@ -207,6 +211,22 @@ def tasks_repo(tmp_path: Path) -> Path: return repo +@pytest.fixture(params=["pwsh", "windows-powershell"]) +def powershell_command_engine(request, monkeypatch) -> None: + """Exercise both supported engines rather than always preferring Core.""" + exe = shutil.which("pwsh") if request.param == "pwsh" else _WINDOWS_POWERSHELL + if exe is None: + pytest.skip(f"{request.param} not available") + monkeypatch.setitem(globals(), "HAS_PWSH", False) + monkeypatch.setitem(globals(), "_WINDOWS_POWERSHELL", exe) + + +@pytest.fixture(params=["project with spaces", "project's", "project's files"]) +def powershell_command_repo(powershell_command_engine, tasks_repo: Path, request) -> Path: + """Put special characters in the script path, above the installed scripts.""" + return tasks_repo.rename(tasks_repo.with_name(request.param)) + + # =========================================================================== # BASH TESTS # =========================================================================== @@ -1082,8 +1102,8 @@ def _run_powershell_test_dir(repo: Path, target: Path) -> subprocess.CompletedPr exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL return subprocess.run( [exe, "-NoProfile", "-Command", - '& { param($common, $dir) . $common; Test-DirHasFiles -Path $dir -Description "contracts/" }', - str(script), str(target)], + '& { param($common, $dir) . $common; Test-DirHasFiles -Path $dir -Description "contracts/" } ' + f"{_powershell_literal(script)} {_powershell_literal(target)}"], cwd=repo, capture_output=True, text=True, encoding="utf-8", check=False, env=_clean_env(), ) @@ -1111,3 +1131,80 @@ def test_dir_has_files_ps_counts_subdir_only_contracts(tasks_repo: Path) -> None # $false, so the [OK] marker in stdout — not the return code — is what proves # non-emptiness. assert "[OK]" in result.stdout and "[FAIL]" not in result.stdout, result.stderr + result.stdout + + +def test_powershell_command_path_json(powershell_command_repo: Path) -> None: + repo = powershell_command_repo + feature = _minimal_feature(repo) + + result = _run_powershell_json_utf8(repo) + + assert result.returncode == 0, result.stderr + result.stdout + data = json.loads(result.stdout) + assert Path(data["FEATURE_DIR"]) == feature + assert Path(data["TASKS_TEMPLATE"]) == repo / ".specify" / "templates" / "tasks-template.md" + assert "⚠️" in data["TASKS_TEMPLATE_CONTENT"] + + +def test_powershell_command_path_format(powershell_command_repo: Path) -> None: + # These characters must survive as argument data, including in the second + # interpolation, rather than being evaluated as PowerShell source. + command_name = "owner's task $name; (draft) `value`" + _write_integration_state(powershell_command_repo, "copilot", ".") + + result = _run_powershell_format_command(powershell_command_repo, command_name) + + assert result.returncode == 0, result.stderr + result.stdout + assert result.stdout.strip() == f"/speckit.{command_name}" + + +@pytest.mark.parametrize("state", ["populated", "empty", "missing"]) +def test_powershell_command_path_dir(powershell_command_repo: Path, state: str) -> None: + target = powershell_command_repo / "owner's contracts $draft" + if state != "missing": + target.mkdir() + if state == "populated": + (target / "v1").mkdir() + + result = _run_powershell_test_dir(powershell_command_repo, target) + + assert result.returncode == 0, result.stderr + result.stdout + expected = ["[OK] contracts/", "True"] if state == "populated" else ["[FAIL] contracts/", "False"] + assert [line.strip() for line in result.stdout.splitlines()] == expected + + +@pytest.mark.parametrize( + ("script_end", "exit_code"), + [("exit 0", 0), ("exit 37", 37), ("throw 'controlled failure'", 1)], + ids=["success", "explicit-failure", "throw"], +) +def test_powershell_json_utf8_preserves_status( + powershell_command_repo: Path, script_end: str, exit_code: int +) -> None: + script = powershell_command_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + # Windows PowerShell 5.1 requires a BOM to decode non-ASCII script source. + script.write_text( + "param([switch]$Json)\n" + "if (-not $Json) { throw 'missing -Json' }\n" + "Write-Output 'héllo ⚠️'\n" + f"{script_end}\n", + encoding="utf-8-sig", + ) + + result = _run_powershell_json_utf8(powershell_command_repo) + + assert result.returncode == exit_code, result.stderr + result.stdout + assert result.stdout.strip() == "héllo ⚠️" + if script_end.startswith("throw"): + assert "controlled failure" in result.stderr + + +def test_powershell_command_path_missing_plan(powershell_command_repo: Path) -> None: + feature = _minimal_feature(powershell_command_repo) + (feature / "plan.md").unlink() + + result = _run_powershell_json_utf8(powershell_command_repo) + + assert result.returncode == 1, result.stderr + result.stdout + assert "plan.md not found" in result.stderr + assert not result.stdout.strip()