chore: add pre-commit config and fix trailing whitespace/end-of-file (#3430)

issues
This commit is contained in:
Vincent Lee
2026-07-10 12:47:36 -04:00
committed by GitHub
parent 983a87f3e3
commit c8ce488073
42 changed files with 304 additions and 303 deletions

1
.github/CODEOWNERS vendored
View File

@@ -5,4 +5,3 @@
/extensions/catalog.community.json @mnriem
/integrations/catalog.community.json @mnriem
/presets/catalog.community.json @mnriem

View File

@@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
- type: input

View File

@@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for contributing an extension! This template helps you submit your extension to the community catalog.
**Before submitting:**
- Review the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md)
- Ensure your extension has a valid `extension.yml` manifest
@@ -209,9 +209,9 @@ body:
**Tested on:**
- macOS 14.0 with Spec Kit v0.1.0
- Linux Ubuntu 22.04 with Spec Kit v0.1.0
**Test project:** [Link or description]
**Test scenarios:**
1. Installed extension
2. Configured settings
@@ -230,7 +230,7 @@ body:
```bash
# Install extension
specify extension add <extension-name> --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip
# Use a command
/speckit.your-extension.command-name arg1 arg2
```

View File

@@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for contributing a preset! This template helps you submit your preset to the community catalog.
**Before submitting:**
- Review the [Preset Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md)
- Ensure your preset has a valid `preset.yml` manifest

View File

@@ -19,4 +19,3 @@
- [ ] I **did** use AI assistance (describe below)
<!-- If you used AI, briefly describe how (e.g., "Code generated by Copilot", "Consulted ChatGPT for approach"): -->

View File

@@ -20,24 +20,24 @@ jobs:
days-before-stale: 150
# Days of inactivity before a stale issue or PR is closed (after being marked stale)
days-before-close: 30
# Stale issue settings
stale-issue-message: 'This issue has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
close-issue-message: 'This issue has been automatically closed due to inactivity (180 days total). If you believe this issue is still relevant, please reopen it or create a new issue.'
stale-issue-label: 'stale'
# Stale PR settings
stale-pr-message: 'This pull request has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
close-pr-message: 'This pull request has been automatically closed due to inactivity (180 days total). If you believe this PR is still relevant, please reopen it or create a new PR.'
stale-pr-label: 'stale'
# Exempt issues and PRs with these labels from being marked as stale
exempt-issue-labels: 'pinned,security'
exempt-pr-labels: 'pinned,security'
# Only issues or PRs with all of these labels are checked
# Leave empty to check all issues and PRs
any-of-labels: ''
# Operations per run (helps avoid rate limits)
operations-per-run: 250

View File

@@ -26,4 +26,4 @@
"ignores": [
".genreleases/"
]
}
}

12
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,12 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-executables-have-shebangs
- id: check-yaml
exclude: \.lock\.yml$
- id: end-of-file-fixer
exclude: \.lock\.yml$
- id: trailing-whitespace
exclude: \.lock\.yml$

View File

@@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1
docs/.gitignore vendored
View File

@@ -6,4 +6,3 @@ obj/
# Temporary files
*.tmp
*.log

View File

@@ -48,4 +48,4 @@
]
}
}
}
}

View File

@@ -48,7 +48,7 @@ cat .specify/extensions/.registry/$ARGUMENTS.json
### Step 4: Verification Report
Analyze the standard output of the three steps.
Analyze the standard output of the three steps.
Generate a terminal-style test output format detailing the results of discovery, installation, and registration. Return this directly to the user.
Example output format:

View File

@@ -76,5 +76,3 @@ Areas under discussion or in progress for future development:
- **Continued agent expansion** -- seven new agents were added in March alone. The agent-agnostic design means support for emerging tools can be added by anyone. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/)
- **Experience simplification** -- the preset system, custom workflows, and growing walkthrough library lower the learning curve, but extension discoverability will need a more robust solution as the catalog grows. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Toward a stable release** -- nine releases in one month reflects pre-1.0 momentum. Reaching 1.0 will require stabilizing the extension and preset APIs and ensuring backward compatibility across the agent and extension surface area. [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md)

View File

@@ -83,4 +83,3 @@ extend-select = [
"S604", # call-with-shell-equals-true
"S605", # start-process-with-a-shell
]

View File

@@ -57,13 +57,13 @@ OPTIONS:
EXAMPLES:
# Check task prerequisites (plan.md required)
./check-prerequisites.sh --json
# Check implementation prerequisites (plan.md + tasks.md required)
./check-prerequisites.sh --json --require-tasks --include-tasks
# Get feature paths only (no validation)
./check-prerequisites.sh --paths-only
EOF
exit 0
;;
@@ -182,13 +182,13 @@ else
# Text output
echo "FEATURE_DIR:$FEATURE_DIR"
echo "AVAILABLE_DOCS:"
# Show status of each potential document
check_file "$RESEARCH" "research.md"
check_file "$DATA_MODEL" "data-model.md"
check_dir "$CONTRACTS_DIR" "contracts/"
check_file "$QUICKSTART" "quickstart.md"
if $INCLUDE_TASKS; then
check_file "$TASKS" "tasks.md"
fi

