diff --git a/scripts/bash/setup-tasks.sh b/scripts/bash/setup-tasks.sh new file mode 100644 index 0000000000..3f6a40b12d --- /dev/null +++ b/scripts/bash/setup-tasks.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false + +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# Validate branch +# If feature.json pins an existing feature directory, branch naming is not required. +if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then + check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 +fi + +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.plan first to create the implementation plan." >&2 + exit 1 +fi + +if [[ ! -f "$FEATURE_SPEC" ]]; then + echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 + exit 1 +fi + +# Build available docs list +docs=() +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Resolve tasks template through override stack +TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true +if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then + echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2 + echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2 + exit 1 +fi + +# Output results +if $JSON_MODE; then + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + --arg tasks_template "${TASKS_TEMPLATE:-}" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \ + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" + fi +else + echo "FEATURE_DIR: $FEATURE_DIR" + echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}" + echo "AVAILABLE_DOCS:" + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" +fi diff --git a/scripts/powershell/setup-tasks.ps1 b/scripts/powershell/setup-tasks.ps1 new file mode 100644 index 0000000000..e00ae7a02f --- /dev/null +++ b/scripts/powershell/setup-tasks.ps1 @@ -0,0 +1,74 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [switch]$Json, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]" + exit 0 +} + +# Source common functions +. "$PSScriptRoot/common.ps1" + +# Get feature paths and validate branch +$paths = Get-FeaturePathsEnv + +# If feature.json pins an existing feature directory, branch naming is not required. +if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) { + if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) { + exit 1 + } +} + +if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { + [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)") + [Console]::Error.WriteLine("Run /speckit.plan first to create the implementation plan.") + exit 1 +} + +if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) { + [Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)") + [Console]::Error.WriteLine("Run /speckit.specify first to create the feature structure.") + exit 1 +} + +# 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)) { + $docs += 'contracts/' +} +if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' } + +# Resolve tasks template through override stack +$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT +if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) { + $expectedCoreTemplate = Join-Path $paths.REPO_ROOT '.specify/templates/tasks-template.md' + [Console]::Error.WriteLine("ERROR: Tasks template not found for repository root: $($paths.REPO_ROOT)`nTemplate resolution order: overrides -> presets -> extensions -> core.`nExpected shared/core template location: $expectedCoreTemplate`nTo continue, verify whether 'tasks-template.md' is available in '.specify/templates/overrides/', preset templates, extension templates, or restore the shared/core templates (for example by re-running 'specify init') so that '.specify/templates/tasks-template.md' exists.") + exit 1 +} +$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path + +# Output results +if ($Json) { + [PSCustomObject]@{ + FEATURE_DIR = $paths.FEATURE_DIR + AVAILABLE_DOCS = $docs + TASKS_TEMPLATE = $tasksTemplate + } | ConvertTo-Json -Compress +} else { + Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)" + Write-Output "TASKS_TEMPLATE: $(if ($tasksTemplate) { $tasksTemplate } else { 'not found' })" + Write-Output "AVAILABLE_DOCS:" + Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null + Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null + Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null + Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null +} diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 4e204abc1b..e5af6793b6 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -10,8 +10,8 @@ handoffs: prompt: Start the implementation in phases send: true scripts: - sh: scripts/bash/check-prerequisites.sh --json - ps: scripts/powershell/check-prerequisites.ps1 -Json + sh: scripts/bash/setup-tasks.sh --json + ps: scripts/powershell/setup-tasks.ps1 -Json --- ## User Input @@ -58,7 +58,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline -1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). +1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). 2. **Load design documents**: Read from FEATURE_DIR: - **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities) @@ -76,7 +76,7 @@ You **MUST** consider the user input before proceeding (if not empty). - Create parallel execution examples per user story - Validate task completeness (each user story has all needed tasks, independently testable) -4. **Generate tasks.md**: Use `templates/tasks-template.md` as structure, fill with: +4. **Generate tasks.md**: Read the tasks template from TASKS_TEMPLATE (from the JSON output above) and use it as structure. If TASKS_TEMPLATE is empty, fall back to `.specify/templates/tasks-template.md`. Fill with: - Correct feature name from plan.md - Phase 1: Setup tasks (project initialization) - Phase 2: Foundational tasks (blocking prerequisites for all user stories) diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 82d7b8cfb3..0b74a6f1a9 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -274,11 +274,11 @@ def _expected_files(self, script_variant: str) -> list[str]: if script_variant == "sh": for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", - "setup-plan.sh"]: + "setup-plan.sh", "setup-tasks.sh"]: files.append(f".specify/scripts/bash/{name}") else: for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", - "setup-plan.ps1"]: + "setup-plan.ps1", "setup-tasks.ps1"]: files.append(f".specify/scripts/powershell/{name}") for name in ["checklist-template.md", diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 98a65fcff4..89140de1c3 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -387,6 +387,7 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/setup-tasks.sh", ] else: files += [ @@ -394,6 +395,7 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", + ".specify/scripts/powershell/setup-tasks.ps1", ] # Templates files += [ diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 78273b560e..56862e534c 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -516,6 +516,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.sh", "create-new-feature.sh", "setup-plan.sh", + "setup-tasks.sh", ]: files.append(f".specify/scripts/bash/{name}") else: @@ -524,6 +525,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.ps1", "create-new-feature.ps1", "setup-plan.ps1", + "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index e1dee3bad7..956c7a796f 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -395,6 +395,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.sh", "create-new-feature.sh", "setup-plan.sh", + "setup-tasks.sh", ]: files.append(f".specify/scripts/bash/{name}") else: @@ -403,6 +404,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.ps1", "create-new-feature.ps1", "setup-plan.ps1", + "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 2df4d2d7df..c6e9259b09 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -206,6 +206,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/setup-tasks.sh", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -265,6 +266,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", + ".specify/scripts/powershell/setup-tasks.ps1", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -614,6 +616,7 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/setup-tasks.sh", # Templates ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index f0272afa8d..290a36419e 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -264,6 +264,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/setup-tasks.sh", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -319,6 +320,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", + ".specify/scripts/powershell/setup-tasks.ps1", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py new file mode 100644 index 0000000000..f2e10d8b0f --- /dev/null +++ b/tests/test_setup_tasks.py @@ -0,0 +1,584 @@ +"""Tests for setup-tasks.{sh,ps1} template resolution and branch validation.""" + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh" +COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" +SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1" +TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md" + +HAS_PWSH = shutil.which("pwsh") is not None +_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _install_bash_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "bash" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_SH, d / "common.sh") + shutil.copy(SETUP_TASKS_SH, d / "setup-tasks.sh") + + +def _install_ps_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "powershell" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_PS, d / "common.ps1") + shutil.copy(SETUP_TASKS_PS, d / "setup-tasks.ps1") + + +def _install_core_tasks_template(repo: Path) -> None: + """Copy the real tasks-template.md into the core template location.""" + tdir = repo / ".specify" / "templates" + tdir.mkdir(parents=True, exist_ok=True) + shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md") + + +def _minimal_feature(repo: Path) -> Path: + """ + Create a numbered branch-style feature directory with spec.md and plan.md + so all prerequisite checks in setup-tasks pass. + Returns the feature directory path. + """ + feat = repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + return feat + + +def _clean_env() -> dict[str, str]: + """ + Return os.environ with all SPECIFY_* variables stripped so the scripts + rely purely on git branch + feature.json state set up by each fixture. + """ + env = os.environ.copy() + for key in list(env): + if key.startswith("SPECIFY_"): + env.pop(key) + return env + + +def _git_init(repo: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=repo, check=True + ) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True + ) + + +# --------------------------------------------------------------------------- +# Shared fixture +# --------------------------------------------------------------------------- + +@pytest.fixture +def tasks_repo(tmp_path: Path) -> Path: + """ + A minimal repo with: + - git initialised on a numbered branch (001-my-feature) + - core tasks-template.md in place + - both bash and PowerShell scripts installed + """ + repo = tmp_path / "proj" + repo.mkdir() + _git_init(repo) + + # Switch to a numbered branch so branch validation passes without feature.json + subprocess.run( + ["git", "checkout", "-q", "-b", "001-my-feature"], + cwd=repo, + check=True, + ) + + (repo / ".specify").mkdir() + _install_core_tasks_template(repo) + _install_bash_scripts(repo) + _install_ps_scripts(repo) + return repo + + +# =========================================================================== +# BASH TESTS +# =========================================================================== + +@requires_bash +def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None: + """ + When the core tasks-template.md is present and all prerequisites are met, + setup-tasks.sh --json should exit 0 and return an absolute, existing + TASKS_TEMPLATE path pointing to the core template. + """ + feat = _minimal_feature(tasks_repo) + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl.name == "tasks-template.md" + + +@requires_bash +def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None: + """ + When an override exists at .specify/templates/overrides/tasks-template.md, + setup-tasks.sh --json must return the override path, not the core path. + """ + feat = _minimal_feature(tasks_repo) + + # Create the override + overrides_dir = tasks_repo / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + override_file = overrides_dir / "tasks-template.md" + override_file.write_text("# override tasks template\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + # The resolved path must be inside the overrides directory + assert "overrides" in tasks_tmpl.parts, ( + f"Expected override path but got: {tasks_tmpl}" + ) + + +@requires_bash +def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None: + """ + When an extension template exists, setup-tasks.sh --json must resolve + tasks-template.md from the extension before falling back to the core path. + """ + feat = _minimal_feature(tasks_repo) + + # FIX: real extension layout is .specify/extensions//templates/.md + extension_dir = ( + tasks_repo / ".specify" / "extensions" / "test-extension" / "templates" + ) + extension_dir.mkdir(parents=True, exist_ok=True) + extension_file = extension_dir / "tasks-template.md" + extension_file.write_text("# extension tasks template\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl == extension_file.resolve(), ( + f"Expected extension path but got: {tasks_tmpl}" + ) + + +@requires_bash +def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None: + """ + When both preset and extension templates exist, setup-tasks.sh --json must + resolve the preset path because presets outrank extensions. + """ + feat = _minimal_feature(tasks_repo) + + # FIX: real extension layout is .specify/extensions//templates/.md + extension_dir = ( + tasks_repo / ".specify" / "extensions" / "test-extension" / "templates" + ) + extension_dir.mkdir(parents=True, exist_ok=True) + extension_file = extension_dir / "tasks-template.md" + extension_file.write_text("# extension tasks template\n", encoding="utf-8") + + # FIX: real preset layout is .specify/presets//templates/.md + preset_dir = tasks_repo / ".specify" / "presets" / "test-preset" / "templates" + preset_dir.mkdir(parents=True, exist_ok=True) + preset_file = preset_dir / "tasks-template.md" + preset_file.write_text("# preset tasks template\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl == preset_file.resolve(), ( + f"Expected preset path but got: {tasks_tmpl}" + ) + + +@requires_bash +def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None: + """ + When two presets both provide tasks-template.md, the one listed first in + .specify/presets/.registry wins. + """ + feat = _minimal_feature(tasks_repo) + + # resolve_template reads .specify/presets/.registry as a JSON object with a + # "presets" map where each entry has a numeric "priority" (lower = higher + # precedence). Create two presets; priority-1-preset wins over priority-2-preset. + high_priority_dir = ( + tasks_repo / ".specify" / "presets" / "priority-1-preset" / "templates" + ) + high_priority_dir.mkdir(parents=True, exist_ok=True) + high_priority_file = high_priority_dir / "tasks-template.md" + high_priority_file.write_text("# high priority preset tasks template\n", encoding="utf-8") + low_priority_dir = ( + tasks_repo / ".specify" / "presets" / "priority-2-preset" / "templates" + ) + + low_priority_dir.mkdir(parents=True, exist_ok=True) + low_priority_file = low_priority_dir / "tasks-template.md" + low_priority_file.write_text("# low priority preset tasks template\n", encoding="utf-8") + + # Write .registry JSON using the correct schema: object with "presets" map, + # each preset has a numeric "priority" (lower number = higher precedence). + registry_json = tasks_repo / ".specify" / "presets" / ".registry" + registry_json.write_text( + json.dumps({ + "presets": { + "priority-1-preset": {"priority": 1, "enabled": True}, + "priority-2-preset": {"priority": 2, "enabled": True}, + } + }), + encoding="utf-8", + ) + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl == high_priority_file.resolve(), ( + f"Expected high-priority preset path but got: {tasks_tmpl}" + ) + + +@requires_bash +def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None: + """ + When tasks-template.md is absent from all locations, setup-tasks.sh must + exit non-zero and print a helpful ERROR message to stderr. + """ + feat = _minimal_feature(tasks_repo) + + # Remove the core template so no template exists anywhere + core = tasks_repo / ".specify" / "templates" / "tasks-template.md" + core.unlink() + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode != 0 + assert "ERROR" in result.stderr + assert "tasks-template" in result.stderr + + +@requires_bash +def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid( + tasks_repo: Path, +) -> None: + """ + On a non-standard branch, setup-tasks.sh must succeed when feature.json + pins a valid FEATURE_DIR (branch validation should be skipped). + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/custom-branch"], + cwd=tasks_repo, + check=True, + ) + + feat = tasks_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + + (tasks_repo / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": "specs/001-my-feature"}), + encoding="utf-8", + ) + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + +@requires_bash +def test_setup_tasks_bash_fails_custom_branch_without_feature_json( + tasks_repo: Path, +) -> None: + """ + On a non-standard branch with no feature.json, setup-tasks.sh must fail + and report that we are not on a feature branch. + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/custom-branch"], + cwd=tasks_repo, + check=True, + ) + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr + + +# =========================================================================== +# POWERSHELL TESTS +# =========================================================================== + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None: + """ + When the core tasks-template.md is present and all prerequisites are met, + setup-tasks.ps1 -Json should exit 0 and return an absolute, existing + TASKS_TEMPLATE path. + """ + feat = _minimal_feature(tasks_repo) + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _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, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl.name == "tasks-template.md" + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None: + """ + When an override exists at .specify/templates/overrides/tasks-template.md, + setup-tasks.ps1 -Json must return the override path, not the core path. + """ + feat = _minimal_feature(tasks_repo) + + overrides_dir = tasks_repo / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + override_file = overrides_dir / "tasks-template.md" + override_file.write_text("# override tasks template\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _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, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert "overrides" in tasks_tmpl.parts, ( + f"Expected override path but got: {tasks_tmpl}" + ) + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None: + """ + When tasks-template.md is absent from all locations, setup-tasks.ps1 must + exit non-zero and write a helpful error to stderr. + """ + feat = _minimal_feature(tasks_repo) + + core = tasks_repo / ".specify" / "templates" / "tasks-template.md" + core.unlink() + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _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 "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower() + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid( + tasks_repo: Path, +) -> None: + """ + On a non-standard branch, setup-tasks.ps1 must succeed when feature.json + pins a valid FEATURE_DIR (branch validation should be skipped). + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/custom-branch"], + cwd=tasks_repo, + check=True, + ) + + feat = tasks_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + + (tasks_repo / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": "specs/001-my-feature"}), + encoding="utf-8", + ) + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _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, result.stderr + result.stdout + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_fails_custom_branch_without_feature_json( + tasks_repo: Path, +) -> None: + """ + On a non-standard branch with no feature.json, setup-tasks.ps1 must fail + and report that we are not on a feature branch. + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/custom-branch"], + cwd=tasks_repo, + check=True, + ) + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _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 "Not on a feature branch" in result.stderr + \ No newline at end of file