mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +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)
295 lines
12 KiB
Python
295 lines
12 KiB
Python
"""Tests for the SPECIFY_INIT_DIR override in the Python CLI (`specify`).
|
|
|
|
PR #2892 taught the shell resolver (`get_repo_root` / `Get-RepoRoot`) to honor
|
|
SPECIFY_INIT_DIR, so the core slash-command scripts can target a member project
|
|
from a monorepo root. This extends the same validation rules to the Python CLI's
|
|
project resolution — `_require_specify_project()` (the chokepoint for every
|
|
project-scoped subcommand) and the `workflow run <file>` standalone-YAML path —
|
|
so those can target a member project without `cd` too.
|
|
|
|
The contract mirrors `tests/test_init_dir.py` (the shell side): the value names
|
|
the project root (the directory *containing* `.specify/`), relative paths
|
|
resolve against cwd, and an invalid value hard-errors with no silent fallback to
|
|
cwd. See proposals/monorepo-support and github/spec-kit discussion #2834.
|
|
|
|
SPECIFY_* vars are stripped from the environment for every test by the autouse
|
|
`_strip_specify_env` fixture in conftest.py; tests that want an override set it
|
|
explicitly via monkeypatch.
|
|
"""
|
|
|
|
import pytest
|
|
import yaml
|
|
from typer.testing import CliRunner
|
|
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
def _make_project(root, name):
|
|
"""Create <root>/<name>/.specify (the minimal Spec Kit project marker)."""
|
|
proj = root / name
|
|
(proj / ".specify").mkdir(parents=True)
|
|
return proj
|
|
|
|
|
|
def _workflow_yaml(wf_id):
|
|
"""A minimal valid standalone workflow YAML with a single no-op shell step."""
|
|
return yaml.dump(
|
|
{
|
|
"schema_version": "1.0",
|
|
"workflow": {
|
|
"id": wf_id,
|
|
"name": wf_id,
|
|
"version": "1.0.0",
|
|
"description": f"standalone workflow {wf_id}",
|
|
},
|
|
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
|
}
|
|
)
|
|
|
|
|
|
# ── chokepoint: _require_specify_project() via `workflow list` ───────────────
|
|
# `workflow list` is the lightest subcommand routed through the chokepoint: it
|
|
# resolves the project, then reads <project>/.specify/workflows/. An empty
|
|
# project prints "No workflows installed"; a failed resolution prints the error
|
|
# and exits non-zero.
|
|
|
|
|
|
def test_override_redirects_to_sibling_from_nonproject_cwd(tmp_path, monkeypatch):
|
|
"""A valid SPECIFY_INIT_DIR resolves the target even when cwd is not itself a
|
|
project — without the override this would error 'Not a Spec Kit project'."""
|
|
elsewhere = tmp_path / "elsewhere"
|
|
elsewhere.mkdir()
|
|
web = _make_project(tmp_path, "web")
|
|
monkeypatch.chdir(elsewhere)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
|
|
|
result = runner.invoke(app, ["workflow", "list"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "No workflows installed" in result.output
|
|
|
|
|
|
def test_override_relative_path_normalized_against_cwd(tmp_path, monkeypatch):
|
|
web = _make_project(tmp_path, "web")
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", "web")
|
|
|
|
result = runner.invoke(app, ["workflow", "list"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "No workflows installed" in result.output
|
|
assert web.exists()
|
|
|
|
|
|
def test_override_trailing_slash_tolerated(tmp_path, monkeypatch):
|
|
_make_project(tmp_path, "web")
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", "web/")
|
|
|
|
result = runner.invoke(app, ["workflow", "list"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "No workflows installed" in result.output
|
|
|
|
|
|
def test_override_redirects_bundle_commands(tmp_path, monkeypatch):
|
|
web = _make_project(tmp_path, "web")
|
|
elsewhere = tmp_path / "elsewhere"
|
|
elsewhere.mkdir()
|
|
monkeypatch.chdir(elsewhere)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
|
|
|
result = runner.invoke(app, ["bundle", "list"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "No bundles installed" in result.output
|
|
|
|
|
|
def test_unset_override_uses_cwd(tmp_path, monkeypatch):
|
|
"""With SPECIFY_INIT_DIR unset, the project is the current directory."""
|
|
cwd_proj = _make_project(tmp_path, "cwd")
|
|
monkeypatch.chdir(cwd_proj)
|
|
|
|
result = runner.invoke(app, ["workflow", "list"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "No workflows installed" in result.output
|
|
|
|
|
|
def test_empty_override_treated_as_unset(tmp_path, monkeypatch):
|
|
"""An empty SPECIFY_INIT_DIR behaves as unset (falls through to cwd), not as
|
|
'.' — which from a deep non-project cwd would otherwise diverge."""
|
|
cwd_proj = _make_project(tmp_path, "cwd")
|
|
monkeypatch.chdir(cwd_proj)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", "")
|
|
|
|
result = runner.invoke(app, ["workflow", "list"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "No workflows installed" in result.output
|
|
|
|
|
|
def test_override_nonexistent_errors_no_fallback(tmp_path, monkeypatch):
|
|
"""A non-existent path hard-errors even from inside a valid project, proving
|
|
there is no silent fallback to the cwd project."""
|
|
cwd_proj = _make_project(tmp_path, "cwd")
|
|
monkeypatch.chdir(cwd_proj)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
|
|
|
|
result = runner.invoke(app, ["workflow", "list"])
|
|
assert result.exit_code != 0
|
|
assert "does not point to an existing directory" in result.output
|
|
assert "No workflows installed" not in result.output # no fallback to cwd
|
|
|
|
|
|
def test_override_nonexistent_errors_bundle_commands_no_fallback(tmp_path, monkeypatch):
|
|
"""Bundle commands also honor the strict override contract."""
|
|
cwd_proj = _make_project(tmp_path, "cwd")
|
|
monkeypatch.chdir(cwd_proj)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
|
|
|
|
result = runner.invoke(app, ["bundle", "list"])
|
|
assert result.exit_code != 0
|
|
assert "does not point to an existing directory" in result.output
|
|
assert "No bundles installed" not in result.output
|
|
|
|
|
|
def test_override_nonexistent_bundle_json_error_stays_off_stdout(tmp_path, monkeypatch):
|
|
"""Invalid override errors must not contaminate JSON stdout."""
|
|
cwd_proj = _make_project(tmp_path, "cwd")
|
|
monkeypatch.chdir(cwd_proj)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
|
|
|
|
result = runner.invoke(app, ["bundle", "list", "--json"])
|
|
assert result.exit_code != 0
|
|
assert result.stdout == ""
|
|
assert "does not point to an existing directory" in result.stderr
|
|
|
|
|
|
def test_override_symlinked_specify_errors_bundle_init_no_fallback(tmp_path, monkeypatch):
|
|
"""A symlinked override .specify must not make bundle init fall back to cwd."""
|
|
web = tmp_path / "web"
|
|
web.mkdir()
|
|
real = tmp_path / "real-specify"
|
|
real.mkdir()
|
|
try:
|
|
(web / ".specify").symlink_to(real, target_is_directory=True)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("Symlinks are not available in this environment")
|
|
|
|
elsewhere = tmp_path / "elsewhere"
|
|
elsewhere.mkdir()
|
|
monkeypatch.chdir(elsewhere)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
|
|
|
result = runner.invoke(app, ["bundle", "init", "--offline"])
|
|
assert result.exit_code != 0
|
|
assert "symlinked .specify" in result.output
|
|
assert not (elsewhere / ".specify").exists()
|
|
|
|
|
|
def test_override_without_specify_errors_no_fallback(tmp_path, monkeypatch):
|
|
"""A path that exists but lacks .specify/ hard-errors, no fallback."""
|
|
cwd_proj = _make_project(tmp_path, "cwd")
|
|
nodot = tmp_path / "nodot"
|
|
nodot.mkdir()
|
|
monkeypatch.chdir(cwd_proj)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(nodot))
|
|
|
|
result = runner.invoke(app, ["workflow", "list"])
|
|
assert result.exit_code != 0
|
|
assert "not a Spec Kit project" in result.output
|
|
assert "No workflows installed" not in result.output
|
|
|
|
|
|
def test_override_file_path_errors_no_fallback(tmp_path, monkeypatch):
|
|
"""A path that is a file (not a directory) hard-errors with the
|
|
existing-directory message."""
|
|
cwd_proj = _make_project(tmp_path, "cwd")
|
|
a_file = tmp_path / "afile"
|
|
a_file.write_text("x")
|
|
monkeypatch.chdir(cwd_proj)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(a_file))
|
|
|
|
result = runner.invoke(app, ["workflow", "list"])
|
|
assert result.exit_code != 0
|
|
assert "does not point to an existing directory" in result.output
|
|
|
|
|
|
# ── bypass: `workflow run <file>` ────────────────────────────────────────────
|
|
|
|
|
|
def test_override_redirects_workflow_run_file(tmp_path, monkeypatch):
|
|
"""Running a standalone YAML with SPECIFY_INIT_DIR set uses the target as the
|
|
project root: run artifacts land under the target, not cwd."""
|
|
web = _make_project(tmp_path, "web")
|
|
elsewhere = tmp_path / "elsewhere"
|
|
elsewhere.mkdir()
|
|
workflow_file = elsewhere / "wf.yml"
|
|
workflow_file.write_text(_workflow_yaml("override-run"), encoding="utf-8")
|
|
monkeypatch.chdir(elsewhere)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
|
|
|
result = runner.invoke(app, ["workflow", "run", str(workflow_file)], catch_exceptions=False)
|
|
assert result.exit_code == 0, result.output
|
|
assert (web / ".specify" / "workflows" / "runs").is_dir()
|
|
assert not (elsewhere / ".specify").exists() # cwd was not used as the project
|
|
|
|
|
|
def test_override_invalid_errors_workflow_run_file(tmp_path, monkeypatch):
|
|
"""An invalid SPECIFY_INIT_DIR hard-errors the file path too — no fallback to
|
|
cwd's standalone-YAML behavior."""
|
|
elsewhere = tmp_path / "elsewhere"
|
|
elsewhere.mkdir()
|
|
workflow_file = elsewhere / "wf.yml"
|
|
workflow_file.write_text(_workflow_yaml("x"), encoding="utf-8")
|
|
monkeypatch.chdir(elsewhere)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
|
|
|
|
result = runner.invoke(app, ["workflow", "run", str(workflow_file)])
|
|
assert result.exit_code != 0
|
|
assert "does not point to an existing directory" in result.output
|
|
|
|
|
|
def test_override_rejects_symlinked_specify(tmp_path, monkeypatch):
|
|
"""`workflow run <file>` refuses a symlinked .specify under the override
|
|
target, matching the guard the cwd path applies (the override resolver's
|
|
is_dir() check follows symlinks, so this is re-checked on the override path)."""
|
|
web = tmp_path / "web"
|
|
web.mkdir()
|
|
real = tmp_path / "real-specify"
|
|
real.mkdir()
|
|
try:
|
|
(web / ".specify").symlink_to(real, target_is_directory=True)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("Symlinks are not available in this environment")
|
|
elsewhere = tmp_path / "elsewhere"
|
|
elsewhere.mkdir()
|
|
workflow_file = elsewhere / "wf.yml"
|
|
workflow_file.write_text(_workflow_yaml("symlink-run"), encoding="utf-8")
|
|
monkeypatch.chdir(elsewhere)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
|
|
|
result = runner.invoke(app, ["workflow", "run", str(workflow_file)])
|
|
assert result.exit_code != 0
|
|
assert "Refusing to use symlinked .specify path" in result.output
|
|
|
|
|
|
def test_override_rejects_symlinked_specify_json_error_stays_off_stdout(tmp_path, monkeypatch):
|
|
"""`workflow run --json <file>` must keep this hard error off stdout."""
|
|
web = tmp_path / "web"
|
|
web.mkdir()
|
|
real = tmp_path / "real-specify"
|
|
real.mkdir()
|
|
try:
|
|
(web / ".specify").symlink_to(real, target_is_directory=True)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("Symlinks are not available in this environment")
|
|
elsewhere = tmp_path / "elsewhere"
|
|
elsewhere.mkdir()
|
|
workflow_file = elsewhere / "wf.yml"
|
|
workflow_file.write_text(_workflow_yaml("symlink-json-run"), encoding="utf-8")
|
|
monkeypatch.chdir(elsewhere)
|
|
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
|
|
|
result = runner.invoke(app, ["workflow", "run", str(workflow_file), "--json"])
|
|
assert result.exit_code != 0
|
|
assert result.stdout == ""
|
|
assert "Refusing to use symlinked .specify path" in result.stderr
|