mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0759cc4750 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -2,22 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.14] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- [extension] Add Spec Kit Memory extension to community catalog (#3455)
|
||||
- Add Test-First Governance preset to community catalog (#3504)
|
||||
- Add Autonomous Run Governance preset to community catalog (#3501)
|
||||
- fix(workflows): validate command step input/options are mappings (#3262)
|
||||
- fix(presets): resolve() honors manifest-declared file: for installed presets (#3351)
|
||||
- fix(init): don't block on confirmation for 'init --here' without a TTY (#3236)
|
||||
- [extension] Add Quality Gates (Enforcement Layer) extension to community catalog (#3431)
|
||||
- fix(integrations): exit cleanly on unbalanced quote in --integration-options (#3457) (#3466)
|
||||
- fix(integrations): declare kiro-cli multi-install safe (#3471) (#3485)
|
||||
- fix(workflows): fail fan-in step on non-list wait_for instead of crashing (#3482)
|
||||
- chore: release 0.12.13, begin 0.12.14.dev0 development (#3498)
|
||||
|
||||
## [0.12.13] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -98,7 +98,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
|
||||
| Quality Gates (Enforcement Layer) | Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity). | `process` | Read+Write | [spec-gates](https://github.com/schwichtgit/spec-gates) |
|
||||
| RAG Azure Builder | Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows. | `process` | Read+Write | [spec-kit-extension-rag-azure-builder](https://github.com/Sertxito/spec-kit-extension-rag-azure-builder) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
@@ -120,7 +119,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
|
||||
| Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
|
||||
| Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) |
|
||||
| Spec Kit Memory | Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows | `docs` | Read+Write | [spec-kit-memory](https://github.com/zaytsevand/spec-kit-memory) |
|
||||
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
|
||||
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
|
||||
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |
|
||||
|
||||
@@ -11,7 +11,6 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning. | 10 templates, 2 commands | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
@@ -29,7 +28,6 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||
| Test-First Governance | Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates. | 10 templates, 8 commands | — | [spec-kit-preset-test-first-governance](https://github.com/ka-zo/spec-kit-preset-test-first-governance) |
|
||||
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
|
||||
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-10T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -1427,57 +1427,6 @@
|
||||
"created_at": "2026-05-06T00:00:00Z",
|
||||
"updated_at": "2026-05-06T00:00:00Z"
|
||||
},
|
||||
"gates": {
|
||||
"name": "Quality Gates (Enforcement Layer)",
|
||||
"id": "gates",
|
||||
"description": "Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity).",
|
||||
"author": "schwichtgit",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.1.0/gates-0.1.0.zip",
|
||||
"repository": "https://github.com/schwichtgit/spec-gates",
|
||||
"homepage": "https://github.com/schwichtgit/spec-gates",
|
||||
"documentation": "https://github.com/schwichtgit/spec-gates/blob/main/docs/how-it-works.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.12.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "jq",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "node",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "shellcheck",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"quality",
|
||||
"enforcement",
|
||||
"hooks",
|
||||
"ci",
|
||||
"governance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-09T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"github-issues": {
|
||||
"name": "GitHub Issues Integration 1",
|
||||
"id": "github-issues",
|
||||
@@ -2268,42 +2217,6 @@
|
||||
"created_at": "2026-05-08T00:00:00Z",
|
||||
"updated_at": "2026-05-08T00:00:00Z"
|
||||
},
|
||||
"memory": {
|
||||
"name": "Spec Kit Memory",
|
||||
"id": "memory",
|
||||
"description": "Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows.",
|
||||
"author": "Andrey Zaytsev",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/zaytsevand/spec-kit-memory/archive/refs/tags/v0.3.0.zip",
|
||||
"repository": "https://github.com/zaytsevand/spec-kit-memory",
|
||||
"homepage": "https://github.com/zaytsevand/spec-kit-memory",
|
||||
"documentation": "https://github.com/zaytsevand/spec-kit-memory/blob/main/README.md",
|
||||
"changelog": "",
|
||||
"license": "MIT",
|
||||
"category": "docs",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"tools": [
|
||||
{ "name": "memsearch", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"memory",
|
||||
"recall",
|
||||
"research",
|
||||
"memsearch"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-10T00:00:00Z"
|
||||
},
|
||||
"memory-loader": {
|
||||
"name": "Memory Loader",
|
||||
"id": "memory-loader",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-06-30T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -131,34 +131,6 @@
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
"id": "autonomous-run-governance",
|
||||
"version": "0.1.1",
|
||||
"description": "Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.1.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 10,
|
||||
"commands": 2
|
||||
},
|
||||
"tags": [
|
||||
"autonomous",
|
||||
"governance",
|
||||
"evidence",
|
||||
"permissions",
|
||||
"retrospective"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
"id": "canon-core",
|
||||
@@ -646,34 +618,6 @@
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"test-first-governance": {
|
||||
"name": "Test-First Governance",
|
||||
"id": "test-first-governance",
|
||||
"version": "1.3.0",
|
||||
"description": "Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates.",
|
||||
"author": "Zoltán Katona, PhD",
|
||||
"repository": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
|
||||
"download_url": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/archive/refs/tags/1.3.0.zip",
|
||||
"homepage": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
|
||||
"documentation": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.12.11"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 10,
|
||||
"commands": 8
|
||||
},
|
||||
"tags": [
|
||||
"tdd",
|
||||
"bdd",
|
||||
"atdd",
|
||||
"quality-gates",
|
||||
"traceability"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"toc-navigation": {
|
||||
"name": "Table of Contents Navigation",
|
||||
"id": "toc-navigation",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.14"
|
||||
version = "0.12.13"
|
||||
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"
|
||||
|
||||
@@ -220,45 +220,16 @@ def register(app: typer.Typer) -> None:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)"
|
||||
)
|
||||
console.print(
|
||||
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
|
||||
)
|
||||
if force:
|
||||
# Proceeding: the merge/overwrite warning is accurate here.
|
||||
console.print(
|
||||
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
|
||||
)
|
||||
console.print(
|
||||
"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]"
|
||||
)
|
||||
else:
|
||||
# Fold the merge risk into the confirmation prompt rather than
|
||||
# printing it unconditionally first: on the EOF/no-input path
|
||||
# below the command exits without changing anything, so a
|
||||
# standalone "will be merged" line would mislead. Interactive
|
||||
# users still see the risk as part of the question.
|
||||
#
|
||||
# Call typer.confirm normally so piped y/n is honored — e.g.
|
||||
# `echo y | specify init --here` keeps reaching the
|
||||
# non-destructive preserve-merge path.
|
||||
try:
|
||||
proceed = typer.confirm(
|
||||
"Template files will be merged with existing content "
|
||||
"and may overwrite existing files. Do you want to continue?"
|
||||
)
|
||||
except (typer.Abort, EOFError):
|
||||
# typer.confirm raises Abort for BOTH an interactive Ctrl+C
|
||||
# and an EOF on closed/empty stdin. Distinguish them: a real
|
||||
# TTY cancellation is a normal exit (0, "cancelled"), while a
|
||||
# missing-input EOF (non-interactive) becomes an actionable
|
||||
# error pointing at --force.
|
||||
if _stdin_is_interactive():
|
||||
console.print("[yellow]Operation cancelled[/yellow]")
|
||||
raise typer.Exit(0) from None
|
||||
console.print(
|
||||
"[red]Error:[/red] Current directory is not empty and no "
|
||||
"confirmation input is available. Re-run with "
|
||||
"[bold]--force[/bold] to merge into it."
|
||||
)
|
||||
raise typer.Exit(1) from None
|
||||
if not proceed:
|
||||
response = typer.confirm("Do you want to continue?")
|
||||
if not response:
|
||||
console.print("[yellow]Operation cancelled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
else:
|
||||
|
||||
@@ -190,15 +190,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
|
||||
"""
|
||||
import shlex
|
||||
parsed: dict[str, Any] = {}
|
||||
try:
|
||||
tokens = shlex.split(raw_options)
|
||||
except ValueError as exc:
|
||||
# An unbalanced quote (e.g. --integration-options='--commands-dir "foo')
|
||||
# makes shlex raise "No closing quotation". Translate it into the same
|
||||
# clean exit-1 UX as every other bad-input path below rather than
|
||||
# letting a raw traceback escape.
|
||||
console.print(f"[red]Error:[/red] Could not parse integration options: {exc}.")
|
||||
raise typer.Exit(1)
|
||||
tokens = shlex.split(raw_options)
|
||||
declared_options = list(integration.options())
|
||||
declared = {opt.name.lstrip("-"): opt for opt in declared_options}
|
||||
allowed = ", ".join(sorted(opt.name for opt in declared_options))
|
||||
|
||||
@@ -27,10 +27,3 @@ class KiroCliIntegration(MarkdownIntegration):
|
||||
"args": _KIRO_ARG_FALLBACK,
|
||||
"extension": ".md",
|
||||
}
|
||||
|
||||
# Kiro CLI keeps everything under a static, isolated agent root
|
||||
# (``.kiro/`` with commands in ``.kiro/prompts``) that no other
|
||||
# integration writes to, so it is safe to install alongside others
|
||||
# (issue #3471). The registry's multi-install-safe contract tests
|
||||
# enforce that isolation for every integration setting this flag.
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -2588,39 +2588,6 @@ class PresetResolver:
|
||||
self._manifest_cache[key] = None
|
||||
return self._manifest_cache[key]
|
||||
|
||||
def _manifest_declared_template(
|
||||
self, pack_dir: Path, template_name: str, template_type: str
|
||||
) -> tuple[dict | None, Path | None]:
|
||||
"""Resolve a preset's manifest-declared template entry and usable file.
|
||||
|
||||
Returns ``(entry, candidate)``:
|
||||
- ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if
|
||||
the manifest is absent or does not list this ``(name, type)``.
|
||||
- ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF
|
||||
it is a regular file (``is_file()``); ``None`` otherwise — a missing,
|
||||
empty, or non-file (e.g. directory) declaration yields ``(entry, None)``.
|
||||
|
||||
The manifest is authoritative: when it declares a template (``entry`` is
|
||||
not ``None``) but the file is unusable (``candidate`` is ``None``),
|
||||
callers must NOT fall back to the convention lookup — that would mask a
|
||||
typo or pick up an undeclared file. Shared by ``resolve()`` and
|
||||
``collect_all_layers()`` so their manifest-first resolution cannot
|
||||
silently diverge again (the divergence this fix addressed).
|
||||
"""
|
||||
manifest = self._get_manifest(pack_dir)
|
||||
if not manifest:
|
||||
return None, None
|
||||
for tmpl in manifest.templates:
|
||||
if tmpl.get("name") == template_name and tmpl.get("type") == template_type:
|
||||
file_path = tmpl.get("file")
|
||||
if file_path:
|
||||
manifest_candidate = pack_dir / file_path
|
||||
return tmpl, (
|
||||
manifest_candidate if manifest_candidate.is_file() else None
|
||||
)
|
||||
return tmpl, None
|
||||
return None, None
|
||||
|
||||
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
|
||||
"""Build unified list of registered and unregistered extensions sorted by priority.
|
||||
|
||||
@@ -2723,27 +2690,6 @@ class PresetResolver:
|
||||
registry = PresetRegistry(self.presets_dir)
|
||||
for pack_id, _metadata in registry.list_by_priority():
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
# The preset manifest is authoritative: if it declares this
|
||||
# template with an explicit ``file:``, resolve to that path —
|
||||
# and do NOT fall back to convention when it's missing, to
|
||||
# avoid masking typos or picking up an undeclared file. Only
|
||||
# when the manifest is absent or doesn't list this template do
|
||||
# we use the convention-based subdir lookup. Mirrors
|
||||
# collect_all_layers()/resolve_content() so resolve() and
|
||||
# resolve_with_source() agree with them instead of returning
|
||||
# the core template (or a stray convention file).
|
||||
entry, manifest_candidate = self._manifest_declared_template(
|
||||
pack_dir, template_name, template_type
|
||||
)
|
||||
if manifest_candidate is not None:
|
||||
return manifest_candidate
|
||||
if entry is not None:
|
||||
# Manifest declares this template but the file is missing,
|
||||
# non-file (e.g. a directory), or an empty/falsey ``file``
|
||||
# value. The manifest is authoritative, so skip this pack's
|
||||
# convention fallback rather than mask a typo — mirrors
|
||||
# collect_all_layers().
|
||||
continue
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = pack_dir / subdir / f"{template_name}{ext}"
|
||||
@@ -3011,22 +2957,31 @@ class PresetResolver:
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
# Read strategy and manifest file path from preset manifest
|
||||
strategy = "replace"
|
||||
manifest_file_path = None
|
||||
manifest_has_strategy = False
|
||||
entry, manifest_candidate = self._manifest_declared_template(
|
||||
pack_dir, template_name, template_type
|
||||
)
|
||||
if entry is not None:
|
||||
strategy = entry.get("strategy", "replace")
|
||||
manifest_has_strategy = "strategy" in entry
|
||||
# Use the manifest's declared file when it's a usable regular file;
|
||||
# only fall back to convention-based lookup when the manifest
|
||||
# doesn't list this template at all, so preset.yml stays
|
||||
# authoritative (a declared-but-unusable file skips convention —
|
||||
# parity with resolve()).
|
||||
manifest_found_entry = False
|
||||
manifest = self._get_manifest(pack_dir)
|
||||
if manifest:
|
||||
for tmpl in manifest.templates:
|
||||
if (tmpl.get("name") == template_name
|
||||
and tmpl.get("type") == template_type):
|
||||
strategy = tmpl.get("strategy", "replace")
|
||||
manifest_has_strategy = "strategy" in tmpl
|
||||
manifest_file_path = tmpl.get("file")
|
||||
manifest_found_entry = True
|
||||
break
|
||||
# Use manifest file path if specified, otherwise convention-based
|
||||
# lookup — but only when the manifest doesn't exist or doesn't
|
||||
# list this template, so preset.yml stays authoritative.
|
||||
candidate = None
|
||||
if manifest_candidate is not None:
|
||||
candidate = manifest_candidate
|
||||
elif entry is None:
|
||||
if manifest_file_path:
|
||||
manifest_candidate = pack_dir / manifest_file_path
|
||||
if manifest_candidate.exists():
|
||||
candidate = manifest_candidate
|
||||
# Explicit file path that doesn't exist: skip convention
|
||||
# fallback to avoid masking typos or picking up unintended files.
|
||||
elif not manifest_found_entry:
|
||||
# Manifest doesn't list this template — check convention paths
|
||||
candidate = _find_in_subdirs(pack_dir)
|
||||
if candidate:
|
||||
# Legacy fallback: if manifest doesn't explicitly declare a
|
||||
|
||||
@@ -31,20 +31,6 @@ class CommandStep(StepBase):
|
||||
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
|
||||
command = config.get("command", "")
|
||||
input_data = config.get("input", {})
|
||||
# validate() rejects a non-mapping input, but the engine does not
|
||||
# auto-validate before execute(); a workflow that skipped validation can
|
||||
# still reach here. Fail the step with the same contract error rather
|
||||
# than silently coercing to {} and dispatching with empty args — that
|
||||
# would change the command's meaning, hide the config error, and report
|
||||
# COMPLETED, defeating the per-step FAILED / continue_on_error behavior.
|
||||
if not isinstance(input_data, dict):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'input' must be a "
|
||||
f"mapping, got {type(input_data).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
# Resolve expressions in input
|
||||
resolved_input: dict[str, Any] = {}
|
||||
@@ -64,18 +50,8 @@ class CommandStep(StepBase):
|
||||
# Merge options (workflow defaults ← step overrides)
|
||||
options = dict(context.default_options)
|
||||
step_options = config.get("options", {})
|
||||
# Same rationale as 'input': a malformed options fails the step rather
|
||||
# than being silently ignored (which would let an invalid step run and
|
||||
# apparently complete).
|
||||
if not isinstance(step_options, dict):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'options' must be a "
|
||||
f"mapping, got {type(step_options).__name__}."
|
||||
),
|
||||
)
|
||||
options.update(step_options)
|
||||
if step_options:
|
||||
options.update(step_options)
|
||||
|
||||
# Attempt CLI dispatch
|
||||
args_str = str(resolved_input.get("args", ""))
|
||||
@@ -179,16 +155,4 @@ class CommandStep(StepBase):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r} is missing 'command' field."
|
||||
)
|
||||
# execute() iterates input.items() and options.update(step_options); a
|
||||
# non-mapping here would raise at run time. Validate the shape like the
|
||||
# sibling steps (switch 'cases', fan-out 'step') so it is reported, not
|
||||
# crashed on.
|
||||
if "input" in config and not isinstance(config["input"], dict):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'input' must be a mapping."
|
||||
)
|
||||
if "options" in config and not isinstance(config["options"], dict):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -24,24 +24,6 @@ class FanInStep(StepBase):
|
||||
if not isinstance(output_config, dict):
|
||||
output_config = {}
|
||||
|
||||
# The engine does not auto-validate step config, so an unvalidated run
|
||||
# with a non-list ``wait_for`` reaches here raw. Iterating it then
|
||||
# either crashes the whole run (a scalar like an int or None raises
|
||||
# TypeError) or, worse, silently iterates a string's characters and
|
||||
# yields a bogus join of empty results with a COMPLETED status — the
|
||||
# exact "silent empty result + COMPLETED" wiring bug the engine's
|
||||
# fan-in validation guards against. Fail this step loudly instead,
|
||||
# mirroring the fan-out step's non-list ``items`` handling.
|
||||
if not isinstance(wait_for, list):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Fan-in step {config.get('id', '?')!r}: 'wait_for' must be "
|
||||
f"a list of step IDs, got {type(wait_for).__name__}."
|
||||
),
|
||||
output={"results": []},
|
||||
)
|
||||
|
||||
# Collect results from referenced steps
|
||||
results = []
|
||||
for step_id in wait_for:
|
||||
|
||||
@@ -115,63 +115,6 @@ class TestInitIntegrationFlag:
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
|
||||
|
||||
def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path):
|
||||
"""`init --here` on a non-empty directory with no confirmation input (empty
|
||||
stdin) must fail fast with guidance to use --force, instead of the bare
|
||||
'Aborted.' from an EOF on typer.confirm. CliRunner with no `input=` provides
|
||||
empty stdin, so typer.confirm raises Abort, which the command converts to the
|
||||
actionable error."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "nonempty-here"
|
||||
project.mkdir()
|
||||
(project / "existing.txt").write_text("keep me", encoding="utf-8")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "--force" in result.output
|
||||
# Aborted before scaffolding: the pre-existing file is untouched.
|
||||
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
|
||||
|
||||
def test_init_here_interactive_cancel_exits_zero(self, tmp_path, monkeypatch):
|
||||
"""An interactive Ctrl+C at the merge confirmation (typer.Abort on a TTY)
|
||||
is a normal cancellation — exit 0, "cancelled" — NOT the missing-input
|
||||
--force error, which is reserved for non-interactive EOF. Guards the
|
||||
regression where Abort was caught unconditionally and every cancel became
|
||||
an exit-1 --force error."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
import specify_cli.commands.init as init_mod
|
||||
|
||||
# Simulate an interactive terminal so the Abort is treated as a cancel.
|
||||
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
|
||||
|
||||
project = tmp_path / "cancel-here"
|
||||
project.mkdir()
|
||||
(project / "existing.txt").write_text("keep me", encoding="utf-8")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
# No input → typer.confirm raises Abort (stands in for Ctrl+C).
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "cancelled" in result.output.lower()
|
||||
assert "--force" not in result.output # not the missing-input error
|
||||
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
|
||||
|
||||
def test_integration_copilot_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
@@ -892,8 +835,7 @@ class TestInitIntegrationFlag:
|
||||
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
||||
|
||||
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
|
||||
"""E2E: confirming the merge with piped "y" (no --force) preserves
|
||||
existing shared infra files (unlike --force, which overwrites them)."""
|
||||
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
|
||||
@@ -2675,27 +2675,6 @@ class TestParseIntegrationOptionsEqualsForm:
|
||||
assert result_space["commands_dir"] == "./mydir"
|
||||
assert result_equals["commands_dir"] == "./mydir"
|
||||
|
||||
def test_unbalanced_quote_exits_cleanly(self, capsys):
|
||||
"""An unbalanced quote must exit(1) with a message, not a raw ValueError.
|
||||
|
||||
shlex.split() raises ValueError("No closing quotation") on an unbalanced
|
||||
quote; the parser must translate that into the same clean typer.Exit(1)
|
||||
UX as unknown-option / missing-value, rather than letting the traceback
|
||||
escape (issue #3457).
|
||||
"""
|
||||
import typer
|
||||
|
||||
from specify_cli.integrations._commands import _parse_integration_options
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
integration = get_integration("generic")
|
||||
assert integration is not None
|
||||
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
_parse_integration_options(integration, '--commands-dir "foo')
|
||||
assert excinfo.value.exit_code == 1
|
||||
assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestUninstallNoManifestClearsInitOptions:
|
||||
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):
|
||||
|
||||
@@ -272,20 +272,6 @@ class TestMultiInstallSafeContracts:
|
||||
f"these files: {sorted(overlap)}"
|
||||
)
|
||||
|
||||
def test_kiro_cli_is_declared_multi_install_safe(self):
|
||||
"""kiro-cli confines itself to an isolated ``.kiro/`` root that no
|
||||
other integration touches, so it must be declared multi-install safe
|
||||
(issue #3471).
|
||||
|
||||
Before the fix, co-installing kiro-cli alongside another integration
|
||||
left ``specify integration status`` permanently in ERROR
|
||||
(``unsafe-multi-install``) with no way to acknowledge it. The
|
||||
parametrized isolation/manifest contracts above already exercise
|
||||
kiro-cli once the flag is set; this pins the declaration itself so a
|
||||
future edit cannot silently drop it and reintroduce the error.
|
||||
"""
|
||||
assert INTEGRATION_REGISTRY["kiro-cli"].multi_install_safe is True
|
||||
|
||||
|
||||
class TestCatalogParity:
|
||||
"""The discovery catalog must list every registered integration."""
|
||||
|
||||
@@ -884,186 +884,6 @@ class TestPresetResolver:
|
||||
assert result is not None
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
|
||||
def _install_pack_with_manifest_file(self, project_dir, *, extra_file=False):
|
||||
"""Create a pack whose manifest declares a NON-convention file: path.
|
||||
|
||||
Returns the pack dir under the project. The declared file lives at
|
||||
custom/spec.md (not the convention templates/spec-template.md).
|
||||
"""
|
||||
presets_dir = project_dir / ".specify" / "presets"
|
||||
pack_dir = presets_dir / "mypack"
|
||||
(pack_dir / "custom").mkdir(parents=True)
|
||||
(pack_dir / "custom" / "spec.md").write_text(
|
||||
"# Manifest-declared Spec\n", encoding="utf-8"
|
||||
)
|
||||
if extra_file:
|
||||
# An undeclared convention-path file the manifest points away from.
|
||||
(pack_dir / "templates").mkdir()
|
||||
(pack_dir / "templates" / "spec-template.md").write_text(
|
||||
"# Stray Convention Spec\n", encoding="utf-8"
|
||||
)
|
||||
manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "mypack",
|
||||
"name": "My Pack",
|
||||
"version": "1.0.0",
|
||||
"description": "declares a non-convention file path",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "custom/spec.md",
|
||||
"strategy": "replace",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(pack_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest, f)
|
||||
PresetRegistry(presets_dir).add(
|
||||
"mypack", {"version": "1.0.0", "priority": 10}
|
||||
)
|
||||
return pack_dir
|
||||
|
||||
def test_resolve_uses_manifest_declared_file_path(self, project_dir):
|
||||
"""resolve() must honor a manifest-declared non-convention file: path.
|
||||
|
||||
Previously the tier-2 loop was convention-only, so it returned the
|
||||
core template and resolve_with_source() misattributed source='core',
|
||||
diverging from collect_all_layers()/resolve_content().
|
||||
"""
|
||||
pack_dir = self._install_pack_with_manifest_file(project_dir)
|
||||
resolver = PresetResolver(project_dir)
|
||||
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result == pack_dir / "custom" / "spec.md"
|
||||
assert "Manifest-declared Spec" in result.read_text()
|
||||
|
||||
sourced = resolver.resolve_with_source("spec-template")
|
||||
assert sourced is not None
|
||||
assert "mypack" in sourced["source"]
|
||||
# resolve() must agree with collect_all_layers()'s top layer.
|
||||
layers = resolver.collect_all_layers("spec-template")
|
||||
assert Path(layers[0]["path"]) == pack_dir / "custom" / "spec.md"
|
||||
|
||||
def test_resolve_manifest_file_wins_over_undeclared_convention_file(
|
||||
self, project_dir
|
||||
):
|
||||
"""A stray convention-path file must not shadow the manifest's file:."""
|
||||
pack_dir = self._install_pack_with_manifest_file(
|
||||
project_dir, extra_file=True
|
||||
)
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result == pack_dir / "custom" / "spec.md"
|
||||
assert "Manifest-declared Spec" in result.read_text()
|
||||
|
||||
def test_resolve_skips_convention_when_manifest_file_missing(self, project_dir):
|
||||
"""When the manifest declares a file: that does not exist, resolve()
|
||||
must NOT fall back to a convention file in the same pack (that would
|
||||
mask a typo) — it skips the pack and resolves core instead."""
|
||||
presets_dir = project_dir / ".specify" / "presets"
|
||||
pack_dir = presets_dir / "mypack"
|
||||
# Manifest declares custom/spec.md (MISSING); a convention file exists
|
||||
# in the pack and must NOT be used.
|
||||
(pack_dir / "templates").mkdir(parents=True)
|
||||
(pack_dir / "templates" / "spec-template.md").write_text(
|
||||
"# Stray Convention Spec\n", encoding="utf-8"
|
||||
)
|
||||
manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "mypack",
|
||||
"name": "My Pack",
|
||||
"version": "1.0.0",
|
||||
"description": "declares a missing file path",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "custom/spec.md",
|
||||
"strategy": "replace",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(pack_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest, f)
|
||||
PresetRegistry(presets_dir).add(
|
||||
"mypack", {"version": "1.0.0", "priority": 10}
|
||||
)
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
content = result.read_text()
|
||||
assert "Stray Convention Spec" not in content # pack convention skipped
|
||||
assert "Core Spec Template" in content # fell through to core
|
||||
|
||||
def test_resolve_skips_convention_when_manifest_file_is_directory(
|
||||
self, project_dir
|
||||
):
|
||||
"""When the manifest's file: path resolves to a DIRECTORY (not a regular
|
||||
file), resolve()/collect_all_layers() must treat it as missing — exists()
|
||||
would accept it and downstream read_text() on a directory would crash.
|
||||
The pack is skipped (no convention fallback), so core wins."""
|
||||
presets_dir = project_dir / ".specify" / "presets"
|
||||
pack_dir = presets_dir / "mypack"
|
||||
# Declared file: custom/spec.md is created as a DIRECTORY.
|
||||
(pack_dir / "custom" / "spec.md").mkdir(parents=True)
|
||||
# A convention file also exists and must NOT be used.
|
||||
(pack_dir / "templates").mkdir(parents=True)
|
||||
(pack_dir / "templates" / "spec-template.md").write_text(
|
||||
"# Stray Convention Spec\n", encoding="utf-8"
|
||||
)
|
||||
manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "mypack",
|
||||
"name": "My Pack",
|
||||
"version": "1.0.0",
|
||||
"description": "declares a file: that is actually a directory",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "custom/spec.md",
|
||||
"strategy": "replace",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(pack_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest, f)
|
||||
PresetRegistry(presets_dir).add(
|
||||
"mypack", {"version": "1.0.0", "priority": 10}
|
||||
)
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert result.is_file() # never a directory
|
||||
content = result.read_text()
|
||||
assert "Stray Convention Spec" not in content # pack convention skipped
|
||||
assert "Core Spec Template" in content # fell through to core
|
||||
# collect_all_layers() must agree: the directory is not a layer.
|
||||
layers = resolver.collect_all_layers("spec-template")
|
||||
assert all(Path(layer["path"]).is_file() for layer in layers)
|
||||
assert all(
|
||||
Path(layer["path"]) != pack_dir / "custom" / "spec.md"
|
||||
for layer in layers
|
||||
)
|
||||
|
||||
def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir):
|
||||
"""Test that overrides take priority over installed packs."""
|
||||
# Install the pack
|
||||
|
||||
@@ -953,33 +953,6 @@ class TestCommandStep:
|
||||
errors = step.validate({"id": "test"})
|
||||
assert any("missing 'command'" in e for e in errors)
|
||||
|
||||
def test_validate_rejects_non_mapping_input_and_options(self):
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = CommandStep()
|
||||
# execute() does input.items() / options.update(); a non-mapping must be
|
||||
# reported by validate(), not crash at run time (like switch 'cases').
|
||||
for bad in (None, "args", ["a", "b"], 5):
|
||||
errs = step.validate({"id": "c", "command": "/x", "input": bad})
|
||||
assert any("'input' must be a mapping" in e for e in errs), bad
|
||||
errs = step.validate({"id": "c", "command": "/x", "options": 42})
|
||||
assert any("'options' must be a mapping" in e for e in errs)
|
||||
# a valid mapping config is still accepted
|
||||
assert step.validate({"id": "c", "command": "/x", "input": {"args": "y"}, "options": {"k": 1}}) == []
|
||||
# execute() has no auto-validation guarantee (the engine may skip
|
||||
# validate), so a non-mapping input/options FAILS the step with the same
|
||||
# contract error — it does not silently coerce to empty and report
|
||||
# COMPLETED (which would defeat continue_on_error).
|
||||
res_in = step.execute({"id": "c", "command": "echo", "input": None}, StepContext())
|
||||
assert res_in.status is StepStatus.FAILED
|
||||
assert "'input' must be a mapping" in (res_in.error or "")
|
||||
res_opt = step.execute(
|
||||
{"id": "c", "command": "echo", "input": {}, "options": 42}, StepContext()
|
||||
)
|
||||
assert res_opt.status is StepStatus.FAILED
|
||||
assert "'options' must be a mapping" in (res_opt.error or "")
|
||||
|
||||
def test_step_override_integration(self):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
@@ -2502,29 +2475,6 @@ class TestFanInStep:
|
||||
result = step.execute(config, ctx)
|
||||
assert result.output["results"] == [{}]
|
||||
|
||||
@pytest.mark.parametrize("bad_wait_for", ["stepA", 5, None, {"a": 1}])
|
||||
def test_execute_non_list_wait_for_fails_loudly(self, bad_wait_for):
|
||||
"""A non-list ``wait_for`` must fail the step, not crash the run or
|
||||
silently produce a bogus join.
|
||||
|
||||
``validate`` rejects a non-list ``wait_for``, but the engine's
|
||||
``execute()`` does not auto-validate. Before the guard, ``execute``
|
||||
iterated the raw value: a scalar (int/None) raised TypeError and took
|
||||
down the whole run, while a string silently iterated its characters and
|
||||
returned a join of empty results with a COMPLETED status — the exact
|
||||
"silent empty result + COMPLETED" wiring bug the engine's fan-in
|
||||
validation warns against. Mirrors the fan-out non-list ``items`` guard.
|
||||
"""
|
||||
from specify_cli.workflows.steps.fan_in import FanInStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = FanInStep()
|
||||
ctx = StepContext(steps={"a": {"output": {"x": 1}}})
|
||||
result = step.execute({"id": "collect", "wait_for": bad_wait_for}, ctx)
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert "'wait_for' must be a list" in (result.error or "")
|
||||
assert result.output["results"] == []
|
||||
|
||||
def test_validate_empty_wait_for(self):
|
||||
from specify_cli.workflows.steps.fan_in import FanInStep
|
||||
|
||||
|
||||
Reference in New Issue
Block a user