mirror of
https://github.com/github/spec-kit.git
synced 2026-07-09 16:35:58 +08:00
Compare commits
9 Commits
v0.12.2
...
benbtg-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa09272873 | ||
|
|
f954e30a66 | ||
|
|
ccf8dc246a | ||
|
|
810d6fcfe1 | ||
|
|
36501d459f | ||
|
|
c5ac90b245 | ||
|
|
3571ba72d8 | ||
|
|
6fb7e77b3e | ||
|
|
5e72b1d486 |
@@ -48,8 +48,6 @@
|
||||
"openai.chatgpt",
|
||||
// Kilo Code
|
||||
"kilocode.Kilo-Code",
|
||||
// Roo Code
|
||||
"RooVeterinaryInc.roo-cline",
|
||||
// Claude Code
|
||||
"anthropic.claude-code"
|
||||
],
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
@@ -8,7 +8,7 @@ body:
|
||||
value: |
|
||||
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
|
||||
|
||||
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -89,7 +89,6 @@ body:
|
||||
- Pi Coding Agent
|
||||
- Qoder CLI
|
||||
- Qwen Code
|
||||
- Roo Code
|
||||
- RovoDev ACLI
|
||||
- SHAI
|
||||
- Tabnine CLI
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -83,7 +83,6 @@ body:
|
||||
- Pi Coding Agent
|
||||
- Qoder CLI
|
||||
- Qwen Code
|
||||
- Roo Code
|
||||
- RovoDev ACLI
|
||||
- SHAI
|
||||
- Tabnine CLI
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.2] - 2026-06-30
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(scripts): portable uppercase for branch-name acronym retention (bash 3.2) (#3192)
|
||||
- chore: retire Windsurf integration — absorbed into Cognition Devin (#3168) (#3213)
|
||||
- [extension] Update Intake extension to v0.1.3 (#3254)
|
||||
- feat(workflows): honor max_concurrency in fan-out via a bounded thread pool (#3224)
|
||||
- Update Architecture Workflow extension to v1.2.2 (#3255)
|
||||
- Add Repository Governance extension to community catalog (#3252)
|
||||
- Update Workflow Preset to v1.3.11 (#3251)
|
||||
- chore: retire iflow integration — product discontinued (#3166) (#3211)
|
||||
- docs(codebuddy): fix dead install links and CodeBuddy capitalization (#3172) (#3216)
|
||||
- fix: reject host-less catalog URLs in base and preset validators (#3209) (#3227)
|
||||
- chore: release 0.12.1, begin 0.12.2.dev0 development (#3253)
|
||||
|
||||
## [0.12.1] - 2026-06-30
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -33,7 +33,6 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
|
||||
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
|
||||
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | |
|
||||
| [Roo Code](https://roocode.com/) | `roo` | |
|
||||
| [RovoDev](https://www.atlassian.com/software/rovo-dev) | `rovodev` | Generates `.rovodev/skills/`, prompt wrappers, and `prompts.yml`; runtime dispatch uses `acli rovodev` |
|
||||
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | |
|
||||
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | |
|
||||
@@ -267,7 +266,6 @@ The currently declared multi-install safe integrations are:
|
||||
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
|
||||
| `qodercli` | `.qoder/commands`, `QODER.md` |
|
||||
| `qwen` | `.qwen/commands`, `QWEN.md` |
|
||||
| `roo` | `.roo/commands`, `.roo/rules/specify-rules.md` |
|
||||
| `shai` | `.shai/commands`, `SHAI.md` |
|
||||
| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
|
||||
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
|
||||
|
||||
@@ -185,7 +185,7 @@ cp -r .specify/scripts .specify/scripts-backup
|
||||
|
||||
### 3. Duplicate slash commands (IDE-based agents)
|
||||
|
||||
Some IDE-based agents (like Kilo Code, Roo Code) may show **duplicate slash commands** after upgrading—both old and new versions appear.
|
||||
Some IDE-based agents (like Kilo Code, Cline) may show **duplicate slash commands** after upgrading—both old and new versions appear.
|
||||
|
||||
**Solution:** Manually delete the old command files from your agent's folder.
|
||||
|
||||
@@ -242,7 +242,7 @@ mv /tmp/constitution-backup.md .specify/memory/constitution.md
|
||||
|
||||
### Scenario 3: "I see duplicate slash commands in my IDE"
|
||||
|
||||
This happens with IDE-based agents (Kilo Code, Roo Code, Cline, etc.).
|
||||
This happens with IDE-based agents (Kilo Code, Cline, etc.).
|
||||
|
||||
```bash
|
||||
# Find the agent folder (example: .kilocode/workflows/)
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"pi": "AGENTS.md",
|
||||
"qodercli": "QODER.md",
|
||||
"qwen": "QWEN.md",
|
||||
"roo": ".roo/rules/specify-rules.md",
|
||||
"rovodev": "AGENTS.md",
|
||||
"shai": "SHAI.md",
|
||||
"tabnine": "TABNINE.md",
|
||||
|
||||
@@ -165,15 +165,6 @@
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["ide"]
|
||||
},
|
||||
"roo": {
|
||||
"id": "roo",
|
||||
"name": "Roo Code",
|
||||
"version": "1.0.0",
|
||||
"description": "Roo Code IDE integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["ide"]
|
||||
},
|
||||
"rovodev": {
|
||||
"id": "rovodev",
|
||||
"name": "RovoDev ACLI",
|
||||
|
||||
@@ -99,7 +99,7 @@ The `CommandRegistrar` renders commands differently per agent:
|
||||
|
||||
| Agent | Format | Extension | Arg placeholder |
|
||||
|-------|--------|-----------|-----------------|
|
||||
| Claude, Kilo Code, opencode, Roo Code, etc. | Markdown | `.md` | `$ARGUMENTS` |
|
||||
| Claude, Kilo Code, opencode, etc. | Markdown | `.md` | `$ARGUMENTS` |
|
||||
| Copilot | Markdown | `.agent.md` + `.prompt.md` | `$ARGUMENTS` |
|
||||
| Gemini, Qwen, Tabnine | TOML | `.toml` | `{{args}}` |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.2.dev0"
|
||||
version = "0.12.3.dev0"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -304,3 +304,27 @@ def _display_project_path(project_root: Path, path: str | Path) -> str:
|
||||
except (OSError, ValueError):
|
||||
return path_obj.as_posix()
|
||||
return rel_path.as_posix()
|
||||
|
||||
|
||||
def version_satisfies(current: str, required: str) -> bool:
|
||||
"""Check if current version satisfies required version specifier.
|
||||
|
||||
Evaluates the version against the specifier using the project's
|
||||
prerelease policy (prereleases are allowed).
|
||||
|
||||
Args:
|
||||
current: Current version (e.g., "0.1.5")
|
||||
required: Required version specifier (e.g., ">=0.1.0,<2.0.0")
|
||||
|
||||
Returns:
|
||||
True if version satisfies requirement
|
||||
"""
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
|
||||
try:
|
||||
current_ver = pkg_version.Version(current)
|
||||
specifier = SpecifierSet(required)
|
||||
return specifier.contains(current_ver, prereleases=True)
|
||||
except (pkg_version.InvalidVersion, InvalidSpecifier):
|
||||
return False
|
||||
|
||||
@@ -180,9 +180,18 @@ def remove_source(project_root: Path, id_or_url: str) -> str:
|
||||
)
|
||||
|
||||
catalogs = _read(project_root)
|
||||
remaining = [
|
||||
c for c in catalogs if c.get("id") != target and c.get("url") != target
|
||||
]
|
||||
# Prefer an exact id/url match.
|
||||
remaining = [c for c in catalogs if c.get("id") != target and c.get("url") != target]
|
||||
if len(remaining) == len(catalogs):
|
||||
# No exact match. add_source canonicalizes a local path to an absolute
|
||||
# url before storing, so fall back to a canonicalized-url match -- this
|
||||
# lets `remove ./cat.json` undo `add ./cat.json` (stored absolute).
|
||||
# Only as a *fallback*: _canonicalize_url treats a bare id as a local
|
||||
# path (empty scheme), so applying it unconditionally could also delete a
|
||||
# different source whose url equals the id's canonicalized path.
|
||||
canonical = _canonicalize_url(target)
|
||||
if canonical != target:
|
||||
remaining = [c for c in catalogs if c.get("url") != canonical]
|
||||
if len(remaining) == len(catalogs):
|
||||
raise BundlerError(
|
||||
f"No project-scoped catalog source matching '{target}' was found."
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -33,11 +32,17 @@ def _stdin_is_interactive() -> bool:
|
||||
def ensure_constitution_from_template(
|
||||
project_path: Path, tracker: StepTracker | None = None
|
||||
) -> None:
|
||||
"""Copy constitution template to memory if it doesn't exist."""
|
||||
"""Materialize the resolved constitution template to memory if missing.
|
||||
|
||||
Resolution walks the full priority stack (project overrides → installed
|
||||
presets → extensions → core) via :class:`PresetResolver`, so a preset that
|
||||
ships a ``constitution-template`` (e.g. ``strategy: replace`` with a ratified
|
||||
constitution) can seed the memory file. When nothing overrides it, the
|
||||
resolver falls through to the core template.
|
||||
"""
|
||||
from ..presets import _materialize_constitution_template
|
||||
|
||||
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
|
||||
template_constitution = (
|
||||
project_path / ".specify" / "templates" / "constitution-template.md"
|
||||
)
|
||||
|
||||
if memory_constitution.exists():
|
||||
if tracker:
|
||||
@@ -45,18 +50,21 @@ def ensure_constitution_from_template(
|
||||
tracker.skip("constitution", "existing file preserved")
|
||||
return
|
||||
|
||||
if not template_constitution.exists():
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.error("constitution", "template not found")
|
||||
return
|
||||
|
||||
try:
|
||||
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(template_constitution, memory_constitution)
|
||||
materialization = _materialize_constitution_template(
|
||||
project_path, memory_constitution
|
||||
)
|
||||
if materialization is None:
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.error("constitution", "template not found")
|
||||
return
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.complete("constitution", "copied from template")
|
||||
if materialization == "copied":
|
||||
tracker.complete("constitution", "copied from template")
|
||||
else:
|
||||
tracker.complete("constitution", "composed from template")
|
||||
else:
|
||||
console.print("[cyan]Initialized constitution from template[/cyan]")
|
||||
except Exception as e:
|
||||
@@ -447,8 +455,6 @@ def register(app: typer.Typer) -> None:
|
||||
"shared-infra", f"scripts ({selected_script}) + templates"
|
||||
)
|
||||
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
try:
|
||||
bundled_wf = _locate_bundled_workflow("speckit")
|
||||
if bundled_wf:
|
||||
@@ -576,6 +582,11 @@ def register(app: typer.Typer) -> None:
|
||||
continuing="Continuing without the optional preset.",
|
||||
)
|
||||
|
||||
# Seed the constitution AFTER preset installation so that a
|
||||
# preset-provided constitution-template (resolved via the
|
||||
# priority stack) wins over the core template.
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
tracker.complete("final", "project ready")
|
||||
except (typer.Exit, SystemExit):
|
||||
raise
|
||||
|
||||
@@ -28,7 +28,7 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
|
||||
from .._utils import dump_frontmatter, relative_extension_path_violation
|
||||
from .._utils import dump_frontmatter, relative_extension_path_violation, version_satisfies
|
||||
from ..catalogs import CatalogEntry as BaseCatalogEntry
|
||||
from ..catalogs import CatalogStackBase
|
||||
from ..shared_infra import verify_archive_sha256
|
||||
@@ -1279,20 +1279,20 @@ class ExtensionManager:
|
||||
CompatibilityError: If extension is incompatible
|
||||
"""
|
||||
required = manifest.requires_speckit_version
|
||||
current = pkg_version.Version(speckit_version)
|
||||
|
||||
# Parse version specifier (e.g., ">=0.1.0,<2.0.0")
|
||||
try:
|
||||
specifier = SpecifierSet(required)
|
||||
if current not in specifier:
|
||||
raise CompatibilityError(
|
||||
f"Extension requires spec-kit {required}, "
|
||||
f"but {speckit_version} is installed.\n"
|
||||
f"Upgrade spec-kit with: {REINSTALL_COMMAND}"
|
||||
)
|
||||
SpecifierSet(required) # Just to validate
|
||||
except InvalidSpecifier:
|
||||
raise CompatibilityError(f"Invalid version specifier: {required}")
|
||||
|
||||
if not version_satisfies(speckit_version, required):
|
||||
raise CompatibilityError(
|
||||
f"Extension requires spec-kit {required}, "
|
||||
f"but {speckit_version} is installed.\n"
|
||||
f"Upgrade spec-kit with: {REINSTALL_COMMAND}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def install_from_directory(
|
||||
@@ -1871,24 +1871,6 @@ class ExtensionManager:
|
||||
return None
|
||||
|
||||
|
||||
def version_satisfies(current: str, required: str) -> bool:
|
||||
"""Check if current version satisfies required version specifier.
|
||||
|
||||
Args:
|
||||
current: Current version (e.g., "0.1.5")
|
||||
required: Required version specifier (e.g., ">=0.1.0,<2.0.0")
|
||||
|
||||
Returns:
|
||||
True if version satisfies requirement
|
||||
"""
|
||||
try:
|
||||
current_ver = pkg_version.Version(current)
|
||||
specifier = SpecifierSet(required)
|
||||
return current_ver in specifier
|
||||
except (pkg_version.InvalidVersion, InvalidSpecifier):
|
||||
return False
|
||||
|
||||
|
||||
class CommandRegistrar:
|
||||
"""Handles registration of extension commands with AI agents.
|
||||
|
||||
|
||||
@@ -74,7 +74,6 @@ def _register_builtins() -> None:
|
||||
from .pi import PiIntegration
|
||||
from .qodercli import QodercliIntegration
|
||||
from .qwen import QwenIntegration
|
||||
from .roo import RooIntegration
|
||||
from .rovodev import RovodevIntegration
|
||||
from .shai import ShaiIntegration
|
||||
from .tabnine import TabnineIntegration
|
||||
@@ -111,7 +110,6 @@ def _register_builtins() -> None:
|
||||
_register(PiIntegration())
|
||||
_register(QodercliIntegration())
|
||||
_register(QwenIntegration())
|
||||
_register(RooIntegration())
|
||||
_register(RovodevIntegration())
|
||||
_register(ShaiIntegration())
|
||||
_register(TabnineIntegration())
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Roo Code integration."""
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
|
||||
|
||||
class RooIntegration(MarkdownIntegration):
|
||||
key = "roo"
|
||||
config = {
|
||||
"name": "Roo Code",
|
||||
"folder": ".roo/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".roo/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
@@ -30,10 +30,50 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
from ..integrations.base import IntegrationBase
|
||||
from .._utils import dump_frontmatter
|
||||
from .._utils import dump_frontmatter, version_satisfies
|
||||
from ..shared_infra import verify_archive_sha256
|
||||
|
||||
|
||||
# Tokens that mark an unmodified, generic constitution that has not yet been
|
||||
# authored. Used to decide whether seeding/re-seeding memory/constitution.md
|
||||
# from a preset-provided template is safe (i.e. won't clobber authored content).
|
||||
_CONSTITUTION_PLACEHOLDER_TOKENS = ("[PROJECT_NAME]", "[PRINCIPLE_1_NAME]")
|
||||
|
||||
|
||||
def _constitution_is_placeholder(content: str) -> bool:
|
||||
"""Return True if a constitution body is still the generic placeholder."""
|
||||
return any(token in content for token in _CONSTITUTION_PLACEHOLDER_TOKENS)
|
||||
|
||||
|
||||
def _materialize_constitution_template(
|
||||
project_root: Path,
|
||||
memory_constitution: Path,
|
||||
) -> str | None:
|
||||
"""Materialize constitution-template content into memory/constitution.md.
|
||||
|
||||
Returns:
|
||||
"copied" when the winning layer is ``replace`` and the source file is
|
||||
copied verbatim; "composed" when a composing strategy is materialized
|
||||
via ``resolve_content``; ``None`` when no constitution template resolves.
|
||||
"""
|
||||
resolver = PresetResolver(project_root)
|
||||
layers = resolver.collect_all_layers("constitution-template", "template")
|
||||
if not layers:
|
||||
return None
|
||||
|
||||
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
|
||||
top_layer = layers[0]
|
||||
if top_layer["strategy"] == "replace":
|
||||
shutil.copy2(top_layer["path"], memory_constitution)
|
||||
return "copied"
|
||||
|
||||
composed_content = resolver.resolve_content("constitution-template", "template")
|
||||
if composed_content is None:
|
||||
return None
|
||||
memory_constitution.write_text(composed_content, encoding="utf-8")
|
||||
return "composed"
|
||||
|
||||
|
||||
def _substitute_core_template(
|
||||
body: str,
|
||||
cmd_name: str,
|
||||
@@ -572,19 +612,16 @@ class PresetManager:
|
||||
PresetCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
required = manifest.requires_speckit_version
|
||||
current = pkg_version.Version(speckit_version)
|
||||
|
||||
try:
|
||||
specifier = SpecifierSet(required)
|
||||
if current not in specifier:
|
||||
raise PresetCompatibilityError(
|
||||
f"Preset requires spec-kit {required}, "
|
||||
f"but {speckit_version} is installed.\n"
|
||||
f"Upgrade spec-kit with: {REINSTALL_COMMAND}"
|
||||
)
|
||||
SpecifierSet(required) # Just to validate
|
||||
except InvalidSpecifier:
|
||||
raise PresetCompatibilityError(f"Invalid version specifier: {required}")
|
||||
|
||||
if not version_satisfies(speckit_version, required):
|
||||
raise PresetCompatibilityError(
|
||||
f"Invalid version specifier: {required}"
|
||||
f"Preset requires spec-kit {required}, "
|
||||
f"but {speckit_version} is installed.\n"
|
||||
f"Upgrade spec-kit with: {REINSTALL_COMMAND}"
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -1618,8 +1655,58 @@ class PresetManager:
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Seed/re-seed memory/constitution.md from a preset-provided
|
||||
# constitution-template. The constitution is the only template that is
|
||||
# materialized to a live file rather than resolved on demand, so a
|
||||
# preset that ships one (e.g. strategy: replace with a ratified
|
||||
# constitution) must be propagated here. Guard against clobbering an
|
||||
# already-authored constitution by only seeding when the memory file is
|
||||
# missing or still contains generic placeholder tokens.
|
||||
self._seed_constitution_from_preset(manifest)
|
||||
|
||||
return manifest
|
||||
|
||||
def _seed_constitution_from_preset(self, manifest: PresetManifest) -> None:
|
||||
"""Seed memory/constitution.md from a preset constitution-template.
|
||||
|
||||
Only runs when the preset declares a ``type: template`` entry named
|
||||
``constitution-template`` and the live memory file is either missing or
|
||||
still the generic placeholder. Authored constitutions are never
|
||||
overwritten.
|
||||
"""
|
||||
provides_constitution = any(
|
||||
t.get("type") == "template" and t.get("name") == "constitution-template"
|
||||
for t in manifest.templates
|
||||
)
|
||||
if not provides_constitution:
|
||||
return
|
||||
|
||||
memory_constitution = (
|
||||
self.project_root / ".specify" / "memory" / "constitution.md"
|
||||
)
|
||||
if memory_constitution.exists():
|
||||
try:
|
||||
existing = memory_constitution.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return
|
||||
if not _constitution_is_placeholder(existing):
|
||||
# Legitimately authored constitution; leave it untouched.
|
||||
return
|
||||
|
||||
try:
|
||||
result = _materialize_constitution_template(
|
||||
self.project_root, memory_constitution
|
||||
)
|
||||
if result is None:
|
||||
return
|
||||
except OSError as exc:
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"Failed to seed constitution from preset {manifest.id}: {exc}.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
|
||||
1700
src/specify_cli/workflows/_commands.py
Normal file
1700
src/specify_cli/workflows/_commands.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,10 @@ class DoWhileStep(StepBase):
|
||||
)
|
||||
max_iter = config.get("max_iterations")
|
||||
if max_iter is not None:
|
||||
if not isinstance(max_iter, int) or max_iter < 1:
|
||||
# bool is a subclass of int, so isinstance(True, int) is True and
|
||||
# True < 1 is False; reject bools explicitly so `max_iterations: true`
|
||||
# is a type error rather than a silent single iteration.
|
||||
if isinstance(max_iter, bool) or not isinstance(max_iter, int) or max_iter < 1:
|
||||
errors.append(
|
||||
f"Do-while step {config.get('id', '?')!r}: "
|
||||
f"'max_iterations' must be an integer >= 1."
|
||||
|
||||
@@ -55,7 +55,10 @@ class WhileStep(StepBase):
|
||||
)
|
||||
max_iter = config.get("max_iterations")
|
||||
if max_iter is not None:
|
||||
if not isinstance(max_iter, int) or max_iter < 1:
|
||||
# bool is a subclass of int, so isinstance(True, int) is True and
|
||||
# True < 1 is False; reject bools explicitly so `max_iterations: true`
|
||||
# is a type error rather than a silent single iteration.
|
||||
if isinstance(max_iter, bool) or not isinstance(max_iter, int) or max_iter < 1:
|
||||
errors.append(
|
||||
f"While step {config.get('id', '?')!r}: "
|
||||
f"'max_iterations' must be an integer >= 1."
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Tests for RooIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestRooIntegration(MarkdownIntegrationTests):
|
||||
KEY = "roo"
|
||||
FOLDER = ".roo/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".roo/commands"
|
||||
@@ -22,7 +22,7 @@ ALL_INTEGRATION_KEYS = [
|
||||
"copilot",
|
||||
# Stage 3 — standard markdown integrations
|
||||
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
|
||||
"roo", "rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
|
||||
"rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
|
||||
"pi", "kiro-cli", "vibe", "cursor-agent", "firebender",
|
||||
# Stage 4 — TOML integrations
|
||||
"gemini", "tabnine",
|
||||
|
||||
@@ -38,7 +38,6 @@ ISSUE_TEMPLATE_AGENT_KEYS = [
|
||||
"pi",
|
||||
"qodercli",
|
||||
"qwen",
|
||||
"roo",
|
||||
"rovodev",
|
||||
"shai",
|
||||
"tabnine",
|
||||
|
||||
@@ -37,8 +37,8 @@ from specify_cli.extensions import (
|
||||
ValidationError,
|
||||
CompatibilityError,
|
||||
normalize_priority,
|
||||
version_satisfies,
|
||||
)
|
||||
from specify_cli._utils import version_satisfies
|
||||
|
||||
# Minimal valid ZIP (empty end-of-central-directory record). Passes
|
||||
# zipfile.is_zipfile() so --from download tests exercise the content guard.
|
||||
@@ -1005,6 +1005,14 @@ class TestExtensionManager:
|
||||
with pytest.raises(CompatibilityError, match="Extension requires spec-kit"):
|
||||
manager.check_compatibility(manifest, "0.0.1")
|
||||
|
||||
def test_check_compatibility_allows_prerelease_builds(self, extension_dir, project_dir):
|
||||
"""Prerelease spec-kit builds should satisfy compatible version ranges."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
|
||||
result = manager.check_compatibility(manifest, "0.8.8.dev0")
|
||||
assert result is True
|
||||
|
||||
def test_install_from_directory(self, extension_dir, project_dir):
|
||||
"""Test installing extension from directory."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
@@ -2629,6 +2637,12 @@ class TestVersionSatisfies:
|
||||
assert version_satisfies("1.0.5", ">=1.0.0,!=1.0.3")
|
||||
assert not version_satisfies("1.0.3", ">=1.0.0,!=1.0.3")
|
||||
|
||||
def test_version_satisfies_prerelease(self):
|
||||
"""Prerelease builds should satisfy compatible lower bounds, but not higher bounds."""
|
||||
assert version_satisfies("0.8.8.dev0", ">=0.2.0")
|
||||
assert not version_satisfies("0.2.0.dev0", ">=0.2.0")
|
||||
assert not version_satisfies("0.8.7.dev1", ">=0.8.8")
|
||||
|
||||
def test_version_satisfies_invalid(self):
|
||||
"""Test invalid version strings."""
|
||||
assert not version_satisfies("invalid", ">=1.0.0")
|
||||
|
||||
@@ -710,6 +710,15 @@ class TestPresetManager:
|
||||
manifest = PresetManifest(pack_dir / "preset.yml")
|
||||
assert manager.check_compatibility(manifest, "0.1.5") is True
|
||||
|
||||
def test_check_compatibility_prerelease(self, pack_dir, temp_dir):
|
||||
"""Test compatibility check allows prereleases and fails on boundary."""
|
||||
manager = PresetManager(temp_dir)
|
||||
manifest = PresetManifest(pack_dir / "preset.yml")
|
||||
# manifest requires >=0.1.0
|
||||
assert manager.check_compatibility(manifest, "0.8.8.dev0") is True
|
||||
with pytest.raises(PresetCompatibilityError, match="Preset requires spec-kit"):
|
||||
manager.check_compatibility(manifest, "0.1.0.dev0")
|
||||
|
||||
def test_check_compatibility_invalid(self, pack_dir, temp_dir):
|
||||
"""Test compatibility check with invalid specifier."""
|
||||
manager = PresetManager(temp_dir)
|
||||
@@ -2769,6 +2778,115 @@ class TestSelfTestPreset:
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert metadata["registered_commands"] == {}
|
||||
|
||||
def test_self_test_seeds_constitution_when_memory_absent(self, project_dir):
|
||||
"""Installing a preset seeds memory/constitution.md from its template."""
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert memory.exists(), "constitution.md was not seeded from the preset"
|
||||
assert "preset:self-test" in memory.read_text(), (
|
||||
"constitution.md was not seeded from the self-test preset template"
|
||||
)
|
||||
|
||||
def test_self_test_reseeds_placeholder_constitution(self, project_dir):
|
||||
"""A placeholder memory constitution is re-seeded from the preset template."""
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.parent.mkdir(parents=True, exist_ok=True)
|
||||
memory.write_text("# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
content = memory.read_text()
|
||||
assert "preset:self-test" in content, "placeholder constitution was not re-seeded"
|
||||
assert "[PROJECT_NAME]" not in content
|
||||
|
||||
def test_self_test_preserves_authored_constitution(self, project_dir):
|
||||
"""An authored (placeholder-free) constitution is never overwritten."""
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.parent.mkdir(parents=True, exist_ok=True)
|
||||
authored = "# Acme Constitution\n\n### I. Ship It\nAuthored by a human.\n"
|
||||
memory.write_text(authored)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
assert memory.read_text() == authored, "authored constitution was overwritten"
|
||||
|
||||
def test_self_test_override_resolves_constitution_template(self, project_dir):
|
||||
"""The preset override of constitution-template resolves to the preset file."""
|
||||
templates_dir = project_dir / ".specify" / "templates"
|
||||
(templates_dir / "constitution-template.md").write_text("# Core constitution\n")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("constitution-template", "template")
|
||||
assert result is not None
|
||||
assert "preset:self-test" in result.read_text()
|
||||
|
||||
def test_constitution_seed_composes_wrap_strategy(self, project_dir, temp_dir):
|
||||
"""Seeding memory composes wrap constitution-template layers."""
|
||||
templates_dir = project_dir / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
(templates_dir / "constitution-template.md").write_text(
|
||||
"# Core Constitution\n\n## Core Principle\n"
|
||||
)
|
||||
|
||||
preset_dir = temp_dir / "constitution-wrap"
|
||||
(preset_dir / "templates").mkdir(parents=True)
|
||||
(preset_dir / "templates" / "constitution-template.md").write_text(
|
||||
"# Wrapper Constitution\n\n{CORE_TEMPLATE}\n\n## Wrapper Footer\n"
|
||||
)
|
||||
(preset_dir / "preset.yml").write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "constitution-wrap",
|
||||
"name": "Constitution Wrap",
|
||||
"version": "1.0.0",
|
||||
"description": "Wrap constitution template for testing",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "constitution-template",
|
||||
"file": "templates/constitution-template.md",
|
||||
"strategy": "wrap",
|
||||
"description": "Wrapped constitution template",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
content = memory.read_text()
|
||||
assert "{CORE_TEMPLATE}" not in content
|
||||
assert "# Wrapper Constitution" in content
|
||||
assert "## Core Principle" in content
|
||||
|
||||
def test_constitution_placeholder_tokens_are_pinned_to_core_template(self):
|
||||
"""Guard placeholder token drift between code and core template."""
|
||||
from specify_cli.presets import _CONSTITUTION_PLACEHOLDER_TOKENS
|
||||
|
||||
expected_tokens = {"[PROJECT_NAME]", "[PRINCIPLE_1_NAME]"}
|
||||
assert set(_CONSTITUTION_PLACEHOLDER_TOKENS) == expected_tokens
|
||||
|
||||
core_template = Path(__file__).parent.parent / "templates" / "constitution-template.md"
|
||||
content = core_template.read_text(encoding="utf-8")
|
||||
for token in expected_tokens:
|
||||
assert token in content
|
||||
|
||||
def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir):
|
||||
"""Test that extension command overrides are skipped if the extension isn't installed."""
|
||||
claude_dir = project_dir / ".claude" / "skills"
|
||||
@@ -6140,3 +6258,115 @@ def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monke
|
||||
)
|
||||
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9"
|
||||
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"]
|
||||
|
||||
|
||||
# ===== ensure_constitution_from_template resolver-awareness =====
|
||||
|
||||
|
||||
class TestEnsureConstitutionResolverAware:
|
||||
"""`ensure_constitution_from_template` must resolve through PresetResolver.
|
||||
|
||||
The constitution is the only template materialized to a live file rather
|
||||
than resolved on demand. These tests pin the regression from issue #3272:
|
||||
a preset-provided ``constitution-template`` must seed memory, while the
|
||||
core template is used when no preset overrides it.
|
||||
"""
|
||||
|
||||
def _core_constitution(self, project_dir):
|
||||
templates_dir = project_dir / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
(templates_dir / "constitution-template.md").write_text(
|
||||
"# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n"
|
||||
)
|
||||
|
||||
def _wrap_constitution_preset(self, temp_dir):
|
||||
preset_dir = temp_dir / "ensure-wrap-preset"
|
||||
(preset_dir / "templates").mkdir(parents=True)
|
||||
(preset_dir / "templates" / "constitution-template.md").write_text(
|
||||
"# Ensure Wrapper\n\n{CORE_TEMPLATE}\n\n## Tail\n"
|
||||
)
|
||||
(preset_dir / "preset.yml").write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "ensure-wrap",
|
||||
"name": "Ensure Wrap",
|
||||
"version": "1.0.0",
|
||||
"description": "Wrap strategy for ensure() coverage",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "constitution-template",
|
||||
"file": "templates/constitution-template.md",
|
||||
"strategy": "wrap",
|
||||
"description": "Wrapped constitution",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
return preset_dir
|
||||
|
||||
def test_seeds_from_core_when_no_preset(self, project_dir):
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
self._core_constitution(project_dir)
|
||||
ensure_constitution_from_template(project_dir)
|
||||
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert memory.exists()
|
||||
assert "[PROJECT_NAME]" in memory.read_text()
|
||||
|
||||
def test_seeds_from_preset_when_installed(self, project_dir):
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
self._core_constitution(project_dir)
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
# Remove the memory file seeded during install to test ensure() in
|
||||
# isolation; it must re-seed from the preset, not the core template.
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.unlink()
|
||||
|
||||
ensure_constitution_from_template(project_dir)
|
||||
|
||||
assert memory.exists()
|
||||
content = memory.read_text()
|
||||
assert "preset:self-test" in content
|
||||
assert "[PROJECT_NAME]" not in content
|
||||
|
||||
def test_preserves_existing_memory(self, project_dir):
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
self._core_constitution(project_dir)
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.parent.mkdir(parents=True, exist_ok=True)
|
||||
authored = "# Acme Constitution\nAuthored.\n"
|
||||
memory.write_text(authored)
|
||||
|
||||
ensure_constitution_from_template(project_dir)
|
||||
|
||||
assert memory.read_text() == authored
|
||||
|
||||
def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir):
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
self._core_constitution(project_dir)
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(self._wrap_constitution_preset(temp_dir), "0.1.5")
|
||||
|
||||
# Ensure we validate ensure() behavior directly.
|
||||
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
memory.unlink()
|
||||
ensure_constitution_from_template(project_dir)
|
||||
|
||||
content = memory.read_text()
|
||||
assert "{CORE_TEMPLATE}" not in content
|
||||
assert "# Ensure Wrapper" in content
|
||||
assert "[PROJECT_NAME]" in content
|
||||
|
||||
@@ -204,7 +204,91 @@ class TestWorkflowRunWithoutProject:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Refusing to use symlinked .specify path in current directory" in result.output
|
||||
assert "Refusing to use symlinked .specify path" in result.output
|
||||
|
||||
def test_workflow_run_yaml_rejects_symlinked_workflows_dir(self, tmp_path):
|
||||
"""Running local YAML should fail when .specify/workflows is a symlink."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
workflow_file = tmp_path / "test-workflow.yml"
|
||||
workflow_content = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "symlink-workflows-test",
|
||||
"name": "Symlink Workflows Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A workflow for symlink guard testing",
|
||||
},
|
||||
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
||||
}
|
||||
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
||||
|
||||
(tmp_path / ".specify").mkdir()
|
||||
target_dir = tmp_path / "real-workflows-dir"
|
||||
target_dir.mkdir()
|
||||
try:
|
||||
(tmp_path / ".specify" / "workflows").symlink_to(
|
||||
target_dir, target_is_directory=True
|
||||
)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "run", str(workflow_file),
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Refusing to use symlinked .specify/workflows path" in result.output
|
||||
|
||||
def test_workflow_run_yaml_rejects_symlinked_runs_dir(self, tmp_path):
|
||||
"""Running local YAML should fail when .specify/workflows/runs is a symlink."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
workflow_file = tmp_path / "test-workflow.yml"
|
||||
workflow_content = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "symlink-runs-test",
|
||||
"name": "Symlink Runs Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A workflow for symlink guard testing",
|
||||
},
|
||||
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
||||
}
|
||||
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
||||
|
||||
(tmp_path / ".specify" / "workflows").mkdir(parents=True)
|
||||
target_dir = tmp_path / "real-runs-dir"
|
||||
target_dir.mkdir()
|
||||
try:
|
||||
(tmp_path / ".specify" / "workflows" / "runs").symlink_to(
|
||||
target_dir, target_is_directory=True
|
||||
)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "run", str(workflow_file),
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Refusing to use symlinked .specify/workflows/runs path" in result.output
|
||||
|
||||
def test_workflow_run_yaml_rejects_non_directory_specify_path(self, tmp_path):
|
||||
"""Running local YAML should fail when .specify is not a directory."""
|
||||
|
||||
@@ -1822,6 +1822,12 @@ class TestWhileStep:
|
||||
step = WhileStep()
|
||||
errors = step.validate({"id": "test", "condition": "{{ true }}", "max_iterations": 0, "steps": []})
|
||||
assert any("must be an integer >= 1" in e for e in errors)
|
||||
# bool is an int subclass; `max_iterations: true` must be rejected, not
|
||||
# silently treated as a single iteration.
|
||||
bool_errors = step.validate(
|
||||
{"id": "test", "condition": "{{ true }}", "max_iterations": True, "steps": []}
|
||||
)
|
||||
assert any("must be an integer >= 1" in e for e in bool_errors)
|
||||
|
||||
|
||||
class TestDoWhileStep:
|
||||
@@ -1861,6 +1867,21 @@ class TestDoWhileStep:
|
||||
assert len(result.next_steps) == 1
|
||||
assert result.output["max_iterations"] == 5
|
||||
|
||||
def test_validate_rejects_bool_max_iterations(self):
|
||||
from specify_cli.workflows.steps.do_while import DoWhileStep
|
||||
|
||||
step = DoWhileStep()
|
||||
# bool is an int subclass; `max_iterations: true` must be rejected.
|
||||
errors = step.validate(
|
||||
{"id": "test", "condition": "{{ true }}", "max_iterations": True, "steps": []}
|
||||
)
|
||||
assert any("must be an integer >= 1" in e for e in errors)
|
||||
# a real positive integer is fully valid (no errors at all).
|
||||
ok = step.validate(
|
||||
{"id": "test", "condition": "{{ true }}", "max_iterations": 3, "steps": []}
|
||||
)
|
||||
assert ok == [], ok
|
||||
|
||||
def test_execute_empty_steps(self):
|
||||
from specify_cli.workflows.steps.do_while import DoWhileStep
|
||||
from specify_cli.workflows.base import StepContext
|
||||
@@ -5282,6 +5303,279 @@ class TestWorkflowStepRemoveCLI:
|
||||
assert "Refusing to use symlinked step directory" in result.output
|
||||
|
||||
|
||||
class TestWorkflowRemoveGuard:
|
||||
def test_remove_rejects_traversal_registry_key(self, project_dir, monkeypatch):
|
||||
"""A corrupted registry key must not let remove delete outside workflows/."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.catalog import WorkflowRegistry
|
||||
|
||||
registry = WorkflowRegistry(project_dir)
|
||||
registry.add("../outside", {"name": "Bad"})
|
||||
outside = project_dir / ".specify" / "outside"
|
||||
outside.mkdir()
|
||||
sentinel = outside / "keep.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "remove", "../outside"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Invalid workflow ID" in result.output
|
||||
assert sentinel.read_text(encoding="utf-8") == "keep"
|
||||
|
||||
@pytest.mark.parametrize("workflow_id", ["runs", "steps"])
|
||||
def test_remove_rejects_reserved_storage_ids(
|
||||
self, project_dir, monkeypatch, workflow_id
|
||||
):
|
||||
"""Reserved workflow storage directories must never be removable workflows."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.catalog import WorkflowRegistry
|
||||
|
||||
registry = WorkflowRegistry(project_dir)
|
||||
registry.add(workflow_id, {"name": "Bad"})
|
||||
reserved_dir = project_dir / ".specify" / "workflows" / workflow_id
|
||||
reserved_dir.mkdir(exist_ok=True)
|
||||
sentinel = reserved_dir / "keep.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "remove", workflow_id])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Invalid workflow ID" in result.output
|
||||
assert sentinel.read_text(encoding="utf-8") == "keep"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_remove_refuses_symlinked_workflow_dir(self, project_dir, monkeypatch):
|
||||
"""A symlinked workflow directory must not let remove delete its target."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.catalog import WorkflowRegistry
|
||||
|
||||
registry = WorkflowRegistry(project_dir)
|
||||
registry.add("test-wf", {"name": "Test"})
|
||||
outside = project_dir / "outside-workflow-remove-target"
|
||||
outside.mkdir(exist_ok=True)
|
||||
sentinel = outside / "keep.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
(project_dir / ".specify" / "workflows" / "test-wf").symlink_to(
|
||||
outside, target_is_directory=True
|
||||
)
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "remove", "test-wf"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "symlinked .specify/workflows/test-wf" in result.output
|
||||
assert sentinel.read_text(encoding="utf-8") == "keep"
|
||||
assert WorkflowRegistry(project_dir).is_installed("test-wf")
|
||||
|
||||
def test_remove_refuses_non_directory_workflow_path(self, project_dir, monkeypatch):
|
||||
"""A file at the workflow path must fail cleanly instead of crashing."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.catalog import WorkflowRegistry
|
||||
|
||||
registry = WorkflowRegistry(project_dir)
|
||||
registry.add("test-wf", {"name": "Test"})
|
||||
workflow_path = project_dir / ".specify" / "workflows" / "test-wf"
|
||||
workflow_path.write_text("not a directory", encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "remove", "test-wf"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "exists but is not a directory" in result.output
|
||||
assert workflow_path.read_text(encoding="utf-8") == "not a directory"
|
||||
assert WorkflowRegistry(project_dir).is_installed("test-wf")
|
||||
|
||||
|
||||
class TestWorkflowAddSymlinkGuard:
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch):
|
||||
"""workflow add must refuse a symlinked .specify (writes could escape root)."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
outside = temp_dir.parent / "outside-specify-target"
|
||||
(outside / "workflows").mkdir(parents=True, exist_ok=True)
|
||||
(temp_dir / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", "anything.yml"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "symlinked .specify" in result.output
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_refuses_symlinked_workflows_dir(self, temp_dir, monkeypatch):
|
||||
"""workflow add must refuse a symlinked .specify/workflows directory."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify").mkdir()
|
||||
outside = temp_dir.parent / "outside-workflows-target"
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
(temp_dir / ".specify" / "workflows").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", "anything.yml"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "symlinked .specify/workflows" in result.output
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_refuses_symlinked_id_dir(self, temp_dir, monkeypatch, sample_workflow_yaml):
|
||||
"""A symlinked <id> install dir must not let a copy escape the project root."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
|
||||
outside = temp_dir.parent / "outside-id-target"
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
# <id> from the YAML below is "test-workflow"; plant it as a symlink.
|
||||
(temp_dir / ".specify" / "workflows" / "test-workflow").symlink_to(
|
||||
outside, target_is_directory=True
|
||||
)
|
||||
src = temp_dir / "incoming.yml"
|
||||
src.write_text(sample_workflow_yaml, encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", str(src)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
# No write-through: the symlink target stays empty.
|
||||
assert not (outside / "workflow.yml").exists()
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_refuses_symlinked_workflow_yml_leaf(self, temp_dir, monkeypatch, sample_workflow_yaml):
|
||||
"""A symlinked <id>/workflow.yml must not let copy2 write through the link."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
id_dir = temp_dir / ".specify" / "workflows" / "test-workflow"
|
||||
id_dir.mkdir(parents=True)
|
||||
outside_file = temp_dir.parent / "outside-leaf-target.yml"
|
||||
outside_file.write_text("original\n", encoding="utf-8")
|
||||
(id_dir / "workflow.yml").symlink_to(outside_file)
|
||||
src = temp_dir / "incoming.yml"
|
||||
src.write_text(sample_workflow_yaml, encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", str(src)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
# Rich may wrap the message; assert on the unbroken path fragment.
|
||||
assert "test-workflow/workflow.yml" in result.output
|
||||
assert "symlinked" in result.output
|
||||
# The link target content is untouched.
|
||||
assert outside_file.read_text(encoding="utf-8") == "original\n"
|
||||
|
||||
def test_add_refuses_non_directory_id(self, temp_dir, monkeypatch, sample_workflow_yaml):
|
||||
"""An <id> path that already exists as a file must fail cleanly, not crash."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
wf_dir = temp_dir / ".specify" / "workflows"
|
||||
wf_dir.mkdir(parents=True)
|
||||
(wf_dir / "test-workflow").write_text("not a dir", encoding="utf-8")
|
||||
src = temp_dir / "incoming.yml"
|
||||
src.write_text(sample_workflow_yaml, encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", str(src)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "exists but is not a directory" in result.output
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
|
||||
def test_add_refuses_workflow_yml_as_directory(self, temp_dir, monkeypatch, sample_workflow_yaml):
|
||||
"""A pre-existing <id>/workflow.yml *directory* must fail cleanly, not crash."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
id_dir = temp_dir / ".specify" / "workflows" / "test-workflow"
|
||||
id_dir.mkdir(parents=True)
|
||||
# Plant workflow.yml as a directory so a later write/copy2 would raise
|
||||
# IsADirectoryError without the explicit non-file guard.
|
||||
(id_dir / "workflow.yml").mkdir()
|
||||
src = temp_dir / "incoming.yml"
|
||||
src.write_text(sample_workflow_yaml, encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", str(src)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "test-workflow/workflow.yml" in result.output
|
||||
assert "is not a file" in result.output
|
||||
# Clean exit, not an unhandled IsADirectoryError traceback.
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
|
||||
def test_safe_workflow_id_dir_escapes_markup_in_invalid_id(self, temp_dir, capsys):
|
||||
"""A traversal <id> carrying Rich markup must be escaped, not interpreted."""
|
||||
import typer
|
||||
from specify_cli.workflows._commands import _safe_workflow_id_dir
|
||||
|
||||
workflows_dir = temp_dir / ".specify" / "workflows"
|
||||
workflows_dir.mkdir(parents=True)
|
||||
# Traversal (so the "Invalid workflow ID" branch fires) plus markup.
|
||||
with pytest.raises(typer.Exit):
|
||||
_safe_workflow_id_dir(workflows_dir, "../[red]evil[/red]")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
# Literal bracketed text survives; Rich did not consume it as a tag.
|
||||
assert "[red]evil[/red]" in out
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"workflow_id",
|
||||
[
|
||||
"runs",
|
||||
"steps",
|
||||
"nested/workflow",
|
||||
"nested\\workflow",
|
||||
"bad id",
|
||||
" bad-id",
|
||||
"bad-id ",
|
||||
],
|
||||
)
|
||||
def test_safe_workflow_id_dir_rejects_reserved_or_non_segment_ids(
|
||||
self, temp_dir, workflow_id, capsys
|
||||
):
|
||||
"""Install IDs must not collide with workflow internals or create nested paths."""
|
||||
import typer
|
||||
from specify_cli.workflows._commands import _safe_workflow_id_dir
|
||||
|
||||
workflows_dir = temp_dir / ".specify" / "workflows"
|
||||
workflows_dir.mkdir(parents=True)
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
_safe_workflow_id_dir(workflows_dir, workflow_id)
|
||||
|
||||
assert "Invalid workflow ID" in capsys.readouterr().out
|
||||
assert not (workflows_dir / workflow_id).exists()
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_list_refuses_symlinked_runs_dir(self, temp_dir, monkeypatch):
|
||||
"""workflow commands using the project shim must refuse symlinked run storage."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
|
||||
outside = temp_dir.parent / "outside-runs-target"
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
(temp_dir / ".specify" / "workflows" / "runs").symlink_to(
|
||||
outside, target_is_directory=True
|
||||
)
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "list"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "symlinked .specify/workflows/runs" in result.output
|
||||
|
||||
|
||||
class TestWorkflowStepAddCLI:
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_rejects_symlinked_steps_base_dir(self, project_dir, monkeypatch):
|
||||
@@ -5595,7 +5889,7 @@ steps:
|
||||
# at the file-descriptor level, so it sees the subprocess output too.
|
||||
import subprocess
|
||||
import sys as _sys
|
||||
from specify_cli import _stdout_to_stderr_when
|
||||
from specify_cli.workflows._commands import _stdout_to_stderr_when
|
||||
|
||||
print("STDOUT_BEFORE")
|
||||
with _stdout_to_stderr_when(True):
|
||||
@@ -5614,7 +5908,7 @@ steps:
|
||||
assert "PY_LEAK" in err and "SUBPROC_LEAK" in err
|
||||
|
||||
def test_json_redirect_inactive_is_noop(self, capfd):
|
||||
from specify_cli import _stdout_to_stderr_when
|
||||
from specify_cli.workflows._commands import _stdout_to_stderr_when
|
||||
|
||||
with _stdout_to_stderr_when(False):
|
||||
print("VISIBLE_ON_STDOUT")
|
||||
@@ -6235,7 +6529,7 @@ steps:
|
||||
# not cleared afterwards, so a `completed`/`failed` run whose last
|
||||
# executed step was a gate must NOT surface a stale gate block.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
from specify_cli.workflows._commands import _gate_outcome
|
||||
|
||||
gate_step = {
|
||||
"type": "gate",
|
||||
@@ -6262,7 +6556,7 @@ steps:
|
||||
# message may be a non-string YAML literal (e.g. a number); the JSON
|
||||
# surface normalises it so the emitted schema stays stable.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
from specify_cli.workflows._commands import _gate_outcome
|
||||
|
||||
state = SimpleNamespace(
|
||||
status=SimpleNamespace(value="paused"),
|
||||
@@ -6281,7 +6575,7 @@ steps:
|
||||
# workflow; the JSON surface always normalises them to list[str] | None
|
||||
# so the emitted schema is stable regardless of the input shape.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
from specify_cli.workflows._commands import _gate_outcome
|
||||
|
||||
def _options_payload(options):
|
||||
state = SimpleNamespace(
|
||||
@@ -6311,7 +6605,7 @@ steps:
|
||||
# surface normalises it to str (and keeps None = no decision yet),
|
||||
# consistent with the message/options normalization.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
from specify_cli.workflows._commands import _gate_outcome
|
||||
|
||||
def _choice_payload(choice):
|
||||
state = SimpleNamespace(
|
||||
@@ -6335,7 +6629,7 @@ steps:
|
||||
# gate is still detected by its unique output signature (`on_reject`),
|
||||
# so resume surfaces the gate block instead of silently dropping it.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
from specify_cli.workflows._commands import _gate_outcome
|
||||
|
||||
state = SimpleNamespace(
|
||||
status=SimpleNamespace(value="paused"),
|
||||
@@ -6361,7 +6655,7 @@ steps:
|
||||
# A typeless record lacking the gate signature must NOT be mistaken for
|
||||
# a gate (the fallback keys off `on_reject`, which only GateStep writes).
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
from specify_cli.workflows._commands import _gate_outcome
|
||||
|
||||
state = SimpleNamespace(
|
||||
status=SimpleNamespace(value="paused"),
|
||||
|
||||
@@ -69,6 +69,49 @@ def test_add_source_persists_absolute_local_path(tmp_path: Path, monkeypatch):
|
||||
assert Path(source.url) == catalog.resolve()
|
||||
|
||||
|
||||
def test_remove_source_accepts_relative_local_path(tmp_path: Path, monkeypatch):
|
||||
"""add_source stores a local path as an absolute url, so remove_source must
|
||||
accept the same relative path the caller added; otherwise `remove ./cat.json`
|
||||
cannot undo `add ./cat.json`."""
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
catalog = project / "sub" / "cat.json"
|
||||
catalog.parent.mkdir()
|
||||
catalog.write_text("{}", encoding="utf-8")
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
cc.add_source(project, "sub/cat.json", policy="install-allowed", priority=50)
|
||||
# Removing with the same relative path must succeed (stored absolute).
|
||||
removed = cc.remove_source(project, "sub/cat.json")
|
||||
assert removed == "sub/cat.json"
|
||||
# And it is actually gone now.
|
||||
with pytest.raises(BundlerError, match="No project-scoped catalog source"):
|
||||
cc.remove_source(project, "sub/cat.json")
|
||||
|
||||
|
||||
def test_remove_by_id_does_not_also_delete_canonical_url_match(tmp_path: Path, monkeypatch):
|
||||
"""`remove <id>` must remove only the exact-id source, not also a different
|
||||
source whose url happens to equal the id's canonicalized path. (_canonicalize_url
|
||||
treats a bare id as a local path, so the canonical match is only a fallback when
|
||||
there is no exact id/url match.)"""
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
monkeypatch.chdir(project)
|
||||
# Source A: id "local", a remote url.
|
||||
cc.add_source(
|
||||
project, "https://example.com/a.json", source_id="local",
|
||||
policy="install-allowed", priority=10,
|
||||
)
|
||||
# Source B: a local path that canonicalizes to <cwd>/local, with a distinct id.
|
||||
cc.add_source(project, "local", source_id="bsource", policy="install-allowed", priority=20)
|
||||
|
||||
removed = cc.remove_source(project, "local")
|
||||
assert removed == "local"
|
||||
ids = {c["id"] for c in cc._read(project)}
|
||||
assert "local" not in ids # the exact-id source was removed
|
||||
assert "bsource" in ids # the canonical-url source survives (not collateral)
|
||||
|
||||
|
||||
def test_add_source_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
|
||||
Reference in New Issue
Block a user