Files
spec-kit/tests/test_setup_tasks.py
T
Manfred RiemandCopilot App 16cfab7724 feat(presets): resolve constitution templates at command time (#3984)
* feat(presets): resolve constitutions at command time

Gate install-time constitution materialization behind the constitution-sync preset while preserving one-time init seeding and authored-file safeguards.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84

* fix(presets): emit composed template content

Add a machine-readable preset resolve mode backed by PresetResolver.resolve_content and require the constitution command to consume it.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84

* fix(presets): unify runtime template composition

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84

* fix(presets): secure runtime template resolution

Align runtime resolution across script variants, validate registry path components, and honor canonical extension ordering and convention paths.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): align runtime priority semantics

Normalize and tie-break preset priorities consistently across script variants, and preserve template bytes when Python materializes generated files.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): stop at effective template base

Avoid parsing irrelevant lower layers once resolution reaches a replace base, and decode raw bytes so Python preserves source line endings.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): align extension template resolution

Support root-level extension templates across runtime resolvers, fail safely when Bash cannot parse an extension registry, and validate requested templates in every prerequisite output mode.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): resolve dotted command identifiers

Route safe dotted names through command resolution, correct traversal coverage, and make Windows CI text decoding explicit.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Avoid orphan feature directories on template errors

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Align malformed preset manifest handling

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Complete runtime resolver parity

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Fail closed on resolver input errors

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Force UTF-8 and full manifest validation

Force UTF-8 decoding for registry and manifest reads in the Bash and
PowerShell embedded-Python parsers so resolution no longer depends on the
process locale, and validate every manifest template entry's required
fields, type, and strategy consistent with the canonical PresetManifest.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Fail closed on empty manifests and corrupt registries

Reject manifests missing the provides/templates sections or declaring an
empty template list in all three runtime resolvers, matching the canonical
PresetManifest which treats those as invalid instead of silently degrading a
composing layer to a convention `replace` lookup.

Make a corrupt or unreadable extension registry fail closed in Bash,
PowerShell, and Python instead of swallowing the error and treating every
on-disk extension directory as unregistered-and-enabled, which could activate
a disabled extension.

Read the preset and extension registries as explicit UTF-8 in the PowerShell
resolver so priority/enabled-state decoding no longer depends on the process
code page under Windows PowerShell 5.1.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* fix(presets): fail closed when extension registry is not a regular file

The Bash and Python resolvers used is_file()/`-f` to gate reading the
extension `.registry`, which returns false for a directory or a broken
symlink at that path. In those cases the resolvers treated the registry
as absent and scanned every on-disk extension directory as unregistered
and enabled — a fail-open path. Detect any filesystem entry at the
registry path (including broken symlinks) and reject unless it is a
readable regular file. PowerShell now rejects a non-leaf entry explicitly
for parity. Adds directory- and broken-symlink parity regressions.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* fix(presets): fail closed on corrupt registry in canonical resolver and PowerShell

Two remaining fail-open paths for an invalid extension registry:

- The canonical PresetResolver enumerated extensions through
  ExtensionRegistry, whose _load() normalizes a corrupt or unreadable
  registry to an empty mapping. The directory scan then admitted every
  on-disk extension directory as unregistered-and-enabled, so a corrupt
  registry could still supply constitution content at init and through
  constitution-sync materialization. Add a non-invasive is_corrupt()
  probe (recovery behavior for install/enable/disable is unchanged) and
  raise from _get_all_extensions_by_priority() when the registry exists
  but is invalid. _load() now also recovers from OSError/UnicodeDecodeError
  so a directory or unreadable registry no longer crashes construction.

- The PowerShell resolver gated the registry read with Test-Path, which
  returns false for a dangling symlink on Windows, letting a broken
  .registry symlink bypass the guard and enable every on-disk extension.
  Detect the entry via directory enumeration (which observes a broken
  symlink) and reject it unless it is a readable regular file.

Adds canonical corrupt/directory-registry regressions and extends the
broken-symlink parity test to PowerShell.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* fix(presets): detect dangling registry symlink in ExtensionRegistry.is_corrupt

