From 3f7392ae32131c9cfe6a1f97c5c213311183263a Mon Sep 17 00:00:00 2001 From: Tine Kondo Date: Thu, 9 Jul 2026 20:45:04 +0200 Subject: [PATCH 01/12] docs: add 'spectatui' entry to friends.md (#3362) * docs: add 'spectatui' entry to friends.md Added a new entry for 'spectatui', a terminal UI dashboard for GitHub Spec-Kit, detailing its features and capabilities. https://github.com/tinesoft/spectatui * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * docs: fix wording of the `spectatui` tool's decription Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/community/friends.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/community/friends.md b/docs/community/friends.md index 9aff166d0..82fd9f2bc 100644 --- a/docs/community/friends.md +++ b/docs/community/friends.md @@ -14,3 +14,5 @@ Community projects that extend, visualize, or build on Spec Kit: - **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. - **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace. + +- **[spectatui](https://github.com/tinesoft/spectatui)** — A terminal UI (TUI) dashboard for Spec Kit that lets you track features, manage specifications, integrations, presets, workflows, and extensions, and monitor AI agent workflows. Attach to existing AI sessions or launch new ones from your terminal. Keyboard and mouse support. Light/dark theme support. Customizable and performance-oriented. Requires the `specify` CLI in your PATH. From dbefc66acb9ede5006640aafa453e865a63094aa Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Fri, 10 Jul 2026 17:45:09 +0500 Subject: [PATCH 02/12] fix(git-ext): honor explicit -Number 0 in PowerShell branch creation (#3412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `-Number` defaults to 0, so the previous `-eq 0` / `-ne 0` checks could not distinguish an unset flag from an explicit `-Number 0`: a user requesting branch `000-...` was silently routed into auto-detection. Switch both checks to `$PSBoundParameters.ContainsKey('Number')`, which tests whether the flag was actually supplied — mirroring the bash twin's empty-string sentinel (`[ -z "$BRANCH_NUMBER" ]` / `[ -n ... ]`). Add a parity regression test to both TestCreateFeatureBash and TestCreateFeaturePowerShell asserting `--number 0` / `-Number 0` yields `000-zero`. Co-authored-by: Claude Opus 4.8 (1M context) --- .../powershell/create-new-feature-branch.ps1 | 10 ++++-- tests/extensions/git/test_git_extension.py | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 68983db63..3c47d17e3 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -446,7 +446,10 @@ if ($env:GIT_BRANCH_NAME) { $branchSuffix = Get-BranchName -Description $featureDesc } - if ($Timestamp -and $Number -ne 0) { + # Warn if -Number and -Timestamp are both specified. Use ContainsKey (not + # `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's + # `[ -n "$BRANCH_NUMBER" ]` check. + if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) { Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" $Number = 0 } @@ -456,7 +459,10 @@ if ($env:GIT_BRANCH_NAME) { $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix } else { $branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix - if ($Number -eq 0) { + # Auto-detect the next number only when -Number was not supplied; an + # explicit value (including 0) is honored, matching the bash twin's + # `[ -z "$BRANCH_NUMBER" ]` check. + if (-not $PSBoundParameters.ContainsKey('Number')) { if ($DryRun -and $hasGit) { $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix } elseif ($DryRun) { diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 71dfd0222..0478b9085 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -638,6 +638,21 @@ class TestCreateFeatureBash: assert result.returncode != 0 assert "requires updated Spec Kit core scripts" in result.stderr + def test_explicit_number_zero_is_honored(self, tmp_path: Path): + """An explicit --number 0 is honored (yields 000), not treated as + 'auto-detect'. Pins the canonical behavior the PowerShell twin must + mirror; the empty-string check (`[ -z "$BRANCH_NUMBER" ]`) already + distinguishes an unset flag from a supplied 0.""" + project = _setup_project(tmp_path) + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--number", "0", "--short-name", "zero", "Zero feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "000-zero" + assert data["FEATURE_NUM"] == "000" + @pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") class TestCreateFeaturePowerShell: @@ -942,6 +957,23 @@ class TestCreateFeaturePowerShell: assert result.returncode != 0 assert "requires updated Spec Kit core scripts" in result.stderr + def test_explicit_number_zero_is_honored(self, tmp_path: Path): + """An explicit -Number 0 is honored (yields 000), matching the bash twin's + --number 0. Regression guard: -Number defaults to 0, so a bare `-eq 0` + check cannot tell an unset flag from a supplied 0 and would silently + auto-detect instead. Uses PSBoundParameters.ContainsKey('Number').""" + project = _setup_project(tmp_path) + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-Number", "0", "-ShortName", "zero", "Zero feature", + ) + assert result.returncode == 0, result.stderr + json_line = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")] + assert json_line, f"No JSON in output: {result.stdout}" + data = json.loads(json_line[-1]) + assert data["BRANCH_NAME"] == "000-zero" + assert data["FEATURE_NUM"] == "000" + # ── auto-commit.sh Tests ───────────────────────────────────────────────────── From d035a3f0395ac9f79d39da8fab6c7383070a045d Mon Sep 17 00:00:00 2001 From: Marsel Safin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:48:06 +0200 Subject: [PATCH 03/12] fix(templates): correct phase numbering in plan.md (#3416) The plan command's outline listed two bullets labeled Phase 1 and the completion report said the command ends after "Phase 2 planning", but the Phases section only defines Phase 0 and Phase 1. Phase 2 (tasks.md) belongs to the tasks command, as plan-template.md states. The duplicated "Phase 1: Update agent context" bullet is a leftover from before the agent-context extension: core no longer ships an agent script, and the update runs via the extension's after_plan hook. Fixes #1036 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- templates/commands/plan.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/templates/commands/plan.md b/templates/commands/plan.md index e82bd4b30..063fc911b 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -68,7 +68,6 @@ You **MUST** consider the user input before proceeding (if not empty). - Evaluate gates (ERROR if violations unjustified) - Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION) - Phase 1: Generate data-model.md, contracts/, quickstart.md - - Phase 1: Update agent context by running the agent script - Re-evaluate Constitution Check post-design ## Mandatory Post-Execution Hooks @@ -107,7 +106,7 @@ Check if `.specify/extensions.yml` exists in the project root. ## Completion Report -Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts. +Command ends after Phase 1 design. Report branch, IMPL_PLAN path, and generated artifacts. ## Phases From 43ac4c158c02a737950b0fedc3102bfbc0b9c72d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:03:35 -0500 Subject: [PATCH 04/12] chore(deps): bump DavidAnson/markdownlint-cli2-action (#3438) Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 23.2.0 to 24.0.0. - [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases) - [Commits](https://github.com/davidanson/markdownlint-cli2-action/compare/ded1f9488f68a970bc66ea5619e13e9b52e601cd...8de2aa07cae85fd17c0b35642db70cf5495f1d25) --- updated-dependencies: - dependency-name: DavidAnson/markdownlint-cli2-action dependency-version: 24.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8ec68a6e9..49bf14fa1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -37,7 +37,7 @@ jobs: fi - name: Run markdownlint-cli2 - uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23 + uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0 with: globs: | '**/*.md' From f537dfb2ac26cf7a2744437f8655bc555b6515e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:05:40 -0500 Subject: [PATCH 05/12] chore(deps): bump astral-sh/setup-uv from 8.2.0 to 8.3.2 (#3439) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 8.2.0 to 8.3.2. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/fac544c07dec837d0ccb6301d7b5580bf5edae39...11f9893b081a58869d3b5fccaea48c9e9e46f990) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 8.3.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-pypi.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 1abda3e91..d93501906 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -32,7 +32,7 @@ jobs: ref: refs/tags/${{ inputs.tag }} - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 @@ -74,7 +74,7 @@ jobs: path: dist/ - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - name: Publish to PyPI run: uv publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index be3caa784..fa4c1e8b6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 From b58ffba000be0058b079f40ee09b48cd323bf684 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:14:48 -0500 Subject: [PATCH 06/12] chore: release 0.12.10, begin 0.12.11.dev0 development (#3453) * chore: bump version to 0.12.10 * chore: begin 0.12.11.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f3109b2..8bbc77efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.12.10] - 2026-07-10 + +### Changed + +- chore(deps): bump astral-sh/setup-uv from 8.2.0 to 8.3.2 (#3439) +- chore(deps): bump DavidAnson/markdownlint-cli2-action (#3438) +- fix(templates): correct phase numbering in plan.md (#3416) +- fix(git-ext): honor explicit -Number 0 in PowerShell branch creation (#3412) +- docs: add 'spectatui' entry to friends.md (#3362) +- test: pin interpreter probe so py-template render test passes on Windows (#3428) +- feat(workflows): make shell step timeout configurable (#3404) +- fix: find plans in nested spec directories (#3405) +- feat(templates): add py: lines to command templates' scripts frontmatter (#3403) +- chore: release 0.12.9, begin 0.12.10.dev0 development (#3426) + ## [0.12.9] - 2026-07-09 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 7b1fd7790..87bc67336 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.12.10.dev0" +version = "0.12.11.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" From 87a9690cf9ee5361a6dba34538321059aa19a6d5 Mon Sep 17 00:00:00 2001 From: Marsel Safin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:45:31 +0200 Subject: [PATCH 07/12] fix(templates): remove self-referencing path in plan-template.md note (#3417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note told readers to "See .specify/templates/plan-template.md for the execution workflow" — that path is the file itself. The execution workflow lives in the plan command's definition, which the note already names via the __SPECKIT_COMMAND_PLAN__ placeholder. Point there instead of at a self-reference. Fixes #1148 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- templates/plan-template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/plan-template.md b/templates/plan-template.md index 4fe6c8844..36f2eab16 100644 --- a/templates/plan-template.md +++ b/templates/plan-template.md @@ -4,7 +4,7 @@ **Input**: Feature specification from `/specs/[###-feature-name]/spec.md` -**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow. +**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command; its definition describes the execution workflow. ## Summary From 34514fb20a34d71429364b9617e4f45a4be8349a Mon Sep 17 00:00:00 2001 From: Marsel Safin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:51:19 +0200 Subject: [PATCH 08/12] fix(workflows): report validation errors instead of crashing on non-string workflow.yml scalars (#3421) * fix(workflows): validate scalar types before string operations in workflow validation YAML parses unquoted scalars like version: 1.0 and id: 123 as float/int, which crashed validate_workflow and workflow add with raw tracebacks. Type-check id, name, version and step ids before regex and string operations so these surface as validation errors. Accept an unquoted schema_version: 1.0 instead of printing a self-identical rejection message. Fixes #3420 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(workflows): treat falsey non-strings as type errors, not missing fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(workflows): only accept schema_version 1.0 so the error message is accurate The check also accepted "1" while the error said Expected '1.0'. Unquoted YAML 1.0 still works via str(); plain 1 is now rejected with the message that matches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/workflows/_commands.py | 10 +- src/specify_cli/workflows/engine.py | 39 ++++++- tests/test_workflows.py | 145 +++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py index b1eecd619..e1d29b47d 100644 --- a/src/specify_cli/workflows/_commands.py +++ b/src/specify_cli/workflows/_commands.py @@ -601,7 +601,15 @@ def workflow_add( except (ValueError, yaml.YAMLError) as exc: console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}") raise typer.Exit(1) - if not definition.id or not definition.id.strip(): + # Non-string ids (e.g. unquoted ``id: 123`` or ``id: 0``) fall through + # to validate_workflow below, which reports a typed error instead of + # crashing on ``.strip()`` here. Only None/empty/whitespace-only ids + # are rejected as missing. + if ( + definition.id is None + or definition.id == "" + or (isinstance(definition.id, str) and not definition.id.strip()) + ): console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'") raise typer.Exit(1) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 68f2ca6f3..6025c30c5 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -129,26 +129,49 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]: errors: list[str] = [] # -- Schema version --------------------------------------------------- - if definition.schema_version not in ("1.0", "1"): + # str() so an unquoted ``schema_version: 1.0`` (YAML float) is accepted — + # rejecting it would print "Unsupported schema_version 1.0. Expected '1.0'." + if str(definition.schema_version) != "1.0": errors.append( f"Unsupported schema_version {definition.schema_version!r}. " f"Expected '1.0'." ) # -- Top-level fields ------------------------------------------------- - if not definition.id: + # YAML parses unquoted scalars like ``id: 123`` or ``version: 1.0`` as + # int/float; check types before regex/string operations so authoring + # mistakes surface as validation errors instead of tracebacks. Only + # ``None``/empty-string count as missing so falsey non-strings + # (``id: 0``, ``name: false``) still get the typed error. + if definition.id is None or definition.id == "": errors.append("Workflow is missing 'workflow.id'.") + elif not isinstance(definition.id, str): + errors.append( + f"'workflow.id' must be a string, got " + f"{type(definition.id).__name__} ({definition.id!r})." + ) elif not _ID_PATTERN.match(definition.id): errors.append( f"Workflow ID {definition.id!r} must be lowercase alphanumeric " f"with hyphens." ) - if not definition.name: + if definition.name is None or definition.name == "": errors.append("Workflow is missing 'workflow.name'.") + elif not isinstance(definition.name, str): + errors.append( + f"'workflow.name' must be a string, got " + f"{type(definition.name).__name__} ({definition.name!r})." + ) - if not definition.version: + if definition.version is None or definition.version == "": errors.append("Workflow is missing 'workflow.version'.") + elif not isinstance(definition.version, str): + errors.append( + f"'workflow.version' must be a string, got " + f"{type(definition.version).__name__} ({definition.version!r}) — " + f'quote it in YAML (version: "1.0.0").' + ) elif not re.match(r"^\d+\.\d+\.\d+$", definition.version): errors.append( f"Workflow version {definition.version!r} is not valid " @@ -256,9 +279,15 @@ def _validate_steps( continue step_id = step_config.get("id") - if not step_id: + if step_id is None or step_id == "": errors.append("Step is missing 'id' field.") continue + if not isinstance(step_id, str): + errors.append( + f"Step ID must be a string, got " + f"{type(step_id).__name__} ({step_id!r})." + ) + continue if ":" in step_id: errors.append( diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 00e0bafed..d7cff20f6 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -2794,6 +2794,101 @@ steps: errors = validate_workflow(definition) assert any("lowercase alphanumeric" in e for e in errors) + def test_non_string_workflow_id_reports_error(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: 123 + name: "Test" + version: "1.0.0" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("workflow.id" in e and "string" in e for e in errors) + + def test_non_string_name_reports_error(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: 123 + version: "1.0.0" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("workflow.name" in e and "string" in e for e in errors) + + def test_unquoted_float_version_reports_error(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: 1.0 +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("workflow.version" in e and "quote" in e for e in errors) + + def test_non_string_step_id_reports_error(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: 123 + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("Step ID" in e and "string" in e for e in errors) + + def test_falsey_non_string_scalars_report_typed_errors(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: 0 + name: false + version: 0.0 +steps: + - id: 0 + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("'workflow.id' must be a string" in e for e in errors) + assert any("'workflow.name' must be a string" in e for e in errors) + assert any("'workflow.version' must be a string" in e for e in errors) + assert any("Step ID must be a string" in e for e in errors) + assert not any("missing" in e for e in errors) + + def test_unquoted_schema_version_accepted(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +schema_version: 1.0 +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert errors == [] + def test_no_steps(self): from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow @@ -7049,3 +7144,53 @@ steps: }, ) assert _gate_outcome(state) is None + + +class TestWorkflowAddNonStringScalars: + """`workflow add` reports clean errors for non-string YAML scalars (#3420).""" + + @pytest.mark.parametrize( + ("field_yaml", "expected"), + [ + ('id: 123\n name: "Probe"\n version: "1.0.0"', "workflow.id"), + ('id: "probe"\n name: "Probe"\n version: 1.0', "workflow.version"), + ], + ) + def test_add_reports_validation_error_not_traceback( + self, project_dir, monkeypatch, field_yaml, expected + ): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + wf = project_dir / "workflow.yml" + wf.write_text( + "schema_version: \"1.0\"\n" + f"workflow:\n {field_yaml}\n" + "steps:\n - id: s1\n type: shell\n run: \"echo hi\"\n", + encoding="utf-8", + ) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "add", str(wf)]) + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert expected in result.output + + def test_add_non_string_step_id_reports_validation_error( + self, project_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + wf = project_dir / "workflow.yml" + wf.write_text( + "workflow:\n id: \"probe\"\n name: \"Probe\"\n version: \"1.0.0\"\n" + "steps:\n - id: 123\n type: shell\n run: \"echo hi\"\n", + encoding="utf-8", + ) + runner = CliRunner() + result = runner.invoke(app, ["workflow", "add", str(wf)]) + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "Step ID" in result.output From e3989e35724517f33f34fce2479d6cd6f09a486e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:53:36 -0500 Subject: [PATCH 09/12] Add Spec Kit Figma extension to community catalog (#3408) Add figma extension submitted by @sebastienthibaud to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #3396 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 43 ++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index ae953b3ce..73ef4735c 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -117,6 +117,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | | Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | | Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) | +| Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) | | Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) | | Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) | | Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index e84d677e6..691902d89 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-07-07T00:00:00Z", + "updated_at": "2026-07-08T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1213,6 +1213,47 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-04-23T00:00:00Z" }, + "figma": { + "name": "Spec Kit Figma", + "id": "figma", + "description": "Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows.", + "author": "Fyloss", + "version": "1.6.0", + "download_url": "https://github.com/Fyloss/spec-kit-figma/archive/refs/tags/v1.6.0.zip", + "repository": "https://github.com/Fyloss/spec-kit-figma", + "homepage": "https://github.com/Fyloss/spec-kit-figma", + "documentation": "https://github.com/Fyloss/spec-kit-figma/blob/main/docs/INSTALL.md", + "changelog": "https://github.com/Fyloss/spec-kit-figma/blob/main/CHANGELOG.md", + "license": "MIT", + "category": "integration", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { "name": "git", "required": true }, + { "name": "bash", "required": false }, + { "name": "curl", "required": false }, + { "name": "jq", "required": false }, + { "name": "pwsh", "required": false } + ] + }, + "provides": { + "commands": 5, + "hooks": 6 + }, + "tags": [ + "figma", + "design", + "frontend", + "ui", + "design-system" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-07-08T00:00:00Z", + "updated_at": "2026-07-08T00:00:00Z" + }, "fix-findings": { "name": "Fix Findings", "id": "fix-findings", From 983a87f3e38ab8a2ebcc893cb28ea6541234fd62 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:56:24 -0500 Subject: [PATCH 10/12] Add EARS Requirements Syntax extension to community catalog (#3407) Add ears extension submitted by @v-dhruv to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #3395 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 33 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 73ef4735c..45fd2dc0f 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -52,6 +52,7 @@ The following community-contributed extensions are available in [`catalog.commun | 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) | +| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) | | Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) | | Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 691902d89..6b074648f 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1180,6 +1180,39 @@ "created_at": "2026-03-13T00:00:00Z", "updated_at": "2026-03-13T00:00:00Z" }, + "ears": { + "name": "EARS Requirements Syntax", + "id": "ears", + "description": "Author, lint, and convert requirements using EARS (Easy Approach to Requirements Syntax) - the five industry-standard sentence patterns for unambiguous, testable requirements.", + "author": "dhruv-15-03", + "version": "1.0.0", + "download_url": "https://github.com/dhruv-15-03/spec-kit-ears/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/dhruv-15-03/spec-kit-ears", + "homepage": "https://github.com/dhruv-15-03/spec-kit-ears", + "documentation": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/README.md", + "changelog": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/CHANGELOG.md", + "license": "MIT", + "category": "docs", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "ears", + "requirements", + "specification", + "quality" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-07-08T00:00:00Z", + "updated_at": "2026-07-08T00:00:00Z" + }, "extensify": { "name": "Extensify", "id": "extensify", From c8ce4880734b56623258981712077a77d0ee1c6a Mon Sep 17 00:00:00 2001 From: Vincent Lee <5388077+vincentclee@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:47:36 -0400 Subject: [PATCH 11/12] chore: add pre-commit config and fix trailing whitespace/end-of-file (#3430) issues --- .github/CODEOWNERS | 1 - .github/ISSUE_TEMPLATE/agent_request.yml | 2 +- .../ISSUE_TEMPLATE/extension_submission.yml | 8 +- .github/ISSUE_TEMPLATE/preset_submission.yml | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 1 - .github/workflows/stale.yml | 10 +- .markdownlint-cli2.jsonc | 2 +- .pre-commit-config.yaml | 12 ++ LICENSE | 1 - docs/.gitignore | 1 - extensions/catalog.json | 2 +- extensions/selftest/commands/selftest.md | 2 +- newsletters/2026-March.md | 2 - pyproject.toml | 1 - scripts/bash/check-prerequisites.sh | 10 +- scripts/bash/create-new-feature.sh | 22 +- scripts/bash/setup-plan.sh | 15 +- scripts/powershell/check-prerequisites.ps1 | 20 +- scripts/powershell/common.ps1 | 2 +- scripts/powershell/setup-plan.ps1 | 2 +- spec-kit.code-workspace | 2 +- .../integrations/devin/__init__.py | 2 +- .../integrations/forge/__init__.py | 18 +- templates/checklist-template.md | 6 +- templates/commands/clarify.md | 2 +- templates/commands/constitution.md | 2 +- templates/commands/implement.md | 4 +- templates/commands/plan.md | 2 +- templates/commands/specify.md | 30 +-- templates/commands/tasks.md | 6 +- templates/vscode-settings.json | 1 - tests/hooks/.specify/extensions.yml | 2 +- tests/hooks/TESTING.md | 2 +- .../test_integration_cursor_agent.py | 1 - tests/integrations/test_integration_devin.py | 2 +- tests/integrations/test_integration_forge.py | 38 ++-- tests/integrations/test_integration_vibe.py | 2 +- tests/test_extension_registration.py | 98 ++++----- tests/test_extension_update_hardening.py | 36 ++-- tests/test_merge.py | 36 ++-- tests/test_setup_tasks.py | 196 +++++++++--------- tests/test_timestamp_branches.py | 1 - 42 files changed, 304 insertions(+), 303 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cf0686db1..b15e59c3b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,4 +5,3 @@ /extensions/catalog.community.json @mnriem /integrations/catalog.community.json @mnriem /presets/catalog.community.json @mnriem - diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index 1353e48e6..3d3253c52 100644 --- a/.github/ISSUE_TEMPLATE/agent_request.yml +++ b/.github/ISSUE_TEMPLATE/agent_request.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/extension_submission.yml b/.github/ISSUE_TEMPLATE/extension_submission.yml index f1cf25197..62508dd56 100644 --- a/.github/ISSUE_TEMPLATE/extension_submission.yml +++ b/.github/ISSUE_TEMPLATE/extension_submission.yml @@ -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 --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 ``` diff --git a/.github/ISSUE_TEMPLATE/preset_submission.yml b/.github/ISSUE_TEMPLATE/preset_submission.yml index f25c60f92..45c1f8173 100644 --- a/.github/ISSUE_TEMPLATE/preset_submission.yml +++ b/.github/ISSUE_TEMPLATE/preset_submission.yml @@ -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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 68c4aeb25..c9fce2cd8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -19,4 +19,3 @@ - [ ] I **did** use AI assistance (describe below) - diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 667b942df..67c8064e9 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -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 diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 0108b9f23..f75fc24ce 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -26,4 +26,4 @@ "ignores": [ ".genreleases/" ] -} \ No newline at end of file +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..f5a465904 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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$ diff --git a/LICENSE b/LICENSE index a0eb787a8..28a50fa22 100644 --- a/LICENSE +++ b/LICENSE @@ -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. - diff --git a/docs/.gitignore b/docs/.gitignore index 68fec76e3..614670d9f 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -6,4 +6,3 @@ obj/ # Temporary files *.tmp *.log - diff --git a/extensions/catalog.json b/extensions/catalog.json index 6ab98edb8..a3fac3039 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -48,4 +48,4 @@ ] } } -} \ No newline at end of file +} diff --git a/extensions/selftest/commands/selftest.md b/extensions/selftest/commands/selftest.md index 6f5655edd..4c3d281aa 100644 --- a/extensions/selftest/commands/selftest.md +++ b/extensions/selftest/commands/selftest.md @@ -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: diff --git a/newsletters/2026-March.md b/newsletters/2026-March.md index d97ca3960..402539e58 100644 --- a/newsletters/2026-March.md +++ b/newsletters/2026-March.md @@ -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) - - diff --git a/pyproject.toml b/pyproject.toml index 87bc67336..379b2434f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,4 +83,3 @@ extend-select = [ "S604", # call-with-shell-equals-true "S605", # start-process-with-a-shell ] - diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 1d2433632..b9688d674 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -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 diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 3cffce860..50b2ce08d 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -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)" diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index cb679437a..e01dc44bc 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -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 - diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index 2a424b49a..07ece76e2 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -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 } diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index a6e1b631b..bb9d19062 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -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 diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index 0ebd591c8..9e0403eba 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -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 diff --git a/spec-kit.code-workspace b/spec-kit.code-workspace index 876a1499c..57097327f 100644 --- a/spec-kit.code-workspace +++ b/spec-kit.code-workspace @@ -5,4 +5,4 @@ } ], "settings": {} -} \ No newline at end of file +} diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index 18c1fc8d6..0d60bc954 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -62,4 +62,4 @@ class DevinIntegration(SkillsIntegration): default=True, help="Install as agent skills (default for Devin)", ), - ] \ No newline at end of file + ] diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index d0a8cc7ab..49407c3a7 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -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}" diff --git a/templates/checklist-template.md b/templates/checklist-template.md index 9752c130e..78ee7fd4d 100644 --- a/templates/checklist-template.md +++ b/templates/checklist-template.md @@ -6,16 +6,16 @@ **Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements. - diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index a52ef3942..fb0e91281 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -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... diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index d003d5c9b..7ba1c7640 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -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... diff --git a/templates/commands/implement.md b/templates/commands/implement.md index 5efbcf724..1d312a1c3 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -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 diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 063fc911b..312c5ab18 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -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 diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 09a584e0e..e32fd4897 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -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]_ ``` diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 4d3e45a7c..ae7192c3d 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -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 diff --git a/templates/vscode-settings.json b/templates/vscode-settings.json index d454aa6df..388adcec7 100644 --- a/templates/vscode-settings.json +++ b/templates/vscode-settings.json @@ -11,4 +11,3 @@ ".specify/scripts/powershell/": true } } - diff --git a/tests/hooks/.specify/extensions.yml b/tests/hooks/.specify/extensions.yml index 0a73b3254..3e254abcd 100644 --- a/tests/hooks/.specify/extensions.yml +++ b/tests/hooks/.specify/extensions.yml @@ -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 diff --git a/tests/hooks/TESTING.md b/tests/hooks/TESTING.md index 6ab704442..28471ffb2 100644 --- a/tests/hooks/TESTING.md +++ b/tests/hooks/TESTING.md @@ -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`. diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 7b6822f7e..010d739b8 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -231,4 +231,3 @@ class TestCursorAgentCliDispatch: argv = mock_run.call_args[0][0] assert argv[0] == "cursor-agent" - diff --git a/tests/integrations/test_integration_devin.py b/tests/integrations/test_integration_devin.py index 9e20f2f41..096e6bf23 100644 --- a/tests/integrations/test_integration_devin.py +++ b/tests/integrations/test_integration_devin.py @@ -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() \ No newline at end of file + assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists() diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index e7e9ec0e3..137c7e220 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -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, ( diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index 98c9fdf06..20ff3c030 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -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" - ) \ No newline at end of file + ) diff --git a/tests/test_extension_registration.py b/tests/test_extension_registration.py index 9965deae4..4dc4d1beb 100644 --- a/tests/test_extension_registration.py +++ b/tests/test_extension_registration.py @@ -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"] diff --git a/tests/test_extension_update_hardening.py b/tests/test_extension_update_hardening.py index 8e8ed1854..73fce4e3e 100644 --- a/tests/test_extension_update_hardening.py +++ b/tests/test_extension_update_hardening.py @@ -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) diff --git a/tests/test_merge.py b/tests/test_merge.py index 69f946ccc..07cc46884 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -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 diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index 4551b5c29..6ff8aa23b 100644 --- a/tests/test_setup_tasks.py +++ b/tests/test_setup_tasks.py @@ -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//templates/.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//templates/.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//templates/.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, diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 2a0a2ca69..44c85ca79 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -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 - \ No newline at end of file From 1736f0746b129fe47cd36b793efca9a975bb8491 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Jul 2026 21:57:53 +0500 Subject: [PATCH 12/12] fix(bundler): raise BundlerError, not raw ValueError, on a malformed catalog URL (#3433) adapters._validate_remote_url reads parsed.hostname, which raises ValueError on a malformed authority (e.g. an unclosed ipv6 bracket https://[::1). the function's contract is to raise BundlerError for any bad url - every other reject path does - so the raw ValueError leaked to the caller and crashed the fetch instead of failing cleanly. wrap the parse and convert to BundlerError. bundler sibling of #3369, which fixed the cli extension/preset/workflow add paths but not this validator. added a regression test that fails pre-fix. --- src/specify_cli/bundler/services/adapters.py | 16 +++++++++++++--- tests/unit/test_bundler_adapters.py | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/bundler/services/adapters.py b/src/specify_cli/bundler/services/adapters.py index 91e3cf1cb..80fd7fc4f 100644 --- a/src/specify_cli/bundler/services/adapters.py +++ b/src/specify_cli/bundler/services/adapters.py @@ -68,8 +68,18 @@ def _validate_remote_url(source_id: str, url: str) -> None: Mirrors ``specify_cli.catalogs`` URL validation to avoid MITM/downgrade issues before any network call. """ - parsed = urlparse(url) - is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + # A malformed authority (e.g. an unclosed IPv6 bracket ``https://[::1``) + # makes urlparse / hostname access raise ValueError. This function's + # contract is to raise BundlerError for a bad URL, so surface that as a + # clean error rather than leaking a raw ValueError to the caller. + try: + parsed = urlparse(url) + hostname = parsed.hostname + except ValueError: + raise BundlerError( + f"Catalog '{source_id}' URL is malformed: {url}" + ) from None + is_localhost = hostname in ("localhost", "127.0.0.1", "::1") if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): raise BundlerError( f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). " @@ -79,7 +89,7 @@ def _validate_remote_url(source_id: str, url: str) -> None: # "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 hostname: raise BundlerError( f"Catalog '{source_id}' URL must be a valid URL with a host: {url}" ) diff --git a/tests/unit/test_bundler_adapters.py b/tests/unit/test_bundler_adapters.py index a6a9b5c42..93402b300 100644 --- a/tests/unit/test_bundler_adapters.py +++ b/tests/unit/test_bundler_adapters.py @@ -96,3 +96,21 @@ def test_validate_remote_url_rejects_host_less_urls(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") + + +@pytest.mark.parametrize( + "url", + [ + "https://[::1", # unclosed IPv6 bracket + "https://[not-an-ip]/c.json", + ], +) +def test_validate_remote_url_rejects_malformed_url_cleanly(url): + """A malformed URL must raise BundlerError, not a raw ValueError. + + ``urlparse``/``hostname`` raise ``ValueError`` on a malformed authority + (e.g. an unclosed IPv6 bracket). The validator's contract is to raise + BundlerError for any bad URL, so the raw ValueError must not escape to the + caller. Bundler sibling of #3369.""" + with pytest.raises(BundlerError): + adapters._validate_remote_url("team", url)