mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
* feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver The shell resolver honors SPECIFY_INIT_DIR (#2892), but the Python CLI did not: it resolved the project as Path.cwd() + a .specify/ check and never read the override. So setup-plan.sh respected it while `specify integration install` ignored it, and you still had to cd into the member project. Route project resolution through a shared _resolve_init_dir_override() that applies the shell resolver's validation rules (relative to cwd, must exist and contain .specify/, hard error, no fallback, same error strings). It's wired into _require_specify_project() — the chokepoint for every project-scoped subcommand (integration/extension/workflow/preset/...) — and the `workflow run <file>` standalone path, which re-applies its symlinked-.specify guard on the override branch too. init is unchanged: it creates .specify/, so the must-pre-exist rule doesn't apply. The resolver canonicalizes symlinks via Path.resolve() while the shell keeps the logical path; they agree for non-symlinked paths (documented in the resolver). Tests in tests/test_init_dir_cli.py mirror the strict cases from test_init_dir.py through the CLI; conftest now strips SPECIFY_* for the whole suite so a stray export can't perturb the now-env-reading resolver. Docs note the CLI applies the same rules. Discussion: github/spec-kit#2834 (Disclosure: I used an AI coding agent to audit the call sites and resolver, draft the change, and run an adversarial code review; reviewed by me.) * fix(cli): honor SPECIFY_INIT_DIR for bundle commands Assisted-by: Codex (model: GPT-5, autonomous) * fix(bundler): refuse symlinked .specify on the SPECIFY_INIT_DIR override path find_project_root refuses a symlinked .specify (following it could read/write outside the tree, and a test pins that), but the SPECIFY_INIT_DIR override added for bundle commands returned early and skipped that guard: _resolve_init_dir_override validates .specify with is_dir(), which follows symlinks. So `specify bundle` accepted via the override a layout the cwd path rejects. Re-check the override result with the same guard, plus a regression test. (Disclosure: found via an AI code review and fixed with an AI coding agent; reviewed by me.) * fix(cli): keep SPECIFY_INIT_DIR strict for bundles Treat an explicit symlinked SPECIFY_INIT_DIR project as a hard bundle error instead of returning no project, which could initialize the current directory. Align the docs with the actual unset resolver behavior. Assisted-by: Codex (model: GPT-5, autonomous) * docs(core): note symlinked .specify handling differs across CLI surfaces A symlinked .specify is followed by integration/extension/workflow (matching the shell resolver) but refused by bundle and workflow run <file> (write confinement). Document the asymmetry so it reads as intentional. (Disclosure: AI-assisted; reviewed by me.) * docs(core): reframe symlinked .specify note around the override invariant Per maintainer feedback on #3186: SPECIFY_INIT_DIR relocates where the project is, not how a surface treats symlinks. Each surface keeps its cwd-path stance (write surfaces refuse a symlinked .specify, read/config surfaces follow it), so the split is one policy relocated, not an inconsistency. * docs: address Copilot review on resolver docstrings - _project.py: the error messages "mirror" the shell wording rather than "match" it (the CLI renders a Rich `Error:` line, the shell a plain `ERROR:`). - find_project_root: document that honoring SPECIFY_INIT_DIR when start is None can raise typer.Exit / BundlerError, so the Path | None signature isn't surprising to direct callers. * docs(bundler): note require_project_root inherits the override raise behavior find_project_root can raise typer.Exit / BundlerError under the SPECIFY_INIT_DIR override (start=None); require_project_root inherits that, so document it alongside its own BundlerError-on-missing-project. * docs: clarify symlinked project root behavior Assisted-by: OpenAI Codex (model: GPT-5, autonomous) * Address SPECIFY_INIT_DIR review feedback Assisted-by: OpenAI Codex (model: GPT-5, autonomous) * Route workflow JSON errors to stderr Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
167 lines
5.9 KiB
Python
167 lines
5.9 KiB
Python
"""Shared test helpers for the Spec Kit test suite."""
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
|
|
|
|
|
def _has_working_bash() -> bool:
|
|
"""Check whether a functional native bash is available.
|
|
|
|
On Windows, ``subprocess.run(["bash", ...])`` uses CreateProcess,
|
|
which searches System32 *before* PATH — so it may find the WSL
|
|
launcher even when Git-for-Windows bash appears first in PATH via
|
|
``shutil.which``. We therefore probe with bare ``"bash"`` (the
|
|
same way test helpers invoke it) to get an accurate result.
|
|
|
|
On Windows, only Git-for-Windows bash (MSYS2/MINGW) is accepted.
|
|
The WSL launcher is rejected because it runs in a separate Linux
|
|
filesystem and cannot handle native Windows paths used by the
|
|
test fixtures.
|
|
|
|
Set SPECKIT_TEST_BASH=1 to force-enable bash tests regardless.
|
|
"""
|
|
if os.environ.get("SPECKIT_TEST_BASH") == "1":
|
|
return True
|
|
if shutil.which("bash") is None:
|
|
return False
|
|
# Probe with bare "bash" — same as the test helpers — so that
|
|
# Windows CreateProcess resolution order is respected.
|
|
try:
|
|
r = subprocess.run(
|
|
["bash", "-c", "echo ok"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
if r.returncode != 0 or "ok" not in r.stdout:
|
|
return False
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
# On Windows, verify we have MSYS/MINGW bash (Git for Windows),
|
|
# not the WSL launcher which can't handle native paths.
|
|
if sys.platform == "win32":
|
|
try:
|
|
u = subprocess.run(
|
|
["bash", "-c", "uname -s"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
kernel = u.stdout.strip().upper()
|
|
if not any(k in kernel for k in ("MSYS", "MINGW", "CYGWIN")):
|
|
return False
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
return True
|
|
|
|
|
|
requires_bash = pytest.mark.skipif(
|
|
not _has_working_bash(), reason="working bash not available"
|
|
)
|
|
|
|
|
|
def strip_ansi(text: str) -> str:
|
|
"""Remove ANSI escape codes from Rich-formatted CLI output."""
|
|
return _ANSI_ESCAPE_RE.sub("", text)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth config isolation — prevents tests from reading ~/.specify/auth.json
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_auth_config(monkeypatch):
|
|
"""Ensure no test reads the real ~/.specify/auth.json."""
|
|
from specify_cli.authentication import http as _auth_http
|
|
monkeypatch.setattr(_auth_http, "_config_override", [])
|
|
# Also clear the per-process cache so tests that unset _config_override
|
|
# won't see a previously cached real-file result.
|
|
monkeypatch.setattr(_auth_http, "_config_cache", None)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _strip_specify_env(monkeypatch):
|
|
"""Drop any inherited SPECIFY_* vars for every test.
|
|
|
|
The Python CLI's project resolver (`_require_specify_project`) now honors
|
|
SPECIFY_INIT_DIR, and the shell resolvers honor SPECIFY_FEATURE* — so a
|
|
developer or CI runner with any SPECIFY_* var exported would silently
|
|
retarget (or hard-error) the many command/script tests that resolve a
|
|
project. Stripping them here keeps resolution tests deterministic; a test
|
|
that wants an override sets it explicitly via monkeypatch afterwards."""
|
|
for key in [k for k in os.environ if k.startswith("SPECIFY_")]:
|
|
monkeypatch.delenv(key, raising=False)
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_environ(monkeypatch):
|
|
"""Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment."""
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
|
|
|
|
def _fake_self_upgrade_argv0(monkeypatch, tmp_path, env_name, path_parts):
|
|
"""Create a fake executable under tmp_path and point sys.argv[0] at it."""
|
|
monkeypatch.setenv(env_name, str(tmp_path))
|
|
fake_dir = tmp_path.joinpath(*path_parts)
|
|
fake_dir.mkdir(parents=True)
|
|
fake_specify = fake_dir / ("specify.exe" if os.name == "nt" else "specify")
|
|
fake_specify.write_text("#!/usr/bin/env python\n")
|
|
fake_specify.chmod(0o755)
|
|
monkeypatch.setattr("sys.argv", [str(fake_specify)])
|
|
return fake_specify
|
|
|
|
|
|
@pytest.fixture
|
|
def uv_tool_argv0(monkeypatch, tmp_path):
|
|
"""Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME."""
|
|
if os.name == "nt":
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin")
|
|
)
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch,
|
|
tmp_path,
|
|
"HOME",
|
|
(".local", "share", "uv", "tools", "specify-cli", "bin"),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def pipx_argv0(monkeypatch, tmp_path):
|
|
"""Point sys.argv[0] at a simulated pipx install path under tmp HOME."""
|
|
if os.name == "nt":
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin")
|
|
)
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin")
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def uvx_ephemeral_argv0(monkeypatch, tmp_path):
|
|
"""Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME."""
|
|
if os.name == "nt":
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch,
|
|
tmp_path,
|
|
"LOCALAPPDATA",
|
|
("uv", "cache", "archive-v0", "abc123", "bin"),
|
|
)
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin")
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def unsupported_argv0(monkeypatch, tmp_path):
|
|
"""Point sys.argv[0] at a path that does not match any installer prefix."""
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch, tmp_path, "HOME", ("random", "location", "bin")
|
|
)
|