View File

@@ -94,7 +94,7 @@ fi
get_highest_from_specs() {
local specs_dir="$1"
local highest=0
if [ -d "$specs_dir" ]; then
for dir in "$specs_dir"/*; do
[ -d "$dir" ] || continue
@@ -109,7 +109,7 @@ get_highest_from_specs() {
fi
done
fi
echo "$highest"
}
@@ -135,19 +135,19 @@ fi
# Function to generate branch name with stop word filtering and length filtering
generate_branch_name() {
local description="$1"
# Common stop words to filter out
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
# Convert to lowercase and split into words
local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
local meaningful_words=()
for word in $clean_name; do
# Skip empty words
[ -z "$word" ] && continue
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
@@ -160,12 +160,12 @@ generate_branch_name() {
fi
fi
done
# If we have meaningful words, use first 3-4 of them
if [ ${#meaningful_words[@]} -gt 0 ]; then
local max_words=3
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
local result=""
local count=0
for word in "${meaningful_words[@]}"; do
@@ -221,15 +221,15 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
# Truncate suffix at word boundary if possible
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
# Remove trailing hyphen if truncation created one
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"

View File

@@ -8,17 +8,17 @@ ARGS=()
for arg in "$@"; do
case "$arg" in
--json)
JSON_MODE=true
--json)
JSON_MODE=true
;;
--help|-h)
--help|-h)
echo "Usage: $0 [--json]"
echo " --json Output results in JSON format"
echo " --help Show this help message"
exit 0
exit 0
;;
*)
ARGS+=("$arg")
*)
ARGS+=("$arg")
;;
esac
done
@@ -77,8 +77,7 @@ if $JSON_MODE; then
fi
else
echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "SPECS_DIR: $FEATURE_DIR"
echo "BRANCH: $CURRENT_BRANCH"
fi

View File

@@ -42,10 +42,10 @@ OPTIONS:
EXAMPLES:
# Check task prerequisites (plan.md required)
.\check-prerequisites.ps1 -Json
# Check implementation prerequisites (plan.md + tasks.md required)
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
# Get feature paths only (no validation)
.\check-prerequisites.ps1 -PathsOnly
@@ -118,35 +118,35 @@ if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
# Check contracts directory (only if it exists and has files)
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
}
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Include tasks.md if requested and it exists
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md'
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md'
}
# Output results
if ($Json) {
# JSON output
[PSCustomObject]@{
[PSCustomObject]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
AVAILABLE_DOCS = $docs
} | ConvertTo-Json -Compress
} else {
# Text output
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
Write-Output "AVAILABLE_DOCS:"
# Show status of each potential document
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
if ($IncludeTasks) {
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null
}

View File

@@ -191,7 +191,7 @@ function Get-FeaturePathsEnv {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
exit 1
}
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
# directory basename so CURRENT_BRANCH is a usable identifier rather than

View File

@@ -63,7 +63,7 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
# Output results
if ($Json) {
$result = [PSCustomObject]@{
$result = [PSCustomObject]@{
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
SPECS_DIR = $paths.FEATURE_DIR

View File

@@ -5,4 +5,4 @@
}
],
"settings": {}
}
}

View File

@@ -62,4 +62,4 @@ class DevinIntegration(SkillsIntegration):
default=True,
help="Install as agent skills (default for Devin)",
),
]
]

View File

@@ -18,13 +18,13 @@ from ..manifest import IntegrationManifest
def format_forge_command_name(cmd_name: str) -> str:
"""Convert command name to Forge-compatible hyphenated format.
Forge requires command names to use hyphens instead of dots for
compatibility with ZSH and other shells. This function converts
dot-notation command names to hyphenated format.
The function is idempotent: already-formatted names are returned unchanged.
Examples:
>>> format_forge_command_name("plan")
'speckit-plan'
@@ -38,26 +38,26 @@ def format_forge_command_name(cmd_name: str) -> str:
'speckit-my-extension-example'
>>> format_forge_command_name("speckit.jira.sync-status")
'speckit-jira-sync-status'
Args:
cmd_name: Command name in dot notation (speckit.foo.bar),
cmd_name: Command name in dot notation (speckit.foo.bar),
hyphenated format (speckit-foo-bar), or plain name (foo)
Returns:
Hyphenated command name with 'speckit-' prefix
"""
# Already in hyphenated format - return as-is (idempotent)
if cmd_name.startswith("speckit-"):
return cmd_name
# Strip 'speckit.' prefix if present
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]
# Replace all dots with hyphens
short_name = short_name.replace(".", "-")
# Return with 'speckit-' prefix
return f"speckit-{short_name}"

View File

@@ -6,16 +6,16 @@
**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
<!--
<!--
============================================================================
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
The __SPECKIT_COMMAND_CHECKLIST__ command MUST replace these with actual items based on:
- User's specific checklist request
- Feature requirements from spec.md
- Technical context from plan.md
- Implementation details from tasks.md
DO NOT keep these sample items in the generated checklist file.
============================================================================
-->

View File

@@ -1,6 +1,6 @@
---
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
handoffs:
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a plan for the spec. I am building with...

View File

@@ -1,6 +1,6 @@
---
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
handoffs:
handoffs:
- label: Build Specification
agent: speckit.specify
prompt: Implement the feature specification based on the updated constitution. I want to build...

View File

@@ -43,7 +43,7 @@ You **MUST** consider the user input before proceeding (if not empty).
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
@@ -145,7 +145,7 @@ You **MUST** consider the user input before proceeding (if not empty).
6. Execute implementation following the task plan:
- **Phase-by-phase execution**: Complete each phase before moving to the next
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
- **File-based coordination**: Tasks affecting the same files must run sequentially
- **Validation checkpoints**: Verify each phase completion before proceeding

View File

@@ -1,6 +1,6 @@
---
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
handoffs:
handoffs:
- label: Create Tasks
agent: speckit.tasks
prompt: Break the plan into tasks

View File

@@ -1,6 +1,6 @@
---
description: Create or update the feature specification from a natural language feature description.
handoffs:
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a plan for the spec. I am building with...
@@ -147,20 +147,20 @@ Given that feature description, do this:
```markdown
# Specification Quality Checklist: [FEATURE NAME]
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: [DATE]
**Feature**: [Link to spec.md]
## Content Quality
- [ ] No implementation details (languages, frameworks, APIs)
- [ ] Focused on user value and business needs
- [ ] Written for non-technical stakeholders
- [ ] All mandatory sections completed
## Requirement Completeness
- [ ] No [NEEDS CLARIFICATION] markers remain
- [ ] Requirements are testable and unambiguous
- [ ] Success criteria are measurable
@@ -169,16 +169,16 @@ Given that feature description, do this:
- [ ] Edge cases are identified
- [ ] Scope is clearly bounded
- [ ] Dependencies and assumptions identified
## Feature Readiness
- [ ] All functional requirements have clear acceptance criteria
- [ ] User scenarios cover primary flows
- [ ] Feature meets measurable outcomes defined in Success Criteria
- [ ] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`
```
@@ -203,20 +203,20 @@ Given that feature description, do this:
```markdown
## Question [N]: [Topic]
**Context**: [Quote relevant spec section]
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
**Suggested Answers**:
| Option | Answer | Implications |
|--------|--------|--------------|
| A | [First suggested answer] | [What this means for the feature] |
| B | [Second suggested answer] | [What this means for the feature] |
| C | [Third suggested answer] | [What this means for the feature] |
| Custom | Provide your own answer | [Explain how to provide custom input] |
**Your choice**: _[Wait for user response]_
```

View File

@@ -1,6 +1,6 @@
---
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
handoffs:
handoffs:
- label: Analyze For Consistency
agent: speckit.analyze
prompt: Run a project analysis for consistency
@@ -51,7 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty).
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
@@ -161,7 +161,7 @@ Every task MUST strictly follow this format:
4. **[Story] label**: REQUIRED for user story phase tasks only
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
- Setup phase: NO story label
- Foundational phase: NO story label
- Foundational phase: NO story label
- User Story phases: MUST have story label
- Polish phase: NO story label
5. **Description**: Clear action with exact file path

View File

@@ -11,4 +11,3 @@
".specify/scripts/powershell/": true
}
}

View File

@@ -6,7 +6,7 @@ hooks:
extension: "test-extension"
command: "pre_implement_test"
description: "Test before implement hook execution"
after_implement:
- id: post_test
enabled: true

View File

@@ -7,7 +7,7 @@ This directory contains a mock project to verify that LLM agents correctly ident
1. Open a chat with an LLM (like GitHub Copilot) in this project.
2. Ask it to generate tasks for the current directory:
> "Please follow `/speckit.tasks` for the `./tests/hooks` directory."
3. **Expected Behavior**:
3. **Expected Behavior**:
- Before doing any generation, the LLM should notice the `AUTOMATIC Pre-Hook` in `.specify/extensions.yml` under `before_tasks`.
- It should state it is executing `EXECUTE_COMMAND: pre_tasks_test`.
- It should then proceed to read the `.md` docs and produce a `tasks.md`.

View File

@@ -231,4 +231,3 @@ class TestCursorAgentCliDispatch:
argv = mock_run.call_args[0][0]
assert argv[0] == "cursor-agent"

View File

@@ -71,4 +71,4 @@ class TestDevinInitFlow:
)
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -288,13 +288,13 @@ class TestForgeCommandRegistrar:
def test_registrar_formats_extension_command_names_for_forge(self, tmp_path):
"""Verify CommandRegistrar converts dot notation to hyphens for Forge."""
from specify_cli.agents import CommandRegistrar
# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()
# Create a test command with dot notation name
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
@@ -304,7 +304,7 @@ class TestForgeCommandRegistrar:
"Test content with $ARGUMENTS\n",
encoding="utf-8"
)
# Register with Forge
registrar = CommandRegistrar()
commands = [
@@ -313,7 +313,7 @@ class TestForgeCommandRegistrar:
"file": "commands/example.md"
}
]
registered = registrar.register_commands(
"forge",
commands,
@@ -321,14 +321,14 @@ class TestForgeCommandRegistrar:
ext_dir,
tmp_path
)
# Verify registration succeeded
assert "speckit.my-extension.example" in registered
# Check the generated file has hyphenated name in frontmatter
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-my-extension-example.md"
assert forge_cmd.exists()
content = forge_cmd.read_text(encoding="utf-8")
# Parse frontmatter to validate name field precisely
frontmatter, _ = registrar.parse_frontmatter(content)
@@ -339,13 +339,13 @@ class TestForgeCommandRegistrar:
def test_registrar_formats_alias_names_for_forge(self, tmp_path):
"""Verify CommandRegistrar converts alias names to hyphens for Forge."""
from specify_cli.agents import CommandRegistrar
# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
@@ -354,7 +354,7 @@ class TestForgeCommandRegistrar:
"Test content\n",
encoding="utf-8"
)
# Register with Forge including an alias
registrar = CommandRegistrar()
commands = [
@@ -364,7 +364,7 @@ class TestForgeCommandRegistrar:
"aliases": ["speckit.my-extension.ex"]
}
]
registrar.register_commands(
"forge",
commands,
@@ -372,11 +372,11 @@ class TestForgeCommandRegistrar:
ext_dir,
tmp_path
)
# Check the alias file has hyphenated name in frontmatter
alias_file = tmp_path / ".forge" / "commands" / "speckit-my-extension-ex.md"
assert alias_file.exists()
content = alias_file.read_text(encoding="utf-8")
# Parse frontmatter to validate alias name field precisely
frontmatter, _ = registrar.parse_frontmatter(content)
@@ -387,13 +387,13 @@ class TestForgeCommandRegistrar:
def test_registrar_does_not_affect_other_agents(self, tmp_path):
"""Verify format_name callback is Forge-specific and doesn't affect other agents."""
from specify_cli.agents import CommandRegistrar
# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
@@ -402,7 +402,7 @@ class TestForgeCommandRegistrar:
"Test content with $ARGUMENTS\n",
encoding="utf-8"
)
# Register with Kilo Code (standard markdown agent without inject_name)
registrar = CommandRegistrar()
commands = [
@@ -411,7 +411,7 @@ class TestForgeCommandRegistrar:
"file": "commands/example.md"
}
]
registrar.register_commands(
"kilocode",
commands,
@@ -419,12 +419,12 @@ class TestForgeCommandRegistrar:
ext_dir,
tmp_path
)
# Kilo Code uses standard markdown format without name injection.
# The format_name callback should not be invoked for non-Forge agents.
kilocode_cmd = tmp_path / ".kilocode" / "workflows" / "speckit.my-extension.example.md"
assert kilocode_cmd.exists()
content = kilocode_cmd.read_text(encoding="utf-8")
# Kilo Code should NOT have a name field injected
assert "name:" not in content, (

View File

@@ -34,4 +34,4 @@ class TestVibeUserInvocable:
parsed = yaml.safe_load(parts[1])
assert parsed.get("user-invocable") is True, (
f"{f.parent.name}/SKILL.md is missing user-invocable: true in frontmatter"
)
)

View File

@@ -17,7 +17,7 @@ class TestExtensionRegistration:
"""Standard registration: Adding an extension should add it to the list."""
executor = HookExecutor(project_dir)
executor.register_extension("test-ext")
config = executor.get_project_config()
assert "installed" in config
assert config["installed"] == ["test-ext"]
@@ -28,7 +28,7 @@ class TestExtensionRegistration:
executor.register_extension("zebra-ext")
executor.register_extension("apple-ext")
executor.register_extension("middle-ext")
config = executor.get_project_config()
assert config["installed"] == ["apple-ext", "middle-ext", "zebra-ext"]
@@ -37,7 +37,7 @@ class TestExtensionRegistration:
executor = HookExecutor(project_dir)
executor.register_extension("test-ext")
executor.register_extension("test-ext")
config = executor.get_project_config()
assert config["installed"] == ["test-ext"]
assert len(config["installed"]) == 1
@@ -47,9 +47,9 @@ class TestExtensionRegistration:
executor = HookExecutor(project_dir)
executor.register_extension("ext-1")
executor.register_extension("ext-2")
executor.unregister_extension("ext-1")
config = executor.get_project_config()
assert config["installed"] == ["ext-2"]
@@ -57,10 +57,10 @@ class TestExtensionRegistration:
"""Safe Removal: Unregistering a non-existent extension should do nothing."""
executor = HookExecutor(project_dir)
executor.register_extension("ext-1")
# Should not raise or change the list
executor.unregister_extension("ext-nonexistent")
config = executor.get_project_config()
assert config["installed"] == ["ext-1"]
@@ -87,27 +87,27 @@ class TestExtensionRegistration:
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
executor = HookExecutor(project_dir)
# This should call register_extension internally
executor.register_hooks(manifest)
config = executor.get_project_config()
assert "hook-ext" in config["installed"]
def test_missing_installed_key_initialization(self, project_dir):
"""Graceful Initialization: If 'installed' key is missing, it should be created."""
executor = HookExecutor(project_dir)
# Manually create a config without 'installed'
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({"settings": {"auto_execute_hooks": True}}))
# This should detect the missing key and initialize it
executor.register_extension("new-ext")
config = executor.get_project_config()
assert "installed" in config
assert config["installed"] == ["new-ext"]
@@ -135,20 +135,20 @@ class TestExtensionRegistration:
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
executor = HookExecutor(project_dir)
# Register hooks first
executor.register_hooks(manifest)
config = executor.get_project_config()
assert "hook-ext" in config["installed"]
assert "after_tasks" in config["hooks"]
# Now unregister hooks
executor.unregister_hooks("hook-ext")
config = executor.get_project_config()
assert "hook-ext" not in config["installed"]
# unregister_hooks() removes the empty hook array entirely, so the key is absent
@@ -157,16 +157,16 @@ class TestExtensionRegistration:
def test_unregister_hooks_no_hooks_key(self, project_dir):
"""Resilience: unregister_hooks should work even if config has no 'hooks' key."""
executor = HookExecutor(project_dir)
# Register extension without hooks
executor.register_extension("ext-no-hooks")
config = executor.get_project_config()
assert "ext-no-hooks" in config["installed"]
# Unregister should not crash even if no hooks key exists
executor.unregister_hooks("ext-no-hooks")
config = executor.get_project_config()
assert "ext-no-hooks" not in config["installed"]
@@ -175,12 +175,12 @@ class TestExtensionRegistration:
# Create a corrupted config (root is a list)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump(["corrupted", "list"]))
executor = HookExecutor(project_dir)
# Should not raise even with corrupted config
executor.unregister_hooks("non-existent")
# Config should remain as-is or be handled gracefully
config = executor.get_project_config()
# If it's corrupted, it's returned as-is or handled by defensive logic
@@ -223,30 +223,30 @@ class TestExtensionRegistration:
"after_tasks": {"command": "speckit.ext-2.run"}
}
}
manifest_path_1 = tmp_path / "extension1.yml"
manifest_path_2 = tmp_path / "extension2.yml"
with open(manifest_path_1, "w") as f:
yaml.dump(manifest_data_1, f)
with open(manifest_path_2, "w") as f:
yaml.dump(manifest_data_2, f)
manifest1 = ExtensionManifest(manifest_path_1)
manifest2 = ExtensionManifest(manifest_path_2)
executor = HookExecutor(project_dir)
# Register both extensions
executor.register_hooks(manifest1)
executor.register_hooks(manifest2)
config = executor.get_project_config()
assert "ext-1" in config["installed"]
assert "ext-2" in config["installed"]
assert len(config["hooks"]["after_tasks"]) == 2
# Unregister first extension
executor.unregister_hooks("ext-1")
config = executor.get_project_config()
assert "ext-1" not in config["installed"]
assert "ext-2" in config["installed"]
@@ -346,9 +346,9 @@ class TestExtensionRegistration:
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
# Should not raise TypeError when trying to append to None
executor.register_hooks(manifest)
@@ -378,16 +378,16 @@ class TestExtensionRegistration:
"""Review Feedback: register_extension should support and preserve dict entries."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
# Setup config with a pinned extension (dict)
pinned_ext = {"id": "pinned-ext", "version": "1.0.0"}
config_path.write_text(yaml.dump({
"installed": [pinned_ext, "string-ext"]
}))
# Register a new extension
executor.register_extension("new-ext")
config = executor.get_project_config()
# Should contain all three, sorted by id: new-ext, pinned-ext, string-ext
assert config["installed"] == ["new-ext", pinned_ext, "string-ext"]
@@ -396,15 +396,15 @@ class TestExtensionRegistration:
"""Review Feedback: unregister_extension should support removing matching dict entries."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
pinned_ext = {"id": "to-remove", "version": "1.0.0"}
config_path.write_text(yaml.dump({
"installed": [pinned_ext, "other-ext"]
}))
# Unregister by ID
executor.unregister_extension("to-remove")
config = executor.get_project_config()
assert config["installed"] == ["other-ext"]
@@ -412,14 +412,14 @@ class TestExtensionRegistration:
"""Hardening: unregister_extension should handle non-list installed key."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": "not-a-list"
}))
# Should not crash and should normalize to []
executor.unregister_extension("any-ext")
config = executor.get_project_config()
assert config["installed"] == []
def test_register_hooks_mixed_type_hook_list(self, project_dir, tmp_path):
@@ -458,7 +458,7 @@ class TestExtensionRegistration:
config = executor.get_project_config()
hooks = config["hooks"]["after_tasks"]
# Should have 2 valid dict hooks, and 0 non-dict items
assert len(hooks) == 2
assert all(isinstance(h, dict) for h in hooks)
@@ -469,12 +469,12 @@ class TestExtensionRegistration:
"""Hardening: unregister_extension should handle scalar root config."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump(123))
# Should not crash and should normalize to {}
executor.unregister_extension("any-ext")
config = executor.get_project_config()
assert isinstance(config, dict)
assert config["installed"] == []
@@ -483,15 +483,15 @@ class TestExtensionRegistration:
"""Regression: unregister_hooks() must handle scalar hook event values."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["some-ext"],
"hooks": {"after_tasks": 123}
}))
# Should not raise TypeError when iterating
executor.unregister_hooks("some-ext")
config = executor.get_project_config()
assert "some-ext" not in config["installed"]
assert "after_tasks" not in config["hooks"]

View File

@@ -20,28 +20,28 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch):
"""Regression: extension update must handle corrupted extensions.yml (root is scalar)."""
# chdir into project_dir so _require_specify_project() succeeds
monkeypatch.chdir(project_dir)
# Corrupt extensions.yml
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump(123))
# Mock ExtensionManager to return an installed extension for resolution
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
# Mock download_extension to avoid network calls; use tmp_path so the test is hermetic
# and returns a Path so zip_path.exists() / zip_path.unlink() work without AttributeError
mock_zip = project_dir / "mock.zip"
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
# Mock confirmation to true
monkeypatch.setattr("typer.confirm", lambda _: True)
# Run update
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
# extension_update() catches exceptions internally and exits with code 1 on failure.
assert result.exit_code == 1
assert "AttributeError" not in result.output
@@ -50,13 +50,13 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch):
def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
"""Regression: extension update must handle non-dict 'hooks' in extensions.yml."""
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["test-ext"],
"hooks": ["not", "a", "dict"]
}))
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
@@ -64,9 +64,9 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
mock_zip = project_dir / "mock.zip"
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
monkeypatch.setattr("typer.confirm", lambda _: True)
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
# extension_update() catches exceptions internally and exits with code 1 on failure.
assert result.exit_code == 1
assert "AttributeError" not in result.output
@@ -75,33 +75,33 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch):
"""Regression: extension update rollback must handle corrupted extensions.yml."""
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
# Write config with hooks: null; get_project_config() normalizes this to {}
# so the backup captures {} and the restored config will have hooks: {}.
config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": None}))
# Mock update process to fail after backup
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
# Force failure in download_extension to trigger rollback
def mock_download_fail(*args, **kwargs):
# Corrupt the config BEFORE rollback is triggered
config_path.write_text(yaml.dump("CORRUPTED"))
raise Exception("Download failed")
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
monkeypatch.setattr(ExtensionCatalog, "download_extension", mock_download_fail)
monkeypatch.setattr("typer.confirm", lambda _: True)
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
# Should handle Exception and NOT crash with AttributeError during rollback
assert result.exit_code == 1
assert "Download failed" in result.output
assert not isinstance(result.exception, AttributeError)
# Verify hooks key was preserved (normalized to {} if it was null/corrupted)
restored_config = yaml.safe_load(config_path.read_text())
assert isinstance(restored_config, dict)

View File

@@ -10,12 +10,12 @@ def test_merge_json_files_type_mismatch_preservation(tmp_path):
existing_file = tmp_path / "settings.json"
# User might have overridden a setting with a simple string or different type
existing_file.write_text('{"chat.editor.fontFamily": "CustomFont"}')
# Template might expect a dict for the same key (hypothetically)
new_settings = {
"chat.editor.fontFamily": {"font": "TemplateFont"}
}
merged = merge_json_files(existing_file, new_settings)
# Result is None because user settings were preserved and nothing else changed
assert merged is None
@@ -32,7 +32,7 @@ def test_merge_json_files_deep_nesting(tmp_path):
}
}
""")
new_settings = {
"a": {
"b": {
@@ -41,7 +41,7 @@ def test_merge_json_files_deep_nesting(tmp_path):
"e": 3 # New mid-level key
}
}
merged = merge_json_files(existing_file, new_settings)
assert merged["a"]["b"]["c"] == 1
assert merged["a"]["b"]["d"] == 2
@@ -51,7 +51,7 @@ def test_merge_json_files_empty_existing(tmp_path):
"""Merging into an empty/new file."""
existing_file = tmp_path / "empty.json"
existing_file.write_text("{}")
new_settings = {"a": 1}
merged = merge_json_files(existing_file, new_settings)
assert merged == {"a": 1}
@@ -74,7 +74,7 @@ def test_merge_vscode_realistic_scenario(tmp_path):
} // User comment
}
""")
template_settings = {
"chat.promptFilesRecommendations": {
"speckit.specify": True,
@@ -84,14 +84,14 @@ def test_merge_vscode_realistic_scenario(tmp_path):
".specify/scripts/bash/": True
}
}
merged = merge_json_files(existing_file, template_settings)
# Check preservation
assert merged["editor.fontSize"] == 12
assert merged["files.exclude"]["**/.git"] is True
assert merged["chat.promptFilesRecommendations"]["existing.tool"] is True
# Check additions
assert merged["chat.promptFilesRecommendations"]["speckit.specify"] is True
assert merged["chat.tools.terminal.autoApprove"][".specify/scripts/bash/"] is True
@@ -104,7 +104,7 @@ def test_merge_json_files_with_bom(tmp_path):
content = '{"a": 1}'
# Prepend UTF-8 BOM
existing_file.write_bytes(b'\xef\xbb\xbf' + content.encode('utf-8'))
new_settings = {"b": 2}
merged = merge_json_files(existing_file, new_settings)
assert merged == {"a": 1, "b": 2}
@@ -113,7 +113,7 @@ def test_merge_json_files_not_a_dictionary_template(tmp_path):
"""If for some reason new_content is not a dict, PRESERVE existing settings by returning None."""
existing_file = tmp_path / "ok.json"
existing_file.write_text('{"a": 1}')
# Secure fallback: return None to skip writing and avoid clobbering
assert merge_json_files(existing_file, ["not", "a", "dict"]) is None
@@ -121,7 +121,7 @@ def test_merge_json_files_unparseable_existing(tmp_path):
"""If the existing file is unparseable JSON, return None to avoid overwriting it."""
bad_file = tmp_path / "bad.json"
bad_file.write_text('{"a": 1, missing_value}') # Invalid JSON
assert merge_json_files(bad_file, {"b": 2}) is None
@@ -129,11 +129,11 @@ def test_merge_json_files_list_preservation(tmp_path):
"""Verify that existing list values are preserved and NOT merged or overwritten."""
existing_file = tmp_path / "list.json"
existing_file.write_text('{"my.list": ["user_item"]}')
template_settings = {
"my.list": ["template_item"]
}
merged = merge_json_files(existing_file, template_settings)
# The polite merge policy says: keep existing values if they exist and aren't both dicts.
# Since nothing changed, it returns None.
@@ -143,12 +143,12 @@ def test_merge_json_files_no_changes(tmp_path):
"""If the merge doesn't introduce any new keys or changes, return None to skip rewrite."""
existing_file = tmp_path / "no_change.json"
existing_file.write_text('{"a": 1, "b": {"c": 2}}')
template_settings = {
"a": 1, # Already exists
"b": {"c": 2} # Already exists nested
}
# Should return None because result == existing
assert merge_json_files(existing_file, template_settings) is None
@@ -156,11 +156,11 @@ def test_merge_json_files_type_mismatch_no_op(tmp_path):
"""If a key exists with different type and we preserve it, it might still result in no change."""
existing_file = tmp_path / "mismatch_no_op.json"
existing_file.write_text('{"a": "user_string"}')
template_settings = {
"a": {"key": "template_dict"} # Mismatch, will be ignored
}
# Should return None because we preserved the user's string and nothing else changed
assert merge_json_files(existing_file, template_settings) is None

View File

@@ -1,15 +1,15 @@
"""Tests for setup-tasks.{sh,ps1} template resolution and feature resolution."""
import json
import os
import shutil
import subprocess
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"
SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh"
@@ -18,38 +18,38 @@ COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1"
CHECK_PREREQ_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _install_bash_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "bash"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, d / "common.sh")
shutil.copy(SETUP_TASKS_SH, d / "setup-tasks.sh")
shutil.copy(CHECK_PREREQ_SH, d / "check-prerequisites.sh")
def _install_ps_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "powershell"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, d / "common.ps1")
shutil.copy(SETUP_TASKS_PS, d / "setup-tasks.ps1")
shutil.copy(CHECK_PREREQ_PS, d / "check-prerequisites.ps1")
def _install_core_tasks_template(repo: Path) -> None:
"""Copy the real tasks-template.md into the core template location."""
tdir = repo / ".specify" / "templates"
tdir.mkdir(parents=True, exist_ok=True)
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
@@ -90,8 +90,8 @@ def _write_integration_state(repo: Path, integration: str = "claude", separator:
json.dumps(state),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
"""
Return os.environ with all SPECIFY_* variables stripped so the scripts
@@ -102,8 +102,8 @@ def _clean_env() -> dict[str, str]:
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _run_bash_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
script = repo / ".specify" / "scripts" / "bash" / "common.sh"
return subprocess.run(
@@ -145,12 +145,12 @@ def _git_init(repo: Path) -> None:
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
)
# ---------------------------------------------------------------------------
# Shared fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def tasks_repo(tmp_path: Path) -> Path:
"""
@@ -162,7 +162,7 @@ def tasks_repo(tmp_path: Path) -> Path:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
# Keep a numbered branch name in this repo fixture; setup-tasks now resolves
# feature directories from repository state rather than validating git branches.
subprocess.run(
@@ -170,18 +170,18 @@ def tasks_repo(tmp_path: Path) -> Path:
cwd=repo,
check=True,
)
(repo / ".specify").mkdir()
_install_core_tasks_template(repo)
_install_bash_scripts(repo)
_install_ps_scripts(repo)
return repo
# ===========================================================================
# BASH TESTS
# ===========================================================================
@requires_bash
def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
"""
@@ -191,7 +191,7 @@ def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
"""
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -200,16 +200,16 @@ def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl.name == "tasks-template.md"
@requires_bash
def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
"""
@@ -217,15 +217,15 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
setup-tasks.sh --json must return the override path, not the core path.
"""
_minimal_feature(tasks_repo)
# Create the override
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True, exist_ok=True)
override_file = overrides_dir / "tasks-template.md"
override_file.write_text("# override tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -234,9 +234,9 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
@@ -245,8 +245,8 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
assert "overrides" in tasks_tmpl.parts, (
f"Expected override path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
"""
@@ -254,7 +254,7 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
tasks-template.md from the extension before falling back to the core path.
"""
_minimal_feature(tasks_repo)
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
extension_dir = (
tasks_repo / ".specify" / "extensions" / "test-extension" / "templates"
@@ -262,9 +262,9 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
extension_dir.mkdir(parents=True, exist_ok=True)
extension_file = extension_dir / "tasks-template.md"
extension_file.write_text("# extension tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -273,9 +273,9 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
@@ -283,8 +283,8 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
assert tasks_tmpl == extension_file.resolve(), (
f"Expected extension path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
"""
@@ -292,7 +292,7 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
resolve the preset path because presets outrank extensions.
"""
_minimal_feature(tasks_repo)
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
extension_dir = (
tasks_repo / ".specify" / "extensions" / "test-extension" / "templates"
@@ -300,15 +300,15 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
extension_dir.mkdir(parents=True, exist_ok=True)
extension_file = extension_dir / "tasks-template.md"
extension_file.write_text("# extension tasks template\n", encoding="utf-8")
# FIX: real preset layout is .specify/presets/<id>/templates/<name>.md
preset_dir = tasks_repo / ".specify" / "presets" / "test-preset" / "templates"
preset_dir.mkdir(parents=True, exist_ok=True)
preset_file = preset_dir / "tasks-template.md"
preset_file.write_text("# preset tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -317,9 +317,9 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
@@ -327,8 +327,8 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
assert tasks_tmpl == preset_file.resolve(), (
f"Expected preset path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
"""
@@ -336,7 +336,7 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
.specify/presets/.registry wins.
"""
_minimal_feature(tasks_repo)
# resolve_template reads .specify/presets/.registry as a JSON object with a
# "presets" map where each entry has a numeric "priority" (lower = higher
# precedence). Create two presets; priority-1-preset wins over priority-2-preset.
@@ -349,7 +349,7 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
low_priority_dir = (
tasks_repo / ".specify" / "presets" / "priority-2-preset" / "templates"
)
low_priority_dir.mkdir(parents=True, exist_ok=True)
low_priority_file = low_priority_dir / "tasks-template.md"
low_priority_file.write_text("# low priority preset tasks template\n", encoding="utf-8")
@@ -366,9 +366,9 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
}),
encoding="utf-8",
)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -377,9 +377,9 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
@@ -387,8 +387,8 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
assert tasks_tmpl == high_priority_file.resolve(), (
f"Expected high-priority preset path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
"""
@@ -396,13 +396,13 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
exit non-zero and print a helpful ERROR message to stderr.
"""
_minimal_feature(tasks_repo)
# Remove the core template so no template exists anywhere
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
core.unlink()
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -415,8 +415,8 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
assert result.returncode != 0
assert "ERROR" in result.stderr
assert "tasks-template" in result.stderr
@requires_bash
def test_bash_command_hint_defaults_to_dot_without_integration_json(tasks_repo: Path) -> None:
integration_json = tasks_repo / ".specify" / "integration.json"
@@ -619,15 +619,15 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
cwd=tasks_repo,
check=True,
)
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -636,10 +636,10 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
@requires_bash
def test_setup_tasks_bash_errors_without_feature_context(
tasks_repo: Path,
@@ -663,11 +663,11 @@ def test_setup_tasks_bash_errors_without_feature_context(
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
# ===========================================================================
# POWERSHELL TESTS
# ===========================================================================
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
"""
@@ -678,7 +678,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
@@ -687,16 +687,16 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl.name == "tasks-template.md"
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
"""
@@ -704,15 +704,15 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
setup-tasks.ps1 -Json must return the override path, not the core path.
"""
_minimal_feature(tasks_repo)
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True, exist_ok=True)
override_file = overrides_dir / "tasks-template.md"
override_file.write_text("# override tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
@@ -721,9 +721,9 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
@@ -731,8 +731,8 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
assert "overrides" in tasks_tmpl.parts, (
f"Expected override path but got: {tasks_tmpl}"
)
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
"""
@@ -740,13 +740,13 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
exit non-zero and write a helpful error to stderr.
"""
_minimal_feature(tasks_repo)
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
core.unlink()
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
@@ -755,11 +755,11 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower()
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_powershell_command_hint_normalizes_mixed_separators(
tasks_repo: Path,
@@ -855,16 +855,16 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
cwd=tasks_repo,
check=True,
)
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
@@ -873,10 +873,10 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_errors_without_feature_context(
tasks_repo: Path,

View File

@@ -1321,4 +1321,3 @@ class TestDescriptionQuoting:
"""Plain description without special characters continues to work."""
result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature")
assert result.returncode == 0, result.stderr