mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
* feat(init)!: make git extension opt-in and remove --no-git at v0.10.0 - Remove --no-git parameter from specify init command - Remove git extension auto-installation from init flow - Git repository initialization (git init) still runs when git is available - Remove --no-git from all test invocations across the test suite - Update docs to reflect opt-in git extension behavior - Replace TestGitExtensionAutoInstall with TestGitExtensionOptIn tests BREAKING CHANGE: specify init no longer auto-installs the git extension. Use `specify extension add git` to install it explicitly. The --no-git flag has been removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove git operations from core scripts Git functionality is now entirely managed by the git extension. Core scripts only handle directory-based feature creation and numbering. - Remove has_git(), check_feature_branch(), git branch creation from core - Simplify number detection to use only spec directory scanning - Remove HAS_GIT output from get_feature_paths() - Remove git remote fetching and branch querying - Keep BRANCH_NAME output key for backward compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove all git operations from core - Remove is_git_repo() and init_git_repo() dead code from _utils.py - Remove --branch-numbering from init command - Remove git from 'specify check' (now extension-only) - Update docs: git is optional prerequisite, check command description - Fix tests to reflect no-git-in-core reality (fallback to main) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove directory scanning and branch fallback from core Core scripts now resolve feature context exclusively from: 1. SPECIFY_FEATURE env var (set by git extension) 2. .specify/feature.json (persisted by specify command) Removed find_feature_dir_by_prefix() and directory scanning heuristics — these are the git extension's responsibility. Scripts error clearly when no feature context is available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: introduce feature_numbering, deprecate branch_numbering in init-options - specify command template now reads feature_numbering (preferred) with fallback to branch_numbering (deprecated) from init-options.json - Git extension reads git-config.yml > feature_numbering > branch_numbering - init now writes feature_numbering: sequential to init-options.json - Deprecation warning emitted when branch_numbering is used as fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove trailing whitespace in common.ps1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(scripts): persist SPECIFY_FEATURE_DIRECTORY env var to feature.json When SPECIFY_FEATURE_DIRECTORY is set, get_feature_paths() now writes the value to .specify/feature.json so future sessions without the env var can still resolve the feature directory. The write is idempotent — it skips when the file already contains the same value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review feedback — error messages and docs - Update error messages in common.sh and common.ps1 to reference SPECIFY_FEATURE_DIRECTORY instead of SPECIFY_FEATURE (which no longer resolves feature directories) - Fix get_current_branch comment (returns empty string, not error) - Update upgrade.md to reference SPECIFY_FEATURE_DIRECTORY with correct example paths - Update local-development.md troubleshooting: replace stale 'Git step skipped' row with actionable git extension guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden feature.json persistence - Use json_escape in printf fallback when jq is unavailable (common.sh) - Replace utf8NoBOM encoding with UTF8Encoding($false) for PowerShell 5.1 compatibility (common.ps1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove dead feature_json_matches_feature_dir functions These guards are no longer needed since the branch-name validation they protected against has been removed from check-prerequisites. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(git-ext): rename create-new-feature to create-new-feature-branch The git extension's script only creates the git branch — rename it to reflect that responsibility. The core create-new-feature.sh/.ps1 handles feature directory creation and feature.json persistence. Also includes fixes from review feedback: - common.sh: _persist_feature_json uses json_escape fallback - common.ps1: Save-FeatureJson uses UTF8Encoding for PS 5.1 compat - common.ps1: case-sensitive path stripping on non-Windows - create-new-feature.sh/ps1: output both SPECIFY_FEATURE and SPECIFY_FEATURE_DIRECTORY - setup-tasks.sh: fix stale 'Validate branch' comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): update references to renamed git extension scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): remove duplicate EXT_CREATE_FEATURE assignments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
305 lines
13 KiB
Python
305 lines
13 KiB
Python
"""Tests for RovodevIntegration."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
import pytest
|
||
import yaml
|
||
from click.testing import Result
|
||
from typer.testing import CliRunner
|
||
|
||
from specify_cli import app
|
||
from specify_cli.integrations import get_integration
|
||
from specify_cli.integrations.manifest import IntegrationManifest
|
||
|
||
|
||
def _run_init(project, *flags: str) -> Result:
|
||
"""Run ``specify init --here`` in *project* with the given extra flags.
|
||
|
||
Centralises the cwd-management boilerplate so individual tests just
|
||
declare the flags they care about.
|
||
"""
|
||
old_cwd = os.getcwd()
|
||
try:
|
||
os.chdir(project)
|
||
return CliRunner().invoke(
|
||
app,
|
||
["init", "--here", *flags, "--script", "sh", "--ignore-agent-tools"],
|
||
catch_exceptions=False,
|
||
)
|
||
finally:
|
||
os.chdir(old_cwd)
|
||
|
||
|
||
@pytest.fixture
|
||
def rovodev_init_project(tmp_path):
|
||
"""Run ``specify init --integration rovodev`` once and return the project root.
|
||
|
||
Shared across the slow init-inventory tests so we pay the full-CLI cost
|
||
only once instead of three times.
|
||
"""
|
||
project = tmp_path / "rovodev-init"
|
||
project.mkdir()
|
||
result = _run_init(project, "--integration", "rovodev")
|
||
assert result.exit_code == 0, result.output
|
||
return project
|
||
|
||
|
||
class TestRovodevIntegration:
|
||
"""Rovodev-specific tests (not inherited from SkillsIntegrationTests because
|
||
rovodev's setup() emits prompt wrappers + prompts.yml in addition to skills,
|
||
which violates the base mixin's pure-skills assumptions)."""
|
||
|
||
KEY = "rovodev"
|
||
CONTEXT_FILE = "AGENTS.md"
|
||
|
||
# -- ACLI dispatch -----------------------------------------------------
|
||
|
||
def test_build_exec_args(self):
|
||
impl = get_integration(self.KEY)
|
||
args = impl.build_exec_args("/speckit.plan add OAuth")
|
||
assert args[0:3] == ["acli", "rovodev", "run"]
|
||
assert args[3] == "/speckit.plan add OAuth"
|
||
assert "--output-schema" in args
|
||
|
||
def test_build_exec_args_without_json(self):
|
||
impl = get_integration(self.KEY)
|
||
args = impl.build_exec_args("/speckit.plan add OAuth", output_json=False)
|
||
assert args == ["acli", "rovodev", "run", "/speckit.plan add OAuth"]
|
||
|
||
def test_build_exec_args_executable_env_override(self, monkeypatch):
|
||
"""SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE overrides the binary path.
|
||
|
||
Lets operators pin a specific ``acli`` build or relocate the binary
|
||
without modifying the integration. Mirrors codex/devin/claude/etc.
|
||
"""
|
||
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE", "/opt/atl/bin/acli")
|
||
impl = get_integration(self.KEY)
|
||
args = impl.build_exec_args("hello", output_json=False)
|
||
assert args == ["/opt/atl/bin/acli", "rovodev", "run", "hello"]
|
||
|
||
def test_build_exec_args_executable_env_blank_falls_back(self, monkeypatch):
|
||
"""Whitespace/empty env override is treated as unset → default ``acli``."""
|
||
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE", " ")
|
||
impl = get_integration(self.KEY)
|
||
args = impl.build_exec_args("hello", output_json=False)
|
||
assert args[0] == "acli"
|
||
|
||
def test_build_exec_args_extra_args_env_injection(self, monkeypatch):
|
||
"""SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS injects extra CLI flags.
|
||
|
||
Useful for CI or non-interactive contexts that need to pass flags
|
||
the integration doesn't expose. Mirrors the contract on every other
|
||
CLI integration (claude, codex, devin, …).
|
||
"""
|
||
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS", "--quiet --no-color")
|
||
impl = get_integration(self.KEY)
|
||
args = impl.build_exec_args("hello", output_json=False)
|
||
assert args == [
|
||
"acli", "rovodev", "run", "hello", "--quiet", "--no-color",
|
||
]
|
||
|
||
# -- Setup-level: prompt wrappers + prompts.yml ------------------------
|
||
|
||
def test_setup_creates_prompts_and_manifest(self, tmp_path):
|
||
impl = get_integration(self.KEY)
|
||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||
created = impl.setup(tmp_path, manifest)
|
||
|
||
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
|
||
assert prompts_manifest in created
|
||
assert prompts_manifest.exists()
|
||
|
||
prompts_dir = tmp_path / ".rovodev" / "prompts"
|
||
skills_dir = tmp_path / ".rovodev" / "skills"
|
||
assert prompts_dir.is_dir()
|
||
assert skills_dir.is_dir()
|
||
|
||
templates = impl.list_command_templates()
|
||
prompt_files = sorted(prompts_dir.glob("speckit-*.prompt.md"))
|
||
skill_dirs = sorted(d for d in skills_dir.iterdir() if d.is_dir() and d.name.startswith("speckit-"))
|
||
assert len(prompt_files) == len(templates)
|
||
assert len(skill_dirs) == len(templates)
|
||
for skill_dir in skill_dirs:
|
||
assert (skill_dir / "SKILL.md").exists()
|
||
|
||
def test_prompts_manifest_entries_well_formed(self, tmp_path):
|
||
impl = get_integration(self.KEY)
|
||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||
impl.setup(tmp_path, manifest)
|
||
|
||
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
|
||
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
|
||
assert list(data) == ["prompts"]
|
||
entries = data["prompts"]
|
||
assert entries
|
||
for entry in entries:
|
||
assert entry["name"].startswith("speckit-")
|
||
assert entry["description"]
|
||
content_file = tmp_path / ".rovodev" / entry["content_file"]
|
||
assert content_file.exists(), f"Missing prompt file {content_file}"
|
||
|
||
def test_prompt_wrapper_format(self, tmp_path):
|
||
"""Every prompt wrapper delegates to its paired skill via 'use skill ...'."""
|
||
impl = get_integration(self.KEY)
|
||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||
impl.setup(tmp_path, manifest)
|
||
|
||
prompts_dir = tmp_path / ".rovodev" / "prompts"
|
||
prompt_files = sorted(prompts_dir.glob("speckit-*.prompt.md"))
|
||
assert prompt_files
|
||
for prompt_file in prompt_files:
|
||
skill_name = prompt_file.name.removesuffix(".prompt.md")
|
||
content = prompt_file.read_text(encoding="utf-8")
|
||
assert content == f"use skill {skill_name} $ARGUMENTS\n", (
|
||
f"{prompt_file} has unexpected wrapper format"
|
||
)
|
||
|
||
def test_prompts_manifest_merge_preserves_user_entries(self, tmp_path):
|
||
impl = get_integration(self.KEY)
|
||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||
|
||
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
|
||
prompts_manifest.parent.mkdir(parents=True, exist_ok=True)
|
||
user_entry = {
|
||
"name": "my-custom-prompt",
|
||
"description": "User-added prompt",
|
||
"content_file": "prompts/my-custom-prompt.md",
|
||
}
|
||
prompts_manifest.write_text(
|
||
yaml.safe_dump({"prompts": [user_entry]}, sort_keys=False),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
impl.setup(tmp_path, manifest)
|
||
|
||
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
|
||
names = {entry.get("name") for entry in data.get("prompts", [])}
|
||
assert "my-custom-prompt" in names
|
||
assert "speckit-plan" in names
|
||
|
||
def test_modified_prompts_yml_survives_uninstall(self, tmp_path):
|
||
impl = get_integration(self.KEY)
|
||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||
impl.install(tmp_path, manifest)
|
||
manifest.save()
|
||
modified = tmp_path / ".rovodev" / "prompts.yml"
|
||
modified.write_text("user modified this", encoding="utf-8")
|
||
_, skipped = impl.uninstall(tmp_path, manifest)
|
||
assert modified.exists()
|
||
assert modified in skipped
|
||
|
||
# -- Full-CLI init: skills + prompts integration with extensions -------
|
||
|
||
def test_init_inventory(self, rovodev_init_project):
|
||
"""Rovodev + extensions produce the expected skill / prompt set.
|
||
|
||
Contract:
|
||
- Rovodev.setup() emits one SKILL.md + one .prompt.md per core template.
|
||
- Extensions install additional SKILL.md directories with NO prompt wrapper.
|
||
"""
|
||
project = rovodev_init_project
|
||
impl = get_integration(self.KEY)
|
||
core_skill_names = {
|
||
f"speckit-{t.stem.replace('.', '-')}"
|
||
for t in impl.list_command_templates()
|
||
}
|
||
|
||
prompt_files = sorted((project / ".rovodev" / "prompts").glob("speckit-*.prompt.md"))
|
||
prompt_stems = {p.name.removesuffix(".prompt.md") for p in prompt_files}
|
||
|
||
skills_dir = project / ".rovodev" / "skills"
|
||
skill_names = {
|
||
d.name for d in skills_dir.iterdir()
|
||
if d.is_dir() and d.name.startswith("speckit-")
|
||
}
|
||
|
||
# Prompts: exactly the core template set.
|
||
assert prompt_stems == core_skill_names
|
||
|
||
# Skills: core ∪ extension-installed.
|
||
assert core_skill_names.issubset(skill_names)
|
||
extension_skills = skill_names - core_skill_names
|
||
assert extension_skills, (
|
||
"Expected at least one extension-installed skill (e.g. agent-context)"
|
||
)
|
||
|
||
# prompts.yml mirrors the prompt files exactly.
|
||
prompts_manifest = project / ".rovodev" / "prompts.yml"
|
||
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
|
||
assert {e["name"] for e in data["prompts"]} == core_skill_names
|
||
|
||
def test_init_skill_files_well_formed(self, rovodev_init_project):
|
||
"""Every speckit-* SKILL.md from full init has valid frontmatter +
|
||
processed body, including extension-installed skills."""
|
||
project = rovodev_init_project
|
||
skills_dir = project / ".rovodev" / "skills"
|
||
skill_dirs = sorted(
|
||
d for d in skills_dir.iterdir()
|
||
if d.is_dir() and d.name.startswith("speckit-")
|
||
)
|
||
assert skill_dirs
|
||
|
||
for skill_dir in skill_dirs:
|
||
skill_file = skill_dir / "SKILL.md"
|
||
assert skill_file.exists(), f"Missing {skill_file}"
|
||
content = skill_file.read_text(encoding="utf-8")
|
||
|
||
# Frontmatter delimited by leading '---\n' ... '\n---\n'
|
||
assert content.startswith("---\n"), f"{skill_file} missing frontmatter"
|
||
fm_end = content.find("\n---\n", 4)
|
||
assert fm_end != -1, f"{skill_file} has unterminated frontmatter"
|
||
fm = yaml.safe_load(content[4:fm_end])
|
||
body = content[fm_end + len("\n---\n"):]
|
||
|
||
assert fm.get("name") == skill_dir.name
|
||
assert fm.get("description")
|
||
assert body.strip(), f"{skill_file} has empty body"
|
||
|
||
for placeholder in ("{SCRIPT}", "__AGENT__", "__CONTEXT_FILE__", "__SPECKIT_COMMAND_"):
|
||
assert placeholder not in body, (
|
||
f"{skill_file} body contains unprocessed placeholder {placeholder!r}"
|
||
)
|
||
# Skills agents must use hyphen-style refs in body.
|
||
assert "/speckit." not in body, (
|
||
f"{skill_file} body contains dot-notation /speckit. reference"
|
||
)
|
||
|
||
# The plan skill must reference the agent's context file.
|
||
plan_content = (skills_dir / "speckit-plan" / "SKILL.md").read_text(encoding="utf-8")
|
||
assert self.CONTEXT_FILE in plan_content
|
||
|
||
# -- Full-CLI init: integration metadata -------------------------------
|
||
|
||
def test_init_writes_integration_manifest_and_options(self, rovodev_init_project):
|
||
"""Full init must produce an integration manifest and well-formed
|
||
init-options.json — used by extensions, presets, and uninstall."""
|
||
import json
|
||
|
||
project = rovodev_init_project
|
||
|
||
manifest_path = project / ".specify" / "integrations" / "rovodev.manifest.json"
|
||
speckit_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||
assert manifest_path.exists(), "rovodev integration manifest missing"
|
||
assert speckit_manifest.exists(), "speckit shared manifest missing"
|
||
|
||
init_options = json.loads(
|
||
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
|
||
)
|
||
assert init_options["integration"] == self.KEY
|
||
assert init_options["ai"] == self.KEY
|
||
# Rovodev is a SkillsIntegration, so ai_skills is auto-set.
|
||
assert init_options.get("ai_skills") is True
|
||
assert init_options.get("script") == "sh"
|
||
|
||
def test_integration_flag_creates_expected_files(self, tmp_path):
|
||
"""``--integration rovodev`` should create all expected rovodev files."""
|
||
project = tmp_path / "rovodev-int"
|
||
project.mkdir()
|
||
result = _run_init(project, "--integration", "rovodev")
|
||
assert result.exit_code == 0, result.output
|
||
assert (project / ".rovodev" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||
assert (project / ".rovodev" / "prompts.yml").exists()
|
||
assert (project / ".specify" / "integrations" / "rovodev.manifest.json").exists()
|