Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
87fff02281 fix: limit catalog.community.json changes to wiki entry + timestamps only
Reverts the unintended reordering and reformatting of existing extensions
(aide, checkpoint, critique, threatmodel, etc.) and companion's tools array.
Only the new wiki entry and updated_at timestamps are now changed.

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
2026-07-07 14:54:51 +00:00
github-actions[bot]
f757149946 Add LLM Wiki extension to community catalog
Add wiki extension submitted by @formin to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3319

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-06 13:57:57 +00:00
47 changed files with 250 additions and 3087 deletions

View File

@@ -2,51 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.12.7] - 2026-07-07
### Changed
- fix(bundler): bundle update uninstalls components dropped by new version (#3353)
- fix(workflows): route run/resume errors to stderr under --json (#3352)
- fix(workflows): fan-in validate() rejects non-mapping output (#3349)
- fix(workflows): shell step validate() rejects non-string run (#3348)
- fix(integrations): agy honors SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (#3347)
- Add Orchestration Task Context Management extension to community catalog (#3372)
- Update DocGuard — CDD Enforcement extension to v0.30.0 (#3371)
- Update Ripple extension to v1.1.0 (#3370)
- feat(integrations): generalize post-processing to all format types (#3311)
- chore: release 0.12.6, begin 0.12.7.dev0 development (#3393)
## [0.12.6] - 2026-07-07
### Changed
- fix(bundler): validate catalog URLs in `catalog add` (HTTPS-only, require host) (#3367)
- Update Ralph Loop extension to v1.2.1 (#3365)
- fix extension-local script path rewriting (#3364)
- Add Charter extension to community catalog (#3363)
- feat(scripts): add Python check-prerequisites PoC (#3302)
- test: reduce registry manifest test repetition (#3146)
- fix(integrations): hermes honors SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS (#3346)
- fix(extensions): coerce non-mapping YAML config roots to {} in ConfigManager (#3345)
- fix(yaml): pin goose recipe prompt block-scalar indentation (#3343)
- chore: release 0.12.5, begin 0.12.6.dev0 development (#3381)
## [0.12.5] - 2026-07-06
### Changed
- fix(workflows): match gate reject option case-insensitively (#3335)
- fix(bundler): reject host-less catalog URLs in adapters (use hostname, not netloc) (#3333)
- fix(bundler): resolve catalog search at highest-precedence source before filtering (#3331)
- fix(workflows): compare non-numeric strings lexicographically instead of returning False (#3323)
- fix(workflows): quote-aware interpolation so a literal }} in a filter arg doesn't break multi-expression templates (#3307)
- Support namespaced git feature branch templates (#3293)
- chore(deps): bump actions/setup-dotnet from 5.3.0 to 5.4.0 (#3315)
- fix(integrations): cursor-agent honors executable/extra-args env overrides (#3265)
- docs: drop stale kimi KIMI.md->AGENTS.md migration note (#3291)
- chore: release 0.12.4, begin 0.12.5.dev0 development (#3305)
## [0.12.4] - 2026-07-02
### Changed

View File

@@ -42,7 +42,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
| Charter | Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent. | `process` | Read+Write | [spec-kit-charter](https://github.com/Fyloss/spec-kit-charter) |
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
@@ -51,7 +50,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 | 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) |
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| 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) |
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
@@ -67,6 +66,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) |
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
@@ -86,7 +86,6 @@ The following community-contributed extensions are available in [`catalog.commun
| .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) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
@@ -107,7 +106,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
| Ripple | Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |

View File

@@ -670,40 +670,6 @@
"created_at": "2026-04-11T18:00:00Z",
"updated_at": "2026-04-11T18:00:00Z"
},
"charter": {
"name": "Charter",
"id": "charter",
"description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.",
"author": "Fyloss",
"version": "0.3.1",
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.3.1.zip",
"repository": "https://github.com/Fyloss/spec-kit-charter",
"homepage": "https://github.com/Fyloss/spec-kit-charter",
"documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs",
"changelog": "https://github.com/Fyloss/spec-kit-charter/blob/master/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.11.9"
},
"provides": {
"commands": 5,
"hooks": 1
},
"tags": [
"constitution",
"governance",
"modular",
"fragments",
"registry"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
},
"ci-guard": {
"name": "CI Guard",
"id": "ci-guard",
@@ -1106,10 +1072,10 @@
"docguard": {
"name": "DocGuard — CDD Enforcement",
"id": "docguard",
"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.",
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.",
"author": "raccioly",
"version": "0.30.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
"version": "0.28.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.28.0/spec-kit-docguard-v0.28.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",
@@ -1145,7 +1111,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
"updated_at": "2026-06-23T00:00:00Z"
},
"doctor": {
"name": "Project Health Check",
@@ -2396,39 +2362,6 @@
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z"
},
"orchestration-task-context-management": {
"name": "Orchestration Task Context Management",
"id": "orchestration-task-context-management",
"description": "Adds subagent work-unit orchestration to generated Spec Kit task files",
"author": "Igor Benicio de Mesquita",
"version": "0.0.0",
"download_url": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/archive/refs/tags/v0.0.0.zip",
"repository": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
"homepage": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
"documentation": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/README.md",
"changelog": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.7.2"
},
"provides": {
"commands": 2,
"hooks": 2
},
"tags": [
"agent",
"orchestration",
"tasks",
"context"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
},
"orchestrator": {
"name": "Spec Orchestrator",
"id": "orchestrator",
@@ -2746,8 +2679,8 @@
"id": "ralph",
"description": "Autonomous implementation loop using AI agent CLI",
"author": "Rubiss",
"version": "1.2.1",
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.2.1.zip",
"version": "1.1.1",
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.1.1.zip",
"repository": "https://github.com/Rubiss-Projects/spec-kit-ralph",
"homepage": "https://github.com/Rubiss-Projects/spec-kit-ralph",
"documentation": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/README.md",
@@ -2756,7 +2689,7 @@
"category": "code",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.8.5",
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "copilot",
@@ -2766,10 +2699,6 @@
"name": "codex",
"required": false
},
{
"name": "claude",
"required": false
},
{
"name": "git",
"required": true
@@ -2785,14 +2714,13 @@
"automation",
"loop",
"copilot",
"codex",
"claude"
"codex"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-09T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
"updated_at": "2026-06-05T03:11:06Z"
},
"reconcile": {
"name": "Reconcile Extension",
@@ -3119,10 +3047,10 @@
"ripple": {
"name": "Ripple",
"id": "ripple",
"description": "Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories",
"description": "Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories with fix-induced side effect detection",
"author": "chordpli",
"version": "1.1.0",
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.1.0.zip",
"version": "1.0.0",
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/chordpli/spec-kit-ripple",
"homepage": "https://github.com/chordpli/spec-kit-ripple",
"documentation": "https://github.com/chordpli/spec-kit-ripple/blob/main/README.md",
@@ -3131,13 +3059,7 @@
"category": "code",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0",
"tools": [
{
"name": "git",
"required": true
}
]
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 3,
@@ -3154,7 +3076,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
"updated_at": "2026-04-20T00:00:00Z"
},
"roadmap": {
"name": "Spec Roadmap",
@@ -4375,6 +4297,40 @@
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
},
"wiki": {
"name": "LLM Wiki",
"id": "wiki",
"description": "LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting",
"author": "formin",
"version": "1.0.0",
"download_url": "https://github.com/formin/spec-kit-wiki/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/formin/spec-kit-wiki",
"homepage": "https://github.com/formin/spec-kit-wiki",
"documentation": "https://github.com/formin/spec-kit-wiki/blob/main/README.md",
"changelog": "https://github.com/formin/spec-kit-wiki/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 5,
"hooks": 2
},
"tags": [
"wiki",
"knowledge-base",
"docs",
"memory",
"context-management"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
},
"wireframe": {
"name": "Wireframe Visual Feedback Loop",
"id": "wireframe",

View File

@@ -7,7 +7,7 @@ Git repository initialization, feature branch creation, numbering (sequential/ti
This extension provides Git operations as an optional, self-contained module. It manages:
- **Repository initialization** with configurable commit messages
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
- **Branch validation** to ensure branches follow naming conventions
- **Git remote detection** for GitHub integration (e.g., issue creation)
- **Auto-commit** after core commands (configurable per-command with custom messages)
@@ -53,16 +53,6 @@ Configuration is stored in `.specify/extensions/git/git-config.yml`:
# Branch numbering strategy: "sequential" or "timestamp"
branch_numbering: sequential
# Optional branch name template. Leave empty for the default "{number}-{slug}".
# Supported tokens: {author}, {app}, {number}, {slug}; {slug} must not appear
# before {number}, and the final path segment must start with {number}-.
# Example for monorepos: "{author}/{app}/{number}-{slug}"
branch_template: ""
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
branch_prefix: ""
# Custom commit message for git init
init_commit_message: "[Spec Kit] Initial commit"
@@ -75,10 +65,6 @@ auto_commit:
message: "[Spec Kit] Add specification"
```
`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. Custom templates must not put `{slug}` before `{number}`, and must put `{number}-` at the start of the final path segment so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`.
For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `<branch_prefix>/{number}-{slug}`.
## Installation
```bash

View File

@@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty).
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
- `--short-name`, `--number`, and `--timestamp` flags are ignored
- `FEATURE_NUM` is extracted when the final path segment starts with a numeric or timestamp feature marker (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
## Prerequisites
@@ -35,19 +35,6 @@ Determine the branch numbering strategy by checking configuration in this order:
3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
4. Default to `sequential` if none of the above exist
## Branch Name Template
Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, `{slug}` must not appear before `{number}`, its final path segment must start with `{number}-`, and the script expands these tokens:
- `{author}`: sanitized Git config author (`user.name`, falling back to the email local part)
- `{app}`: sanitized Spec Kit init directory name
- `{number}`: sequential number or timestamp
- `{slug}`: generated short branch slug
For monorepos, a template such as `{author}/{app}/{number}-{slug}` creates names like `jdoe/web/008-guided-tour` while preserving per-project feature numbering.
The script also accepts `branch_prefix` as a shorthand for simple namespaces; it expands to `<branch_prefix>/{number}-{slug}`.
## Execution
Generate a concise short name (2-4 words) for the branch:
@@ -67,7 +54,6 @@ Run the appropriate script based on your platform:
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
- Do not manually expand `branch_template`; the script reads the git extension config and applies it consistently
## Graceful Degradation
@@ -78,5 +64,5 @@ If Git is not installed or the current directory is not a Git repository:
## Output
The script outputs JSON with:
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth`, `20260319-143022-user-auth`, or `jdoe/web/003-user-auth`)
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `FEATURE_NUM`: The numeric or timestamp prefix used

View File

@@ -22,24 +22,24 @@ Get the current branch name:
git rev-parse --abbrev-ref HEAD
```
The branch name's final path segment must start with one of these feature markers:
The branch name must match one of these patterns:
1. **Sequential**: `[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`, `jdoe/web/008-guided-tour`)
2. **Timestamp**: `[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`, `jdoe/web/20260319-143022-feature-name`)
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
## Execution
If on a feature branch (matches either pattern):
- Output: `✓ On feature branch: <branch-name>`
- Check if the corresponding spec directory exists under `specs/`:
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion, regardless of branch namespace prefixes
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion, regardless of branch namespace prefixes
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
- If spec directory exists: `✓ Spec directory found: <path>`
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
If NOT on a feature branch:
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
- Output: `Feature branches should be named like: 001-feature-name, 20260319-143022-feature-name, or <namespace>/001-feature-name`
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
## Graceful Degradation

View File

@@ -4,16 +4,6 @@
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Optional branch name template. Leave empty for the default "{number}-{slug}".
# Supported tokens: {author}, {app}, {number}, {slug}
# {slug} must not appear before {number}; final path segment must start with {number}-.
# Example for monorepos: "{author}/{app}/{number}-{slug}"
branch_template: ""
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
branch_prefix: ""
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -4,7 +4,7 @@ extension:
id: git
name: "Git Branching Workflow"
version: "1.0.0"
description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection"
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
author: spec-kit-core
repository: https://github.com/github/spec-kit
license: MIT
@@ -19,7 +19,7 @@ provides:
commands:
- name: speckit.git.feature
file: commands/speckit.git.feature.md
description: "Create a feature branch with sequential or timestamp numbering and optional templates"
description: "Create a feature branch with sequential or timestamp numbering"
- name: speckit.git.validate
file: commands/speckit.git.validate.md
description: "Validate current branch follows feature branch naming conventions"
@@ -137,6 +137,4 @@ tags:
config:
defaults:
branch_numbering: sequential
branch_template: ""
branch_prefix: ""
init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -4,16 +4,6 @@
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Optional branch name template. Leave empty for the default "{number}-{slug}".
# Supported tokens: {author}, {app}, {number}, {slug}
# {slug} must not appear before {number}; final path segment must start with {number}-.
# Example for monorepos: "{author}/{app}/{number}-{slug}"
branch_template: ""
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
branch_prefix: ""
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -75,10 +75,6 @@ while [ $i -le $# ]; do
echo "Environment variables:"
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
echo ""
echo "Configuration:"
echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
echo " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5"
@@ -131,28 +127,16 @@ get_highest_from_specs() {
# Function to get highest number from git branches
get_highest_from_branches() {
local scope_prefix="${1:-}"
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number "$scope_prefix"
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number
}
# Extract the highest sequential feature number from a list of ref names (one per line).
_extract_highest_number() {
local scope_prefix="${1:-}"
local highest=0
while IFS= read -r name; do
[ -z "$name" ] && continue
if [ -n "$scope_prefix" ]; then
case "$name" in
"$scope_prefix"*) name="${name#"$scope_prefix"}" ;;
*) continue ;;
esac
fi
name="${name##*/}"
if echo "$name" | grep -Eq '^[0-9]{3,}-' \
&& ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-' \
&& ! echo "$name" | grep -Eq '^[0-9]{7}-[0-9]{6}-' \
&& ! echo "$name" | grep -Eq '^[0-9]{7,8}-[0-9]{6}$'; then
number=$(echo "$name" | grep -Eo '^[0-9]{3,}-' | sed -E 's/-$//' || echo "0")
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
@@ -164,12 +148,11 @@ _extract_highest_number() {
# Function to get highest number from remote branches without fetching (side-effect-free)
get_highest_from_remote_refs() {
local scope_prefix="${1:-}"
local highest=0
for remote in $(git remote 2>/dev/null); do
local remote_highest
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number "$scope_prefix")
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
if [ "$remote_highest" -gt "$highest" ]; then
highest=$remote_highest
fi
@@ -182,17 +165,16 @@ get_highest_from_remote_refs() {
check_existing_branches() {
local specs_dir="$1"
local skip_fetch="${2:-false}"
local scope_prefix="${3:-}"
if [ "$skip_fetch" = true ]; then
local highest_remote=$(get_highest_from_remote_refs "$scope_prefix")
local highest_branch=$(get_highest_from_branches "$scope_prefix")
local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches)
if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote
fi
else
git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches "$scope_prefix")
local highest_branch=$(get_highest_from_branches)
fi
local highest_spec=$(get_highest_from_specs "$specs_dir")
@@ -291,152 +273,6 @@ fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
CONFIG_FILE="$REPO_ROOT/.specify/extensions/git/git-config.yml"
read_git_config_value() {
local key="$1"
[ -f "$CONFIG_FILE" ] || return 0
grep -E "^[[:space:]]*${key}:" "$CONFIG_FILE" 2>/dev/null \
| head -n 1 \
| sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" \
| sed -E 's/[[:space:]]+#.*$//' \
| sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \
| sed -E 's/^"//; s/"$//' \
| sed -E "s/^'//; s/'$//"
}
branch_token() {
local value="$1"
local fallback="$2"
local cleaned
cleaned=$(clean_branch_name "$value")
if [ -n "$cleaned" ]; then
printf '%s\n' "$cleaned"
else
printf '%s\n' "$fallback"
fi
}
get_author_token() {
local author=""
if command -v git >/dev/null 2>&1; then
author=$(git config user.name 2>/dev/null || true)
if [ -z "$author" ]; then
author=$(git config user.email 2>/dev/null | sed 's/@.*$//' || true)
fi
fi
if [ -z "$author" ]; then
author="${USER:-unknown}"
fi
branch_token "$author" "unknown"
}
get_app_token() {
branch_token "$(basename "$REPO_ROOT")" "app"
}
resolve_branch_template() {
local template
local prefix
template=$(read_git_config_value "branch_template")
if [ -n "$template" ]; then
printf '%s\n' "$template"
return
fi
prefix=$(read_git_config_value "branch_prefix")
if [ -z "$prefix" ]; then
printf '%s\n' ""
return
fi
case "$prefix" in
*/) printf '%s%s\n' "$prefix" "{number}-{slug}" ;;
*) printf '%s/%s\n' "$prefix" "{number}-{slug}" ;;
esac
}
render_branch_template() {
local template="$1"
local feature_num="$2"
local branch_suffix="$3"
local rendered="$template"
rendered=${rendered//\{author\}/$AUTHOR_TOKEN}
rendered=${rendered//\{app\}/$APP_TOKEN}
rendered=${rendered//\{number\}/$feature_num}
rendered=${rendered//\{slug\}/$branch_suffix}
printf '%s\n' "$rendered"
}
validate_branch_template() {
local template="$1"
[ -n "$template" ] || return 0
local feature_segment
feature_segment="${template##*/}"
case "$template" in
*"{number}"*) ;;
*)
>&2 echo "Error: branch_template must include the {number} token so generated branches remain valid feature branches."
exit 1
;;
esac
case "$template" in
*"{slug}"*"{number}"*)
>&2 echo "Error: branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
exit 1
;;
esac
case "$feature_segment" in
"{number}-"*) ;;
*)
>&2 echo "Error: branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
exit 1
;;
esac
}
build_branch_name() {
local feature_num="$1"
local branch_suffix="$2"
if [ -n "$BRANCH_TEMPLATE" ]; then
render_branch_template "$BRANCH_TEMPLATE" "$feature_num" "$branch_suffix"
else
printf '%s-%s\n' "$feature_num" "$branch_suffix"
fi
}
branch_scope_prefix() {
local template="$1"
local prefix="$template"
[ -n "$prefix" ] || return 0
case "$prefix" in
*"{number}"*) prefix="${prefix%%\{number\}*}" ;;
*"{slug}"*) prefix="${prefix%%\{slug\}*}" ;;
*) return 0 ;;
esac
render_branch_template "$prefix" "" "$BRANCH_SUFFIX"
}
extract_feature_num_from_branch() {
local branch_name="$1"
local feature_segment="${branch_name##*/}"
local match
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true)
if [ -n "$match" ]; then
printf '%s\n' "$match" | sed -E 's/-$//'
return
fi
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]+-' | head -n 1 || true)
if [ -n "$match" ]; then
printf '%s\n' "$match" | sed -E 's/-$//'
return
fi
printf '%s\n' "$branch_name"
}
AUTHOR_TOKEN=$(get_author_token)
APP_TOKEN=$(get_app_token)
BRANCH_TEMPLATE=$(resolve_branch_template)
validate_branch_template "$BRANCH_TEMPLATE"
# Function to generate branch name with stop word filtering
generate_branch_name() {
@@ -482,8 +318,18 @@ generate_branch_name() {
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
BRANCH_NAME="$GIT_BRANCH_NAME"
FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME")
BRANCH_SUFFIX="$BRANCH_NAME"
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
else
FEATURE_NUM="$BRANCH_NAME"
BRANCH_SUFFIX="$BRANCH_NAME"
fi
else
# Generate branch name
if [ -n "$SHORT_NAME" ]; then
@@ -501,17 +347,16 @@ else
# Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
BRANCH_SCOPE_PREFIX=$(branch_scope_prefix "$BRANCH_TEMPLATE")
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true "$BRANCH_SCOPE_PREFIX")
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" false "$BRANCH_SCOPE_PREFIX")
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
@@ -519,7 +364,7 @@ else
fi
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi
fi
@@ -531,23 +376,18 @@ if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
exit 1
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
TRUNCATED_SUFFIX="$BRANCH_SUFFIX"
while [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ] && [ -n "$TRUNCATED_SUFFIX" ]; do
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%?}"
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%-}"
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$TRUNCATED_SUFFIX")
done
if [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ]; then
>&2 echo "Error: Branch template prefix exceeds GitHub's 244-byte branch name limit."
exit 1
fi
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
ORIGINAL_BRANCH_BYTE_LEN=$(_byte_length "$ORIGINAL_BRANCH_NAME")
TRUNCATED_BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${ORIGINAL_BRANCH_BYTE_LEN} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${TRUNCATED_BRANCH_BYTE_LEN} bytes)"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi
if [ "$DRY_RUN" != true ]; then

View File

@@ -23,9 +23,8 @@ spec_kit_effective_branch_name() {
}
# Validate that a branch name matches the expected feature branch pattern.
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats,
# either at the start of the branch or after path-style namespace prefixes.
# Logic aligned with the git extension's PowerShell Test-FeatureBranch twin.
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
check_feature_branch() {
local raw="$1"
local has_git_repo="$2"
@@ -38,17 +37,16 @@ check_feature_branch() {
local branch
branch=$(spec_kit_effective_branch_name "$raw")
local feature_segment="${branch##*/}"
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
if [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi

View File

@@ -34,10 +34,6 @@ if ($Help) {
Write-Host "Environment variables:"
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
Write-Host ""
Write-Host "Configuration:"
Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
Write-Host ""
exit 0
}
@@ -71,23 +67,11 @@ function Get-HighestNumberFromSpecs {
}
function Get-HighestNumberFromNames {
param(
[string[]]$Names,
[string]$ScopePrefix = ''
)
param([string[]]$Names)
[long]$highest = 0
foreach ($name in $Names) {
if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) {
continue
}
if ($ScopePrefix) {
$name = $name.Substring($ScopePrefix.Length)
}
$name = ($name -split '/')[-1]
$hasTimestampPrefix = $name -match '^\d{8}-\d{6}-'
$hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$')
if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) {
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
@@ -98,7 +82,7 @@ function Get-HighestNumberFromNames {
}
function Get-HighestNumberFromBranches {
param([string]$ScopePrefix = '')
param()
try {
$branches = git branch -a 2>$null
@@ -106,7 +90,7 @@ function Get-HighestNumberFromBranches {
$cleanNames = $branches | ForEach-Object {
$_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
}
return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix
return Get-HighestNumberFromNames -Names $cleanNames
}
} catch {
Write-Verbose "Could not check Git branches: $_"
@@ -115,8 +99,6 @@ function Get-HighestNumberFromBranches {
}
function Get-HighestNumberFromRemoteRefs {
param([string]$ScopePrefix = '')
[long]$highest = 0
try {
$remotes = git remote 2>$null
@@ -129,7 +111,7 @@ function Get-HighestNumberFromRemoteRefs {
$refNames = $refs | ForEach-Object {
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
} | Where-Object { $_ }
$remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
}
}
@@ -143,19 +125,18 @@ function Get-HighestNumberFromRemoteRefs {
function Get-NextBranchNumber {
param(
[string]$SpecsDir,
[switch]$SkipFetch,
[string]$ScopePrefix = ''
[switch]$SkipFetch
)
if ($SkipFetch) {
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
$highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix
$highestBranch = Get-HighestNumberFromBranches
$highestRemote = Get-HighestNumberFromRemoteRefs
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
} else {
try {
git fetch --all --prune 2>$null | Out-Null
} catch { }
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
$highestBranch = Get-HighestNumberFromBranches
}
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
@@ -251,145 +232,6 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
function Read-GitConfigValue {
param([string]$Key)
if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' }
$escapedKey = [regex]::Escape($Key)
foreach ($line in Get-Content -LiteralPath $configFile) {
if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") {
$val = ($matches[1] -replace '\s+#.*$', '').Trim()
$val = $val -replace '^["'']', '' -replace '["'']$', ''
return $val
}
}
return ''
}
function ConvertTo-BranchToken {
param(
[string]$Value,
[string]$Fallback
)
$cleaned = ConvertTo-CleanBranchName -Name $Value
if ($cleaned) { return $cleaned }
return $Fallback
}
function Get-GitAuthorToken {
$author = ''
if (Get-Command git -ErrorAction SilentlyContinue) {
try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {}
if (-not $author) {
try {
$email = (git config user.email 2>$null | Out-String).Trim()
if ($email) { $author = ($email -split '@')[0] }
} catch {}
}
}
if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } }
return ConvertTo-BranchToken -Value $author -Fallback 'unknown'
}
function Get-AppToken {
return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app'
}
function Resolve-BranchTemplate {
$template = Read-GitConfigValue -Key 'branch_template'
if ($template) { return $template }
$prefix = Read-GitConfigValue -Key 'branch_prefix'
if (-not $prefix) { return '' }
if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" }
return "$prefix/{number}-{slug}"
}
function Expand-BranchTemplate {
param(
[string]$Template,
[string]$FeatureNum,
[string]$BranchSuffix
)
$rendered = $Template.Replace('{author}', $authorToken)
$rendered = $rendered.Replace('{app}', $appToken)
$rendered = $rendered.Replace('{number}', $FeatureNum)
$rendered = $rendered.Replace('{slug}', $BranchSuffix)
return $rendered
}
function Assert-BranchTemplateValid {
param([string]$Template)
if ($Template -and -not $Template.Contains('{number}')) {
throw "branch_template must include the {number} token so generated branches remain valid feature branches."
}
if ($Template) {
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) {
throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
}
$featureSegment = ($Template -split '/')[-1]
if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) {
throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
}
}
}
function New-BranchName {
param(
[string]$FeatureNum,
[string]$BranchSuffix
)
if ($branchTemplate) {
return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix
}
return "$FeatureNum-$BranchSuffix"
}
function Get-BranchScopePrefix {
param(
[string]$Template,
[string]$BranchSuffix
)
if (-not $Template) { return '' }
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
$indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object
if (-not $indexes) { return '' }
$prefix = $Template.Substring(0, $indexes[0])
return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix
}
function Get-FeatureNumberFromBranchName {
param([string]$BranchName)
$featureSegment = ($BranchName -split '/')[-1]
if ($featureSegment -match '^(\d{8}-\d{6})-') {
return $matches[1]
}
if ($featureSegment -match '^(\d+)-') {
return $matches[1]
}
return $BranchName
}
function Get-Utf8ByteCount {
param([string]$Value)
return [System.Text.Encoding]::UTF8.GetByteCount($Value)
}
$authorToken = Get-GitAuthorToken
$appToken = Get-AppToken
$branchTemplate = Resolve-BranchTemplate
Assert-BranchTemplateValid -Template $branchTemplate
function Get-BranchName {
param([string]$Description)
@@ -434,11 +276,19 @@ function Get-BranchName {
if ($env:GIT_BRANCH_NAME) {
$branchName = $env:GIT_BRANCH_NAME
# Check 244-byte limit (UTF-8) for override names
$branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
if ($branchNameUtf8ByteCount -gt 244) {
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
}
$featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
if ($branchName -match '^(\d{8}-\d{6})-') {
$featureNum = $matches[1]
} elseif ($branchName -match '^(\d+)-') {
$featureNum = $matches[1]
} else {
$featureNum = $branchName
}
} else {
if ($ShortName) {
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
@@ -453,41 +303,39 @@ if ($env:GIT_BRANCH_NAME) {
if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
$branchName = "$featureNum-$branchSuffix"
} else {
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
if ($Number -eq 0) {
if ($DryRun -and $hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
} elseif ($DryRun) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} elseif ($hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
}
$featureNum = ('{0:000}' -f $Number)
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
$branchName = "$featureNum-$branchSuffix"
}
}
$maxBranchLength = 244
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
if ($branchName.Length -gt $maxBranchLength) {
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$originalBranchName = $branchName
$truncatedSuffix = $branchSuffix
while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) {
$truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', ''
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix
}
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
throw "Branch template prefix exceeds GitHub's 244-byte branch name limit."
}
$branchName = "$featureNum-$truncatedSuffix"
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)"
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
}
if (-not $DryRun) {

View File

@@ -37,15 +37,14 @@ function Test-FeatureBranch {
$raw = $Branch
$Branch = Get-SpecKitEffectiveBranchName $raw
$featureSegment = ($Branch -split '/')[-1]
# Accept sequential prefix (3+ digits), at the start or after namespace
# segments, but exclude malformed timestamps.
$hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') {
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
return $false
}
return $true

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.12.7"
version = "0.12.5.dev0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -1,207 +0,0 @@
#!/usr/bin/env python3
"""Consolidated prerequisite checking script."""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from pathlib import Path
try:
from common import FeaturePaths, format_speckit_command, get_feature_paths
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import FeaturePaths, format_speckit_command, get_feature_paths
def _json_line(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS]
Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS:
--json Output in JSON format
--require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation)
--help, -h Show this help message
EXAMPLES:
# Check task prerequisites (plan.md required)
./check_prerequisites.py --json
# Check implementation prerequisites (plan.md + tasks.md required)
./check_prerequisites.py --json --require-tasks --include-tasks
# Get feature paths only (no validation)
./check_prerequisites.py --paths-only
"""
@dataclass(frozen=True)
class Args:
json_mode: bool = False
require_tasks: bool = False
include_tasks: bool = False
paths_only: bool = False
def _parse_args(argv: list[str]) -> Args:
json_mode = False
require_tasks = False
include_tasks = False
paths_only = False
for arg in argv:
if arg == "--json":
json_mode = True
elif arg == "--require-tasks":
require_tasks = True
elif arg == "--include-tasks":
include_tasks = True
elif arg == "--paths-only":
paths_only = True
elif arg in {"--help", "-h"}:
sys.stdout.write(HELP_TEXT)
raise SystemExit(0)
else:
print(
f"ERROR: Unknown option '{arg}'. Use --help for usage information.",
file=sys.stderr,
)
raise SystemExit(1)
return Args(
json_mode=json_mode,
require_tasks=require_tasks,
include_tasks=include_tasks,
paths_only=paths_only,
)
def _dir_has_entries(path: Path) -> bool:
try:
return path.is_dir() and any(path.iterdir())
except OSError:
return False
def _available_docs(paths: FeaturePaths, include_tasks: bool) -> list[str]:
docs: list[str] = []
if paths.research.is_file():
docs.append("research.md")
if paths.data_model.is_file():
docs.append("data-model.md")
if _dir_has_entries(paths.contracts_dir):
docs.append("contracts/")
if paths.quickstart.is_file():
docs.append("quickstart.md")
if include_tasks and paths.tasks.is_file():
docs.append("tasks.md")
return docs
def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None:
if json_mode:
sys.stdout.write(
_json_line(
{
"REPO_ROOT": str(paths.repo_root),
"BRANCH": paths.current_branch,
"FEATURE_DIR": str(paths.feature_dir),
"FEATURE_SPEC": str(paths.feature_spec),
"IMPL_PLAN": str(paths.impl_plan),
"TASKS": str(paths.tasks),
}
)
)
return
print(f"REPO_ROOT: {paths.repo_root}")
print(f"BRANCH: {paths.current_branch}")
print(f"FEATURE_DIR: {paths.feature_dir}")
print(f"FEATURE_SPEC: {paths.feature_spec}")
print(f"IMPL_PLAN: {paths.impl_plan}")
print(f"TASKS: {paths.tasks}")
def _check_file(path: Path, description: str) -> None:
marker = "" if path.is_file() else ""
print(f" {marker} {description}")
def _check_dir(path: Path, description: str) -> None:
marker = "" if _dir_has_entries(path) else ""
print(f" {marker} {description}")
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
print(f"FEATURE_DIR:{paths.feature_dir}")
print("AVAILABLE_DOCS:")
_check_file(paths.research, "research.md")
_check_file(paths.data_model, "data-model.md")
_check_dir(paths.contracts_dir, "contracts/")
_check_file(paths.quickstart, "quickstart.md")
if include_tasks:
_check_file(paths.tasks, "tasks.md")
def main(argv: list[str] | None = None) -> int:
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
try:
paths = get_feature_paths(
no_persist=args.paths_only,
script_file=Path(__file__),
)
except SystemExit as exc:
if exc.code == 0:
return 0
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
return int(exc.code) if isinstance(exc.code, int) else 1
if args.paths_only:
_print_paths_only(paths, args.json_mode)
return 0
if not paths.feature_dir.is_dir():
print(f"ERROR: Feature directory not found: {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
file=sys.stderr,
)
return 1
if not paths.impl_plan.is_file():
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
file=sys.stderr,
)
return 1
if args.require_tasks and not paths.tasks.is_file():
print(f"ERROR: tasks.md not found in {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('tasks', paths.repo_root)} first to create the task list.",
file=sys.stderr,
)
return 1
docs = _available_docs(paths, args.include_tasks)
if args.json_mode:
sys.stdout.write(
_json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs})
)
else:
_print_text_results(paths, args.include_tasks)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,210 +0,0 @@
"""Shared helpers for Spec Kit Python scripts."""
from __future__ import annotations
import json
import os
import sys
from dataclasses import dataclass
from pathlib import Path
def _trim_trailing_separators(value: Path) -> str:
text = str(value)
while len(text) > 1 and text.endswith((os.sep, "/")):
text = text[:-1]
return text
def find_specify_root(start_dir: Path | None = None) -> Path | None:
current = (start_dir or Path.cwd()).resolve()
while True:
if (current / ".specify").is_dir():
return current
parent = current.parent
if parent == current:
return None
current = parent
def resolve_specify_init_dir() -> Path:
raw = os.environ.get("SPECIFY_INIT_DIR", "")
candidate = Path(raw)
if not candidate.is_absolute():
candidate = Path.cwd() / candidate
try:
init_root = candidate.resolve(strict=True)
except OSError:
print(
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
file=sys.stderr,
)
raise SystemExit(1)
if not init_root.is_dir():
print(
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
file=sys.stderr,
)
raise SystemExit(1)
if not (init_root / ".specify").is_dir():
print(
"ERROR: SPECIFY_INIT_DIR is not a Spec Kit project "
f"(no .specify/ directory): {init_root}",
file=sys.stderr,
)
raise SystemExit(1)
return init_root
def get_repo_root(script_file: Path | None = None) -> Path:
if os.environ.get("SPECIFY_INIT_DIR"):
return resolve_specify_init_dir()
specify_root = find_specify_root()
if specify_root is not None:
return specify_root
if script_file is not None:
script_root = find_specify_root(script_file.resolve().parent)
if script_root is not None:
return script_root
# Installed scripts live at .specify/scripts/python/<script>.py.
return script_file.resolve().parents[3]
return Path.cwd().resolve()
def get_current_branch() -> str:
return os.environ.get("SPECIFY_FEATURE", "")
def read_feature_json_feature_directory(repo_root: Path) -> str:
feature_json = repo_root / ".specify" / "feature.json"
if not feature_json.is_file():
return ""
try:
data = json.loads(feature_json.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return ""
value = data.get("feature_directory") if isinstance(data, dict) else None
return value if isinstance(value, str) else ""
def _json_dump(data: dict[str, str]) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n"
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
value = feature_dir_value
try:
relative = Path(value)
if relative.is_absolute():
try:
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
except ValueError:
value = str(relative)
except OSError:
pass
current = read_feature_json_feature_directory(repo_root)
if current == value:
return
specify_dir = repo_root / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "feature.json").write_text(
_json_dump({"feature_directory": value}),
encoding="utf-8",
)
@dataclass(frozen=True)
class FeaturePaths:
repo_root: Path
current_branch: str
feature_dir: Path
feature_spec: Path
impl_plan: Path
tasks: Path
research: Path
data_model: Path
quickstart: Path
contracts_dir: Path
def get_feature_paths(
*, no_persist: bool = False, script_file: Path | None = None
) -> FeaturePaths:
repo_root = get_repo_root(script_file)
current_branch = get_current_branch()
feature_dir_raw = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "")
if feature_dir_raw:
feature_dir = Path(feature_dir_raw)
if not feature_dir.is_absolute():
feature_dir = repo_root / feature_dir
if not no_persist:
persist_feature_json(repo_root, feature_dir_raw)
elif (repo_root / ".specify" / "feature.json").is_file():
stored = read_feature_json_feature_directory(repo_root)
if not stored:
print(
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
"or ensure .specify/feature.json contains feature_directory.",
file=sys.stderr,
)
raise SystemExit(1)
feature_dir = Path(stored)
if not feature_dir.is_absolute():
feature_dir = repo_root / feature_dir
else:
print(
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
"or run the specify command to create .specify/feature.json.",
file=sys.stderr,
)
raise SystemExit(1)
if not current_branch:
current_branch = Path(_trim_trailing_separators(feature_dir)).name
return FeaturePaths(
repo_root=repo_root,
current_branch=current_branch,
feature_dir=feature_dir,
feature_spec=feature_dir / "spec.md",
impl_plan=feature_dir / "plan.md",
tasks=feature_dir / "tasks.md",
research=feature_dir / "research.md",
data_model=feature_dir / "data-model.md",
quickstart=feature_dir / "quickstart.md",
contracts_dir=feature_dir / "contracts",
)
def get_invoke_separator(repo_root: Path) -> str:
integration_json = repo_root / ".specify" / "integration.json"
if not integration_json.is_file():
return "."
try:
state = json.loads(integration_json.read_text(encoding="utf-8"))
key = state.get("default_integration") or state.get("integration") or ""
settings = state.get("integration_settings")
if isinstance(key, str) and isinstance(settings, dict):
entry = settings.get(key)
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
return entry["invoke_separator"]
except (OSError, json.JSONDecodeError):
pass
return "."
def format_speckit_command(command_name: str, repo_root: Path) -> str:
separator = get_invoke_separator(repo_root)
name = command_name.lstrip("/")
if name.startswith("speckit."):
name = name[len("speckit.") :]
elif name.startswith("speckit-"):
name = name[len("speckit-") :]
name = name.replace(".", separator)
return f"/speckit{separator}{name}"

View File

@@ -148,9 +148,7 @@ class CommandRegistrar:
)
return f"---\n{yaml_str}---\n"
def _adjust_script_paths(
self, frontmatter: dict, extension_id: Optional[str] = None
) -> dict:
def _adjust_script_paths(self, frontmatter: dict) -> dict:
"""Normalize script paths in frontmatter to generated project locations.
Rewrites known repo-relative and top-level script paths under the
@@ -160,7 +158,6 @@ class CommandRegistrar:
Args:
frontmatter: Frontmatter dictionary
extension_id: Extension id when rendering extension-owned commands.
Returns:
Modified frontmatter with normalized project paths
@@ -171,15 +168,11 @@ class CommandRegistrar:
if isinstance(scripts, dict):
for key, script_path in scripts.items():
if isinstance(script_path, str):
scripts[key] = self.rewrite_project_relative_paths(
script_path, extension_id=extension_id
)
scripts[key] = self.rewrite_project_relative_paths(script_path)
return frontmatter
@staticmethod
def rewrite_project_relative_paths(
text: str, extension_id: Optional[str] = None
) -> str:
def rewrite_project_relative_paths(text: str) -> str:
"""Rewrite repo-relative paths to their generated project locations."""
if not isinstance(text, str) or not text:
return text
@@ -191,18 +184,10 @@ class CommandRegistrar:
):
text = text.replace(old, new)
# Only rewrite top-level style references so existing generated paths
# like ".specify/extensions/<ext>/scripts/..." remain intact. When
# rendering extension commands, top-level "scripts/" is extension-local.
scripts_replacement = (
f".specify/extensions/{extension_id}/scripts/"
if extension_id
else ".specify/scripts/"
)
# Only rewrite top-level style references so extension-local paths like
# ".specify/extensions/<ext>/scripts/..." remain intact.
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
text = re.sub(
r'(^|[\s`"\'(])(?:\.?/)?scripts/', rf"\1{scripts_replacement}", text
)
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
text = re.sub(
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
)
@@ -327,7 +312,6 @@ class CommandRegistrar:
source_id: str,
source_file: str,
project_root: Path,
extension_id: Optional[str] = None,
) -> str:
"""Render a command override as a SKILL.md file.
@@ -347,7 +331,7 @@ class CommandRegistrar:
agent_config = self.AGENT_CONFIGS.get(agent_name, {})
if agent_config.get("extension") == "/SKILL.md":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root, extension_id=extension_id
agent_name, frontmatter, body, project_root
)
description = frontmatter.get(
@@ -409,11 +393,7 @@ class CommandRegistrar:
@staticmethod
def resolve_skill_placeholders(
agent_name: str,
frontmatter: dict,
body: str,
project_root: Path,
extension_id: Optional[str] = None,
agent_name: str, frontmatter: dict, body: str, project_root: Path
) -> str:
"""Resolve script placeholders for skills-backed agents."""
if not isinstance(frontmatter, dict):
@@ -453,9 +433,7 @@ class CommandRegistrar:
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
return CommandRegistrar.rewrite_project_relative_paths(
body, extension_id=extension_id
)
return CommandRegistrar.rewrite_project_relative_paths(body)
def _convert_argument_placeholder(
self, content: str, from_placeholder: str, to_placeholder: str
@@ -550,7 +528,6 @@ class CommandRegistrar:
context_note: str = None,
_resolved_dir: Path = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
) -> List[str]:
"""Register commands for a specific agent.
@@ -568,7 +545,6 @@ class CommandRegistrar:
link_outputs: If True, write rendered output to a source-local
dev cache and symlink the agent command file to it. Falls back
to a normal file write when symlinks are unavailable.
extension_id: Extension id when rendering extension-owned commands.
Returns:
List of registered command names
@@ -638,9 +614,7 @@ class CommandRegistrar:
frontmatter[key] = core_frontmatter[key]
frontmatter.pop("strategy", None)
frontmatter = self._adjust_script_paths(
frontmatter, extension_id=extension_id
)
frontmatter = self._adjust_script_paths(frontmatter)
for key in agent_config.get("strip_frontmatter_keys", []):
frontmatter.pop(key, None)
@@ -679,11 +653,10 @@ class CommandRegistrar:
source_id,
cmd_file,
project_root,
extension_id=extension_id,
)
elif agent_config["format"] == "markdown":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root, extension_id=extension_id
agent_name, frontmatter, body, project_root
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
@@ -693,7 +666,7 @@ class CommandRegistrar:
)
elif agent_config["format"] == "toml":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root, extension_id=extension_id
agent_name, frontmatter, body, project_root
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
@@ -706,17 +679,6 @@ class CommandRegistrar:
else:
raise ValueError(f"Unsupported format: {agent_config['format']}")
# -- Post-process for non-skills agents -----------------------
_integration = None
if agent_config["extension"] != "/SKILL.md":
from specify_cli.integrations import ( # noqa: PLC0415
get_integration,
)
_integration = get_integration(agent_name)
if _integration is not None:
output = _integration.post_process_command_content(output)
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
self._ensure_inside(dest_file, commands_dir)
dest_file.parent.mkdir(parents=True, exist_ok=True)
@@ -759,7 +721,6 @@ class CommandRegistrar:
source_id,
cmd_file,
project_root,
extension_id=extension_id,
)
elif agent_config["format"] == "markdown":
alias_output = self.render_markdown_command(
@@ -777,9 +738,6 @@ class CommandRegistrar:
raise ValueError(
f"Unsupported format: {agent_config['format']}"
)
if agent_config["extension"] != "/SKILL.md" and _integration is not None:
alias_output = _integration.post_process_command_content(alias_output)
else:
# For other agents, reuse the primary output
alias_output = output
@@ -792,7 +750,6 @@ class CommandRegistrar:
source_id,
cmd_file,
project_root,
extension_id=extension_id,
)
alias_file = (
@@ -924,7 +881,6 @@ class CommandRegistrar:
context_note: str = None,
link_outputs: bool = False,
create_missing_active_skills_dir: bool = False,
extension_id: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
@@ -941,7 +897,6 @@ class CommandRegistrar:
Recovery requires active skills mode (or Kimi's existing native
skills directory) and is skipped when safe resolution or
creation fails.
extension_id: Extension id when rendering extension-owned commands.
Returns:
Dictionary mapping agent names to list of registered commands
@@ -1044,7 +999,6 @@ class CommandRegistrar:
context_note=context_note,
_resolved_dir=agent_dir,
link_outputs=link_outputs,
extension_id=extension_id,
)
if registered:
results[agent_name] = registered
@@ -1069,7 +1023,6 @@ class CommandRegistrar:
project_root: Path,
context_note: Optional[str] = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register commands for all non-skill agents in the project.
@@ -1085,7 +1038,6 @@ class CommandRegistrar:
context_note: Custom context comment for markdown output
link_outputs: If True, create dev-mode symlinks for rendered
command files when supported by the OS.
extension_id: Extension id when rendering extension-owned commands.
Returns:
Dictionary mapping agent names to list of registered commands
@@ -1114,7 +1066,6 @@ class CommandRegistrar:
context_note=context_note,
_resolved_dir=agent_dir,
link_outputs=link_outputs,
extension_id=extension_id,
)
if registered:
results[agent_name] = registered

View File

@@ -95,11 +95,7 @@ def _is_local_path(url: str) -> bool:
"""True when *url* denotes a local filesystem path rather than a URL."""
if _WINDOWS_DRIVE_RE.match(url):
return True
try:
scheme = urlparse(url).scheme.lower()
except ValueError:
# Malformed URLs (e.g. an unclosed IPv6 bracket) are not local paths.
return False
scheme = urlparse(url).scheme.lower()
return scheme not in _REMOTE_SCHEMES
@@ -141,10 +137,7 @@ def add_source(
url = url.strip()
if not url:
raise BundlerError("A catalog url is required.")
try:
parsed = urlparse(url)
except ValueError as exc:
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
parsed = urlparse(url)
if not (parsed.scheme or parsed.path):
raise BundlerError(f"Invalid catalog url: '{url}'.")
# Reject unsupported URL schemes (e.g. ssh://, ftp://) up front so they are
@@ -155,20 +148,6 @@ def add_source(
f"Unsupported catalog url scheme '{parsed.scheme}://' in '{url}'. "
"Use http(s)://, file://, builtin://, or a local path."
)
if parsed.scheme.lower() in {"http", "https"}:
# Mirror specify_cli.catalogs._validate_catalog_url (#3209/#3210):
# HTTPS only (HTTP just for localhost), and check hostname, not
# netloc — netloc is truthy for host-less URLs like "https://:8080"
# or "https://user@". Validating here keeps junk out of
# bundle-catalogs.yml instead of failing later at fetch time.
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme.lower() != "https" and not is_localhost:
raise BundlerError(
f"Catalog url must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.hostname:
raise BundlerError(f"Catalog url must be a valid URL with a host: {url}")
url = _canonicalize_url(url)
install_policy = InstallPolicy.parse(policy)

View File

@@ -75,11 +75,7 @@ def _validate_remote_url(source_id: str, url: str) -> None:
f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
# Check hostname, not netloc: netloc is truthy for host-less URLs like
# "https://:8080" or "https://user@...", so requiring netloc would let
# those through even though they carry no host. hostname is None in those
# cases. Mirrors the fix in ``specify_cli.catalogs`` (#3210).
if not parsed.hostname:
if not parsed.netloc:
raise BundlerError(
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
)

View File

@@ -88,25 +88,17 @@ class CatalogStack:
Results are sorted by bundle id for deterministic output.
"""
needle = query.strip().lower()
# Resolve each id to its highest-precedence entry FIRST, then filter by
# the query. Claiming an id only when it matches would let a lower-
# precedence entry with the same id surface when the highest-precedence
# one doesn't match the query — but that shadowed entry is not what
# `resolve()`/install would use, so search would advertise a bundle
# (name, version, author) the user can never actually get.
resolved: dict[str, ResolvedBundle] = {}
seen: dict[str, ResolvedBundle] = {}
for source in self._sources:
for bundle_id, entry in self._entries_for(source).items():
if bundle_id in resolved:
if bundle_id in seen:
continue
resolved[bundle_id] = ResolvedBundle(
if needle and not _matches(entry, needle):
continue
seen[bundle_id] = ResolvedBundle(
entry=entry.with_provenance(source), source=source
)
return [
resolved[k]
for k in sorted(resolved)
if not needle or _matches(resolved[k].entry, needle)
]
return [seen[k] for k in sorted(seen)]
def _matches(entry: CatalogEntry, needle: str) -> bool:

View File

@@ -130,28 +130,6 @@ def install_bundle(
done.append(component)
result.installed.append(component)
contributed.append(component)
# On update (refresh), uninstall components this bundle used to own
# that the new version no longer ships. Otherwise they are dropped
# from the record below (contributed only holds plan.components) yet
# left on disk — permanently orphaned, since no bundle record can
# ever remove them. A stale component still owned by another bundle
# is kept installed and simply de-attributed here (it stays in that
# bundle's record). Mirrors remove_bundle's refcount logic.
if refresh and existing is not None:
planned = {(c.kind, c.id) for c in plan.components}
still_needed = components_still_needed(
records, exclude_bundle_id=plan.bundle_id
)
for component in existing.contributed_components:
key = (component.kind, component.id)
if key in planned:
continue
if key in still_needed:
continue
if installer.is_installed(project_root, component):
installer.remove(project_root, component)
result.uninstalled.append(component)
except BundlerError:
_rollback(project_root, installer, done)
raise

View File

@@ -1075,11 +1075,9 @@ class ExtensionManager:
pass # best-effort cleanup
continue
frontmatter, body = registrar.parse_frontmatter(content)
frontmatter = registrar._adjust_script_paths(
frontmatter, extension_id=manifest.id
)
frontmatter = registrar._adjust_script_paths(frontmatter)
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
selected_ai, frontmatter, body, self.project_root
)
original_desc = frontmatter.get("description", "")
@@ -1960,7 +1958,6 @@ class CommandRegistrar:
project_root,
context_note=context_note,
link_outputs=link_outputs,
extension_id=manifest.id,
)
def register_commands_for_all_agents(
@@ -1981,7 +1978,6 @@ class CommandRegistrar:
context_note=context_note,
link_outputs=link_outputs,
create_missing_active_skills_dir=create_missing_active_skills_dir,
extension_id=manifest.id,
)
def unregister_commands(
@@ -2692,12 +2688,7 @@ class ConfigManager:
return {}
try:
data = yaml.safe_load(file_path.read_text(encoding="utf-8"))
# Coerce a non-mapping root (list/scalar, or None for an empty
# file) to {} so callers that iterate/merge the result — e.g.
# _merge_configs' .items() — never crash. Mirrors the same
# non-dict-root guard in get_project_config().
return data if isinstance(data, dict) else {}
return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError):
return {}

View File

@@ -89,11 +89,7 @@ class AgyIntegration(SkillsIntegration):
output_json: bool = True,
) -> list[str] | None:
# agy does not support --model or JSON output; both params are ignored
args = [self._resolve_executable(), "--print", prompt]
# Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags),
# appended after the positional prompt like the devin integration.
self._apply_extra_args_env_var(args)
return args
return [self._resolve_executable(), "--print", prompt]
def setup(
self,

View File

@@ -123,19 +123,6 @@ class IntegrationBase(ABC):
integration that sets this flag.
"""
def post_process_command_content(self, content: str) -> str:
"""Transform command content after format rendering.
Called by ``register_commands()`` for non-skills format types
(Markdown, TOML, YAML) after the command has been rendered into
its target format and before writing to disk. Skills-format
agents use ``post_process_skill_content()`` instead.
Subclasses may override to inject agent-specific content.
The default implementation returns *content* unchanged.
"""
return content
# -- Public API -------------------------------------------------------
@classmethod
@@ -1191,18 +1178,12 @@ class YamlIntegration(IntegrationBase):
default_flow_style=False,
).strip()
# 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
# first line is itself indented (e.g. a markdown code block or a nested
# list item) would make the parser expect that deeper indent for the
# whole block and reject the later, less-indented lines. Pinning the
# indent to 2 keeps the recipe parseable whatever the body looks like.
# Indent the body for YAML block scalar
indented = "\n".join(f" {line}" for line in body.split("\n"))
lines = [
header_yaml,
"prompt: |2",
"prompt: |",
indented,
"",
f"# Source: {source_id}",

View File

@@ -253,11 +253,6 @@ class HermesIntegration(SkillsIntegration):
"""
args = [self._resolve_executable(), "chat", "-Q"]
# Operator-supplied SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS go here —
# after the base command but before Spec Kit's canonical -m/--json/-s/-q
# flags — so they can't displace or clobber them (mirrors opencode).
self._apply_extra_args_env_var(args)
if model:
args.extend(["-m", model])
if output_json:

View File

@@ -50,17 +50,7 @@ workflow_step_catalog_app = typer.Typer(
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
def _error_console(json_output: bool):
"""Console for error text: stderr under ``--json`` so the JSON stdout
stream stays parseable, the normal console otherwise. Mirrors the
stderr-only error routing already used by ``specify bundle``.
"""
return err_console if json_output else console
def _parse_input_values(
input_values: list[str] | None, *, json_output: bool = False
) -> dict[str, Any]:
def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
"""Parse repeated ``key=value`` CLI inputs into a dict.
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
@@ -69,9 +59,7 @@ def _parse_input_values(
inputs: dict[str, Any] = {}
for kv in input_values or []:
if "=" not in kv:
_error_console(json_output).print(
f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)"
)
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
raise typer.Exit(1)
key, _, value = kv.partition("=")
inputs[key.strip()] = value.strip()
@@ -347,26 +335,25 @@ def workflow_run(
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
err = _error_console(json_output)
try:
definition = engine.load_workflow(source_path if is_file_source else source)
except FileNotFoundError:
err.print(f"[red]Error:[/red] Workflow not found: {source}")
console.print(f"[red]Error:[/red] Workflow not found: {source}")
raise typer.Exit(1)
except ValueError as exc:
err.print(f"[red]Error:[/red] Invalid workflow: {exc}")
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
raise typer.Exit(1)
# Validate
errors = engine.validate(definition)
if errors:
err.print("[red]Workflow validation failed:[/red]")
for verr in errors:
err.print(f"{verr}")
console.print("[red]Workflow validation failed:[/red]")
for err in errors:
console.print(f"{err}")
raise typer.Exit(1)
# Parse inputs
inputs = _parse_input_values(input_values, json_output=json_output)
inputs = _parse_input_values(input_values)
if not json_output:
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
@@ -376,10 +363,10 @@ def workflow_run(
with _stdout_to_stderr_when(json_output):
state = engine.execute(definition, inputs)
except ValueError as exc:
err.print(f"[red]Error:[/red] {exc}")
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
err.print(f"[red]Workflow failed:[/red] {exc}")
console.print(f"[red]Workflow failed:[/red] {exc}")
raise typer.Exit(1)
if json_output:
@@ -424,20 +411,19 @@ def workflow_resume(
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
inputs = _parse_input_values(input_values, json_output=json_output)
err = _error_console(json_output)
inputs = _parse_input_values(input_values)
try:
with _stdout_to_stderr_when(json_output):
state = engine.resume(run_id, inputs or None)
except FileNotFoundError:
err.print(f"[red]Error:[/red] Run not found: {run_id}")
console.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
except ValueError as exc:
err.print(f"[red]Error:[/red] {exc}")
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
err.print(f"[red]Resume failed:[/red] {exc}")
console.print(f"[red]Resume failed:[/red] {exc}")
raise typer.Exit(1)
if json_output:

View File

@@ -183,65 +183,6 @@ def _is_single_expression(stripped: str) -> bool:
return True
def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
"""Substitute every top-level ``{{ ... }}`` block in *template*, quote-aware.
Walks the template and, for each block, finds the closing ``}}`` that lies
outside string literals -- the same quote-scanning used by
``_is_single_expression``. This keeps a literal ``}}`` inside a string
argument (e.g. ``| default('}}')``) from prematurely closing a block.
``_EXPR_PATTERN.sub`` cannot do this: its non-greedy body stops at the first
``}}`` regardless of quoting, so in a multi-expression template any block
whose argument contains a literal ``}}`` is captured truncated and mis-parsed
(raising ``ValueError`` from the filter parser). #3208/#3228 fixed exactly
this for the single-expression fast path but left the interpolation path on
the old regex.
"""
out: list[str] = []
i = 0
n = len(template)
while i < n:
start = template.find("{{", i)
if start == -1:
out.append(template[i:])
break
out.append(template[i:start])
# Scan for the block-closing ``}}`` that is outside any string literal.
j = start + 2
quote: str | None = None
close = -1
while j < n:
ch = template[j]
if quote is not None:
if ch == quote:
quote = None
elif ch in ("'", '"'):
quote = ch
elif ch == "}" and j + 1 < n and template[j + 1] == "}":
close = j
break
j += 1
if close == -1:
# No quote-aware close. Two sub-cases, both kept identical to the old
# regex so a malformed template is never silently hidden:
# * a raw ``}}`` still exists in the tail (e.g. an unbalanced quote
# in a filter arg swallowed the real delimiter) -- fall back to
# that first raw ``}}`` and evaluate, letting the parser surface
# a ValueError just as ``_EXPR_PATTERN.sub`` would have.
# * no ``}}`` at all -- a genuinely unterminated ``{{``; leave the
# tail verbatim, again matching the regex (which cannot match).
raw_close = template.find("}}", start + 2)
if raw_close == -1:
out.append(template[start:])
break
close = raw_close
val = _evaluate_simple_expression(template[start + 2:close].strip(), namespace)
out.append(str(val) if val is not None else "")
i = close + 2
return "".join(out)
def _split_top_level_commas(text: str) -> list[str]:
"""Split *text* on commas that are not inside quotes or nested brackets.
@@ -467,34 +408,15 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
return _resolve_dot_path(namespace, expr)
def _coerce_number(value: Any) -> Any:
"""Return *value* as int/float if it is a numeric string, else unchanged."""
if isinstance(value, str):
try:
return float(value) if "." in value else int(value)
except ValueError:
return value
return value
def _safe_compare(left: Any, right: Any, op: str) -> bool:
"""Compare two values for ordering, coercing numeric strings when possible.
Numeric coercion is applied only when *both* operands look numeric, so a
pair like ``"10"`` and ``"9"`` compares as numbers (10 > 9). When either
side is a non-numeric string, both fall back to their original values and
are compared directly -- so ordinary strings (dates, semver-ish tags,
names) compare lexicographically the way Python does, instead of every
such comparison silently returning ``False`` after a failed int()/float()
coercion. A genuinely incomparable pair (e.g. number vs non-numeric string)
raises ``TypeError`` and yields ``False``.
"""
cl, cr = _coerce_number(left), _coerce_number(right)
# Only use the coerced numbers when both converted; otherwise a numeric
# string paired with a plain string would become an int-vs-str mismatch
# (always False) rather than a lexicographic string comparison.
if isinstance(cl, (int, float)) and isinstance(cr, (int, float)):
left, right = cl, cr
"""Safely compare two values, coercing types when possible."""
try:
if isinstance(left, str):
left = float(left) if "." in left else int(left)
if isinstance(right, str):
right = float(right) if "." in right else int(right)
except (ValueError, TypeError):
return False
try:
if op == ">":
return left > right # type: ignore[operator]
@@ -550,11 +472,12 @@ def evaluate_expression(template: str, context: Any) -> Any:
if _is_single_expression(stripped):
return _evaluate_simple_expression(stripped[2:-2].strip(), namespace)
# Multi-expression: interpolate each block inline. Uses a quote-aware scan
# (not ``_EXPR_PATTERN.sub``) so a literal ``}}`` inside a string argument
# in any block does not close that block early -- matching the handling the
# single-expression path above already got in #3208/#3228.
return _interpolate_expressions(template, namespace)
# Multi-expression: string interpolation
def _replacer(m: re.Match[str]) -> str:
val = _evaluate_simple_expression(m.group(1).strip(), namespace)
return str(val) if val is not None else ""
return _EXPR_PATTERN.sub(_replacer, template)
def evaluate_condition(condition: str, context: Any) -> bool:

View File

@@ -58,13 +58,4 @@ class FanInStep(StepBase):
f"Fan-in step {config.get('id', '?')!r}: "
f"'wait_for' must be a non-empty list of step IDs."
)
output = config.get("output")
if output is not None and not isinstance(output, dict):
# execute() silently coerces a non-mapping output to {}, so the
# author's declared aggregation keys would vanish with no error.
# Reject at validation, mirroring the command-step (#3262) fix.
errors.append(
f"Fan-in step {config.get('id', '?')!r}: 'output' must be a "
f"mapping of key -> expression, got {type(output).__name__}."
)
return errors

View File

@@ -73,14 +73,7 @@ class GateStep(StepBase):
choice = self._prompt(self._compose_prompt(message, show_file), options)
output["choice"] = choice
# Match rejection case-insensitively. ``_prompt`` echoes the option's
# original casing, and ``validate`` accepts a reject option
# case-insensitively (``o.lower() in {"reject", "abort"}``), so a gate
# authored as ``options: [Approve, Reject]`` passes validation. Comparing
# ``choice`` case-sensitively here would then treat a ``Reject`` pick as
# approval and silently skip the abort — the reject path must agree with
# the check that let the option through.
if choice.lower() in ("reject", "abort"):
if choice in ("reject", "abort"):
if on_reject == "abort":
output["aborted"] = True
return StepResult(

View File

@@ -90,16 +90,6 @@ class ShellStep(StepBase):
errors.append(
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
)
elif not isinstance(config["run"], str):
# execute() str()-coerces run and invokes it under shell=True, so a
# null or list 'run' would run the Python repr ('None', "['echo']")
# as a command. Reject non-strings at validation, mirroring the
# command-step input/options and gate options type checks. An
# expression like "{{ ... }}" is still a str, so it stays valid.
errors.append(
f"Shell step {config.get('id', '?')!r}: 'run' must be a string, "
f"got {type(config['run']).__name__}."
)
output_format = config.get("output_format")
if output_format is not None and output_format != "json":
errors.append(

View File

@@ -122,10 +122,10 @@ def _run_bash(script_name: str, cwd: Path, *args: str, env_extra: dict | None =
)
def _run_pwsh(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess:
def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess:
"""Run an extension PowerShell script."""
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name
env = {**os.environ, **_GIT_ENV, **(env_extra or {})}
env = {**os.environ, **_GIT_ENV}
return subprocess.run(
["pwsh", "-NoProfile", "-File", str(script), *args],
cwd=cwd,
@@ -320,15 +320,6 @@ class TestCreateFeatureBash:
assert rt.returncode == 0, rt.stderr
assert "HAS_GIT" not in rt.stdout
def test_help_documents_branch_prefix(self, tmp_path: Path):
"""--help documents both template config knobs."""
project = _setup_project(tmp_path)
result = _run_bash("create-new-feature-branch.sh", project, "--help")
assert result.returncode == 0
assert "branch_template" in result.stdout
assert "branch_prefix" in result.stdout
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
"""A short word is dropped from the derived branch name unless it appears
as an acronym in UPPERCASE in the description (case-sensitive, must match the
@@ -372,183 +363,6 @@ class TestCreateFeatureBash:
data = json.loads(result.stdout)
assert data["FEATURE_NUM"] == "003"
def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path):
"""branch_template namespaces generated branch names for monorepos."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour"
assert data["FEATURE_NUM"] == "001"
def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path):
"""branch_prefix expands to a namespace before the default branch shape."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_prefix: "features/{app}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour"
assert data["FEATURE_NUM"] == "001"
def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path):
"""Numeric-looking namespace segments must not be parsed as feature numbers."""
project = _setup_project(tmp_path / "2026-app")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path):
"""Malformed timestamp-looking branches must not inflate sequential numbering."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path):
"""Templated branch numbering ignores branches outside the current namespace."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_requires_number_token(self, tmp_path: Path):
"""Configured templates must include {number} so generated branches validate."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{app}/{slug}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{number}" in result.stderr
def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path):
"""Templates must render a final path segment that validation accepts."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{number}-" in result.stderr
def test_branch_template_rejects_slug_before_number(self, tmp_path: Path):
"""{slug} before {number} would make branch-number scanning slug-specific."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{slug}" in result.stderr
assert "{number}" in result.stderr
def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path):
"""GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch."""
project = _setup_project(tmp_path / "app-a")
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path):
"""GIT_BRANCH_NAME uses the feature segment, not numeric namespace segments."""
project = _setup_project(tmp_path / "2026-app")
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path):
"""GIT_BRANCH_NAME without a feature marker keeps the historical FEATURE_NUM."""
project = _setup_project(tmp_path / "app-a")
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch"
assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch"
def test_truncation_warning_reports_utf8_bytes(self):
"""Bash truncation warnings should use the same byte counter as enforcement."""
source = (EXT_BASH / "create-new-feature-branch.sh").read_text(encoding="utf-8")
assert '_byte_length "$ORIGINAL_BRANCH_NAME"' in source
assert '_byte_length "$BRANCH_NAME"' in source
def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path):
"""Branches checked out in sibling worktrees still reserve their prefix."""
project = _setup_project(tmp_path / "project")
@@ -670,15 +484,6 @@ class TestCreateFeaturePowerShell:
assert rt.returncode == 0, rt.stderr
assert "HAS_GIT" not in rt.stdout
def test_help_documents_branch_prefix(self, tmp_path: Path):
"""-Help documents both template config knobs."""
project = _setup_project(tmp_path)
result = _run_pwsh("create-new-feature-branch.ps1", project, "-Help")
assert result.returncode == 0
assert "branch_template" in result.stdout
assert "branch_prefix" in result.stdout
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
"""PowerShell must match the bash twin: a short word is dropped unless it
appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match)."""
@@ -720,176 +525,6 @@ class TestCreateFeaturePowerShell:
data = json.loads(result.stdout)
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path):
"""PowerShell supports branch_template namespaces."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour"
assert data["FEATURE_NUM"] == "001"
def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path):
"""PowerShell supports branch_prefix shorthand namespaces."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_prefix: "features/{app}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour"
assert data["FEATURE_NUM"] == "001"
def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path):
"""PowerShell ignores numeric-looking namespace segments when numbering."""
project = _setup_project(tmp_path / "2026-app")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path):
"""PowerShell skips malformed timestamp-looking refs during sequential numbering."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path):
"""PowerShell templated numbering ignores branches outside the namespace."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_requires_number_token(self, tmp_path: Path):
"""PowerShell rejects templates without {number}."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{app}/{slug}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{number}" in result.stderr
def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path):
"""PowerShell rejects templates whose final segment cannot validate."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{number}-" in result.stderr
def test_branch_template_rejects_slug_before_number(self, tmp_path: Path):
"""PowerShell rejects templates where {slug} scopes number scanning."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{slug}" in result.stderr
assert "{number}" in result.stderr
def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path):
"""PowerShell GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch."""
project = _setup_project(tmp_path / "app-a")
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path):
"""PowerShell GIT_BRANCH_NAME ignores numeric namespace segments."""
project = _setup_project(tmp_path / "2026-app")
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path):
"""PowerShell keeps the full override name when no feature marker exists."""
project = _setup_project(tmp_path / "app-a")
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch"
assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch"
def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature-branch.ps1 works without git."""
project = _setup_project(tmp_path, git=False)
@@ -1376,31 +1011,13 @@ class TestGitCommonBash:
)
assert result.returncode == 0
def test_check_feature_branch_accepts_nested_prefix(self, tmp_path: Path):
def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'],
capture_output=True, text=True,
)
assert result.returncode == 0
def test_check_feature_branch_rejects_nested_prefix_without_number(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/no-number" "true"'],
capture_output=True, text=True,
)
assert result.returncode != 0
def test_check_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "jdoe/2026-app/no-number" "true"'],
capture_output=True, text=True,
)
assert result.returncode != 0
@@ -1420,33 +1037,3 @@ class TestGitCommonPowerShell:
text=True,
)
assert result.returncode == 0
def test_test_feature_branch_accepts_nested_prefix(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
result = subprocess.run(
[
"pwsh",
"-NoProfile",
"-Command",
f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/app-a/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
],
capture_output=True,
text=True,
)
assert result.returncode == 0
def test_test_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
result = subprocess.run(
[
"pwsh",
"-NoProfile",
"-Command",
f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/2026-app/no-number" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
],
capture_output=True,
text=True,
)
assert result.returncode != 0

View File

@@ -71,47 +71,6 @@ def test_search_dedupes_by_precedence_and_filters():
assert [r.entry.id for r in qa_only] == ["beta"]
def test_search_does_not_surface_a_shadowed_lower_precedence_entry():
"""Search must resolve each id at its highest-precedence source, then
filter — never fall through to a shadowed lower-precedence entry the query
happens to match.
If the query matched only the lower-precedence copy of an id, search used
to return that copy, even though `resolve()`/install always use the
higher-precedence one. That advertised a bundle (name/version/source) the
user could never actually get.
"""
sources = [_source("high", 1, "install-allowed"), _source("low", 2, "install-allowed")]
payloads = {
# Highest-precedence entry for 'shared' does NOT match "widget".
"high": catalog_payload({
"shared": catalog_entry_dict(
"shared", name="Alpha Tool", role="developer",
description="nothing relevant", version="2.0.0",
),
}),
# Lower-precedence entry for the same id DOES match "widget".
"low": catalog_payload({
"shared": catalog_entry_dict(
"shared", name="Searchable Widget", version="1.0.0",
),
}),
}
stack = _stack(sources, payloads)
# resolve() uses the high-precedence entry.
assert stack.resolve("shared").source.id == "high"
# A query that only the shadowed low-precedence entry matches returns
# nothing — search agrees with resolve().
assert stack.search("widget") == []
# And a query the high-precedence entry matches returns it (from 'high').
alpha = stack.search("alpha tool")
assert [r.entry.id for r in alpha] == ["shared"]
assert alpha[0].source.id == "high"
def test_unreachable_source_raises_named_error():
def fetcher(src):
raise RuntimeError("boom")

View File

@@ -220,68 +220,3 @@ def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path):
remove_bundle(tmp_path, "demo-bundle", installer)
assert ("extensions", "ext-a") in installer.installed
def _bundle(manifest_id, ext_ids, *, version="1.0.0"):
data = valid_manifest_dict()
data["bundle"]["id"] = manifest_id
data["bundle"]["version"] = version
data["provides"] = {
"extensions": [{"id": e, "version": version} for e in ext_ids]
}
return BundleManifest.from_dict(data)
def test_update_uninstalls_components_dropped_by_new_version(tmp_path: Path):
"""`bundle update` must uninstall components the new version no longer
ships, instead of orphaning them (installed on disk, tracked by nothing)."""
make_project(tmp_path)
installer = FakeInstaller()
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
assert ("extensions", "ext-b") in installer.installed
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
result = install_bundle(
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
)
# ext-b was dropped by v2 -> uninstalled and reported.
assert ("extensions", "ext-b") in installer.remove_calls
assert ("extensions", "ext-b") in {(c.kind, c.id) for c in result.uninstalled}
assert ("extensions", "ext-b") not in installer.installed
assert ("extensions", "ext-a") in installer.installed
# The saved record lists only ext-a.
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
keys = {(c.kind, c.id) for c in rec.contributed_components}
assert ("extensions", "ext-a") in keys
assert ("extensions", "ext-b") not in keys
def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
"""A dropped component still owned by another bundle stays installed."""
make_project(tmp_path)
installer = FakeInstaller()
man_sib = _bundle("sibling", ["ext-b"])
install_bundle(tmp_path, _plan(man_sib), installer, manifest=man_sib)
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
install_bundle(
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
)
# ext-b is still needed by 'sibling' -> not removed, stays installed.
assert ("extensions", "ext-b") not in installer.remove_calls
assert ("extensions", "ext-b") in installer.installed
# But demo's record no longer attributes it.
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
assert ("extensions", "ext-b") not in {
(c.kind, c.id) for c in rec.contributed_components
}

View File

@@ -1,24 +1,8 @@
"""Shared test helpers for integration tests."""
import pytest
from specify_cli.integrations.base import MarkdownIntegration
@pytest.fixture(autouse=True)
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
"""Keep integration tests from reading or writing the real user home."""
home = tmp_path / "home"
for path in (home, home / ".cache", home / ".config", home / ".local" / "share"):
path.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
monkeypatch.setenv("XDG_CACHE_HOME", str(home / ".cache"))
monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share"))
class StubIntegration(MarkdownIntegration):
"""Minimal concrete integration for testing."""

View File

@@ -1,21 +0,0 @@
"""Regression tests for integration-test environment isolation."""
from __future__ import annotations
import os
from pathlib import Path
def test_integration_tests_use_tmp_home(tmp_path: Path) -> None:
home = tmp_path / "home"
assert Path(os.environ["HOME"]) == home
assert Path(os.environ["USERPROFILE"]) == home
assert Path(os.environ["XDG_CACHE_HOME"]) == home / ".cache"
assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config"
assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share"
assert home.is_dir()
assert (home / ".cache").is_dir()
assert (home / ".config").is_dir()
assert (home / ".local" / "share").is_dir()

View File

@@ -81,26 +81,6 @@ class TestAgyBuildExecArgs:
result = i.build_exec_args("my prompt", output_json=False)
assert result == ["agy", "--print", "my prompt"]
def test_build_exec_args_honors_extra_args(self, monkeypatch):
"""SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be appended after the prompt.
agy previously skipped _apply_extra_args_env_var entirely, so the
documented per-integration extra-args hook was silently ignored
(same class as the merged cursor-agent fix #3265).
"""
from specify_cli.integrations import get_integration
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--verbose")
i = get_integration("agy")
assert i.build_exec_args("my prompt") == [
"agy", "--print", "my prompt", "--verbose",
]
def test_build_exec_args_honors_executable_override(self, monkeypatch):
from specify_cli.integrations import get_integration
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXECUTABLE", "/custom/agy")
i = get_integration("agy")
assert i.build_exec_args("my prompt")[0] == "/custom/agy"
class TestAgyHookCommandNote:
"""Verify dot-to-hyphen normalization note is injected into hook sections."""

View File

@@ -184,23 +184,6 @@ class YamlIntegrationTests:
assert "scripts:" not in parsed["prompt"]
assert "---" not in parsed["prompt"]
def test_yaml_prompt_with_indented_first_line_stays_valid(self):
"""A body whose first line is indented must still parse.
A bare ``|`` block scalar infers its indentation from the first
non-empty line, so a body starting with an indented line (e.g. a
markdown code block or nested list item) made the parser expect that
deeper indent for the whole block and reject the later, shallower
lines. The explicit ``|2`` indicator pins the indent so it parses."""
body = " indented first line\nback to normal\n indented again"
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
yaml_lines = [
ln for ln in rendered.split("\n") if not ln.startswith("# Source:")
]
parsed = yaml.safe_load("\n".join(yaml_lines))
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.

View File

@@ -353,38 +353,3 @@ class TestHermesInitFlow:
if "agent-context" not in d.name
]
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
class TestHermesBuildExecArgs:
"""CLI dispatch argv, including the operator extra-args env hook."""
def test_build_exec_args_default_shape(self):
i = get_integration("hermes")
assert i.build_exec_args("/speckit-plan hi", output_json=True) == [
"hermes", "chat", "-Q", "--json", "-s", "speckit-plan", "-q", "hi",
]
def test_build_exec_args_honors_extra_args(self, monkeypatch):
"""SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS is injected before the
canonical -m/--json/-s/-q flags (same env hook as codex/opencode/
devin; hermes previously skipped _apply_extra_args_env_var entirely).
"""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS", "--temperature 0.2"
)
i = get_integration("hermes")
args = i.build_exec_args("/speckit-plan hi", output_json=True)
assert args == [
"hermes", "chat", "-Q", "--temperature", "0.2",
"--json", "-s", "speckit-plan", "-q", "hi",
]
# Injected before the canonical flags so it can't displace them.
assert args.index("--temperature") < args.index("--json")
assert args.index("--temperature") < args.index("-s")
def test_build_exec_args_honors_executable_override(self, monkeypatch):
monkeypatch.setenv(
"SPECKIT_INTEGRATION_HERMES_EXECUTABLE", "/custom/hermes"
)
i = get_integration("hermes")
assert i.build_exec_args("/speckit-plan hi")[0] == "/custom/hermes"

View File

@@ -48,19 +48,6 @@ def _multi_install_safe_pairs() -> list[tuple[str, str]]:
]
def _multi_install_safe_orders() -> list[list[str]]:
safe_keys = _multi_install_safe_keys()
if len(safe_keys) < 2:
return [safe_keys]
return [safe_keys[index:] + safe_keys[:index] for index in range(len(safe_keys))]
def _multi_install_safe_order_id(ordered_keys: list[str]) -> str:
if not ordered_keys:
return "no-safe-integrations"
return f"init-{ordered_keys[0]}"
def _posix_path(value: str | None) -> str | None:
if not value:
return None
@@ -100,6 +87,16 @@ def _paths_overlap(first: str | None, second: str | None) -> bool:
return False
def _path_is_inside(path: str | None, directory: str | None) -> bool:
if not path or not directory:
return False
try:
PurePosixPath(path).relative_to(PurePosixPath(directory))
return True
except ValueError:
return False
class TestRegistry:
def test_registry_is_dict(self):
assert isinstance(INTEGRATION_REGISTRY, dict)
@@ -165,15 +162,6 @@ class TestRegistrarKeyAlignment:
class TestMultiInstallSafeContracts:
"""Declared safe integrations must stay isolated from each other."""
def test_safe_install_orders_rotate_each_integration_through_init(self):
safe_keys = _multi_install_safe_keys()
orders = _multi_install_safe_orders()
assert len(safe_keys) >= 2
assert [order[0] for order in orders] == safe_keys
assert len({tuple(order) for order in orders}) == len(safe_keys)
assert all(sorted(order) == safe_keys for order in orders)
@pytest.mark.parametrize("key", _multi_install_safe_keys())
def test_safe_integrations_have_static_isolated_paths(self, key):
assert _integration_root_dir(key), (
@@ -199,77 +187,62 @@ class TestMultiInstallSafeContracts:
f"{_integration_commands_dir(second)!r}"
)
@pytest.mark.parametrize(
"ordered_keys",
_multi_install_safe_orders(),
ids=_multi_install_safe_order_id,
)
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
def test_safe_integrations_have_disjoint_manifests(
self,
tmp_path,
ordered_keys,
first,
second,
):
# The pairwise disjointness contract is only meaningful with at least
# two safe integrations. Guard so a shrunken registry fails loudly here
# rather than passing vacuously (or tripping over ordered_keys[0] below).
assert len(ordered_keys) >= 2, (
f"expected at least two multi-install-safe integrations, got {ordered_keys}"
)
for initial, additional in ((first, second), (second, first)):
project_root = tmp_path / f"project-{initial}-{additional}"
project_root.mkdir()
runner = CliRunner()
project_root = tmp_path / "project"
project_root.mkdir()
runner = CliRunner()
original_cwd = os.getcwd()
try:
os.chdir(project_root)
init_result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
initial,
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
assert init_result.exit_code == 0, init_result.output
# Install every safe integration once into a single project, then assert
# pairwise manifest isolation. Each safe integration writes only to its
# own (disjoint) directories and always records what it writes, so a
# manifest's contents are independent of install order and of which other
# integrations are co-installed. The parametrized rotations keep the
# aggregate setup while placing each safe integration first once, so each
# one still exercises the `specify init --integration ...` path.
original_cwd = os.getcwd()
try:
os.chdir(project_root)
init_result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
ordered_keys[0],
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
assert init_result.exit_code == 0, init_result.output
for key in ordered_keys[1:]:
install_result = runner.invoke(
app,
["integration", "install", key, "--script", "sh"],
["integration", "install", additional, "--script", "sh"],
catch_exceptions=False,
)
assert install_result.exit_code == 0, install_result.output
finally:
os.chdir(original_cwd)
finally:
os.chdir(original_cwd)
integrations_dir = project_root / ".specify" / "integrations"
manifests = {}
for key in ordered_keys:
manifest = json.loads(
(integrations_dir / f"{key}.manifest.json").read_text(encoding="utf-8")
initial_manifest = json.loads(
(
project_root / ".specify" / "integrations" / f"{initial}.manifest.json"
).read_text(encoding="utf-8")
)
additional_manifest = json.loads(
(
project_root / ".specify" / "integrations" / f"{additional}.manifest.json"
).read_text(encoding="utf-8")
)
files = manifest.get("files", {})
assert isinstance(files, dict), f"{key} manifest files must be an object"
manifests[key] = set(files.keys())
for first, second in _multi_install_safe_pairs():
overlap = manifests[first] & manifests[second]
assert not overlap, (
f"{first} and {second} are declared multi-install safe but both manage "
f"these files: {sorted(overlap)}"
initial_files = set(initial_manifest.get("files", {}))
additional_files = set(additional_manifest.get("files", {}))
assert initial_files.isdisjoint(additional_files), (
f"{initial} and {additional} are declared multi-install safe but both manage "
f"these files: {sorted(initial_files & additional_files)}"
)

View File

@@ -1,339 +0,0 @@
"""Parity tests for the Python check-prerequisites PoC."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
CHECK_PREREQS_PY = PROJECT_ROOT / "scripts" / "python" / "check_prerequisites.py"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (
shutil.which("powershell.exe") or shutil.which("powershell")
) if os.name == "nt" else None
def _install_scripts(repo: Path) -> None:
bash_dir = repo / ".specify" / "scripts" / "bash"
bash_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, bash_dir / "common.sh")
shutil.copy(CHECK_PREREQS_SH, bash_dir / "check-prerequisites.sh")
ps_dir = repo / ".specify" / "scripts" / "powershell"
ps_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, ps_dir / "common.ps1")
shutil.copy(CHECK_PREREQS_PS, ps_dir / "check-prerequisites.ps1")
py_dir = repo / ".specify" / "scripts" / "python"
py_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PY, py_dir / "common.py")
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
+ "\n",
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _git_init(repo: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
)
@pytest.fixture
def prereq_repo(tmp_path: Path) -> Path:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_install_scripts(repo)
return repo
def _py_cmd(repo: Path, *args: str) -> list[str]:
script = repo / ".specify" / "scripts" / "python" / "check_prerequisites.py"
return [sys.executable, str(script), *args]
def _repo_copy_py_cmd(repo: Path, *args: str) -> list[str]:
script = repo / "scripts" / "python" / "check_prerequisites.py"
return [sys.executable, str(script), *args]
def _bash_cmd(repo: Path, *args: str) -> list[str]:
script = repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
return ["bash", str(script), *args]
def _ps_cmd(repo: Path, *args: str) -> list[str]:
script = repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
return [exe, "-NoProfile", "-File", str(script), *args]
def _run(
cmd: list[str], repo: Path, env: dict[str, str] | None = None
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
cmd,
cwd=repo,
capture_output=True,
text=True,
check=False,
env=env or _clean_env(),
)
def _json_stdout(result: subprocess.CompletedProcess[str]) -> object:
return json.loads(result.stdout)
def _normalize_status_text(text: str) -> str:
return (
text.replace("", " [OK] ")
.replace("", " [FAIL] ")
.replace("\r\n", "\n")
)
def _normalize_help_text(text: str) -> str:
normalized = text.replace("\r\n", "\n").replace(
"check-prerequisites.sh", "check_prerequisites.py"
)
return "\n".join("" if not line.strip() else line for line in normalized.split("\n"))
@requires_bash
@pytest.mark.parametrize(
"args",
[
("--json",),
("--json", "--include-tasks"),
("--json", "--require-tasks", "--include-tasks"),
("--json", "--paths-only"),
],
)
def test_python_json_output_matches_bash(prereq_repo: Path, args: tuple[str, ...]) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
(feat / "research.md").write_text("# research\n", encoding="utf-8")
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
(feat / "contracts" / "v1").mkdir(parents=True)
_write_feature_json(prereq_repo)
bash = _run(_bash_cmd(prereq_repo, *args), prereq_repo)
py = _run(_py_cmd(prereq_repo, *args), prereq_repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert _json_stdout(py) == _json_stdout(bash)
@requires_bash
def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "contracts").mkdir()
_write_feature_json(prereq_repo)
bash = _run(_bash_cmd(prereq_repo, "--include-tasks"), prereq_repo)
py = _run(_py_cmd(prereq_repo, "--include-tasks"), prereq_repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert _normalize_status_text(py.stdout) == _normalize_status_text(bash.stdout)
@requires_bash
def test_python_help_output_matches_bash(prereq_repo: Path) -> None:
bash = _run(_bash_cmd(prereq_repo, "--help"), prereq_repo)
py = _run(_py_cmd(prereq_repo, "--help"), prereq_repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert _normalize_help_text(py.stdout) == _normalize_help_text(bash.stdout)
@requires_bash
def test_python_unknown_option_matches_bash_error_shape(prereq_repo: Path) -> None:
bash = _run(_bash_cmd(prereq_repo, "--bogus"), prereq_repo)
py = _run(_py_cmd(prereq_repo, "--bogus"), prereq_repo)
assert py.returncode == bash.returncode == 1
assert py.stdout == bash.stdout == ""
assert py.stderr == bash.stderr
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.parametrize(
("py_args", "ps_args"),
[
(("--json",), ("-Json",)),
(("--json", "--include-tasks"), ("-Json", "-IncludeTasks")),
(
("--json", "--require-tasks", "--include-tasks"),
("-Json", "-RequireTasks", "-IncludeTasks"),
),
(("--json", "--paths-only"), ("-Json", "-PathsOnly")),
],
ids=[
"json",
"json_include_tasks",
"json_require_tasks_include_tasks",
"json_paths_only",
],
)
def test_python_json_output_matches_powershell(
prereq_repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
(feat / "research.md").write_text("# research\n", encoding="utf-8")
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
(feat / "contracts" / "v1").mkdir(parents=True)
_write_feature_json(prereq_repo)
ps = _run(_ps_cmd(prereq_repo, *ps_args), prereq_repo)
py = _run(_py_cmd(prereq_repo, *py_args), prereq_repo)
assert py.returncode == ps.returncode == 0
assert py.stderr == ps.stderr == ""
assert _json_stdout(py) == _json_stdout(ps)
def test_python_repo_copy_script_file_fallback_finds_repo_root(tmp_path: Path) -> None:
repo = tmp_path / "proj"
outside = tmp_path / "outside"
repo.mkdir()
outside.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_write_feature_json(repo)
(repo / "specs" / "001-my-feature").mkdir(parents=True)
py_dir = repo / "scripts" / "python"
py_dir.mkdir(parents=True)
shutil.copy(COMMON_PY, py_dir / "common.py")
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
py = _run(_repo_copy_py_cmd(repo, "--json", "--paths-only"), outside)
assert py.returncode == 0, py.stderr
assert Path(_json_stdout(py)["REPO_ROOT"]) == repo
def test_python_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
(prereq_repo / "specs" / "002-other").mkdir(parents=True)
_write_feature_json(prereq_repo, "specs/001-my-feature")
feature_json = prereq_repo / ".specify" / "feature.json"
before = feature_json.read_text(encoding="utf-8")
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo, env=env)
assert py.returncode == 0, py.stderr
assert "002-other" in _json_stdout(py)["FEATURE_DIR"]
assert feature_json.read_text(encoding="utf-8") == before
def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
feat = prereq_repo / "specs" / "002-other"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(prereq_repo, "specs/001-my-feature")
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
assert py.returncode == 0, py.stderr
data = json.loads(
(prereq_repo / ".specify" / "feature.json").read_text(encoding="utf-8")
)
assert data["feature_directory"] == "specs/002-other"
@pytest.mark.parametrize(
("args", "expected"),
[
(("--json",), "Feature directory not found"),
(("--json",), "plan.md not found"),
(("--json", "--require-tasks"), "tasks.md not found"),
],
ids=["missing_feature_context", "missing_plan", "missing_tasks"],
)
def test_python_negative_errors_are_stderr_only(
tmp_path: Path, args: tuple[str, ...], expected: str
) -> None:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_install_scripts(repo)
if expected in {"plan.md not found", "tasks.md not found"}:
feat = repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
_write_feature_json(repo)
if expected == "tasks.md not found":
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
py = _run(_py_cmd(repo, *args), repo)
assert py.returncode != 0
assert expected in py.stderr
assert expected not in py.stdout
assert py.stdout.strip() == ""
def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) -> None:
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
_write_feature_json(prereq_repo)
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo)
assert py.returncode == 0, py.stderr
assert _json_stdout(py)["BRANCH"] == "001-my-feature"

View File

@@ -850,67 +850,6 @@ class TestExtensionSkillRegistration:
assert ".specify/templates/checklist.md" in content
assert ".specify/memory/constitution.md" in content
def test_skill_registration_uses_extension_local_script_paths(self, project_dir, temp_dir):
"""Auto-registered skills should not rewrite extension scripts into core scripts."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = temp_dir / "scripted-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "scripted-ext",
"name": "Scripted Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.scripted-ext.check",
"file": "commands/check.md",
"description": "Scripted check command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "scripts" / "bash").mkdir(parents=True)
(ext_dir / "scripts" / "bash" / "resolve-skill.sh").write_text(
"#!/usr/bin/env bash\n"
)
(ext_dir / "scripts" / "bash" / "ensure-skills.sh").write_text(
"#!/usr/bin/env bash\n"
)
(ext_dir / "commands" / "check.md").write_text(
"---\n"
"description: Scripted check command\n"
"scripts:\n"
' sh: scripts/bash/resolve-skill.sh "{ARGS}"\n'
"---\n\n"
"Run {SCRIPT}\n"
"Then run scripts/bash/ensure-skills.sh.\n"
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
content = (skills_dir / "speckit-scripted-ext-check" / "SKILL.md").read_text()
assert "{SCRIPT}" not in content
assert "{ARGS}" not in content
assert (
'.specify/extensions/scripted-ext/scripts/bash/resolve-skill.sh "$ARGUMENTS"'
in content
)
assert ".specify/extensions/scripted-ext/scripts/bash/ensure-skills.sh" in content
assert ".specify/scripts/bash/resolve-skill.sh" not in content
assert ".specify/scripts/bash/ensure-skills.sh" not in content
def test_missing_command_file_skipped(self, skills_project, temp_dir):
"""Commands with missing source files should be skipped gracefully."""
project_dir, skills_dir = skills_project

View File

@@ -31,7 +31,6 @@ from specify_cli.extensions import (
ExtensionRegistry,
ExtensionManager,
CommandRegistrar,
ConfigManager,
HookExecutor,
ExtensionCatalog,
ExtensionError,
@@ -1147,12 +1146,10 @@ class TestExtensionManager:
context_note=None,
link_outputs=False,
create_missing_active_skills_dir=False,
extension_id=None,
):
captured["create_missing_active_skills_dir"] = (
create_missing_active_skills_dir
)
captured["extension_id"] = extension_id
return {}
monkeypatch.setattr(
@@ -1166,7 +1163,6 @@ class TestExtensionManager:
registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
assert captured["create_missing_active_skills_dir"] is False
assert captured["extension_id"] == manifest.id
def test_install_duplicate(self, extension_dir, project_dir):
"""Test installing already installed extension."""
@@ -1698,29 +1694,6 @@ $ARGUMENTS
assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}"
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
def test_adjust_script_paths_rewrites_extension_top_level_scripts(self):
"""Extension command-local scripts should resolve under the installed extension."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
original = {
"scripts": {
"sh": "scripts/bash/resolve-skill.sh {ARGS}",
"ps": "../../scripts/powershell/setup-plan.ps1 -Json",
}
}
adjusted = registrar._adjust_script_paths(original, extension_id="test-ext")
assert (
adjusted["scripts"]["sh"]
== ".specify/extensions/test-ext/scripts/bash/resolve-skill.sh {ARGS}"
)
assert (
adjusted["scripts"]["ps"]
== ".specify/scripts/powershell/setup-plan.ps1 -Json"
)
def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self):
"""Body rewrites should preserve extension-local assets while fixing top-level refs."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
@@ -1735,24 +1708,6 @@ $ARGUMENTS
assert ".specify/extensions/test-ext/templates/spec.md" in rewritten
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
def test_rewrite_project_relative_paths_uses_extension_context_for_scripts(self):
"""Extension source bodies treat top-level scripts/ as extension-local."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
body = (
"Run scripts/bash/ensure-skills.sh\n"
"Fallback ../../scripts/bash/setup-plan.sh\n"
"Read templates/checklist.md\n"
)
rewritten = AgentCommandRegistrar.rewrite_project_relative_paths(
body, extension_id="test-ext"
)
assert ".specify/extensions/test-ext/scripts/bash/ensure-skills.sh" in rewritten
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
assert ".specify/templates/checklist.md" in rewritten
def test_render_toml_command_handles_embedded_triple_double_quotes(self):
"""TOML renderer should stay valid when body includes triple double-quotes."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
@@ -7537,52 +7492,3 @@ def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, mo
)
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"]
class TestConfigManagerNonMappingYaml:
"""A non-mapping YAML config root must not crash config/hook resolution."""
def _make(self, tmp_path, body: str):
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text(body, encoding="utf-8")
return ConfigManager(tmp_path, "jira")
def test_get_config_coerces_list_root(self, tmp_path):
"""A YAML list root previously raised AttributeError in _merge_configs."""
cm = self._make(tmp_path, "- foo\n- bar\n")
assert cm.get_config() == {}
def test_get_config_coerces_scalar_root(self, tmp_path):
cm = self._make(tmp_path, "just a string\n")
assert cm.get_config() == {}
def test_has_value_and_get_value_do_not_raise(self, tmp_path):
cm = self._make(tmp_path, "- foo\n")
assert cm.has_value("anything") is False
assert cm.get_value("anything") is None
def test_valid_local_config_layers_over_list_root_project_config(self, tmp_path):
"""A malformed project config must not block a valid local config."""
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text("- foo\n- bar\n", encoding="utf-8")
(ext_dir / "local-config.yml").write_text(
"notifications:\n enabled: true\n", encoding="utf-8"
)
cm = ConfigManager(tmp_path, "jira")
assert cm.get_value("notifications.enabled") is True
def test_hook_condition_returns_false_without_raising(self, tmp_path):
"""`config.x is set` on a scalar-root config must evaluate cleanly.
Before the fix, _merge_configs raised AttributeError and the
exception was swallowed by should_execute_hook, silently disabling
every config-based hook for the extension. Assert on
_evaluate_condition directly so the crash isn't masked.
"""
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
executor = HookExecutor(tmp_path)
assert executor._evaluate_condition("config.x is set", "jira") is False

View File

@@ -1,245 +0,0 @@
"""Tests for post_process_command_content() hook on IntegrationBase.
Verifies that the generalized post-processing hook:
- Runs for non-skills format types (Markdown, TOML, YAML)
- Does NOT run for skills-format agents
- Default no-op returns content unchanged
- Exceptions propagate to caller
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from specify_cli.agents import CommandRegistrar
from specify_cli.integrations.base import IntegrationBase
@pytest.fixture
def registrar():
return CommandRegistrar()
@pytest.fixture
def ext_dir(tmp_path):
"""Create a mock extension with a simple command template."""
ext = tmp_path / "extension"
ext.mkdir()
cmd_dir = ext / "commands"
cmd_dir.mkdir()
return ext, cmd_dir
def _write_cmd(cmd_dir, name="review.md", body="Review the code.\n"):
cmd_file = cmd_dir / name
cmd_file.write_text(
f"---\ndescription: Test command\n---\n\n{body}",
encoding="utf-8",
)
return cmd_file
class TestDefaultNoOp:
def test_returns_content_unchanged(self):
base = IntegrationBase()
content = "Some command content\nwith multiple lines."
assert base.post_process_command_content(content) == content
def test_empty_string(self):
base = IntegrationBase()
assert base.post_process_command_content("") == ""
class TestMarkdownAgentPostProcess:
def test_opencode_post_process_applied(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
opencode = get_integration("opencode")
marker = "<!-- POST_PROCESSED -->"
def _inject_marker(self, content):
return content + marker
monkeypatch.setattr(
opencode.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"opencode", commands, "test-ext", ext, tmp_path
)
cmd_output = tmp_path / ".opencode" / "commands" / "speckit.test.review.md"
assert cmd_output.exists()
content = cmd_output.read_text(encoding="utf-8")
assert marker in content
class TestTomlAgentPostProcess:
def test_gemini_post_process_applied(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
gemini = get_integration("gemini")
marker = "# POST_PROCESSED"
def _inject_marker(self, content):
return content + f"\n{marker}\n"
monkeypatch.setattr(
gemini.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"gemini", commands, "test-ext", ext, tmp_path
)
cmd_output = tmp_path / ".gemini" / "commands" / "speckit.test.review.toml"
assert cmd_output.exists()
content = cmd_output.read_text(encoding="utf-8")
assert marker in content
class TestYamlAgentPostProcess:
def test_goose_post_process_applied(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
goose = get_integration("goose")
marker = "# POST_PROCESSED"
def _inject_marker(self, content):
return content + f"\n{marker}\n"
monkeypatch.setattr(
goose.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"goose", commands, "test-ext", ext, tmp_path
)
cmd_output = tmp_path / ".goose" / "recipes" / "speckit.test.review.yaml"
assert cmd_output.exists()
content = cmd_output.read_text(encoding="utf-8")
assert marker in content
class TestSkillsAgentExcluded:
def test_claude_post_process_not_called(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
claude = get_integration("claude")
marker = "<!-- SHOULD_NOT_APPEAR -->"
def _inject_marker(self, content):
return content + marker
monkeypatch.setattr(
claude.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"claude", commands, "test-ext", ext, tmp_path
)
skill_file = (
tmp_path / ".claude" / "skills" / "speckit-test-review" / "SKILL.md"
)
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert marker not in content
def test_skills_agent_method_never_called(
self, tmp_path, registrar, ext_dir
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
claude = get_integration("claude")
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
with patch.object(
claude.__class__, "post_process_command_content", wraps=claude.post_process_command_content
) as mock_method:
registrar.register_commands(
"claude", commands, "test-ext", ext, tmp_path
)
mock_method.assert_not_called()
class TestExceptionPropagation:
def test_hook_exception_propagates(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
opencode = get_integration("opencode")
def _raise(self, content):
raise RuntimeError("Hook failed")
monkeypatch.setattr(
opencode.__class__, "post_process_command_content", _raise
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
with pytest.raises(RuntimeError, match="Hook failed"):
registrar.register_commands(
"opencode", commands, "test-ext", ext, tmp_path
)
class TestRegressionPlainTemplate:
@pytest.mark.parametrize(
"agent,path_pattern",
[
("claude", ".claude/skills/speckit-test-plain/SKILL.md"),
("opencode", ".opencode/commands/speckit.test.plain.md"),
],
ids=["skills", "markdown"],
)
def test_plain_template_unchanged(
self, tmp_path, registrar, ext_dir, agent, path_pattern
):
ext, cmd_dir = ext_dir
body_text = "This is a plain command with no special content.\n"
_write_cmd(cmd_dir, name="plain.md", body=body_text)
commands = [{"name": "speckit.test.plain", "file": "commands/plain.md"}]
registrar.register_commands(
agent, commands, "test-ext", ext, tmp_path
)
output_file = tmp_path / path_pattern
assert output_file.exists(), f"Output file missing for {agent}"
content = output_file.read_text(encoding="utf-8")
assert body_text.strip() in content, f"Body text missing in {agent} output"

View File

@@ -322,87 +322,3 @@ class TestWorkflowRunWithoutProject:
assert result.exit_code != 0
assert ".specify path exists but is not a directory" in result.output
class TestWorkflowRunJsonErrorStream:
"""Under --json, error text must go to stderr so stdout stays parseable."""
def _bad_workflow(self, tmp_path):
wf = tmp_path / "bad.yml"
wf.write_text(
yaml.dump(
{
"schema_version": "1.0",
"workflow": {
"id": "bad-wf",
"name": "Bad",
"version": "1.0.0",
"description": "fails validation",
},
# shell step missing required 'run' -> validation error
"steps": [{"id": "s", "type": "shell"}],
}
),
encoding="utf-8",
)
return wf
def test_run_json_validation_error_not_on_stdout(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
wf = self._bad_workflow(tmp_path)
runner = CliRunner()
old = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(
app, ["workflow", "run", str(wf), "--json"], catch_exceptions=False
)
finally:
os.chdir(old)
assert result.exit_code == 1
# stdout must carry only JSON (here: nothing) — never human error text.
assert "validation failed" not in result.stdout
assert "Error" not in result.stdout
# The message is routed to stderr instead.
assert "validation failed" in result.stderr
def test_run_json_invalid_input_not_on_stdout(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
# A valid single-shell workflow so we get past load/validate to
# _parse_input_values, which rejects the malformed --input.
wf = tmp_path / "ok.yml"
wf.write_text(
yaml.dump(
{
"schema_version": "1.0",
"workflow": {
"id": "ok-wf",
"name": "OK",
"version": "1.0.0",
"description": "x",
},
"steps": [{"id": "s", "type": "shell", "run": "echo hi"}],
}
),
encoding="utf-8",
)
runner = CliRunner()
old = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(
app,
["workflow", "run", str(wf), "--json", "--input", "no-equals"],
catch_exceptions=False,
)
finally:
os.chdir(old)
assert result.exit_code == 1
assert "Invalid input format" not in result.stdout
assert "Invalid input format" in result.stderr

View File

@@ -260,60 +260,6 @@ class TestExpressions:
ctx = StepContext(inputs={"text": "uses }} syntax"})
assert evaluate_expression("{{ inputs.text | contains('}}') }}", ctx) is True
def test_multi_expression_with_literal_close_brace_in_argument(self):
"""A multi-expression template with a literal ``}}`` inside a string
argument must interpolate, not raise. #3208/#3228 hardened the single-
expression fast path for literal braces but left the interpolation path
on ``_EXPR_PATTERN``, whose non-greedy body stops at the first ``}}`` --
so the block was captured truncated and the filter parser raised
ValueError."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"name": "Bob", "missing": None})
# ``}}`` in the default fallback of the second block.
result = evaluate_expression(
"{{ inputs.name }}: {{ inputs.missing | default('}}') }}", ctx
)
assert result == "Bob: }}"
# ``}}`` in the first block, expression following it.
result = evaluate_expression(
"{{ inputs.missing | default('}}') }} / {{ inputs.name }}", ctx
)
assert result == "}} / Bob"
def test_multi_expression_with_literal_open_brace_in_argument(self):
"""A literal ``{{`` inside a string argument in a multi-expression
template must not confuse block detection either."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"name": "Bob", "missing": None})
result = evaluate_expression(
"{{ inputs.name }} {{ inputs.missing | default('{{') }}", ctx
)
assert result == "Bob {{"
def test_multi_expression_unbalanced_quote_still_raises(self):
"""A malformed block (an unbalanced quote in a filter arg) must still
surface a ValueError, not be silently emitted verbatim.
The quote-aware scan never finds a block-closing ``}}`` when a quote is
left open, but a raw ``}}`` is still present in the tail. It must fall
back to that raw delimiter and evaluate — same as the old regex path —
so a typo fails loudly instead of being hidden (Copilot review on
#3307)."""
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"name": "Bob", "missing": None})
with pytest.raises(ValueError):
evaluate_expression(
"{{ inputs.name }} {{ inputs.missing | default('oops }}", ctx
)
def test_comparison_equals(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
@@ -342,35 +288,6 @@ class TestExpressions:
assert evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True
assert evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False
def test_ordering_comparison_of_non_numeric_strings(self):
"""`<`/`>`/`<=`/`>=` between non-numeric strings must compare
lexicographically, not silently return False.
`_safe_compare` used to coerce both operands to int/float unconditionally;
a non-numeric string (date, version tag, name) failed that coercion and
the whole comparison returned False. Ordinary strings should order the
way Python does; numeric strings must still compare as numbers."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
# ISO dates compare lexicographically (correct chronological order).
ctx = StepContext(inputs={"d": "2026-01-01"})
assert evaluate_expression("{{ inputs.d < '2026-02-01' }}", ctx) is True
assert evaluate_expression("{{ inputs.d > '2026-02-01' }}", ctx) is False
# Plain string ordering.
ctx = StepContext(inputs={"name": "beta"})
assert evaluate_expression("{{ inputs.name > 'alpha' }}", ctx) is True
# Two numeric strings still compare numerically, not lexically
# ("10" > "9" is True as numbers; as strings it would be False).
ctx = StepContext(inputs={"v": "10"})
assert evaluate_expression("{{ inputs.v > '9' }}", ctx) is True
# A number vs a non-numeric string is genuinely incomparable -> False.
ctx = StepContext(inputs={"n": 5})
assert evaluate_expression("{{ inputs.n > 'abc' }}", ctx) is False
def test_boolean_and(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
@@ -1266,26 +1183,6 @@ class TestShellStep:
errors = step.validate({"id": "test"})
assert any("missing 'run'" in e for e in errors)
@pytest.mark.parametrize("bad_run", [None, ["echo", "hi"], 42])
def test_validate_rejects_non_string_run(self, bad_run):
"""A non-string 'run' must be rejected at validation.
execute() str()-coerces run and invokes it under shell=True, so a
null or list run would otherwise run the Python repr as a command.
"""
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
errors = step.validate({"id": "s", "run": bad_run})
assert any("'run' must be a string" in e for e in errors)
def test_validate_accepts_string_and_expression_run(self):
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
assert step.validate({"id": "s", "run": "echo hi"}) == []
assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == []
def test_output_format_json_exposes_data(self, tmp_path):
from specify_cli.workflows.steps.shell import ShellStep
@@ -2202,27 +2099,6 @@ class TestFanInStep:
errors = step.validate({"id": "test", "wait_for": "not-a-list"})
assert any("non-empty list" in e for e in errors)
@pytest.mark.parametrize("bad_output", [["{{ fan_in.results }}"], "{{ x }}", 42])
def test_validate_rejects_non_mapping_output(self, bad_output):
"""A non-mapping 'output' must be rejected: execute() would otherwise
silently coerce it to {} and drop the declared aggregation keys."""
from specify_cli.workflows.steps.fan_in import FanInStep
step = FanInStep()
errors = step.validate(
{"id": "j", "wait_for": ["a"], "output": bad_output}
)
assert any("'output' must be a mapping" in e for e in errors)
def test_validate_accepts_mapping_or_absent_output(self):
from specify_cli.workflows.steps.fan_in import FanInStep
step = FanInStep()
assert step.validate(
{"id": "j", "wait_for": ["a"], "output": {"joined": "{{ x }}"}}
) == []
assert step.validate({"id": "j", "wait_for": ["a"]}) == []
class TestFanOutConcurrency:
"""Fan-out honors max_concurrency (WorkflowEngine._run_fan_out)."""
@@ -4101,48 +3977,6 @@ steps:
assert state.status == RunStatus.ABORTED
assert "should-not-run" not in state.step_results
def test_gate_reject_matches_case_insensitively(
self, project_dir, monkeypatch
):
"""A capitalised reject option (`options: [Approve, Reject]`) still
aborts the run. `validate` accepts a reject choice case-insensitively,
so the runtime reject check must agree — a case-sensitive comparison
would treat the echoed `Reject` as approval and silently run
downstream steps.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
from specify_cli.workflows.steps.gate import GateStep
# `_prompt` echoes the option's original casing, so the operator
# picking "Reject" hands `execute` the capitalised string.
_force_gate_stdin(monkeypatch, tty=True)
monkeypatch.setattr(
GateStep, "_prompt", staticmethod(lambda _msg, _opts: "Reject")
)
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "gate-reject-case"
name: "Gate Reject Case"
version: "1.0.0"
steps:
- id: gate-step
type: gate
message: "Approve?"
options: [Approve, Reject]
on_reject: abort
- id: should-not-run
type: shell
run: "echo nope"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.ABORTED
assert "should-not-run" not in state.step_results
def test_validation_rejects_non_bool_continue_on_error(self):
"""`continue_on_error` must be a literal boolean; coerced
strings like `"true"` are rejected at validation time so

View File

@@ -69,30 +69,3 @@ def test_http_fetch_rejects_non_https_final_url(monkeypatch):
fetcher = adapters.make_catalog_fetcher(allow_network=True)
with pytest.raises(BundlerError, match="must use HTTPS"):
fetcher(_source("https://example.com/c.json"))
@pytest.mark.parametrize(
"url",
[
"https://:8080", # port only, no host
"https://:0",
"https://user@", # userinfo only, no host
"https://user:pw@",
"https://:8080/catalog.json",
],
)
def test_validate_remote_url_rejects_host_less_urls(url):
"""A URL with a truthy netloc but no host (``https://:8080``,
``https://user@``) must be rejected.
``urlparse`` gives these a non-empty ``netloc`` but ``hostname is None``,
so a ``netloc`` check would wrongly accept them. This mirrors the fix in
``specify_cli.catalogs`` (#3210), which the docstring says this validator
mirrors."""
with pytest.raises(BundlerError, match="valid URL with a host"):
adapters._validate_remote_url("team", url)
def test_validate_remote_url_accepts_normal_https_url():
# Sanity: a real host with a port still passes.
adapters._validate_remote_url("team", "https://example.com:8080/c.json")

View File

@@ -222,39 +222,3 @@ def test_add_source_allows_local_path_with_colon(tmp_path: Path, monkeypatch):
# A relative path containing ':' but no '://' is still a local path.
source = cc.add_source(project, "weird:name.json", policy="install-allowed", priority=50)
assert source.url.endswith("weird:name.json") or "weird" in source.url
def test_add_source_rejects_plain_http_for_non_localhost(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
with pytest.raises(BundlerError, match="HTTPS"):
cc.add_source(project, "http://example.com/catalog.json", policy="install-allowed", priority=50)
def test_add_source_allows_http_for_localhost(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
source = cc.add_source(project, "http://localhost:8080/c.json", policy="install-allowed", priority=50)
assert source.url == "http://localhost:8080/c.json"
def test_add_source_rejects_host_less_remote_urls(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
for url in ("https://:8080", "https://user@"):
with pytest.raises(BundlerError, match="host"):
cc.add_source(project, url, policy="install-allowed", priority=50)
def test_add_source_wraps_invalid_ipv6_as_bundler_error(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
with pytest.raises(BundlerError, match="Invalid catalog url"):
cc.add_source(project, "https://[::1/c.json", policy="install-allowed", priority=50)
def test_remove_source_does_not_crash_on_invalid_ipv6(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
with pytest.raises(BundlerError, match="No project-scoped catalog source"):
cc.remove_source(project, "https://[::1/c.json")