Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
56515ff610 chore: bump version to 0.12.12 2026-07-13 15:02:40 +00:00
24 changed files with 151 additions and 1002 deletions

View File

@@ -2,21 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.12.13] - 2026-07-13
### Changed
- fix(workflows): fail switch step on non-mapping cases instead of crashing (#3481)
- Cleanup agent-file-template.md (#2579)
- fix: mark Kiro integration as multi-install safe (#3472)
- fix: rewrite extension-relative subdir paths in generated command bodies (#3444)
- fix(templates): point constitution sync checklist at installed command files (#3418)
- feat(workflows): make shell step timeout configurable (#3327) (#3328)
- docs: clarify that release tags keep the leading v prefix (#3463)
- fix(workflows): don't crash on membership test against a non-iterable (#3448)
- fix(workflows): if-step validate accepts falsy non-list else (#3264)
- chore: release 0.12.12, begin 0.12.13.dev0 development (#3490)
## [0.12.12] - 2026-07-13
### Changed

View File

@@ -45,7 +45,7 @@ Spec-Driven Development **flips the script** on traditional software development
### 1. Install Specify CLI
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest tag from [Releases](https://github.com/github/spec-kit/releases):
```bash
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z

View File

@@ -11,8 +11,7 @@ If you want to try Spec Kit without installing it permanently, use `uvx` to run
# Create a new project (latest from main)
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
# Or target a specific release (replace vX.Y.Z with a tag from Releases;
# keep the leading v, e.g. v0.12.11 not 0.12.11)
# Or target a specific release (replace vX.Y.Z with a tag from Releases)
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
# Initialize in the current directory

View File

@@ -7,8 +7,7 @@
Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
```bash
# Install a specific stable release (recommended — replace vX.Y.Z with the
# latest tag, keeping the leading v, e.g. v0.12.11 not 0.12.11)
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
# Or install latest from main (may include unreleased changes)

View File

@@ -15,7 +15,7 @@
### Persistent Installation (Recommended)
Install once and use everywhere. Replace `vX.Y.Z` with a release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
Install once and use everywhere. Replace `vX.Y.Z` with a tag from [Releases](https://github.com/github/spec-kit/releases):
> [!NOTE]
> The command below requires **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uv`, [install uv first](./install/uv.md).

View File

@@ -158,7 +158,8 @@ presets/
├── plan-template.md
├── tasks-template.md
├── checklist-template.md
── constitution-template.md
── constitution-template.md
└── agent-file-template.md
```
## Module Structure

View File

@@ -44,6 +44,12 @@ provides:
description: "Self-test constitution template"
replaces: "constitution-template"
- type: "template"
name: "agent-file-template"
file: "templates/agent-file-template.md"
description: "Self-test agent file template"
replaces: "agent-file-template"
- type: "command"
name: "speckit.specify"
file: "commands/speckit.specify.md"

View File

@@ -0,0 +1,9 @@
# Agent File (Self-Test Preset)
<!-- preset:self-test -->
> This template is provided by the self-test preset.
## Agent Instructions
Follow these guidelines when working on this project.

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.12.13"
version = "0.12.12"
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"

View File

@@ -213,52 +213,6 @@ class CommandRegistrar:
".specify.specify/", ".specify/"
)
@staticmethod
def rewrite_extension_paths(
text: str, extension_id: str, extension_dir: Path
) -> str:
"""Rewrite extension-relative paths to their installed locations.
Extension command bodies reference bundled files relative to the
extension root (e.g. ``agents/control/commander.md``). After install
those files live under ``.specify/extensions/<id>/``, so bare
references would resolve against the workspace root and never be
found (#2101).
Only directories that actually exist inside *extension_dir* are
rewritten, keeping the behaviour conservative and avoiding false
positives on prose. ``commands`` (slash-command sources), ``specs``
(user project artifacts) and dot-directories are never rewritten.
"""
if not isinstance(text, str) or not text:
return text
skip = {"commands", ".git", "specs"}
try:
subdirs = [
entry.name
for entry in extension_dir.iterdir()
if entry.is_dir()
and entry.name not in skip
and not entry.name.startswith(".")
]
except OSError:
return text
for subdir in subdirs:
# Only rewrite relative references (subdir/... or ./subdir/...);
# absolute paths like /subdir/... keep their meaning. Use a
# callable replacement: subdir/extension_id come from the
# filesystem and could contain backslashes or "\1"-like
# sequences, which would corrupt a string replacement template.
replacement = f".specify/extensions/{extension_id}/{subdir}/"
text = re.sub(
r'(^|[\s`"\'(])(?:\./)?' + re.escape(subdir) + "/",
lambda m: m.group(1) + replacement,
text,
)
return text
def render_markdown_command(
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
) -> str:
@@ -685,9 +639,6 @@ class CommandRegistrar:
frontmatter[key] = core_frontmatter[key]
frontmatter.pop("strategy", None)
if extension_id:
body = self.rewrite_extension_paths(body, extension_id, source_root)
frontmatter = self._adjust_script_paths(
frontmatter, extension_id=extension_id
)

View File

@@ -1078,11 +1078,6 @@ class ExtensionManager:
frontmatter = registrar._adjust_script_paths(
frontmatter, extension_id=manifest.id
)
# Mirror the register_commands() rewrite (#2101): resolve
# extension-relative subdir references (agents/, knowledge-base/,
# etc.) to their installed .specify/extensions/<id>/ location
# before the generic placeholder/path resolution below.
body = registrar.rewrite_extension_paths(body, manifest.id, extension_dir)
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
)

View File

@@ -13,7 +13,6 @@ _KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"
class KiroCliIntegration(MarkdownIntegration):
key = "kiro-cli"
multi_install_safe = True
config = {
"name": "Kiro CLI",
"folder": ".kiro/",

View File

@@ -778,7 +778,6 @@ class PresetManager:
matching_cmds, ext_id, ext_dir,
self.project_root,
context_note=f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n",
extension_id=ext_id,
)
registered = True
except Exception:
@@ -1200,8 +1199,6 @@ class PresetManager:
"command_name": cmd_name,
"source_file": source_file,
"source": f"extension:{manifest.id}",
"extension_id": manifest.id,
"extension_dir": ext_root,
}
modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
restore_index.setdefault(modern_skill_name, restore_info)
@@ -1466,17 +1463,6 @@ class PresetManager:
if extension_restore:
content = extension_restore["source_file"].read_text(encoding="utf-8")
frontmatter, body = registrar.parse_frontmatter(content)
# Mirror the register-time rewrite (#2101): resolve
# extension-relative subdir references (agents/,
# knowledge-base/, etc.) to their installed location before
# the generic placeholder resolution below, otherwise
# restoring after a preset override removal would leave
# bare, unresolvable paths in the skill body.
body = registrar.rewrite_extension_paths(
body,
extension_restore["extension_id"],
extension_restore["extension_dir"],
)
if isinstance(selected_ai, str):
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root
@@ -3052,8 +3038,6 @@ class PresetResolver:
"path": candidate,
"source": source,
"strategy": "replace",
"extension_id": ext_id,
"extension_dir": ext_dir,
})
# Priority 4: Core templates (always "replace")
@@ -3173,32 +3157,10 @@ class PresetResolver:
if not layers:
return None
def _read_layer_content(layer: Dict[str, Any]) -> str:
"""Read a layer's raw text, rewriting extension-relative subdir
references (agents/, knowledge-base/, etc.) to their installed
location when the layer is extension-provided (#2101).
Extension layers are always inserted with strategy "replace"
(see collect_all_layers), so a layer only ever needs this
rewrite when it wins outright above or serves as the
composition base below — never as a mid-stack composing
(append/prepend/wrap) layer.
"""
text = layer["path"].read_text(encoding="utf-8")
extension_id = layer.get("extension_id")
extension_dir = layer.get("extension_dir")
if extension_id and extension_dir:
from ..agents import CommandRegistrar
text = CommandRegistrar.rewrite_extension_paths(
text, extension_id, extension_dir
)
return text
# If the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies.
if layers[0]["strategy"] == "replace":
return _read_layer_content(layers[0])
return layers[0]["path"].read_text(encoding="utf-8")
# Composition: build content bottom-up from the effective base.
# The base is the nearest replace layer scanning from highest priority
@@ -3221,7 +3183,7 @@ class PresetResolver:
# Convert to reversed_layers index
base_reversed_idx = len(layers) - 1 - base_layer_idx
content = _read_layer_content(layers[base_layer_idx])
content = layers[base_layer_idx]["path"].read_text(encoding="utf-8")
# Compose only the layers above the base (higher priority = lower index in layers,
# higher index in reversed_layers). Process bottom-up from base+1.
start_idx = base_reversed_idx + 1

View File

@@ -464,9 +464,9 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
if op == "<=":
return _safe_compare(left, right, "<=")
if op == " in ":
return _safe_membership(left, right, negate=False)
return left in right if right is not None else False
if op == " not in ":
return _safe_membership(left, right, negate=True)
return left not in right if right is not None else True
# Numeric literal
try:
@@ -511,26 +511,6 @@ def _coerce_number(value: Any) -> Any:
return value
def _safe_membership(left: Any, right: Any, *, negate: bool) -> bool:
"""Safely evaluate ``left in right`` (or ``not in``) without crashing.
``left in right`` raises ``TypeError`` whenever the operands don't support
membership testing — most commonly a non-iterable right operand (``None``,
an int, a bool), but also cases like an unhashable ``left`` against a set.
In every such case the membership relation is undefined, so treat it as
``False`` (``not in`` as ``True``) rather than leaking the error out of the
evaluator and crashing the whole workflow. Mirrors the graceful
``TypeError`` handling in ``_safe_compare`` for the ordering operators, and
generalizes the previous ``right is not None`` guard to any operand pair
that can't be membership-tested.
"""
try:
contained = left in right
except TypeError:
contained = False
return not contained if negate else contained
def _safe_compare(left: Any, right: Any, op: str) -> bool:
"""Compare two values for ordering, coercing numeric strings when possible.

View File

@@ -47,8 +47,8 @@ class IfThenStep(StepBase):
errors.append(
f"If step {config.get('id', '?')!r}: 'then' must be a list of steps."
)
else_branch = config.get("else")
if else_branch is not None and not isinstance(else_branch, list):
else_branch = config.get("else", [])
if else_branch and not isinstance(else_branch, list):
errors.append(
f"If step {config.get('id', '?')!r}: 'else' must be a list of steps."
)

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import json
import math
import subprocess
from typing import Any
@@ -26,20 +25,15 @@ class ShellStep(StepBase):
run_cmd = str(run_cmd)
cwd = context.project_root or "."
# Per-step execution timeout in seconds; defaults to 300 for backward
# compatibility. The engine does not auto-validate step config, so
# validate here as well — a caller that skips WorkflowEngine.validate()
# must fail the step cleanly rather than crash subprocess.run() with a
# TypeError (or silently coerce ``timeout: true`` to a 1s duration,
# since bool is an int subclass).
# Defensive: the engine does not auto-validate step config, so an
# invalid ``timeout`` (string, None, ...) would otherwise raise a
# TypeError from subprocess.run() and crash the whole run. Mirror
# the engine's handling of unvalidated ``continue_on_error`` by
# only honoring well-formed values and falling back to the default.
timeout = config.get("timeout", 300)
timeout_error = self._timeout_error(config)
if timeout_error is not None:
return StepResult(
status=StepStatus.FAILED,
error=timeout_error,
output={"exit_code": -1, "stdout": "", "stderr": "invalid timeout"},
)
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
timeout = 300
# NOTE: shell=True is required to support pipes, redirects, and
# multi-command expressions in workflow YAML. Workflow authors
# control commands; catalog-installed workflows should be reviewed
@@ -98,32 +92,6 @@ class ShellStep(StepBase):
output={"exit_code": -1, "stdout": "", "stderr": str(exc)},
)
@staticmethod
def _timeout_error(config: dict[str, Any]) -> str | None:
"""Return an error message if ``config['timeout']`` is invalid, else None.
Shared by execute() and validate() so both paths reject the same
values with the same message. An absent ``timeout`` is valid (the
default is used). bool is a subclass of int, but ``timeout: true`` is a
config error rather than a duration, so it is rejected explicitly.
Non-finite floats (YAML ``.inf``/``.nan``) pass a plain ``> 0`` check
but would raise in subprocess.run(), so they are rejected too.
"""
if "timeout" not in config:
return None
timeout = config["timeout"]
if (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
or timeout <= 0
):
return (
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive number of seconds, got {timeout!r}."
)
return None
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "run" not in config:
@@ -146,7 +114,16 @@ class ShellStep(StepBase):
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
f"be 'json' when present, got {output_format!r}."
)
timeout_error = self._timeout_error(config)
if timeout_error is not None:
errors.append(timeout_error)
if "timeout" in config:
timeout = config["timeout"]
# bool is an int subclass, so reject it explicitly.
if (
isinstance(timeout, bool)
or not isinstance(timeout, int)
or timeout <= 0
):
errors.append(
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive integer (seconds) when present, got {timeout!r}."
)
return errors

View File

@@ -26,20 +26,6 @@ class SwitchStep(StepBase):
str_value = str(value) if value is not None else ""
cases = config.get("cases", {})
if not isinstance(cases, dict):
# The engine does not auto-validate step config, so an unvalidated
# run with a non-mapping ``cases`` (a list/scalar authoring mistake)
# would otherwise raise AttributeError from ``.items()`` below and
# crash the whole run. Fail this step loudly instead, mirroring the
# fan-out step's non-list ``items`` handling.
return StepResult(
status=StepStatus.FAILED,
error=(
f"Switch step {config.get('id', '?')!r}: 'cases' must be a "
f"mapping, got {type(cases).__name__}."
),
output={"matched_case": None, "expression_value": value},
)
for case_key, case_steps in cases.items():
if str(case_key) == str_value:
return StepResult(

View File

@@ -81,7 +81,7 @@ Follow this execution flow:
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
- Read each installed Spec Kit command file for your agent (including this one) — named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-<name>/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance is required.
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):

View File

@@ -42,9 +42,6 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
COMMANDS_SUBDIR = "prompts"
REGISTRAR_DIR = ".kiro/prompts"
def test_declares_multi_install_safe(self):
assert get_integration(self.KEY).multi_install_safe is True
def test_registrar_config(self):
"""Override base assertion: kiro-cli uses a prose fallback for args
because Kiro CLI file-based prompts do not natively substitute

View File

@@ -911,65 +911,6 @@ class TestExtensionSkillRegistration:
assert ".specify/scripts/bash/resolve-skill.sh" not in content
assert ".specify/scripts/bash/ensure-skills.sh" not in content
def test_skill_registration_rewrites_extension_subdir_paths(self, project_dir, temp_dir):
"""Auto-registered skills should resolve extension-relative subdir
references (agents/, knowledge-base/) to their installed location,
matching the rewrite already applied by register_commands() (#2101)."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = temp_dir / "path-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "path-ext",
"name": "Path Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.path-ext.run",
"file": "commands/run.md",
"description": "Run command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "agents" / "control").mkdir(parents=True)
(ext_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(ext_dir / "knowledge-base").mkdir()
(ext_dir / "knowledge-base" / "agent-scores.yaml").write_text("scores: {}\n")
(ext_dir / "templates").mkdir()
(ext_dir / "templates" / "kill-report.md").write_text("# Kill Report\n")
(ext_dir / "commands" / "run.md").write_text(
"---\n"
"description: Run command\n"
"---\n\n"
"Read agents/control/commander.md and knowledge-base/agent-scores.yaml.\n"
"Use templates/kill-report.md as the report template.\n"
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
content = (skills_dir / "speckit-path-ext-run" / "SKILL.md").read_text()
assert ".specify/extensions/path-ext/agents/control/commander.md" in content
assert ".specify/extensions/path-ext/knowledge-base/agent-scores.yaml" in content
# extension's own templates/ dir must resolve under the extension,
# not the project-level .specify/templates/
assert ".specify/extensions/path-ext/templates/kill-report.md" in content
assert "Read agents/control" not in content
assert "and knowledge-base/" not in content
def test_missing_command_file_skipped(self, skills_project, temp_dir):
"""Commands with missing source files should be skipped gracefully."""
project_dir, skills_dir = skills_project

View File

@@ -2376,185 +2376,6 @@ Run {SCRIPT}
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
assert ".specify/scripts/bash/setup-plan.sh" not in content
@staticmethod
def _make_subdir_extension(temp_dir, ext_id="echelon", aliases=None):
"""Create an extension whose command body references bundled subdirs."""
import yaml
ext_dir = temp_dir / ext_id
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
(ext_dir / "agents" / "control").mkdir(parents=True)
(ext_dir / "knowledge-base").mkdir()
(ext_dir / "templates").mkdir()
(ext_dir / "specs" / "001-internal").mkdir(parents=True)
command = {
"name": f"speckit.{ext_id}.run",
"file": "commands/run.md",
"description": "Run",
}
if aliases:
command["aliases"] = aliases
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": ext_id,
"name": "Echelon",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {"commands": [command]},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "run.md").write_text(
"---\ndescription: Run\n---\n\n"
"Read agents/control/commander.md for instructions.\n"
"Load knowledge-base/agent-scores.yaml for calibration.\n"
"Use templates/kill-report.md as output format.\n"
"Artifacts go to specs/001-internal/plan.md.\n"
"See commands/run.md for the source.\n"
)
return ext_dir
def test_codex_skill_registration_rewrites_extension_subdir_paths(
self, project_dir, temp_dir
):
"""Extension-relative subdir refs must point at the installed location."""
ext_dir = self._make_subdir_extension(temp_dir)
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
content = (skills_dir / "speckit-echelon-run" / "SKILL.md").read_text()
assert ".specify/extensions/echelon/agents/control/commander.md" in content
assert ".specify/extensions/echelon/knowledge-base/agent-scores.yaml" in content
assert ".specify/extensions/echelon/templates/kill-report.md" in content
assert "Read agents/" not in content
# specs/ refs point at the user's project artifacts, never the extension
assert "to specs/001-internal/plan.md" in content
assert ".specify/extensions/echelon/specs/" not in content
# commands/ refs are slash-command sources, not runtime reads
assert "See commands/run.md" in content
def test_skill_registration_rewrites_extension_subdir_paths_in_aliases(
self, project_dir, temp_dir
):
"""Alias skills reuse the rewritten body."""
ext_dir = self._make_subdir_extension(
temp_dir, ext_id="ext-alias-paths", aliases=["speckit.ext-alias-paths.go"]
)
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
alias_content = (
skills_dir / "speckit-ext-alias-paths-go" / "SKILL.md"
).read_text()
assert (
".specify/extensions/ext-alias-paths/agents/control/commander.md"
in alias_content
)
assert "Read agents/" not in alias_content
def test_markdown_registration_rewrites_extension_subdir_paths(
self, project_dir, temp_dir
):
"""Markdown-format agents get the same rewrite via the shared path."""
ext_dir = self._make_subdir_extension(temp_dir, ext_id="ext-md-paths")
amp_dir = project_dir / ".agents" / "commands"
amp_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("amp", manifest, ext_dir, project_dir)
content = (amp_dir / "speckit.ext-md-paths.run.md").read_text()
assert ".specify/extensions/ext-md-paths/agents/control/commander.md" in content
assert "Read agents/" not in content
def test_rewrite_extension_paths_only_rewrites_existing_subdirs(self, temp_dir):
"""Only directories present in the extension are rewritten."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
ext_dir = temp_dir / "ext-existing"
(ext_dir / "agents").mkdir(parents=True)
(ext_dir / ".hidden").mkdir()
text = (
"Read agents/one.md then knowledge-base/two.md.\n"
"Also ./agents/three.md but not /agents/abs.md.\n"
"Keep .hidden/secret.md alone.\n"
)
rewritten = AgentCommandRegistrar.rewrite_extension_paths(
text, "ext-existing", ext_dir
)
assert ".specify/extensions/ext-existing/agents/one.md" in rewritten
assert "Also .specify/extensions/ext-existing/agents/three.md" in rewritten
# absolute paths keep their meaning
assert "not /agents/abs.md" in rewritten
# knowledge-base/ does not exist in this extension: left untouched
assert "then knowledge-base/two.md" in rewritten
assert ".hidden/secret.md" in rewritten
assert ".specify/extensions/ext-existing/.hidden/" not in rewritten
def test_rewrite_extension_paths_handles_regex_special_replacement_text(
self, temp_dir
):
"""subdir/extension_id containing regex-replacement-special characters
(e.g. backslash / group references) must not raise or be misinterpreted
by re.sub's replacement template (#2101).
The subdir name uses brackets rather than a backslash: on Windows,
"\\" is a path separator, so a subdir literally named "assets\\q"
would create nested directories "assets/q" instead of a single
directory, and iterdir() would then only discover "assets" - never
exercising the intended replacement text. extension_id isn't used to
create a directory, so it's free to contain a real backslash/"\\1"
to verify the callable replacement treats it literally.
"""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
ext_dir = temp_dir / "ext-backslash"
weird_subdir = "assets[q]"
(ext_dir / weird_subdir).mkdir(parents=True)
# sanity-check the cross-platform assumption above
assert [p.name for p in ext_dir.iterdir()] == [weird_subdir]
text = f"Read {weird_subdir}/file.md but not /{weird_subdir}/abs.md.\n"
rewritten = AgentCommandRegistrar.rewrite_extension_paths(
text, "ext\\1", ext_dir
)
assert f".specify/extensions/ext\\1/{weird_subdir}/file.md" in rewritten
# absolute paths are still left untouched
assert f"/{weird_subdir}/abs.md" in rewritten
def test_rewrite_extension_paths_missing_dir_returns_text(self, temp_dir):
"""A missing extension directory leaves the text unchanged."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
text = "Read agents/one.md."
assert (
AgentCommandRegistrar.rewrite_extension_paths(
text, "gone", temp_dir / "does-not-exist"
)
== text
)
def test_register_commands_for_copilot(self, extension_dir, project_dir):
"""Test registering commands for Copilot agent with .agent.md extension."""
# Create .github/agents directory (Copilot project)

View File

@@ -2620,7 +2620,7 @@ class TestSelfTestPreset:
assert manifest.id == "self-test"
assert manifest.name == "Self-Test Preset"
assert manifest.version == "1.0.0"
assert len(manifest.templates) == 7 # 5 templates + 2 commands
assert len(manifest.templates) == 8 # 6 templates + 2 commands
def test_self_test_provides_all_core_templates(self):
"""Verify the self-test preset provides an override for every core template."""
@@ -3663,8 +3663,6 @@ class TestPresetSkills:
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(extension_dir / "commands" / "cmd.md").write_text(
"---\n"
"description: Extension fakeext cmd\n"
@@ -3673,7 +3671,6 @@ class TestPresetSkills:
"---\n\n"
"extension:fakeext\n"
"Run {SCRIPT}\n"
"Read agents/control/commander.md for context.\n"
)
extension_manifest = {
"schema_version": "1.0",
@@ -3739,92 +3736,8 @@ class TestPresetSkills:
assert "source: extension:fakeext" in content
assert "extension:fakeext" in content
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
# Extension-relative subdir references must resolve to their
# installed location on restore too (#2101), not just on first
# registration.
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
assert "Read agents/control" not in content
assert "# Fakeext Cmd Skill" in content
def test_skill_composed_over_extension_base_rewrites_subdir_paths(
self, project_dir, temp_dir
):
"""When a preset composes (append) over an extension-provided base
command, the resulting skill (read from the .composed output) must
still resolve the extension's own subdir references (#2101), not
just when the extension wins outright (replace)."""
self._write_init_options(project_dir, ai="codex")
skills_dir = project_dir / ".agents" / "skills"
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill")
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(extension_dir / "commands" / "cmd.md").write_text(
"---\ndescription: Extension fakeext cmd\n---\n\n"
"Read agents/control/commander.md for context.\n"
)
extension_manifest = {
"schema_version": "1.0",
"extension": {
"id": "fakeext",
"name": "Fake Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.fakeext.cmd",
"file": "commands/cmd.md",
"description": "Fake extension command",
}
]
},
}
with open(extension_dir / "extension.yml", "w") as f:
yaml.dump(extension_manifest, f)
preset_dir = temp_dir / "ext-base-append-skill"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
"---\ndescription: Preset overlay\n---\n\n## Extra\n"
)
preset_manifest = {
"schema_version": "1.0",
"preset": {
"id": "ext-base-append-skill",
"name": "Ext Base Append Skill",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
"strategy": "append",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(preset_manifest, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
content = skill_file.read_text()
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
assert "Read agents/control" not in content
assert "## Extra" in content
def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir):
"""Preset removal should not delete arbitrary directories missing SKILL.md."""
self._write_init_options(project_dir, ai="codex")
@@ -6059,86 +5972,6 @@ class TestResolveContent:
content = resolver.resolve_content("spec-template")
assert content == "# Replaced content\n"
@pytest.mark.parametrize("strategy", ["append", "prepend", "wrap"])
def test_resolve_content_rewrites_extension_base_subdir_paths(
self, project_dir, temp_dir, strategy
):
"""Composing over an extension-provided base command must resolve the
extension's own subdir references (agents/, knowledge-base/) to their
installed location (#2101), not just when the extension wins outright.
"""
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(extension_dir / "commands" / "cmd.md").write_text(
"---\ndescription: Extension fakeext cmd\n---\n\n"
"Read agents/control/commander.md for context.\n"
)
extension_manifest = {
"schema_version": "1.0",
"extension": {
"id": "fakeext",
"name": "Fake Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.fakeext.cmd",
"file": "commands/cmd.md",
"description": "Fake extension command",
}
]
},
}
with open(extension_dir / "extension.yml", "w") as f:
yaml.dump(extension_manifest, f)
preset_dir = temp_dir / f"ext-base-{strategy}"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
overlay_body = (
"{CORE_TEMPLATE}\n## Extra\n" if strategy == "wrap" else "## Extra\n"
)
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
f"---\ndescription: Preset overlay\n---\n\n{overlay_body}"
)
preset_manifest = {
"schema_version": "1.0",
"preset": {
"id": f"ext-base-{strategy}",
"name": "Ext Base",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
"strategy": strategy,
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(preset_manifest, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("speckit.fakeext.cmd", "command")
assert content is not None
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
assert "Read agents/control" not in content
assert "## Extra" in content
class TestCollectAllLayers:
"""Test PresetResolver.collect_all_layers() method."""
@@ -6219,172 +6052,6 @@ class TestCollectAllLayers:
class TestRemoveReconciliation:
"""Test that removing a preset re-registers the next layer's command."""
def test_remove_restores_extension_command_subdir_paths_for_non_skill_agent(
self, project_dir, temp_dir
):
"""When a preset override of an extension command is removed, the
reconciled non-skill-agent command file should have the extension's
own subdir references rewritten to their installed location (#2101),
not left as bare, unresolvable paths."""
gemini_dir = project_dir / ".gemini" / "commands"
gemini_dir.mkdir(parents=True)
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(extension_dir / "commands" / "cmd.md").write_text(
"---\ndescription: Extension fakeext cmd\n---\n\n"
"Read agents/control/commander.md for context.\n"
)
extension_manifest = {
"schema_version": "1.0",
"extension": {
"id": "fakeext",
"name": "Fake Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.fakeext.cmd",
"file": "commands/cmd.md",
"description": "Fake extension command",
}
]
},
}
with open(extension_dir / "extension.yml", "w") as f:
yaml.dump(extension_manifest, f)
manager = PresetManager(project_dir)
preset_dir = temp_dir / "ext-cmd-override"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
"---\ndescription: Override fakeext cmd\n---\n\npreset override content\n"
)
preset_manifest = {
"schema_version": "1.0",
"preset": {
"id": "ext-cmd-override",
"name": "Ext Cmd Override",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(preset_manifest, f)
manager.install_from_directory(preset_dir, "0.1.5")
cmd_files = list(gemini_dir.glob("*fakeext*"))
assert cmd_files, "Command file should exist in gemini dir"
assert "preset override content" in cmd_files[0].read_text()
manager.remove("ext-cmd-override")
cmd_files = list(gemini_dir.glob("*fakeext*"))
assert cmd_files, "Command file should still exist after removal"
content = cmd_files[0].read_text()
assert "preset override content" not in content
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
assert "Read agents/control" not in content
def test_install_composes_extension_command_and_rewrites_subdir_paths_for_non_skill_agent(
self, project_dir, temp_dir
):
"""When a preset overlays (append) an extension-provided base command,
the initial composed non-skill-agent command file must have the
extension's own subdir references rewritten to their installed
location (#2101), matching the live repro: extension body
'Read agents/control/commander.md', preset appends to
speckit.fakeext.cmd, generated Gemini content retains the bare path."""
gemini_dir = project_dir / ".gemini" / "commands"
gemini_dir.mkdir(parents=True)
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(extension_dir / "commands" / "cmd.md").write_text(
"---\ndescription: Extension fakeext cmd\n---\n\n"
"Read agents/control/commander.md for context.\n"
)
extension_manifest = {
"schema_version": "1.0",
"extension": {
"id": "fakeext",
"name": "Fake Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.fakeext.cmd",
"file": "commands/cmd.md",
"description": "Fake extension command",
}
]
},
}
with open(extension_dir / "extension.yml", "w") as f:
yaml.dump(extension_manifest, f)
preset_dir = temp_dir / "ext-cmd-append"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
"---\ndescription: Append fakeext cmd\n---\n\n## Extra\n"
)
preset_manifest = {
"schema_version": "1.0",
"preset": {
"id": "ext-cmd-append",
"name": "Ext Cmd Append",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
"strategy": "append",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(preset_manifest, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
cmd_files = list(gemini_dir.glob("*fakeext*"))
assert cmd_files, "Command file should exist in gemini dir"
content = cmd_files[0].read_text()
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
assert "Read agents/control" not in content
assert "## Extra" in content
def test_remove_restores_lower_priority_command(
self, project_dir, temp_dir, valid_pack_data
):

View File

@@ -460,34 +460,6 @@ class TestExpressions:
assert evaluate_expression("{{ inputs.s | contains('ab') }}", ctx2) is True
assert evaluate_expression("{{ inputs.missing | default('a|b') }}", ctx2) == "a|b"
def test_membership_against_non_iterable_is_false_not_error(self):
from specify_cli.workflows.expressions import (
evaluate_condition,
evaluate_expression,
)
from specify_cli.workflows.base import StepContext
# A non-iterable right operand (int, bool, None, float) makes a raw
# `x in y` raise TypeError in Python. The evaluator must treat it as
# "not contained" (False, and `not in` as True) instead of leaking the
# TypeError and crashing the whole workflow run. This generalizes the
# previous `right is not None` guard and mirrors _safe_compare, which
# already swallows TypeError for the ordering operators.
ctx = StepContext(inputs={"tag": "x", "count": 5, "ratio": 1.5, "flag": True})
assert evaluate_expression("{{ inputs.tag in inputs.count }}", ctx) is False
assert evaluate_expression("{{ inputs.tag not in inputs.count }}", ctx) is True
assert evaluate_expression("{{ 'a' in inputs.ratio }}", ctx) is False
assert evaluate_expression("{{ 'a' in inputs.flag }}", ctx) is False
assert evaluate_expression("{{ inputs.tag in inputs.missing }}", ctx) is False
# A condition that would otherwise crash the run now evaluates cleanly.
assert evaluate_condition("{{ inputs.tag in inputs.count }}", ctx) is False
# Regression: genuine membership over a real iterable still works.
ok = StepContext(inputs={"items": ["x", "y"], "s": "xyz"})
assert evaluate_expression("{{ 'x' in inputs.items }}", ok) is True
assert evaluate_expression("{{ 'z' not in inputs.items }}", ok) is True
assert evaluate_expression("{{ 'y' in inputs.s }}", ok) is True
def test_filter_default(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
@@ -1381,6 +1353,107 @@ class TestShellStep:
assert step.validate({"id": "s", "run": "echo hi"}) == []
assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == []
def test_timeout_is_configurable(self, monkeypatch):
"""A 'timeout' field overrides the 300s default (#3327)."""
import subprocess as sp
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
seen = {}
real_run = sp.run
def spy_run(*args, **kwargs):
seen["timeout"] = kwargs.get("timeout")
return real_run(*args, **kwargs)
monkeypatch.setattr(
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
)
step = ShellStep()
result = step.execute(
{"id": "t", "run": "echo hi", "timeout": 1800}, StepContext()
)
assert result.status == StepStatus.COMPLETED
assert seen["timeout"] == 1800
def test_timeout_defaults_to_300(self, monkeypatch):
import subprocess as sp
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
seen = {}
real_run = sp.run
def spy_run(*args, **kwargs):
seen["timeout"] = kwargs.get("timeout")
return real_run(*args, **kwargs)
monkeypatch.setattr(
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
)
result = ShellStep().execute({"id": "t", "run": "echo hi"}, StepContext())
assert result.status == StepStatus.COMPLETED
assert seen["timeout"] == 300
def test_timeout_error_reports_configured_value(self, monkeypatch):
import subprocess as sp
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
def raise_timeout(*args, **kwargs):
raise sp.TimeoutExpired(cmd="x", timeout=kwargs.get("timeout"))
monkeypatch.setattr(
"specify_cli.workflows.steps.shell.subprocess.run", raise_timeout
)
result = ShellStep().execute(
{"id": "t", "run": "sleep 999", "timeout": 7}, StepContext()
)
assert result.status == StepStatus.FAILED
assert "7 seconds" in result.error
@pytest.mark.parametrize("bad", [0, -5, "600", 1.5, None, True])
def test_execute_ignores_unvalidated_bad_timeout(self, bad, monkeypatch):
"""execute() falls back to 300 when config skipped validation (#3327)."""
import subprocess as sp
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
seen = {}
real_run = sp.run
def spy_run(*args, **kwargs):
seen["timeout"] = kwargs.get("timeout")
return real_run(*args, **kwargs)
monkeypatch.setattr(
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
)
result = ShellStep().execute(
{"id": "t", "run": "echo hi", "timeout": bad}, StepContext()
)
assert result.status == StepStatus.COMPLETED
assert seen["timeout"] == 300
@pytest.mark.parametrize("bad", [0, -5, "600", 1.5, None, True])
def test_validate_rejects_bad_timeout(self, bad):
from specify_cli.workflows.steps.shell import ShellStep
errors = ShellStep().validate({"id": "s", "run": "echo hi", "timeout": bad})
assert any("'timeout'" in e for e in errors)
def test_validate_accepts_positive_int_timeout(self):
from specify_cli.workflows.steps.shell import ShellStep
assert (
ShellStep().validate({"id": "s", "run": "echo hi", "timeout": 1800}) == []
)
def test_output_format_json_exposes_data(self, tmp_path):
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
@@ -1437,130 +1510,6 @@ class TestShellStep:
errors = step.validate({"id": "emit", "run": "exit 0", "output_format": "yaml"})
assert any("'output_format' must be 'json'" in e for e in errors)
def test_configured_timeout_is_passed_to_subprocess(self, monkeypatch):
"""A ``timeout:`` value on the step overrides the 300s default and is
threaded through to ``subprocess.run`` (issue #3327)."""
import subprocess
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
captured: dict[str, object] = {}
def fake_run(*args, **kwargs):
captured["timeout"] = kwargs.get("timeout")
return subprocess.CompletedProcess(
args=args[0] if args else "", returncode=0, stdout="", stderr=""
)
monkeypatch.setattr(subprocess, "run", fake_run)
step = ShellStep()
result = step.execute(
{"id": "qa", "run": "echo hi", "timeout": 1800}, StepContext()
)
assert result.status == StepStatus.COMPLETED
assert captured["timeout"] == 1800
def test_default_timeout_preserved_when_omitted(self, monkeypatch):
"""Omitting ``timeout:`` preserves the historical 300s default."""
import subprocess
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext
captured: dict[str, object] = {}
def fake_run(*args, **kwargs):
captured["timeout"] = kwargs.get("timeout")
return subprocess.CompletedProcess(
args=args[0] if args else "", returncode=0, stdout="", stderr=""
)
monkeypatch.setattr(subprocess, "run", fake_run)
step = ShellStep()
step.execute({"id": "qa", "run": "echo hi"}, StepContext())
assert captured["timeout"] == 300
def test_timeout_error_reports_configured_value(self, monkeypatch):
"""The timeout failure message reflects the configured duration, not a
hardcoded 300."""
import subprocess
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
def fake_run(*args, **kwargs):
raise subprocess.TimeoutExpired(cmd="echo hi", timeout=5)
monkeypatch.setattr(subprocess, "run", fake_run)
step = ShellStep()
result = step.execute(
{"id": "qa", "run": "echo hi", "timeout": 5}, StepContext()
)
assert result.status == StepStatus.FAILED
assert "5 seconds" in (result.error or "")
def test_execute_fails_cleanly_on_invalid_timeout(self, monkeypatch):
"""execute() must fail the step (not raise) on an invalid timeout even
when validate() was skipped — the engine does not auto-validate step
config, so an unvalidated string/bool/non-finite timeout would
otherwise crash subprocess.run() and take down the whole run."""
import subprocess
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
def fail_if_called(*args, **kwargs):
raise AssertionError("subprocess.run should not run on invalid timeout")
monkeypatch.setattr(subprocess, "run", fail_if_called)
step = ShellStep()
# A string would raise TypeError; ``True`` would silently become a 1s
# timeout (bool is an int subclass); ``.inf`` would raise at runtime.
for bad in ("30", True, float("inf"), 0):
result = step.execute(
{"id": "qa", "run": "echo hi", "timeout": bad}, StepContext()
)
assert result.status == StepStatus.FAILED
assert "'timeout' must be a positive number" in (result.error or "")
def test_validate_rejects_non_positive_timeout(self):
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
for bad in (0, -30):
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
assert any("'timeout' must be a positive number" in e for e in errors)
def test_validate_rejects_non_numeric_timeout(self):
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
# A string and a bool are both invalid (bool is an int subclass but a
# config error, not a duration).
for bad in ("30", True):
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
assert any("'timeout' must be a positive number" in e for e in errors)
def test_validate_rejects_non_finite_timeout(self):
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
# inf/nan are floats and slip past a plain ``> 0`` check (``nan <= 0``
# is False), but ``subprocess.run(timeout=...)`` would then fail at
# runtime. YAML ``.inf``/``.nan`` scalars parse to these via safe_load.
for bad in (float("inf"), float("-inf"), float("nan")):
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
assert any("'timeout' must be a positive number" in e for e in errors)
def test_validate_accepts_positive_numeric_timeout(self):
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
for good in (1, 300, 1800, 12.5):
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": good})
assert not any("'timeout'" in e for e in errors)
class _StubStdin:
"""Stdin stub exposing only a fixed ``isatty`` result.
@@ -2039,46 +1988,6 @@ class TestIfThenStep:
errors = step.validate({"id": "test", "then": []})
assert any("missing 'condition'" in e for e in errors)
@pytest.mark.parametrize("bad_else", [False, 0, "", {}, 42])
def test_validate_rejects_non_list_else(self, bad_else):
"""A non-list 'else' must be rejected even when it is falsy.
The original guard used ``if else_branch and ...`` which
short-circuits for falsy non-list values (False/0/''/{}), letting a
malformed else-branch pass validation only to be silently skipped at
runtime. ``then`` is already strictly validated; ``else`` must match.
"""
from specify_cli.workflows.steps.if_then import IfThenStep
step = IfThenStep()
errors = step.validate(
{"id": "i", "condition": "true", "then": [], "else": bad_else}
)
assert any("'else' must be a list of steps" in e for e in errors)
@pytest.mark.parametrize("ok_else", [None, [], [{"id": "x", "command": "/y"}]])
def test_validate_accepts_valid_else(self, ok_else):
"""An explicit 'else' of None or a list stays valid.
``else`` is set explicitly here (including ``else: None``) so the
explicit-None case is exercised, not just the missing-key case.
"""
from specify_cli.workflows.steps.if_then import IfThenStep
step = IfThenStep()
errors = step.validate(
{"id": "i", "condition": "true", "then": [], "else": ok_else}
)
assert not any("'else'" in e for e in errors)
def test_validate_accepts_missing_else(self):
"""A missing 'else' key stays valid (no else branch)."""
from specify_cli.workflows.steps.if_then import IfThenStep
step = IfThenStep()
errors = step.validate({"id": "i", "condition": "true", "then": []})
assert not any("'else'" in e for e in errors)
class TestSwitchStep:
"""Test the switch step type."""
@@ -2143,35 +2052,6 @@ class TestSwitchStep:
assert result.output["matched_case"] == "__default__"
assert result.next_steps == []
def test_execute_non_dict_cases_fails_loudly(self):
"""A non-mapping ``cases`` must fail the step, not crash the run.
``validate`` rejects a non-dict ``cases``, but the engine's
``execute()`` does not auto-validate (see ``WorkflowEngine.load_workflow``
docstring). Before the guard, ``execute`` called ``cases.items()`` on the
raw value, so an unvalidated run with a list/scalar ``cases`` raised
AttributeError and took down the whole run instead of failing this step.
Mirrors the fan-out step's non-list ``items`` handling.
"""
from specify_cli.workflows.steps.switch import SwitchStep
from specify_cli.workflows.base import StepContext, StepStatus
step = SwitchStep()
ctx = StepContext(steps={"review": {"output": {"choice": "approve"}}})
for bad_cases in (["approve"], "approve", 5):
result = step.execute(
{
"id": "route",
"expression": "{{ steps.review.output.choice }}",
"cases": bad_cases,
},
ctx,
)
assert result.status == StepStatus.FAILED
assert "'cases' must be a mapping" in (result.error or "")
# expression is still evaluated, so its value is surfaced for context.
assert result.output["expression_value"] == "approve"
def test_validate_missing_expression(self):
from specify_cli.workflows.steps.switch import SwitchStep

View File

@@ -112,14 +112,8 @@ Run a shell command and capture output:
- id: run-tests
type: shell
run: "cd {{ inputs.project_dir }} && npm test"
timeout: 1800 # Optional: max seconds before the command is killed (default 300)
```
`timeout` is the maximum time in seconds the command may run before it is
killed and the step fails; it must be a positive number and defaults to
`300` (five minutes) when omitted. Raise it for long-running gates such as
full builds, linter aggregators, or integration-test targets.
### Init Steps
Bootstrap a project the same way `specify init` does — scaffolding