is_corrupt() gated on Path.exists(), which follows symlinks and returns
False for a dangling .registry symlink — so the canonical PresetResolver
treated it as an absent registry and fell back to scanning every on-disk
extension directory as unregistered-and-enabled, reopening the fail-open
path this guard closes. Detect lexical existence with os.path.lexists and
require a regular file before parsing, so a broken symlink (or directory)
is reported corrupt and resolution fails closed. Adds a canonical
broken-symlink regression alongside the directory case.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
2026-08-10 10:22:31 -05:00

1010 lines
35 KiB
Python

"""Tests for setup-tasks.{sh,ps1} template resolution and feature resolution."""
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"
CHECK_PREREQ_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1"
CHECK_PREREQ_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
# ---------------------------------------------------------------------------
# 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")
shutil.copy(CHECK_PREREQ_SH, d / "check-prerequisites.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")
shutil.copy(CHECK_PREREQ_PS, d / "check-prerequisites.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 _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
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")
_write_feature_json(repo)
return feat
def _write_integration_state(repo: Path, integration: str = "claude", separator: str = "-") -> None:
specify_dir = repo / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
state = {
"integration": integration,
"default_integration": integration,
"installed_integrations": [integration],
"integration_settings": {
integration: {
"invoke_separator": separator,
},
},
}
(specify_dir / "integration.json").write_text(
json.dumps(state),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
"""
Return os.environ with all SPECIFY_* variables stripped so the scripts
rely purely on feature.json and on-disk feature directories set up by each fixture.
"""
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _run_bash_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
script = repo / ".specify" / "scripts" / "bash" / "common.sh"
return subprocess.run(
["bash", "-c", 'source "$1"; format_speckit_command "$2" "$PWD"', "bash", str(script), command_name],
cwd=repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
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
return subprocess.run(
[
exe,
"-NoProfile",
"-Command",
'& { param($common, $commandName) . $common; Format-SpecKitCommand -CommandName $commandName -RepoRoot (Get-Location).Path }',
str(script),
command_name,
],
cwd=repo,
capture_output=True,
text=True,
check=False,
env=_clean_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)
# Keep a numbered branch name in this repo fixture; setup-tasks now resolves
# feature directories from repository state rather than validating git branches.
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.
"""
_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.
"""
_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.
"""
_minimal_feature(tasks_repo)
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.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.
"""
_minimal_feature(tasks_repo)
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.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/<id>/templates/<name>.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.
"""
_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.
"""
_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_bash_command_hint_defaults_to_dot_without_integration_json(tasks_repo: Path) -> None:
integration_json = tasks_repo / ".specify" / "integration.json"
if integration_json.exists():
integration_json.unlink()
result = _run_bash_format_command(tasks_repo, "plan")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.plan"
@requires_bash
def test_bash_command_hint_rejects_invalid_invoke_separator(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "/")
result = _run_bash_format_command(tasks_repo, "plan")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.plan"
@requires_bash
def test_bash_command_hint_normalizes_mixed_separators(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "copilot", ".")
result = _run_bash_format_command(tasks_repo, "/speckit-git.commit")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.git.commit"
_write_integration_state(tasks_repo, "claude", "-")
result = _run_bash_format_command(tasks_repo, "speckit.git-commit")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit-git-commit"
@requires_bash
def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "copilot", ".")
result = _run_bash_format_command(tasks_repo, "speckit.jira.sync-status")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.jira.sync-status"
@requires_bash
def test_installed_bash_formatter_uses_dollar_prefix(tmp_path: Path) -> None:
from specify_cli import _install_shared_infra
project = tmp_path / "bash-dollar-prefix"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(
project, "sh", invoke_separator="-", invoke_prefix="$"
)
_write_integration_state(project, "codex", "-")
result = _run_bash_format_command(project, "plan")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "$speckit-plan"
@requires_bash
def test_installed_bash_formatter_uses_skill_colon_prefix(tmp_path: Path) -> None:
from specify_cli import _install_shared_infra
project = tmp_path / "bash-skill-colon-prefix"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(
project, "sh", invoke_separator="-", invoke_prefix="/skill:"
)
_write_integration_state(project, "kimi", "-")
result = _run_bash_format_command(project, "plan")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/skill:speckit-plan"
def _install_broken_json_tool_stubs(repo: Path) -> Path:
"""Create a bin dir with `jq` and `python3` stubs that exist but fail.
Mimics stock Windows + Git Bash, where a JSON tool may be missing or broken
and `python3` resolves to the Microsoft Store App Execution Alias stub: both
satisfy `command -v` yet fail at runtime (the alias exits 49). Prepending
this to PATH forces the invoke-separator parser past jq and python3 to its
awk text fallback (#3304).
"""
stub_dir = repo / "_broken_bin"
stub_dir.mkdir(exist_ok=True)
for name in ("jq", "python3"):
stub = stub_dir / name
stub.write_text(
"#!/bin/sh\n"
'echo "simulated broken interpreter/tool" >&2\n'
"exit 49\n",
encoding="utf-8",
newline="\n",
)
stub.chmod(0o755)
return stub_dir
@requires_bash
def test_bash_command_hint_falls_back_to_awk_when_jq_and_python3_broken(
tasks_repo: Path,
) -> None:
"""Separator resolution survives broken jq and python3 stubs (#3304).
`get_invoke_separator` historically selected python3 by availability and
had no text fallback, so a Windows Store python3 stub made it silently
return "." even for `-`-separator integrations (e.g. forge), yielding a
wrong hint like `/speckit.plan`. The awk fallback must recover `-`.
"""
_write_integration_state(tasks_repo, "forge", "-")
stub_dir = _install_broken_json_tool_stubs(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "common.sh"
env = _clean_env()
env["PATH"] = f"{stub_dir}{os.pathsep}{env.get('PATH', '')}"
result = subprocess.run(
[
"bash",
"-c",
'source "$1"; format_speckit_command "$2" "$PWD"',
"bash",
str(script),
"plan",
],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit-plan"
@requires_bash
def test_bash_command_hint_caches_invoke_separator_per_process(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "-")
script = tasks_repo / ".specify" / "scripts" / "bash" / "common.sh"
dot_state = {
"integration": "copilot",
"default_integration": "copilot",
"installed_integrations": ["copilot"],
"integration_settings": {"copilot": {"invoke_separator": "."}},
}
result = subprocess.run(
[
"bash",
"-c",
'source "$1"; format_speckit_command plan "$PWD"; printf "%s" "$2" > .specify/integration.json; format_speckit_command tasks "$PWD"',
"bash",
str(script),
json.dumps(dot_state),
],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
assert result.stdout.splitlines() == ["/speckit-plan", "/speckit-tasks"]
@requires_bash
def test_setup_tasks_bash_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "-")
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(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
assert "Run /speckit-plan first" in result.stderr
assert "/speckit.plan" not in result.stderr
@requires_bash
def test_check_prerequisites_bash_uses_invoke_separator_in_tasks_hint(
tasks_repo: Path,
) -> None:
_write_integration_state(tasks_repo, "claude", "-")
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--require-tasks"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Run /speckit-tasks first" in result.stderr
assert "/speckit.tasks" not 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")
_write_feature_json(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
@requires_bash
def test_setup_tasks_bash_errors_without_feature_context(
tasks_repo: Path,
) -> None:
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.sh must error."""
main_feat = tasks_repo / "specs" / "main"
main_feat.mkdir(parents=True, exist_ok=True)
(main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(main_feat / "plan.md").write_text("# plan\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
assert "Feature directory not found" in result.stderr
# ===========================================================================
# POWERSHELL TESTS
# ===========================================================================
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_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.
"""
_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(),
)
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 _WINDOWS_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.
"""
_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 _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, 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 _WINDOWS_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.
"""
_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 _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 "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower()
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_powershell_command_hint_normalizes_mixed_separators(
tasks_repo: Path,
) -> None:
_write_integration_state(tasks_repo, "copilot", ".")
result = _run_powershell_format_command(tasks_repo, "/speckit-git.commit")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.git.commit"
_write_integration_state(tasks_repo, "claude", "-")
result = _run_powershell_format_command(tasks_repo, "speckit.git-commit")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit-git-commit"
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_installed_powershell_formatter_uses_dollar_prefix(tmp_path: Path) -> None:
from specify_cli import _install_shared_infra
project = tmp_path / "powershell-dollar-prefix"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(
project, "ps", invoke_separator="-", invoke_prefix="$"
)
_write_integration_state(project, "codex", "-")
result = _run_powershell_format_command(project, "plan")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "$speckit-plan"
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_powershell_command_hint_preserves_hyphens_inside_segments(
tasks_repo: Path,
) -> None:
_write_integration_state(tasks_repo, "copilot", ".")
result = _run_powershell_format_command(tasks_repo, "speckit.jira.sync-status")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.jira.sync-status"
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "-")
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\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(),
)
output = result.stderr + result.stdout
assert result.returncode != 0
assert "Run /speckit-plan first" in output
assert "/speckit.plan" not in output
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_check_prerequisites_ps_uses_invoke_separator_in_tasks_hint(
tasks_repo: Path,
) -> None:
_write_integration_state(tasks_repo, "claude", "-")
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-RequireTasks"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
output = result.stderr + result.stdout
assert result.returncode != 0
assert "Run /speckit-tasks first" in output
assert "/speckit.tasks" not in output
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_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")
_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(),
)
assert result.returncode == 0, result.stderr + result.stdout
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_errors_without_feature_context(
tasks_repo: Path,
) -> None:
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.ps1 must error."""
main_feat = tasks_repo / "specs" / "main"
main_feat.mkdir(parents=True, exist_ok=True)
(main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
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(),
)
output = result.stderr + result.stdout
assert result.returncode != 0
assert "Feature directory not found" in output
# ---------------------------------------------------------------------------
# Directory non-emptiness parity: a dir whose only contents are subdirectories
# (e.g. contracts/v1/openapi.yaml) must count as non-empty in both shells.
# ---------------------------------------------------------------------------
def _run_bash_check_dir(repo: Path, target: Path) -> subprocess.CompletedProcess:
script = repo / ".specify" / "scripts" / "bash" / "common.sh"
return subprocess.run(
["bash", "-c", 'source "$1"; check_dir "$2" "contracts/"', "bash", str(script), str(target)],
# check_dir echoes the non-ASCII markers ✓/✗; decode UTF-8 explicitly so
# the result does not depend on the platform locale (e.g. cp1252 on Windows).
cwd=repo, capture_output=True, text=True, encoding="utf-8", check=False, env=_clean_env(),
)
def _run_powershell_test_dir(repo: Path, target: Path) -> subprocess.CompletedProcess:
script = repo / ".specify" / "scripts" / "powershell" / "common.ps1"
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)],
cwd=repo, capture_output=True, text=True, encoding="utf-8", check=False, env=_clean_env(),
)
@requires_bash
def test_check_dir_bash_counts_subdir_only_contracts(tasks_repo: Path) -> None:
"""bash check_dir treats a dir containing only subdirectories as non-empty."""
contracts = tasks_repo / "contracts" / "v1"
contracts.mkdir(parents=True)
(contracts / "openapi.yaml").write_text("openapi: 3.0\n", encoding="utf-8")
result = _run_bash_check_dir(tasks_repo, tasks_repo / "contracts")
# check_dir always exits 0 (it echoes ✓/✗ instead of setting an exit code),
# so the ✓ marker in stdout — not the return code — is what proves non-emptiness.
assert "✓" in result.stdout and "✗" not in result.stdout, result.stderr + result.stdout
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_dir_has_files_ps_counts_subdir_only_contracts(tasks_repo: Path) -> None:
"""Test-DirHasFiles must match bash: a subdir-only dir counts as non-empty."""
contracts = tasks_repo / "contracts" / "v1"
contracts.mkdir(parents=True)
(contracts / "openapi.yaml").write_text("openapi: 3.0\n", encoding="utf-8")
result = _run_powershell_test_dir(tasks_repo, tasks_repo / "contracts")
# Test-DirHasFiles returns a boolean and pwsh still exits 0 when it returns
# $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