diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index 785f9193e3..344b1c9031 100644 --- a/.github/ISSUE_TEMPLATE/agent_request.yml +++ b/.github/ISSUE_TEMPLATE/agent_request.yml @@ -8,7 +8,7 @@ body: value: | Thanks for requesting a new agent! Before submitting, please check if the agent is already supported. - **Currently supported agents**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Command Code, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, 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 + **Currently supported agents**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Command Code, Cursor, Devin for Terminal, Docker Agent, Factory Droid, DeepSeek Harness, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, 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 id: agent-name diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 03fa6c124f..a89e441d30 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -73,7 +73,9 @@ body: - Command Code - Cursor - Devin for Terminal + - Docker Agent - Factory Droid + - DeepSeek Harness - Firebender - Forge - Gemini CLI diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 4613c8ebae..f80040e334 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -67,7 +67,9 @@ body: - Command Code - Cursor - Devin for Terminal + - Docker Agent - Factory Droid + - DeepSeek Harness - Firebender - Forge - Gemini CLI diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 36daac9877..253a22b53f 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -25,10 +25,10 @@ "version": "v7.0.0", "sha": "5fda3b95a4ea91299a34e894583c3862153e4b97" }, - "astral-sh/setup-uv@v9.0.0": { + "astral-sh/setup-uv@v10.0.1": { "repo": "astral-sh/setup-uv", - "version": "v9.0.0", - "sha": "c771a70e6277c0a99b617c7a806ffedaca235ff9" + "version": "v10.0.1", + "sha": "20cfd1bf945f4377ade1205e4dbc17946fc9a30d" }, "actions/upload-artifact@v7.0.1": { "repo": "actions/upload-artifact", diff --git a/.github/workflows/bug-test.lock.yml b/.github/workflows/bug-test.lock.yml index 810be3ae77..f4fe11ea64 100644 --- a/.github/workflows/bug-test.lock.yml +++ b/.github/workflows/bug-test.lock.yml @@ -1,5 +1,5 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"aa190ac1bd31b2e5e68cafd25951bda4d92a275ce1c55f58856f924e415fdb17","body_hash":"5aa25f2a19d30f31a71fb4fa9c709563d3d2c5060b2984f4ba913b7097158763","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"c771a70e6277c0a99b617c7a806ffedaca235ff9","version":"v9.0.0"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"ec50d44af032f2f0c04073858a24d73cb1fa9036515b3bc7ee4dcfe02138f34a","body_hash":"5aa25f2a19d30f31a71fb4fa9c709563d3d2c5060b2984f4ba913b7097158763","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"20cfd1bf945f4377ade1205e4dbc17946fc9a30d","version":"v10.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} # This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ @@ -38,7 +38,7 @@ # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 +# - astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 # - github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8 # # Container images used: @@ -438,7 +438,7 @@ jobs: persist-credentials: false fetch-depth: 0 - name: Setup uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Create gh-aw temp directory run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" - name: Configure gh CLI for GitHub Enterprise diff --git a/.github/workflows/bug-test.md b/.github/workflows/bug-test.md index 87656d7eec..6febb032d3 100644 --- a/.github/workflows/bug-test.md +++ b/.github/workflows/bug-test.md @@ -68,7 +68,7 @@ network: steps: - name: Setup uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index abd808926c..2cc123238c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,11 +22,11 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/feature-assess.lock.yml b/.github/workflows/feature-assess.lock.yml index 1954767909..d8c5cab2d9 100644 --- a/.github/workflows/feature-assess.lock.yml +++ b/.github/workflows/feature-assess.lock.yml @@ -1,5 +1,5 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d64425d4c710146adc49679a08d355977f6a9b8bc5d6f95d91861f3836f4b007","body_hash":"6d78e8c183819f6f12a07f0c9cb28a83cc2471ac20c6df6999e503a0d731da4b","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"5fda3b95a4ea91299a34e894583c3862153e4b97"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"c771a70e6277c0a99b617c7a806ffedaca235ff9","version":"c771a70e6277c0a99b617c7a806ffedaca235ff9"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"669e5f4d2956792cf5b7a2dfbbda10e7ef26f25fc8283f5b3db1cc838f05d940","body_hash":"6d78e8c183819f6f12a07f0c9cb28a83cc2471ac20c6df6999e503a0d731da4b","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"5fda3b95a4ea91299a34e894583c3862153e4b97"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"astral-sh/setup-uv","sha":"20cfd1bf945f4377ade1205e4dbc17946fc9a30d","version":"v10.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} # This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ @@ -32,13 +32,13 @@ # - GITHUB_TOKEN # # Custom actions used: -# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 +# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 -# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 +# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # 5fda3b95a4ea91299a34e894583c3862153e4b97 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # c771a70e6277c0a99b617c7a806ffedaca235ff9 +# - astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 # - github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8 # # Container images used: @@ -78,7 +78,7 @@ jobs: actions: read contents: read env: - GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + GH_AW_MAX_DAILY_AI_CREDITS: "20000" outputs: body: ${{ steps.sanitized.outputs.body }} comment_id: "" @@ -149,7 +149,7 @@ jobs: GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }} GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + GH_AW_MAX_DAILY_AI_CREDITS: "20000" with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -163,7 +163,7 @@ jobs: env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Checkout .github and .agents folders - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false sparse-checkout: | @@ -432,12 +432,12 @@ jobs: echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" } >> "$GITHUB_OUTPUT" - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 - name: Setup uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # c771a70e6277c0a99b617c7a806ffedaca235ff9 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Create gh-aw temp directory run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" - name: Configure gh CLI for GitHub Enterprise @@ -1333,7 +1333,7 @@ jobs: echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Checkout repository for patch context if: needs.agent.outputs.has_patch == 'true' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # --- Threat Detection --- @@ -1400,7 +1400,7 @@ jobs: mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false @@ -1675,4 +1675,3 @@ jobs: /tmp/gh-aw/safe-output-items.jsonl /tmp/gh-aw/temporary-id-map.json if-no-files-found: ignore - diff --git a/.github/workflows/feature-assess.md b/.github/workflows/feature-assess.md index 5f6afbc633..4f2dbff5f8 100644 --- a/.github/workflows/feature-assess.md +++ b/.github/workflows/feature-assess.md @@ -9,6 +9,7 @@ on: skip-bots: [github-actions, copilot, dependabot] engine: copilot +max-daily-ai-credits: 20K tools: bash: ["echo", "cat", "head", "tail", "grep", "wc", "sort", "uniq", "python3", "pip", "pip3", "jq", "date", "ls", "find", "mkdir", "sed", "env", "which", "curl", "sh", "bash", "uv", "uvx", "specify", "git"] @@ -38,7 +39,7 @@ checkout: steps: - name: Setup uv continue-on-error: true - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python continue-on-error: true uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index ce6185ea6c..028565bf5b 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -19,8 +19,9 @@ jobs: actions: write steps: - name: Verify tag format + env: + TAG: ${{ inputs.tag }} run: | - TAG="${{ inputs.tag }}" if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Error: '$TAG' is not a valid release tag (expected vX.Y.Z)" exit 1 @@ -32,7 +33,7 @@ jobs: ref: refs/tags/${{ inputs.tag }} - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -40,9 +41,10 @@ jobs: python-version: "3.13" - name: Verify tag matches package version + env: + TAG: ${{ inputs.tag }} run: | - TAG_VERSION="${{ inputs.tag }}" - TAG_VERSION="${TAG_VERSION#v}" + TAG_VERSION="${TAG#v}" PROJECT_VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')" if [[ "$TAG_VERSION" != "$PROJECT_VERSION" ]]; then echo "Error: Tag version ($TAG_VERSION) does not match pyproject.toml version ($PROJECT_VERSION)" @@ -74,7 +76,7 @@ jobs: path: dist/ - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Publish to PyPI run: uv publish diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ed9f6606ed..8c5a5eb72a 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -24,7 +24,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -55,7 +55,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1d4399cb23..dceb97c6e5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/AGENTS.md b/AGENTS.md index 989f1ca459..b7b7c1147d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,8 @@ src/specify_cli/integrations/ │ └── __init__.py ├── copilot/ # Example: IntegrationBase subclass (custom setup) │ └── __init__.py +├── docker_agent/ # Example: Docker Agent SkillsIntegration subclass +│ └── __init__.py └── ... # One subpackage per supported agent ``` @@ -504,7 +506,7 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag ### Opening pull requests - Before opening a pull request, check whether the account that will file it already has three open pull requests in this repository. -- If so, alert the user that additional submissions may receive lower review priority and ask for explicit permission to proceed. Do not assume consent. +- If so, alert the user that additional submissions may receive lower review priority and ask for explicit permission to proceed. Do not assume consent. If the user is unavailable to provide that permission, including during autonomous or non-interactive operation, do not open the pull request. Preserve the work on a branch and report that confirmation is required. ### Commits diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ef915b936..bfa0e2ed0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,131 @@ +## [1.0.4] - 2026-09-02 + +### Changed + +- fix(scripts): stop wrap composition looping on a token in core content (#4396) +- [extension] Update Charter extension to v0.6.1 (#4409) +- fix(workflows): keep non-ASCII text readable in written overlay files (#4148) +- fix(workflows): report overlay operation keys in declaration order (#4146) +- fix: skip corrupted state.json in list_runs() instead of aborting (#3904) +- fix(rovodev): guard non-string prompt names when merging prompts.yml (#4145) +- fix: narrow bare except Exception in preset command reconciliation (#3842) +- fix(workflows): refuse a filter mixed with a comparison operator instead of silently mis-binding it (#3894) +- fix: escape Rich markup in workflow error output (#3837) +- fix: add JSON error handling to auth config loader (#3836) +- fix: use missing_ok=True in extension ZIP cleanup (#3870) +- feat(presets): let a preset declare a required extension (#4250) +- fix(bundler): reject unsupported catalog payload versions (#4090) +- fix(extensions): install bundled extension updates from the local package (#4351) +- docs: clarify autonomous PR handling (#4392) +- fix(workflows): reject malformed step config on add (#4087) +- fix(powershell): stop create-new-feature crashing on a non-Latin description (#4138) +- fix(bundler): treat an explicit-null records field as missing, not "None" (#4136) +- Add DeepSeek Harness (DSH) integration (#4336) +- chore: release 1.0.3, begin 1.0.4.dev0 development (#4391) + +## [1.0.3] - 2026-09-01 + +### Changed + +- fix(workflows): reject malformed step config on remove (#4095) +- fix(workflows): reject malformed workflow config on remove (#4096) +- fix(events): skip unreadable extension manifests (#4089) +- docs(extensions): fix private catalog FAQ command (#4373) +- [extension] Add Vurnix Honest Gate extension to community catalog (#4388) +- fix(presets): fail closed on unreadable provenance (#4092) +- docs: explain how Spec Kit dogfoods itself (#4381) +- add --require-spec to check-prerequisites (#4367) +- fix(ci): harden PyPI release tag handling (#4386) +- fix(github-http): reject malformed explicit URL ports (#4372) +- fix: scaffold extension config when installing via bundler (#4285) +- fix: reject unknown setup-plan arguments (#4371) +- feat(docker-agent): add Docker Agent integration (#4302) +- fix(workflows): reject a condition that is spliced into text, not evaluated (#4292) +- fix(bundler): pass explicit workflow_add options from bundle install (#4284) +- test(bundle): join across wrap points in the build output-path assertion (#4280) +- chore(deps): bump the codeql-action group with 2 updates (#4357) +- [extension] Update Spec Kit Figma extension to v3.1.1 (#4377) +- fix(presets): reject falsy non-mapping catalog roots (#4088) +- chore: release 1.0.2, begin 1.0.3.dev0 development (#4379) + +## [1.0.2] - 2026-08-31 + +### Changed + +- [extension] Add Jira Mirror extension to community catalog (#4376) +- [extension] Add AgentDocx SpecKit V2 extension to community catalog (#4369) +- fix(bundler): reject non-string catalog entry tag members (#4318) +- fix(auth): reject malformed URL ports before credential matching (#4362) +- fix: decode feature.json as UTF-8 in Windows PowerShell (#4359) +- fix(events): stop falling back to a fake "pwsh" argv when no launcher exists (#4340) +- Add Pre-Spec Cards extension to community catalog (#4365) +- docs(workflows): document Python init script support (#4331) +- fix(presets): validate catalog URL port, not just hostname (#4341) +- Add Verified Codebase Context preset to community catalog (#4344) +- fix(events): stop `event run` crashing on every piped stdin payload (#4326) +- Add Taco Review extension to community catalog (#4322) +- Update SpecKit Grill Me extension to v1.0.1 (#4317) +- [extension] Update BDD extension to v1.0.3 (#4299) +- Update Parallel Autonomous Run Governance preset to v0.2.6 (#4304) +- Update SpecAssay bundle to v0.4.12 (#4257) +- Update SpecAssay preset to v0.4.12 (#4256) +- Update Archive Extension to v1.3.0 (#4298) +- Update Reconcile extension to v1.2.1 (#4297) +- chore: release 1.0.1, begin 1.0.2.dev0 development (#4266) + +## [1.0.1] - 2026-08-21 + +### Changed + +- docs: flatten project history navigation (#4265) +- docs: use Spec Kit branding on documentation site (#4264) +- docs: add existing project adoption guide (#4263) +- docs: add project history page (#4262) +- docs: mark Spec Kit's first anniversary (#4260) +- docs: add workflow quickstarts (#4258) +- chore(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1 (#4244) +- Update SpecAssay Check extension to v0.4.12 (#4254) +- fix(workflows): require a 'cases' block on switch steps (#4144) +- fix(workflows): strip the resolved value before matching switch cases (#4143) +- fix(bundler): reject non-string manifest list members (#4091) +- fix(presets): reject non-mapping catalog mutations (#4094) +- fix(workflows): stop offering a condition correction that inverts it (#4230) +- fix: use chunked read for integration and preset manifest hash (#3843) +- docs: update landing page stats for 1.0.0 (#4251) +- Add Azure Cosmos DB extension to community catalog (#4247) +- chore(deps): bump actions/checkout from 6.0.3 to 7.0.1 (#4243) +- chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#4242) +- chore(deps): bump the codeql-action group with 2 updates (#4241) +- chore: release 1.0.0, begin 1.0.1.dev0 development (#4246) + +## [1.0.0] - 2026-08-21 + +### Changed + +- [extension] Update Security Review extension to v2.0.0 (#4223) +- fix(presets): reject duplicate provides.templates name+type entries (#4191) +- fix(bundler): decode a downloaded (non-zip) bundle manifest as UTF-8 (#4190) +- Update Intake Sequencing Governance preset to v0.2.3 (#4235) +- Update MAQA — Multi-Agent & Quality Assurance extension to v0.1.6 (#4234) +- [bug-fix] Fix qodercli-skills-migration: migrate QodercliIntegration to SkillsIntegration (#4205) +- [preset] Add Inventory Alignment preset to community catalog (#4229) +- [extension] Add Spec Inventory extension to community catalog (#4228) +- [extension] Update Architecture Guard extension to v2.3.6 (#4224) +- Update SpecKit Companion extension to v0.20.2 (#4225) +- fix(workflows): reject a condition that has no {{ }} block (#4182) +- fix: raise feature assessment credit budget (#4222) +- [extension] Add AgentDocx extension to community catalog (#4184) +- fix(integrations): report a falsy non-mapping integration descriptor as a shape error (#4187) +- Update Autonomous Run Governance preset to v0.4.1 (#4203) +- fix(workflows): validate dispatch defaults (#4181) +- Update Atlas extension display name in community catalog (#4202) +- Add Closed Vocabulary Check preset to community catalog (#4201) +- fix(utils): narrow bare except Exception in merge_json_files (#4189) +- chore: release 0.16.5, begin 0.16.6.dev0 development (#4206) + ## [0.16.5] - 2026-08-19 ### Changed diff --git a/README.md b/README.md index de92639cec..a4e7fdafb9 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,22 @@ 简体中文

+> [!NOTE] +> **One year of Spec Kit — and 1.0.0** +> +> One year after the first commit, Spec Kit has reached [1.0.0](https://github.com/github/spec-kit/releases/tag/v1.0.0) — not because the work is finished or its shape is frozen, but because the project has grown into something coherent, useful, and shaped by far more people than those who started it. +> +> The lead maintainer's personal anniversary post, [*Spec Kit Turns One — and Ships 1.0.0*](https://www.manorrock.com/blog/2026/08/21/spec_kit_turns_one.html), defines what 1.0.0 actually means for the project: **it is now just a number**. As agents make adapting to change dramatically cheaper, the value moves from stability to adaptability. +> +> To everyone who has used Spec Kit, challenged its assumptions, reported a problem, contributed code or documentation, created an extension or preset, shared an idea, or helped someone else get started: **thank you**. This milestone belongs to the community that carried the project through its first year and continues to shape where it goes next. + --- ## Table of Contents - [🤔 What is Spec-Driven Development?](#-what-is-spec-driven-development) +- [🐞 Bug Fixing with Spec Kit](#-bug-fixing-with-spec-kit) +- [💡 Assessing Ideas with Spec Kit](#-assessing-ideas-with-spec-kit) - [⚡ Get Started](#-get-started) - [📽️ Video Overview](#️-video-overview) - [🌍 Community](#-community) @@ -33,6 +44,7 @@ - [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets) - [📦 Bundles: Role-Based Setups](#-bundles-role-based-setups) - [📚 Core Philosophy](#-core-philosophy) +- [🪞 Does Spec Kit Use Spec Kit?](#-does-spec-kit-use-spec-kit) - [🌟 Development Phases](#-development-phases) - [🎯 Experimental Goals](#-experimental-goals) - [🔧 Prerequisites](#-prerequisites) @@ -45,6 +57,75 @@ Spec-Driven Development **flips the script** on traditional software development. For decades, code has been king — specifications were just scaffolding we built and discarded once the "real work" of coding began. Spec-Driven Development changes this: **specifications become executable**, directly generating working implementations rather than just guiding them. +### SDD Quickstart + +Replace `vX.Y.Z` with the [latest release tag](https://github.com/github/spec-kit/releases), keeping the leading `v`. + +```bash +uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z +specify init my-project --integration copilot +cd my-project +``` + +Launch your coding agent in the project directory, then: + +0. **Establish** your project principles once (`/speckit-constitution`). This is a one-time step per project. +1. **Specify** what you want to build (`/speckit-specify`). +2. **Plan** how you will build it (`/speckit-plan`). +3. **Break down** the plan into actionable tasks (`/speckit-tasks`). +4. **Implement** the tasks (`/speckit-implement`). +5. **Converge** the implementation against the spec, plan, and tasks (`/speckit-converge`). + +> [!NOTE] +> Repeat steps 4 and 5 until `/speckit-converge` reports **Converged**. + +## 🐞 Bug Fixing with Spec Kit + +Bug fixes are risky when an agent jumps straight from a report to a patch without validating the diagnosis or confirming that the fix resolves the original symptom. The bundled, opt-in bug extension provides a repeatable **assess → fix → test** workflow that keeps each fix scoped, evidence-based, and documented from root cause through verification. + +### Bug Fix Quickstart + +Replace `vX.Y.Z` with the [latest release tag](https://github.com/github/spec-kit/releases), keeping the leading `v`. + +```bash +uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z +specify init my-project --integration copilot +cd my-project +specify extension add bug +``` + +Launch your coding agent in the project directory, then: + +1. **Assess** the bug (`/speckit-bug-assess "" slug=login-crash`). +2. **Fix** the assessed cause (`/speckit-bug-fix slug=login-crash`). +3. **Test** the fix (`/speckit-bug-test slug=login-crash`). + +## 💡 Assessing Ideas with Spec Kit + +Good ideas deserve evidence before commitment, whether or not they become software. The bundled, opt-in assess extension turns a raw idea into a documented **go / needs-clarification / kill** decision through an independent **intake → research → define → shape → decide** workflow. + +### Idea Assessment Quickstart + +Replace `vX.Y.Z` with the [latest release tag](https://github.com/github/spec-kit/releases), keeping the leading `v`. + +```bash +uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z +specify init my-project --integration copilot +cd my-project +specify extension add assess +``` + +Launch your coding agent in the project directory, then: + +1. **Intake** the idea (`/speckit-assess-intake "" slug=offline-mode`). +2. **Research** supporting and opposing evidence (`/speckit-assess-research slug=offline-mode`). +3. **Define** the problem, goals, and success metrics (`/speckit-assess-define slug=offline-mode`). +4. **Shape** possible solutions and their trade-offs (`/speckit-assess-shape slug=offline-mode`). +5. **Decide** whether to proceed, clarify, or stop (`/speckit-assess-decide slug=offline-mode`). + +> [!NOTE] +> Idea assessment is standalone. If you choose to build an idea with a **go** decision, you can hand it off to `/speckit-specify`. + ## ⚡ Get Started ### 1. Install Specify CLI @@ -321,6 +402,26 @@ Spec-Driven Development is a structured process that emphasizes: - **Multi-step refinement** rather than one-shot code generation from prompts - **Heavy reliance** on advanced AI model capabilities for specification interpretation +## 🪞 Does Spec Kit Use Spec Kit? + +Yes — we dogfood Spec Kit while developing Spec Kit, especially for substantial +features and changes to the development workflow. Contributors are asked to test +relevant changes through the Spec-Driven Development commands. The +[feature assessment workflow](./.github/workflows/feature-assess.md) is currently +the automated dogfooding path: its setup uses the CLI from the current checkout +to initialize Copilot and install the `assess` extension, after which Copilot +follows the generated assessment skills against feature requests. The other +agentic workflows currently operate independently of the Specify CLI. + +This does not mean every change goes through the full workflow. Small fixes can +use the normal issue, pull request, review, and test process. Dogfooding +scaffolding and artifacts under `.github/agents/`, `.github/prompts/`, +`.github/copilot-instructions.md`, `.grok/`, `.specify/`, and `specs/` are +intentionally gitignored. The automated assessment workflow is ephemeral and +neither commits nor pushes its generated Copilot skills, so its output does not +enter repository history. See the [contributor development +workflow](./CONTRIBUTING.md#development-workflow) for the validation expectations. + ## 🌟 Development Phases | Phase | Focus | Key Activities | diff --git a/bundles/catalog.community.json b/bundles/catalog.community.json index ed6b97dcd5..070a1132cd 100644 --- a/bundles/catalog.community.json +++ b/bundles/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-14T00:00:00Z", + "updated_at": "2026-08-21T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json", "bundles": { "sicario-spec": { @@ -34,12 +34,12 @@ "specassay": { "name": "SpecAssay", "id": "specassay", - "version": "0.3.4", + "version": "0.4.12", "role": "developer", "description": "Durable-ID promotion for stock Spec Kit: templates, Gate 2 refusal, and trace-manifest emission.", "author": "Rik Dryfoos", "license": "MIT", - "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.3.4/specassay-0.3.4.zip", + "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.4.12/specassay-0.4.12.zip", "repository": "https://github.com/rdryfoos/specassay", "requires": { "speckit_version": ">=0.14.0" diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 1de44ad152..072601fb39 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -28,18 +28,22 @@ The following community-contributed extensions are available in [`catalog.commun | adrkit — decision memory for spec-driven development | Pulls the decisions governing this work into agent context, checks produced plans against them, and drafts an ADR from a plan artifact | `process` | Read+Write | [adrkit](https://github.com/mbeacom/adrkit) | | Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) | | Agent Governance | Generate agent-platform repository governance files from Spec Kit metadata | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) | +| AgentDocx | Full-stack multi-agent specification pipeline with VS Code extension control, automated Kanban/Jira sync, and React monitoring dashboard | `integration` | Read+Write | [extension-github-spec-kit](https://github.com/abir-ommezzine/extension-github-spec-kit) | +| AgentDocx SpecKit V2 | AgentDocx evolved: same pipeline + far more autonomous Ticket Manager (5 CLI, per-project Kanban, bulk sync, auto-switch, Auditor) | `integration` | Read+Write | [Extension_GithubSpecKit](https://github.com/ahmed200346/Extension_GithubSpecKit) | | AgentPay x402 — Spend Controls for Spec Kit Agents | Set USDC spending caps and execute x402 payments to paid APIs during spec implementation. Zero platform fee on Base L2 | `integration` | Read+Write | [spec-kit-pay-x402](https://github.com/shawnhvac/spec-kit-pay-x402) | | AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | | Analytics | Measure what your AI builds, and how much time it saves you | `visibility` | Read+Write | [spec-kit-analytics](https://github.com/Fyloss/spec-kit-analytics) | | API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) | | Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) | | Architecture Governance | Keep specs, code & ADRs in sync: citation slots + a read-only, fail-closed validator | `docs` | Read+Write | [spec-kit-arch-governance](https://github.com/ashbrener/spec-kit-arch-governance) | -| Architecture Guard | Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) | +| Architecture Guard | Framework-agnostic architecture governance for Spec Kit workflows, detecting drift, enforcing architectural rules, and generating actionable refactor tasks | `process` | Read+Write | [architecture-guard](https://github.com/DyanGalih/architecture-guard) | | Architecture Workflow | Generate or reverse project-level 4+1 architecture views with per-view and full-workflow commands | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) | | Archive Extension | Archive merged features into main project memory, resolving gaps and conflicts. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | ASCII Diagram Renderer | Renders hand-drawn ASCII/Unicode diagrams (state machine, architecture, flow, coverage map) of what spec/plan/tasks/analyze already say — plain text, no Mermaid renderer needed | `docs` | Read+Write | [spec-kit-ascii-diagram](https://github.com/MRZHUH/spec-kit-ascii-diagram) | -| spec-kit-atlas | Synthesize spec-kit specs into faithful, interactive architecture storybooks & doc portals. | `docs` | Read-only | [spec-kit-atlas](https://github.com/ashbrener/spec-kit-atlas) | +| Atlas | Synthesize spec-kit specs into faithful, interactive architecture storybooks & doc portals. | `docs` | Read-only | [spec-kit-atlas](https://github.com/ashbrener/spec-kit-atlas) | +| Azure Cosmos DB | Best-practice Azure Cosmos DB code generation and review for any AI coding agent | `code` | Read+Write | [spec-kit-cosmosdb](https://github.com/AzureCosmosDB/spec-kit-cosmosdb) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | +| BDD | Convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage | `process` | Read+Write | [spec-kit-bdd](https://github.com/RSginer/spec-kit-bdd) | | Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) | | Blueprint Index — Living Architecture Map | A living architecture map for spec-driven projects, kept honest by a deterministic, low-friction, machine-first CI gate (JSON, self-healable) that blocks only when the map contradicts the specs or code. Brownfield or greenfield. | `process` | Read+Write | [spec-kit-blueprint](https://github.com/ogil109/spec-kit-blueprint) | | Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | @@ -48,7 +52,7 @@ The following community-contributed extensions are available in [`catalog.commun | Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | | Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) | -| Charter | Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent. | `process` | Read+Write | [spec-kit-charter](https://github.com/Fyloss/spec-kit-charter) | +| Charter | Compose project constitutions from shared fragment registries | `process` | Read+Write | [spec-kit-charter](https://github.com/Fyloss/spec-kit-charter) | | CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | @@ -77,6 +81,7 @@ The following community-contributed extensions are available in [`catalog.commun | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) | +| Jira Mirror | Spec Kit ↔ Jira bridge for team-managed and company-managed projects: configurable workflows & hierarchies (Scrum/SAFe), multi-project, idempotent and fail-closed. macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-jira-mirror](https://github.com/Fyloss/spec-kit-jira-mirror) | | Keel Discovery | Evidence-backed discovery upstream of /speckit.specify, plus round-trip drift auditing after implementation | `process` | Read+Write | [spec-kit-keel](https://github.com/keeldiscovery/spec-kit-keel) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | | Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) | @@ -108,6 +113,7 @@ The following community-contributed extensions are available in [`catalog.commun | PatchWarden Evidence Pack | Map Spec Kit tasks into a guarded PatchWarden Goal and export bounded, traceable evidence for an accepted lineage. | `process` | Read+Write | [spec-kit-patchwarden](https://github.com/jiezeng2004-design/spec-kit-patchwarden) | | Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | | PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) | +| Pre-Spec Cards | Card-based pre-spec thinking: paste an idea, get your card plus the paths you'd miss, then play each through — story, snags, trade-offs, difficulty vs payoff — before /speckit.specify | `process` | Read+Write | [pre-spec](https://github.com/bendlikeabamboo/pre-spec) | | Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | | Product Forge | Full product-lifecycle orchestrator for Spec Kit: research → product-spec → plan → tasks → implement → verify → test → release-readiness, across express/lite/standard/v-model modes with human-in-the-loop gates. | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | | Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) | @@ -128,14 +134,15 @@ The following community-contributed extensions are available in [`catalog.commun | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | Ripple | Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) | | SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | -| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | +| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [security-review](https://github.com/DyanGalih/security-review) | | SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) | | Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | | Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) | | 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 Inventory | Read-only inventory of live requirement and task IDs, with focused per-task context packs instead of whole-file dumps | `visibility` | Read-only | [spec-kit-inventory-alignment](https://github.com/Yash-Chindam/spec-kit-inventory-alignment) | | 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 Figma | Grounds SpecKit spec/plan/tasks in Figma design context via REST or MCP, on macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) | | Spec Kit Memory | Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows | `docs` | Read+Write | [spec-kit-memory](https://github.com/zaytsevand/spec-kit-memory) | | Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) | | Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) | @@ -148,12 +155,11 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) | | Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) | -| Spec-Kit BDD | ATDD/BDD extension: convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage | `process` | Read+Write | [spec-kit-bdd](https://github.com/RSginer/spec-kit-bdd) | | Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) | | SpecAssay Check | Gate 2 refuses silent gaps and emits a trace-manifest (trace-manifest.json). | `visibility` | Read+Write | [specassay](https://github.com/rdryfoos/specassay) | | SpecJudge — right-size the model before you implement | Recommends the model that fits your tasks, citing the spec fragment behind every level. | `process` | Read-only | [SpecJudge](https://github.com/JoaquinRuiz/SpecJudge) | -| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, and a turbo pipeline profile | `visibility` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) | -| SpecKit Grill Me | Exhaustively resolve specification ambiguities and decisions before planning | `process` | Read+Write | [speckit-grill-me](https://github.com/yoshi1220/speckit-grill-me) | +| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, living specs, and composable commands with hooks and recipes | `process` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) | +| SpecKit Grill Me | Exhaustively clarify specifications and optionally sync canonical domain knowledge | `process` | Read+Write | [speckit-grill-me](https://github.com/yoshi1220/speckit-grill-me) | | SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) | | Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | @@ -161,6 +167,7 @@ The following community-contributed extensions are available in [`catalog.commun | Superpowers Bridge | Bridges selected Superpowers disciplines into Spec Kit as evidence-first trust gates for agent workflows. | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) | | Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) | +| Taco Review | Packages Spec Kit features for human review and syncs edits and comments back. | `integration` | Read+Write | [taco](https://github.com/Arcadia822/taco) | | Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) | | TDD Extension | Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength. | `process` | Read+Write | [spec-kit-tdd](https://github.com/d0whc3r/spec-kit-tdd) | | Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) | @@ -175,6 +182,7 @@ The following community-contributed extensions are available in [`catalog.commun | Verify Review Ship | Post-convergence operational verification, technical review, learning governance, and transactional delivery. | `process` | Read+Write | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) | | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | | Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) | +| Vurnix Honest Gate | Deterministic three-state honest gate for AI-written code: compile + phantom-import + honest test count in one verdict, executed as code — not as agent self-review. PASS/BLOCK/UNPROVEN by exit code. | `process` | Read-only | [vurnix-spec-kit](https://github.com/shiersa/vurnix-spec-kit) | | What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) | | Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) | | Work IQ | Integrate Microsoft 365 organizational knowledge into spec-driven development workflows | `integration` | Read-only | [spec-kit-workiq](https://github.com/sakitA/spec-kit-workiq) | diff --git a/docs/community/presets.md b/docs/community/presets.md index 2b8f56b319..69a1523343 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -11,9 +11,10 @@ The following community-contributed presets customize how Spec Kit behaves — o | Agent Parity Governance | Adds shared-guidance parity, fleet-completion evidence, secret-free runner metadata, audit-ready Spec Kit evidence, and agent-neutral model routing across declared AI-agent surfaces. | 7 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) | | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Architecture Governance | Adds secure architecture, STRIDE/CAPEC threat modeling, arc42/S-ADR guidance, Zero Trust, SAMM, BSI cloud assurance, audit evidence, and provider-neutral model routing. | 14 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) | -| Autonomous Run Governance | Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) | +| Autonomous Run Governance | Adds permission-bounded autonomous delivery with validated delivery sets, semantic phase completion, and lifecycle-bound exact-head evidence. | 15 templates, 5 commands, 11 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) | | Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | | Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | +| Closed Vocabulary Check | Adds a pass to /speckit.analyze that flags closed sets of values enumerated more than once with different members, and reports its own coverage. | 1 command | — | [spec-kit-preset-closed-vocabulary](https://github.com/yunusdim/spec-kit-preset-closed-vocabulary) | | Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) | | Cross-Platform Governance | Adds Bash/PowerShell parity, read-only checks, path and native-override review, Unix man pages, bilingual PowerShell help, and provider-neutral model routing. | 9 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | @@ -21,13 +22,14 @@ The following community-contributed presets customize how Spec Kit behaves — o | Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) | | Intake Authoring Governance | Governs traceable intake CRUD, language-aware requirements collections, bounded public HTTPS sources, and explicitly approved single or series authoring. | 13 templates, 5 commands, 7 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) | | Intake Review Governance | Reviews single, series, campaign, and language-aware requirements collections before Spec Kit execution. | 9 templates, 3 commands, 5 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) | -| Intake Sequencing Governance | Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection. | 11 templates, 6 commands, 8 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) | +| Intake Sequencing Governance | Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection. | 12 templates, 6 commands, 8 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) | +| Inventory Alignment | Classifies each requirement against a read-only inventory of live IDs before writing, so reworded requirements are updated instead of duplicated. | 1 template, 2 commands | speckit-inventory extension | [spec-kit-inventory-alignment](https://github.com/Yash-Chindam/spec-kit-inventory-alignment) | | iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance, architecture views, quality scenarios, ADRs, risks, technical-debt evidence, and provider-neutral model routing. | 14 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) | | Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) | | Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) | | Model Routing Governance | Maps provider-neutral Spec Kit roles to validated harness-local runner profiles without storing model availability, credentials, or machine-specific selections in Git. | 4 templates, 2 commands, 2 scripts | — | [spec-kit-preset-model-routing-governance](https://github.com/hindermath/spec-kit-preset-model-routing-governance) | | Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | -| Parallel Autonomous Run Governance | Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) | +| Parallel Autonomous Run Governance | Coordinates isolated autonomous campaigns and optionally gates worker scheduling on a current campaign intake review. | 10 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) | | Security Governance | Adds memory-safe-language and secure-coding governance, exact-head security evidence, ASVS, supply-chain transparency, EU regulatory screening, and provider-neutral model routing. | 15 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) | @@ -36,6 +38,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | SpecAssay | Appends durable-ID, Carries, and SpecAssay vocabulary onto Spec Kit spec, tasks, and constitution templates. | 3 templates | — | [specassay](https://github.com/rdryfoos/specassay) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | | Test-First Governance | Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates. | 10 templates, 8 commands | — | [spec-kit-preset-test-first-governance](https://github.com/ka-zo/spec-kit-preset-test-first-governance) | +| Verified Codebase Context | Generates evidence-qualified repository context with codebase-memory-mcp and applies it across planning, tasks, analysis, and implementation. | 1 template, 5 commands | — | [spec-kit-preset-codebase-memory-context](https://github.com/philo-x/spec-kit-preset-codebase-memory-context) | | VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | | Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) | diff --git a/docs/docfx.json b/docs/docfx.json index e22b394ba0..77a7653dd7 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -64,6 +64,8 @@ "globalMetadata": { "_appTitle": "Spec Kit Documentation", "_appName": "Spec Kit", + "_appLogoPath": "images/spec-kit-logo.webp", + "_appFaviconPath": "images/spec-kit-logo.webp", "_appFooter": "Spec Kit - A specification-driven development toolkit", "_enableSearch": true, "_disableContribution": false, diff --git a/docs/guides/evolving-specs.md b/docs/guides/evolving-specs.md index e2941f08b3..17a91298ea 100644 --- a/docs/guides/evolving-specs.md +++ b/docs/guides/evolving-specs.md @@ -1,5 +1,9 @@ # Evolving Specs in Existing Projects +If the repository has not been initialized with Spec Kit yet, start with +[Adopting Spec Kit in an Existing Project](existing-projects.md). This page +covers how to maintain artifacts after adoption. + Existing projects need two separate maintenance loops: - **Spec Kit project-file updates** refresh managed commands, scripts, diff --git a/docs/guides/existing-projects.md b/docs/guides/existing-projects.md new file mode 100644 index 0000000000..479715546e --- /dev/null +++ b/docs/guides/existing-projects.md @@ -0,0 +1,106 @@ +# Adopting Spec Kit in an Existing Project + +You do not need to recreate an existing system from specifications before using +Spec Kit. Initialize the repository in place, capture the rules that matter, +and use the workflow for the next bounded change. + +## 1. Start from a Reviewable Baseline + +Before initialization, commit or stash existing work and create a branch for the +adoption. This makes every generated file visible in a normal code review. + +Choose the [integration key](../reference/integrations.md) for the coding agent +you use. Then run the command from the repository root: + +```bash +specify init --here --force --integration +``` + +`--here` targets the current directory. `--force` allows initialization in a +non-empty directory and may replace files at conflicting managed paths, so use +it only after creating a reviewable baseline. It does not delete the rest of +your application. + +Review the resulting diff before continuing. Initialization adds the shared +`.specify/` project files and the command or skill files required by your +selected integration. It does not rewrite your application or infer +specifications for existing behavior. + +> [!NOTE] +> Git initialization and feature branches are optional and are managed by the +> **git** extension. Add it with `specify extension add git` if you want that +> workflow. + +## 2. Capture Project Guardrails + +Run `/speckit.constitution` with principles that are already true for the +repository or that the team has explicitly agreed to adopt: + +```text +/speckit.constitution Preserve public API compatibility. Follow the existing +service boundaries. Every database migration must include a rollback plan. +Run the repository's established unit and integration test suites. +``` + +Use the repository's README, architecture decisions, contribution guide, and +CI configuration as evidence. Do not invent standards merely to fill the +constitution template. The constitution governs later planning and analysis, +so unrealistic rules create noise instead of useful constraints. + +## 3. Choose a Bounded First Change + +Start with a feature, bug fix, or modernization slice that can be reviewed +independently. Do not make "document the entire existing system" your first +feature unless that inventory is itself the intended deliverable. + +Describe both the requested outcome and the compatibility boundaries that must +remain intact: + +```text +/speckit.specify Add CSV export to the existing orders page. Preserve current +filters and authorization behavior. Export only the rows visible to the signed-in +user, and do not change the existing JSON API response. +``` + +The codebase remains implementation context. The new `spec.md` defines the +change you intend to make, not a retroactive specification of every existing +behavior. + +## 4. Plan Against the Repository + +Continue through the normal workflow: + +1. Run `/speckit.clarify` to resolve uncertain behavior and compatibility + requirements. +2. Run `/speckit.plan` and verify that the proposed design reuses the existing + architecture, dependencies, and test conventions. +3. Run `/speckit.tasks`, then `/speckit.analyze` to check consistency before + implementation. +4. Run `/speckit.implement` and review code and artifact changes together. +5. Run `/speckit.converge` to find remaining gaps. If it adds tasks, repeat + implementation and convergence until the feature is complete. + +For command details and optional quality gates, see the +[Quick Start Guide](../quickstart.md) and +[Agentic SDD reference](../reference/agentic-sdd.md). + +## 5. Decide How Specs Will Age + +After the first change, agree on how the team will maintain completed feature +artifacts: + +- Keep each feature directory as an immutable historical record. +- Maintain `spec.md` as a living contract and regenerate downstream artifacts. +- Allow discoveries to flow back from code, tasks, or plans, then reconcile the + full artifact set. + +The [Spec Persistence Models](../concepts/spec-persistence.md) page compares +these choices. The [Evolving Specs guide](evolving-specs.md) provides the +maintenance loop for each model. + +## Existing-Project Examples + +The [community walkthroughs](../community/walkthroughs.md) include brownfield +examples across .NET, Java, and Go/React codebases. Community extensions for +architecture discovery and brownfield bootstrapping are listed in the +[extension catalog](../community/extensions.md). diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 0000000000..b3aee62909 --- /dev/null +++ b/docs/history.md @@ -0,0 +1,173 @@ +# History + +Spec Kit began as a toolkit for making specifications the starting point of +AI-assisted development. From its +[first full check-in](https://github.com/github/spec-kit/commit/28fdfaa86973d4402eecd89ba6c87d31e1edae03), +it described three ways to apply Spec-Driven Development: + +- **0-to-1 Development ("Greenfield")** generates a new system from + requirements. +- **Creative Exploration** compares parallel implementations, technology + choices, and experience designs. +- **Iterative Enhancement ("Brownfield")** adds features to and modernizes + existing systems. + +All three moved from durable planning artifacts into implementation: + +**Specify → Plan → Tasks → Implement** + +Those development paths and that core sequence remain, but the project has +grown into an extensible harness for coding agents, software delivery +processes, and other structured work. + +## Project stewardship + +Spec Kit's history includes two distinct stewardship periods. Recording them +here preserves the contemporary account of the project's leadership without +reducing the work to any one person. + +### Founding stewardship: August 2025–January 2026 + +[Den Delimarsky](https://github.com/localden) and +[John Lam](https://github.com/jflam) conceived Spec Kit and gave the project its +first shape. Den authored the +[initial commit](https://github.com/github/spec-kit/commit/fa2736371e077f55c4fe145fea186bab2561386d) on +August 21, 2025 and led the repository through its first months. + +That founding period established the shape users still recognize: the Specify +CLI, coding-agent-specific scaffolding, project constitutions, and the +specification → plan → tasks → implementation process. It also framed SDD as +useful for greenfield development, parallel exploration, and brownfield +enhancement rather than tying the method to a single agent or development +scenario. + +### Community stewardship: January 2026–present + +[Manfred Riem](https://github.com/mnriem) took over as lead maintainer on +January 22, 2026. The transition became publicly visible when the repository's +global [`CODEOWNERS` entry](https://github.com/github/spec-kit/commit/3040d33c31d8a26d50f91aec5d62d1cecac3298c) +changed to `@mnriem` on February 23. + +During this stewardship, the maintainer team's focus moved from building a +composable model to using it to ship complete first-party processes. That shift +was not sequential for the community: the modular extension system began as a +community contribution, and contributors adopted and extended each primitive +as it arrived. + +These dates and roles are also documented in the lead maintainer's +[six-month retrospective](https://www.manorrock.com/blog/2026/07/22/six_months_leading_spec_kit.html) +and +[first-anniversary account](https://www.manorrock.com/blog/2026/08/21/spec_kit_turns_one.html), +and are consistent with the repository's commit and ownership history. + +## Milestones + +### August 2025: The foundation + +The repository history begins on August 21, 2025. The first releases established +the Specify CLI, reusable templates, and the core Spec-Driven Development +paths. Support for multiple coding agents through centrally configured, +agent-specific scaffolding was part of the project from the start, keeping the +process independent of any one model or tool. + +### February–April 2026: Building the primitives + +The modular extension system arrived in February as a community contribution +from Michal Bachorik, allowing capabilities to be added without expanding the +core process. March brought pluggable presets, which made templates and +commands replaceable or composable while preserving the same CLI experience. + +The founding-era agent scaffolding was rewritten as a registry-backed +integration architecture. Core assets were also embedded in the Python package, +enabling reliable offline and air-gapped initialization. + +The workflow engine introduced catalog-distributed automation and built-in +workflow step types in April. Workflows could coordinate reusable steps rather +than requiring users to invoke every command manually. An integration catalog +followed, making coding-agent support discoverable and independently +distributable. + +The composable model came to be described through five primitives: + +- **Integrations** connect Spec Kit to coding agents. +- **Extensions** add capabilities, commands, templates, scripts, and hooks. +- **Presets** customize or replace behavior. +- **Workflows** automate multi-step processes. +- **Workflow steps** provide reusable units of workflow behavior. + +The emphasis during these first months was on creating reusable machinery: +making the process configurable, distributable, and automatable before adding +more first-party processes. Community contributors did not wait for the full +model to be complete; they quickly used the new extension and preset surfaces +to publish their own capabilities and process variations. + +### June–July 2026: Composing and applying the primitives + +For the core team, June marked the turn from mainly building primitives to using +them. A workflow step catalog made custom step types community-installable, +extending a primitive that had shipped with the workflow engine in April. +Bundles then made it possible to package extensions, presets, workflows, and +steps as a coherent setup for a role or team, optionally targeting a specific +integration. + +Catalogs became the bridge between the primitives and the community. Community +authors built extensions, presets, integrations, workflows, step types, and +bundles; the maintainer team checked submission metadata and listed accepted +entries in community catalogs so users could discover and install them. A +catalog listing made a component visible, but did not mean its code had been +audited or endorsed. + +At the same time, core maintainers began using the model to add two first-party +processes alongside feature delivery: + +- On June 5, version 0.9.5 introduced the bundled, opt-in + [`bug` extension](https://github.com/github/spec-kit/commit/60302fefec541a68fcac6f0428a95ba35f2acadf). + Its assess → fix → test process keeps bug diagnosis, remediation, and + verification separate and documented. +- On July 17, version 0.13.0 introduced the bundled, opt-in + [`assess` extension](https://github.com/github/spec-kit/commit/208d38695fc88d8eaec7855c96e5098a852927cf). + Its intake → research → define → shape → decide process evaluates an idea + before it enters SDD. + +Distribution broadened too: the release pipeline added PyPI publishing, and +Python joined Bash and PowerShell as a supported project script type. These +changes made installation and cross-platform use simpler while preserving +support for offline and enterprise environments. + +### August 2026: First anniversary + +Spec Kit turned one and released version 1.0.0 on August 21, 2026. By then, its +five primitives — integrations, extensions, presets, workflows, and workflow +steps — already formed a coherent model. Bundles composed extensions, presets, +workflows, and steps around a selected integration. A README refresh made the +existing SDD, bug-fixing, and idea-assessment processes easier to discover +through separate quickstarts. + +Version 1.0.0 did not create or freeze that model; it gave the project's +evolving state a round number. The documentation then reported 38 coding-agent +integrations, 157 community extensions, 33 presets, and 270+ contributors. Spec +Kit continues to favor adaptability: processes, integrations, and conventions +can evolve while agents help projects apply those changes. + +## Enduring themes + +Several themes connect the project's stewardship periods and technical +evolution: + +- **Intent comes before implementation.** Specifications capture what should be + built before technical decisions dominate the work. +- **Artifacts should be durable.** Specs, plans, and tasks remain useful beyond + a single prompt or agent session. +- **The process should be agent-independent.** Teams can change coding agents + without abandoning their development method. +- **The method should adapt to the work.** The original development paths grew + into a formally composable model that teams can modify, automate, or replace. +- **The community shapes the kit.** Community contributions have influenced + both the project's infrastructure and the ecosystem built on it. + +## Release history + +This page records the project's broad evolution, not every feature or breaking +change. For release-level detail, see the +[changelog](https://github.com/github/spec-kit/blob/main/CHANGELOG.md) and +[GitHub Releases](https://github.com/github/spec-kit/releases). diff --git a/docs/images/spec-kit-logo.webp b/docs/images/spec-kit-logo.webp new file mode 100644 index 0000000000..209e3deeff Binary files /dev/null and b/docs/images/spec-kit-logo.webp differ diff --git a/docs/index.md b/docs/index.md index 61cd50dd47..082c22ac7d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,7 @@
+ + # GitHub Spec Kit **Spec-Driven Development or your own process — step by step or as an automated workflow.** @@ -31,7 +33,7 @@ Define what to build before building it. Rich templates, quality checklists, and ### Use any coding agent -35 integrations — Copilot, Gemini, Codex, Kilo Code, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in. +38 integrations — Copilot, Gemini, Codex, Kilo Code, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in. Run `specify init` with your agent of choice and Spec Kit sets up the right command files and directory structures automatically. If your agent isn't listed, the `generic` integration is an escape hatch for any tool. @@ -43,7 +45,7 @@ Run `specify init` with your agent of choice and Spec Kit sets up the right comm ### Make it your own -138 community extensions (70+ authors), 25 presets, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, and package it all up as bundles you can share — or replace the process entirely. The process itself lives in these building blocks, so you're never locked to SDD, or even to software. +157 community extensions (90+ authors), 33 presets, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, and package it all up as bundles you can share — or replace the process entirely. The process itself lives in these building blocks, so you're never locked to SDD, or even to software. Including entirely different processes: @@ -82,31 +84,31 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a ## Built by the community -**240+ contributors** power the Spec Kit ecosystem — from core integrations to entirely new processes. Anyone can create and publish an extension, preset, or workflow. +**270+ contributors** power the Spec Kit ecosystem — from core integrations to entirely new processes. Anyone can create and publish an extension, preset, or workflow.
- 121K+ + 130K+ GitHub stars
- 240+ + 270+ Contributors
- 35 + 38 Integrations
- 138 + 157 Extensions
- 25 + 33 Presets
- 6 + 7 Friends projects
@@ -124,6 +126,14 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a Getting Started Install, configure, and run your first SDD workflow + + Existing Projects + Adopt Spec Kit safely in an established codebase + + + Upgrade + Keep an existing Spec Kit project current across releases + Reference Core commands, integrations, extensions, presets, and workflows @@ -140,6 +150,10 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a What is SDD? The philosophy behind Spec-Driven Development + + History + How Spec Kit grew from its SDD foundation into an extensible process harness +
--- @@ -155,4 +169,4 @@ Ready to start? Follow the [Quick Start Guide](quickstart.md). -

Last updated: July 16, 2026

+

Last updated: August 21, 2026

diff --git a/docs/quickstart.md b/docs/quickstart.md index 2813118b5f..fb2ecc2f74 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -47,6 +47,9 @@ specify init taskify # or: specify init . to use the current directory > [!NOTE] > Prefer `pipx`, one-time `uvx` runs, a pinned release, or an offline/air-gapped setup? See the [Installation Guide](installation.md) for all supported methods. +> Adding Spec Kit to a repository that already contains code? Follow +> [Adopting Spec Kit in an Existing Project](guides/existing-projects.md) before +> starting the workflow below. ### Step 1: `/speckit.constitution` — set the ground rules diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 8de2c18c86..0473e72008 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -75,6 +75,8 @@ specify extension update [] Updates a specific extension, or all installed extensions if no name is given. +Bundled extensions (such as `agent-context` and `git`) have no download URL; their updates install from the copy shipped with the running spec-kit release. When the catalog advertises a newer version than your spec-kit release ships, the update is reported as requiring a spec-kit upgrade first. + ## Enable / Disable an Extension ```bash diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 57bb46b10c..ac9e2978b7 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -16,7 +16,9 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | | [Command Code](https://commandcode.ai/docs) | `command-code` | Skills-based integration; installs skills into `.commandcode/skills/` and invokes them as `$speckit-` | | [Cursor](https://cursor.sh/) | `cursor-agent` | | +| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | `dsh` | Skills-based integration; installs skills into `.dsh/skills` and invokes them as `/speckit-` | | [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | +| [Docker Agent](https://docs.docker.com/ai/docker-agent/) | `docker-agent` | Skills-based integration; installs skills into `.agents/skills/` (the same directory used by Codex and Zed). In the selected agent YAML, enable local skills with `skills: true` and provide filesystem read access. Detects either the standalone `docker-agent` binary or the Docker CLI plugin (`docker agent`). Configure workflow dispatch with `SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS=./agent.yaml`; the Spec Kit prompt is appended after these arguments. Not multi-install safe by default because the skills directory is shared. | | [Factory Droid](https://docs.factory.ai/cli/getting-started/overview) | `droid` | Skills-based integration; installs skills into `.factory/skills/` and invokes them as `/speckit-` | | [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ | | [Forge](https://forgecode.dev/) | `forge` | | @@ -292,7 +294,7 @@ The currently declared multi-install safe integrations are: | `lingma` | `.lingma/skills` | | `omp` | `.omp/commands` | | `pi` | `.pi/prompts` | -| `qodercli` | `.qoder/commands` | +| `qodercli` | `.qoder/skills` | | `qwen` | `.qwen/commands` | | `shai` | `.shai/commands` | | `tabnine` | `.tabnine/agent/commands` | diff --git a/docs/template/public/main.css b/docs/template/public/main.css index 52ce456064..68f91d9dfa 100644 --- a/docs/template/public/main.css +++ b/docs/template/public/main.css @@ -25,6 +25,13 @@ --gh-coral-subtle: #2d0f0d; } +/* Keep the raster Spec Kit logo aligned with DocFX's default header dimensions. */ +.navbar-brand #logo { + width: 1.5rem; + height: 1.5rem; + margin-right: 0.375rem; +} + /* Override Bootstrap primary with GitHub blue */ body[data-layout="landing"] { --bs-primary: var(--gh-blue); @@ -44,6 +51,13 @@ body[data-layout="landing"][data-bs-theme="dark"] { padding: 3rem 0 1.5rem; } +.landing-hero-logo { + display: block; + width: 7.5rem; + height: 7.5rem; + margin: 0 auto 1rem; +} + .landing-hero h1 { font-size: 2.6rem; font-weight: 800; diff --git a/docs/toc.yml b/docs/toc.yml index a2e07b270c..d2f1b2bd21 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -2,6 +2,10 @@ - name: Home href: index.md +# Project history +- name: Project History + href: history.md + # Getting started section - name: Getting Started items: @@ -9,6 +13,8 @@ href: installation.md - name: Quick Start href: quickstart.md + - name: Existing Projects + href: guides/existing-projects.md - name: Upgrade href: upgrade.md - name: Install uv diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md index f0eff5417b..0cbe93aae6 100644 --- a/extensions/EXTENSION-PUBLISHING-GUIDE.md +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -274,8 +274,7 @@ When releasing a new version: A: The main catalog is for public extensions only. For private extensions: - Host your own catalog.json file -- Users add your catalog: `specify extension add-catalog https://your-domain.com/catalog.json` -- Not yet implemented - coming in Phase 4 +- In a Spec Kit project, users add your catalog: `specify extension catalog add https://your-domain.com/catalog.json --name private-catalog` ### Q: How long does review take? diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 15174e83b3..0f918c1d9f 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-18T00:00:00Z", + "updated_at": "2026-09-02T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -19,7 +19,13 @@ "effect": "read-write", "requires": { "speckit_version": ">=0.13.0,<0.16.0", - "tools": [{ "name": "adr", "version": ">=0.3.0", "required": true }] + "tools": [ + { + "name": "adr", + "version": ">=0.3.0", + "required": true + } + ] }, "provides": { "commands": 3, @@ -180,6 +186,92 @@ "created_at": "2026-05-04T00:00:00Z", "updated_at": "2026-05-04T00:00:00Z" }, + "agentdocx-speckit": { + "name": "AgentDocx", + "id": "agentdocx-speckit", + "description": "Full-stack multi-agent specification pipeline with VS Code extension control, automated Kanban/Jira sync, and React monitoring dashboard.", + "author": "Abir Ommezzine and Ahmed Aziz Ammar", + "version": "0.0.3", + "download_url": "https://github.com/abir-ommezzine/extension-github-spec-kit/archive/refs/tags/v0.0.3.zip", + "repository": "https://github.com/abir-ommezzine/extension-github-spec-kit", + "homepage": "https://github.com/abir-ommezzine/extension-github-spec-kit", + "documentation": "https://github.com/abir-ommezzine/extension-github-spec-kit/blob/main/README.md", + "changelog": "", + "license": "MIT", + "category": "integration", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 0, + "hooks": 0 + }, + "tags": [ + "issue-tracking", + "jira", + "automation", + "workflow", + "pipeline", + "agents" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-18T00:00:00Z", + "updated_at": "2026-08-18T00:00:00Z" + }, + "agentdocx-speckitv2": { + "name": "AgentDocx SpecKit V2", + "id": "agentdocx-speckitv2", + "description": "AgentDocx evolved: same pipeline + far more autonomous Ticket Manager (5 CLI, per-project Kanban, bulk sync, auto-switch, Auditor).", + "author": "ahmed200346", + "version": "0.0.7", + "download_url": "https://github.com/ahmed200346/Extension_GithubSpecKit/archive/refs/tags/v0.0.7.zip", + "repository": "https://github.com/ahmed200346/Extension_GithubSpecKit", + "homepage": "https://github.com/ahmed200346/Extension_GithubSpecKit", + "documentation": "https://github.com/ahmed200346/Extension_GithubSpecKit/blob/main/README.md", + "changelog": "https://github.com/ahmed200346/Extension_GithubSpecKit/blob/main/agentdocx-speckit/CHANGELOG.md", + "license": "MIT", + "category": "integration", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "python", + "version": ">=3.10", + "required": true + }, + { + "name": "node", + "version": ">=18", + "required": true + }, + { + "name": "postgresql", + "version": ">=12", + "required": true + } + ] + }, + "provides": { + "commands": 0, + "hooks": 0 + }, + "tags": [ + "integration", + "kanban", + "ticket-management", + "vscode-extension", + "multi-agent" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-28T00:00:00Z", + "updated_at": "2026-08-28T00:00:00Z" + }, "analytics": { "name": "Analytics", "id": "analytics", @@ -303,8 +395,15 @@ "requires": { "speckit_version": ">=0.1.0", "tools": [ - { "name": "python", "version": ">=3.11", "required": true }, - { "name": "uv", "required": true } + { + "name": "python", + "version": ">=3.11", + "required": true + }, + { + "name": "uv", + "required": true + } ] }, "provides": { @@ -361,47 +460,55 @@ "architecture-guard": { "name": "Architecture Guard", "id": "architecture-guard", - "description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.", + "description": "Framework-agnostic architecture governance for Spec Kit workflows, detecting drift, enforcing architectural rules, and generating actionable refactor tasks.", "author": "DyanGalih", - "version": "1.13.1", - "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.13.1.zip", - "repository": "https://github.com/DyanGalih/spec-kit-architecture-guard", - "homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard", - "documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/docs/architecture-overview.md", - "changelog": "https://github.com/DyanGalih/spec-kit-architecture-guard/releases", + "version": "2.3.6", + "download_url": "https://github.com/DyanGalih/architecture-guard/archive/refs/tags/v2.3.6.zip", + "repository": "https://github.com/DyanGalih/architecture-guard", + "homepage": "https://github.com/DyanGalih/architecture-guard", + "documentation": "https://github.com/DyanGalih/architecture-guard/blob/main/SPECKIT-INTEGRATION.md", + "changelog": "https://github.com/DyanGalih/architecture-guard/blob/main/docs/release-notes.md", "license": "MIT", "category": "process", "effect": "read-write", "requires": { - "speckit_version": ">=0.1.0" + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "node", + "version": ">=18", + "required": false + }, + { + "name": "npm", + "required": false + } + ] }, "provides": { - "commands": 14, + "commands": 18, "hooks": 3 }, "tags": [ "architecture", - "spec-kit", + "governance", "review", "refactor", - "workflow", - "governance", - "guardrails", - "hygiene" + "workflow" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-05-05T07:26:00Z", - "updated_at": "2026-07-24T00:00:00Z" + "updated_at": "2026-08-20T00:00:00Z" }, "archive": { "name": "Archive Extension", "id": "archive", "description": "Archive merged features into main project memory, resolving gaps and conflicts.", "author": "Stanislav Deviatov", - "version": "1.2.2", - "download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.2.2.zip", + "version": "1.3.0", + "download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.3.0.zip", "repository": "https://github.com/stn1slv/spec-kit-archive", "homepage": "https://github.com/stn1slv/spec-kit-archive", "documentation": "https://github.com/stn1slv/spec-kit-archive/blob/main/README.md", @@ -410,7 +517,7 @@ "category": "docs", "effect": "read-write", "requires": { - "speckit_version": ">=0.1.0" + "speckit_version": ">=0.14.0" }, "provides": { "commands": 1, @@ -426,7 +533,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-14T00:00:00Z", - "updated_at": "2026-08-11T00:00:00Z" + "updated_at": "2026-08-24T00:00:00Z" }, "ascii-diagram": { "name": "ASCII Diagram Renderer", @@ -463,7 +570,7 @@ "updated_at": "2026-08-17T00:00:00Z" }, "atlas": { - "name": "spec-kit-atlas", + "name": "Atlas", "id": "atlas", "description": "Synthesize spec-kit specs into faithful, interactive architecture storybooks & doc portals.", "author": "Ash Brener", @@ -479,8 +586,15 @@ "requires": { "speckit_version": ">=0.1.0", "tools": [ - { "name": "python", "version": ">=3.11", "required": true }, - { "name": "uv", "required": true } + { + "name": "python", + "version": ">=3.11", + "required": true + }, + { + "name": "uv", + "required": true + } ] }, "provides": { @@ -498,7 +612,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-08-13T00:00:00Z", - "updated_at": "2026-08-13T00:00:00Z" + "updated_at": "2026-08-19T00:00:00Z" }, "azure-devops": { "name": "Azure DevOps Integration", @@ -542,15 +656,15 @@ "updated_at": "2026-03-03T00:00:00Z" }, "bdd": { - "name": "Spec-Kit BDD", + "name": "BDD", "id": "bdd", - "description": "ATDD/BDD extension: convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage.", + "description": "Convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage.", "author": "RSginer", - "version": "1.0.2", - "download_url": "https://github.com/RSginer/spec-kit-bdd/archive/refs/tags/v1.0.2.zip", + "version": "1.0.3", + "download_url": "https://github.com/RSginer/spec-kit-bdd/archive/refs/tags/v1.0.3.zip", "repository": "https://github.com/RSginer/spec-kit-bdd", - "homepage": "https://github.com/RSginer/spec-kit-bdd", - "documentation": "https://github.com/RSginer/spec-kit-bdd/blob/main/docs/usage.md", + "homepage": "https://rsginer.github.io/spec-kit-bdd/", + "documentation": "https://rsginer.github.io/spec-kit-bdd/", "changelog": "https://github.com/RSginer/spec-kit-bdd/releases", "license": "MIT", "category": "process", @@ -599,7 +713,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-07-15T00:00:00Z", - "updated_at": "2026-07-15T00:00:00Z" + "updated_at": "2026-08-24T00:00:00Z" }, "blueprint": { "name": "Blueprint", @@ -652,8 +766,14 @@ "requires": { "speckit_version": ">=0.10.0", "tools": [ - { "name": "bash", "required": false }, - { "name": "git", "required": false } + { + "name": "bash", + "required": false + }, + { + "name": "git", + "required": false + } ] }, "provides": { @@ -919,20 +1039,25 @@ "charter": { "name": "Charter", "id": "charter", - "description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.", + "description": "Compose project constitutions from shared fragment registries", "author": "Fyloss", - "version": "0.5.1", - "download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.5.1.zip", + "version": "0.6.1", + "download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.6.1.zip", "repository": "https://github.com/Fyloss/spec-kit-charter", "homepage": "https://github.com/Fyloss/spec-kit-charter", - "documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs", + "documentation": "https://github.com/Fyloss/spec-kit-charter/blob/master/README.md", "changelog": "https://github.com/Fyloss/spec-kit-charter/blob/master/CHANGELOG.md", "license": "MIT", "category": "process", "effect": "read-write", "requires": { "speckit_version": ">=0.11.9", - "tools": [{ "name": "git", "required": false }] + "tools": [ + { + "name": "git", + "required": false + } + ] }, "provides": { "commands": 5, @@ -941,15 +1066,15 @@ "tags": [ "constitution", "governance", - "modular", - "fragments", - "registry" + "multi-repo", + "composition", + "fragments" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-07-06T00:00:00Z", - "updated_at": "2026-08-04T00:00:00Z" + "updated_at": "2026-09-02T00:00:00Z" }, "ci-guard": { "name": "CI Guard", @@ -1087,40 +1212,42 @@ "companion": { "name": "SpecKit Companion", "id": "companion", - "description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, and composable commands you can customize with hooks and recipes.", + "description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, living specs, and composable commands you can customize with hooks and recipes.", "author": "alfredoperez", - "version": "0.11.0", - "download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.11.0/companion-0.11.0.zip", + "version": "0.20.2", + "download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.20.2/companion-0.20.2.zip", "repository": "https://github.com/alfredoperez/speckit-companion", "homepage": "https://github.com/alfredoperez/speckit-companion/tree/main/speckit-extension", "documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/README.md", "changelog": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/CHANGELOG.md", "license": "MIT", - "category": "visibility", + "category": "process", "effect": "read-write", "requires": { "speckit_version": ">=0.9.5", "tools": [ - { "name": "python3", "required": false } + { + "name": "python3", + "required": false + } ] }, "provides": { - "commands": 13, + "commands": 18, "hooks": 4 }, "tags": [ "vscode", "progress", - "status", - "resume", - "configurable", - "extensible" + "living-specs", + "drift", + "hooks" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-06-11T00:00:00Z", - "updated_at": "2026-06-24T00:00:00Z" + "updated_at": "2026-08-20T00:00:00Z" }, "conduct": { "name": "Conduct Extension", @@ -1251,6 +1378,40 @@ "created_at": "2026-07-13T00:00:00Z", "updated_at": "2026-07-13T00:00:00Z" }, + "cosmosdb": { + "name": "Azure Cosmos DB", + "id": "cosmosdb", + "description": "Best-practice Azure Cosmos DB code generation and review for any AI coding agent", + "author": "Theo van Kraay (maintained on behalf of the Azure Cosmos DB team; hosted in the AzureCosmosDB org)", + "version": "0.1.0", + "download_url": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb", + "homepage": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb", + "documentation": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb/blob/main/README.md", + "changelog": "https://github.com/AzureCosmosDB/spec-kit-cosmosdb/blob/main/CHANGELOG.md", + "license": "MIT", + "category": "code", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 53, + "hooks": 2 + }, + "tags": [ + "azure", + "cosmosdb", + "database", + "nosql", + "recommend-coding" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-21T00:00:00Z", + "updated_at": "2026-08-21T00:00:00Z" + }, "cost": { "name": "Cost Tracker", "id": "cost", @@ -1622,10 +1783,10 @@ "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.", + "description": "Grounds SpecKit spec/plan/tasks in Figma design context via REST or MCP, on 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", + "version": "3.1.1", + "download_url": "https://github.com/Fyloss/spec-kit-figma/archive/refs/tags/v3.1.1.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", @@ -1634,18 +1795,33 @@ "category": "integration", "effect": "read-write", "requires": { - "speckit_version": ">=0.1.0", + "speckit_version": ">=0.11.2", "tools": [ - { "name": "git", "required": true }, - { "name": "bash", "required": false }, - { "name": "curl", "required": false }, - { "name": "jq", "required": false }, - { "name": "pwsh", "required": false } + { + "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 + "commands": 7, + "hooks": 11 }, "tags": [ "figma", @@ -1658,7 +1834,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-07-08T00:00:00Z", - "updated_at": "2026-07-08T00:00:00Z" + "updated_at": "2026-08-31T00:00:00Z" }, "figma-starter": { "name": "Figma Starter", @@ -1677,7 +1853,11 @@ "requires": { "speckit_version": ">=0.1.0", "tools": [ - { "name": "python3", "version": ">=3.8", "required": true } + { + "name": "python3", + "version": ">=3.8", + "required": true + } ] }, "provides": { @@ -1970,10 +2150,10 @@ "grill": { "name": "SpecKit Grill Me", "id": "grill", - "description": "Exhaustively resolve specification ambiguities and decisions before planning.", + "description": "Exhaustively clarify specifications and optionally sync canonical domain knowledge.", "author": "yoshi1220", - "version": "1.0.0", - "download_url": "https://github.com/yoshi1220/speckit-grill-me/releases/download/v1.0.0/speckit-grill-me-extension-v1.0.0.zip", + "version": "1.0.1", + "download_url": "https://github.com/yoshi1220/speckit-grill-me/releases/download/v1.0.1/speckit-grill-me-extension-v1.0.1.zip", "repository": "https://github.com/yoshi1220/speckit-grill-me", "homepage": "https://github.com/yoshi1220/speckit-grill-me/tree/main/spec-kit-extension", "documentation": "https://github.com/yoshi1220/speckit-grill-me/blob/main/spec-kit-extension/README.md", @@ -1983,10 +2163,15 @@ "effect": "read-write", "requires": { "speckit_version": ">=0.16.2", - "tools": [{ "name": "bash", "required": true }] + "tools": [ + { + "name": "bash", + "required": true + } + ] }, "provides": { - "commands": 1, + "commands": 2, "hooks": 0 }, "tags": [ @@ -2000,7 +2185,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-08-11T00:00:00Z", - "updated_at": "2026-08-11T00:00:00Z" + "updated_at": "2026-08-25T00:00:00Z" }, "harness": { "name": "Research Harness", @@ -2252,6 +2437,46 @@ "created_at": "2026-03-05T00:00:00Z", "updated_at": "2026-03-05T00:00:00Z" }, + "jira-mirror": { + "name": "Jira Mirror", + "id": "jira-mirror", + "description": "Spec Kit ↔ Jira bridge for team-managed and company-managed projects: configurable workflows & hierarchies (Scrum/SAFe), multi-project, idempotent and fail-closed. macOS/Linux/Windows.", + "author": "Fyloss", + "version": "0.24.0", + "download_url": "https://github.com/Fyloss/spec-kit-jira-mirror/releases/download/v0.24.0/spec-kit-jira-mirror-0.24.0.zip", + "sha256": "c3fe2f81fc3f47010cf92cdb8c49b612416b4c6aedf91beb579eba871fe1df16", + "repository": "https://github.com/Fyloss/spec-kit-jira-mirror", + "homepage": "https://github.com/Fyloss/spec-kit-jira-mirror", + "documentation": "https://github.com/Fyloss/spec-kit-jira-mirror/tree/main/docs", + "changelog": "https://github.com/Fyloss/spec-kit-jira-mirror/blob/main/CHANGELOG.md", + "license": "MIT", + "category": "integration", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.13.0", + "tools": [ + { "name": "bash", "version": ">=4", "required": false }, + { "name": "pwsh", "version": ">=7", "required": false }, + { "name": "curl", "required": false }, + { "name": "jq", "required": false }, + { "name": "git", "required": true } + ] + }, + "provides": { + "commands": 4, + "hooks": 7 + }, + "tags": [ + "jira", + "integration", + "sync" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-31T00:00:00Z", + "updated_at": "2026-08-31T00:00:00Z" + }, "jira-sync": { "name": "Jira Integration (Sync Engine)", "id": "jira-sync", @@ -2269,12 +2494,31 @@ "requires": { "speckit_version": ">=0.1.0", "tools": [ - { "name": "bash", "version": ">=4.4", "required": true }, - { "name": "git", "required": true }, - { "name": "curl", "required": true }, - { "name": "jq", "required": true }, - { "name": "gitleaks", "required": false }, - { "name": "trufflehog", "required": false } + { + "name": "bash", + "version": ">=4.4", + "required": true + }, + { + "name": "git", + "required": true + }, + { + "name": "curl", + "required": true + }, + { + "name": "jq", + "required": true + }, + { + "name": "gitleaks", + "required": false + }, + { + "name": "trufflehog", + "required": false + } ] }, "provides": { @@ -2412,7 +2656,12 @@ "effect": "read-write", "requires": { "speckit_version": ">=0.13.0,<1.0.0", - "tools": [{ "name": "linear-mcp", "required": true }] + "tools": [ + { + "name": "linear-mcp", + "required": true + } + ] }, "provides": { "commands": 5, @@ -2509,8 +2758,8 @@ "id": "maqa", "description": "Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins (Trello, Linear, GitHub Projects, Jira, Azure DevOps). Optional CI gate.", "author": "GenieRobot", - "version": "0.1.3", - "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ext/releases/download/maqa-v0.1.3/maqa.zip", + "version": "0.1.6", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ext/releases/download/maqa-v0.1.6/maqa.zip", "repository": "https://github.com/GenieRobot/spec-kit-maqa-ext", "homepage": "https://github.com/GenieRobot/spec-kit-maqa-ext", "documentation": "https://github.com/GenieRobot/spec-kit-maqa-ext/blob/main/README.md", @@ -2519,7 +2768,11 @@ "category": "process", "effect": "read-write", "requires": { - "speckit_version": ">=0.3.0" + "speckit_version": ">=0.3.0", + "tools": [ + { "name": "git", "required": true }, + { "name": "python3", "required": true } + ] }, "provides": { "commands": 4, @@ -2537,7 +2790,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-26T00:00:00Z", - "updated_at": "2026-03-27T00:00:00Z" + "updated_at": "2026-08-20T00:00:00Z" }, "maqa-azure-devops": { "name": "MAQA Azure DevOps Integration", @@ -2833,7 +3086,10 @@ "requires": { "speckit_version": ">=0.2.0", "tools": [ - { "name": "memsearch", "required": false } + { + "name": "memsearch", + "required": false + } ] }, "provides": { @@ -3425,6 +3681,40 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "prespec": { + "name": "Pre-Spec Cards", + "id": "prespec", + "description": "Card-based pre-spec thinking: paste an idea, get your card plus the paths you'd miss, then play each through — story, snags, trade-offs, difficulty vs payoff — before /speckit.specify.", + "author": "bendlikeabamboo", + "version": "0.3.0", + "download_url": "https://github.com/bendlikeabamboo/pre-spec/archive/refs/tags/v0.3.0.zip", + "repository": "https://github.com/bendlikeabamboo/pre-spec", + "homepage": "https://github.com/bendlikeabamboo/pre-spec", + "documentation": "https://github.com/bendlikeabamboo/pre-spec#readme", + "changelog": "https://github.com/bendlikeabamboo/pre-spec/releases", + "license": "MIT", + "category": "process", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "discovery", + "ideation", + "decision-cards", + "product", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-28T00:00:00Z", + "updated_at": "2026-08-28T00:00:00Z" + }, "preview": { "name": "Spec Kit Preview", "id": "preview", @@ -3661,8 +3951,8 @@ "id": "reconcile", "description": "Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.", "author": "Stanislav Deviatov", - "version": "1.1.0", - "download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.1.0.zip", + "version": "1.2.1", + "download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.2.1.zip", "repository": "https://github.com/stn1slv/spec-kit-reconcile", "homepage": "https://github.com/stn1slv/spec-kit-reconcile", "documentation": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/README.md", @@ -3671,11 +3961,11 @@ "category": "docs", "effect": "read-write", "requires": { - "speckit_version": ">=0.1.0" + "speckit_version": ">=0.16.2" }, "provides": { "commands": 1, - "hooks": 0 + "hooks": 2 }, "tags": [ "reconcile", @@ -3687,7 +3977,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-14T00:00:00Z", - "updated_at": "2026-08-10T00:00:00Z" + "updated_at": "2026-08-24T00:00:00Z" }, "red-team": { "name": "Red Team", @@ -4125,35 +4415,39 @@ "name": "Security Review", "id": "security-review", "description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews", - "author": "Spec-Kit Security Team", - "version": "1.5.3", - "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.5.3.zip", - "repository": "https://github.com/DyanGalih/spec-kit-security-review", - "homepage": "https://github.com/DyanGalih/spec-kit-security-review", - "documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md", - "changelog": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/CHANGELOG.md", + "author": "DyanGalih", + "version": "2.0.0", + "download_url": "https://github.com/DyanGalih/security-review/archive/refs/tags/v2.0.0.zip", + "repository": "https://github.com/DyanGalih/security-review", + "homepage": "https://github.com/DyanGalih/security-review", + "documentation": "https://github.com/DyanGalih/security-review/blob/main/docs/usage.md", + "changelog": "https://github.com/DyanGalih/security-review/blob/main/CHANGELOG.md", "license": "MIT", "category": "code", "effect": "read-write", "requires": { - "speckit_version": ">=0.1.0" + "speckit_version": ">=0.1.0", + "tools": [ + { "name": "git", "version": ">=2.0.0", "required": true }, + { "name": "node", "version": ">=22.0.0", "required": false } + ] }, "provides": { - "commands": 9, + "commands": 10, "hooks": 3 }, "tags": [ "security", - "devsecops", "audit", "owasp", - "compliance" + "compliance", + "governance" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-04-03T03:24:03Z", - "updated_at": "2026-06-08T00:00:00Z" + "updated_at": "2026-08-20T00:00:00Z" }, "sf": { "name": "SFSpeckit — Salesforce Spec-Driven Development", @@ -4337,10 +4631,10 @@ "specassay-check": { "name": "SpecAssay Check", "id": "specassay-check", - "description": "Gate 2 refuses silent gaps and emits a trace-manifest (trace-manifest.json).", + "description": "Gate 2 refuses silent gaps and emits a trace-manifest (`trace-manifest.json`).", "author": "Rik Dryfoos", - "version": "0.3.3", - "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.3.3/specassay-check-0.3.3.zip", + "version": "0.4.12", + "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.4.12/specassay-check-0.4.12.zip", "repository": "https://github.com/rdryfoos/specassay", "homepage": "https://www.specassay.com", "documentation": "https://github.com/rdryfoos/specassay/blob/main/extensions/specassay-check/README.md", @@ -4351,12 +4645,19 @@ "requires": { "speckit_version": ">=0.14.0", "tools": [ - { "name": "bash", "required": true }, - { "name": "python3", "version": ">=3.8", "required": true } + { + "name": "bash", + "required": true + }, + { + "name": "python3", + "version": ">=3.8", + "required": true + } ] }, "provides": { - "commands": 1, + "commands": 2, "hooks": 1 }, "tags": [ @@ -4370,7 +4671,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-08-13T00:00:00Z", - "updated_at": "2026-08-13T00:00:00Z" + "updated_at": "2026-08-21T00:00:00Z" }, "specjudge": { "name": "SpecJudge — right-size the model before you implement", @@ -4388,7 +4689,13 @@ "effect": "read-only", "requires": { "speckit_version": ">=0.13.0", - "tools": [{ "name": "specjudge", "version": ">=0.5.0", "required": true }] + "tools": [ + { + "name": "specjudge", + "version": ">=0.5.0", + "required": true + } + ] }, "provides": { "commands": 1, @@ -4406,6 +4713,41 @@ "created_at": "2026-08-12T00:00:00Z", "updated_at": "2026-08-12T00:00:00Z" }, + "speckit-inventory": { + "name": "Spec Inventory", + "id": "speckit-inventory", + "description": "Read-only inventory of live requirement and task IDs, with focused per-task context packs instead of whole-file dumps.", + "author": "Yash Chindam", + "version": "0.1.0", + "download_url": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment/releases/download/v0.1.0/speckit-inventory.zip", + "sha256": "9ebf004ef6494323f6dccfab2554a04898c9e92bc7f25e0638b5aee916566e7f", + "repository": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment", + "homepage": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment", + "documentation": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment/blob/main/speckit-inventory/README.md", + "changelog": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment/blob/main/speckit-inventory/CHANGELOG.md", + "license": "MIT", + "category": "visibility", + "effect": "read-only", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "commands": 2, + "hooks": 2 + }, + "tags": [ + "inventory", + "requirements", + "context", + "traceability", + "alignment" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-20T00:00:00Z", + "updated_at": "2026-08-20T00:00:00Z" + }, "speckit-superpowers-bridge": { "name": "Superpowers Implementation Bridge", "id": "speckit-superpowers-bridge", @@ -4778,6 +5120,46 @@ "created_at": "2026-03-02T00:00:00Z", "updated_at": "2026-03-02T00:00:00Z" }, + "taco": { + "name": "Taco Review", + "id": "taco", + "description": "Packages Spec Kit features for human review and syncs edits and comments back.", + "author": "Arcadia822", + "version": "0.3.1", + "download_url": "https://github.com/Arcadia822/taco/archive/refs/tags/v0.3.1.zip", + "repository": "https://github.com/Arcadia822/taco", + "homepage": "https://github.com/Arcadia822/taco", + "documentation": "https://github.com/Arcadia822/taco/blob/main/extensions/taco/README.md", + "changelog": "https://github.com/Arcadia822/taco/blob/v0.3.1/CHANGELOG.md", + "license": "MIT", + "category": "integration", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.16.0,<2.0.0", + "tools": [ + { + "name": "node", + "version": ">=22", + "required": true + } + ] + }, + "provides": { + "commands": 2, + "hooks": 8 + }, + "tags": [ + "documentation", + "review", + "spec-kit", + "human-in-the-loop" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-25T00:00:00Z", + "updated_at": "2026-08-25T00:00:00Z" + }, "tasks-to-project": { "name": "Tasks to GitHub Project", "id": "tasks-to-project", @@ -4795,8 +5177,14 @@ "requires": { "speckit_version": ">=0.2.0", "tools": [ - { "name": "gh", "required": true }, - { "name": "python3", "required": true } + { + "name": "gh", + "required": true + }, + { + "name": "python3", + "required": true + } ] }, "provides": { @@ -5165,11 +5553,27 @@ "requires": { "speckit_version": ">=0.10.0", "tools": [ - { "name": "rtk", "required": false }, - { "name": "headroom", "required": false }, - { "name": "token-router", "required": false }, - { "name": "ollama", "required": false }, - { "name": "python", "version": ">=3.10", "required": false } + { + "name": "rtk", + "required": false + }, + { + "name": "headroom", + "required": false + }, + { + "name": "token-router", + "required": false + }, + { + "name": "ollama", + "required": false + }, + { + "name": "python", + "version": ">=3.10", + "required": false + } ] }, "provides": { @@ -5392,6 +5796,45 @@ "created_at": "2026-04-20T00:00:00Z", "updated_at": "2026-04-22T21:10:00Z" }, + "vurnix": { + "name": "Vurnix Honest Gate", + "id": "vurnix", + "description": "Deterministic three-state honest gate for AI-written code: compile + phantom-import + honest test count, executed as code — not agent self-review. PASS/BLOCK/UNPROVEN by exit code.", + "author": "shiersa", + "version": "0.1.1", + "download_url": "https://github.com/shiersa/vurnix-spec-kit/releases/download/v0.1.1/vurnix-0.1.1.zip", + "repository": "https://github.com/shiersa/vurnix-spec-kit", + "homepage": "https://vurnix.dev", + "documentation": "https://github.com/shiersa/vurnix-spec-kit#readme", + "changelog": "", + "license": "MIT", + "category": "process", + "effect": "read-only", + "requires": { + "speckit_version": ">=0.16.2", + "tools": [ + { "name": "python3", "version": ">=3.10", "required": true }, + { "name": "vurnix", "version": ">=0.3.0", "required": true }, + { "name": "bash", "required": true } + ] + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "deterministic", + "honest-gate", + "test-integrity", + "quality", + "fail-closed" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-09-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z" + }, "whatif": { "name": "What-if Analysis", "id": "whatif", diff --git a/integrations/catalog.json b/integrations/catalog.json index f3f7a7fe7f..d4dbb168d2 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-07-27T00:00:00Z", + "updated_at": "2026-08-31T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", "integrations": { "alquimia": { @@ -66,6 +66,15 @@ "repository": "https://github.com/github/spec-kit", "tags": ["cli", "skills", "factory"] }, + "dsh": { + "id": "dsh", + "name": "DeepSeek Harness", + "version": "1.0.0", + "description": "DeepSeek Harness (DSH) CLI skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills"] + }, "amp": { "id": "amp", "name": "Amp", @@ -102,6 +111,15 @@ "repository": "https://github.com/github/spec-kit", "tags": ["cli", "skills"] }, + "docker-agent": { + "id": "docker-agent", + "name": "Docker Agent", + "version": "1.0.0", + "description": "Docker Agent skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills", "docker"] + }, "qwen": { "id": "qwen", "name": "Qwen Code", diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md index f71c1f45d8..ece79bfa0c 100644 --- a/presets/PUBLISHING.md +++ b/presets/PUBLISHING.md @@ -68,6 +68,8 @@ preset: requires: speckit_version: ">=0.1.0" # Required spec-kit version + extensions: # Optional: extensions this preset needs + - "companion-extension" provides: templates: @@ -93,6 +95,41 @@ tags: # 2-5 relevant tags - ✅ Command names use dot notation (e.g. `speckit.specify`) - ✅ Tags are lowercase and descriptive +#### Declaring extension dependencies + +If your preset overrides commands that call into an extension, declare it in +`requires.extensions`. Without the extension the preset still installs and the +overrides fall through to the core workflow, so nothing errors — the feature +just silently does less than the user expects. Declaring the dependency makes +`specify preset add` say so, and spell out how to resolve it. + +Use a bare id, or a mapping when you need a version constraint or an optional +dependency: + +```yaml +requires: + speckit_version: ">=0.9.0" + extensions: + - "companion-extension" # required, any version + - id: "other-extension" + version: ">=1.2.0,<2" # optional PEP 440 specifier + required: false # optional, defaults to true +``` + +`version` accepts any PEP 440 specifier, not just a lower bound — upper bounds +(`<2`), exact pins (`==1.2.0`), and exclusions (`!=1.3.0`) all work. + +Notes: + +- The field is optional. A preset that declares nothing behaves exactly as before. +- A dependency that is missing, stale, disabled, or version-unsatisfied produces a **warning, not a failure** — the install still succeeds. +- A disabled extension counts as unmet, and so does one whose registry entry survives after its files were removed. Resolution skips both, so the preset is just as inert as if the extension were absent. +- The warning names an exact command for the missing, stale, and disabled cases. For a version mismatch it states the constraint to satisfy rather than naming a command, because `specify extension update` only moves forward to the catalog release and cannot satisfy an upper bound, a pin, or a downgrade. +- A recorded version that cannot be parsed is treated as uncomparable rather than as a mismatch, so an extension whose registry version reads `unknown` is not reported as failing a constraint it was never evaluated against. +- An extension present on disk but absent from the registry counts as satisfied. Resolution admits unregistered directories, so the preset works and warning about it would be a false alarm — though with no recorded version, a `version` constraint cannot be checked against it. +- `required: false` documents an enhancing-but-optional extension and is never warned about. +- Declare it in `preset.yml`, not only in your catalog entry. The catalog is not consulted for `--dev` and `--from ` installs, so the manifest is the only copy present on every install path. + ### 3. Test Locally ```bash diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 788a5d78c5..3f608378a0 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-17T00:00:00Z", + "updated_at": "2026-08-26T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -120,31 +120,31 @@ "autonomous-run-governance": { "name": "Autonomous Run Governance", "id": "autonomous-run-governance", - "version": "0.3.3", - "description": "Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract.", + "version": "0.4.1", + "description": "Adds permission-bounded autonomous delivery with validated delivery sets, semantic phase completion, and lifecycle-bound exact-head evidence.", "author": "Thorsten Hindermann", "repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance", - "download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.3.zip", + "download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.4.1.zip", "homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance", - "documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.3/README.md", + "documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.4.1/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.8.3" }, "provides": { - "templates": 13, + "templates": 15, "commands": 5, - "scripts": 4 + "scripts": 11 }, "tags": [ "autonomous", "governance", "evidence", "permissions", - "accessibility" + "sdd" ], "created_at": "2026-07-13T00:00:00Z", - "updated_at": "2026-07-28T00:00:00Z" + "updated_at": "2026-08-19T00:00:00Z" }, "canon-core": { "name": "Canon Core", @@ -197,6 +197,60 @@ "created_at": "2026-04-13T00:00:00Z", "updated_at": "2026-04-13T00:00:00Z" }, + "closed-vocabulary": { + "name": "Closed Vocabulary Check", + "id": "closed-vocabulary", + "version": "1.0.1", + "description": "Adds a pass to /speckit.analyze that flags closed sets of values enumerated more than once with different members, and reports its own coverage.", + "author": "Diego Gabriel Impieri", + "repository": "https://github.com/yunusdim/spec-kit-preset-closed-vocabulary", + "download_url": "https://github.com/yunusdim/spec-kit-preset-closed-vocabulary/archive/refs/tags/v1.0.1.zip", + "homepage": "https://github.com/yunusdim/spec-kit-preset-closed-vocabulary", + "documentation": "https://github.com/yunusdim/spec-kit-preset-closed-vocabulary/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 0, + "commands": 1 + }, + "tags": [ + "analysis", + "consistency", + "vocabulary", + "verification" + ], + "created_at": "2026-08-19T00:00:00Z", + "updated_at": "2026-08-19T00:00:00Z" + }, + "codebase-memory-context": { + "name": "Verified Codebase Context", + "id": "codebase-memory-context", + "version": "1.0.1", + "description": "Generates evidence-qualified repository context with codebase-memory-mcp and applies it across planning, tasks, analysis, and implementation.", + "author": "Xu Yin (philo-x)", + "repository": "https://github.com/philo-x/spec-kit-preset-codebase-memory-context", + "download_url": "https://github.com/philo-x/spec-kit-preset-codebase-memory-context/archive/refs/tags/v1.0.1.zip", + "homepage": "https://github.com/philo-x/spec-kit-preset-codebase-memory-context", + "documentation": "https://github.com/philo-x/spec-kit-preset-codebase-memory-context/blob/v1.0.1/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=1.0.1" + }, + "provides": { + "templates": 1, + "commands": 5 + }, + "tags": [ + "code-intelligence", + "codebase-memory", + "architecture", + "workflow" + ], + "created_at": "2026-08-26T00:00:00Z", + "updated_at": "2026-08-26T00:00:00Z" + }, "command-density": { "name": "Command Density", "id": "command-density", @@ -405,19 +459,19 @@ "intake-sequencing-governance": { "name": "Intake Sequencing Governance", "id": "intake-sequencing-governance", - "version": "0.2.2", + "version": "0.2.3", "description": "Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection.", "author": "Thorsten Hindermann", "repository": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance", - "download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.2.2.zip", + "download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.2.3.zip", "homepage": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance", - "documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.2.2/README.md", + "documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.2.3/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.8.3" }, "provides": { - "templates": 11, + "templates": 12, "commands": 6, "scripts": 8 }, @@ -426,10 +480,42 @@ "sequencing", "governance", "dag", - "lifecycle" + "model-routing" ], "created_at": "2026-07-27T00:00:00Z", - "updated_at": "2026-07-28T00:00:00Z" + "updated_at": "2026-08-20T00:00:00Z" + }, + "inventory-alignment": { + "name": "Inventory Alignment", + "id": "inventory-alignment", + "version": "0.1.0", + "description": "Classifies each requirement against a read-only inventory of live IDs before writing, so reworded requirements are updated instead of duplicated.", + "author": "Yash Chindam", + "repository": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment", + "download_url": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment/releases/download/v0.1.0/inventory-alignment.zip", + "sha256": "8ea62813aeb88d85001f54d91d8eceb011f5fb872bc764d5ea83e8e7ab92a2c1", + "homepage": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment", + "documentation": "https://github.com/Yash-Chindam/spec-kit-inventory-alignment/blob/main/inventory-alignment/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.9.0", + "extensions": [ + "speckit-inventory" + ] + }, + "provides": { + "templates": 1, + "commands": 2 + }, + "tags": [ + "inventory", + "alignment", + "requirements", + "traceability", + "workflow" + ], + "created_at": "2026-08-20T00:00:00Z", + "updated_at": "2026-08-20T00:00:00Z" }, "isaqb-architecture-governance": { "name": "iSAQB Architecture Governance", @@ -576,19 +662,19 @@ "parallel-autonomous-run-governance": { "name": "Parallel Autonomous Run Governance", "id": "parallel-autonomous-run-governance", - "version": "0.2.4", - "description": "Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation.", + "version": "0.2.6", + "description": "Coordinates isolated autonomous campaigns and optionally gates worker scheduling on a current campaign intake review.", "author": "Thorsten Hindermann", "repository": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance", - "download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.4.zip", + "download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.6.zip", "homepage": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance", - "documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.4/README.md", + "documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.6/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.8.3" }, "provides": { - "templates": 9, + "templates": 10, "commands": 5, "scripts": 2 }, @@ -596,11 +682,11 @@ "parallel", "autonomous", "governance", - "accessibility", - "orchestration" + "orchestration", + "model-routing" ], "created_at": "2026-07-22T00:00:00Z", - "updated_at": "2026-07-28T00:00:00Z" + "updated_at": "2026-08-24T00:00:00Z" }, "pirate": { "name": "Pirate Speak (Full)", @@ -750,11 +836,11 @@ "specassay": { "name": "SpecAssay", "id": "specassay", - "version": "0.3.4", + "version": "0.4.12", "description": "Appends durable-ID, Carries, and SpecAssay vocabulary onto Spec Kit spec, tasks, and constitution templates.", "author": "Rik Dryfoos", "repository": "https://github.com/rdryfoos/specassay", - "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.3.4/specassay-preset-0.3.4.zip", + "download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.4.12/specassay-preset-0.4.12.zip", "homepage": "https://github.com/rdryfoos/specassay", "documentation": "https://github.com/rdryfoos/specassay/blob/main/presets/specassay/README.md", "license": "MIT", @@ -772,7 +858,7 @@ "sdd" ], "created_at": "2026-08-14T00:00:00Z", - "updated_at": "2026-08-14T00:00:00Z" + "updated_at": "2026-08-21T00:00:00Z" }, "test-first-governance": { "name": "Test-First Governance", diff --git a/pyproject.toml b/pyproject.toml index 647fe83224..9adede0838 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.16.5" +version = "1.0.4" 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" diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index c21edc41f0..7d6dba1353 100755 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -9,6 +9,7 @@ # # OPTIONS: # --json Output in JSON format +# --require-spec Require spec.md to exist (for analysis phase) # --require-tasks Require tasks.md to exist (for implementation phase) # --include-tasks Include tasks.md in AVAILABLE_DOCS list # --paths-only Only output path variables (no validation) @@ -24,6 +25,7 @@ set -e # Parse command line arguments JSON_MODE=false +REQUIRE_SPEC=false REQUIRE_TASKS=false INCLUDE_TASKS=false PATHS_ONLY=false @@ -34,6 +36,9 @@ while [[ $# -gt 0 ]]; do --json) JSON_MODE=true ;; + --require-spec) + REQUIRE_SPEC=true + ;; --require-tasks) REQUIRE_TASKS=true ;; @@ -59,6 +64,7 @@ Consolidated prerequisite checking for Spec-Driven Development workflow. OPTIONS: --json Output in JSON format + --require-spec Require spec.md to exist (for analysis phase) --require-tasks Require tasks.md to exist (for implementation phase) --include-tasks Include tasks.md in AVAILABLE_DOCS list --paths-only Only output path variables (no prerequisite validation) @@ -142,6 +148,13 @@ if [[ ! -f "$IMPL_PLAN" ]]; then exit 1 fi +# Check for spec.md if required +if $REQUIRE_SPEC && [[ ! -f "$FEATURE_SPEC" ]]; then + echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 + echo "Run $(format_speckit_command specify "$REPO_ROOT") first to create the feature specification." >&2 + exit 1 +fi + # Check for tasks.md if required if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 33f90b8dbb..9efcfad5e6 100755 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -902,12 +902,20 @@ except Exception as exc: *'{CORE_TEMPLATE}'*) ;; *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 2 ;; esac - while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do - local before="${layer_content%%\{CORE_TEMPLATE\}*}" - local after="${layer_content#*\{CORE_TEMPLATE\}}" - layer_content="${before}${content}${after}" + # Consume the wrapper left to right instead of rewriting it in + # place. Rewriting re-scanned the string just modified, so base + # content holding a literal {CORE_TEMPLATE} reintroduced the + # token every pass and the loop never terminated. Advancing over + # ``rest`` bounds the work by the tokens in the original wrapper + # and leaves inserted content untouched, matching the single-pass + # semantics of .Replace()/.replace() in the PowerShell and Python + # ports. + local wrapped="" rest="$layer_content" + while [[ "$rest" == *'{CORE_TEMPLATE}'* ]]; do + wrapped="${wrapped}${rest%%\{CORE_TEMPLATE\}*}${content}" + rest="${rest#*\{CORE_TEMPLATE\}}" done - content="$layer_content" + content="${wrapped}${rest}" ;; *) echo "Error: unknown strategy '$strat'" >&2; return 2 ;; esac diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index 03eaf713b0..f3edb3d9f8 100755 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -4,7 +4,6 @@ set -e # Parse command line arguments JSON_MODE=false -ARGS=() for arg in "$@"; do case "$arg" in @@ -18,7 +17,8 @@ for arg in "$@"; do exit 0 ;; *) - ARGS+=("$arg") + echo "ERROR: Unknown option '$arg'" >&2 + exit 1 ;; esac done diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index 27c87d6c69..0e9434d063 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -9,6 +9,7 @@ # # OPTIONS: # -Json Output in JSON format +# -RequireSpec Require spec.md to exist (for analysis phase) # -RequireTasks Require tasks.md to exist (for implementation phase) # -IncludeTasks Include tasks.md in AVAILABLE_DOCS list # -PathsOnly Only output path variables (no validation) @@ -18,6 +19,7 @@ [CmdletBinding()] param( [switch]$Json, + [switch]$RequireSpec, [switch]$RequireTasks, [switch]$IncludeTasks, [switch]$PathsOnly, @@ -36,6 +38,7 @@ Consolidated prerequisite checking for Spec-Driven Development workflow. OPTIONS: -Json Output in JSON format + -RequireSpec Require spec.md to exist (for analysis phase) -RequireTasks Require tasks.md to exist (for implementation phase) -IncludeTasks Include tasks.md in AVAILABLE_DOCS list -PathsOnly Only output path variables (no prerequisite validation) @@ -105,6 +108,14 @@ if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { exit 1 } +# Check for spec.md if required +if ($RequireSpec -and -not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) { + [Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)") + $specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT + [Console]::Error.WriteLine("Run $specifyCommand first to create the feature specification.") + exit 1 +} + # Check for tasks.md if required if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) { [Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)") diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 585e884702..bb61f623bb 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -135,7 +135,7 @@ function Save-FeatureJson { # Read current value and skip write when unchanged if (Test-Path -LiteralPath $fjPath -PathType Leaf) { try { - $raw = Get-Content -LiteralPath $fjPath -Raw + $raw = [System.IO.File]::ReadAllText($fjPath, [System.Text.Encoding]::UTF8) $cfg = $raw | ConvertFrom-Json if ($cfg.feature_directory -eq $FeatureDirectory) { return @@ -187,7 +187,7 @@ function Get-FeaturePathsEnv { Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY } } elseif (Test-Path $featureJson) { - $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw + $featureJsonRaw = [System.IO.File]::ReadAllText($featureJson, [System.Text.Encoding]::UTF8) try { $featureConfig = $featureJsonRaw | ConvertFrom-Json } catch { diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index e7a68c4076..216cca5e20 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -162,7 +162,14 @@ function Get-BranchName { } else { # Fallback to original logic if no meaningful words found $result = ConvertTo-CleanBranchName -Name $Description - $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 + # @() keeps this an array. ConvertTo-CleanBranchName blanks every + # non-[a-z0-9] character, so a description written in a non-Latin script + # (or made only of punctuation) leaves nothing for the pipeline to + # emit -- it yields $null, and [string]::Join on $null throws + # ArgumentNullException. With $ErrorActionPreference = 'Stop' that is + # terminating, so the script died with a .NET stack trace and exit 1 + # where the bash and Python twins both return an empty suffix. + $fallbackWords = @(($result -split '-') | Where-Object { $_ } | Select-Object -First 3) return [string]::Join('-', $fallbackWords) } } diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index 52f615aaad..300582d5eb 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -20,6 +20,11 @@ if ($Help) { exit 0 } +if ($RemainingArgs.Count -gt 0) { + [Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'") + exit 1 +} + # Load common functions . "$PSScriptRoot/common.ps1" diff --git a/scripts/python/check_prerequisites.py b/scripts/python/check_prerequisites.py index a5dc3e7e39..e025b4d672 100644 --- a/scripts/python/check_prerequisites.py +++ b/scripts/python/check_prerequisites.py @@ -37,6 +37,7 @@ def _json_line(payload: object) -> str: OPTIONS: --json Output in JSON format + --require-spec Require spec.md to exist (for analysis phase) --require-tasks Require tasks.md to exist (for implementation phase) --include-tasks Include tasks.md in AVAILABLE_DOCS list --paths-only Only output path variables (no prerequisite validation) @@ -59,6 +60,7 @@ def _json_line(payload: object) -> str: @dataclass(frozen=True) class Args: json_mode: bool = False + require_spec: bool = False require_tasks: bool = False include_tasks: bool = False paths_only: bool = False @@ -67,6 +69,7 @@ class Args: def _parse_args(argv: list[str]) -> Args: json_mode = False + require_spec = False require_tasks = False include_tasks = False paths_only = False @@ -77,6 +80,8 @@ def _parse_args(argv: list[str]) -> Args: arg = argv[index] if arg == "--json": json_mode = True + elif arg == "--require-spec": + require_spec = True elif arg == "--require-tasks": require_tasks = True elif arg == "--include-tasks": @@ -105,6 +110,7 @@ def _parse_args(argv: list[str]) -> Args: return Args( json_mode=json_mode, + require_spec=require_spec, require_tasks=require_tasks, include_tasks=include_tasks, paths_only=paths_only, @@ -230,6 +236,14 @@ def main(argv: list[str] | None = None) -> int: ) return 1 + if args.require_spec and not paths.feature_spec.is_file(): + print(f"ERROR: spec.md not found in {paths.feature_dir}", file=sys.stderr) + print( + f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature specification.", + file=sys.stderr, + ) + return 1 + if args.require_tasks and not paths.tasks.is_file(): print(f"ERROR: tasks.md not found in {paths.feature_dir}", file=sys.stderr) print( diff --git a/scripts/python/setup_plan.py b/scripts/python/setup_plan.py index d25fdd7829..3b8acc4fd4 100644 --- a/scripts/python/setup_plan.py +++ b/scripts/python/setup_plan.py @@ -42,7 +42,9 @@ def main(argv: list[str] | None = None) -> int: elif arg in {"--help", "-h"}: sys.stdout.write(_help_text(sys.argv[0])) return 0 - # Other arguments are accepted and silently ignored, matching setup-plan.sh. + else: + print(f"ERROR: Unknown option '{arg}'", file=sys.stderr) + return 1 try: paths = get_feature_paths(script_file=Path(__file__)) diff --git a/specs/metadata.json b/specs/metadata.json index aee7f0581d..9e9ca01024 100644 --- a/specs/metadata.json +++ b/specs/metadata.json @@ -1,7 +1,7 @@ { "name": "spec-kit", - "version": "0.16.5", - "fork_version": "satware-v0.16.5", + "version": "1.0.4", + "fork_version": "satware-v1.0.4", "sdd_source": "https://github.com/satwareAG/spec-kit", "forge": "github", "visibility": "public", @@ -77,5 +77,5 @@ ], "symlink_script": "$SATWARE_HARNESS/scripts/env-setup-symlinks.sh" }, - "project_version": "0.16.5" + "project_version": "1.0.4" } diff --git a/src/specify_cli/_github_http.py b/src/specify_cli/_github_http.py index 017f50b5d1..8734528481 100644 --- a/src/specify_cli/_github_http.py +++ b/src/specify_cli/_github_http.py @@ -39,6 +39,7 @@ def build_github_request(url: str) -> urllib.request.Request: ValueError: If ``url`` is empty or whitespace-only. ValueError: If ``url`` does not use the ``http`` or ``https`` scheme. ValueError: If ``url`` does not include a hostname. + ValueError: If ``url`` includes a malformed explicit port. """ headers: Dict[str, str] = {} url = url.strip() @@ -49,6 +50,8 @@ def build_github_request(url: str) -> urllib.request.Request: raise ValueError(f"url must start with http:// or https://, got: {url!r}") if not parsed.hostname: raise ValueError(f"url must include a hostname, got: {url!r}") + # Accessing ``port`` validates any explicit port before request construction. + parsed.port github_token = (os.environ.get("GITHUB_TOKEN") or "").strip() gh_token = (os.environ.get("GH_TOKEN") or "").strip() token = github_token or gh_token or None diff --git a/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py index 5cc7098837..3233a6bab4 100644 --- a/src/specify_cli/_invocation_style.py +++ b/src/specify_cli/_invocation_style.py @@ -12,7 +12,9 @@ DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode", "command-code"}) # Agents that always render /speckit-, regardless of ai_skills. -ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "droid", "grok", "trae", "zed"}) +ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset( + {"devin", "droid", "dsh", "grok", "qodercli", "trae", "zed"} +) # Agents that render /speckit- only when ai_skills is enabled. CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset( diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index f2364f6d43..300ca4ff58 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -16,6 +16,45 @@ CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" +DOCKER_AGENT_CHECK_TIMEOUT = 5 + + +def docker_agent_command(executable: str | None = None) -> list[str] | None: + """Return a runnable Docker Agent command, or ``None`` if unavailable. + + Docker Agent is distributed either as the standalone ``docker-agent`` + executable or as the ``docker agent`` Docker CLI plugin. The plugin form + is verified with a bounded, read-only version probe so a plain Docker CLI + is not mistaken for an installed Docker Agent. + """ + resolved_from_path = executable is None + if executable is None: + if shutil.which("docker-agent"): + return ["docker-agent", "run"] + executable = shutil.which("docker") + if executable is None: + return None + + executable_name = Path(executable).name.lower() + if executable_name in {"docker", "docker.exe"}: + command = [executable, "agent", "version"] + run_command = [executable, "agent", "run"] + else: + # An explicit non-Docker executable is an operator override. Preserve + # the existing override contract without probing a custom binary. + return [executable, "run"] + try: + result = subprocess.run( + command, + capture_output=True, + check=False, + timeout=DOCKER_AGENT_CHECK_TIMEOUT, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + return ["docker", "agent", "run"] if resolved_from_path else run_command def relative_extension_path_violation(value: Any) -> str | None: @@ -137,6 +176,8 @@ def check_tool(tool: str, tracker=None) -> bool: found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None elif tool == "rovodev": found = shutil.which("acli") is not None + elif tool == "docker-agent": + found = docker_agent_command() is not None else: found = shutil.which(tool) is not None @@ -249,7 +290,7 @@ def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = Fals except FileNotFoundError: # Handle race condition where file is deleted after exists() check exists = False - except Exception as e: + except (OSError, ValueError) as e: if verbose: console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]") # Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError) diff --git a/src/specify_cli/authentication/config.py b/src/specify_cli/authentication/config.py index 9f19fbc522..95b8ff99b4 100644 --- a/src/specify_cli/authentication/config.py +++ b/src/specify_cli/authentication/config.py @@ -112,7 +112,10 @@ def load_auth_config( except OSError: pass # stat failed — skip permission check - raw = json.loads(config_path.read_text(encoding="utf-8")) + try: + raw = json.loads(config_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"{config_path} contains invalid JSON: {exc}") from exc if not isinstance(raw, dict): raise ValueError(f"auth.json must be a JSON object, got {type(raw).__name__}") @@ -221,12 +224,14 @@ def find_entries_for_url( ) -> list[AuthConfigEntry]: """Return entries whose ``hosts`` match the hostname of *url*.""" # A malformed authority (e.g. an unterminated IPv6 bracket "https://[::1") - # makes urlparse/hostname raise ValueError. Treat that the same as a + # makes urlparse, hostname, or port raise ValueError. Treat that the same as a # host-less URL: no entry can match, so return no matches rather than # leaking a raw ValueError out of the shared HTTP client (build_request / # open_url call this before any URL validation). try: - hostname = (urlparse(url).hostname or "").lower() + parsed = urlparse(url) + hostname = (parsed.hostname or "").lower() + _ = parsed.port except ValueError: return [] if not hostname: diff --git a/src/specify_cli/bundler/models/catalog.py b/src/specify_cli/bundler/models/catalog.py index 53e83a52e7..2ef882d576 100644 --- a/src/specify_cli/bundler/models/catalog.py +++ b/src/specify_cli/bundler/models/catalog.py @@ -20,6 +20,7 @@ # reject an unsupported major version so a file written by a newer/incompatible # Spec Kit fails fast instead of being parsed under the wrong assumptions. CONFIG_SCHEMA_VERSION = "1.0" +CATALOG_SCHEMA_VERSION = "1.0" class InstallPolicy(str, Enum): @@ -106,10 +107,11 @@ def to_dict(self) -> dict[str, Any]: def _parse_tags(value: Any, entry_id: str) -> tuple[str, ...]: - """Coerce a catalog entry's ``tags`` into a tuple of strings. + """Parse a catalog entry's ``tags`` into a tuple of strings. Catalogs are untrusted input: a bare string would otherwise be iterated - character-by-character, so reject anything that is not a list/tuple. + character-by-character, so reject anything that is not a list/tuple, and + reject any non-string member instead of silently coercing it. """ if value is None: return () @@ -117,7 +119,11 @@ def _parse_tags(value: Any, entry_id: str) -> tuple[str, ...]: raise BundlerError( f"Catalog entry '{entry_id}': 'tags' must be a list of strings." ) - return tuple(str(t) for t in value) + if any(not isinstance(item, str) for item in value): + raise BundlerError( + f"Catalog entry '{entry_id}': 'tags' must be a list of strings." + ) + return tuple(value) def _parse_verified(value: Any, entry_id: str) -> bool: @@ -215,6 +221,16 @@ def load_catalog_payload(data: Any) -> dict[str, CatalogEntry]: """Parse a catalog JSON payload into ``{bundle_id: CatalogEntry}``.""" if not isinstance(data, dict): raise BundlerError("Catalog payload must be a JSON object.") + schema_version = data.get("schema_version") + if schema_version is not None and ( + str(schema_version).strip().split(".")[0] + != CATALOG_SCHEMA_VERSION.split(".")[0] + ): + raise BundlerError( + f"Unsupported catalog schema version " + f"'{str(schema_version).strip()}'; this Spec Kit understands " + f"version {CATALOG_SCHEMA_VERSION}." + ) bundles_raw = data.get("bundles") if not isinstance(bundles_raw, dict): raise BundlerError("Catalog payload is missing a 'bundles' object.") diff --git a/src/specify_cli/bundler/models/manifest.py b/src/specify_cli/bundler/models/manifest.py index 032863a2e8..39684b2327 100644 --- a/src/specify_cli/bundler/models/manifest.py +++ b/src/specify_cli/bundler/models/manifest.py @@ -237,17 +237,19 @@ def _text(raw: Any) -> str: def _parse_str_list(raw: Any, field_name: str) -> tuple[str, ...]: - """Coerce a manifest list-of-strings field into a tuple of strings. + """Parse a manifest list-of-strings field into a tuple of strings. Rejects a bare string/bytes (which would otherwise be iterated - character-by-character) and any non-list/tuple, matching the manifest - contract (``string[]``). + character-by-character), any non-list/tuple, and any non-string member, + matching the manifest contract (``string[]``). """ if raw is None: return () if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)): raise BundlerError(f"'{field_name}' must be a list of strings when present.") - return tuple(str(item) for item in raw) + if any(not isinstance(item, str) for item in raw): + raise BundlerError(f"'{field_name}' must be a list of strings when present.") + return tuple(raw) def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]: diff --git a/src/specify_cli/bundler/models/records.py b/src/specify_cli/bundler/models/records.py index 2d0c8b73a0..748b23759a 100644 --- a/src/specify_cli/bundler/models/records.py +++ b/src/specify_cli/bundler/models/records.py @@ -13,7 +13,7 @@ from .. import BundlerError from ..lib.yamlio import dump_json, ensure_within, load_json -from .manifest import COMPONENT_KINDS, ComponentRef +from .manifest import COMPONENT_KINDS, ComponentRef, _text RECORDS_FILENAME = "bundle-records.json" RECORDS_SCHEMA_VERSION = "1.0" @@ -65,8 +65,14 @@ def from_dict(cls, data: Any) -> "InstalledBundleRecord": raise BundlerError( "Corrupt record: 'contributed_components' must be a list." ) - bundle_id = str(data.get("bundle_id", "")).strip() - version = str(data.get("version", "")).strip() + # ``.get(key, "")`` defaults only a *missing* key. A key that is + # present but null -- how a hand-edited or corrupt record spells an + # empty field -- yields ``None``, and ``str(None)`` is the non-empty + # literal ``"None"``, which sails past the required-field checks + # below. Reuse the manifest's ``_text`` so records and bundle.yml + # agree on what an explicit null means. + bundle_id = _text(data.get("bundle_id")) + version = _text(data.get("version")) if not bundle_id: raise BundlerError( "Corrupt records file: an installed-bundle record is missing " @@ -80,7 +86,7 @@ def from_dict(cls, data: Any) -> "InstalledBundleRecord": return cls( bundle_id=bundle_id, version=version, - installed_at=str(data.get("installed_at", "")).strip(), + installed_at=_text(data.get("installed_at")), contributed_components=tuple( _component_from_dict(c) for c in components_raw ), @@ -201,8 +207,8 @@ def _component_to_dict(ref: ComponentRef) -> dict[str, Any]: def _component_from_dict(data: Any) -> ComponentRef: if not isinstance(data, dict): raise BundlerError("Each contributed component must be a mapping.") - kind = str(data.get("kind", "")).strip() - cid = str(data.get("id", "")).strip() + kind = _text(data.get("kind")) + cid = _text(data.get("id")) if kind not in COMPONENT_KINDS: raise BundlerError( f"Corrupt records file: component 'kind' must be one of " diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundler/services/primitives.py index 31b1126a34..01fa14769e 100644 --- a/src/specify_cli/bundler/services/primitives.py +++ b/src/specify_cli/bundler/services/primitives.py @@ -263,9 +263,10 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: component.version, _bundled_manifest_version(bundled / "extension.yml", "extension"), ) - self._manager.install_from_directory( + manifest = self._manager.install_from_directory( bundled, speckit_version, priority=priority, force=force ) + self._manager.scaffold_config(manifest.id) return if not self._allow_network: @@ -293,9 +294,10 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: ) zip_path = catalog.download_extension(component.id) try: - self._manager.install_from_zip( + manifest = self._manager.install_from_zip( zip_path, speckit_version, priority=priority, force=force ) + self._manager.scaffold_config(manifest.id) finally: with contextlib.suppress(Exception): if zip_path.exists(): @@ -337,7 +339,7 @@ def install(self, component: ComponentRef) -> None: with _chdir(self._root): _delegate_command( "install", f"workflow '{component.id}'", - lambda: workflow_add(component.id), + lambda: workflow_add(component.id, dev=False, from_url=None), ) def refresh(self, component: ComponentRef) -> None: diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 1edbeef2ca..165f674a36 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -934,7 +934,6 @@ def _download_remote_manifest( expected_sha256: str | None = None, ): """Fetch a remote bundle artifact over HTTPS and extract its manifest.""" - import io import tempfile from pathlib import PurePosixPath from urllib.parse import urlparse as _urlparse @@ -1038,7 +1037,20 @@ def _validate_redirect(old_url: str, new_url: str) -> None: ) return manifest - data = _yaml.safe_load(io.BytesIO(raw)) + # Decode as UTF-8 explicitly -- matching yamlio.load_yaml's contract -- + # instead of feeding PyYAML the raw byte stream. PyYAML's Reader + # auto-detects a UTF-16 BOM and would silently *accept* a manifest + # that the local directory/bundle.yml sources reject, letting this + # remote-download path diverge from them (see the sibling .zip fix + # for _local_manifest_source, which had the identical bug). + try: + text = raw.decode("utf-8") + except UnicodeError as exc: + raise BundlerError( + f"Downloaded content for bundle '{entry_id}' from " + f"{_source_desc} could not be read: {exc}" + ) from exc + data = _yaml.safe_load(text) return BundleManifest.from_dict(data) except BundlerError: raise diff --git a/src/specify_cli/commands/event.py b/src/specify_cli/commands/event.py index d1576c2c70..47bcf86e70 100644 --- a/src/specify_cli/commands/event.py +++ b/src/specify_cli/commands/event.py @@ -27,14 +27,25 @@ def event_run( # Read payload from stdin if available (capped at 1 MiB to prevent DoS). MAX_STDIN_BYTES = 1 * 1024 * 1024 if not sys.stdin.isatty(): - raw = sys.stdin.read(MAX_STDIN_BYTES) - if not sys.stdin.eof: - raise typer.Exit( - code=1, - message="stdin payload exceeds 1 MiB limit; " + # Read from the underlying binary buffer so the cap counts encoded + # bytes, not decoded characters — `sys.stdin.read()` on a text stream + # counts Unicode characters, which lets multibyte payloads (e.g. a + # few hundred thousand emoji) exceed 1 MiB on the wire while still + # passing the length check. Reading one byte past the cap tells us + # whether more data was waiting beyond it. + raw = sys.stdin.buffer.read(MAX_STDIN_BYTES + 1) + if len(raw) > MAX_STDIN_BYTES: + typer.echo( + "stdin payload exceeds 1 MiB limit; " "truncate or pipe a smaller payload", + err=True, ) - payload = raw + raise typer.Exit(code=1) + try: + payload = raw.decode("utf-8") + except UnicodeDecodeError: + typer.echo("stdin payload must be valid UTF-8", err=True) + raise typer.Exit(code=1) from None else: payload = "{}" diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 4af9427bfa..2f686e2fa9 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -1013,6 +1013,7 @@ def init( devin_skill_mode = selected_ai == "devin" zed_skill_mode = selected_ai == "zed" and _is_skills_integration grok_skill_mode = selected_ai == "grok" and _is_skills_integration + dsh_skill_mode = selected_ai == "dsh" and _is_skills_integration cline_skill_mode = selected_ai == "cline" forge_skill_mode = selected_ai == "forge" bob_skill_mode = selected_ai == "bob" and _is_skills_integration @@ -1028,6 +1029,7 @@ def init( or devin_skill_mode or zed_skill_mode or grok_skill_mode + or dsh_skill_mode or bob_skill_mode ) @@ -1066,6 +1068,11 @@ def init( f"{step_num}. Start Grok Build in this project directory; spec-kit skills were installed to [cyan].grok/skills[/cyan]" ) step_num += 1 + if dsh_skill_mode: + steps_lines.append( + f"{step_num}. Start DSH ([cyan]dsh web[/cyan]) in this project directory; spec-kit skills were installed to [cyan].dsh/skills[/cyan]" + ) + step_num += 1 if bob_skill_mode: steps_lines.append( f"{step_num}. Start Bob in this project directory; spec-kit skills were installed to [cyan].bob/skills[/cyan]" diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 83da04d4fb..ba0a4f6363 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -677,7 +677,16 @@ def _resolve_event_command_argv( # subprocess.run(shell=False); invoke via `pwsh -File` (PowerShell 7+), # falling back to `powershell -File` (Windows PowerShell) when pwsh is # absent (S6). The default Windows script type would otherwise fail. - launcher = shutil.which("pwsh") or shutil.which("powershell") or "pwsh" + # When NEITHER is on PATH, degrade to "no argv" like every other + # failure branch in this resolver (and its documented stdlib mirror, + # the generated dispatcher's `_resolve_argv`) — a bare "pwsh" here + # would make subprocess.run() raise FileNotFoundError, surfacing as a + # confusing "[Errno 2] No such file or directory: 'pwsh'" instead of + # the clean "No script found for event command" the caller reports + # for a genuinely missing script. + launcher = shutil.which("pwsh") or shutil.which("powershell") + if not launcher: + return None return [launcher, "-File", str(script_abs), *rest_args] # sh: the script is chmod'd executable during install on POSIX. On Windows @@ -1082,7 +1091,7 @@ def collect_extension_events(project_root: Path) -> ResolvedEvents: continue try: data = yaml.safe_load(ext_yml.read_text(encoding="utf-8")) or {} - except (UnicodeDecodeError, yaml.YAMLError): + except (OSError, UnicodeDecodeError, yaml.YAMLError): continue if not isinstance(data, dict): continue diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index fb4a30519d..3968e4fcbe 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3100,6 +3100,76 @@ def unregister_agent_artifacts( if updates: self.registry.update(ext_id, updates) + def _retire_legacy_flat_extension_commands( + self, + agent_name: str, + command_names: List[str], + ) -> List[Path]: + """Remove old flat commands whose replacement skills were written.""" + from ..agents import CommandRegistrar + from ..integrations import get_integration + + integration = get_integration(agent_name) + legacy_dir = getattr(integration, "legacy_flat_command_dir", None) + legacy_extension = getattr( + integration, "legacy_flat_command_extension", None + ) + if ( + not isinstance(legacy_dir, str) + or not legacy_dir + or not isinstance(legacy_extension, str) + or not legacy_extension + ): + return [] + + registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if not agent_config or agent_config.get("extension") != "/SKILL.md": + return [] + + def safe_project_dir(relative: str) -> Optional[Path]: + rel = Path(relative) + if rel.is_absolute() or ".." in rel.parts: + return None + current = self.project_root + for part in rel.parts: + current /= part + if current.is_symlink(): + return None + try: + current.resolve().relative_to(self.project_root.resolve()) + except (OSError, ValueError): + return None + return current + + legacy_root = safe_project_dir(legacy_dir) + skills_root = safe_project_dir(str(agent_config.get("dir", ""))) + if legacy_root is None or skills_root is None or not legacy_root.is_dir(): + return [] + + removed: List[Path] = [] + for command_name in command_names: + if ( + not isinstance(command_name, str) + or not command_name + or not registrar._is_safe_command_name(command_name) + ): + continue + + skill_name = registrar._compute_output_name( + agent_name, command_name, agent_config + ) + replacement = skills_root / skill_name / "SKILL.md" + if replacement.is_symlink() or not replacement.is_file(): + continue + + legacy_file = legacy_root / f"{command_name}{legacy_extension}" + if legacy_file.is_symlink() or legacy_file.is_file(): + legacy_file.unlink() + removed.append(legacy_file) + + return removed + def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None: """Register installed, enabled extensions for ``agent_name``. @@ -3160,6 +3230,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool # registration of the remaining enabled extensions for this agent. try: updates: Dict[str, Any] = {} + registered: List[str] = [] # Set when a command -> skills toggle for this same agent # defers stale command-mode cleanup until the skills # replacement below confirms success (#2948). @@ -3380,6 +3451,12 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool if new_registered != registered_commands: updates["registered_commands"] = new_registered + if registered: + self._retire_legacy_flat_extension_commands( + agent_name, + registered, + ) + if updates: self.registry.update(ext_id, updates) except Exception as ext_err: diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 7f7933e934..11ab50385e 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -15,9 +15,12 @@ import stat import tempfile from pathlib import Path -from typing import Optional +from typing import Optional, TYPE_CHECKING from uuid import uuid4 +if TYPE_CHECKING: + from packaging.version import Version + import typer import yaml from rich.markup import escape as _escape_markup @@ -106,6 +109,58 @@ def _command_safe_id(raw_id: object, placeholder: str = "") -> str return placeholder +def _bundled_update_source(ext_id: str) -> tuple[Path, Version] | tuple[None, None]: + """Locate the local bundled copy of *ext_id* and its parsed version. + + Bundled extensions have no download URL, so an update can only come + from the copy shipped with the running spec-kit release — which may + lag the version the catalog on main advertises. Returns + ``(path, Version)`` when a valid local copy exists, ``(None, None)`` + otherwise. + """ + from . import ExtensionManifest, ValidationError + from packaging import version as pkg_version + + bundled_dir = _locate_bundled_extension(ext_id) + if bundled_dir is None: + return None, None + try: + manifest = ExtensionManifest(bundled_dir / "extension.yml") + return bundled_dir, pkg_version.Version(manifest.version) + except (ValidationError, pkg_version.InvalidVersion, OSError): + return None, None + + +def _archive_extension_directory(source_dir: Path) -> Path: + """Package an extension directory as a ZIP archive for the update flow. + + The update pipeline validates and installs archives (bounded + extraction, manifest preflight, ID/version checks, backup/rollback), + so a locally bundled extension is fed through that identical hardened + path rather than growing a second install code path. The caller + deletes the archive after the update, the same as a downloaded one. + """ + import zipfile + + fd, tmp_name = tempfile.mkstemp(prefix="speckit-bundled-update-", suffix=".zip") + try: + with os.fdopen(fd, "wb") as archive_file: + with zipfile.ZipFile(archive_file, "w", zipfile.ZIP_DEFLATED) as zf: + for path in sorted(source_dir.rglob("*")): + # Never follow symlinks: is_file() follows the target + # and ZipFile.write() reads its bytes, which would turn + # an out-of-tree target into a regular archive member + # before the hardened extractor ever sees it. + if path.is_symlink(): + continue + if path.is_file(): + zf.write(path, path.relative_to(source_dir).as_posix()) + except BaseException: + Path(tmp_name).unlink(missing_ok=True) + raise + return Path(tmp_name) + + def _refresh_events_and_warn(project_root: Path) -> None: """Refresh native event config and surface failures (R3). @@ -1089,8 +1144,7 @@ def extension_add( force=force, ) finally: - if archive_path.exists(): - archive_path.unlink() + archive_path.unlink(missing_ok=True) console.print("\n[green]✓[/green] Extension installed successfully!") console.print(f"\n[bold]{_escape_markup(str(manifest.name))}[/bold] (v{_escape_markup(str(manifest.version))})") @@ -1622,6 +1676,7 @@ def extension_update( console.print("🔄 Checking for updates...\n") updates_available = [] + blocked_updates = [] for ext_id in extensions_to_update: safe_ext_id = _escape_markup(str(ext_id)) @@ -1658,20 +1713,55 @@ def extension_update( continue if catalog_version > installed_version: + download_url = ext_info.get("download_url") + bundled_dir = None + available_version = catalog_version + if ext_info.get("bundled") and not download_url: + # Bundled extensions cannot be downloaded; the update has + # to come from the copy shipped with the running spec-kit + # release, which may lag the catalog on main (#4345). + bundled_dir, bundled_version = _bundled_update_source(ext_id) + # Block whenever the local copy lags the catalog, not + # just when it lags the installation: installing an + # intermediate version would leave the project behind + # the catalog while reporting success, contrary to the + # documented "upgrade spec-kit first" behavior. + if bundled_dir is None or bundled_version < catalog_version: + local_desc = ( + f"only ships v{bundled_version}" + if bundled_dir is not None + else "does not ship a local copy" + ) + console.print( + f"⚠ {safe_ext_id}: v{catalog_version} is available, but this " + f"spec-kit release {local_desc} — upgrade spec-kit, then rerun " + f"'specify extension update'" + ) + blocked_updates.append(ext_id) + continue + available_version = bundled_version updates_available.append( { "id": ext_id, "name": ext_info.get("name", ext_id), # Display name for status messages "installed": str(installed_version), - "available": str(catalog_version), - "download_url": ext_info.get("download_url"), + "available": str(available_version), + "download_url": download_url, + "bundled_dir": bundled_dir, } ) else: console.print(f"✓ {safe_ext_id}: Up to date (v{installed_version})") if not updates_available: - console.print("\n[green]All extensions are up to date![/green]") + if blocked_updates: + console.print( + "\n[yellow]Update(s) exist but require a newer spec-kit " + "release — upgrade spec-kit, then rerun " + "'specify extension update'.[/yellow]" + ) + else: + console.print("\n[green]All extensions are up to date![/green]") raise typer.Exit(0) # Show available updates @@ -1968,8 +2058,15 @@ def backup_extension_skills(skill_names, *, skills_dir=None): if ext_hooks: backup_hooks[hook_name] = ext_hooks - # 5. Download new version - archive_path = catalog.download_extension(extension_id) + # 5. Acquire the new version. Bundled extensions install from + # the copy shipped with the running spec-kit release (they + # have no download URL); everything else downloads. Both are + # packaged as archives so the identical validation, + # backup/rollback, and install pipeline below applies. + if update.get("bundled_dir") is not None: + archive_path = _archive_extension_directory(update["bundled_dir"]) + else: + archive_path = catalog.download_extension(extension_id) try: # 6. Validate the archive and extension ID before modifying # the existing installation. The shared extractor applies @@ -2312,11 +2409,10 @@ def backup_extension_skills(skill_names, *, skills_dir=None): # Archive cleanup is housekeeping: never replace an install # error or roll back an already committed update because a # scanner temporarily locks the download on Windows. - if archive_path.exists(): - try: - archive_path.unlink() - except OSError as error: - zip_cleanup_error = error + try: + archive_path.unlink(missing_ok=True) + except OSError as error: + zip_cleanup_error = error # 10. Clean up backup on success. The update has committed at # this point, so a locked backup file must not trigger rollback diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 75c2f9d0de..d3e58c963f 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -60,7 +60,9 @@ def _register_builtins() -> None: from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration from .devin import DevinIntegration + from .docker_agent import DockerAgentIntegration from .droid import DroidIntegration + from .dsh import DshIntegration from .firebender import FirebenderIntegration from .forge import ForgeIntegration from .gemini import GeminiIntegration @@ -100,7 +102,9 @@ def _register_builtins() -> None: _register(CopilotIntegration()) _register(CursorAgentIntegration()) _register(DevinIntegration()) + _register(DockerAgentIntegration()) _register(DroidIntegration()) + _register(DshIntegration()) _register(FirebenderIntegration()) _register(ForgeIntegration()) _register(GeminiIntegration()) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 03c7a90e74..27c43582b0 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -142,6 +142,12 @@ class IntegrationBase(ABC): integration that sets this flag. """ + legacy_flat_command_dir: str | None = None + """Previous flat command directory retired after skill replacements exist.""" + + legacy_flat_command_extension: str | None = None + """File extension used by commands in ``legacy_flat_command_dir``.""" + def post_process_command_content(self, content: str) -> str: """Transform command content after format rendering. diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py index e18d30a6fa..b8d76cb9c6 100644 --- a/src/specify_cli/integrations/catalog.py +++ b/src/specify_cli/integrations/catalog.py @@ -674,16 +674,37 @@ def __init__(self, descriptor_path: Path) -> None: @staticmethod def _load(path: Path) -> dict: try: - with open(path, "r", encoding="utf-8") as fh: - return yaml.safe_load(fh) or {} - except yaml.YAMLError as exc: - raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}") + text = path.read_text(encoding="utf-8") except FileNotFoundError: raise IntegrationDescriptorError(f"Descriptor not found: {path}") except (OSError, UnicodeError) as exc: raise IntegrationDescriptorError( f"Unable to read descriptor {path}: {exc}" ) + try: + # ``safe_load`` returns None for BOTH an empty document and an + # explicit null scalar (``null``, ``~``, ``Null``, ``NULL``), so it + # cannot tell them apart on its own. ``compose`` yields no node + # only for a genuinely empty document. + node = yaml.compose(text) + data = yaml.safe_load(text) + is_empty_document = node is None or ( + data is None + and isinstance(node, yaml.nodes.ScalarNode) + and node.value == "" + and node.start_mark.index == node.end_mark.index + ) + except yaml.YAMLError as exc: + raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}") + # Only a genuinely EMPTY document becomes an empty mapping, so its + # missing-field errors are reported. Every non-mapping document -- + # including an explicit ``null``/``~`` and the falsy shapes ``[]``, + # ``false``, ``0``, ``''`` that a plain ``or {}`` would mask -- must + # reach ``_validate`` unchanged so it reports the wrong descriptor + # shape, like the truthy twins (``- a``, ``hello``) already do. + if is_empty_document: + data = {} + return data # -- Validation ------------------------------------------------------- @@ -850,5 +871,8 @@ def tools(self) -> List[Dict[str, Any]]: def get_hash(self) -> str: """SHA-256 hash of the descriptor file.""" + h = hashlib.sha256() with open(self.path, "rb") as fh: - return f"sha256:{hashlib.sha256(fh.read()).hexdigest()}" + for chunk in iter(lambda: fh.read(8192), b""): + h.update(chunk) + return f"sha256:{h.hexdigest()}" diff --git a/src/specify_cli/integrations/docker_agent/__init__.py b/src/specify_cli/integrations/docker_agent/__init__.py new file mode 100644 index 0000000000..d962f1f994 --- /dev/null +++ b/src/specify_cli/integrations/docker_agent/__init__.py @@ -0,0 +1,126 @@ +"""Docker Agent integration — skills-based Docker CLI agent. + +Docker Agent discovers project skills from ``.agents/skills`` when the selected +agent configuration enables local skills and filesystem reads. Runtime +configuration is owned by Docker Agent and is not managed by Spec Kit. +""" + +from __future__ import annotations + +import os +import shlex + +from specify_cli._utils import docker_agent_command + +from ..base import IntegrationOption, SkillsIntegration + + +class DockerAgentIntegration(SkillsIntegration): + """Integration for Docker Agent.""" + + key = "docker-agent" + config = { + "name": "Docker Agent", + "folder": ".agents/", + "commands_subdir": "skills", + "install_url": "https://docs.docker.com/ai/docker-agent/getting-started/installation/", + # Docker Agent is exposed as either `docker-agent` or `docker agent`. + "requires_cli": True, + } + registrar_config = { + "dir": ".agents/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + # Docker Agent shares the ``.agents/skills`` layout with Codex and Zed. + # Keep co-installation opt-in until shared manifest ownership is supported. + multi_install_safe = False + + # Docker Agent hooks are configured in the selected agent YAML under + # ``agents..hooks``. Spec Kit does not edit that user-owned file, so + # hooks are intentionally not exposed through the integration event system. + + def _agent_command(self) -> list[str]: + """Return the available Docker Agent command form.""" + + # The shared executable override supports both a standalone + # ``docker-agent`` binary and the Docker CLI plugin form. + executable = self._resolve_executable() + command = docker_agent_command( + None if executable == self.key else executable + ) + if command is None: + # Preserve the normal executable-shaped argv for dispatch callers; + # preflight and the subprocess runner report the unavailable CLI. + return [executable, "run"] + return command + + + @classmethod + def options(cls) -> list[IntegrationOption]: + opts = super().options() + opts.append( + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Docker Agent)", + ) + ) + return opts + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + """Build a headless Docker Agent invocation with an agent config.""" + extra_env_name = "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS" + extra_args = os.environ.get(extra_env_name, "").strip() + if not extra_args: + raise ValueError( + "Docker Agent requires an agent configuration reference. " + f"Set {extra_env_name}, for example: " + f"{extra_env_name}=./agent.yaml" + ) + # Validate only the argument shape here: require a first positional + # agent reference and reject malformed quoting or a leading option. + # The reference may be a local file or a registry reference, so its + # existence and validity are intentionally left to Docker Agent. + try: + first_arg = shlex.split(extra_args)[0] + except (IndexError, ValueError) as exc: + raise ValueError( + f"{extra_env_name} must start with an agent configuration reference, " + "for example ./agent.yaml" + ) from exc + if first_arg.startswith("-"): + raise ValueError( + f"{extra_env_name} must start with an agent configuration reference, " + "for example ./agent.yaml" + ) + + args = [*self._agent_command(), "--exec"] + + # Extra args carry the required agent source (for example + # ``./agent.yaml``) and any Docker Agent CLI flags. The shared helper + # also preserves shell-style quoting when splitting multiple args. + self._apply_extra_args_env_var(args) + + if output_json: + args.append("--json") + if model: + args.extend(["--model", model]) + + # Stop Cobra flag parsing before the user prompt so values such as + # ``--help`` or ``--json`` are passed as messages, not CLI options. + # For example, the complete argv is + # ``docker-agent run --exec ./agent.yaml --agent root -- --help``; + # everything before ``--`` is parsed by Docker Agent, while ``--help`` + # is passed to the configured agent as the user message. + args.extend(["--", prompt]) + + return args diff --git a/src/specify_cli/integrations/dsh/__init__.py b/src/specify_cli/integrations/dsh/__init__.py new file mode 100644 index 0000000000..9b533dec99 --- /dev/null +++ b/src/specify_cli/integrations/dsh/__init__.py @@ -0,0 +1,64 @@ +"""DeepSeek Harness (DSH) integration — skills-based agent. + +DSH discovers project skills from ``.dsh/skills`` (its native root, highest +provider rank) and from the shared ``.agents/skills`` root, one level deep, +using ``/SKILL.md`` directory bundles with ``name``/``description`` +frontmatter — the same agentskills.io layout Spec Kit scaffolds for other +skills-based agents. Skills are user-invocable through the ``/``-trigger +input in the DSH Web GUI (and any TUI/ACP front end): typing +``/speckit-specify `` ships the literal token plus the +user text, and the harness injects the skill's ```` into the +turn. Project guidance in ``AGENTS.md`` at the repo root is loaded +automatically by DSH, so no context-file handling is needed here. + +See: https://github.com/deepseek-ai/deepseek-harness +""" + +from __future__ import annotations + +from ..base import SkillsIntegration + + +class DshIntegration(SkillsIntegration): + """Integration for the DeepSeek Harness (DSH) agent.""" + + key = "dsh" + config = { + "name": "DeepSeek Harness", + "folder": ".dsh/", + "commands_subdir": "skills", + "install_url": "https://github.com/deepseek-ai/deepseek-harness", + "requires_cli": True, + } + registrar_config = { + "dir": ".dsh/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + # ``.dsh/`` is a static, unique agent root that no other integration + # writes into, so co-installing DSH alongside other agents is safe. + multi_install_safe = True + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + """Build non-interactive CLI args for DSH. + + DSH's one-shot mode is ``dsh --profile headless ""``: the + runner submits the task as an ordinary user message, waits for + quiescence, and prints the last assistant message to stdout. The + headless profile recognizes whitespace-bounded ``/name`` tokens + naming user-invocable skills, so a slash-command prompt such as + ``/speckit-specify build photo albums`` loads the skill exactly as + an interactive session would. The CLI has no structured-JSON output + flag, so ``output_json`` and ``model`` are ignored. + """ + args = [self._resolve_executable(), "--profile", "headless"] + self._apply_extra_args_env_var(args) + args.append(prompt) + return args diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py index 13535203cf..0fec683fae 100644 --- a/src/specify_cli/integrations/qodercli/__init__.py +++ b/src/specify_cli/integrations/qodercli/__init__.py @@ -1,21 +1,28 @@ -"""Qoder CLI integration.""" +"""Qoder CLI integration. -from ..base import MarkdownIntegration +Qoder IDE 1.24+ dropped ``.qoder/commands/`` scanning in favour of the +skills layout: ``.qoder/skills/{skill-name}/SKILL.md`` with a ``name`` +field in frontmatter. Migrated to ``SkillsIntegration`` to match. +""" +from ..base import SkillsIntegration -class QodercliIntegration(MarkdownIntegration): + +class QodercliIntegration(SkillsIntegration): key = "qodercli" config = { "name": "Qoder CLI", "folder": ".qoder/", - "commands_subdir": "commands", + "commands_subdir": "skills", "install_url": "https://qoder.com/cli", "requires_cli": True, } registrar_config = { - "dir": ".qoder/commands", + "dir": ".qoder/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", + "extension": "/SKILL.md", } + legacy_flat_command_dir = ".qoder/commands" + legacy_flat_command_extension = ".md" multi_install_safe = True diff --git a/src/specify_cli/integrations/rovodev/__init__.py b/src/specify_cli/integrations/rovodev/__init__.py index 01aa870c66..fe0fcb30b6 100644 --- a/src/specify_cli/integrations/rovodev/__init__.py +++ b/src/specify_cli/integrations/rovodev/__init__.py @@ -179,6 +179,17 @@ def _merge_prompt_entries( for entry in existing: name = entry.get("name", "") + # ``prompts.yml`` is user-editable, and ``_read_prompts_yml`` only + # filters at the entry level -- it never validates the entry's + # ``name``. A YAML sequence or mapping there is unhashable, so this + # dict-membership test raised a raw ``TypeError`` out of ``setup()`` + # and aborted every ``specify init`` / ``integration install`` for + # rovodev on that project, leaving prompts.yml unwritten. A + # non-string name can never match a generated entry, so treat it + # like any other unmatched entry and preserve it verbatim. + if not isinstance(name, str): + merged.append(entry) + continue if name in generated_by_name: merged.append(generated_by_name[name]) seen.add(name) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 3d37f6fb74..6b80b4fe1f 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -63,6 +63,20 @@ def _content_sha256(content: bytes) -> str: return hashlib.sha256(content).hexdigest() +def _is_comparable_version(value: str) -> bool: + """Return whether a recorded version can be evaluated against a specifier. + + ``version_satisfies()`` answers "does not satisfy" for an unparseable + version, which is indistinguishable from a genuine mismatch. Callers that + need to tell those apart check here first. + """ + try: + pkg_version.Version(value) + except pkg_version.InvalidVersion: + return False + return True + + def _constitution_is_generated( project_root: Path, memory_constitution: Path, @@ -107,7 +121,7 @@ def _constitution_provenance_matches_preset( return False try: metadata = json.loads(provenance.read_text(encoding="utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError): + except (OSError, json.JSONDecodeError, UnicodeDecodeError): return False return ( isinstance(metadata, dict) @@ -381,6 +395,14 @@ def _validate(self): f"got {type(requires['speckit_version']).__name__}" ) + # Validate the optional extension dependency list. A preset that + # overrides commands calling into an extension is inert without it, and + # until now the only place that could be said was the README -- see + # issue #4231. Absent means "no dependencies", so every existing preset + # stays valid. + if "extensions" in requires: + self._validate_requires_extensions(requires["extensions"]) + # Validate provides section provides = self.data["provides"] if "templates" not in provides: @@ -409,6 +431,7 @@ def _validate(self): raise PresetValidationError( "Preset must provide at least one template" ) + seen_name_types: set[tuple[str, str]] = set() for tmpl in templates: if not isinstance(tmpl, dict): raise PresetValidationError( @@ -438,6 +461,20 @@ def _validate(self): f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}" ) + # PresetResolver._manifest_declared_template returns the first + # 'provides.templates' entry matching a given (name, type) pair, so + # a later duplicate would be silently unreachable while still being + # counted by PresetManifest.templates. Reject at validation time + # instead, mirroring the sibling fix for ExtensionManifest's + # provides.templates/scripts (#4016). + name_type = (tmpl["name"], tmpl["type"]) + if name_type in seen_name_types: + raise PresetValidationError( + f"Duplicate template name '{tmpl['name']}' of type " + f"'{tmpl['type']}' in 'provides.templates'" + ) + seen_name_types.add(name_type) + # Validate file path safety: must be relative, no parent traversal file_path = tmpl["file"] normalized = os.path.normpath(file_path) @@ -509,11 +546,121 @@ def author(self) -> str: """Get preset author.""" return self.data["preset"].get("author", "") + @staticmethod + def _validate_requires_extensions(declared: Any) -> None: + """Validate the optional ``requires.extensions`` list. + + Accepts either a bare extension id or a mapping carrying an optional + version specifier and an optional ``required`` flag: + + .. code-block:: yaml + + requires: + extensions: + - speckit-inventory + - id: other-ext + version: ">=1.2.0" + required: false + + Raises: + PresetValidationError: If the list or any entry is malformed. + """ + if not isinstance(declared, list): + raise PresetValidationError( + "Invalid requires.extensions: expected a list, " + f"got {type(declared).__name__}" + ) + + for index, entry in enumerate(declared): + label = f"requires.extensions[{index}]" + + if isinstance(entry, str): + entry = {"id": entry} + elif not isinstance(entry, dict): + raise PresetValidationError( + f"Invalid {label}: expected a string or a mapping, " + f"got {type(entry).__name__}" + ) + + if "id" not in entry: + raise PresetValidationError(f"Missing {label}.id") + extension_id = entry["id"] + if not isinstance(extension_id, str): + raise PresetValidationError( + f"Invalid {label}.id: expected a string, " + f"got {type(extension_id).__name__}" + ) + # Same id shape the extension loader enforces, so a dependency can + # never name something that could not be installed in the first + # place. fullmatch rather than match with an anchored pattern: `$` + # also matches before a trailing newline, so "demo-ext\n" would + # otherwise validate here while PresetResolver._is_safe_registry_id + # (which uses fullmatch) rejects it, and the newline would land in + # a suggested command. + if not re.fullmatch(r'[a-z0-9-]+', extension_id): + raise PresetValidationError( + f"Invalid {label}.id {extension_id!r}: " + "must be lowercase alphanumeric with hyphens only" + ) + + if "version" in entry: + constraint = entry["version"] + # Mirrors the requires.speckit_version reasoning: a non-string + # escapes InvalidSpecifier two ways -- scalars raise TypeError + # from the constructor, and a list/dict is iterable so it + # constructs and only fails later inside .contains(). + if not isinstance(constraint, str) or not constraint.strip(): + raise PresetValidationError( + f"Invalid {label}.version: expected a non-empty string, " + f"got {type(constraint).__name__}" + ) + try: + SpecifierSet(constraint) + except InvalidSpecifier: + raise PresetValidationError( + f"Invalid {label}.version '{constraint}': " + "not a valid version specifier" + ) + + if "required" in entry and not isinstance(entry["required"], bool): + raise PresetValidationError( + f"Invalid {label}.required: expected a boolean, " + f"got {type(entry['required']).__name__}" + ) + @property def requires_speckit_version(self) -> str: """Get required spec-kit version range.""" return self.data["requires"]["speckit_version"] + @property + def requires_extensions(self) -> List[Dict[str, Any]]: + """Get declared extension dependencies, normalized to mappings. + + Returns: + One entry per dependency with ``id``, ``version`` (``None`` when + unconstrained), and ``required`` (defaulting to ``True``). Empty + when the manifest declares no dependencies. + """ + declared = self.data["requires"].get("extensions") + if not isinstance(declared, list): + return [] + + normalized: List[Dict[str, Any]] = [] + for entry in declared: + if isinstance(entry, str): + entry = {"id": entry} + if not isinstance(entry, dict) or not isinstance(entry.get("id"), str): + continue + normalized.append( + { + "id": entry["id"], + "version": entry.get("version"), + "required": entry.get("required", True), + } + ) + return normalized + @property def templates(self) -> List[Dict[str, Any]]: """Get list of provided templates.""" @@ -526,8 +673,11 @@ def tags(self) -> List[str]: def get_hash(self) -> str: """Calculate SHA256 hash of manifest file.""" + h = hashlib.sha256() with open(self.path, 'rb') as f: - return f"sha256:{hashlib.sha256(f.read()).hexdigest()}" + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return f"sha256:{h.hexdigest()}" class PresetRegistry: @@ -823,6 +973,153 @@ def check_compatibility( return True + def find_unmet_extension_dependencies( + self, + manifest: PresetManifest + ) -> List[Dict[str, Any]]: + """Find declared extension dependencies that are not satisfied. + + Reports rather than raises. A preset whose overrides call into an + extension is written to degrade safely -- without the extension the + core workflow still runs -- so a missing dependency is a warning, not + an install failure. See issue #4231. + + Args: + manifest: Preset manifest to inspect + + Returns: + One entry per unsatisfied dependency, each with ``id``, the + requested ``version`` specifier (``None`` when unconstrained), the + ``installed`` version (``None`` when absent or unusable), and a + ``reason`` of ``"missing"``, ``"corrupt"``, ``"stale"``, + ``"disabled"``, or ``"version"``. Optional dependencies + (``required: false``) are never reported. + + An unreadable registry yields no results rather than raising, since + this runs after the install has already succeeded. + + A registry version that cannot be parsed is treated as + uncomparable, not as a mismatch: the extension is installed and + usable, and only its recorded version is unreadable. An extension + present on disk but absent from the registry is likewise treated as + satisfied, because resolution admits unregistered directories. + """ + # Defense in depth, mirroring check_compatibility(): this method is + # public and also reachable with a hand-built manifest object that + # predates this field. A manifest without it declares nothing. + candidates = getattr(manifest, "requires_extensions", None) + if not isinstance(candidates, list): + return [] + + # Collapse exact repeats so a manifest naming the same dependency twice + # warns once. Two entries for one id with *different* constraints are + # kept, since both genuinely have to hold. + declared: List[Dict[str, Any]] = [] + seen: Set[tuple] = set() + for dep in candidates: + if not isinstance(dep, dict) or not dep.get("required", True): + continue + key = (dep.get("id"), dep.get("version")) + if key in seen: + continue + seen.add(key) + declared.append(dep) + if not declared: + return [] + + extensions_dir = self.project_root / ".specify" / "extensions" + try: + registry = ExtensionRegistry(extensions_dir) + registered_ids = registry.keys() + registry_corrupt = registry.is_corrupt() + except OSError: + # Both reads can raise: _load() recovers from malformed content but + # deliberately lets OSError through, and is_corrupt() re-reads the + # file. This check runs *after* the install has completed, and + # preset_add only handles preset-domain errors, so letting that + # escape would turn a finished install into a traceback over a + # warning. An unreadable registry simply cannot be inspected. + return [] + + unmet: List[Dict[str, Any]] = [] + + for dep in declared: + metadata = registry.get(dep["id"]) + if metadata is None: + # An absent registry entry does not mean the extension is + # unusable. _get_all_extensions_by_priority() admits a safe + # on-disk directory as an unregistered extension at implicit + # priority 10, so it resolves and the preset works -- but only + # when the registry is readable, since a corrupt one makes that + # path fail closed and contribute nothing. + # + # get() returns None for a corrupted (non-dict) entry as well as + # an absent one, but keys() retains corrupted ids -- both so + # resolution does not re-admit their directories as + # unregistered, and because is_installed() still counts them, so + # a plain `extension add` would be refused as already installed. + # That is a different state from absent, and needs a different + # remedy. + if dep["id"] in registered_ids: + unmet.append({**dep, "installed": None, "reason": "corrupt"}) + continue + if ( + (extensions_dir / dep["id"]).is_dir() + and PresetResolver._is_safe_registry_id(dep["id"]) + and not registry_corrupt + ): + # Unregistered means no recorded version, so a constraint + # cannot be evaluated -- uncomparable, not unsatisfied. + continue + unmet.append({**dep, "installed": None, "reason": "missing"}) + continue + + installed_version = metadata.get("version") + installed_version = ( + installed_version if isinstance(installed_version, str) else None + ) + + # A registry entry is not proof the extension can contribute. If + # its directory is gone, PresetResolver skips it outright (both + # template lookup and layer collection guard on ``is_dir()``), so + # the preset is as inert as if it were never installed -- but the + # surviving entry would otherwise read as satisfied. + if not (extensions_dir / dep["id"]).is_dir(): + unmet.append( + {**dep, "installed": installed_version, "reason": "stale"} + ) + continue + + # A disabled extension is registered but contributes nothing: + # resolution skips it (see _collect_extension_layers), so the + # preset is just as inert as if it were absent. Report it before + # any version check -- enabling it is the prerequisite, and the + # version may well be fine once it is. + if not metadata.get("enabled", True): + unmet.append( + {**dep, "installed": installed_version, "reason": "disabled"} + ) + continue + + constraint = dep["version"] + if not constraint: + continue + + # A version that cannot be compared is not a mismatch. Absent or + # non-string is one way to be unusable; an unparseable string such + # as "unknown" is another, and version_satisfies() cannot tell them + # apart -- it catches InvalidVersion and returns False, which would + # report a mismatch against a version nobody can evaluate. Check + # parseability up front so only real comparisons reach the warning. + if installed_version is None or not _is_comparable_version(installed_version): + continue + if not version_satisfies(installed_version, constraint): + unmet.append( + {**dep, "installed": installed_version, "reason": "version"} + ) + + return unmet + def _register_commands( self, manifest: PresetManifest, @@ -1800,7 +2097,7 @@ def record_written(written: Dict[str, List[str]]) -> None: ) record_written(written) registered = True - except Exception: + except (ImportError, FileNotFoundError, OSError): # Extension registration failed; fall back to # generic path-based registration below. pass @@ -4147,6 +4444,13 @@ def _validate_catalog_url(self, url: str) -> None: try: parsed = urlparse(url) hostname = parsed.hostname + # Accessing ``port`` performs urllib's syntax/range validation; + # ``hostname`` alone does not, so a non-numeric or out-of-range + # port would otherwise pass validation here and only fail later, + # at fetch time, as a raw error this function does not translate + # into PresetValidationError. Mirrors specify_cli.catalogs and + # bundler/services/adapters.py's copy of this same guard. + _ = parsed.port except ValueError: raise PresetValidationError(f"Catalog URL is malformed: {url}") from None is_localhost = hostname in ("localhost", "127.0.0.1", "::1") @@ -4274,11 +4578,13 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalog if not config_path.exists(): return None try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) except (yaml.YAMLError, OSError, UnicodeError) as e: raise PresetValidationError( f"Failed to read catalog config {config_path}: {e}" ) + if data is None: + return None if not isinstance(data, dict): raise PresetValidationError( f"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}" diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index 48d5c9f14f..ab74a8e029 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -40,6 +40,112 @@ preset_app.add_typer(preset_catalog_app, name="catalog") +def _warn_unmet_extension_dependencies(manager, manifest) -> None: + """Warn when a preset's declared extension dependencies are unsatisfied. + + A preset whose command overrides call into an extension is inert without + it, but the overrides still fall through to the core workflow, so nothing + breaks -- it just silently does less than the user expects. Naming the + missing extension and the command that installs it turns that silence into + something actionable. See issue #4231. + """ + from ..extensions._commands import _command_safe_id + + unmet = manager.find_unmet_extension_dependencies(manifest) + if not unmet: + return + + console.print() + console.print("[yellow]![/yellow] This preset depends on extensions that are not satisfied:") + needs_catalog = False + for dep in unmet: + uses_catalog = False + extension_id = _escape_markup(dep["id"]) + # The displayed id only needs Rich escaping, but a suggested command + # has to survive Typer's parser: `^[a-z0-9-]+$` admits a leading + # hyphen, so an id like `--force` would render as an option rather + # than the positional argument. _command_safe_id substitutes a + # placeholder in that case, the same way extension commands do. + command_id = _command_safe_id(dep["id"]) + reason = dep["reason"] + # The remediation has to match the reason. `extension add` refuses an + # already-installed extension without --force, and `extension update` + # only moves forward to the catalog release. A general PEP 440 + # constraint may require an exact version, an upper bound, or a + # downgrade, so do not promise that update will satisfy it. + if reason == "missing": + console.print(f" [yellow]{extension_id}[/yellow] is not installed") + label, remedy = "Install with", f"specify extension add {command_id}" + uses_catalog = True + elif reason == "corrupt": + console.print( + f" [yellow]{extension_id}[/yellow] has an unreadable " + "registry entry" + ) + # is_installed() still counts the key, so a plain add is refused. + label = "Reinstall with" + remedy = f"specify extension add {command_id} --force" + uses_catalog = True + elif reason == "stale": + console.print( + f" [yellow]{extension_id}[/yellow] is registered but its " + "files are missing" + ) + label = "Reinstall with" + remedy = f"specify extension add {command_id} --force" + uses_catalog = True + elif reason == "disabled": + console.print(f" [yellow]{extension_id}[/yellow] is installed but disabled") + label, remedy = "Enable with", f"specify extension enable {command_id}" + else: + console.print( + f" [yellow]{extension_id}[/yellow] " + f"{_escape_markup(dep['installed'])} does not satisfy " + f"{_escape_markup(dep['version'])}" + ) + label = "Needs" + remedy = ( + f"a release of {command_id} satisfying " + f"{_escape_markup(dep['version'])}" + ) + console.print(f" {label}: {remedy}") + needs_catalog = needs_catalog or uses_catalog + console.print() + # The consequence differs by reason and must not be overstated. An + # unavailable extension contributes nothing, so those features are simply + # inert. A version mismatch is the opposite: the extension is installed and + # enabled, so the preset does invoke it -- the combination is just untested + # against the declared constraint, which is not the same as "safe". + console.print("[dim]The preset is installed.[/dim]") + if any( + dep["reason"] in ("missing", "corrupt", "stale", "disabled") + for dep in unmet + ): + console.print( + "[dim]Anything relying on an unavailable extension does nothing " + "until that is resolved.[/dim]" + ) + if any(dep["reason"] == "version" for dep in unmet): + console.print( + "[dim]Where only a version constraint is unmet the extension is " + "still used, so it may not behave as the preset expects.[/dim]" + ) + if needs_catalog: + # `extension add ` resolves through the catalogs, and the default + # community catalog is discovery-only, so installing by id is refused + # for anything listed only there -- true of every extension motivating + # this feature. Knowing which applies would mean a catalog fetch, and + # this runs on an install path that touches no network, so describe + # the outcome instead of asserting the command succeeds. The rejection + # itself prints the exact --from form, so this is a signpost rather + # than a dead end. + console.print( + "[dim]If an extension is listed only in a discovery-only catalog, " + "that command is refused and prints the " + "--from form to use instead.[/dim]" + ) + + # ===== Preset Commands ===== @@ -283,6 +389,12 @@ def _validate_download_redirect(old_url, new_url): console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") raise typer.Exit(1) + # Every install path above binds `manifest` and the no-source branch + # exits, so one call here covers --dev, --from, and catalog installs + # alike. Warns rather than fails: the preset is installed and its + # overrides fall through to the core workflow without the extension. + _warn_unmet_extension_dependencies(manager, manifest) + except PresetCompatibilityError as e: console.print(f"[red]Compatibility Error:[/red] {_escape_markup(str(e))}") raise typer.Exit(1) @@ -767,11 +879,16 @@ def preset_catalog_add( # Load existing config if config_path.exists(): try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) except Exception as e: config_label = _display_project_path(project_root, config_path) console.print(f"[red]Error:[/red] Failed to read {_escape_markup(str(config_label))}: {_escape_markup(str(e))}") raise typer.Exit(1) + if config is None: + config = {} + elif not isinstance(config, dict): + console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.") + raise typer.Exit(1) else: config = {} @@ -827,10 +944,15 @@ def preset_catalog_remove( raise typer.Exit(1) try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) except Exception as e: console.print(f"[red]Error:[/red] Failed to read preset catalog config: {e}") raise typer.Exit(1) + if config is None: + config = {} + elif not isinstance(config, dict): + console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.") + raise typer.Exit(1) catalogs = config.get("catalogs", []) if not isinstance(catalogs, list): diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py index 5e40569af0..716b6c8a19 100644 --- a/src/specify_cli/workflows/_commands.py +++ b/src/specify_cli/workflows/_commands.py @@ -1383,7 +1383,7 @@ def workflow_run( err.print(f"[red]Error:[/red] Workflow not found: {source}") raise typer.Exit(1) except ValueError as exc: - err.print(f"[red]Error:[/red] Invalid workflow: {exc}") + err.print(f"[red]Error:[/red] Invalid workflow: {_escape_markup(str(exc))}") raise typer.Exit(1) # Validate @@ -1424,10 +1424,10 @@ def workflow_run( ), ) except ValueError as exc: - err.print(f"[red]Error:[/red] {exc}") + err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") raise typer.Exit(1) except Exception as exc: - err.print(f"[red]Workflow failed:[/red] {exc}") + err.print(f"[red]Workflow failed:[/red] {_escape_markup(str(exc))}") raise typer.Exit(1) if json_output: diff --git a/src/specify_cli/workflows/catalog.py b/src/specify_cli/workflows/catalog.py index 9021c94449..eec3ef0d3e 100644 --- a/src/specify_cli/workflows/catalog.py +++ b/src/specify_cli/workflows/catalog.py @@ -784,12 +784,14 @@ def remove_catalog(self, index: int) -> str: raise WorkflowValidationError("No catalog config file found.") try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc: raise WorkflowValidationError( f"Catalog config file is unreadable or malformed: {exc}" ) from exc - if not isinstance(data, dict): + if data is None: + data = {} + elif not isinstance(data, dict): raise WorkflowValidationError( "Catalog config file is corrupted (expected a mapping)." ) @@ -1408,12 +1410,14 @@ def add_catalog(self, url: str, name: str | None = None) -> None: data: dict[str, Any] = {"catalogs": []} if config_path.exists(): try: - raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc: raise StepValidationError( f"Catalog config file is unreadable or malformed: {exc}" ) from exc - if not isinstance(raw, dict): + if raw is None: + raw = {} + elif not isinstance(raw, dict): raise StepValidationError( "Catalog config file is corrupted (expected a mapping)." ) @@ -1477,12 +1481,14 @@ def remove_catalog(self, index: int) -> str: raise StepValidationError("No step catalog config file found.") try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc: raise StepValidationError( f"Catalog config file is unreadable or malformed: {exc}" ) from exc - if not isinstance(data, dict): + if data is None: + data = {} + elif not isinstance(data, dict): raise StepValidationError( "Catalog config file is corrupted (expected a mapping)." ) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index a74450ed9a..d17513cc0b 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -61,11 +61,15 @@ def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> Non self.schema_version: str = data.get("schema_version", "1.0") # Defaults - self.default_integration: str | None = workflow.get("integration") - self.default_model: str | None = workflow.get("model") - self.default_options: dict[str, Any] = workflow.get("options") or {} - if not isinstance(self.default_options, dict): - self.default_options = {} + # Keep malformed values intact until ``validate_workflow`` can report + # them. ``None`` remains the supported "no defaults" form for options + # and retains its existing runtime representation as an empty mapping. + self.default_integration: Any = workflow.get("integration") + self.default_model: Any = workflow.get("model") + raw_default_options = workflow.get("options") + self.default_options: Any = ( + {} if raw_default_options is None else raw_default_options + ) # Advisory pre-conditions (spec-kit version / integrations a workflow # expects). Validated by ``validate_workflow`` (recognized keys only; @@ -140,6 +144,40 @@ def _get_valid_step_types() -> set[str]: } +def _dispatch_default_errors(definition: WorkflowDefinition) -> list[str]: + """Return validation errors for workflow defaults inherited by dispatch steps.""" + errors: list[str] = [] + + if ( + definition.default_integration is not None + and not isinstance(definition.default_integration, str) + ): + errors.append( + "'workflow.integration' must be a string or null, got " + f"{type(definition.default_integration).__name__} " + f"({definition.default_integration!r})." + ) + + if ( + definition.default_model is not None + and not isinstance(definition.default_model, str) + ): + errors.append( + "'workflow.model' must be a string or null, got " + f"{type(definition.default_model).__name__} " + f"({definition.default_model!r})." + ) + + if not isinstance(definition.default_options, dict): + errors.append( + "'workflow.options' must be a mapping or null, got " + f"{type(definition.default_options).__name__} " + f"({definition.default_options!r})." + ) + + return errors + + def validate_workflow(definition: WorkflowDefinition) -> list[str]: """Validate a workflow definition and return a list of error messages. @@ -197,6 +235,11 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]: f"semantic versioning (expected X.Y.Z)." ) + # Workflow-level dispatch defaults are inherited by command and prompt + # steps. Validate their shapes before an invalid value reaches dispatch, or + # (for options) is silently normalized away during construction. + errors.extend(_dispatch_default_errors(definition)) + # -- Inputs ----------------------------------------------------------- if not isinstance(definition.inputs, dict): errors.append("'inputs' must be a mapping (or omitted).") @@ -947,6 +990,10 @@ def execute( ------- The final ``RunState`` after execution completes (or pauses). """ + dispatch_default_errors = _dispatch_default_errors(definition) + if dispatch_default_errors: + raise ValueError(" ".join(dispatch_default_errors)) + from . import STEP_REGISTRY effective_run_id = run_id @@ -1048,6 +1095,10 @@ def resume( else: definition = self.load_workflow(state.workflow_id) + dispatch_default_errors = _dispatch_default_errors(definition) + if dispatch_default_errors: + raise ValueError(" ".join(dispatch_default_errors)) + # Merge any newly-supplied inputs over the persisted ones and # re-validate through the same typing path as the initial run. if inputs: diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 38a29890ae..198010838e 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -224,6 +224,59 @@ def _is_single_expression(stripped: str) -> bool: return True +def _find_block_close(text: str, start: int) -> int: + """Index of the ``}}`` closing the block opened by the ``{{`` at *start*, or -1. + + Quote-aware, so a literal ``}}`` inside a string argument + (``{{ inputs.text | default('}}') }}``) does not close the block early -- + the same rule ``_is_single_expression`` applies. Shared with + ``condition_is_never_evaluated`` so the validator cannot disagree with the + substitution it is predicting. + """ + quote: str | None = None + i = start + 2 + n = len(text) + while i < n: + ch = text[i] + if quote is not None: + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + elif ch == "}" and i + 1 < n and text[i + 1] == "}": + return i + i += 1 + return -1 + + +def _first_unclosable_block(text: str) -> str | None: + """How ``_interpolate_expressions`` will fail on the first block it cannot + close with the quote-aware scan, or ``None`` when every block closes. + + Returns ``"evaluated"`` when a raw ``}}`` still follows the opener -- the + interpolator falls back to it and evaluates the truncated body, which reaches + the filter parser and raises ``ValueError``. Returns ``"verbatim"`` when no + ``}}`` follows at all -- the tail is emitted unchanged, so it survives into the + result as truthy text. + + Walks blocks exactly the way ``_interpolate_expressions`` does, continuing past + each block that *does* close. Checking only the first opener let a later + unterminated block through both validators: ``{{ true }} and {{ inputs.ready`` + closes its first block, so the scan stopped and reported no fault, while + interpolation leaves ``and {{ inputs.ready`` in the result and ``bool()`` makes + the condition always true. + """ + i = 0 + while True: + start = text.find("{{", i) + if start == -1: + return None + close = _find_block_close(text, start) + if close == -1: + return "evaluated" if text.find("}}", start + 2) != -1 else "verbatim" + i = close + 2 + + def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str: """Substitute every top-level ``{{ ... }}`` block in *template*, quote-aware. @@ -249,20 +302,7 @@ def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str: break out.append(template[i:start]) # Scan for the block-closing ``}}`` that is outside any string literal. - j = start + 2 - quote: str | None = None - close = -1 - while j < n: - ch = template[j] - if quote is not None: - if ch == quote: - quote = None - elif ch in ("'", '"'): - quote = ch - elif ch == "}" and j + 1 < n and template[j + 1] == "}": - close = j - break - j += 1 + close = _find_block_close(template, start) if close == -1: # No quote-aware close. Two sub-cases, both kept identical to the old # regex so a malformed template is never silently hidden: @@ -434,6 +474,12 @@ def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> An ) +# Order matters -- multi-char operators first, so "!=" is not split as "!" + "=". +# Shared with the remediation check so a validator cannot drift from what the +# evaluator will actually split on. +_COMPARISON_OPERATORS = ("!=", "==", ">=", "<=", ">", "<", " not in ", " in ") + + def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: """Evaluate a simple expression against the namespace. @@ -465,7 +511,40 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: pipe_idx = _find_top_level(expr, "|") if pipe_idx != -1: segments = _split_top_level(expr, "|") - value = _evaluate_simple_expression(segments[0].strip(), namespace) + # The pipe is detected before the operators below, so a filter written on + # the right-hand operand of a comparison was applied to the comparison's + # BOOLEAN RESULT instead: `count > limit | default(5)` evaluated + # `count > limit` first and then `default` on the bool, which is a no-op, + # so the expression silently returned the comparison against the + # *unfiltered* operand. This is the mirror of a filter followed by a + # comparison (`default('7') > '5'`), which this module already refuses + # rather than guessing at the intended precedence. Refuse both the same + # way, so an ambiguous expression is reported instead of quietly + # producing the answer the author did not ask for. + head = segments[0].strip() + # Unary ``not`` is a leading prefix, not an infix token, so it has no + # surrounding space for the scan below to match -- it has to be checked + # the same way the parser itself does (``expr.startswith("not ")``). + # Without this, ``not inputs.missing | default(1)`` still evaluated + # ``not inputs.missing`` first and applied the filter to that boolean, + # which is the exact mis-binding this guard exists to reject. + # (A ``not`` that follows ``and``/``or`` is already caught by those + # tokens below.) + _ambiguous_op = "not" if head.startswith("not ") else None + if _ambiguous_op is None: + for _op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in ", + " or ", " and "): + if _find_top_level(head, _op) != -1: + _ambiguous_op = _op.strip() + break + if _ambiguous_op is not None: + raise ValueError( + f"ambiguous filter precedence in '{expr}': " + f"'| {segments[1].strip()}' would apply to the result of " + f"'{head}', not to an operand of '{_ambiguous_op}'. Filter the " + f"operand in its own expression instead." + ) + value = _evaluate_simple_expression(head, namespace) for segment in segments[1:]: value = _apply_filter(value, segment.strip(), namespace) return value @@ -493,7 +572,7 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: # Comparison operators (order matters — check multi-char ops first). Split at # the first top-level occurrence so an operator inside a quoted operand is # ignored. - for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "): + for op in _COMPARISON_OPERATORS: op_idx = _find_top_level(expr, op) if op_idx != -1: left = _evaluate_simple_expression(expr[:op_idx].strip(), namespace) @@ -690,3 +769,514 @@ def evaluate_condition(condition: str, context: Any) -> bool: if lower == "true": return True return bool(result) + + +def condition_is_never_evaluated(condition: Any) -> bool: + """True when a string *condition* is silently treated as always-true text. + + ``evaluate_condition`` resolves its argument through + ``evaluate_expression``, which only substitutes ``{{ ... }}`` blocks. A + string with no such block comes back unchanged, and — unless it reads + ``true``/``false`` — is then coerced by ``bool()``. So an expression + authored without the braces, e.g. ``condition: inputs.count > 100``, is + never evaluated at all: it is a non-empty string, so the ``if`` step always + takes ``then`` and a ``while``/``do-while`` step always runs to + ``max_iterations``. + + That is the same silent-truthiness authoring mistake the step validators + already reject for a list/dict/number condition, and it is easy to write: + GitHub Actions accepts a bare expression in ``if:``. + + The empty string is excluded — it coerces to ``False``, which is a definite + answer rather than a silent always-true. Non-empty whitespace is *not* + excluded: ``bool(" ")`` is true, and ``evaluate_condition`` strips only + while testing the ``true``/``false`` keywords before falling through to + ``bool()`` on the raw string. That runtime behaviour is pinned deliberately + by ``test_condition_whitespace_only_string_stays_truthy``, so the authoring + mistake has to be caught here instead: ``condition: " "`` always takes + ``then``. + """ + if not isinstance(condition, str): + return False + if condition == "": + return False + stripped = condition.strip() + if not stripped: + return True + if stripped.lower() in ("true", "false"): + return False + if "{{" not in stripped: + return True + # An opening ``{{`` the substituter cannot close is no better than a missing + # one -- but only when the substituter really does leave it alone. + # ``_interpolate_expressions`` has two sub-cases when its quote-aware scan + # fails, and they do not behave alike: with no raw ``}}`` in the tail the + # block is emitted verbatim (never evaluated, so ``bool()`` makes it true), + # while a raw ``}}`` further along is used as the close and the truncated + # body *is* evaluated. Only the first is "never evaluated"; see + # ``condition_has_malformed_expression_block`` for the second. + return _first_unclosable_block(stripped) == "verbatim" + + +def condition_is_interpolated_to_text(condition: Any) -> bool: + """True when *condition* holds ``{{ }}`` blocks but is spliced into text, not evaluated. + + ``evaluate_expression`` takes its typed fast path only when the whole string is + exactly one ``{{ ... }}`` block (``_is_single_expression``). Anything else — two + blocks, or one block with any text around it — goes to ``_interpolate_expressions``, + which substitutes each block into the surrounding string and returns a *string*. + ``evaluate_condition`` then coerces that with ``bool()``, so the result is true for + every rendering except ``""``, ``"true"`` and ``"false"``:: + + {{ inputs.ready }} and {{ inputs.count > 100 }} -> "False and False" -> True + not {{ inputs.ready }} -> "not False" -> True + {{ inputs.count }} > 100 -> "0 > 100" -> True + + Each of those reads as a real expression and is always true, which is the same + silent-truthiness fault ``condition_is_never_evaluated`` reports one layer out: there + the braces are missing, here they are present but do not cover the whole condition. + The operators belong *inside* one block, and the validators already tell authors the + condition must be "a single complete '{{ }}' block" -- this is the check behind that + sentence. + + Deliberately derived from ``_is_single_expression`` rather than restated, so this + cannot drift from the fast path it is predicting. + """ + if not isinstance(condition, str): + return False + stripped = condition.strip() + if not stripped or "{{" not in stripped: + return False + # Leave both of the faults that already have their own message and advice: a block + # the substituter cannot close is not an interpolation problem. + if condition_is_never_evaluated(condition) or condition_has_malformed_expression_block(condition): + return False + return not _is_single_expression(stripped) + + +def condition_has_malformed_expression_block(condition: Any) -> bool: + """True when *condition* holds a ``{{`` block the quote-aware scan cannot close, + but which ``_interpolate_expressions`` still evaluates through its raw-close + fallback. + + This is a different fault from the one + ``condition_is_never_evaluated`` reports, and it deserves a different message. + The block is not skipped: the interpolator takes the first raw ``}}`` after the + opener and evaluates whatever it truncated, so + + {{ inputs.missing | default('oops }} + + reaches ``_apply_filter`` and raises ``ValueError`` at run time. The truncation does + not always raise -- ``{{ inputs.x == '}}'`` evaluates to the residual ``"False'"`` -- + but either way what runs is not what was written, so "never evaluated and always + true" is the wrong report. + + Kept separate from the never-evaluated check rather than folded in, because the + two need opposite advice: one says "you forgot the braces", this one says "your + delimiters or quotes do not balance". + """ + if not isinstance(condition, str): + return False + stripped = condition.strip() + if not stripped or stripped.lower() in ("true", "false"): + return False + return _first_unclosable_block(stripped) == "evaluated" + + +def _strip_stray_delimiters(text: str) -> str: + """Remove every ``{{``/``}}`` that lies outside a quoted operand. + + Quote-aware for the same reason the rest of this module is: ``inputs.x == '}}'`` + holds a delimiter as *data*, and a blanket ``re.sub`` would eat it and change + what the corrected condition compares against. Whitespace orphaned by a removed + delimiter collapses to one separator so the suggestion still reads as an + expression; whitespace inside a quoted operand is never touched. + + ``_find_top_level`` cannot serve here: it counts ``{`` and ``}`` as bracket + depth, so it never reports a ``{{`` as a top-level token at all. + """ + out: list[str] = [] + quote: str | None = None + i = 0 + n = len(text) + while i < n: + ch = text[i] + if quote is not None: + out.append(ch) + if ch == quote: + quote = None + i += 1 + continue + if ch in ("'", '"'): + quote = ch + out.append(ch) + i += 1 + continue + if text.startswith("{{", i) or text.startswith("}}", i): + i += 2 + while i < n and text[i].isspace(): + i += 1 + while out and out[-1].isspace(): + out.pop() + out.append(" ") + continue + out.append(ch) + i += 1 + return "".join(out) + +def format_condition_correction(condition: Any) -> str: + """Render *condition* wrapped in ``{{ }}`` as a quoted, paste-ready YAML scalar. + + The validators hand this back as the corrected form, so it has to survive a + round trip through a YAML parser. A plain ``"{{ ... }}"`` does not: a + condition holding a double quote (``inputs.name == "zzz"``) closes the + scalar early and the workflow file no longer loads. Quoting is therefore + chosen from the content. That enumeration was incomplete: a condition loaded + from a YAML literal block can carry a newline, which a double-quoted scalar + folds, so the correction did not round-trip. + + ``json.dumps`` decides it instead. Every JSON string is a valid YAML + double-quoted scalar, and it escapes the quotes, backslashes, newlines and + other control characters that hand-rolled quoting has to enumerate. + ``ensure_ascii=False`` keeps non-ASCII operands readable rather than + expanding them into numeric escapes. + + A stray delimiter is dropped rather than nested: ``{{ inputs.count > 100`` + corrects to ``"{{ inputs.count > 100 }}"``, not to a doubled ``{{ {{ ... }} }}``. + Every stray delimiter goes, not only the ones sitting at the edges. Trimming + just the edges left ``prefix {{ inputs.ready`` reading + ``"{{ prefix {{ inputs.ready }}"`` -- an unclosed inner block, and one whose + complete *outer* block then carried the correction straight back through + ``condition_is_never_evaluated`` as if it were valid. + """ + core = _strip_stray_delimiters(str(condition)).strip() + # A blank core has nothing to wrap; render the empty block rather than the + # double-spaced "{{ }}" that string concatenation would otherwise produce. + body = "{{ " + core + " }}" if core else "{{ }}" + return json.dumps(body, ensure_ascii=False) + + +def _has_unbalanced_quote(text: str) -> bool: + """True when a quote opened in *text* is never closed. + + Same left-to-right, first-quote-wins scan the rest of this module uses, so the + answer agrees with what ``_find_block_close`` and ``_strip_stray_delimiters`` + consider "inside a string". + """ + quote: str | None = None + for ch in text: + if quote is not None: + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + return quote is not None + + +_BRACKET_PAIRS = {")": "(", "]": "[", "}": "{"} + +# The operators the evaluator delimits with spaces; derived so the check cannot +# drift from _COMPARISON_OPERATORS. +_WORD_OPERATORS = tuple( + op for op in (" or ", " and ") + _COMPARISON_OPERATORS if op.startswith(" ") +) + + +def _has_unbalanced_bracket(text: str) -> bool: + """True when brackets outside a quoted operand do not nest and match. + + A depth counter is not enough: it calls ``inputs.f(]`` balanced, because the + ``]`` cancels the ``(``. The evaluator then resolves that body to ``None`` and + the comparison is false, which is the inversion this module is trying to keep + out of the suggested correction. Track the opener types instead. + """ + stack: list[str] = [] + quote: str | None = None + for ch in text: + if quote is not None: + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + elif ch in "([{": + stack.append(ch) + elif ch in _BRACKET_PAIRS and (not stack or stack.pop() != _BRACKET_PAIRS[ch]): + return True + return bool(stack) + + +def _has_incomplete_operand(text: str) -> bool: + """True when an operator in *text* is missing an operand on either side. + + Splits on **every** top-level occurrence rather than the first. Checking only + the first is the same defect this module exists to reject one level up: it let + ``inputs.a == inputs.b ==`` through, because the leading ``==`` has operands on + both sides and the scan stopped there. + + Reads ``_COMPARISON_OPERATORS`` from the evaluator rather than restating it, so + the check cannot drift from what ``_evaluate_simple_expression`` splits on. + """ + stripped = text.strip() + if not stripped: + return True + + # `not x` is a valid prefix form; `and x` and `or x` are not, and none of the + # three is valid alone or trailing. The keyword scans below use bare words + # because a leading operator has no space in front of it to match on. + if stripped in ("and", "or", "not") or stripped.endswith(" not"): + return True + # Word operators lose their delimiting space at the ends of a stripped core, so + # a trailing "not in" or a leading "and" needs matching without it. Derived from + # the evaluator's own table rather than restated. + for op in _WORD_OPERATORS: + if stripped.endswith(op.rstrip()) or stripped.startswith(op.lstrip()): + return True + + for op in (" or ", " and ") + _COMPARISON_OPERATORS: + if _find_top_level(stripped, op) == -1: + continue + if any(not segment.strip() for segment in _split_top_level(stripped, op)): + return True + + return _find_top_level(stripped, "|") != -1 and any( + not segment.strip() for segment in _split_top_level(stripped, "|") + ) + + +# The roots _build_namespace supplies. A reference to anything else resolves to +# None, so a correction built on one turns a truthy condition false. +_NAMESPACE_ROOTS = ("inputs", "steps", "item", "fan_in", "context") + +# Exactly what _resolve_dot_path accepts: a name, optionally one numeric index. +_PATH_SEGMENT = re.compile(r"^[\w-]+(\[\d+\])?$") + + +class _ProbeNamespace(dict): + """Namespace for the parse probe: every root exists, every leaf is absent. + + Enough for ``_evaluate_simple_expression`` to walk the grammar without needing + real inputs. Deliberately *not* resolving leaves to a sentinel value: a probe + that answers every lookup also answers ``inputs.count+1``, which is the + malformed shape the probe is meant to expose. + """ + + def __missing__(self, key: str) -> "_ProbeNamespace": # noqa: UP037 # pragma: no cover + return _ProbeNamespace() + + +def _evaluator_rejects(text: str) -> str | None: + """The evaluator's own complaint about how *text* is wired, or ``None``. + + Structural checks cannot establish that a core is parseable -- four rounds of + review found a new shape each time -- so this asks the evaluator. It reports + only the two failures ``_apply_filter`` raises about the expression itself: an + unknown filter name, and a registered filter used in an unsupported form. + + Anything else a probe run raises is about the probe's placeholder values, not + the author's text. ``steps.emit.output.stdout | from_json`` is valid against a + string output and is exercised in ``tests/test_workflows.py``; the probe hands + ``from_json`` a dict and it raises, so treating every error as a rejection + withheld a correction from a perfectly good condition. + """ + try: + _evaluate_simple_expression( + text, {root: _ProbeNamespace() for root in _NAMESPACE_ROOTS} + ) + except ValueError as exc: + message = str(exc) + # Every error _apply_filter raises about the filter *expression* quotes the + # segment back as `got '| ...'`. Its value errors instead name the type they + # received, which under a probe is the placeholder, not anything the author + # wrote -- treating those as rejections withheld corrections from valid + # conditions such as `steps.emit.output.stdout | from_json`. + if "got '| " in message: + return message.split(":", 1)[0] + except Exception: # noqa: BLE001 - probe values, not the author's text + return None + return None + + + +def _looks_numeric(text: str) -> bool: + """Mirror the evaluator's numeric literal test exactly. + + `_evaluate_simple_expression` only calls `float()` when a `.` is present and + `int()` otherwise, so `1e3` is not a number to it -- it falls through to a path + lookup and resolves to None. A bare `float()` here accepted `1e3` and the + correction turned a truthy condition false. + """ + try: + if "." in text: + float(text) + else: + int(text) + except (ValueError, TypeError): + return False + return True + + +def _is_literal(text: str) -> bool: + """Mirror the evaluator's literal tests exactly. + + The string case is the opening quote's *matching close being the final + character*, not first/last-character equality: `'a' 'b'` passes the latter but + is two literals to the evaluator, which falls through to a path lookup. + """ + if text[:1] in ("'", '"') and text.find(text[0], 1) == len(text) - 1: + return True + return text.lower() in ("true", "false", "none", "null") or _looks_numeric(text) + + +def _unresolvable_term(text: str) -> str | None: + """The first operand in *text* the evaluator cannot resolve, or ``None``. + + Walks operands the way ``_evaluate_simple_expression`` does -- filters, then + ``or``/``and``/``not``, then comparisons -- and checks each leaf. A leaf must be + a literal or a dotted path rooted in ``_NAMESPACE_ROOTS``. + + Enumerating broken shapes is what made this take several rounds: each new gate + only knew the shapes named so far. ``inputs.a === inputs.b`` split cleanly on + ``==`` and looked complete, while the evaluator read ``= inputs.b`` as a path + and resolved it to ``None``; ``bogus == 'x'`` passed for the same reason one + level up. Recursing to the leaves covers both without naming either. + """ + stripped = text.strip() + if not stripped: + return "an operand is empty" + + if _find_top_level(stripped, "|") != -1: + segments = _split_top_level(stripped, "|") + reason = _unresolvable_term(segments[0]) + if reason is not None: + return reason + # A filter argument is an ordinary operand to `_apply_filter`, which + # evaluates it with `_evaluate_simple_expression` like any other. Skipping + # it let `inputs.tags | join(bogus)` be offered as paste-ready: `bogus` is + # no namespace root, resolves to None, and the wrapped form then raises + # `join: expected a string separator, got NoneType`. Parse with the same + # pattern `_apply_filter` uses, so a form this does not recognize is left + # to the evaluator probe rather than guessed at here. + for segment in segments[1:]: + match = re.fullmatch(r"(\w+)\((.+)\)", segment.strip()) + if match is None: + continue + reason = _unresolvable_term(match.group(2)) + if reason is not None: + return reason + return None + + for op in (" or ", " and "): + idx = _find_top_level(stripped, op) + if idx != -1: + return _unresolvable_term(stripped[:idx]) or _unresolvable_term( + stripped[idx + len(op):] + ) + + if stripped.startswith("not "): + return _unresolvable_term(stripped[4:]) + + for op in _COMPARISON_OPERATORS: + idx = _find_top_level(stripped, op) + if idx != -1: + return _unresolvable_term(stripped[:idx]) or _unresolvable_term( + stripped[idx + len(op):] + ) + + if _is_literal(stripped): + return None + + # A list literal is a term the evaluator understands, and it recurses into the + # elements rather than resolving the brackets as a name. Not mirroring that + # denied the correction to `inputs.tag in ['x', 'y']` -- a condition wrapping + # repairs completely -- while reporting the list as an unresolvable name. The + # empty-segment skip matches `_evaluate_simple_expression`, which drops them so + # `[1, 2,]` is `[1, 2]` rather than `[1, 2, None]`. + if stripped.startswith("[") and stripped.endswith("]"): + inner = stripped[1:-1].strip() + if not inner: + return None + for element in _split_top_level_commas(inner): + if not element.strip(): + continue + reason = _unresolvable_term(element) + if reason is not None: + return reason + return None + + segments = _split_top_level(stripped, ".") + if not _PATH_SEGMENT.match(segments[0].strip()): + return f"{stripped!r} is not a name the evaluator can resolve" + # `item` is the only root that is not always a mapping: `StepContext.item` is + # `Any` and a fan-out assigns the item value itself, so when that value is a + # list `_resolve_dot_path` indexes it and `item[0] == 'x'` resolves. Every + # other root comes back from `_build_namespace` as a mapping, and the index + # branch returns None for those however it is written -- so the index is + # stripped for `item` alone rather than for roots in general. + root = segments[0].strip() + indexed_root = re.fullmatch(r"([\w-]+)\[\d+\]", root) + if indexed_root is not None and indexed_root.group(1) == "item": + root = indexed_root.group(1) + if root not in _NAMESPACE_ROOTS: + return ( + f"{segments[0].strip()!r} is not one of the namespace roots " + f"({', '.join(_NAMESPACE_ROOTS)})" + ) + for segment in segments[1:]: + if not _PATH_SEGMENT.match(segment.strip()): + return f"{segment.strip()!r} is not a valid path segment" + return None + + +def _wrapping_would_not_repair(core: str) -> str | None: + """Why wrapping *core* in ``{{ }}`` would not yield the expression intended. + + ``None`` when it would. Each branch names something observable about the text + itself, deliberately not the interpolator path it will take: two earlier + versions of this message asserted an internal route -- the raw-close fallback -- + and were wrong, because ``_is_single_expression`` accepts the wrapped form and + sends it down the typed fast path instead. + """ + if not core: + return "there is no expression here to wrap" + if _has_unbalanced_quote(core): + return "the quote opened in it is never closed" + if _has_unbalanced_bracket(core): + return "its brackets do not balance" + if _has_incomplete_operand(core): + return "an operator in it is missing an operand" + unresolvable = _unresolvable_term(core) + if unresolvable is not None: + return unresolvable + rejected = _evaluator_rejects(core) + if rejected is not None: + return f"the evaluator rejects it ({rejected})" + return None + + +def format_condition_remediation(condition: Any) -> str: + """The advice sentence for a condition that is never evaluated. + + ``format_condition_correction`` wraps whatever it is handed, which is right for a + formatter but wrong to advertise as paste-ready when wrapping cannot repair the + input. Measured, each of these was being offered as the fix and each **inverts** + the condition instead: + + " " -> "{{ }}" True -> False + {{ inputs.name == 'abc -> "{{ inputs.name == 'abc }}" True -> False + inputs.name == -> "{{ inputs.name == }}" True -> False + + The author is told the condition is always true, pastes the suggestion, and now + has an always-false one. Naming the fault beats handing back something that looks + authoritative and is not -- the same call already made for + ``condition_has_malformed_expression_block``, which offers no suggestion at all. + """ + core = _strip_stray_delimiters(str(condition)).strip() + reason = _wrapping_would_not_repair(core) + if reason is None: + return "Wrap the expression: " + format_condition_correction(condition) + "." + return ( + f"No correction is offered because {reason}: wrapping it as written would " + "produce a different expression from the one intended, and its result can " + "silently invert the condition rather than repair it. Complete the " + "expression, or use the literal true or false." + ) diff --git a/src/specify_cli/workflows/overlays/_commands.py b/src/specify_cli/workflows/overlays/_commands.py index 549f1ea151..06c4dca835 100644 --- a/src/specify_cli/workflows/overlays/_commands.py +++ b/src/specify_cli/workflows/overlays/_commands.py @@ -214,7 +214,15 @@ def workflow_overlay_add( existed_before = target_path.exists() staged = _stage_workflow_file(target_path.parent) try: - staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8")) + # ``allow_unicode=True`` matches every other YAML writer in the + # repo. Without it every non-ASCII character in a hand-authored + # overlay is rewritten as a ``\uXXXX`` escape, so merely toggling + # an overlay makes the user's own file unreadable. + staged.write_bytes( + yaml.safe_dump(data, sort_keys=False, allow_unicode=True).encode( + "utf-8" + ) + ) backup = _commit_workflow_file(staged, target_path, existed_before) except BaseException: _safe_discard_staged_workflow_file( @@ -267,7 +275,15 @@ def _update_overlay_field( existed_before = path.exists() staged = _stage_workflow_file(path.parent) try: - staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8")) + # ``allow_unicode=True`` matches every other YAML writer in the + # repo. Without it every non-ASCII character in a hand-authored + # overlay is rewritten as a ``\uXXXX`` escape, so merely toggling + # an overlay makes the user's own file unreadable. + staged.write_bytes( + yaml.safe_dump(data, sort_keys=False, allow_unicode=True).encode( + "utf-8" + ) + ) backup = _commit_workflow_file(staged, path, existed_before) except BaseException: _safe_discard_staged_workflow_file(staged, path.parent, existed_before) diff --git a/src/specify_cli/workflows/overlays/schema.py b/src/specify_cli/workflows/overlays/schema.py index 0a018b7af0..969bbd94a9 100644 --- a/src/specify_cli/workflows/overlays/schema.py +++ b/src/specify_cli/workflows/overlays/schema.py @@ -60,7 +60,13 @@ def _validate_safe_id( def _parse_edit(edit_raw: dict[str, Any], idx: int) -> tuple[OverlayEdit | None, str | None]: """Parse a single edit dict into an OverlayEdit or an error string.""" - shorthand_keys = [key for key in _SHORTHAND_OPERATION_KEYS if key in edit_raw] + # Iterate ``edit_raw`` rather than ``_SHORTHAND_OPERATION_KEYS``: the latter + # is a frozenset, whose iteration order varies between processes with + # string-hash randomization, so the error messages built from this list + # named the offending keys in a different order on every run for the very + # same overlay file. Dict keys are always hashable, so the membership test + # is safe in this direction too. + shorthand_keys = [key for key in edit_raw if key in _SHORTHAND_OPERATION_KEYS] has_operation = "operation" in edit_raw operation: str | None = None diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/steps/do_while/__init__.py index 024ced55b5..09c5763a5e 100644 --- a/src/specify_cli/workflows/steps/do_while/__init__.py +++ b/src/specify_cli/workflows/steps/do_while/__init__.py @@ -5,6 +5,12 @@ from typing import Any from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import ( + condition_has_malformed_expression_block, + condition_is_interpolated_to_text, + condition_is_never_evaluated, + format_condition_remediation, +) class DoWhileStep(StepBase): @@ -88,6 +94,49 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"Do-while step {config.get('id', '?')!r}: 'condition' must be a " f"string or boolean, got {type(config['condition']).__name__}." ) + elif condition_is_never_evaluated(config["condition"]): + # A string condition with no ``{{ }}`` block is never evaluated: + # evaluate_expression() returns it unchanged and bool() then makes + # any non-empty text true. `condition: inputs.count > 100` reads as + # a real comparison but always takes every iteration. This is the same + # silent-truthiness mistake the list/dict branch above rejects, and + # GitHub Actions accepts a bare expression in `if:`, so it is easy + # to write by habit. + errors.append( + f"Do-while step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so " + "it is never evaluated as an expression and is always true. " + + format_condition_remediation(config["condition"]) + ) + elif condition_has_malformed_expression_block(config["condition"]): + # Different fault, different advice. Here the block is *not* skipped: + # _interpolate_expressions cannot close it with its quote-aware scan, so it + # falls back to the first raw close and evaluates whatever that truncated. + # `{{ inputs.missing | default('oops }}` reaches the filter parser and raises + # ValueError at run time, so reporting it as "always true" would be wrong + # twice over: it is evaluated, and it does not end up true. + errors.append( + f"Do-while step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} opens a '{{{{' the interpolator cannot " + "close, so it falls back to the first raw '}}' and evaluates a " + "truncated expression instead of the one written. Balance the " + "delimiters and quotes." + ) + elif condition_is_interpolated_to_text(config["condition"]): + # Third fault, third message. The braces are here and they close, but they + # do not cover the whole condition, so evaluate_expression takes its text + # path rather than the typed one: each block is substituted into the + # surrounding string and the result is coerced by bool(). Two blocks joined + # by `and` render "False and False", which is true. No paste-ready + # correction is offered: there is no single right rewrite, because only the + # author knows which grouping the operators were meant to have. + errors.append( + f"Do-while step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} holds more than one '{{{{ }}}}' block, or " + "text around one, so it is substituted into a string and coerced by " + "bool() instead of being evaluated. Put the whole expression inside a " + "single '{{ }}' block." + ) max_iter = config.get("max_iterations") if max_iter is not None: # bool is a subclass of int, so isinstance(True, int) is True and diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/steps/if_then/__init__.py index 7189ff8150..0573785d90 100644 --- a/src/specify_cli/workflows/steps/if_then/__init__.py +++ b/src/specify_cli/workflows/steps/if_then/__init__.py @@ -5,7 +5,13 @@ from typing import Any from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus -from specify_cli.workflows.expressions import evaluate_condition +from specify_cli.workflows.expressions import ( + condition_has_malformed_expression_block, + condition_is_interpolated_to_text, + condition_is_never_evaluated, + format_condition_remediation, + evaluate_condition, +) class IfThenStep(StepBase): @@ -79,6 +85,49 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"If step {config.get('id', '?')!r}: 'condition' must be a " f"string or boolean, got {type(config['condition']).__name__}." ) + elif condition_is_never_evaluated(config["condition"]): + # A string condition with no ``{{ }}`` block is never evaluated: + # evaluate_expression() returns it unchanged and bool() then makes + # any non-empty text true. `condition: inputs.count > 100` reads as + # a real comparison but always takes ``then``. This is the same + # silent-truthiness mistake the list/dict branch above rejects, and + # GitHub Actions accepts a bare expression in `if:`, so it is easy + # to write by habit. + errors.append( + f"If step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so " + "it is never evaluated as an expression and is always true. " + + format_condition_remediation(config["condition"]) + ) + elif condition_has_malformed_expression_block(config["condition"]): + # Different fault, different advice. Here the block is *not* skipped: + # _interpolate_expressions cannot close it with its quote-aware scan, so it + # falls back to the first raw close and evaluates whatever that truncated. + # `{{ inputs.missing | default('oops }}` reaches the filter parser and raises + # ValueError at run time, so reporting it as "always true" would be wrong + # twice over: it is evaluated, and it does not end up true. + errors.append( + f"If step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} opens a '{{{{' the interpolator cannot " + "close, so it falls back to the first raw '}}' and evaluates a " + "truncated expression instead of the one written. Balance the " + "delimiters and quotes." + ) + elif condition_is_interpolated_to_text(config["condition"]): + # Third fault, third message. The braces are here and they close, but they + # do not cover the whole condition, so evaluate_expression takes its text + # path rather than the typed one: each block is substituted into the + # surrounding string and the result is coerced by bool(). Two blocks joined + # by `and` render "False and False", which is true. No paste-ready + # correction is offered: there is no single right rewrite, because only the + # author knows which grouping the operators were meant to have. + errors.append( + f"If step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} holds more than one '{{{{ }}}}' block, or " + "text around one, so it is substituted into a string and coerced by " + "bool() instead of being evaluated. Put the whole expression inside a " + "single '{{ }}' block." + ) if "then" not in config: errors.append( f"If step {config.get('id', '?')!r} is missing 'then' field." diff --git a/src/specify_cli/workflows/steps/switch/__init__.py b/src/specify_cli/workflows/steps/switch/__init__.py index 690df0f19a..8a2e4b343e 100644 --- a/src/specify_cli/workflows/steps/switch/__init__.py +++ b/src/specify_cli/workflows/steps/switch/__init__.py @@ -12,7 +12,8 @@ class SwitchStep(StepBase): """Multi-branch dispatch on an expression. Evaluates ``expression:`` once, matches against ``cases:`` keys - (exact match, string-coerced). Falls through to ``default:`` if + (exact match; the resolved value is string-coerced and stripped of + surrounding whitespace first). Falls through to ``default:`` if no case matches. """ @@ -22,8 +23,18 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: expression = config.get("expression", "") value = evaluate_expression(expression, context) - # String-coerce for matching - str_value = str(value) if value is not None else "" + # String-coerce for matching, stripping surrounding whitespace first. + # The value a switch dispatches on is most often captured command + # output, and a ``shell`` step stores ``proc.stdout`` verbatim, so + # ``run: echo approve`` resolves to ``"approve\n"`` and matches no + # ``approve:`` case -- the switch silently falls through to ``default:`` + # while still reporting COMPLETED. A workflow cannot strip it itself: + # the registered filters are default/join/map/contains/from_json, there + # is no ``trim``. ``evaluate_condition`` and ``InitStep._resolve_bool`` + # already strip before matching a resolved string against declared + # literals, and case keys are exactly such literals. ``expression_value`` + # below still reports the raw value, so nothing downstream loses it. + str_value = str(value).strip() if value is not None else "" cases = config.get("cases", {}) if not isinstance(cases, dict): @@ -96,6 +107,19 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"Switch step {config.get('id', '?')!r} is missing " f"'expression' field." ) + # Every other control-flow step requires its branch payload: ``if`` + # requires ``then``, ``fan-out`` requires ``items`` and ``step``, + # ``fan-in`` a non-empty ``wait_for``, ``gate`` a ``message``. Without + # the same check, a switch whose ``cases:`` block is missing or mistyped + # (``case:`` is the obvious slip) validates clean and then reports + # COMPLETED with ``matched_case: "__default__"`` -- a default it may not + # even declare -- having dispatched nothing. That is the "silent empty + # result + COMPLETED" wiring bug the fan-in guard exists to prevent. + if "cases" not in config: + errors.append( + f"Switch step {config.get('id', '?')!r} is missing " + f"'cases' field." + ) cases = config.get("cases", {}) if not isinstance(cases, dict): errors.append( diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/steps/while_loop/__init__.py index e80b93d7f2..8238917320 100644 --- a/src/specify_cli/workflows/steps/while_loop/__init__.py +++ b/src/specify_cli/workflows/steps/while_loop/__init__.py @@ -5,7 +5,13 @@ from typing import Any from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus -from specify_cli.workflows.expressions import evaluate_condition +from specify_cli.workflows.expressions import ( + condition_has_malformed_expression_block, + condition_is_interpolated_to_text, + condition_is_never_evaluated, + format_condition_remediation, + evaluate_condition, +) class WhileStep(StepBase): @@ -97,6 +103,49 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"While step {config.get('id', '?')!r}: 'condition' must be a " f"string or boolean, got {type(config['condition']).__name__}." ) + elif condition_is_never_evaluated(config["condition"]): + # A string condition with no ``{{ }}`` block is never evaluated: + # evaluate_expression() returns it unchanged and bool() then makes + # any non-empty text true. `condition: inputs.count > 100` reads as + # a real comparison but always takes every iteration. This is the same + # silent-truthiness mistake the list/dict branch above rejects, and + # GitHub Actions accepts a bare expression in `if:`, so it is easy + # to write by habit. + errors.append( + f"While step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so " + "it is never evaluated as an expression and is always true. " + + format_condition_remediation(config["condition"]) + ) + elif condition_has_malformed_expression_block(config["condition"]): + # Different fault, different advice. Here the block is *not* skipped: + # _interpolate_expressions cannot close it with its quote-aware scan, so it + # falls back to the first raw close and evaluates whatever that truncated. + # `{{ inputs.missing | default('oops }}` reaches the filter parser and raises + # ValueError at run time, so reporting it as "always true" would be wrong + # twice over: it is evaluated, and it does not end up true. + errors.append( + f"While step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} opens a '{{{{' the interpolator cannot " + "close, so it falls back to the first raw '}}' and evaluates a " + "truncated expression instead of the one written. Balance the " + "delimiters and quotes." + ) + elif condition_is_interpolated_to_text(config["condition"]): + # Third fault, third message. The braces are here and they close, but they + # do not cover the whole condition, so evaluate_expression takes its text + # path rather than the typed one: each block is substituted into the + # surrounding string and the result is coerced by bool(). Two blocks joined + # by `and` render "False and False", which is true. No paste-ready + # correction is offered: there is no single right rewrite, because only the + # author knows which grouping the operators were meant to have. + errors.append( + f"While step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} holds more than one '{{{{ }}}}' block, or " + "text around one, so it is substituted into a string and coerced by " + "bool() instead of being evaluated. Put the whole expression inside a " + "single '{{ }}' block." + ) max_iter = config.get("max_iterations") if max_iter is not None: # bool is a subclass of int, so isinstance(True, int) is True and diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index 2cd83bd7c0..2e13af58ae 100644 --- a/templates/commands/analyze.md +++ b/templates/commands/analyze.md @@ -1,9 +1,9 @@ --- description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation. scripts: - sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks - ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks - py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks + sh: scripts/bash/check-prerequisites.sh --json --require-spec --require-tasks --include-tasks + ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireSpec -RequireTasks -IncludeTasks + py: scripts/python/check_prerequisites.py --json --require-spec --require-tasks --include-tasks --- ## User Input diff --git a/templates/commands/converge.md b/templates/commands/converge.md index eadb96ee58..a177c31371 100644 --- a/templates/commands/converge.md +++ b/templates/commands/converge.md @@ -1,9 +1,9 @@ --- description: Assess the current codebase against the feature's spec, plan, and tasks, then append any remaining unbuilt work as new tasks to tasks.md so implement can complete it. scripts: - sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks - ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks - py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks + sh: scripts/bash/check-prerequisites.sh --json --require-spec --require-tasks --include-tasks + ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireSpec -RequireTasks -IncludeTasks + py: scripts/python/check_prerequisites.py --json --require-spec --require-tasks --include-tasks --- ## User Input diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index c458a810ba..6db4dab769 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -340,7 +340,10 @@ def test_build_escapes_markup_in_output_path(project: Path): assert result.exit_code == 0, repr(result.exception) assert list(out_dir.glob("*.zip")), "the artifact should still be built" - assert "dist[bold]out" in strip_ansi(result.output), ( + # Join across Rich's wrap points: the success line prints an absolute path, + # so the console folds it mid-token whenever the temp directory is long + # enough, which is a property of the runner's path, not of the escaping. + assert "dist[bold]out" in "".join(strip_ansi(result.output).split()), ( "the reported path must match the directory actually written" ) @@ -786,6 +789,38 @@ def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None assert asset_calls[0][1] == {"Accept": "application/octet-stream"} +def test_bundle_info_rejects_utf16_remote_manifest_like_local_sources(project: Path): + """A downloaded (non-zip) bundle.yml must be decoded strictly as UTF-8. + + ``yamlio.load_yaml`` decodes local ``bundle.yml`` sources strictly as + UTF-8, so a well-formed UTF-16 manifest (a realistic PowerShell + ``Out-File`` output) is rejected. Feeding the downloaded bytes straight + to ``yaml.safe_load(io.BytesIO(raw))`` let PyYAML's Reader honour the + UTF-16 BOM and silently *accept* the same manifest instead, diverging + from local/zip sources (the zip branch of this same download path was + already fixed for the identical bug). + """ + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" + manifest_yaml_utf16 = yaml.safe_dump(valid_manifest_dict()).encode("utf-16") + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + return FakeBundleResponse(manifest_yaml_utf16, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 1 + output_flat = " ".join(result.output.split()) + assert "could not be read" in output_flat.lower() + + def test_bundle_info_passes_through_api_asset_url(project: Path): """bundle info passes a direct GitHub API asset URL through with octet-stream.""" api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/77" diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py index 15a844118b..3fd3a5c53d 100644 --- a/tests/contract/test_catalog_schema.py +++ b/tests/contract/test_catalog_schema.py @@ -238,6 +238,15 @@ def test_catalog_entry_rejects_string_tags(): CatalogEntry.from_dict(data) +def test_catalog_entry_rejects_non_string_tag_members(): + from specify_cli.bundler.models.catalog import CatalogEntry + + data = catalog_entry_dict("demo") + data["tags"] = ["valid", 1] + with pytest.raises(BundlerError, match="'tags' must be a list of strings"): + CatalogEntry.from_dict(data) + + def test_catalog_entry_rejects_non_boolean_verified(): from specify_cli.bundler.models.catalog import CatalogEntry @@ -300,6 +309,23 @@ def test_catalog_entry_rejects_non_mapping_provides(): CatalogEntry.from_dict(data) +def test_load_payload_rejects_unsupported_schema_version(): + payload = catalog_payload({"demo": catalog_entry_dict("demo")}) + payload["schema_version"] = "2.0" + + with pytest.raises(BundlerError, match="Unsupported catalog schema version"): + load_catalog_payload(payload) + + +def test_load_payload_accepts_matching_or_absent_schema_version(): + payload = catalog_payload({"demo": catalog_entry_dict("demo")}) + payload["schema_version"] = "1.5" + assert "demo" in load_catalog_payload(payload) + + payload.pop("schema_version") + assert "demo" in load_catalog_payload(payload) + + @pytest.mark.parametrize("field", ["requires", "provides"]) @pytest.mark.parametrize("bad", [[], "", 0, False]) def test_catalog_entry_rejects_falsy_non_mapping(field, bad): diff --git a/tests/contract/test_manifest_schema.py b/tests/contract/test_manifest_schema.py index 2f38620423..4784bdf462 100644 --- a/tests/contract/test_manifest_schema.py +++ b/tests/contract/test_manifest_schema.py @@ -165,6 +165,25 @@ def test_string_mcp_rejected_not_split_per_character(): BundleManifest.from_dict(data) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("tags", [1]), + ("requires.tools", [False]), + ("requires.mcp", [{}]), + ], +) +def test_string_list_fields_reject_non_string_members(field, value): + data = valid_manifest_dict() + if field == "tags": + data["tags"] = value + else: + data["requires"][field.split(".", 1)[1]] = value + + with pytest.raises(BundlerError, match="must be a list of strings"): + BundleManifest.from_dict(data) + + def test_string_integration_rejected_not_silently_dropped(): # A present-but-non-mapping 'integration' (a bare string) was silently # dropped, leaving the bundle wrongly integration-agnostic. Reject it like diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index f74aeaaa36..f5159c1d41 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -188,6 +188,22 @@ def test_non_utf8_manifest_skipped(self, tmp_path): assert collect_extension_events(tmp_path) == {} + def test_unreadable_manifest_skipped(self, tmp_path, monkeypatch): + ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" + ext_dir.mkdir(parents=True) + manifest = ext_dir / "extension.yml" + manifest.write_text("events: {}\n", encoding="utf-8") + real_read_text = Path.read_text + + def unreadable(path, *args, **kwargs): + if path == manifest: + raise OSError("simulated read failure") + return real_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", unreadable) + + assert collect_extension_events(tmp_path) == {} + def test_event_command_ref_canonicalized_via_manifest(self, tmp_path): """R1: events are read from a validated ExtensionManifest, so an obsolete command ref (e.g. my-ext.boot) is canonicalized @@ -1340,6 +1356,37 @@ def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): assert argv[1] == "-File" assert PurePath(argv[2]).as_posix().endswith(".specify/scripts/powershell/boot.ps1") + def test_ps_variant_returns_none_when_no_launcher_available(self, tmp_path, monkeypatch): + """When NEITHER pwsh nor powershell is on PATH, the resolver must + degrade to "no argv" like every other failure branch in this + function — not fall back to a bare "pwsh" string, which would make + subprocess.run() raise FileNotFoundError instead of the caller's + clean "No script found for event command" warning. + + The generated dispatcher's documented stdlib mirror, `_resolve_argv`, + already does this correctly (`if not launcher: return None`). + """ + from specify_cli.events import _resolve_event_command_argv + import shutil as _shutil + + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "boot.md").write_text( + "---\n" + "description: \"Boot\"\n" + "scripts:\n" + " ps: scripts/powershell/boot.ps1\n" + "---\nBody\n", + encoding="utf-8", + ) + ps_dir = tmp_path / ".specify" / "scripts" / "powershell" + ps_dir.mkdir(parents=True) + (ps_dir / "boot.ps1").write_text("exit 0\n", encoding="utf-8") + + monkeypatch.setattr(_shutil, "which", lambda name: None) + argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) + assert argv is None + def test_run_command_executes_with_project_root_cwd(self, tmp_path): """R1: the event command runs with cwd set to the project root, not the caller's arbitrary working directory, so project-relative script logic diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index 9b02632992..c414c3d8ea 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -700,6 +700,36 @@ def test_scripts_not_a_list(self, tmp_path): with pytest.raises(IntegrationDescriptorError, match="expected a list"): IntegrationDescriptor(p) + @pytest.mark.parametrize( + "content", ["[]", "false", "0", "''", "null", "~", "NULL", "- a", "hello"] + ) + def test_falsy_non_mapping_descriptor_reports_shape_error(self, tmp_path, content): + """Every non-mapping document reports the mapping-shape error. + + `_validate` opens with an `isinstance(self.data, dict)` check, so a + truthy non-mapping (`- a`, `hello`) correctly reported "Descriptor root + must be a YAML mapping". `_load`'s plain `yaml.safe_load(fh) or {}` + masked that for the falsy shapes `[]`, `false`, `0`, `''` (coerced to + an empty mapping) and for an explicit null scalar (`null`, `~`, `NULL` + -- indistinguishable from an empty document by `safe_load` alone), so + those five reported "Missing required field: schema_version" instead. + """ + p = tmp_path / "integration.yml" + p.write_text(content) + with pytest.raises( + IntegrationDescriptorError, + match="Descriptor root must be a YAML mapping", + ): + IntegrationDescriptor(p) + + @pytest.mark.parametrize("content", ["", "---"]) + def test_empty_document_still_reports_missing_fields(self, tmp_path, content): + """Empty documents are normalized to an empty mapping, so missing fields are reported.""" + p = tmp_path / "integration.yml" + p.write_text(content) + with pytest.raises(IntegrationDescriptorError, match="Missing required field: schema_version"): + IntegrationDescriptor(p) + def test_file_not_found(self, tmp_path): with pytest.raises(IntegrationDescriptorError, match="Descriptor not found"): IntegrationDescriptor(tmp_path / "nonexistent.yml") @@ -715,6 +745,10 @@ def test_get_hash(self, tmp_path): desc = IntegrationDescriptor(p) h = desc.get_hash() assert h.startswith("sha256:") + import hashlib + content = p.read_bytes() + expected = f"sha256:{hashlib.sha256(content).hexdigest()}" + assert h == expected def test_tools_accessor(self, tmp_path): data = {**VALID_DESCRIPTOR, "requires": { diff --git a/tests/integrations/test_integration_docker_agent.py b/tests/integrations/test_integration_docker_agent.py new file mode 100644 index 0000000000..d962ba978f --- /dev/null +++ b/tests/integrations/test_integration_docker_agent.py @@ -0,0 +1,133 @@ +"""Tests for the Docker Agent integration.""" + +import pytest + +from specify_cli.integrations.docker_agent import DockerAgentIntegration + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestDockerAgentIntegration(SkillsIntegrationTests): + KEY = "docker-agent" + FOLDER = ".agents/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".agents/skills" + + def test_multi_install_is_opt_in(self): + assert DockerAgentIntegration().multi_install_safe is False + + +def test_extra_args_are_applied_to_build_exec_args(monkeypatch): + monkeypatch.setenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", + "./agent.yaml --agent root --model openai/gpt-5", + ) + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/docker" if name == "docker" else None, + ) + monkeypatch.setattr("subprocess.run", lambda *args, **kwargs: type("Result", (), {"returncode": 0})()) + + args = DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + assert args == [ + "docker", + "agent", + "run", + "--exec", + "./agent.yaml", + "--agent", + "root", + "--model", + "openai/gpt-5", + "--", + "prompt", + ] + + +def test_prompt_is_passed_after_agent_config(monkeypatch): + monkeypatch.setenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml" + ) + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/docker" if name == "docker" else None, + ) + monkeypatch.setattr("subprocess.run", lambda *args, **kwargs: type("Result", (), {"returncode": 0})()) + + args = DockerAgentIntegration().build_exec_args( + "/speckit-specify prompt", output_json=False + ) + + assert args == [ + "docker", + "agent", + "run", + "--exec", + "./agent.yaml", + "--", + "/speckit-specify prompt", + ] + + +def test_prompt_starting_with_flag_is_delimited(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml") + monkeypatch.setattr("shutil.which", lambda name: None) + + args = DockerAgentIntegration().build_exec_args("--help", output_json=False) + + assert args == ["docker-agent", "run", "--exec", "./agent.yaml", "--", "--help"] + + +def test_requires_agent_config(monkeypatch): + monkeypatch.delenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", raising=False) + with pytest.raises(ValueError, match="requires an agent configuration reference"): + DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + +def test_uses_standalone_executable(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml") + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/docker-agent" if name == "docker-agent" else None, + ) + + args = DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + assert args == ["docker-agent", "run", "--exec", "./agent.yaml", "--", "prompt"] + + +def test_standalone_executable_has_priority(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml") + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/docker-agent") + + args = DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + assert args == ["docker-agent", "run", "--exec", "./agent.yaml", "--", "prompt"] + + +def test_executable_override(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml") + monkeypatch.setenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", "/opt/docker-agent" + ) + + args = DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + assert args == ["/opt/docker-agent", "run", "--exec", "./agent.yaml", "--", "prompt"] + + +def test_docker_executable_override_uses_agent_subcommand(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml") + monkeypatch.setenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", "/opt/docker" + ) + + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: type("Result", (), {"returncode": 0})(), + ) + + args = DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + assert args == ["/opt/docker", "agent", "run", "--exec", "./agent.yaml", "--", "prompt"] diff --git a/tests/integrations/test_integration_dsh.py b/tests/integrations/test_integration_dsh.py new file mode 100644 index 0000000000..4a772c826e --- /dev/null +++ b/tests/integrations/test_integration_dsh.py @@ -0,0 +1,314 @@ +"""Tests for DshIntegration (DeepSeek Harness).""" + +import json + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestDshIntegration(SkillsIntegrationTests): + KEY = "dsh" + FOLDER = ".dsh/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".dsh/skills" + + def test_options_include_skills_flag(self): + """Not applicable to DSH — DSH is always skills-based with no --skills flag.""" + pytest.skip("DSH is always skills-based and does not expose a --skills option") + + def test_options_do_not_include_skills_flag(self): + """DSH is always skills-based; no --skills option is exposed.""" + i = get_integration(self.KEY) + assert i is not None + opts = i.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 0, ( + "DSH is always skills-based and should not expose a --skills option" + ) + + +class TestDshBuildExecArgs: + """Regression tests for DshIntegration.build_exec_args. + + DSH's one-shot mode is ``dsh --profile headless ""``. The CLI has + no structured-output or model flag, so ``output_json``/``model`` must + not add anything, and the integration must stay CLI-dispatchable + (``None`` is the IDE-only sentinel checked by CommandStep). + """ + + def test_returns_args_not_none_for_dispatch(self): + """DSH is CLI-dispatchable; build_exec_args must not return None.""" + from specify_cli.integrations.dsh import DshIntegration + + impl = DshIntegration() + args = impl.build_exec_args("/speckit-specify build photo albums") + assert args is not None, ( + "DshIntegration.build_exec_args must not return None. " + "None is the codebase sentinel for IDE-only integrations; " + "DSH is dispatchable via 'dsh --profile headless'." + ) + assert args == [ + "dsh", + "--profile", + "headless", + "/speckit-specify build photo albums", + ] + + def test_output_json_and_model_do_not_change_command_line(self): + """DSH has no --output-format/--model flags for the headless profile.""" + from specify_cli.integrations.dsh import DshIntegration + + impl = DshIntegration() + base = impl.build_exec_args("hello") + assert impl.build_exec_args("hello", output_json=True) == base + assert impl.build_exec_args("hello", output_json=False) == base + assert impl.build_exec_args("hello", model="deepseek-chat") == base + + def test_extra_args_precede_headless_task(self, monkeypatch): + """Launcher options must appear before DSH's task positional.""" + from specify_cli.integrations.dsh import DshIntegration + + monkeypatch.setenv( + "SPECKIT_INTEGRATION_DSH_EXTRA_ARGS", "--patch custom.yml" + ) + + assert DshIntegration().build_exec_args("/speckit-plan ship it") == [ + "dsh", + "--profile", + "headless", + "--patch", + "custom.yml", + "/speckit-plan ship it", + ] + + +class TestDshInitFlow: + """--integration dsh creates expected files.""" + + def test_integration_dsh_creates_skills(self, tmp_path): + """--integration dsh should create skills in .dsh/skills.""" + runner = CliRunner() + target = tmp_path / "test-proj" + result = runner.invoke( + app, + ["init", str(target), "--integration", "dsh", "--ignore-agent-tools", "--script", "sh"], + ) + + assert result.exit_code == 0, f"init --integration dsh failed: {result.output}" + assert (target / ".dsh" / "skills" / "speckit-plan" / "SKILL.md").exists() + + +class TestDshNextSteps: + """CLI output tests for DSH next-steps display.""" + + def test_init_next_steps_show_dsh_skill_guidance(self, tmp_path): + """init --integration dsh should guide users to .dsh/skills and /speckit-*.""" + runner = CliRunner() + target = tmp_path / "dsh-next-steps" + result = runner.invoke( + app, + [ + "init", + str(target), + "--integration", + "dsh", + "--ignore-agent-tools", + "--script", + "sh", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, f"init --integration dsh failed: {result.output}" + assert "Start DSH" in result.output, ( + f"Expected DSH start guidance in next steps but got:\n{result.output}" + ) + assert "dsh web" in result.output, ( + f"Expected the 'dsh web' launch command in next steps but got:\n{result.output}" + ) + assert ".dsh/skills" in result.output, ( + f"Expected .dsh/skills install path in next steps but got:\n{result.output}" + ) + assert "/speckit-plan" in result.output, ( + f"Expected /speckit-plan in next steps but got:\n{result.output}" + ) + assert "/speckit.plan" not in result.output, ( + f"Should not show /speckit.plan for DSH skills mode:\n{result.output}" + ) + + +class TestDshSkillCompatibility: + """DSH-specific invariants the generated skills must satisfy. + + The DSH filesystem skill provider discovers one-level-deep + ``/SKILL.md`` bundles and parses the frontmatter as an open YAML + object, requiring a kebab-case ``name`` and a ``description``; extra + keys (``compatibility``, ``metadata``) are tolerated. These tests pin + the properties DSH relies on so a template change cannot silently + break discovery. + """ + + def _setup_skills(self, tmp_path): + integration = get_integration("dsh") + manifest = IntegrationManifest("dsh", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + return tmp_path / ".dsh" / "skills" + + def test_skill_names_are_kebab_case(self, tmp_path): + import re + + skills_dir = self._setup_skills(tmp_path) + skill_dirs = [d for d in skills_dir.iterdir() if d.is_dir()] + assert skill_dirs, "no skill directories were created" + for skill_dir in skill_dirs: + assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", skill_dir.name), ( + f"skill directory {skill_dir.name!r} is not kebab-case; " + "DSH rejects non-kebab-case skill names" + ) + + def test_skill_frontmatter_has_name_and_description(self, tmp_path): + import yaml + + skills_dir = self._setup_skills(tmp_path) + for skill_dir in sorted(skills_dir.iterdir()): + skill_file = skill_dir / "SKILL.md" + assert skill_file.exists(), f"missing SKILL.md in {skill_dir}" + content = skill_file.read_text(encoding="utf-8") + assert content.startswith("---\n"), f"{skill_file} missing frontmatter" + lines = content.splitlines(keepends=True) + close = next( + i for i in range(1, len(lines)) if lines[i].rstrip() == "---" + ) + frontmatter = yaml.safe_load("".join(lines[1:close])) + assert isinstance(frontmatter, dict) + # DSH requires a non-empty name matching the bundle directory and + # a non-empty description for its model-facing skill catalog. + assert frontmatter.get("name") == skill_dir.name + assert isinstance(frontmatter.get("description"), str) + assert frontmatter["description"].strip() + + def test_skill_definition_is_one_level_deep(self, tmp_path): + """DSH discovery only recognizes //SKILL.md — the + SKILL.md file must sit directly inside a single skill directory, + not in nested subdirectories.""" + skills_dir = self._setup_skills(tmp_path) + for skill_dir in sorted(skills_dir.iterdir()): + if not skill_dir.is_dir(): + continue + assert (skill_dir / "SKILL.md").is_file() + + +class TestDshMultiInstallSafe: + """DSH confines itself to an isolated ``.dsh/`` root that no other + integration touches, so it must be declared multi-install safe.""" + + def test_multi_install_safe_is_true(self): + integration = get_integration("dsh") + assert integration.multi_install_safe is True + + def test_dsh_root_does_not_overlap_other_safe_integrations(self): + from pathlib import PurePosixPath + + from specify_cli.integrations import INTEGRATION_REGISTRY + + dsh_root = PurePosixPath(".dsh") + for key, integration in INTEGRATION_REGISTRY.items(): + if key == "dsh" or not integration.multi_install_safe: + continue + folder = (integration.config or {}).get("folder") + if not folder: + continue + other = PurePosixPath(str(folder).rstrip("/")) + for left, right in ((dsh_root, other), (other, dsh_root)): + try: + left.relative_to(right) + except ValueError: + continue + raise AssertionError( + f"dsh agent root .dsh overlaps multi-install-safe " + f"integration {key!r} root {other}" + ) + + +class TestDshHookInvocations: + """DSH is in ALWAYS_SLASH_AGENTS: hook messages and init output must + reference slash-invokable skills regardless of the persisted ai_skills + flag, because the DSH Web GUI invokes skills as ``/speckit-``.""" + + def test_hooks_render_skill_invocation(self, tmp_path): + from specify_cli.extensions import HookExecutor + + project = tmp_path / "dsh-hooks" + project.mkdir() + init_options = project / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "dsh", "ai_skills": False})) + + hook_executor = HookExecutor(project) + message = hook_executor.format_hook_message( + "before_plan", + [ + { + "extension": "test-ext", + "command": "speckit.plan", + "optional": False, + }, + ], + ) + + assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message + + def test_init_persists_ai_skills_for_dsh(self, tmp_path, monkeypatch): + """specify init --integration dsh must persist ai_skills: true, + so HookExecutor renders slash-skill invocations.""" + from specify_cli.extensions import HookExecutor + + project = tmp_path / "dsh-init-test" + project.mkdir() + monkeypatch.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + "dsh", + "--script", + "sh", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, f"init failed: {result.output}" + + opts_path = project / ".specify" / "init-options.json" + assert opts_path.exists() + opts = json.loads(opts_path.read_text(encoding="utf-8")) + assert opts.get("ai") == "dsh" + assert opts.get("ai_skills") is True, ( + f"init must persist ai_skills=true for DSH, got: {opts.get('ai_skills')}" + ) + + hook_executor = HookExecutor(project) + message = hook_executor.format_hook_message( + "before_plan", + [ + { + "extension": "test-ext", + "command": "speckit.plan", + "optional": False, + }, + ], + ) + assert "Executing: `/speckit-plan`" in message, ( + "Hook rendering must produce /speckit-plan for DSH" + ) + assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message diff --git a/tests/integrations/test_integration_qodercli.py b/tests/integrations/test_integration_qodercli.py index 29a6d16d29..f30f62cae0 100644 --- a/tests/integrations/test_integration_qodercli.py +++ b/tests/integrations/test_integration_qodercli.py @@ -1,10 +1,39 @@ """Tests for QodercliIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +import pytest +from specify_cli.integrations import get_integration -class TestQodercliIntegration(MarkdownIntegrationTests): +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestQodercliIntegration(SkillsIntegrationTests): KEY = "qodercli" FOLDER = ".qoder/" - COMMANDS_SUBDIR = "commands" - REGISTRAR_DIR = ".qoder/commands" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".qoder/skills" + + def test_options_include_skills_flag(self): + """Not applicable — Qoder IDE 1.24+ is always skills-based.""" + pytest.skip( + "Qoder is always skills-based and does not expose a --skills option" + ) + + def test_options_do_not_include_skills_flag(self): + """Qoder is always skills-based; no --skills option is exposed.""" + i = get_integration(self.KEY) + assert i is not None + opts = i.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 0, ( + "Qoder is always skills-based and should not expose a --skills option" + ) + + def test_requires_cli_is_true(self): + """Qoder CLI is a CLI-based agent; requires_cli must remain True.""" + i = get_integration(self.KEY) + assert i is not None + assert i.config is not None + assert i.config["requires_cli"] is True + assert i.config["name"] == "Qoder CLI" + assert i.multi_install_safe is True diff --git a/tests/integrations/test_integration_rovodev.py b/tests/integrations/test_integration_rovodev.py index 5bdafc25f9..5e36c7551f 100644 --- a/tests/integrations/test_integration_rovodev.py +++ b/tests/integrations/test_integration_rovodev.py @@ -155,6 +155,45 @@ def test_prompt_wrapper_format(self, tmp_path): f"{prompt_file} has unexpected wrapper format" ) + @pytest.mark.parametrize( + "bad_name", + [["speckit-plan", "speckit-tasks"], {"a": 1}], + ids=["sequence", "mapping"], + ) + def test_prompts_manifest_merge_tolerates_non_scalar_name( + self, tmp_path, bad_name + ): + """An unhashable `name` in a user-edited prompts.yml must not crash setup. + + `_read_prompts_yml` only filters at the entry level, never validating + the entry's `name`, so a YAML sequence or mapping there reached a dict + membership test and raised a raw `TypeError: unhashable type` out of + `setup()` — aborting every `specify init` / `integration install` for + rovodev on that project and leaving prompts.yml unwritten. + """ + impl = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + + prompts_manifest = tmp_path / ".rovodev" / "prompts.yml" + prompts_manifest.parent.mkdir(parents=True, exist_ok=True) + prompts_manifest.write_text( + yaml.safe_dump( + {"prompts": [{"name": bad_name, "content_file": "prompts/x.md"}]} + ), + encoding="utf-8", + ) + + impl.setup(tmp_path, manifest, script_type="sh") + + data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8")) + names = [entry.get("name") for entry in data["prompts"]] + # The malformed user entry is preserved verbatim... + assert bad_name in names, names + # ...and the generated entries were still written. + assert any( + isinstance(n, str) and n.startswith("speckit-") for n in names + ), names + def test_prompts_manifest_merge_preserves_user_entries(self, tmp_path): impl = get_integration(self.KEY) manifest = IntegrationManifest(self.KEY, tmp_path) diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 994fecb148..eaeecc6740 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -3153,6 +3153,66 @@ def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path): f"after upgrade, found: {[f.name for f in core_remaining]}" ) + def test_upgrade_migrates_qodercli_extension_commands_to_skills(self, tmp_path): + """Qoder upgrade retires old extension commands after skills exist.""" + project = _init_project(tmp_path, "qodercli") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + skills = project / ".qoder" / "skills" + commands = project / ".qoder" / "commands" + commands.mkdir(parents=True) + + manifest_path = ( + project / ".specify" / "integrations" / "qodercli.manifest.json" + ) + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + legacy_manifest_files = {} + for path, info in manifest_data["files"].items(): + skill_path = project / path + command_name = skill_path.parent.name.replace("speckit-", "speckit.", 1) + legacy_path = commands / f"{command_name}.md" + legacy_path.write_bytes(skill_path.read_bytes()) + legacy_manifest_files[ + legacy_path.relative_to(project).as_posix() + ] = info + manifest_data["files"] = legacy_manifest_files + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + + registry_path = project / ".specify" / "extensions" / ".registry" + registry = json.loads(registry_path.read_text(encoding="utf-8")) + git_metadata = registry["extensions"]["git"] + registered_commands = git_metadata["registered_commands"]["qodercli"] + for command_name in registered_commands: + skill_name = command_name.replace("speckit.", "speckit-", 1).replace( + ".", "-" + ) + old_command = commands / f"{command_name}.md" + old_command.write_bytes( + (skills / skill_name / "SKILL.md").read_bytes() + ) + missing_replacement = commands / "speckit.git.missing.md" + missing_replacement.write_text("# preserve until replaced\n", encoding="utf-8") + registered_commands.append("speckit.git.missing") + git_metadata["registered_skills"] = [] + registry_path.write_text(json.dumps(registry), encoding="utf-8") + + shutil.rmtree(skills) + result = _run_in_project(project, [ + "integration", "upgrade", "qodercli", "--script", "sh", "--force", + ]) + assert result.exit_code == 0, f"upgrade failed: {result.output}" + + for command_name in registered_commands[:-1]: + skill_name = command_name.replace("speckit.", "speckit-", 1).replace( + ".", "-" + ) + assert (skills / skill_name / "SKILL.md").is_file() + assert not (commands / f"{command_name}.md").exists() + assert missing_replacement.is_file(), ( + "a legacy command must remain when no replacement skill was written" + ) + def test_upgrade_kilocode_legacy_dir_rejects_installed_preset_overrides( self, tmp_path ): diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py index 23627d316d..1a55c9ae87 100644 --- a/tests/integrations/test_integration_zed.py +++ b/tests/integrations/test_integration_zed.py @@ -143,6 +143,8 @@ def _render_invocation(project_path, ai: str, ai_skills: bool) -> str: ("devin", False, "/speckit-plan"), ("grok", True, "/speckit-plan"), ("grok", False, "/speckit-plan"), + ("qodercli", True, "/speckit-plan"), + ("qodercli", False, "/speckit-plan"), ("trae", True, "/speckit-plan"), ("trae", False, "/speckit-plan"), ("zed", True, "/speckit-plan"), diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 0d0a724bd8..87b30a48d3 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -28,7 +28,7 @@ "gemini", "tabnine", # Stage 5 — skills, generic & option-driven integrations "codex", "kimi", "agy", "zed", "generic", - "droid", "command-code", + "droid", "command-code", "dsh", ] diff --git a/tests/parity_helpers.py b/tests/parity_helpers.py index 27627dab5b..3a3878de8d 100644 --- a/tests/parity_helpers.py +++ b/tests/parity_helpers.py @@ -83,8 +83,17 @@ def clean_env() -> dict[str, str]: def run( - cmd: list[str], repo: Path, env: dict[str, str] | None = None + cmd: list[str], + repo: Path, + env: dict[str, str] | None = None, + timeout: float | None = None, ) -> subprocess.CompletedProcess[str]: + """Run a script variant. + + ``timeout`` guards cases whose regression mode is a hang rather than a bad + value; without it such a failure would stall the suite instead of failing + it. ``subprocess.TimeoutExpired`` propagates so the test reports the hang. + """ return subprocess.run( cmd, cwd=repo, @@ -92,6 +101,7 @@ def run( text=True, check=False, env=env if env is not None else clean_env(), + timeout=timeout, ) diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 0cebe7bc33..16dbcae815 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -24,7 +24,9 @@ "command-code", "cursor-agent", "devin", + "docker-agent", "droid", + "dsh", "firebender", "forge", "gemini", diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 6711334a93..38e12edd5f 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -205,7 +205,7 @@ def test_multiple_entries(self, tmp_path): def test_invalid_json_raises(self, tmp_path): cfg = tmp_path / "auth.json" cfg.write_text("not json") - with pytest.raises(json.JSONDecodeError): + with pytest.raises(ValueError, match="invalid JSON"): load_auth_config(cfg) def test_not_object_raises(self, tmp_path): @@ -345,7 +345,7 @@ def test_world_readable_warns(self, tmp_path): class TestFindEntriesForUrl: def test_exact_match(self): entry = _github_entry() - result = find_entries_for_url("https://github.com/org/repo", [entry]) + result = find_entries_for_url("https://github.com:443/org/repo", [entry]) assert result == [entry] def test_wildcard_match(self): @@ -409,10 +409,12 @@ def test_empty_url_returns_empty(self): [ "https://[::1", # unterminated ipv6 bracket "https://[not-an-ip]/file", # bracketed non-ip host + "https://github.com:notaport/x", # non-numeric port + "https://github.com:99999/x", # out-of-range port ], ) def test_malformed_url_returns_empty(self, url): - # A malformed authority makes urlparse/hostname raise ValueError. + # A malformed authority makes urlparse, hostname, or port raise ValueError. # Since no entry can match such a URL, this must return no matches # (like a host-less URL) rather than leaking a raw ValueError out of # the shared HTTP client. diff --git a/tests/test_check_prerequisites_paths_only.py b/tests/test_check_prerequisites_paths_only.py index 3331cf92e4..dc2ee23f8f 100644 --- a/tests/test_check_prerequisites_paths_only.py +++ b/tests/test_check_prerequisites_paths_only.py @@ -38,7 +38,7 @@ def _write_feature_json( repo: Path, feature_directory: str = "specs/001-my-feature" ) -> None: (repo / ".specify" / "feature.json").write_text( - json.dumps({"feature_directory": feature_directory}), + json.dumps({"feature_directory": feature_directory}, ensure_ascii=False), encoding="utf-8", ) @@ -288,6 +288,40 @@ def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None: assert "FEATURE_DIR" in data +@pytest.mark.skipif( + not _WINDOWS_POWERSHELL, reason="Windows PowerShell 5.1 not available" +) +def test_windows_powershell_reads_bomless_utf8_feature_json( + prereq_repo: Path, +) -> None: + """Windows PowerShell must decode non-ASCII feature paths as UTF-8 (#4333).""" + feature_directory = "specs/001-后台信息架构" + feature_path = prereq_repo / feature_directory + feature_path.mkdir(parents=True) + _write_feature_json(prereq_repo, feature_directory) + + resolved_path = prereq_repo / "resolved-feature-path.txt" + common_ps = prereq_repo / ".specify" / "scripts" / "powershell" / "common.ps1" + ps_command = ( + f". '{common_ps}'; " + "$resolved = Get-FeaturePathsEnv -NoPersist; " + "$utf8NoBom = New-Object System.Text.UTF8Encoding($false); " + f"[System.IO.File]::WriteAllText('{resolved_path}', " + "[string]$resolved.FEATURE_DIR, $utf8NoBom)" + ) + result = subprocess.run( + [_WINDOWS_POWERSHELL, "-NoProfile", "-Command", ps_command], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + assert resolved_path.read_text(encoding="utf-8") == str(feature_path) + + @pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") @pytest.mark.parametrize( ("use_env_var", "specify_feature", "expected_branch"), diff --git a/tests/test_check_prerequisites_python_parity.py b/tests/test_check_prerequisites_python_parity.py index b0e74217c0..69372d3f17 100644 --- a/tests/test_check_prerequisites_python_parity.py +++ b/tests/test_check_prerequisites_python_parity.py @@ -247,6 +247,78 @@ def test_python_json_output_matches_bash(prereq_repo: Path, args: tuple[str, ... assert _json_stdout(py) == _json_stdout(bash) +@requires_bash +def test_python_require_spec_matches_bash(prereq_repo: Path) -> None: + feat = prereq_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True) + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + (feat / "tasks.md").write_text("# tasks\n", encoding="utf-8") + _write_feature_json(prereq_repo) + + # spec.md is missing, and without the flag that stays the caller's problem + bash_without = _run(_bash_cmd(prereq_repo, "--json", "--require-tasks"), prereq_repo) + py_without = _run(_py_cmd(prereq_repo, "--json", "--require-tasks"), prereq_repo) + assert py_without.returncode == bash_without.returncode == 0 + + # with the flag both variants fail the same way and name the same command + bash_missing = _run( + _bash_cmd(prereq_repo, "--json", "--require-spec", "--require-tasks"), prereq_repo + ) + py_missing = _run( + _py_cmd(prereq_repo, "--json", "--require-spec", "--require-tasks"), prereq_repo + ) + assert py_missing.returncode == bash_missing.returncode == 1 + assert py_missing.stderr == bash_missing.stderr + assert "spec.md not found" in bash_missing.stderr + + # and once the spec exists the flag is satisfied + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + bash_present = _run( + _bash_cmd(prereq_repo, "--json", "--require-spec", "--require-tasks"), prereq_repo + ) + py_present = _run( + _py_cmd(prereq_repo, "--json", "--require-spec", "--require-tasks"), prereq_repo + ) + assert py_present.returncode == bash_present.returncode == 0 + assert _json_stdout(py_present) == _json_stdout(bash_present) + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_powershell_require_spec_matches_python(prereq_repo: Path) -> None: + feat = prereq_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True) + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + (feat / "tasks.md").write_text("# tasks\n", encoding="utf-8") + _write_feature_json(prereq_repo) + + # spec.md is missing, and without the flag that stays the caller's problem + ps_without = _run(_ps_cmd(prereq_repo, "-Json", "-RequireTasks"), prereq_repo) + py_without = _run(_py_cmd(prereq_repo, "--json", "--require-tasks"), prereq_repo) + assert ps_without.returncode == py_without.returncode == 0 + + # with the flag both variants fail the same way and name the same file + ps_missing = _run( + _ps_cmd(prereq_repo, "-Json", "-RequireSpec", "-RequireTasks"), prereq_repo + ) + py_missing = _run( + _py_cmd(prereq_repo, "--json", "--require-spec", "--require-tasks"), prereq_repo + ) + assert ps_missing.returncode == py_missing.returncode == 1 + assert "spec.md not found" in ps_missing.stderr + assert "spec.md not found" in py_missing.stderr + + # and once the spec exists the flag is satisfied and the payloads agree + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + ps_present = _run( + _ps_cmd(prereq_repo, "-Json", "-RequireSpec", "-RequireTasks"), prereq_repo + ) + py_present = _run( + _py_cmd(prereq_repo, "--json", "--require-spec", "--require-tasks"), prereq_repo + ) + assert ps_present.returncode == py_present.returncode == 0 + assert _json_stdout(ps_present) == _json_stdout(py_present) + + @requires_bash def test_python_text_output_matches_bash(prereq_repo: Path) -> None: feat = prereq_repo / "specs" / "001-my-feature" diff --git a/tests/test_check_tool.py b/tests/test_check_tool.py index 9520046168..ef8a9da2fb 100644 --- a/tests/test_check_tool.py +++ b/tests/test_check_tool.py @@ -120,6 +120,42 @@ def fake_which(name): with patch("shutil.which", side_effect=fake_which): assert check_tool("rovodev") is True + def test_docker_agent_plugin_fallback(self): + """docker-agent should detect a working Docker CLI plugin form.""" + + def fake_which(name): + return "/usr/bin/docker" if name == "docker" else None + + with ( + patch("shutil.which", side_effect=fake_which), + patch("subprocess.run") as run, + ): + run.return_value.returncode = 0 + assert check_tool("docker-agent") is True + run.assert_called_once_with( + ["/usr/bin/docker", "agent", "version"], + capture_output=True, + check=False, + timeout=5, + ) + + def test_docker_agent_missing(self): + """docker-agent should be missing when neither form is installed.""" + with patch("shutil.which", return_value=None): + assert check_tool("docker-agent") is False + + def test_docker_agent_plugin_missing(self): + """Plain Docker CLI should not count as Docker Agent.""" + def fake_which(name): + return "/usr/bin/docker" if name == "docker" else None + + with ( + patch("shutil.which", side_effect=fake_which), + patch("subprocess.run") as run, + ): + run.return_value.returncode = 1 + assert check_tool("docker-agent") is False + class TestCheckTip: """`specify check` should point users to the existing version check.""" diff --git a/tests/test_create_new_feature_python_parity.py b/tests/test_create_new_feature_python_parity.py index 41122b1f5f..74d071ad5f 100644 --- a/tests/test_create_new_feature_python_parity.py +++ b/tests/test_create_new_feature_python_parity.py @@ -1065,3 +1065,56 @@ def test_all_variants_corrected_prefix_skips_timestamp_collision(repo: Path) -> assert json_stdout(py)["FEATURE_NUM"] == "20260320" for result in (bash, ps, py): assert "using 20260320 instead" in result.stderr + + +@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available") +@pytest.mark.parametrize( + "description", + ["!!! ??? ***", "добавить", "添加用户"], + ids=["punctuation_only", "cyrillic", "han"], +) +def test_powershell_survives_description_with_no_ascii_words( + tmp_path: Path, description: str +): + """A description with no [a-z0-9] characters must not crash the PS twin. + + ``ConvertTo-CleanBranchName`` blanks every non-ASCII character, so the + fallback pipeline yields nothing and ``[string]::Join`` received ``$null`` + — an ArgumentNullException, made terminating by + ``$ErrorActionPreference = 'Stop'``. The script died with a .NET stack + trace and exit 1 where the bash and Python twins both return an empty + suffix. This fires for any feature phrased in a non-Latin script. + """ + repo = _setup_repo(tmp_path) + + ps = run(ps_cmd(repo, SCRIPT, "-Json", "-DryRun", description), repo) + + assert ps.returncode == 0, ps.stderr + assert "ArgumentNullException" not in ps.stderr + assert "Join" not in ps.stderr + assert json_stdout(ps)["BRANCH_NAME"] == "001-" + + +@requires_bash +@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available") +def test_no_ascii_word_description_matches_across_twins(tmp_path: Path): + """All three twins agree on the branch name for such a description.""" + description = "добавить" + + bash_repo = _setup_repo(tmp_path, "b") + py_repo = _setup_repo(tmp_path, "p") + ps_repo = _setup_repo(tmp_path, "s") + + bash = run(bash_cmd(bash_repo, SCRIPT, "--json", "--dry-run", description), bash_repo) + py = run(py_cmd(py_repo, SCRIPT, "--json", "--dry-run", description), py_repo) + ps = run(ps_cmd(ps_repo, SCRIPT, "-Json", "-DryRun", description), ps_repo) + + assert bash.returncode == py.returncode == ps.returncode == 0, ( + bash.stderr, py.stderr, ps.stderr, + ) + names = { + json_stdout(bash)["BRANCH_NAME"], + json_stdout(py)["BRANCH_NAME"], + json_stdout(ps)["BRANCH_NAME"], + } + assert names == {"001-"}, names diff --git a/tests/test_event_command.py b/tests/test_event_command.py new file mode 100644 index 0000000000..0a431fb5c6 --- /dev/null +++ b/tests/test_event_command.py @@ -0,0 +1,145 @@ +"""`specify event run` must read piped stdin without crashing. + +`event_run` (src/specify_cli/commands/event.py) capped its stdin read at 1 +MiB to prevent a DoS (#3857), but the truncation check read a `.eof` +attribute that does not exist on any Python file-like object (including +`sys.stdin`) — every piped-stdin invocation raised `AttributeError` instead +of running, regardless of payload size. Piped stdin is the command's +documented primary use case (it is how a native hook feeds it a JSON +payload), so this broke the feature entirely rather than only rejecting +oversized payloads. Even the intended oversized-payload branch was broken a +second way: `typer.Exit(code=1, message=...)` — `typer.Exit` accepts no +`message` keyword argument, so that path raised `TypeError` instead of a +clean CLI error. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +import typer +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.commands.event import event_run + + +def test_event_run_reads_piped_stdin_payload(): + """A normal, under-the-cap piped payload must reach the handler intact.""" + with patch( + "specify_cli.events.resolve_and_run_event_command", return_value=0 + ) as mock_run: + result = CliRunner().invoke( + app, + ["event", "run", "some-command", "session_start"], + input='{"key": "value"}', + ) + + assert result.exit_code == 0, result.output + assert mock_run.called + payload_arg = mock_run.call_args[0][2] + assert payload_arg == '{"key": "value"}' + + +def test_event_run_empty_pipe_reads_empty_payload(): + """An empty (but non-TTY) piped stream must not crash; it forwards `""`. + + CliRunner always provides a non-TTY stdin, even when no `input=` is + given, so this exercises the piped-input branch with zero bytes — not + the TTY fallback. See `test_event_run_tty_uses_empty_object` below for + the actual TTY case. + """ + with patch( + "specify_cli.events.resolve_and_run_event_command", return_value=0 + ) as mock_run: + result = CliRunner().invoke( + app, + ["event", "run", "some-command", "session_start"], + ) + + assert result.exit_code == 0, result.output + assert mock_run.called + payload_arg = mock_run.call_args[0][2] + assert payload_arg == "" + + +def test_event_run_tty_uses_empty_object(monkeypatch): + """A real TTY (no piped input at all) must fall back to `"{}"`.""" + + class FakeTtyStdin: + def isatty(self): + return True + + monkeypatch.setattr("specify_cli.commands.event.sys.stdin", FakeTtyStdin()) + + with patch( + "specify_cli.events.resolve_and_run_event_command", return_value=0 + ) as mock_run: + with pytest.raises(typer.Exit): + event_run(command_name="some-command", event_name="session_start", timeout=120) + + assert mock_run.called + payload_arg = mock_run.call_args[0][2] + assert payload_arg == "{}" + + +def test_event_run_oversized_stdin_reports_clean_error(): + """A payload exceeding the 1 MiB cap must exit 1 with the limit message, + not crash with AttributeError (missing `.eof`) or TypeError (`typer.Exit` + does not accept `message=`).""" + oversized = "x" * (1 * 1024 * 1024 + 10) + with patch( + "specify_cli.events.resolve_and_run_event_command", return_value=0 + ) as mock_run: + result = CliRunner().invoke( + app, + ["event", "run", "some-command", "session_start"], + input=oversized, + ) + + assert result.exit_code == 1, result.output + assert "1 MiB limit" in result.output + assert not mock_run.called + + +def test_event_run_invalid_utf8_reports_clean_error(): + """A piped payload that isn't valid UTF-8 must exit 1 with the encoding + error message, not propagate a raw `UnicodeDecodeError`, and the handler + must never be invoked with undecodable data.""" + with patch( + "specify_cli.events.resolve_and_run_event_command", return_value=0 + ) as mock_run: + result = CliRunner().invoke( + app, + ["event", "run", "some-command", "session_start"], + input=b"\xff\xfe", + ) + + assert result.exit_code == 1, result.output + assert "must be valid UTF-8" in result.output + assert not mock_run.called + + +def test_event_run_multibyte_payload_enforces_byte_limit(): + """The 1 MiB cap must be enforced in encoded bytes, not decoded characters. + + 300,000 emoji is ~1.14 MiB of UTF-8 (4 bytes each) but only 300,000 + *characters* — comfortably under the 1,048,576 character cap a text-mode + `sys.stdin.read(MAX_STDIN_BYTES)` would have applied. Reading from the + binary buffer instead must still reject it. + """ + oversized = "\U0001F600" * 300_000 # 😀, 4 bytes each in UTF-8 + assert len(oversized) < 1 * 1024 * 1024 # under the old, wrong character cap + with patch( + "specify_cli.events.resolve_and_run_event_command", return_value=0 + ) as mock_run: + result = CliRunner().invoke( + app, + ["event", "run", "some-command", "session_start"], + input=oversized, + ) + + assert result.exit_code == 1, result.output + assert "1 MiB limit" in result.output + assert not mock_run.called diff --git a/tests/test_extension_content_staleness.py b/tests/test_extension_content_staleness.py new file mode 100644 index 0000000000..f76e041869 --- /dev/null +++ b/tests/test_extension_content_staleness.py @@ -0,0 +1,134 @@ +"""Tests for the bundled-extension local update route (#4345). + +Bundled extensions have no download URL, so `specify extension update` +installs them from the copy shipped with the running spec-kit release, +packaged by `_archive_extension_directory` into the same hardened +archive pipeline that downloaded updates use. These tests pin that +packaging step and its round trip through the archive installer. +""" + +from __future__ import annotations + +import os + +import pytest +import yaml +from pathlib import Path + +from specify_cli.extensions import ExtensionManager + + +def _create_extension_source( + base_dir: Path, name: str = "test-ext", version: str = "1.0.0" +) -> Path: + """Create a minimal installable extension source directory.""" + ext_dir = base_dir / name + ext_dir.mkdir(parents=True, exist_ok=True) + + manifest = { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": version, + "description": "A test extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.test-ext.hello", + "file": "commands/hello.md", + "description": "Test command", + } + ] + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest, sort_keys=False)) + commands_dir = ext_dir / "commands" + commands_dir.mkdir(exist_ok=True) + (commands_dir / "hello.md").write_text("---\ndescription: Test\n---\n\n$ARGUMENTS\n") + scripts_dir = ext_dir / "scripts" + scripts_dir.mkdir(exist_ok=True) + (scripts_dir / "run.sh").write_text("#!/bin/sh\necho hello\n") + (ext_dir / "test-ext-config.yml").write_text("setting: default\n") + return ext_dir + + +def _make_project(tmp_path: Path) -> Path: + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + return project_dir + + +class TestArchiveExtensionDirectory: + def test_archive_contains_regular_files_only(self, tmp_path): + import zipfile + + from specify_cli.extensions._commands import _archive_extension_directory + + ext_dir = _create_extension_source(tmp_path) + archive_path = _archive_extension_directory(ext_dir) + try: + with zipfile.ZipFile(archive_path) as zf: + names = set(zf.namelist()) + assert "extension.yml" in names + assert "commands/hello.md" in names + finally: + archive_path.unlink() + + def test_archive_never_follows_symlinks(self, tmp_path): + """A symlink in the source must not pull out-of-tree bytes into the + archive before the hardened extractor sees it.""" + import zipfile + + from specify_cli.extensions._commands import _archive_extension_directory + + ext_dir = _create_extension_source(tmp_path) + outside = tmp_path / "outside.txt" + outside.write_text("external bytes\n") + try: + (ext_dir / "scripts" / "link.txt").symlink_to(outside) + except OSError: + pytest.skip("symlink creation requires privileges on this platform") + + archive_path = _archive_extension_directory(ext_dir) + try: + with zipfile.ZipFile(archive_path) as zf: + names = set(zf.namelist()) + assert "scripts/link.txt" not in names + finally: + archive_path.unlink() + + @pytest.mark.skipif( + os.name == "nt", reason="POSIX execute bits do not exist on Windows" + ) + def test_archive_route_restores_script_execute_bits(self, tmp_path): + """safe_extract_archive writes members without their recorded ZIP + modes, so the archive install route depends on install_from_directory's + trailing ensure_executable_scripts() call to keep documented + `.specify/extensions//scripts/*.sh` invocations executable. Pin + that round trip so removing the restoration would fail here instead + of surfacing as `Permission denied` after a bundled update.""" + from specify_cli.extensions._commands import _archive_extension_directory + + project_dir = _make_project(tmp_path) + source = _create_extension_source(tmp_path) + (source / "scripts" / "run.sh").chmod(0o755) + + archive_path = _archive_extension_directory(source) + try: + ExtensionManager(project_dir).install_from_zip(archive_path, "0.1.0") + finally: + archive_path.unlink() + + installed_script = ( + project_dir / ".specify" / "extensions" / "test-ext" / "scripts" / "run.sh" + ) + assert installed_script.is_file() + assert installed_script.stat().st_mode & 0o100, ( + "execute bit lost through the archive install route" + ) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6642da2b09..aec32dc4ba 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -9190,6 +9190,212 @@ def fake_install_from_zip(self_obj, _zip_path, speckit_version): ).read_text() assert restored_config_content == original_config_content + def test_update_installs_bundled_extension_from_local_copy(self, tmp_path): + """A bundled extension (no download URL) updates from the copy shipped + with the running spec-kit release instead of failing at download (#4345).""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v2_dir + ), \ + patch.object( + ExtensionCatalog, + "download_extension", + side_effect=AssertionError("bundled update must not download"), + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "Updated to v2.0.0" in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "2.0.0" + + def test_update_bundled_blocked_when_local_copy_lags_catalog(self, tmp_path): + """When the catalog advertises a newer version than the running release + bundles, the update is reported as requiring a spec-kit upgrade instead + of being offered and then failing.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v1_dir + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "only ships v1.0.0" in flat + assert "upgrade spec-kit" in flat + assert "Update these extensions?" not in flat + assert "All extensions are up to date!" not in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + + def test_update_bundled_blocked_when_local_copy_is_intermediate_version(self, tmp_path): + """A bundled copy newer than the installation but older than the + catalog must be blocked, not installed: an intermediate version would + leave the project lagging the catalog while reporting success.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "3.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v2_dir + ), \ + patch.object( + ExtensionCatalog, + "download_extension", + side_effect=AssertionError("blocked bundled update must not download"), + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "only ships v2.0.0" in flat + assert "upgrade spec-kit" in flat + assert "Update these extensions?" not in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + + def test_update_installs_bundled_copy_newer_than_catalog(self, tmp_path): + """A dev/source checkout can ship a copy newer than the fetched + catalog advertises; the local copy is offered and installed.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + v3_dir = self._create_extension_source(tmp_path, "3.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v3_dir + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "Updated to v3.0.0" in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "3.0.0" + + def test_update_bundled_blocked_when_no_local_copy_exists(self, tmp_path): + """A bundled catalog entry with no locally shipped copy points at a + spec-kit upgrade instead of failing the update at download time.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=None + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "does not ship a local copy" in flat + assert "upgrade spec-kit" in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path, monkeypatch): """Failed update should restore original registry, hooks, and command files.""" from typer.testing import CliRunner diff --git a/tests/test_github_http.py b/tests/test_github_http.py index b31d829907..fcf6ae9936 100644 --- a/tests/test_github_http.py +++ b/tests/test_github_http.py @@ -42,6 +42,16 @@ def test_ftp_url_raises_value_error(self): with pytest.raises(ValueError, match="url must start with http"): build_github_request("ftp://github.com/file.zip") + @pytest.mark.parametrize( + "url", ["https://github.com:notaport/file", "https://github.com:65536/file"] + ) + def test_malformed_explicit_port_raises_before_request_construction(self, url): + """Malformed explicit ports are rejected before creating a Request.""" + with patch("specify_cli._github_http.urllib.request.Request") as request: + with pytest.raises(ValueError): + build_github_request(url) + request.assert_not_called() + # --- Valid URL Tests --- def test_valid_https_url_returns_request(self): @@ -54,6 +64,14 @@ def test_valid_http_url_returns_request(self): req = build_github_request("http://example.com/file") assert req.full_url == "http://example.com/file" + def test_valid_explicit_port_retains_url_method_and_github_auth(self): + """A valid explicit port retains normal GitHub request behavior.""" + with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token", "GH_TOKEN": ""}): + req = build_github_request("https://github.com:8443/github/spec-kit") + assert req.full_url == "https://github.com:8443/github/spec-kit" + assert req.get_method() == "GET" + assert req.get_header("Authorization") == "Bearer test-token" + # --- Auth Header Tests --- def test_github_token_added_for_github_host(self): diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py index aeb8ad7e21..7bb762ebaf 100644 --- a/tests/test_github_workflows.py +++ b/tests/test_github_workflows.py @@ -2,9 +2,16 @@ from __future__ import annotations +import os import re +import subprocess +import sys from pathlib import Path +import yaml + +from tests.conftest import requires_bash + REPO_ROOT = Path(__file__).resolve().parent.parent WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" @@ -12,6 +19,11 @@ # inline shorthand (` - uses: x@sha`) used in catalog-assign.yml. USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P\S+)", re.MULTILINE) PINNED_SHA_RE = re.compile(r"@[0-9a-f]{40}$", re.IGNORECASE) +PUBLISH_WORKFLOW = WORKFLOWS_DIR / "publish-pypi.yml" +PUBLISH_VALIDATION_STEPS = ( + "Verify tag format", + "Verify tag matches package version", +) COMMUNITY_SUBMISSION_WORKFLOWS = ( ( "bundle", @@ -37,6 +49,34 @@ ) +def _publish_workflow_steps() -> dict[str, dict[str, object]]: + workflow = yaml.safe_load(PUBLISH_WORKFLOW.read_text(encoding="utf-8")) + return {step["name"]: step for step in workflow["jobs"]["build"]["steps"]} + + +def _run_publish_validation_step( + step_name: str, tag: str, working_directory: Path +) -> subprocess.CompletedProcess[str]: + step = _publish_workflow_steps()[step_name] + env = os.environ.copy() + env["TAG"] = tag + env["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{env['PATH']}" + return subprocess.run( + ["bash", "-euo", "pipefail", "-c", step["run"]], + cwd=working_directory, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def _write_project_version(working_directory: Path, version: str) -> None: + (working_directory / "pyproject.toml").write_text( + f'[project]\nversion = "{version}"\n', encoding="utf-8" + ) + + def _create_pull_request_allowed_files(source_text: str) -> list[str]: create_pr_match = re.search( r"(?m)^ create-pull-request:\n(?P(?:^ [^\n]*\n?)+)", @@ -78,6 +118,52 @@ def test_github_actions_are_pinned_to_full_commit_shas(): assert unpinned_refs == [] +def test_publish_tag_validation_uses_environment_variable(): + steps = _publish_workflow_steps() + + for step_name in PUBLISH_VALIDATION_STEPS: + step = steps[step_name] + assert step["env"]["TAG"] == "${{ inputs.tag }}" + assert "${{ inputs.tag }}" not in step["run"] + + +@requires_bash +def test_publish_tag_validation_accepts_valid_tag(tmp_path): + _write_project_version(tmp_path, "1.2.3") + + for step_name in PUBLISH_VALIDATION_STEPS: + result = _run_publish_validation_step(step_name, "v1.2.3", tmp_path) + assert result.returncode == 0, result.stderr + + +@requires_bash +def test_publish_tag_validation_rejects_invalid_tag(tmp_path): + for invalid_tag in ("1.2.3", "v1.2", "v1.2.3-rc1"): + result = _run_publish_validation_step( + "Verify tag format", invalid_tag, tmp_path + ) + assert result.returncode != 0 + assert "is not a valid release tag" in result.stdout + + injected_file = tmp_path / "interpolated" + injected_tag = f'v1.2.3"; touch "{injected_file}"; #' + result = _run_publish_validation_step("Verify tag format", injected_tag, tmp_path) + assert result.returncode != 0 + assert not injected_file.exists() + + +@requires_bash +def test_publish_tag_validation_rejects_version_mismatch(tmp_path): + _write_project_version(tmp_path, "1.2.3") + + result = _run_publish_validation_step( + "Verify tag matches package version", "v1.2.4", tmp_path + ) + + assert result.returncode != 0 + assert "does not match pyproject.toml version" in result.stdout + + def test_pinned_action_ref_accepts_uppercase_hex_sha(): assert PINNED_SHA_RE.search( "actions/example@0123456789ABCDEF0123456789ABCDEF01234567" @@ -144,7 +230,7 @@ def test_bug_test_workflow_provisions_python_dependencies(): compiled_text = compiled.read_text(encoding="utf-8") setup_uv = ( - "astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0" + "astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1" ) setup_python = ( "actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0" diff --git a/tests/test_merge.py b/tests/test_merge.py index 6b1eb1c2fc..45889ffdd7 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -212,3 +212,29 @@ def test_handle_vscode_settings_propagates_programming_errors(tmp_path): ) finally: utils_mod.merge_json_files = original_merge + + +def test_merge_json_files_propagates_programming_errors(tmp_path, monkeypatch): + """Unexpected programming errors reading the existing file must propagate. + + ``merge_json_files``'s own read of the existing JSON file caught bare + ``Exception`` around ``json5.load``, so a real bug there (e.g. a + ``TypeError``) was silently treated the same as a normal parse failure -- + ``None`` returned, existing settings preserved, nothing logged unless + ``verbose``. Only ``OSError`` (inaccessible file) and ``ValueError`` + (malformed JSON5 -- json5's decode error is a ``ValueError`` subclass) + are expected outcomes here; anything else must propagate, matching the + narrowing already applied to the caller, ``handle_vscode_settings``. + """ + existing_file = tmp_path / "settings.json" + existing_file.write_text('{"a": 1}\n', encoding="utf-8") + + import specify_cli._utils as utils_mod + + def _boom(*_a, **_kw): + raise TypeError("boom") + + monkeypatch.setattr(utils_mod.json5, "load", _boom) + + with pytest.raises(TypeError): + merge_json_files(existing_file, {"b": 2}) diff --git a/tests/test_presets.py b/tests/test_presets.py index 9775e0afa9..12f81fb9ec 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -40,6 +40,8 @@ VALID_PRESET_TEMPLATE_TYPES, ) from specify_cli.extensions import ExtensionRegistry +from specify_cli._console import console +from specify_cli.presets._commands import _warn_unmet_extension_dependencies # ===== Fixtures ===== @@ -484,7 +486,10 @@ def test_get_hash(self, pack_dir): manifest = PresetManifest(pack_dir / "preset.yml") hash_val = manifest.get_hash() assert hash_val.startswith("sha256:") - assert len(hash_val) > 10 + import hashlib + content = (pack_dir / "preset.yml").read_bytes() + expected = f"sha256:{hashlib.sha256(content).hexdigest()}" + assert hash_val == expected def test_multiple_templates(self, temp_dir, valid_pack_data): """Test pack with multiple templates of different types.""" @@ -500,6 +505,102 @@ def test_multiple_templates(self, temp_dir, valid_pack_data): manifest = PresetManifest(manifest_path) assert len(manifest.templates) == 4 + def test_duplicate_template_name_and_type_raises_validation_error( + self, temp_dir, valid_pack_data + ): + """A later entry with the same (name, type) pair must be rejected. + + ``PresetResolver._manifest_declared_template`` returns the FIRST + 'provides.templates' entry matching a given (name, type) pair, so a + later duplicate would be silently unreachable while still being + counted by ``PresetManifest.templates`` -- mirroring the sibling bug + fixed for ``ExtensionManifest``'s provides.templates/scripts (#4016). + """ + valid_pack_data["provides"]["templates"] = [ + {"type": "command", "name": "specify", "file": "commands/specify-v1.md"}, + {"type": "command", "name": "specify", "file": "commands/specify-v2.md"}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Duplicate template name"): + PresetManifest(manifest_path) + + def test_same_name_different_type_templates_allowed( + self, temp_dir, valid_pack_data + ): + """The same name may recur across different template types.""" + valid_pack_data["provides"]["templates"] = [ + {"type": "template", "name": "specify", "file": "templates/specify.md"}, + {"type": "command", "name": "specify", "file": "commands/specify.md"}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert len(manifest.templates) == 2 + + def test_requires_extensions_absent_is_valid(self, temp_dir, valid_pack_data): + """A preset with no declared dependencies stays valid and reports none.""" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + assert PresetManifest(manifest_path).requires_extensions == [] + + def test_requires_extensions_accepts_both_forms(self, temp_dir, valid_pack_data): + """Bare ids and mappings normalize to the same shape.""" + valid_pack_data["requires"]["extensions"] = [ + "speckit-inventory", + {"id": "other-ext", "version": ">=1.2.0", "required": False}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + + assert PresetManifest(manifest_path).requires_extensions == [ + {"id": "speckit-inventory", "version": None, "required": True}, + {"id": "other-ext", "version": ">=1.2.0", "required": False}, + ] + + @pytest.mark.parametrize( + "bad, expected", + [ + ("speckit-inventory", "Invalid requires.extensions"), # str, not list + ({"id": "x"}, "Invalid requires.extensions"), # mapping, not list + ([123], r"Invalid requires\.extensions\[0\]"), # member not str/mapping + ([None], r"Invalid requires\.extensions\[0\]"), + ([{"version": ">=1"}], r"Missing requires\.extensions\[0\]\.id"), + ([{"id": 5}], r"Invalid requires\.extensions\[0\]\.id"), + ([{"id": "Bad_ID"}], r"Invalid requires\.extensions\[0\]\.id"), + (["Bad_ID"], r"Invalid requires\.extensions\[0\]\.id"), + ([{"id": "x", "version": 1.0}], r"Invalid requires\.extensions\[0\]\.version"), + ([{"id": "x", "version": " "}], r"Invalid requires\.extensions\[0\]\.version"), + ([{"id": "x", "version": "nonsense"}], r"Invalid requires\.extensions\[0\]\.version"), + ([{"id": "x", "required": "yes"}], r"Invalid requires\.extensions\[0\]\.required"), + # `$` also matches before a trailing newline, so an anchored + # re.match would admit these while the resolver's fullmatch-based + # safe-id check rejects them. + (["demo-ext\n"], r"Invalid requires\.extensions\[0\]\.id"), + ([{"id": "demo-ext\n"}], r"Invalid requires\.extensions\[0\]\.id"), + (["demo\next"], r"Invalid requires\.extensions\[0\]\.id"), + ], + ) + def test_requires_extensions_rejects_malformed( + self, temp_dir, valid_pack_data, bad, expected + ): + """Malformed dependency declarations fail as PresetValidationError. + + Same reasoning as requires.speckit_version: an unvalidated value reaches + ``SpecifierSet`` or ``re.match`` later and surfaces as a bare TypeError + that no caller handles as a malformed manifest. + """ + valid_pack_data["requires"]["extensions"] = bad + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match=expected): + PresetManifest(manifest_path) + # ===== PresetRegistry Tests ===== @@ -784,6 +885,30 @@ def test_list_by_priority_includes_disabled_when_requested(self, temp_dir): # ===== PresetManager Tests ===== +def test_unreadable_constitution_provenance_fails_closed( + project_dir, monkeypatch +): + from specify_cli.presets import _constitution_provenance_matches_preset + + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.parent.mkdir(parents=True, exist_ok=True) + memory.write_text("# Constitution\n", encoding="utf-8") + provenance = memory.parent / ".constitution-template.json" + provenance.write_text("{}", encoding="utf-8") + real_read_text = Path.read_text + + def unreadable(path, *args, **kwargs): + if path == provenance: + raise OSError("simulated read failure") + return real_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", unreadable) + + assert not _constitution_provenance_matches_preset( + project_dir, memory, "example", "1.0.0" + ) + + class TestPresetManager: """Test PresetManager installation and removal.""" @@ -1064,6 +1189,534 @@ def test_list_installed_includes_priority(self, project_dir, pack_dir): assert installed[0]["priority"] == 3 +class TestPresetExtensionDependencies: + """Test find_unmet_extension_dependencies (issue #4231).""" + + @staticmethod + def _install_extension( + project_dir, extension_id, version, enabled=True, with_files=True + ): + """Register an installed extension the way the extension installer does. + + ``with_files=False`` leaves the registry entry without its directory, + reproducing the stale state left behind when the files are deleted out + from under the registry. + """ + extensions_dir = project_dir / ".specify" / "extensions" + extensions_dir.mkdir(parents=True, exist_ok=True) + if with_files: + (extensions_dir / extension_id).mkdir(parents=True, exist_ok=True) + registry_path = extensions_dir / ".registry" + data = {"schema_version": "1.0", "extensions": {}} + if registry_path.exists(): + data = json.loads(registry_path.read_text(encoding="utf-8")) + data["extensions"][extension_id] = {"version": version, "enabled": enabled} + registry_path.write_text(json.dumps(data), encoding="utf-8") + + @staticmethod + def _manifest(temp_dir, valid_pack_data, declared): + valid_pack_data["requires"]["extensions"] = declared + manifest_path = temp_dir / "dep-preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + return PresetManifest(manifest_path) + + def test_no_declared_dependencies_is_satisfied( + self, project_dir, temp_dir, valid_pack_data + ): + """A preset declaring nothing never reports an unmet dependency.""" + manifest_path = temp_dir / "plain-preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + + manager = PresetManager(project_dir) + assert manager.find_unmet_extension_dependencies( + PresetManifest(manifest_path) + ) == [] + + def test_missing_dependency_is_reported( + self, project_dir, temp_dir, valid_pack_data + ): + """An uninstalled required extension is reported as missing.""" + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert len(unmet) == 1 + assert unmet[0]["id"] == "speckit-inventory" + assert unmet[0]["reason"] == "missing" + assert unmet[0]["installed"] is None + + def test_installed_dependency_is_satisfied( + self, project_dir, temp_dir, valid_pack_data + ): + """An installed extension with no version constraint is satisfied.""" + self._install_extension(project_dir, "speckit-inventory", "0.1.0") + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + def test_satisfied_version_constraint( + self, project_dir, temp_dir, valid_pack_data + ): + """A satisfied version constraint reports nothing.""" + self._install_extension(project_dir, "speckit-inventory", "1.5.0") + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "version": ">=1.2.0"}], + ) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + def test_unsatisfied_version_constraint_reports_both_versions( + self, project_dir, temp_dir, valid_pack_data + ): + """A version mismatch reports the installed version alongside the constraint.""" + self._install_extension(project_dir, "speckit-inventory", "0.1.0") + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "version": ">=9.0.0"}], + ) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert len(unmet) == 1 + assert unmet[0]["reason"] == "version" + assert unmet[0]["installed"] == "0.1.0" + assert unmet[0]["version"] == ">=9.0.0" + + def test_version_warning_does_not_promise_update_satisfies_constraint(self): + """Version remediation must handle constraints update cannot guarantee.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "version", + "installed": "3.0.0", + "version": "<2", + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = " ".join(strip_ansi(capture.get()).split()) + assert "Needs: a release of speckit-inventory satisfying <2" in output + assert "specify extension update" not in output + + def test_optional_dependency_is_never_reported( + self, project_dir, temp_dir, valid_pack_data + ): + """`required: false` opts out of the warning even when absent.""" + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "required": False}], + ) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + @pytest.mark.parametrize("bad_version", [None, 5, "unknown", "", "latest"]) + def test_uncomparable_registry_version_is_not_a_mismatch( + self, project_dir, temp_dir, valid_pack_data, bad_version + ): + """A version that cannot be evaluated must not be reported as a mismatch. + + ``version_satisfies()`` returns False for an unparseable version, which + is indistinguishable from a genuine mismatch -- so a string like + "unknown" would otherwise be reported as failing a constraint nobody + can actually evaluate it against. + """ + self._install_extension(project_dir, "speckit-inventory", "0.1.0") + registry_path = project_dir / ".specify" / "extensions" / ".registry" + data = json.loads(registry_path.read_text(encoding="utf-8")) + data["extensions"]["speckit-inventory"]["version"] = bad_version + registry_path.write_text(json.dumps(data), encoding="utf-8") + + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "version": ">=9.0.0"}], + ) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + def test_unregistered_extension_on_disk_is_satisfied( + self, project_dir, temp_dir, valid_pack_data + ): + """A directory with no registry entry still resolves, so it is not missing. + + ``_get_all_extensions_by_priority`` admits safe unregistered + directories at implicit priority 10, so the preset works -- warning + that the dependency is absent would be a false alarm. + """ + (project_dir / ".specify" / "extensions" / "speckit-inventory").mkdir( + parents=True + ) + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + def test_unregistered_extension_cannot_be_version_checked( + self, project_dir, temp_dir, valid_pack_data + ): + """No registry entry means no recorded version, so nothing to compare.""" + (project_dir / ".specify" / "extensions" / "speckit-inventory").mkdir( + parents=True + ) + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "version": ">=9.0.0"}], + ) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + def test_corrupted_registry_entry_with_directory_is_not_satisfied( + self, project_dir, temp_dir, valid_pack_data + ): + """A corrupted entry keeps its id registered, so its directory is excluded. + + ``get()`` returns None for a non-dict entry just as it does for an + absent one, but ``keys()`` retains the id specifically so resolution + does not re-admit the directory as an unregistered extension. The + fallback must not revive what resolution excludes. + """ + extensions_dir = project_dir / ".specify" / "extensions" + (extensions_dir / "speckit-inventory").mkdir(parents=True) + (extensions_dir / ".registry").write_text( + json.dumps( + {"schema_version": "1.0", "extensions": {"speckit-inventory": "corrupt"}} + ), + encoding="utf-8", + ) + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + # Reported as corrupt rather than missing: the id is still registered, + # so a plain `extension add` would be refused as already installed. + assert [dep["reason"] for dep in unmet] == ["corrupt"] + + def test_missing_and_stale_warnings_mention_discovery_only_catalogs(self): + """`extension add ` is rejected for discovery-only entries, so say so.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + {"id": "speckit-inventory", "reason": "missing", + "installed": None, "version": None} + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = strip_ansi(capture.get()) + assert "discovery-only catalog" in output + assert "--from " in output + assert "Install with: specify extension add speckit-inventory" in output + + @pytest.mark.parametrize( + "reason, extra", + [ + ("missing", {"installed": None, "version": None}), + ("stale", {"installed": "0.1.0", "version": None}), + ("disabled", {"installed": "0.1.0", "version": None}), + ("version", {"installed": "0.1.0", "version": ">=9.0.0"}), + ], + ) + def test_leading_hyphen_id_is_not_emitted_into_a_command(self, reason, extra): + """A leading-hyphen id satisfies `^[a-z0-9-]+$` but breaks the command. + + Typer would read it as an option rather than the positional extension + argument, so the advertised fix would fail. Every remedy substitutes + the placeholder `_command_safe_id` returns. The id here is deliberately + not a real flag, so a match cannot be confused with `--force` appearing + legitimately in the stale remedy. + """ + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + {"id": "--not-a-real-flag", "reason": reason, **extra} + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = " ".join(strip_ansi(capture.get()).split()) + # Isolate the remedy: the description line legitimately shows the raw + # id, escaped for display; only the copyable command must not carry it. + label = next( + lbl for lbl in ("Install with:", "Reinstall with:", "Enable with:", "Needs:") + if lbl in output + ) + remedy = output.split(label, 1)[1].split("The preset is installed.")[0] + assert "--not-a-real-flag" not in remedy + assert "" in remedy + + def test_version_only_warning_omits_the_discovery_only_note(self): + """The note is about installing by id, which a version mismatch does not do.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + {"id": "speckit-inventory", "reason": "version", + "installed": "0.1.0", "version": ">=9.0.0"} + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + assert "discovery-only" not in strip_ansi(capture.get()) + + def test_unregistered_extension_with_corrupt_registry_is_missing( + self, project_dir, temp_dir, valid_pack_data + ): + """A corrupt registry makes resolution fail closed, so it is not usable.""" + extensions_dir = project_dir / ".specify" / "extensions" + (extensions_dir / "speckit-inventory").mkdir(parents=True) + (extensions_dir / ".registry").write_text("{not valid json", encoding="utf-8") + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [dep["reason"] for dep in unmet] == ["missing"] + + def test_corrupted_entry_gets_a_forced_reinstall_remedy( + self, project_dir, temp_dir, valid_pack_data + ): + """A corrupted entry is not simply absent: `add ` would be refused. + + ``get()`` returns None for it, but ``is_installed()`` still counts the + key, so a plain add reports "already installed". It needs --force. + """ + extensions_dir = project_dir / ".specify" / "extensions" + extensions_dir.mkdir(parents=True) + (extensions_dir / ".registry").write_text( + json.dumps( + {"schema_version": "1.0", "extensions": {"speckit-inventory": "bad"}} + ), + encoding="utf-8", + ) + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [dep["reason"] for dep in unmet] == ["corrupt"] + + def test_corrupt_warning_suggests_forced_reinstall(self): + """The corrupt remedy must use --force, since the id is still registered.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + {"id": "speckit-inventory", "reason": "corrupt", + "installed": None, "version": None} + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = strip_ansi(capture.get()) + assert "unreadable registry entry" in output + assert "Reinstall with: specify extension add speckit-inventory --force" in output + + def test_unreadable_registry_does_not_raise( + self, project_dir, temp_dir, valid_pack_data, monkeypatch + ): + """An OSError from the registry must not crash an already-completed install. + + ``_load()`` lets OSError through, and ``preset_add`` only handles + preset-domain errors, so raising here would turn a finished install + into a traceback over what is only a warning. + """ + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + import specify_cli.presets as presets_mod + + def _boom(*args, **kwargs): + raise PermissionError("registry unreadable") + + monkeypatch.setattr(presets_mod, "ExtensionRegistry", _boom) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + def test_version_only_footer_does_not_claim_the_feature_is_inert(self): + """A version mismatch still invokes the extension, so wording differs.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + {"id": "speckit-inventory", "reason": "version", + "installed": "0.1.0", "version": ">=9.0.0"} + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = " ".join(strip_ansi(capture.get()).split()) + assert "may not behave as the preset expects" in output + assert "does nothing" not in output + assert "safe to use" not in output + + def test_unavailable_footer_states_the_feature_is_inert(self): + """An unavailable extension genuinely contributes nothing.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + {"id": "speckit-inventory", "reason": "missing", + "installed": None, "version": None} + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = " ".join(strip_ansi(capture.get()).split()) + assert "does nothing" in output + assert "may not behave as the preset expects" not in output + + def test_exact_duplicate_declarations_warn_once( + self, project_dir, temp_dir, valid_pack_data + ): + """Naming the same dependency twice must not print the warning twice.""" + manifest = self._manifest( + temp_dir, valid_pack_data, ["speckit-inventory", "speckit-inventory"] + ) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [dep["id"] for dep in unmet] == ["speckit-inventory"] + + def test_same_id_with_different_constraints_is_checked_twice( + self, project_dir, temp_dir, valid_pack_data + ): + """Distinct constraints on one id both have to hold, so both are checked.""" + self._install_extension(project_dir, "speckit-inventory", "1.0.0") + manifest = self._manifest( + temp_dir, valid_pack_data, + [ + {"id": "speckit-inventory", "version": ">=9.0.0"}, + {"id": "speckit-inventory", "version": "<0.5"}, + ], + ) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [dep["version"] for dep in unmet] == [">=9.0.0", "<0.5"] + + def test_stale_registry_entry_is_reported( + self, project_dir, temp_dir, valid_pack_data + ): + """A registry entry whose extension directory is gone counts as unmet. + + PresetResolver guards on ``ext_dir.is_dir()`` in both template lookup + and layer collection, so a stale entry contributes nothing -- but the + surviving registry entry would otherwise read as satisfied. + """ + self._install_extension( + project_dir, "speckit-inventory", "0.1.0", with_files=False + ) + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert len(unmet) == 1 + assert unmet[0]["reason"] == "stale" + assert unmet[0]["installed"] == "0.1.0" + + def test_stale_is_reported_ahead_of_disabled_and_version( + self, project_dir, temp_dir, valid_pack_data + ): + """Restoring the files is the prerequisite, so it is reported first.""" + self._install_extension( + project_dir, "speckit-inventory", "0.1.0", + enabled=False, with_files=False, + ) + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "version": ">=9.0.0"}], + ) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [dep["reason"] for dep in unmet] == ["stale"] + + def test_stale_warning_suggests_a_forced_reinstall(self): + """The stale remedy must restore the files, not re-add a registered id.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "stale", + "installed": "0.1.0", + "version": None, + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = strip_ansi(capture.get()) + assert "its files are missing" in output + assert "specify extension add speckit-inventory --force" in output + + def test_disabled_dependency_is_reported( + self, project_dir, temp_dir, valid_pack_data + ): + """A disabled extension contributes nothing, so it counts as unmet. + + Resolution skips disabled extensions, leaving the preset just as inert + as if the extension were absent -- but the registry entry exists, so a + presence-only check would call it satisfied and stay silent. + """ + self._install_extension(project_dir, "speckit-inventory", "0.1.0", enabled=False) + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert len(unmet) == 1 + assert unmet[0]["reason"] == "disabled" + assert unmet[0]["installed"] == "0.1.0" + + def test_disabled_is_reported_ahead_of_version_mismatch( + self, project_dir, temp_dir, valid_pack_data + ): + """Enabling is the prerequisite, so it is reported before the version.""" + self._install_extension(project_dir, "speckit-inventory", "0.1.0", enabled=False) + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "version": ">=9.0.0"}], + ) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [dep["reason"] for dep in unmet] == ["disabled"] + + def test_multiple_dependencies_report_independently( + self, project_dir, temp_dir, valid_pack_data + ): + """Each declared dependency is evaluated on its own.""" + self._install_extension(project_dir, "present-ext", "1.0.0") + self._install_extension(project_dir, "off-ext", "1.0.0", enabled=False) + manifest = self._manifest( + temp_dir, valid_pack_data, + [ + "present-ext", + "absent-ext", + "off-ext", + {"id": "opt-ext", "required": False}, + ], + ) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [(dep["id"], dep["reason"]) for dep in unmet] == [ + ("absent-ext", "missing"), + ("off-ext", "disabled"), + ] + + class TestRegistryPriority: """Test registry priority sorting.""" @@ -2048,6 +2701,25 @@ def test_validate_catalog_url_malformed_rejected(self, project_dir): with pytest.raises(PresetValidationError, match="malformed"): catalog._validate_catalog_url("https://[::1") + def test_validate_catalog_url_out_of_range_port_rejected(self, project_dir): + """An out-of-range port raises ValueError lazily on ``.port`` access. + + ``urlparse(...).hostname`` alone does not validate the port, so + without a ``_ = parsed.port`` probe inside the try/except, a URL like + ``https://example.com:99999/catalog.json`` sails through this + validator and only fails later, at fetch time, with a raw + untranslated error instead of a clean ``PresetValidationError``. The + sibling ``preset add --from `` download-URL guard already + catches this shape (see + ``test_preset_add_from_url_out_of_range_port_exits_cleanly``); this + catalog-source-URL validator had drifted from it and from the + original guard in ``specify_cli.catalogs``/ + ``bundler/services/adapters.py``. + """ + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="malformed"): + catalog._validate_catalog_url("https://example.com:99999/catalog.json") + def test_env_var_catalog_url(self, project_dir, monkeypatch): """Test catalog URL from environment variable.""" monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json") @@ -3274,6 +3946,38 @@ def test_catalog_remove_escapes_markup_in_not_found_error(self, project_dir): assert result.exit_code == 1 assert "[/red]absent" in result.output + @pytest.mark.parametrize( + "args", + [ + [ + "preset", + "catalog", + "add", + "https://example.com/catalog.json", + "--name", + "example", + ], + ["preset", "catalog", "remove", "example"], + ], + ) + def test_catalog_mutation_rejects_non_mapping_config_root( + self, project_dir, args + ): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + config_path = project_dir / ".specify" / "preset-catalogs.yml" + original = "[]\n" + config_path.write_text(original, encoding="utf-8") + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke(app, args) + + assert result.exit_code == 1 + assert "expected a mapping" in result.output + assert config_path.read_text(encoding="utf-8") == original + def test_env_var_overrides_catalogs(self, project_dir, monkeypatch): """Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults.""" monkeypatch.setenv( @@ -3324,6 +4028,17 @@ def test_load_catalog_config_empty(self, project_dir): result = catalog._load_catalog_config(config_path) assert result is None + @pytest.mark.parametrize("bad", [[], False, 0, ""]) + def test_load_catalog_config_rejects_falsy_non_mapping_root( + self, project_dir, bad + ): + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.safe_dump(bad), encoding="utf-8") + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="expected a mapping"): + catalog._load_catalog_config(config_path) + def test_load_catalog_config_invalid_yaml(self, project_dir): """Test loading invalid YAML raises error.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" diff --git a/tests/test_resolve_template_python_parity.py b/tests/test_resolve_template_python_parity.py index 9af5554b44..2bf9977e14 100644 --- a/tests/test_resolve_template_python_parity.py +++ b/tests/test_resolve_template_python_parity.py @@ -91,6 +91,40 @@ def test_all_variants_preserve_composition_parity( ) +@requires_bash +def test_all_variants_treat_core_token_in_core_content_as_literal( + tmp_path: Path, +) -> None: + """Core content holding a literal ``{CORE_TEMPLATE}`` must not be re-expanded. + + The wrap strategy fills the placeholders present in the *wrapper*. A token + that arrives as part of the composed core content is data, not a slot, so it + survives into the output untouched. Rescanning the substituted string instead + reintroduces a token on every pass and never terminates, so the regression + mode here is a hang rather than a wrong value -- hence the timeout, without + which a reintroduced bug would stall the suite instead of failing it. + """ + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + expected = install_composition_stack(repo, TEMPLATE, "# Core {CORE_TEMPLATE}\n") + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, timeout=30), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, timeout=30), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, timeout=30)) + + assert all(result.returncode == 0 for result in results) + assert all(result.stderr == "" for result in results) + # The wrapper contributes exactly one placeholder, so exactly one literal + # token -- the one carried in by the core content -- remains in the output. + assert expected.count("{CORE_TEMPLATE}") == 1 + assert all( + json_stdout(result)["TEMPLATE_CONTENT"] == expected for result in results + ) + + @requires_bash def test_all_variants_read_utf8_registry_under_ascii_locale( tmp_path: Path, diff --git a/tests/test_setup_plan_python_parity.py b/tests/test_setup_plan_python_parity.py index e8372125a3..d66c7083b3 100644 --- a/tests/test_setup_plan_python_parity.py +++ b/tests/test_setup_plan_python_parity.py @@ -132,7 +132,7 @@ def test_python_existing_plan_matches_bash(repo: Path, args: tuple[str, ...]) -> @requires_bash @pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available") -def test_all_variants_ignore_extra_arguments(tmp_path: Path) -> None: +def test_all_variants_reject_unknown_options(tmp_path: Path) -> None: repos = [ _setup_repo(tmp_path, "bash"), _setup_repo(tmp_path, "powershell"), @@ -143,13 +143,15 @@ def test_all_variants_ignore_extra_arguments(tmp_path: Path) -> None: ps = run(ps_cmd(repos[1], SCRIPT, "-Json", "--bogus"), repos[1]) py = run(py_cmd(repos[2], SCRIPT, "--json", "--bogus"), repos[2]) - assert bash.returncode == ps.returncode == py.returncode == 0 - assert normalize_repo_paths(bash.stdout, repos[0]) == normalize_repo_paths( - ps.stdout, repos[1] - ) == normalize_repo_paths(py.stdout, repos[2]) - assert normalize_repo_paths(bash.stderr, repos[0]) == normalize_repo_paths( - ps.stderr, repos[1] - ) == normalize_repo_paths(py.stderr, repos[2]) + assert bash.returncode == ps.returncode == py.returncode == 1 + assert bash.stdout == ps.stdout == py.stdout == "" + assert bash.stderr == ps.stderr == py.stderr == ( + "ERROR: Unknown option '--bogus'\n" + ) + assert all( + not (current / "specs" / "001-my-feature" / "plan.md").exists() + for current in repos + ) @requires_bash diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 2242daad97..2299752854 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -711,6 +711,74 @@ def test_filter_call_with_trailing_tokens_fails_loudly(self): StepContext(inputs={"tags": ["a", "b"]}), ) + def test_filter_on_a_comparison_operand_is_refused(self): + """A filter mixed with a comparison must be reported, not guessed at. + + The pipe is detected before the operators, so + `count > limit | default(5)` evaluated `count > limit` first and then + applied `default` to the resulting bool — a no-op — silently returning + the comparison against the *unfiltered* operand (False, where the author + meant `10 > 5` = True). + + This is the mirror of a filter followed by a comparison + (`default('7') > '5'`), which this module already refuses rather than + guessing at the intended precedence. Both are now refused the same way. + """ + import pytest + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"count": 10, "name": "x"}) + with pytest.raises(ValueError, match="ambiguous filter precedence"): + evaluate_expression("{{ inputs.count > inputs.limit | default(5) }}", ctx) + with pytest.raises(ValueError, match="ambiguous filter precedence"): + evaluate_expression( + '{{ inputs.name == inputs.other | default("x") }}', ctx + ) + with pytest.raises(ValueError, match="ambiguous filter precedence"): + evaluate_expression("{{ inputs.a and inputs.b | default(1) }}", ctx) + + def test_filter_after_a_unary_not_is_refused(self): + """Unary `not` mis-binds the same way and must be caught too. + + `not` is a leading prefix rather than an infix token (the parser tests + it with `expr.startswith("not ")`), so it has no surrounding space for + the operator scan to match. Without an explicit check, + `not inputs.missing | default(1)` evaluated `not inputs.missing` first + and applied `default` to that boolean — a no-op — silently returning + True where the author meant `not 1` = False. + """ + import pytest + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"value": 0, "flag": True}) + with pytest.raises(ValueError, match="ambiguous filter precedence"): + evaluate_expression("{{ not inputs.missing | default(1) }}", ctx) + with pytest.raises(ValueError, match="ambiguous filter precedence"): + evaluate_expression("{{ not inputs.value | default(1) }}", ctx) + # A `not` that follows and/or is already caught by that token. + with pytest.raises(ValueError, match="ambiguous filter precedence"): + evaluate_expression( + "{{ inputs.flag and not inputs.value | default(1) }}", ctx + ) + # Plain unary `not`, with no filter, is untouched. + assert evaluate_expression("{{ not inputs.value }}", ctx) is True + assert evaluate_expression("{{ not inputs.flag }}", ctx) is False + + def test_plain_filters_and_chains_are_unaffected(self): + """Only a filter mixed with an operator is refused.""" + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"items": ["a", "b"], "count": 10, "name": "x"}) + assert evaluate_expression("{{ inputs.missing | default(5) }}", ctx) == 5 + assert evaluate_expression('{{ inputs.items | join(", ") }}', ctx) == "a, b" + assert evaluate_expression("{{ inputs.items | contains('a') }}", ctx) is True + # Operators without a filter, and filters without an operator, both fine. + assert evaluate_expression("{{ inputs.count > 5 }}", ctx) is True + assert evaluate_expression('{{ inputs.name == "x" }}', ctx) is True + def test_chained_filters_apply_left_to_right(self): # Filters chain: each filter's result feeds the next. `map` yields a # list and `join` is the only filter that renders a list to a string, @@ -3128,6 +3196,55 @@ def test_validate_accepts_missing_else(self): class TestSwitchStep: """Test the switch step type.""" + def test_execute_matches_case_ignoring_surrounding_whitespace(self): + """A shell step's stdout keeps its trailing newline; the case must match. + + `ShellStep` stores `proc.stdout` verbatim, so `run: echo approve` + resolves to "approve" plus a newline. Unstripped, that matched no + `approve:` case and the switch silently fell through to `default:` + while still reporting COMPLETED. There is no `trim` filter, so a + workflow author cannot strip it themselves. + """ + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext, StepStatus + + config = { + "id": "route", + "expression": "{{ steps.check.output.stdout }}", + "cases": { + "approve": [{"id": "approved", "type": "command", "command": "echo"}], + "reject": [{"id": "rejected", "type": "command", "command": "echo"}], + }, + "default": [{"id": "fallback", "type": "command", "command": "echo"}], + } + for raw in ("approve\n", "approve\r\n", " approve ", "approve"): + ctx = StepContext(steps={"check": {"output": {"stdout": raw}}}) + result = SwitchStep().execute(config, ctx) + assert result.status == StepStatus.COMPLETED + assert result.output["matched_case"] == "approve", repr(raw) + assert [s["id"] for s in result.next_steps] == ["approved"], repr(raw) + # The raw value is still reported unchanged. + assert result.output["expression_value"] == raw + + def test_execute_still_falls_through_for_a_genuine_mismatch(self): + """Stripping must not make unrelated values match.""" + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext + + config = { + "id": "route", + "expression": "{{ steps.check.output.stdout }}", + "cases": { + "approve": [{"id": "approved", "type": "command", "command": "echo"}] + }, + "default": [{"id": "fallback", "type": "command", "command": "echo"}], + } + ctx = StepContext(steps={"check": {"output": {"stdout": "approve-later\n"}}}) + result = SwitchStep().execute(config, ctx) + + assert result.output["matched_case"] == "__default__" + assert [s["id"] for s in result.next_steps] == ["fallback"] + def test_execute_matches_case(self): from specify_cli.workflows.steps.switch import SwitchStep from specify_cli.workflows.base import StepContext @@ -3309,6 +3426,38 @@ def test_validate_missing_expression(self): errors = step.validate({"id": "test", "cases": {}}) assert any("missing 'expression'" in e for e in errors) + def test_validate_missing_cases(self): + """`cases` is the switch's branch payload and must be required. + + Every other control-flow step requires its own: `if` requires `then`, + `fan-out` requires `items` and `step`, `fan-in` a non-empty `wait_for`, + `gate` a `message`. Without it, a `case:` typo validated clean and then + reported COMPLETED with `matched_case: "__default__"` having dispatched + nothing. + """ + from specify_cli.workflows.steps.switch import SwitchStep + + step = SwitchStep() + + # Absent entirely. + errors = step.validate({"id": "route", "expression": "{{ inputs.x }}"}) + assert any("missing 'cases'" in e for e in errors), errors + + # The realistic slip: `case:` instead of `cases:`. + errors = step.validate( + {"id": "route", "expression": "{{ inputs.x }}", "case": {"a": []}} + ) + assert any("missing 'cases'" in e for e in errors), errors + + def test_validate_accepts_an_empty_cases_mapping(self): + """An explicitly declared but empty `cases:` is still a declaration.""" + from specify_cli.workflows.steps.switch import SwitchStep + + errors = SwitchStep().validate( + {"id": "route", "expression": "{{ inputs.x }}", "cases": {}} + ) + assert not any("missing 'cases'" in e for e in errors), errors + def test_validate_invalid_cases_and_default(self): from specify_cli.workflows.steps.switch import SwitchStep @@ -4520,6 +4669,94 @@ def test_unquoted_schema_version_accepted(self): errors = validate_workflow(definition) assert errors == [] + @pytest.mark.parametrize( + "field, bad_value", + [ + ("integration", ["claude"]), + ("integration", {"name": "claude"}), + ("integration", False), + ("model", ["gpt-5"]), + ("model", {"name": "gpt-5"}), + ("model", 0), + ("options", ["max_tokens"]), + ("options", "max_tokens"), + ("options", False), + ], + ) + def test_rejects_invalid_workflow_dispatch_defaults(self, field, bad_value): + """Top-level dispatch defaults must retain their invalid shape for + validation instead of being passed to a step or normalized to ``{}``. + """ + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition( + { + "workflow": { + "id": "test", + "name": "Test", + "version": "1.0.0", + field: bad_value, + }, + "steps": [{"id": "step-one", "command": "speckit.specify"}], + } + ) + + errors = validate_workflow(definition) + + assert any(f"workflow.{field}" in error for error in errors), errors + assert any(type(bad_value).__name__ in error for error in errors), errors + if field == "options": + assert definition.default_options == bad_value + + def test_preserves_valid_workflow_dispatch_defaults(self): + """String and mapping defaults stay available unchanged to steps.""" + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + defaults = { + "integration": "claude", + "model": "gpt-5", + "options": {"max_tokens": 8000}, + } + definition = WorkflowDefinition( + { + "workflow": { + "id": "test", + "name": "Test", + "version": "1.0.0", + **defaults, + }, + "steps": [{"id": "step-one", "command": "speckit.specify"}], + } + ) + + assert definition.default_integration == defaults["integration"] + assert definition.default_model == defaults["model"] + assert definition.default_options == defaults["options"] + assert validate_workflow(definition) == [] + + def test_accepts_null_workflow_dispatch_defaults(self): + """Null integration/model inherit at runtime and null options stays {}.""" + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition( + { + "workflow": { + "id": "test", + "name": "Test", + "version": "1.0.0", + "integration": None, + "model": None, + "options": None, + }, + "steps": [{"id": "step-one", "command": "speckit.specify"}], + } + ) + + assert definition.default_integration is None + assert definition.default_model is None + assert definition.default_options == {} + assert validate_workflow(definition) == [] + def test_no_steps(self): from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow @@ -5165,6 +5402,36 @@ def test_malformed_inputs_block_no_cascade(self): class TestWorkflowEngine: """Test WorkflowEngine execution.""" + @pytest.mark.parametrize( + ("field", "value"), + [ + ("integration", ["claude"]), + ("model", {"name": "gpt-5"}), + ("options", ["max_tokens"]), + ], + ) + def test_execute_rejects_invalid_workflow_dispatch_defaults( + self, project_dir, field, value + ): + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + + definition = WorkflowDefinition( + { + "workflow": { + "id": "invalid-dispatch-defaults", + "name": "Invalid dispatch defaults", + "version": "1.0.0", + field: value, + }, + "steps": [], + } + ) + + with pytest.raises(ValueError, match=f"workflow.{field}"): + WorkflowEngine(project_dir).execute(definition) + + assert not (project_dir / ".specify" / "workflows" / "runs").exists() + def test_load_from_file(self, sample_workflow_file, project_dir): from specify_cli.workflows.engine import WorkflowEngine @@ -6684,6 +6951,45 @@ def test_workflow_dir_is_resolved_to_absolute(self, project_dir): # and abort the run. +class TestWorkflowDispatchDefaultExecution: + """Execution safeguards for defaults inherited by dispatch steps.""" + + @pytest.mark.parametrize( + "defaults", + [ + { + "integration": "claude", + "model": "gpt-5", + "options": {"max_tokens": 8000}, + }, + {"integration": None, "model": None, "options": None}, + ], + ) + def test_execute_accepts_valid_and_null_dispatch_defaults( + self, project_dir, defaults + ): + """Defaults with supported shapes remain executable without validation.""" + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + + definition = WorkflowDefinition( + { + "workflow": { + "id": "valid-defaults", + "name": "Valid Defaults", + "version": "1.0.0", + **defaults, + }, + "steps": [], + } + ) + + state = WorkflowEngine(project_dir).execute(definition) + + assert state.status == RunStatus.COMPLETED + assert state.step_results == {} + + class TestContinueOnError: """Test the `continue_on_error` step-level field.""" @@ -7476,6 +7782,71 @@ def test_list_skips_bad_file_with_valid_sibling(self, project_dir): assert len(runs) == 1 assert runs[0]["workflow_id"] == "good-run" + def test_list_skips_invalid_utf8_with_valid_sibling(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + runs_dir = project_dir / ".specify" / "workflows" / "runs" + bad_dir = runs_dir / "bad-utf8" + bad_dir.mkdir(parents=True) + (bad_dir / "state.json").write_bytes(b"\xff\xfe invalid utf8") + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "good-run-utf8" + name: "Good Run UTF8" + version: "1.0.0" +steps: + - id: step-one + type: shell + run: "echo test" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + engine.execute(definition) + + runs = engine.list_runs() + assert len(runs) == 1 + assert runs[0]["workflow_id"] == "good-run-utf8" + + def test_list_skips_oserror_with_valid_sibling(self, project_dir, monkeypatch): + import builtins + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + runs_dir = project_dir / ".specify" / "workflows" / "runs" + bad_dir = runs_dir / "bad-oserror" + bad_dir.mkdir(parents=True) + state_file = bad_dir / "state.json" + state_file.write_text('{"run_id": "bad"}', encoding="utf-8") + + original_open = builtins.open + + def _mock_open(path, *args, **kwargs): + if str(path).endswith("state.json") and "bad-oserror" in str(path): + raise OSError("permission denied") + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(builtins, "open", _mock_open) + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "good-run-oserror" + name: "Good Run OSError" + version: "1.0.0" +steps: + - id: step-one + type: shell + run: "echo test" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + engine.execute(definition) + + runs = engine.list_runs() + assert len(runs) == 1 + assert runs[0]["workflow_id"] == "good-run-oserror" + # ===== Workflow Registry Tests ===== @@ -8171,6 +8542,18 @@ def test_remove_catalog_invalid_index(self, project_dir): with pytest.raises(WorkflowValidationError, match="out of range"): catalog.remove_catalog(5) + @pytest.mark.parametrize("bad", [[], False, 0, ""]) + def test_remove_catalog_rejects_falsy_non_mapping_config( + self, project_dir, bad + ): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + config_path.write_text(yaml.safe_dump(bad), encoding="utf-8") + + with pytest.raises(WorkflowValidationError, match="expected a mapping"): + WorkflowCatalog(project_dir).remove_catalog(0) + def test_get_catalog_configs(self, project_dir): from specify_cli.workflows.catalog import WorkflowCatalog @@ -8852,6 +9235,23 @@ def test_add_catalog_empty_yaml_file(self, project_dir): assert len(data["catalogs"]) == 1 assert data["catalogs"][0]["url"] == "https://example.com/steps.json" + @pytest.mark.parametrize("bad", [[], False, 0, ""]) + def test_add_catalog_rejects_falsy_non_mapping_config( + self, project_dir, bad + ): + from specify_cli.workflows.catalog import StepCatalog, StepValidationError + + config_path = project_dir / ".specify" / "step-catalogs.yml" + original = yaml.safe_dump(bad) + config_path.write_text(original, encoding="utf-8") + + with pytest.raises(StepValidationError, match="expected a mapping"): + StepCatalog(project_dir).add_catalog( + "https://example.com/steps.json", "my-steps" + ) + + assert config_path.read_text(encoding="utf-8") == original + def test_add_catalog_duplicate_rejected(self, project_dir): from specify_cli.workflows.catalog import StepCatalog, StepValidationError @@ -8884,6 +9284,18 @@ def test_remove_catalog_invalid_index(self, project_dir): with pytest.raises(StepValidationError, match="out of range"): catalog.remove_catalog(5) + @pytest.mark.parametrize("bad", [[], False, 0, ""]) + def test_remove_catalog_rejects_falsy_non_mapping_config( + self, project_dir, bad + ): + from specify_cli.workflows.catalog import StepCatalog, StepValidationError + + config_path = project_dir / ".specify" / "step-catalogs.yml" + config_path.write_text(yaml.safe_dump(bad), encoding="utf-8") + + with pytest.raises(StepValidationError, match="expected a mapping"): + StepCatalog(project_dir).remove_catalog(0) + def test_remove_catalog_no_config(self, project_dir): from specify_cli.workflows.catalog import StepCatalog, StepValidationError @@ -10962,6 +11374,45 @@ def test_resume_invalid_typed_input_raises(self, project_dir): with pytest.raises(ValueError): engine.resume(state.run_id, {"count": "not-a-number"}) + def test_resume_rejects_legacy_invalid_options_before_state_mutation( + self, project_dir, monkeypatch + ): + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.engine import RunState, WorkflowDefinition + + definition = WorkflowDefinition.from_string(self._WF_NUM) + engine = self._engine(project_dir) + state = engine.execute(definition) + assert state.status == RunStatus.PAUSED + + workflow_copy = ( + project_dir + / ".specify" + / "workflows" + / "runs" + / state.run_id + / "workflow.yml" + ) + workflow_copy.write_text( + self._WF_NUM.replace( + 'version: "1.0.0"', 'version: "1.0.0"\n options: [max_tokens]' + ), + encoding="utf-8", + ) + + def fail_step_context(*args, **kwargs): + raise AssertionError("StepContext must not be created") + + monkeypatch.setattr("specify_cli.workflows.engine.StepContext", fail_step_context) + + with pytest.raises(ValueError, match="'workflow.options' must be a mapping or null"): + engine.resume(state.run_id, {"count": "5"}) + + reloaded = RunState.load(state.run_id, project_dir) + assert reloaded.status == RunStatus.PAUSED + assert reloaded.error is None + assert reloaded.inputs["count"] == 1 + def test_retry_verdict_input_is_consumed_and_can_be_replaced(self, project_dir): import json as _json from specify_cli.workflows.engine import WorkflowDefinition diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py index dc39106b50..bbbac1133b 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/unit/test_bundler_primitives.py @@ -7,6 +7,7 @@ from __future__ import annotations from pathlib import Path +from types import SimpleNamespace import pytest @@ -77,13 +78,17 @@ def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch): monkeypatch.setattr( assets, "_locate_bundled_workflow", lambda wid: tmp_path / "wf" ) - calls: list[str] = [] - monkeypatch.setattr(specify_cli, "workflow_add", lambda wid: calls.append(wid)) + calls: list[tuple] = [] + monkeypatch.setattr( + specify_cli, + "workflow_add", + lambda wid, dev=object(), from_url=object(): calls.append((wid, dev, from_url)), + ) manager = primitive_manager("workflows", tmp_path, allow_network=False) manager.install(_component("workflows", "bundled-wf")) - assert calls == ["bundled-wf"] + assert calls == [("bundled-wf", False, None)] def test_assert_pinned_version_matches_passes(): @@ -169,10 +174,12 @@ def test_bundled_extension_pin_match_installs(tmp_path: Path, monkeypatch): bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0") monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled) called: list = [] - monkeypatch.setattr( - ExtensionManager, "install_from_directory", - lambda self, *a, **k: called.append(a), - ) + + def _fake_install(self, *a, **k): + called.append(a) + return SimpleNamespace(id="my-ext") + + monkeypatch.setattr(ExtensionManager, "install_from_directory", _fake_install) manager = primitive_manager("extensions", tmp_path, allow_network=False) # matching pin, and unpinned, both install cleanly @@ -181,6 +188,93 @@ def test_bundled_extension_pin_match_installs(tmp_path: Path, monkeypatch): assert len(called) == 2 +def _write_extension_with_config(ext_dir: Path) -> None: + """A minimal, real (unmocked) extension source with a provides.config entry.""" + import yaml + + ext_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "schema_version": "1.0", + "extension": { + "id": "my-ext", + "name": "My Extension", + "version": "1.0.0", + "description": "Test extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + {"name": "speckit.my-ext.hello", "file": "commands/hello.md"}, + ], + "config": [ + {"name": "my-ext-config.yml", "template": "config-template.yml"}, + ], + }, + } + (ext_dir / "extension.yml").write_text(yaml.dump(manifest), encoding="utf-8") + (ext_dir / "config-template.yml").write_text("setting: default\n", encoding="utf-8") + (ext_dir / "commands").mkdir(exist_ok=True) + (ext_dir / "commands" / "hello.md").write_text("---\ndescription: Test\n---\n\nhi\n", encoding="utf-8") + + +def test_bundled_extension_install_scaffolds_config(tmp_path: Path, monkeypatch): + """A bundle-installed extension must have its provides.config templates + scaffolded, exactly like `specify extension add` does (issue: bundle + install skipped ExtensionManager.scaffold_config).""" + import specify_cli._assets as assets + + project = tmp_path / "project" + ext_source = tmp_path / "ext-source" + _write_extension_with_config(ext_source) + monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: ext_source) + + manager = primitive_manager("extensions", project, allow_network=False) + manager.install(ComponentRef(kind="extensions", id="my-ext")) + + scaffolded = project / ".specify" / "extensions" / "my-ext" / "my-ext-config.yml" + assert scaffolded.exists() + assert scaffolded.read_text(encoding="utf-8") == "setting: default\n" + + +def test_catalog_extension_install_scaffolds_config(tmp_path: Path, monkeypatch): + """A catalog-resolved (downloaded ZIP) extension install must also + scaffold its provides.config templates, matching the bundled-directory + coverage above. Exercises the reported reproduction, which installed an + extension resolved from the catalog rather than one shipped with Spec Kit.""" + import zipfile + + import specify_cli._assets as assets + from specify_cli.extensions import ExtensionCatalog + + project = tmp_path / "project" + ext_source = tmp_path / "ext-source" + _write_extension_with_config(ext_source) + + zip_path = tmp_path / "my-ext.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for f in ext_source.rglob("*"): + if f.is_file(): + zf.write(f, f.relative_to(ext_source)) + + # No bundled asset located: forces the catalog/ZIP branch (install_from_zip). + monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: None) + monkeypatch.setattr( + ExtensionCatalog, + "get_extension_info", + lambda self, eid: {"id": eid, "_install_allowed": True}, + ) + monkeypatch.setattr( + ExtensionCatalog, "download_extension", lambda self, eid: zip_path + ) + + manager = primitive_manager("extensions", project, allow_network=True) + manager.install(ComponentRef(kind="extensions", id="my-ext")) + + scaffolded = project / ".specify" / "extensions" / "my-ext" / "my-ext-config.yml" + assert scaffolded.exists() + assert scaffolded.read_text(encoding="utf-8") == "setting: default\n" + + def test_bundled_preset_pin_mismatch_refuses(tmp_path: Path, monkeypatch): import specify_cli._assets as assets from specify_cli.presets import PresetManager @@ -227,10 +321,12 @@ def test_extension_refresh_calls_install_with_force(tmp_path: Path, monkeypatch) bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0") monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled) force_values: list = [] - monkeypatch.setattr( - ExtensionManager, "install_from_directory", - lambda self, *a, **k: force_values.append(k.get("force", False)), - ) + + def _fake_install(self, *a, **k): + force_values.append(k.get("force", False)) + return SimpleNamespace(id="my-ext") + + monkeypatch.setattr(ExtensionManager, "install_from_directory", _fake_install) manager = primitive_manager("extensions", tmp_path, allow_network=False) manager.refresh(ComponentRef(kind="extensions", id="my-ext")) @@ -265,10 +361,12 @@ def test_default_installer_refresh_dispatches_to_kind_manager(tmp_path: Path, mo bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0") monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled) force_values: list = [] - monkeypatch.setattr( - ExtensionManager, "install_from_directory", - lambda self, *a, **k: force_values.append(k.get("force", False)), - ) + + def _fake_install(self, *a, **k): + force_values.append(k.get("force", False)) + return SimpleNamespace(id="my-ext") + + monkeypatch.setattr(ExtensionManager, "install_from_directory", _fake_install) installer = DefaultPrimitiveInstaller(allow_network=False) installer.refresh(tmp_path, _component("extensions", "my-ext")) @@ -290,6 +388,7 @@ def test_refresh_succeeds_and_passes_force_true(tmp_path: Path, monkeypatch): def _fake_install_from_directory(self, *a, **k): force_seen.append(k.get("force", False)) self.registry.add("my-ext", {"version": "1.0.0"}) + return SimpleNamespace(id="my-ext") monkeypatch.setattr( ExtensionManager, "install_from_directory", _fake_install_from_directory diff --git a/tests/unit/test_bundler_records.py b/tests/unit/test_bundler_records.py index 8f6f0d6547..dc1da118a1 100644 --- a/tests/unit/test_bundler_records.py +++ b/tests/unit/test_bundler_records.py @@ -209,3 +209,73 @@ def test_load_records_accepts_forward_compatible_minor_schema(tmp_path: Path): payload = {"schema_version": "1.5", "bundles": []} records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8") assert load_records(tmp_path) == [] + + +@pytest.mark.parametrize( + "field,message", + [ + ("bundle_id", "missing its 'bundle_id'"), + ("version", "missing its 'version'"), + ], +) +def test_load_records_rejects_explicit_null_record_field( + tmp_path: Path, field: str, message: str +): + """An explicit JSON ``null`` is how a corrupt record spells an empty field. + + ``str(data.get(field, ""))`` defaults only a *missing* key, so a + present-but-null value became the literal text ``"None"`` — non-empty, so + it sailed past the required-field checks and the record was accepted as a + bundle actually named ``"None"``. Mirrors ``manifest._text``. + """ + (tmp_path / ".specify").mkdir() + record = {"bundle_id": "a", "version": "1.0.0", "contributed_components": []} + record[field] = None + payload = {"schema_version": "1.0", "bundles": [record]} + records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(BundlerError, match=message): + load_records(tmp_path) + + +def test_load_records_rejects_explicit_null_component_id(tmp_path: Path): + """A null component id became ``"None"`` and entered the refcount. + + ``components_still_needed`` would then report a phantom + ``('presets', 'None')`` as protected. + """ + (tmp_path / ".specify").mkdir() + payload = { + "schema_version": "1.0", + "bundles": [ + { + "bundle_id": "a", + "version": "1.0.0", + "contributed_components": [{"kind": "presets", "id": None}], + } + ], + } + records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(BundlerError, match="missing its 'id'"): + load_records(tmp_path) + + +def test_load_records_accepts_explicit_null_installed_at(tmp_path: Path): + """``installed_at`` is optional, so a null must become "" — not "None".""" + (tmp_path / ".specify").mkdir() + payload = { + "schema_version": "1.0", + "bundles": [ + { + "bundle_id": "a", + "version": "1.0.0", + "installed_at": None, + "contributed_components": [], + } + ], + } + records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8") + + records = load_records(tmp_path) + assert records[0].installed_at == "" diff --git a/tests/unit/test_condition_expression_block.py b/tests/unit/test_condition_expression_block.py new file mode 100644 index 0000000000..0739a2bc29 --- /dev/null +++ b/tests/unit/test_condition_expression_block.py @@ -0,0 +1,829 @@ +"""A string condition with no ``{{ }}`` block is never evaluated (always true).""" + +import pytest +import yaml + +from specify_cli.workflows.base import StepContext +from specify_cli.workflows.expressions import ( + condition_has_malformed_expression_block, + condition_is_interpolated_to_text, + condition_is_never_evaluated, + evaluate_condition, + evaluate_expression, + format_condition_correction, + _has_unbalanced_quote, + _has_unbalanced_bracket, + _has_incomplete_operand, + _unresolvable_term, + _evaluator_rejects, + _is_literal, + _strip_stray_delimiters, + _COMPARISON_OPERATORS, + _WORD_OPERATORS, + format_condition_remediation, +) +from specify_cli.workflows.steps.do_while import DoWhileStep +from specify_cli.workflows.steps.if_then import IfThenStep +from specify_cli.workflows.steps.while_loop import WhileStep + +STEP_CLASSES = [IfThenStep, WhileStep, DoWhileStep] + + +@pytest.mark.parametrize( + "condition", + ["inputs.count > 100", "inputs.name == 'zzz'", "inputs.count < 3"], +) +def test_brace_less_condition_is_always_true_at_runtime(condition): + """The behaviour the validator now warns about, pinned so it cannot drift.""" + ctx = StepContext(inputs={"count": 5, "name": "abc"}) + # Same expression with braces resolves to its real (false) value... + assert evaluate_condition("{{ " + condition + " }}", ctx) is False + # ...without them it is only non-empty text, so bool() makes it true. + assert evaluate_condition(condition, ctx) is True + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +def test_validator_rejects_condition_without_expression_block(step_cls): + config = {"id": "s1", "condition": "inputs.count > 100", "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "never evaluated" in e] + assert len(errors) == 1 + assert "inputs.count > 100" in errors[0] + # The message hands back the corrected form. + assert '"{{ inputs.count > 100 }}"' in errors[0] + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize( + "condition", + ["{{ inputs.count > 100 }}", "true", "false", "TRUE", True, False, ""], +) +def test_validator_accepts_evaluated_and_literal_conditions(step_cls, condition): + """No false positives: braces, boolean literals and bools stay valid.""" + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + assert not [e for e in step_cls().validate(config) if "never evaluated" in e] + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("inputs.count > 100", True), + ("{{ inputs.count > 100 }}", False), + ("prefix {{ inputs.a }} suffix", False), + ("true", False), + ("False", False), + ("", False), + # `bool(" ")` is true and evaluate_condition strips only around the + # true/false keywords, so whitespace is a silent always-true, not a + # definite False. Only "" coerces to False. + (" ", True), + ("\t\n ", True), + (True, False), + (["a"], False), + (3, False), + ], +) +def test_condition_is_never_evaluated(value, expected): + assert condition_is_never_evaluated(value) is expected + + +# --- An unterminated ``{{`` is the same defect, not a different one ----------- +# +# ``_interpolate_expressions`` substitutes nothing when no ``}}`` follows the +# opening ``{{`` (its ``raw_close == -1`` branch appends the tail verbatim), so +# ``{{ inputs.count > 100`` is returned unchanged and coerced to true exactly +# like a brace-less string. + +BACKSLASH = chr(92) + +NEVER_EVALUATED = [ + "inputs.count > 100", # no delimiter at all + "{{ inputs.count > 100", # opened, never closed + "}} inputs.count > 100 {{", # reversed: the only '{{' is last + # A complete block does not vouch for the rest: interpolation leaves the + # second fragment verbatim, and bool() makes the whole string true. + "{{ true }} and {{ inputs.ready", +] + +# A different fault, and the interpolator treats it differently: the quote-aware +# scan finds no close, but a raw '}}' exists further along, so +# _interpolate_expressions falls back to it and *evaluates* the truncated body. +# These are not "never evaluated" -- one leaves residual text that bool() makes +# true, the other reaches the filter parser and raises. +MALFORMED_BLOCKS = [ + "{{ inputs.x == '}}'", + "{{ inputs.missing | default('oops }}", + # Same, but the faulty block is the second one. + "{{ inputs.name }} {{ inputs.missing | default('oops }}", +] + + +@pytest.mark.parametrize("condition", NEVER_EVALUATED) +def test_incomplete_block_is_silently_true_and_is_flagged(condition): + ctx = StepContext(inputs={"count": 5, "name": "abc"}) + assert evaluate_condition(condition, ctx) is True + assert condition_is_never_evaluated(condition) is True + assert condition_has_malformed_expression_block(condition) is False + + +@pytest.mark.parametrize("condition", MALFORMED_BLOCKS) +def test_raw_close_fallback_is_malformed_not_never_evaluated(condition): + """The block *is* evaluated, so it must not be reported as always true.""" + assert condition_has_malformed_expression_block(condition) is True + assert condition_is_never_evaluated(condition) is False + + +def test_a_malformed_block_can_raise_rather_than_be_true(): + """The concrete case the "always true" wording got wrong. + + `default('oops` swallows the real close, the raw-close fallback hands the + filter parser a truncated argument, and the run dies instead of taking a branch. + """ + ctx = StepContext(inputs={"count": 5}) + with pytest.raises(ValueError): + evaluate_condition("{{ inputs.missing | default('oops }}", ctx) + + +# A third fault. The braces are present and they close, but they do not cover the +# whole condition, so `evaluate_expression` leaves its typed fast path: each block is +# substituted into the surrounding text and the result is a *string*, which +# `evaluate_condition` then coerces. Every one of these reads as a real expression and +# is always true. The validators already told authors the condition must be "a single +# complete '{{ }}' block" -- nothing checked it. +INTERPOLATED_TO_TEXT = [ + "{{ inputs.ready }} and {{ inputs.count > 100 }}", # two blocks joined by an operator + "{{ inputs.ready }} or {{ inputs.ready }}", + "not {{ inputs.ready }}", # operator outside the block + "{{ inputs.count }} > 100", # comparison outside the block + "ready: {{ inputs.ready }}", # prose around one block + "{{ inputs.ready }}x", # a single trailing character +] + + +@pytest.mark.parametrize("condition", INTERPOLATED_TO_TEXT) +def test_a_condition_spliced_into_text_is_silently_true_and_is_flagged(condition): + # Ground truth first: the interpolated form really is a string, and really is true + # for a set of inputs where the expression the author wrote would be false. + ctx = StepContext(inputs={"ready": False, "count": 0}) + rendered = evaluate_expression(condition, ctx) + assert isinstance(rendered, str) + assert evaluate_condition(condition, ctx) is True + + assert condition_is_interpolated_to_text(condition) is True + + +@pytest.mark.parametrize("condition", INTERPOLATED_TO_TEXT) +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +def test_every_condition_step_rejects_a_spliced_condition(step_cls, condition): + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "'condition'" in e] + + assert len(errors) == 1 + assert "single '{{ }}' block" in errors[0] + # No paste-ready correction: there is no single right rewrite of `{{ a }} and {{ b }}`. + assert "Wrap the expression" not in errors[0] + + +VALID_SINGLE_BLOCKS = [ + "{{ inputs.ready }}", + "{{ inputs.ready and inputs.count > 100 }}", + "{{ not inputs.ready }}", + "{{ inputs.tags | join(', ') == 'a, b' }}", + # A '}}' inside a quoted argument does not end the block, so this is still one + # expression and must stay on the fast path. + "{{ inputs.text | contains('}}') }}", + # `evaluate_expression` strips before testing the fast path, so surrounding + # whitespace is not "text around the block" and must stay accepted. + " {{ inputs.ready }} ", +] + + +@pytest.mark.parametrize("condition", VALID_SINGLE_BLOCKS) +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +def test_a_single_complete_block_is_still_accepted(step_cls, condition): + """The narrowing must not widen: one block, however complex, is the supported form.""" + assert condition_is_interpolated_to_text(condition) is False + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + assert [e for e in step_cls().validate(config) if "'condition'" in e] == [] + + +@pytest.mark.parametrize("condition", NEVER_EVALUATED + MALFORMED_BLOCKS) +def test_the_older_two_faults_keep_their_own_message(condition): + """The new check yields to both, so each fault keeps the advice written for it.""" + assert condition_is_interpolated_to_text(condition) is False + + +@pytest.mark.parametrize("condition", NEVER_EVALUATED + MALFORMED_BLOCKS) +def test_the_two_faults_are_mutually_exclusive(condition): + assert condition_is_never_evaluated(condition) != condition_has_malformed_expression_block(condition) + + +@pytest.mark.parametrize( + "condition", + [ + "{{ inputs.count > 100 }}", + "{{ inputs.a }} and {{ inputs.b }}", + "{{ inputs.text | default('}}') }}", # literal '}}' inside an argument + "{{ inputs.x == '}}' }}", # quoted '}}' then the real close + ], +) +def test_complete_block_is_not_flagged(condition): + assert condition_is_never_evaluated(condition) is False + + +# --- The suggested correction has to survive a YAML round trip --------------- + +TRICKY_CONDITIONS = [ + "inputs.count > 100", + 'inputs.name == "zzz"', # double quote + "inputs.name == 'zzz'", # single quote + 'inputs.a == "x" and inputs.b == \'y\'', # both + "inputs.path == 'C:" + BACKSLASH + "tmp'", # backslash + 'inputs.path == "C:' + BACKSLASH + 'tmp"', # backslash + quote + '{{ inputs.name == "zzz"', # incomplete + quote + "}} inputs.count > 100 {{", + # A YAML literal block hands the loader a real newline; a folded scalar + # would lose it, so the correction has to escape rather than embed it. + "inputs.x == 1\nand inputs.name == 'abc'", + 'he said "hi"\nthen left', # newline + quote + "inputs.a == 'x\ty'", # tab + "inputs.a == 'x\ry'", # carriage return + "inputs.ten == 'mười'", # non-ASCII operand +] + + +@pytest.mark.parametrize("condition", TRICKY_CONDITIONS) +def test_correction_is_valid_yaml_and_round_trips(condition): + """A correction the author cannot paste into their workflow is no correction.""" + loaded = yaml.safe_load("condition: " + format_condition_correction(condition)) + stripped = condition.strip().lstrip("{}").rstrip("{}").strip() + assert loaded["condition"] == "{{ " + stripped + " }}" + + +@pytest.mark.parametrize("condition", TRICKY_CONDITIONS) +def test_correction_does_not_trip_the_validator_again(condition): + loaded = yaml.safe_load("condition: " + format_condition_correction(condition)) + assert condition_is_never_evaluated(loaded["condition"]) is False + + +@pytest.mark.parametrize("condition", ["{{ inputs.count > 100", "}} a > 1 {{"]) +def test_correction_replaces_a_stray_delimiter_instead_of_nesting_one(condition): + corrected = format_condition_correction(condition) + assert "{{ {{" not in corrected and "}} }}" not in corrected + assert corrected.count("{{") == 1 and corrected.count("}}") == 1 + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition", ['inputs.name == "zzz"', "{{ inputs.count > 100"]) +def test_validator_correction_is_yaml_safe(step_cls, condition): + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "never evaluated" in e] + assert len(errors) == 1 + suggested = errors[0].split("Wrap the expression: ", 1)[1].rstrip(".") + loaded = yaml.safe_load("condition: " + suggested) + assert condition_is_never_evaluated(loaded["condition"]) is False + + +def test_correction_keeps_non_ascii_readable(): + """ensure_ascii=False: an operand should not turn into numeric escapes.""" + corrected = format_condition_correction("inputs.ten == 'mười'") + assert "mười" in corrected + assert chr(92) + "u" not in corrected + + +def test_whitespace_condition_is_flagged_but_the_empty_string_is_not(): + """Whitespace is the silent always-true this validator exists to catch. + + ``test_condition_whitespace_only_string_stays_truthy`` pins the runtime + behaviour deliberately, so the mistake can only be caught at validation time. + """ + assert evaluate_condition(" ", StepContext()) is True + assert condition_is_never_evaluated(" ") is True + + assert evaluate_condition("", StepContext()) is False + assert condition_is_never_evaluated("") is False + + +@pytest.mark.parametrize( + "condition", + [ + "prefix {{ inputs.ready", + "inputs.ready }} suffix", + "{{ inputs.a }} and {{ inputs.b", + ], +) +def test_correction_removes_an_interior_delimiter_too(condition): + """Trimming only the edges left the correction carrying an inner block. + + ``prefix {{ inputs.ready`` corrected to ``"{{ prefix {{ inputs.ready }}"``, + whose complete outer block then walked back past this very validator. + """ + corrected = format_condition_correction(condition) + inner = yaml.safe_load("condition: " + corrected)["condition"] + assert inner.count("{{") == 1 and inner.count("}}") == 1 + assert inner.startswith("{{ ") and inner.endswith(" }}") + + +def test_correction_keeps_a_delimiter_that_is_quoted_data(): + """``'}}'`` is an operand, not a block, so the stripper must not eat it.""" + corrected = format_condition_correction("{{ inputs.x == '}}'") + inner = yaml.safe_load("condition: " + corrected)["condition"] + assert inner == "{{ inputs.x == '}}' }}" + assert condition_is_never_evaluated(inner) is False + + +def test_correction_preserves_spacing_inside_a_quoted_operand(): + """Whitespace is collapsed only where a delimiter was removed.""" + corrected = format_condition_correction('{{ inputs.name == "a b"') + inner = yaml.safe_load("condition: " + corrected)["condition"] + assert inner == '{{ inputs.name == "a b" }}' + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition", MALFORMED_BLOCKS) +def test_validator_reports_malformed_rather_than_always_true(step_cls, condition): + """The two faults need opposite advice, so they must not share a message. + + "never evaluated and is always true" is wrong here on both halves: the + interpolator does evaluate the truncated body, and the result is not + reliably true -- it can raise. + """ + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "'condition'" in e] + + assert len(errors) == 1 + assert "never evaluated" not in errors[0] + assert "cannot close" in errors[0] + assert "truncated expression" in errors[0] + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition", MALFORMED_BLOCKS) +def test_malformed_message_offers_no_paste_ready_correction(step_cls, condition): + """Deliberately no suggestion for this class. + + The fault is unbalanced delimiters or quotes, so the quote-aware stripper + cannot tell operand from delimiter -- for `{{ inputs.missing | default('oops }}` + it produces `"{{ inputs.missing | default('oops }} }}"`, which is not a fix. + Naming the fault beats handing back something that looks authoritative and + is not. + """ + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "'condition'" in e] + assert "Wrap the expression" not in errors[0] + assert errors[0].rstrip().endswith("Balance the delimiters and quotes.") + + +# A correction is only offered when wrapping would actually repair the condition. +# These two inputs reach the same "never evaluated" branch, but wrapping them +# produces something the author must not paste, so the advice names the fault +# instead. Both were previously advertised as paste-ready (Copilot review). +UNFIXABLE_BY_WRAPPING = [ + (" ", "no expression here to wrap"), + ("{{ inputs.name == 'abc", "quote opened in it is never closed"), + ("'unterminated", "quote opened in it is never closed"), + ("inputs.name ==", "missing an operand"), + ("inputs.count >", "missing an operand"), + ("inputs.ready and", "missing an operand"), + ("inputs.x | ", "missing an operand"), + ("inputs.f(", "brackets do not balance"), +] + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition,expected", UNFIXABLE_BY_WRAPPING) +def test_no_paste_ready_correction_when_wrapping_would_not_repair( + step_cls, condition, expected +): + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "'condition'" in e] + + assert len(errors) == 1 + assert "Wrap the expression" not in errors[0] + assert expected in errors[0] + + +def test_wrapping_whitespace_would_invert_the_condition(): + """Why the blank case gets advice instead of a suggestion. + + `{{ }}` interpolates to the empty string, so pasting it turns an always-true + condition into an always-false one -- a different defect, not a repair. + """ + ctx = StepContext(inputs={}) + assert evaluate_condition(" ", ctx) is True + assert evaluate_condition("{{ }}", ctx) is False + + +def test_wrapping_an_open_quote_inverts_the_condition(): + """Why the unbalanced-quote case gets advice instead of a suggestion. + + The raw-close fallback evaluates a truncated comparison and yields the string + "False", which evaluate_condition then reads as the `false` keyword. Pasting + the "correction" flips the condition rather than repairing it. + """ + ctx = StepContext(inputs={"name": "Bob"}) + assert evaluate_condition("{{ inputs.name == 'abc", ctx) is True + assert evaluate_condition("{{ inputs.name == 'abc }}", ctx) is False + + +@pytest.mark.parametrize( + "text,unbalanced", + [ + ("inputs.name == 'abc'", False), + ('inputs.name == "abc"', False), + ("inputs.name == 'abc", True), + ('inputs.name == "abc', True), + ("inputs.text == '\"'", False), + ("inputs.count > 100", False), + ], +) +def test_unbalanced_quote_scan(text, unbalanced): + assert _has_unbalanced_quote(text) is unbalanced + + +# The property behind the case list above, stated once so a new malformed shape +# is caught by the invariant rather than by adding another fixture row. +# Genuine expressions only. TRICKY_CONDITIONS is a quoting/escaping fixture for +# the formatter and deliberately includes prose, so it must not be reused here. +OFFERED_CORRECTION_INPUTS = [ + "inputs.count > 100", + 'inputs.name == "zzz"', + "inputs.name == 'zzz'", + "{{ inputs.count > 100", + "{{ true }} and {{ inputs.ready", + "inputs.a and inputs.b", + "inputs.name", + "not inputs.ready", + "inputs.tags | join(',')", + # The tricky-quoting cases from TRICKY_CONDITIONS that really are expressions. + # Listed rather than filtered out of that fixture, so adding prose there cannot + # silently widen what this invariant claims. + 'inputs.a == "x" and inputs.b == \'y\'', + "inputs.path == 'C:" + BACKSLASH + "tmp'", + 'inputs.path == "C:' + BACKSLASH + 'tmp"', + "inputs.a == 'x\ty'", + "inputs.a == 'x\ry'", + "inputs.ten == 'mười'", + '{{ inputs.name == "zzz"', + "}} inputs.count > 100 {{", +] + + +@pytest.mark.parametrize("condition", OFFERED_CORRECTION_INPUTS) +def test_every_offered_correction_is_a_complete_expression(condition): + """Whatever is advertised as paste-ready must pass our own validators. + + Both earlier rounds of this fix were partial because they enumerated broken + shapes -- blank, then unbalanced quote. This asserts the property instead: if + the remediation offers a correction at all, the wrapped form it hands back is + a single complete block that neither validator objects to. + """ + advice = format_condition_remediation(condition) + assert advice.startswith("Wrap the expression: ") + + suggested = yaml.safe_load( + "condition: " + advice.split("Wrap the expression: ", 1)[1].rstrip(".") + )["condition"] + assert condition_is_never_evaluated(suggested) is False + assert condition_has_malformed_expression_block(suggested) is False + + +@pytest.mark.parametrize("condition,_reason", UNFIXABLE_BY_WRAPPING) +def test_withheld_corrections_would_indeed_have_been_broken(condition, _reason): + """The other half: what is withheld really would not have survived wrapping. + + Guards against the gate growing over-eager and refusing to help with input it + could have corrected. + """ + core = _strip_stray_delimiters(condition).strip() + wrapped = "{{ " + core + " }}" + assert ( + not core + or _has_unbalanced_quote(core) + or _has_unbalanced_bracket(core) + or _has_incomplete_operand(core) + or condition_is_never_evaluated(wrapped) + or condition_has_malformed_expression_block(wrapped) + ) + + +@pytest.mark.parametrize( + "text,unbalanced", + [ + ("inputs.f(1)", False), + ("inputs.f(", True), + ("inputs.f)", True), + ("inputs.tags[0]", False), + ("inputs.text == '('", False), + ], +) +def test_unbalanced_bracket_scan(text, unbalanced): + assert _has_unbalanced_bracket(text) is unbalanced + + +def test_incomplete_operand_reads_the_evaluator_operator_list(): + """The check must not restate the operator table it is predicting.""" + for op in _COMPARISON_OPERATORS: + assert _has_incomplete_operand("inputs.a" + op) is True + assert _has_incomplete_operand("inputs.a" + op + "inputs.b") is False + + +def test_incomplete_operand_covers_every_operator_the_evaluator_splits_on(): + """Hard-coded on purpose. + + Parametrising over `_COMPARISON_OPERATORS` shrinks with the constant, so + dropping an operator from it would make that test pass vacuously -- the same + can't-fail-when-it-matters shape this module exists to reject. Listing the + operators here means removing one from the evaluator fails a test. + """ + for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in ", " and ", " or "): + assert _has_incomplete_operand("inputs.a" + op) is True, op + assert _has_incomplete_operand("inputs.a" + op + "inputs.b") is False, op + + +# Copilot round 3: the first two gates each inspected only one position. These pin +# every-position scanning, both ends, and bracket-type matching. +MULTI_POSITION_UNFIXABLE = [ + ("inputs.a == inputs.b ==", "missing an operand"), # trailing, not the first op + ("and inputs.ready", "missing an operand"), # leading boolean operator + ("inputs.a not in", "missing an operand"), # trailing word operator + ("in inputs.tags", "missing an operand"), # leading word operator + ("inputs.f(]", "brackets do not balance"), # matched count, wrong types + ("inputs.f(]", "brackets do not balance"), + ("inputs.items | length", "the evaluator rejects it"), + ("inputs.tags | join", "used in an unsupported form"), + ('he said "hi" then left', "is not a name the evaluator can resolve"), + ("inputs.count+1", "is not a valid path segment"), + ("inputs.a === inputs.b", "is not a name the evaluator can resolve"), + ("bogus == 'x'", "is not one of the namespace roots"), + ("inputs.payload | from_json()", "the evaluator rejects it"), + # `_find_top_level` matches " and " with literal spaces, so a newline before + # the keyword is not an operator: the wrapped form evaluates False where the + # same expression with a space evaluates True. + ("inputs.x == 1\nand inputs.name == 'abc'", "is not a name the evaluator can resolve"), +] + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition,expected", MULTI_POSITION_UNFIXABLE) +def test_gates_inspect_every_position_not_just_the_first(step_cls, condition, expected): + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "'condition'" in e] + + assert len(errors) == 1 + assert "Wrap the expression" not in errors[0] + assert expected in errors[0] + + +@pytest.mark.parametrize( + "text,unbalanced", + [ + ("inputs.f(]", True), # counts match, types do not + ("inputs.f[)", True), + ("inputs.f(}", True), + ("inputs.f([])", False), + ("inputs.f(])", True), + ("inputs.text == '(]'", False), # mismatched pair inside a quoted operand + ], +) +def test_bracket_scan_matches_types_not_just_depth(text, unbalanced): + assert _has_unbalanced_bracket(text) is unbalanced + + +def test_word_operators_are_derived_from_the_evaluator_table(): + """Guards the derivation, not the literal tuple. + + If a space-delimited operator is added to _COMPARISON_OPERATORS, the end-of-core + checks must pick it up without another edit here. + """ + assert _WORD_OPERATORS == (" or ", " and ", " not in ", " in ") + for op in _WORD_OPERATORS: + assert _has_incomplete_operand("inputs.a" + op.rstrip()) is True, op + assert _has_incomplete_operand(op.lstrip() + "inputs.a") is True, op + + +def test_the_probe_reports_what_the_evaluator_reports(): + """The parse probe must not restate the filter table. + + Four review rounds each found a shape the structural gates did not know about. + Asking the evaluator removes that class: any filter used under an unknown name + or in an unsupported form is reported by the code that will run. + """ + assert _evaluator_rejects("inputs.items | length") is not None + assert _evaluator_rejects("inputs.tags | join") is not None + assert _evaluator_rejects("inputs.tags | join(',')") is None + assert _evaluator_rejects("inputs.count > 100") is None + + +@pytest.mark.parametrize( + "text,not_a_path", + [ + ("inputs.name", False), + ("inputs.a.b.c", False), + ("inputs.tags[0]", False), + ("not inputs.ready", False), + ("true", False), + ("42", False), + ("'a literal'", False), + ("inputs.count > 100", False), # has an operator, not a bare term + ("inputs.count+1", True), # the evaluator has no arithmetic + ('he said "hi" then left', True), + # _resolve_dot_path keys on [w-]+, so a key literally named "2bad" resolves. + ("inputs.2bad", False), + ("inputs.tags[foo]", True), + ("inputs.matrix[0][1]", True), + # Round 7: an operand one level down, which the single-term gate never saw. + ("inputs.a === inputs.b", True), + ("bogus", True), + ("bogus == 'x'", True), + ("item.name == 'x'", False), + ("fan_in.results | join(',')", False), + ("context.run_id != ''", False), + ], +) +def test_operands_must_be_literals_or_known_paths(text, not_a_path): + """Recursing to the leaves replaced the single-term check. + + The old gate only looked at a core with no operator, so `inputs.a === inputs.b` + and `bogus == 'x'` walked past it. This asserts the reachable leaf instead. + """ + assert (_unresolvable_term(text) is not None) is not_a_path + + +@pytest.mark.parametrize( + "condition", + [ + # Valid against a string output and exercised in tests/test_workflows.py. + # The probe hands from_json a dict, so treating every probe error as a + # rejection withheld a correction from a good condition. + "steps.emit.output.stdout | from_json", + # The filter argument is resolved from the namespace too. + "inputs.tags | join(inputs.separator)", + ], +) +def test_probe_value_errors_are_not_treated_as_rejections(condition): + assert _evaluator_rejects(condition) is None + assert format_condition_remediation(condition).startswith("Wrap the expression: ") + + +@pytest.mark.parametrize( + "condition", + ["inputs.items | length", "inputs.tags | join"], +) +def test_filter_wiring_errors_are_still_rejections(condition): + """The other half: a filter named wrong or used wrong is the author's text.""" + assert _evaluator_rejects(condition) is not None + assert "Wrap the expression" not in format_condition_remediation(condition) + + +@pytest.mark.parametrize( + "condition,literal", + [ + ("42", True), + ("3.14", True), + ("-7", True), + # `1e3` has no "." so the evaluator calls int() on it, which fails; it then + # falls through to a path lookup. float() alone accepted it here. + ("1e3", False), + ("'one'", True), + ('"one"', True), + # Two literals, not one: the evaluator requires the opening quote's match to + # be the final character, which first/last-character equality does not. + ("'a' 'b'", False), + ("'a' == 'b'", False), + ("true", True), + ("inputs.name", False), + ], +) +def test_literal_test_mirrors_the_evaluator(condition, literal): + assert _is_literal(condition) is literal + + +@pytest.mark.parametrize( + "condition", + [ + # `_build_namespace` hands back mappings, so an indexed root always resolves + # to None however the index is written. + "inputs[0]", + "steps[1]", + "1e3", + "'a' 'b'", + ], +) +def test_shapes_the_evaluator_resolves_to_none_get_no_correction(condition): + advice = format_condition_remediation(condition) + assert "Wrap the expression" not in advice + + +# The two shapes below were each offered or withheld for the wrong reason. Both are +# checked against what the evaluator actually does with the wrapped form, not against +# a restatement of the check, so a check that drifts from the evaluator fails here. +CORRECTION_OFFERED = "Wrap the expression" + + +def _wrapped_evaluates(condition: str) -> bool: + ctx = StepContext( + inputs={ + "tag": "x", + "tags": ["a", "b"], + "count": 3, + "fallback": ", ", + "blob": '{"k": 1}', + } + ) + try: + evaluate_condition("{{ " + condition + " }}", ctx) + except Exception: + return False + return True + + +@pytest.mark.parametrize( + "condition", + [ + "inputs.tag in ['x', 'y']", + "inputs.tag not in ['x']", + "inputs.tag in [inputs.other, 'z']", + # `_evaluate_simple_expression` drops empty segments, so a trailing comma is + # `[1, 2]` rather than `[1, 2, None]`, and an empty list is a list. + "inputs.count in [1, 2,]", + "inputs.count in []", + ], +) +def test_list_literal_operands_keep_the_correction(condition): + """A list literal is a term, not a name. + + Resolving the brackets as a path reported `"['x', 'y']" is not a name the + evaluator can resolve` and withheld the correction from a condition that + wrapping repairs completely. + """ + assert CORRECTION_OFFERED in format_condition_remediation(condition) + assert _wrapped_evaluates(condition) + + +@pytest.mark.parametrize( + "condition", + ["inputs.tags | join(bogus)", "inputs.tags | map(bogus)"], +) +def test_filter_arguments_that_make_the_wrapped_form_raise_lose_the_correction(condition): + """A filter argument is an operand like any other. + + `_apply_filter` evaluates it with `_evaluate_simple_expression`, so a name that + is no namespace root arrives as None and the filter raises on it. Skipping the + argument offered these as paste-ready. + """ + assert CORRECTION_OFFERED not in format_condition_remediation(condition) + assert not _wrapped_evaluates(condition) + + +def test_a_filter_argument_that_cannot_resolve_loses_it_even_without_raising(): + """`default` tolerates the None, so this one is policy rather than a crash. + + Withholding it is the same call already made for an unresolvable name anywhere + else -- `bogus == 'x'` evaluates fine and is withheld too -- so the argument + check does not need the wrapped form to raise before it declines. + """ + condition = "inputs.count | default(bogus)" + assert CORRECTION_OFFERED not in format_condition_remediation(condition) + assert _wrapped_evaluates(condition) + assert CORRECTION_OFFERED not in format_condition_remediation("bogus == 'x'") + + +@pytest.mark.parametrize( + "condition", + [ + "inputs.tags | join(', ')", + "inputs.tags | join(inputs.fallback)", + "inputs.tags | map('name')", + "inputs.count | default(0)", + "inputs.blob | from_json", + ], +) +def test_resolvable_filter_arguments_keep_the_correction(condition): + """The other direction: the argument check must not become a blanket refusal.""" + assert CORRECTION_OFFERED in format_condition_remediation(condition) + assert _wrapped_evaluates(condition) + + +@pytest.mark.parametrize("condition", ["item[0] == 'x'", "item[1] == 'y'"]) +def test_an_indexed_item_root_keeps_the_correction(condition): + """`item` is the only root that is not always a mapping. + + `StepContext.item` is `Any` and a fan-out assigns the item value itself, so an + item that is a list makes `item[0]` resolve. Rejecting every indexed root + withheld the correction from a condition that evaluates. + """ + ctx = StepContext(inputs={"a": 1}, item=["x", "y"]) + assert CORRECTION_OFFERED in format_condition_remediation(condition) + assert evaluate_condition("{{ " + condition + " }}", ctx) is True + + +@pytest.mark.parametrize("condition", ["inputs[0]", "steps[1]", "fan_in[0]", "context[0]"]) +def test_indexing_an_always_mapping_root_still_loses_the_correction(condition): + """The other side of that split, so it does not widen into "any indexed root". + + `_build_namespace` hands these back as mappings, so `_resolve_dot_path` takes + the index branch, finds no list, and returns None however the index is written. + """ + ctx = StepContext(inputs={"a": 1}, item=["x", "y"]) + assert CORRECTION_OFFERED not in format_condition_remediation(condition) + assert evaluate_condition("{{ " + condition + " }}", ctx) is False diff --git a/tests/workflows/test_overlay_commands.py b/tests/workflows/test_overlay_commands.py index 8a344cacdf..c28f53b050 100644 --- a/tests/workflows/test_overlay_commands.py +++ b/tests/workflows/test_overlay_commands.py @@ -220,6 +220,115 @@ def test_overlay_add_rejects_non_positive_priority(self, project_dir, monkeypatc assert result.exit_code == 1 assert "must be >= 1" in result.output + def test_overlay_add_keeps_non_ascii_text_readable( + self, project_dir, monkeypatch + ): + """``overlay add`` must not escape non-ASCII text in the written file. + + Overlay files are documented as hand-authored, so writing them back + with ``\\uXXXX`` escapes makes the user's own file unreadable. + """ + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + message = "Revisar el plan — ¿aprobar? 日本語" + overlay_file = project_dir / "overlay.yml" + overlay_file.write_text( + yaml.safe_dump( + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "replace", + "anchor": "a", + "step": { + "id": "a", + "type": "gate", + "message": message, + "options": ["approve"], + }, + } + ], + }, + allow_unicode=True, + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["workflow", "overlay", "add", str(overlay_file)]) + assert result.exit_code == 0, result.output + + installed = ( + project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + ) + text = installed.read_text(encoding="utf-8") + assert message in text, text + assert "\\u" not in text and "\\x" not in text, text + # The value must still round-trip identically. + data = yaml.safe_load(text) + assert data["edits"][0]["step"]["message"] == message + + def test_overlay_set_priority_keeps_non_ascii_text_readable( + self, project_dir, monkeypatch + ): + """Toggling an overlay must not mangle non-ASCII text already in it.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + message = "Revisar el plan — ¿aprobar? 日本語" + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "replace", + "anchor": "a", + "step": { + "id": "a", + "type": "gate", + "message": message, + "options": ["approve"], + }, + } + ], + }, + ) + + result = runner.invoke( + app, ["workflow", "overlay", "set-priority", "wf", "ov1", "20"] + ) + assert result.exit_code == 0, result.output + + text = ( + project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + ).read_text(encoding="utf-8") + assert message in text, text + assert "\\u" not in text and "\\x" not in text, text + data = yaml.safe_load(text) + assert data["priority"] == 20 + assert data["edits"][0]["step"]["message"] == message + def test_overlay_set_priority(self, project_dir, monkeypatch): monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) _write_workflow( diff --git a/tests/workflows/test_overlay_schema.py b/tests/workflows/test_overlay_schema.py index 77e0432eca..a91efb0a36 100644 --- a/tests/workflows/test_overlay_schema.py +++ b/tests/workflows/test_overlay_schema.py @@ -124,6 +124,57 @@ def test_multiple_operation_fields_rejected(self): assert overlay is None assert any("multiple" in e.lower() for e in errors), errors + @pytest.mark.parametrize( + "first,second", + [("remove", "insert_after"), ("insert_after", "remove")], + ids=["remove-first", "insert_after-first"], + ) + def test_multiple_operation_keys_reported_in_declaration_order( + self, first, second + ): + """The message must name the keys in the order the user wrote them. + + Collecting the keys by iterating the ``_SHORTHAND_OPERATION_KEYS`` + frozenset made the order depend on per-process string-hash + randomization, so the same overlay file produced a different message on + every run. Whatever fixed order a frozenset happens to have in a given + process, one of these two parametrizations contradicts it -- so this + pair fails deterministically without the fix, in every process. + """ + overlay, errors = validate_overlay_yaml( + { + "id": "ov", + "extends": "wf", + "edits": [{first: "a", second: "a"}], + } + ) + assert overlay is None + assert errors == [ + f"Edit at index 0 has multiple operation keys: {first!r}, {second!r}." + ] + + @pytest.mark.parametrize( + "first,second", + [("remove", "insert_after"), ("insert_after", "remove")], + ids=["remove-first", "insert_after-first"], + ) + def test_shorthand_mixed_with_operation_names_first_declared_key( + self, first, second + ): + """``shorthand_keys[0]`` must be the first key the user declared.""" + overlay, errors = validate_overlay_yaml( + { + "id": "ov", + "extends": "wf", + "edits": [{first: "a", second: "a", "operation": "replace"}], + } + ) + assert overlay is None + assert errors == [ + f"Edit at index 0 mixes shorthand operation key ({first!r}) " + f"with explicit 'operation' field." + ] + def test_invalid_operation_field_rejected(self): overlay, errors = validate_overlay_yaml( { diff --git a/workflows/README.md b/workflows/README.md index bd0b767938..d5569541a2 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -145,7 +145,7 @@ and resolves the integration from the step config or the workflow default: here: true # or: project: my-project integration: copilot # Optional: defaults to workflow integration integration_options: "--skills" # Optional: extra options for the integration - script: sh # Optional: sh or ps + script: sh # Optional: sh, ps, or py force: true # Optional: required when target directory already exists preset: healthcare-compliance # Optional preset ID ```