Compare commits

..
1 Commits
Author SHA1 Message Date
github-actions[bot] 0759cc4750 chore: bump version to 0.12.13 2026-07-13 18:08:07 +00:00
32 changed files with 331 additions and 8399 deletions
-31
View File
@@ -2,37 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.12.15] - 2026-07-14
### Changed
- Update Autonomous Run Governance preset to v0.1.4 (#3511)
- fix(workflows): raise catalog error, not raw ValueError, on a malformed catalog URL (#3484)
- fix(workflows): evaluate 'in'/'not in' safely on a non-iterable right operand (#3447) (#3468)
- fix: add trailing newline to init-options.json output (#3509)
- feat(workflows): align workflow CLI with extension command surface (#3419)
- fix(extensions): stop env-var config leaking across prefix-colliding extension IDs (#3497)
- fix(integrations): escape control characters in goose recipe YAML renderer (#3384)
- [extension] Update DocGuard — CDD Enforcement extension to v0.32.0 (#3489)
- [extension] Add Multi-Repo Branch Sync extension to community catalog (#3411)
- chore: release 0.12.14, begin 0.12.15.dev0 development (#3506)
## [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
+1 -4
View File
@@ -51,7 +51,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
| DocGuard — CDD Enforcement | Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
@@ -84,7 +84,6 @@ The following community-contributed extensions are available in [`catalog.commun
| MemoryLint | Evidence-driven instruction drift checker: audits agent memory files for boundary, reality, conflict, and redundancy drift. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
| Multi-Repo Branch Sync | Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks | `process` | Read+Write | [multi-repo-sync](https://github.com/fyloss/spec-kit-multi-repo-sync) |
| Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) |
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
@@ -99,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) |
@@ -121,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) |
-2
View File
@@ -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. | 12 templates, 2 commands, 2 scripts | — | [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) |
+3 -26
View File
@@ -86,30 +86,8 @@ Lists workflows installed in the current project.
specify workflow add <source>
```
| Option | Description |
| --------------- | ------------------------------------------------------ |
| `--dev` | Install from a local workflow YAML file or directory |
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
Installs a workflow from the catalog, a URL (HTTPS required), or a local file path.
## Update Workflows
```bash
specify workflow update [workflow_id]
```
Updates one installed catalog workflow — or all of them when no ID is given — to the latest catalog version. Prompts for confirmation and keeps the installed copy if a download or validation fails.
## Enable or Disable a Workflow
```bash
specify workflow enable <workflow_id>
specify workflow disable <workflow_id>
```
Disabled workflows stay installed and listed (marked `[disabled]`) but refuse to run until re-enabled.
## Remove a Workflow
```bash
@@ -124,10 +102,9 @@ Removes an installed workflow from the project.
specify workflow search [query]
```
| Option | Description |
| ---------- | ----------------- |
| `--tag` | Filter by tag |
| `--author` | Filter by author |
| Option | Description |
| ------- | --------------- |
| `--tag` | Filter by tag |
Searches all active catalogs for workflows matching the query.
+5 -142
View File
@@ -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": {
@@ -1106,10 +1106,10 @@
"docguard": {
"name": "DocGuard — CDD Enforcement",
"id": "docguard",
"description": "Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js.",
"description": "The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep.",
"author": "raccioly",
"version": "0.32.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.32.0/spec-kit-docguard-v0.32.0.zip",
"version": "0.30.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
"repository": "https://github.com/raccioly/docguard",
"homepage": "https://www.npmjs.com/package/docguard-cli",
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
@@ -1124,14 +1124,6 @@
"name": "node",
"version": ">=18.0.0",
"required": true
},
{
"name": "npx",
"required": true
},
{
"name": "specify",
"required": false
}
]
},
@@ -1153,7 +1145,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
"updated_at": "2026-07-06T00:00:00Z"
},
"doctor": {
"name": "Project Health Check",
@@ -1435,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",
@@ -2276,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",
@@ -2466,48 +2371,6 @@
"created_at": "2026-05-04T02:51:52Z",
"updated_at": "2026-06-18T00:00:00Z"
},
"multi-repo-sync": {
"name": "Multi-Repo Branch Sync",
"id": "multi-repo-sync",
"description": "Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks",
"author": "Fyloss",
"version": "1.0.0",
"download_url": "https://github.com/fyloss/spec-kit-multi-repo-sync/releases/download/v1.0.0/spec-kit-multi-repo-sync.zip",
"sha256": "12a5c7392145b4424b20715aaa3d8b6a8218c143dea596873e344146c1a76ba0",
"repository": "https://github.com/fyloss/spec-kit-multi-repo-sync",
"homepage": "https://github.com/fyloss/spec-kit-multi-repo-sync",
"documentation": "https://github.com/fyloss/spec-kit-multi-repo-sync/blob/main/README.md",
"changelog": "https://github.com/fyloss/spec-kit-multi-repo-sync/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0",
"tools": [
{
"name": "git",
"version": ">=2.31",
"required": true
}
]
},
"provides": {
"commands": 3,
"hooks": 2
},
"tags": [
"git",
"branching",
"multi-repo",
"submodules",
"workflow"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"multi-sites": {
"name": "Multi-Sites Spec Kit",
"id": "multi-sites",
+1 -58
View File
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-14T00: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,35 +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.4",
"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.4.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.1.4/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 12,
"commands": 2,
"scripts": 2
},
"tags": [
"autonomous",
"governance",
"evidence",
"permissions",
"retrospective"
],
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-14T00:00:00Z"
},
"canon-core": {
"name": "Canon Core",
"id": "canon-core",
@@ -647,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 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.12.15"
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"
+5 -30
View File
@@ -24,7 +24,6 @@ GITHUB_HOSTS = frozenset({
"api.github.com",
"codeload.github.com",
})
_MAX_RELEASE_METADATA_BYTES = 5 * 1024 * 1024
def build_github_request(url: str) -> urllib.request.Request:
@@ -69,8 +68,6 @@ def resolve_github_release_asset_api_url(
open_url_fn: Callable,
timeout: int = 60,
github_hosts: tuple[str, ...] = (),
redirect_validator: Callable[[str, str], None] | None = None,
max_metadata_bytes: int = _MAX_RELEASE_METADATA_BYTES,
) -> Optional[str]:
"""Resolve a GitHub release browser-download URL to its REST API asset URL.
@@ -94,8 +91,6 @@ def resolve_github_release_asset_api_url(
authenticated release-metadata lookup.
timeout: Per-request timeout in seconds.
github_hosts: Host patterns to treat as GitHub Enterprise Server.
redirect_validator: Optional policy applied to metadata redirects.
max_metadata_bytes: Maximum release-metadata response size.
"""
import json
import urllib.error
@@ -154,33 +149,13 @@ def resolve_github_release_asset_api_url(
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
try:
open_kwargs = {"timeout": timeout}
if redirect_validator is not None:
open_kwargs["redirect_validator"] = redirect_validator
with open_url_fn(release_url, **open_kwargs) as response:
raw_release_data = response.read(max_metadata_bytes + 1)
if len(raw_release_data) > max_metadata_bytes:
raise ValueError("GitHub release metadata exceeds size limit")
release_data = json.loads(raw_release_data)
except (
urllib.error.URLError,
json.JSONDecodeError,
TypeError,
ValueError,
):
with open_url_fn(release_url, timeout=timeout) as response:
release_data = json.loads(response.read())
except (urllib.error.URLError, json.JSONDecodeError):
return None
if not isinstance(release_data, dict):
return None
assets = release_data.get("assets", [])
if not isinstance(assets, list):
return None
for asset in assets:
if (
isinstance(asset, dict)
and asset.get("name") == asset_name
and asset.get("url")
):
for asset in release_data.get("assets", []):
if asset.get("name") == asset_name and asset.get("url"):
return str(asset["url"])
return None
+1 -1
View File
@@ -14,7 +14,7 @@ def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
dest = project_path / INIT_OPTIONS_FILE
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
encoding="utf-8",
)
+2 -50
View File
@@ -10,8 +10,6 @@ from __future__ import annotations
import json
import os
import re
import stat
import tempfile
from pathlib import Path, PurePosixPath
from typing import Any
@@ -89,63 +87,17 @@ def loads_json(text: str, *, origin: str = "<string>") -> Any:
def dump_json(path: Path, data: Any, *, within: Path | None = None) -> Path:
"""Atomically write pretty JSON to *path* (optionally confined to *within*)."""
"""Write *data* as pretty JSON to *path* (optionally confined to *within*)."""
path = Path(path)
if within is not None:
path = ensure_within(within, path)
fd = -1
temp_path: Path | None = None
try:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
with os.fdopen(os.dup(fd), "w", encoding="utf-8") as handle:
with path.open("w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, sort_keys=False)
handle.write("\n")
try:
if path.exists():
existing = path.stat(follow_symlinks=False)
if stat.S_ISREG(existing.st_mode) and hasattr(os, "fchmod"):
os.fchmod(fd, stat.S_IMODE(existing.st_mode))
if stat.S_ISREG(existing.st_mode) and hasattr(os, "fchown"):
try:
os.fchown(fd, existing.st_uid, existing.st_gid)
except PermissionError:
pass
except OSError:
pass
staged = os.stat(temp_path, follow_symlinks=False)
opened = os.fstat(fd)
if (
not stat.S_ISREG(staged.st_mode)
or staged.st_dev != opened.st_dev
or staged.st_ino != opened.st_ino
):
raise OSError("staged JSON file changed before commit")
os.close(fd)
fd = -1
os.replace(temp_path, path)
temp_path = None
except OSError as exc:
raise BundlerError(f"Could not write {path}: {exc}") from exc
finally:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
if temp_path is not None:
try:
temp_path.unlink(missing_ok=True)
except OSError:
pass
return path
+10 -32
View File
@@ -187,41 +187,19 @@ def remove_bundle(
still_needed = components_still_needed(records, exclude_bundle_id=bundle_id)
result = InstallResult(bundle_id=bundle_id)
remove_attempted = False
try:
for component in target.contributed_components:
key = (component.kind, component.id)
if key in still_needed:
result.skipped.append(component)
continue
if installer.is_installed(project_root, component):
remove_attempted = True
installer.remove(project_root, component)
result.uninstalled.append(component)
save_records(project_root, remove_record(records, bundle_id))
except Exception as exc: # noqa: BLE001
if result.uninstalled:
detail = (
f"{len(result.uninstalled)} component(s) were already removed "
"before this failure; the bundle record was left unchanged, "
"so the project may be partially uninstalled."
)
elif remove_attempted:
detail = (
"No components were removed, but the failing component may "
"have made partial changes before raising, so the project "
"may be partially uninstalled."
)
for component in target.contributed_components:
key = (component.kind, component.id)
if key in still_needed:
result.skipped.append(component)
continue
if installer.is_installed(project_root, component):
installer.remove(project_root, component)
result.uninstalled.append(component)
else:
detail = (
"No components were removed and no removal was attempted; "
"the bundle record was left unchanged."
)
raise BundlerError(
f"Failed to remove bundle '{bundle_id}': {exc}. {detail}"
) from exc
result.skipped.append(component)
save_records(project_root, remove_record(records, bundle_id))
return result
+5 -34
View File
@@ -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:
+1 -65
View File
@@ -2737,36 +2737,6 @@ class ConfigManager:
config_file = self.extension_dir / "local-config.yml"
return self._load_yaml_config(config_file)
def _sibling_extension_ids(self) -> list[str]:
"""Return IDs of other extensions installed alongside this one.
Sourced from ``ExtensionRegistry`` (``.specify/extensions/.registry``)
rather than a directory scan: ``ExtensionManager.remove(...,
keep_config=True)`` deliberately preserves the extension directory
while dropping the registry entry, so a directory scan would treat
that config-only leftover as an installed sibling and keep silently
absorbing its ``SPECKIT_<sibling>_*`` env vars into no one. The
registry is the source of truth for "installed".
Returns an empty list if the registry is missing or corrupted
(fresh project, ad-hoc test harness) so ``_get_env_config`` degrades
to its pre-fix behaviour rather than crashing. ``UnicodeError`` is
caught alongside ``OSError`` because ``ExtensionRegistry._load()``
opens the file in text mode and only handles ``JSONDecodeError`` /
``FileNotFoundError``, so a registry file with non-UTF-8 bytes would
otherwise surface a ``UnicodeDecodeError`` here and break *every*
config read instead of degrading gracefully.
Used by ``_get_env_config`` to detect env vars whose remainder claims
a longer, sibling-owned prefix (e.g. ``SPECKIT_GIT_HOOKS_URL`` is
owned by ``git-hooks`` when it is co-installed with ``git``).
"""
extensions_dir = self.project_root / ".specify" / "extensions"
try:
return list(ExtensionRegistry(extensions_dir).keys())
except (OSError, UnicodeError):
return []
def _get_env_config(self) -> Dict[str, Any]:
"""Get configuration from environment variables.
@@ -2786,49 +2756,15 @@ class ConfigManager:
ext_id_upper = self.extension_id.replace("-", "_").upper()
prefix = f"SPECKIT_{ext_id_upper}_"
# Cross-extension prefix collision: because ``_`` doubles as both the
# separator between the extension ID and the config path *and* the
# substitute for ``-`` inside an extension ID, an env var like
# ``SPECKIT_GIT_HOOKS_URL`` begins with *both* the ``SPECKIT_GIT_``
# prefix of the ``git`` extension and the ``SPECKIT_GIT_HOOKS_`` prefix
# of a co-installed ``git-hooks`` extension. It logically belongs to
# the extension whose normalized ID is the longer, more specific match
# — otherwise config intended for one extension silently surfaces
# inside another and can drive hooks that only inspect
# ``config.<field> is set``. Build the list of sibling-owned
# remainder-prefixes here so a later env var can be skipped if it
# matches one.
sibling_prefixes: list[str] = []
for sibling_id in self._sibling_extension_ids():
if sibling_id == self.extension_id:
continue
sib_upper = sibling_id.replace("-", "_").upper()
# A sibling collides only when its normalized ID *extends* our own
# (i.e. starts with ``<US>_``). ``git`` vs ``not-git`` is not a
# collision; ``git`` vs ``git-hooks`` is.
if sib_upper.startswith(ext_id_upper + "_"):
# The portion of the env-var *remainder* the sibling claims,
# including the trailing ``_`` so a shorter ID that shares a
# non-boundary prefix cannot false-positive (e.g. sibling
# ``hook`` would not eat env vars under key ``hooks``).
sibling_prefixes.append(sib_upper[len(ext_id_upper) + 1 :] + "_")
for key, value in os.environ.items():
if not key.startswith(prefix):
continue
remainder = key[len(prefix) :]
# Skip when a longer sibling ID claims this var — see the block
# above. Keeps ``SPECKIT_GIT_HOOKS_URL`` out of the ``git``
# extension's config when ``git-hooks`` is co-installed.
if any(remainder.startswith(sp) for sp in sibling_prefixes):
continue
# Remove prefix and split into parts. Drop empty components from a
# malformed name (e.g. ``SPECKIT_<EXT>_`` with no key, or
# consecutive underscores ``SPECKIT_X__Y``) so we never create an
# entry under an empty key.
config_path = [p for p in remainder.lower().split("_") if p]
config_path = [p for p in key[len(prefix) :].lower().split("_") if p]
if not config_path:
continue
+1 -9
View File
@@ -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))
+3 -31
View File
@@ -1122,17 +1122,6 @@ class TomlIntegration(IntegrationBase):
# YamlIntegration — YAML-format agents (Goose)
# ---------------------------------------------------------------------------
# Characters a YAML literal block scalar cannot carry: C0 controls other
# than tab/LF (a bare CR acts as a line break inside the scalar), DEL, the
# C1 range, lone UTF-16 surrogates, and the non-characters U+FFFE/U+FFFF.
# NEL (U+0085) is YAML-printable but, like LS/PS (U+2028/U+2029), YAML 1.1
# treats it as a line break, which corrupts the block scalar's structure
# just the same, so all three are included.
_YAML_BLOCK_SCALAR_UNSAFE = re.compile(
r"[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029\ud800-\udfff\ufffe\uffff]"
)
class YamlIntegration(IntegrationBase):
"""Concrete base for integrations that use YAML recipe format.
@@ -1238,9 +1227,9 @@ class YamlIntegration(IntegrationBase):
def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str:
"""Render a YAML recipe file from title, description, and body.
Produces a Goose-compatible recipe with a literal block scalar for
normal prompt content, or an escaped quoted scalar when control
characters require it. Uses ``yaml.safe_dump()`` for the header fields.
Produces a Goose-compatible recipe with a literal block scalar
for the prompt content. Uses ``yaml.safe_dump()`` for the
header fields to ensure proper escaping.
"""
header = cls._build_yaml_header(title, description)
@@ -1251,23 +1240,6 @@ class YamlIntegration(IntegrationBase):
default_flow_style=False,
).strip()
# YAML forbids C0 control characters (except tab and newline) and
# DEL in every scalar form, and a bare CR acts as a line break
# inside a block scalar. A literal block scalar emits such bytes
# verbatim, producing a recipe the YAML parser rejects, so fall
# back to an escaped double-quoted scalar for those bodies.
if _YAML_BLOCK_SCALAR_UNSAFE.search(body):
prompt_yaml = yaml.safe_dump(
{"prompt": body}, allow_unicode=True, default_style='"', width=sys.maxsize
).strip()
lines = [
header_yaml,
prompt_yaml,
"",
f"# Source: {source_id}",
]
return "\n".join(lines) + "\n"
# Indent the body for YAML block scalar. Use an explicit indentation
# indicator ("|2") rather than a bare "|": YAML infers a plain block
# scalar's indentation from its first non-empty line, so a body whose
@@ -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
+23 -68
View File
@@ -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
File diff suppressed because it is too large Load Diff
+30 -215
View File
@@ -13,8 +13,6 @@ from __future__ import annotations
import hashlib
import json
import os
import stat
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
@@ -73,180 +71,48 @@ class WorkflowRegistry:
self.registry_path = self.workflows_dir / self.REGISTRY_FILE
self.data = self._load()
def _has_symlinked_parent(self) -> bool:
"""Return True if any directory under .specify/workflows is a symlink."""
current = self.project_root
for part in (".specify", "workflows"):
current = current / part
if current.is_symlink():
return True
return False
def _load(self) -> dict[str, Any]:
"""Load registry from disk or create default."""
default_registry: dict[str, Any] = {
"schema_version": self.SCHEMA_VERSION,
"workflows": {},
}
# Defense-in-depth: refuse to read through symlinked parents or a
# symlinked registry file. Unlike StepRegistry (read-only best-effort
# elsewhere), a fabricated empty registry here is not safe: read-only
# callers (notably the bundler's remove path) query is_installed()
# before ever writing, and would otherwise conclude an installed
# workflow is absent, skip removing it, then delete the bundle
# record -- leaving the workflow untracked but still on disk. Fail
# closed here just like the unreadable-file case below.
if self._has_symlinked_parent() or self.registry_path.is_symlink():
raise OSError(
f"Refusing to read workflow registry at {self.registry_path}: "
"a parent directory or the registry file itself is a symlink"
)
if self.registry_path.exists():
try:
with open(self.registry_path, encoding="utf-8") as f:
data = json.load(f)
except OSError as exc:
# The real data may still be intact on disk. Fail closed at
# construction rather than fabricating an empty registry that
# a read-only caller could mistake for "nothing installed."
raise OSError(
f"Failed to read workflow registry at {self.registry_path}: {exc}"
) from exc
except (
json.JSONDecodeError,
ValueError,
UnicodeError,
) as exc:
raise OSError(
f"Workflow registry at {self.registry_path} is corrupted: "
f"{exc}"
) from exc
# Validate shape: must be a dict with a dict "workflows" field.
if not isinstance(data, dict):
raise OSError(
f"Workflow registry at {self.registry_path} is corrupted: "
"top-level value must be an object"
)
if not isinstance(data.get("workflows"), dict):
raise OSError(
f"Workflow registry at {self.registry_path} is corrupted: "
"'workflows' must be an object"
)
return data
return default_registry
# Validate shape: must be a dict with a dict "workflows" field,
# otherwise every method that indexes data["workflows"] crashes.
# Mirrors StepRegistry._load.
if not isinstance(data, dict):
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
if not isinstance(data.get("workflows"), dict):
data["workflows"] = {}
return data
except (json.JSONDecodeError, ValueError, OSError, UnicodeError):
# Corrupted registry file — reset to default
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
def save(self) -> None:
"""Persist registry to disk atomically."""
# Refuse to write through symlinked parents (mirrors StepRegistry.save
# and the CLI-level _reject_unsafe_dir guard).
if self._has_symlinked_parent() or self.registry_path.is_symlink():
raise OSError(
"Refusing to write workflow registry through a symlinked path."
)
"""Persist registry to disk."""
self.workflows_dir.mkdir(parents=True, exist_ok=True)
# Unique, exclusive temp then replace: a failed dump cannot truncate
# the registry, a pre-created symlink cannot redirect the write, and
# concurrent CLI processes cannot collide on the same temp path.
fd, tmp = tempfile.mkstemp(
dir=str(self.registry_path.parent),
prefix=f".{self.registry_path.name}.",
suffix=".tmp",
)
try:
# Write through a duplicate so the exclusive mkstemp descriptor
# stays open for fd-based metadata updates and inode verification.
with os.fdopen(os.dup(fd), "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
# mkstemp creates the temp file at 0600. A pre-existing registry
# may be shared more permissively (e.g. 0640/0644); preserve its
# mode across the replace so a save doesn't silently lock other
# project users out. A brand-new registry has no prior mode to
# preserve, so mkstemp's secure 0600 default stands. Mirrors
# _utils.py's atomic_write_json (best-effort; data safety over
# metadata preservation).
try:
if self.registry_path.exists():
existing_stat = self.registry_path.stat(
follow_symlinks=False
)
if stat.S_ISREG(existing_stat.st_mode) and hasattr(
os, "fchmod"
):
os.fchmod(fd, stat.S_IMODE(existing_stat.st_mode))
if stat.S_ISREG(existing_stat.st_mode) and hasattr(
os, "fchown"
):
try:
os.fchown(
fd, existing_stat.st_uid, existing_stat.st_gid
)
except PermissionError:
pass
except OSError:
pass
staged_stat = os.stat(tmp, follow_symlinks=False)
open_stat = os.fstat(fd)
if (
not stat.S_ISREG(staged_stat.st_mode)
or staged_stat.st_dev != open_stat.st_dev
or staged_stat.st_ino != open_stat.st_ino
):
raise OSError(
"Refusing to replace workflow registry: "
"staged file changed before commit"
)
os.close(fd)
fd = -1
os.replace(tmp, self.registry_path)
except BaseException:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(tmp)
except OSError:
pass
raise
with open(self.registry_path, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
def add(self, workflow_id: str, metadata: dict[str, Any]) -> None:
"""Add or update an installed workflow entry."""
from datetime import datetime, timezone
raw_existing = self.data["workflows"].get(workflow_id)
had_entry = workflow_id in self.data["workflows"]
# Corrupted-but-parseable registries may hold non-dict entries.
existing = raw_existing if isinstance(raw_existing, dict) else {}
existing = self.data["workflows"].get(workflow_id, {})
metadata["installed_at"] = existing.get(
"installed_at", datetime.now(timezone.utc).isoformat()
)
metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
self.data["workflows"][workflow_id] = metadata
try:
self.save()
except (OSError, TypeError, ValueError):
# Roll back the in-memory mutation so a later successful save
# cannot persist metadata for a write that failed.
if had_entry:
self.data["workflows"][workflow_id] = raw_existing
else:
del self.data["workflows"][workflow_id]
raise
self.save()
def remove(self, workflow_id: str) -> bool:
"""Remove an installed workflow entry. Returns True if found."""
if workflow_id in self.data["workflows"]:
removed_entry = self.data["workflows"][workflow_id]
del self.data["workflows"][workflow_id]
try:
self.save()
except (OSError, TypeError, ValueError):
# Roll back the in-memory deletion so a save failure can't
# desync this instance from the untouched file on disk,
# mirroring add()'s rollback-on-save-failure.
self.data["workflows"][workflow_id] = removed_entry
raise
self.save()
return True
return False
@@ -299,20 +165,8 @@ class WorkflowCatalog:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
from urllib.parse import urlparse
# A malformed authority (e.g. an unterminated IPv6 bracket
# "https://[::1") makes urlparse / hostname access raise ValueError.
# This validator's contract is to raise WorkflowValidationError for a
# bad URL, so surface that rather than leaking a raw ValueError past the
# command handler (which only catches WorkflowValidationError). Mirrors
# specify_cli.catalogs (#3435).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise WorkflowValidationError(
f"Catalog URL is malformed: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
@@ -320,7 +174,7 @@ class WorkflowCatalog:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not hostname:
if not parsed.hostname:
raise WorkflowValidationError(
"Catalog URL must be a valid URL with a host."
)
@@ -486,26 +340,15 @@ class WorkflowCatalog:
from specify_cli.authentication.http import open_url as _open_url
def _validate_catalog_url(url: str) -> None:
# A malformed authority (e.g. "https://[::1") makes urlparse /
# hostname access raise ValueError; treat it as a refused fetch
# rather than leaking a raw ValueError (this also validates the
# post-redirect resp.geturl(), so a hostile redirect target cannot
# crash the fetch either).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise WorkflowCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
raise WorkflowCatalogError(
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
)
if not hostname:
if not parsed.hostname:
raise WorkflowCatalogError(
f"Refusing to fetch catalog from URL with no hostname: {url}"
)
@@ -592,7 +435,6 @@ class WorkflowCatalog:
self,
query: str | None = None,
tag: str | None = None,
author: str | None = None,
) -> list[dict[str, Any]]:
"""Search workflows across all configured catalogs."""
merged = self._get_merged_workflows()
@@ -617,10 +459,6 @@ class WorkflowCatalog:
normalized_tags = [t.lower() for t in tags if isinstance(t, str)]
if tag.lower() not in normalized_tags:
continue
if author:
wf_author = wf_data.get("author", "")
if not isinstance(wf_author, str) or wf_author.lower() != author.lower():
continue
results.append(wf_data)
return results
@@ -944,20 +782,8 @@ class StepCatalog:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
from urllib.parse import urlparse
# A malformed authority (e.g. an unterminated IPv6 bracket
# "https://[::1") makes urlparse / hostname access raise ValueError.
# This validator's contract is to raise StepValidationError for a bad
# URL, so surface that rather than leaking a raw ValueError past the
# command handler (which only catches StepValidationError). Mirrors
# specify_cli.catalogs (#3435).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise StepValidationError(
f"Catalog URL is malformed: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
@@ -965,7 +791,7 @@ class StepCatalog:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not hostname:
if not parsed.hostname:
raise StepValidationError(
"Catalog URL must be a valid URL with a host."
)
@@ -1131,26 +957,15 @@ class StepCatalog:
from specify_cli.authentication.http import open_url as _open_url
def _validate_url(url: str) -> None:
# A malformed authority (e.g. "https://[::1") makes urlparse /
# hostname access raise ValueError; treat it as a refused fetch
# rather than leaking a raw ValueError (this also validates the
# post-redirect resp.geturl(), so a hostile redirect target cannot
# crash the fetch either).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise StepCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
raise StepCatalogError(
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
)
if not hostname:
if not parsed.hostname:
raise StepCatalogError(
f"Refusing to fetch catalog from URL with no hostname: {url}"
)
+5 -118
View File
@@ -150,7 +150,7 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
f"'workflow.id' must be a string, got "
f"{type(definition.id).__name__} ({definition.id!r})."
)
elif not _ID_PATTERN.fullmatch(definition.id):
elif not _ID_PATTERN.match(definition.id):
errors.append(
f"Workflow ID {definition.id!r} must be lowercase alphanumeric "
f"with hyphens."
@@ -172,7 +172,7 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
f"{type(definition.version).__name__} ({definition.version!r}) — "
f'quote it in YAML (version: "1.0.0").'
)
elif not re.fullmatch(r"\d+\.\d+\.\d+", definition.version):
elif not re.match(r"^\d+\.\d+\.\d+$", definition.version):
errors.append(
f"Workflow version {definition.version!r} is not valid "
f"semantic versioning (expected X.Y.Z)."
@@ -416,57 +416,18 @@ class RunState:
ID into a path so a malicious value cannot probe or read files
outside ``.specify/workflows/runs/<run_id>/``.
"""
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.fullmatch(run_id):
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.match(run_id):
raise ValueError(
f"Invalid run_id {run_id!r}: must be alphanumeric with "
"hyphens/underscores only (and must start with an "
"alphanumeric character)."
)
@staticmethod
def _validate_installed_origin(
installed_workflow_id: str | None,
installed_registry_root: str | None,
) -> None:
"""Validate persisted installed-workflow ownership metadata."""
if installed_workflow_id is not None:
if not isinstance(installed_workflow_id, str):
raise ValueError(
"Invalid run state: 'installed_workflow_id' must be a "
f"string or null, got {type(installed_workflow_id).__name__}"
)
if not _ID_PATTERN.fullmatch(installed_workflow_id):
raise ValueError(
"Invalid run state: 'installed_workflow_id' must be a "
"lowercase alphanumeric workflow ID with hyphens"
)
if installed_registry_root is not None:
if not isinstance(installed_registry_root, str):
raise ValueError(
"Invalid run state: 'installed_registry_root' must be a "
f"string or null, got {type(installed_registry_root).__name__}"
)
if not installed_registry_root or not Path(
installed_registry_root
).is_absolute():
raise ValueError(
"Invalid run state: 'installed_registry_root' must be "
"an absolute path or null"
)
if installed_workflow_id is None:
raise ValueError(
"Invalid run state: 'installed_registry_root' requires "
"'installed_workflow_id'"
)
def __init__(
self,
run_id: str | None = None,
workflow_id: str = "",
project_root: Path | None = None,
installed_workflow_id: str | None = None,
installed_registry_root: str | None = None,
installed_origin_tracked: bool = True,
) -> None:
# ``run_id is None`` (omitted) → auto-generate. An explicit empty
# string is *not* the same as "omitted" and must be validated like
@@ -478,22 +439,8 @@ class RunState:
else:
self.run_id = run_id
self._validate_run_id(self.run_id)
self._validate_installed_origin(
installed_workflow_id, installed_registry_root
)
self.workflow_id = workflow_id
self.project_root = project_root or Path(".")
# Identifies the installed workflow (if any) this run was started
# from, and the project root that owns its registry — set by
# execute() when the source was resolved to an installed ID (see
# workflow_run's ownership mapping). None for a direct/non-installed
# YAML source. ``installed_origin_tracked`` distinguishes those
# explicit None values from legacy state files that predate both
# fields, allowing the CLI to conservatively infer same-project
# registry ownership before resuming.
self.installed_workflow_id = installed_workflow_id
self.installed_registry_root = installed_registry_root
self.installed_origin_tracked = installed_origin_tracked
self.status = RunStatus.CREATED
self.current_step_index = 0
self.current_step_id: str | None = None
@@ -556,8 +503,6 @@ class RunState:
state_data = {
"run_id": self.run_id,
"workflow_id": self.workflow_id,
"installed_workflow_id": self.installed_workflow_id,
"installed_registry_root": self.installed_registry_root,
"status": self.status.value,
"current_step_index": self.current_step_index,
"current_step_id": self.current_step_id,
@@ -609,46 +554,11 @@ class RunState:
with open(state_path, encoding="utf-8") as f:
state_data = json.load(f)
if not isinstance(state_data, dict):
raise ValueError("Invalid run state: expected a JSON object")
missing_fields = [
field
for field in ("run_id", "workflow_id", "status")
if field not in state_data
]
if missing_fields:
raise ValueError(
"Invalid run state: missing required field(s): "
+ ", ".join(missing_fields)
)
workflow_id = state_data["workflow_id"]
if not isinstance(workflow_id, str) or not _ID_PATTERN.fullmatch(
workflow_id
):
raise ValueError(
"Invalid run state: 'workflow_id' must be a lowercase "
"alphanumeric workflow ID with hyphens"
)
has_installed_workflow_id = "installed_workflow_id" in state_data
has_installed_registry_root = "installed_registry_root" in state_data
if has_installed_workflow_id != has_installed_registry_root:
raise ValueError(
"Invalid run state: installed workflow origin fields must "
"either both be present or both be absent"
)
installed_workflow_id = state_data.get("installed_workflow_id")
installed_registry_root = state_data.get("installed_registry_root")
state = cls(
run_id=state_data["run_id"],
workflow_id=workflow_id,
workflow_id=state_data["workflow_id"],
project_root=project_root,
installed_workflow_id=installed_workflow_id,
installed_registry_root=installed_registry_root,
installed_origin_tracked=has_installed_workflow_id,
)
state.status = RunStatus(state_data["status"])
state.current_step_index = state_data.get("current_step_index", 0)
@@ -661,16 +571,7 @@ class RunState:
if inputs_path.exists():
with open(inputs_path, encoding="utf-8") as f:
inputs_data = json.load(f)
if not isinstance(inputs_data, dict):
raise ValueError(
"Invalid run inputs: expected a JSON object"
)
inputs = inputs_data.get("inputs", {})
if not isinstance(inputs, dict):
raise ValueError(
"Invalid run inputs: 'inputs' must be a JSON object"
)
state.inputs = inputs
state.inputs = inputs_data.get("inputs", {})
return state
@@ -753,8 +654,6 @@ class WorkflowEngine:
definition: WorkflowDefinition,
inputs: dict[str, Any] | None = None,
run_id: str | None = None,
installed_workflow_id: str | None = None,
installed_registry_root: Path | None = None,
) -> RunState:
"""Execute a workflow definition.
@@ -766,12 +665,6 @@ class WorkflowEngine:
User-provided input values.
run_id:
Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated).
installed_workflow_id, installed_registry_root:
When the run was started from an installed workflow (as opposed
to a direct/non-installed YAML source), identifies it and its
owning registry root so a later ``resume`` can re-check the
registry's current disabled state before continuing — see
``workflow_resume``.
Returns
-------
@@ -789,12 +682,6 @@ class WorkflowEngine:
run_id=effective_run_id,
workflow_id=definition.id,
project_root=self.project_root,
installed_workflow_id=installed_workflow_id,
installed_registry_root=(
str(installed_registry_root)
if installed_registry_root is not None
else None
),
)
# Persist a copy of the workflow definition so resume can
@@ -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:
+11 -39
View File
@@ -6,7 +6,6 @@ contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error)
"""
from __future__ import annotations
import io
import json
from pathlib import Path
from unittest.mock import patch
@@ -64,42 +63,6 @@ def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch
assert "Spec Kit project" in result.output
def test_remove_reports_clean_error_when_primitive_raises_raw_exception(
project: Path,
):
"""A raw exception from a primitive installer (e.g. an OSError from an
unreadable workflow registry surfacing through _WorkflowKindManager's
fail-closed construction) must not propagate uncaught through
`specify bundle remove` -- the command only catches BundlerError, so
without a conversion at the remove_bundle boundary this would exit
with an unhandled exception and empty/raw output instead of a clean,
actionable message, and no removal side effects should occur either."""
from specify_cli.bundler.models.manifest import BundleManifest
from specify_cli.bundler.models.records import load_records
from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller
from specify_cli.bundler.services.installer import install_bundle
from specify_cli.bundler.services.resolver import resolve_install_plan
from tests.bundler_helpers import FakeInstaller
manifest = BundleManifest.from_dict(valid_manifest_dict())
plan = resolve_install_plan(
manifest, speckit_version="0.11.2", active_integration="copilot"
)
install_bundle(project, plan, FakeInstaller(), manifest=manifest)
def boom(self, project_root, component):
raise OSError("workflow registry unreadable")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(DefaultPrimitiveInstaller, "is_installed", boom)
result = runner.invoke(app, ["bundle", "remove", "demo-bundle"])
assert result.exit_code != 0
assert result.output.strip() != ""
assert result.exception is None or isinstance(result.exception, SystemExit)
assert {r.bundle_id for r in load_records(project)} == {"demo-bundle"}
def test_fail_writes_error_to_stderr_not_stdout(capsys):
"""_fail must write to stderr, not stdout: every bundle command routes errors
through it, and under --json the error would otherwise corrupt the JSON payload
@@ -469,16 +432,25 @@ def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
# ===== Private GitHub release asset URL resolution =====
class FakeBundleResponse(io.BytesIO):
class FakeBundleResponse:
"""Minimal context-manager response stub for open_url fakes."""
def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"):
super().__init__(data)
self._data = data
self._url = url
def read(self) -> bytes:
return self._data
def geturl(self) -> str:
return self._url
def __enter__(self):
return self
def __exit__(self, *_):
return False
def _make_catalog_config(catalog_path: Path, project: Path) -> None:
"""Write a bundle-catalogs.yml pointing at *catalog_path* in *project*."""
+2 -208
View File
@@ -11,7 +11,7 @@ import pytest
from specify_cli.bundler import BundlerError
from specify_cli.bundler.models.manifest import BundleManifest
from specify_cli.bundler.models.records import load_records, records_path
from specify_cli.bundler.models.records import load_records
from specify_cli.bundler.services.installer import install_bundle, remove_bundle
from specify_cli.bundler.services.resolver import resolve_install_plan
from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict
@@ -97,212 +97,6 @@ def test_remove_unknown_bundle_errors(tmp_path: Path):
remove_bundle(tmp_path, "ghost", FakeInstaller())
def test_remove_converts_raw_installer_exception_to_bundler_error(tmp_path: Path):
"""A raw exception from a primitive installer (e.g. an OSError from an
unreadable workflow registry surfacing through _WorkflowKindManager's
fail-closed construction) must not propagate uncaught out of
remove_bundle: install_bundle already converts any non-BundlerError
exception into a clean BundlerError, but remove_bundle had no such
conversion, so the CLI's `bundle remove` (which only catches
BundlerError) would let a raw exception through with no clean message
and no removal side effects should occur either."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
def boom(project_root, component):
raise OSError("workflow registry unreadable")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "is_installed", boom)
with pytest.raises(BundlerError):
remove_bundle(tmp_path, "demo-bundle", installer)
# No removal side effects: the bundle record must still be present.
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_partial_failure_message_reflects_partial_state(tmp_path: Path):
"""A failure can occur after earlier components in the same bundle have
already been removed from disk. The bundle record is left unchanged
(save_records never runs on this path), so it still claims the bundle
fully installed -- but the message must not claim "No changes were
recorded" when components were, in fact, already removed."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
real_remove = installer.remove
calls = {"n": 0}
def remove_then_fail(project_root, component):
calls["n"] += 1
if calls["n"] == 1:
return real_remove(project_root, component)
raise OSError("disk full")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "remove", remove_then_fail)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no changes were recorded" not in message.lower()
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_bundler_error_from_installer_after_partial_removal_reports_partial_state(
tmp_path: Path,
):
"""If the primitive installer itself raises BundlerError (not a raw/
unexpected exception) after an earlier component in the same bundle was
already removed, the surfaced message must still carry the same
partial-removal detail as the generic-exception path -- a bare
``except BundlerError: raise`` would re-raise the installer's original
message verbatim with no mention that the project may now be partially
uninstalled."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
real_remove = installer.remove
calls = {"n": 0}
def remove_then_raise_bundler_error(project_root, component):
calls["n"] += 1
if calls["n"] == 1:
return real_remove(project_root, component)
raise BundlerError("kind manager refused removal")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "remove", remove_then_raise_bundler_error)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no changes were recorded" not in message.lower()
assert "kind manager refused removal" in message
assert "partially uninstalled" in message.lower()
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_bundler_error_from_installer_with_zero_removed_reports_no_changes(
tmp_path: Path,
):
"""When the installer raises BundlerError before anything was actually
removed, the message should not misleadingly claim partial state."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
def boom(project_root, component):
raise BundlerError("kind manager unavailable")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "is_installed", boom)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no components were removed" in message.lower()
assert "no removal was attempted" in message.lower()
assert "partially uninstalled" not in message.lower()
assert "kind manager unavailable" in message
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_zero_completed_removals_still_cautions_about_partial_changes(
tmp_path: Path,
):
"""`result.uninstalled` only records a component after its `remove()`
call returns successfully. If the very first `remove()` call itself
raises after already deleting some files, zero completed removals are
recorded even though the project may already be partially uninstalled --
the zero-count message must not claim "No components were removed" as
an unqualified fact; it must caution that the failing component may
have made partial changes before raising."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
def boom(project_root, component):
# Simulates a remove() that deletes some files before raising --
# from the caller's perspective this component was never recorded
# as completed, but disk state may already be partially changed.
raise OSError("disk full partway through removal")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "remove", boom)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no components were removed" in message.lower()
assert "partial" in message.lower()
assert "partially uninstalled" in message.lower()
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_record_save_failure_reports_partial_state(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
record_file = records_path(tmp_path)
original_record = record_file.read_bytes()
def fail_dump(_data, handle, *_args, **_kwargs):
handle.write('{"partial":')
handle.flush()
raise OSError("disk full")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"specify_cli.bundler.lib.yamlio.json.dump",
fail_dump,
)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "disk full" in message
assert "partially uninstalled" in message.lower()
assert installer.installed == set()
assert record_file.read_bytes() == original_record
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_record_save_failure_without_remove_attempt_is_not_partial(
tmp_path: Path,
):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
installer.installed.clear()
def fail_save(*_args, **_kwargs):
raise OSError("disk full")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"specify_cli.bundler.services.installer.save_records",
fail_save,
)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no removal was attempted" in message.lower()
assert "partially uninstalled" not in message.lower()
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_reports_uninstalled_not_installed(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
@@ -334,7 +128,7 @@ def test_remove_counts_only_components_actually_removed(tmp_path: Path):
assert len(result.uninstalled) == 3
assert (gone.kind, gone.id) not in installer.remove_calls
assert gone not in result.skipped
assert gone in result.skipped
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
+1 -59
View File
@@ -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
@@ -201,36 +201,6 @@ class YamlIntegrationTests:
parsed = yaml.safe_load("\n".join(yaml_lines))
assert parsed["prompt"].rstrip("\n") == body
def test_yaml_prompt_with_control_characters_stays_valid(self):
"""A body containing control characters must still produce parseable YAML.
YAML forbids C0 control characters (except tab and newline), DEL,
C1 controls, lone surrogates and U+FFFE/U+FFFF in every scalar form,
and YAML 1.1 treats NEL (U+0085), LS (U+2028) and PS (U+2029) as
line breaks that corrupt a literal block scalar's structure. The
renderer falls back to an escaped double-quoted scalar for such
bodies."""
for ch in (
"\x08", "\x0c", "\x1b", "\x7f",
"\x80", "\x84", "\x85", "\x86", "\x9f",
"\u2028", "\u2029",
"\ud800", "\udfff", "\ufffe", "\uffff",
):
body = f"before{ch}after\nsecond line"
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
parsed = yaml.safe_load(rendered)
assert parsed["prompt"].rstrip("\n") == body, f"char {ch!r} round-trip"
def test_yaml_prompt_with_bare_carriage_return_stays_valid(self):
"""A bare CR (not part of CRLF) must not break the generated YAML.
Inside a block scalar a lone \r acts as a line break, corrupting
the document structure."""
body = "line1\rstill line1\nline2"
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
parsed = yaml.safe_load(rendered)
assert parsed["prompt"].rstrip("\n") == body
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The generated plan command must not carry a context-file placeholder.
@@ -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):
-14
View File
@@ -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."""
-132
View File
@@ -7896,135 +7896,3 @@ class TestConfigManagerEnvPrefixCollision:
cfg = cm._get_env_config()
assert "" not in cfg
assert cfg == {"a": {"b": "z"}}
class TestConfigManagerCrossExtensionEnvLeak:
"""Cross-extension env-var leak: a longer, co-installed sibling ID must
own its own env vars instead of leaking them into a shorter-prefix sibling.
Before the fix, ``SPECKIT_GIT_HOOKS_URL`` (intended for a ``git-hooks``
extension) also surfaced inside the ``git`` extension's config as
``{'hooks': {'url': ...}}`` because ``SPECKIT_GIT_`` is a strict prefix of
``SPECKIT_GIT_HOOKS_``.
"""
def _install(self, project_root, ext_id):
extensions_dir = project_root / ".specify" / "extensions"
(extensions_dir / ext_id).mkdir(parents=True)
# Register in the extension registry — the registry is the source of
# truth for "installed" (a bare directory can be a config-only leftover
# from ``ExtensionManager.remove(..., keep_config=True)``).
ExtensionRegistry(extensions_dir).add(ext_id, {})
def test_sibling_owns_longer_prefix_env(self, tmp_path, monkeypatch):
"""SPECKIT_GIT_HOOKS_URL belongs to git-hooks when co-installed with git."""
self._install(tmp_path, "git")
self._install(tmp_path, "git-hooks")
monkeypatch.setenv("SPECKIT_GIT_URL", "for_git")
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
git_cfg = ConfigManager(tmp_path, "git")._get_env_config()
gh_cfg = ConfigManager(tmp_path, "git-hooks")._get_env_config()
# 'git' must NOT see the git-hooks var — no cross-extension leak.
assert git_cfg == {"url": "for_git"}
# 'git-hooks' still receives its own var (unchanged behaviour).
assert gh_cfg == {"url": "for_git_hooks"}
def test_no_sibling_installed_keeps_legacy_absorption(self, tmp_path, monkeypatch):
"""Without a longer-prefix sibling installed, the legacy behaviour is
preserved: ``SPECKIT_GIT_HOOKS_URL`` is absorbed as a nested key of
the ``git`` extension. This keeps the fix strictly to the *collision*
case and avoids surprising users who deliberately set a nested key
via env with no sibling to disambiguate against.
"""
self._install(tmp_path, "git")
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
cfg = ConfigManager(tmp_path, "git")._get_env_config()
assert cfg == {"hooks": {"url": "for_git_hooks"}}
def test_non_prefix_sibling_ignored(self, tmp_path, monkeypatch):
"""A sibling whose ID does not extend our own is not a collision.
e.g. current='git' and sibling='not-git' — 'not-git' normalized to
'NOT_GIT' does not start with 'GIT_', so its presence must not
influence git's env-var interpretation.
"""
self._install(tmp_path, "git")
self._install(tmp_path, "not-git")
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
cfg = ConfigManager(tmp_path, "git")._get_env_config()
assert cfg == {"hooks": {"url": "for_git_hooks"}}
def test_boundary_prevents_false_positive(self, tmp_path, monkeypatch):
"""Sibling ID 'hook' (not 'hooks') must NOT eat env keys starting
with 'hooks'. The trailing-underscore boundary in the sibling prefix
prevents this false positive.
"""
self._install(tmp_path, "git")
self._install(tmp_path, "git-hook")
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_key_hooks")
# git-hook's prefix is 'HOOK_', which does not match 'HOOKS_URL',
# so 'git' keeps the env var (single-installed semantics).
cfg = ConfigManager(tmp_path, "git")._get_env_config()
assert cfg == {"hooks": {"url": "for_git_key_hooks"}}
def test_missing_extensions_dir_does_not_crash(self, tmp_path, monkeypatch):
"""A ConfigManager built against a project without ``.specify/extensions``
(fresh project, ad-hoc test harness) must still evaluate env config
rather than raising from the sibling scan.
"""
# Note: no _install call — extensions dir intentionally absent.
monkeypatch.setenv("SPECKIT_TESTEXT_URL", "v")
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
assert cfg == {"url": "v"}
def test_config_only_leftover_not_treated_as_sibling(self, tmp_path, monkeypatch):
"""A directory left behind by ``remove(..., keep_config=True)`` must
NOT be treated as an installed sibling.
``ExtensionManager.remove(keep_config=True)`` preserves the extension
directory (config files remain, dormant, for a possible reinstall) but
removes the registry entry. The sibling scan is sourced from the
registry, so a leftover ``git-hooks/`` directory without a registry
entry must not silently discard ``SPECKIT_GIT_HOOKS_*`` from ``git``.
"""
self._install(tmp_path, "git")
# Simulate ``remove('git-hooks', keep_config=True)``: dir present,
# config file preserved, but no registry entry.
gh_dir = tmp_path / ".specify" / "extensions" / "git-hooks"
gh_dir.mkdir(parents=True)
(gh_dir / "git-hooks-config.yml").write_text("url: leftover\n")
# Sanity: git-hooks is NOT registered.
registry = ExtensionRegistry(tmp_path / ".specify" / "extensions")
assert "git-hooks" not in registry.keys()
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git")
cfg = ConfigManager(tmp_path, "git")._get_env_config()
# git absorbs the var (no registered sibling owns it).
assert cfg == {"hooks": {"url": "for_git"}}
def test_non_utf8_registry_does_not_crash(self, tmp_path, monkeypatch):
"""A registry file with invalid text encoding must NOT propagate
``UnicodeDecodeError`` out of the sibling scan and abort every
config read. ``ExtensionRegistry._load()`` catches ``JSONDecodeError``
/ ``FileNotFoundError`` only, so ``_sibling_extension_ids`` must
additionally swallow ``UnicodeError`` and degrade to the documented
pre-fix behaviour.
"""
extensions_dir = tmp_path / ".specify" / "extensions"
extensions_dir.mkdir(parents=True)
# Write bytes that are not valid UTF-8 to the registry file.
(extensions_dir / ExtensionRegistry.REGISTRY_FILE).write_bytes(
b"\xff\xfe invalid utf-8 registry \xc3\x28"
)
monkeypatch.setenv("SPECKIT_TESTEXT_URL", "v")
# Must not raise; must fall back to the "no siblings" path.
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
assert cfg == {"url": "v"}
-38
View File
@@ -152,44 +152,6 @@ class TestResolveGitHubReleaseAssetApiUrl:
)
assert result is None
def test_metadata_lookup_is_bounded_and_redirect_validated(self):
"""Release metadata reads stay bounded and use the caller's policy."""
captured = {}
class OversizedResponse:
def read(self, amount=None):
captured["read_amount"] = amount
return b"x" * amount
def __enter__(self):
return self
def __exit__(self, *args):
return False
def redirect_validator(old_url, new_url):
return None
def fake_open(
url,
timeout=None,
extra_headers=None,
redirect_validator=None,
):
captured["redirect_validator"] = redirect_validator
return OversizedResponse()
result = resolve_github_release_asset_api_url(
"https://github.com/org/repo/releases/download/v1/pack.zip",
fake_open,
redirect_validator=redirect_validator,
max_metadata_bytes=8,
)
assert result is None
assert captured["read_amount"] == 9
assert captured["redirect_validator"] is redirect_validator
def test_tag_with_special_characters_is_url_encoded(self):
"""Tags with reserved characters (e.g. '/') are encoded in the API URL."""
captured_urls = []
+44 -185
View File
@@ -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
@@ -5016,13 +4836,26 @@ class TestPresetAddFromUrlResolution:
captured_urls = []
class FakeResponse:
def __init__(self, data):
self._data = data
def read(self):
return self._data
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured_urls.append((url, extra_headers))
if "releases/tags/" in url:
return io.BytesIO(json.dumps({
return FakeResponse(json.dumps({
"assets": [{"name": "preset.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/42"}]
}).encode())
return io.BytesIO(zip_bytes)
return FakeResponse(zip_bytes)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
@@ -5061,9 +4894,22 @@ class TestPresetAddFromUrlResolution:
captured_urls = []
class FakeResponse:
def __init__(self, data):
self._data = data
def read(self):
return self._data
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured_urls.append((url, extra_headers))
return io.BytesIO(zip_bytes)
return FakeResponse(zip_bytes)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
@@ -5105,13 +4951,26 @@ class TestPresetAddFromUrlResolution:
captured_urls = []
class FakeResponse:
def __init__(self, data):
self._data = data
def read(self):
return self._data
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured_urls.append((url, extra_headers))
if "releases/tags/" in url:
return io.BytesIO(json.dumps({
return FakeResponse(json.dumps({
"assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}]
}).encode())
return io.BytesIO(zip_bytes)
return FakeResponse(zip_bytes)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
+56 -5151
View File
File diff suppressed because it is too large Load Diff