diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index 360370165e..785f9193e3 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, 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, 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 - type: input id: agent-name diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 03a7e97931..03fa6c124f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -70,6 +70,7 @@ body: - Cline - CodeBuddy - Codex CLI + - Command Code - Cursor - Devin for Terminal - Factory Droid diff --git a/.github/ISSUE_TEMPLATE/bundle_submission.yml b/.github/ISSUE_TEMPLATE/bundle_submission.yml index c2b928f3a7..82bd0ef9e3 100644 --- a/.github/ISSUE_TEMPLATE/bundle_submission.yml +++ b/.github/ISSUE_TEMPLATE/bundle_submission.yml @@ -16,6 +16,8 @@ body: - If you host a bundle catalog, test catalog installation with `specify bundle catalog add --id --policy install-allowed` and `specify bundle install ` - If your bundle depends on components from non-default catalogs, document those catalog URLs and test installation from a clean project + **After submitting:** a maintainer applies the `bundle-submission` label during issue triage, which starts the automated catalog validation. You don't need to apply any label or ask for one. + - type: input id: bundle-id attributes: diff --git a/.github/ISSUE_TEMPLATE/extension_submission.yml b/.github/ISSUE_TEMPLATE/extension_submission.yml index 62508dd569..eae85a4340 100644 --- a/.github/ISSUE_TEMPLATE/extension_submission.yml +++ b/.github/ISSUE_TEMPLATE/extension_submission.yml @@ -14,6 +14,8 @@ body: - Create a GitHub release with a version tag (e.g., v1.0.0) - Test installation: `specify extension add --from ` + **After submitting:** a maintainer applies the `extension-submission` label during issue triage, which starts the automated catalog validation. You don't need to apply any label or ask for one. + - type: input id: extension-id attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 08e1075038..4613c8ebae 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -64,6 +64,7 @@ body: - Cline - CodeBuddy - Codex CLI + - Command Code - Cursor - Devin for Terminal - Factory Droid diff --git a/.github/ISSUE_TEMPLATE/preset_submission.yml b/.github/ISSUE_TEMPLATE/preset_submission.yml index 45c1f81739..bb41d5fe18 100644 --- a/.github/ISSUE_TEMPLATE/preset_submission.yml +++ b/.github/ISSUE_TEMPLATE/preset_submission.yml @@ -14,6 +14,8 @@ body: - Create a GitHub release with a version tag (e.g., v1.0.0) - Test installation from the release archive: `specify preset add --from ` + **After submitting:** a maintainer applies the `preset-submission` label during issue triage, which starts the automated catalog validation. You don't need to apply any label or ask for one. + - type: input id: preset-id attributes: diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 5d7a62fd96..36daac9877 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -1,9 +1,9 @@ { "entries": { - "actions/checkout@v6.0.3": { + "actions/checkout@v7.0.1": { "repo": "actions/checkout", - "version": "v6.0.3", - "sha": "df4cb1c069e1874edd31b4311f1884172cec0e10" + "version": "v7.0.1", + "sha": "3d3c42e5aac5ba805825da76410c181273ba90b1" }, "actions/download-artifact@v8.0.1": { "repo": "actions/download-artifact", @@ -15,10 +15,20 @@ "version": "v9.0.0", "sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3" }, - "actions/setup-node@v6.4.0": { + "actions/setup-node@v7.0.0": { "repo": "actions/setup-node", - "version": "v6.4.0", - "sha": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e" + "version": "v7.0.0", + "sha": "820762786026740c76f36085b0efc47a31fe5020" + }, + "actions/setup-python@v7.0.0": { + "repo": "actions/setup-python", + "version": "v7.0.0", + "sha": "5fda3b95a4ea91299a34e894583c3862153e4b97" + }, + "astral-sh/setup-uv@v9.0.0": { + "repo": "astral-sh/setup-uv", + "version": "v9.0.0", + "sha": "c771a70e6277c0a99b617c7a806ffedaca235ff9" }, "actions/upload-artifact@v7.0.1": { "repo": "actions/upload-artifact", diff --git a/.github/workflows/add-community-bundle.lock.yml b/.github/workflows/add-community-bundle.lock.yml index f4841c97e8..5e277febfb 100644 --- a/.github/workflows/add-community-bundle.lock.yml +++ b/.github/workflows/add-community-bundle.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"c64e3dc29aca89e48108bb6d4eb877f6264b4cec9cd56dcd36827893802d2a64","body_hash":"cade22e5083254b735200f4ff7d686104e4ccab848ff7355141b9689354834db","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_CI_TRIGGER_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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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"}]} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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 # # ___ _ _ diff --git a/.github/workflows/add-community-extension.lock.yml b/.github/workflows/add-community-extension.lock.yml index 1d86dbcfe4..dd4ac29f47 100644 --- a/.github/workflows/add-community-extension.lock.yml +++ b/.github/workflows/add-community-extension.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"687ea37b376b3b918331c13fce6cdbf5b9898bab8e514ca57b662b92b6d3cd2c","body_hash":"83b7e917f475d6ddf32f17e7da09dd4097a01dddbcbbf8eeec673912285de8b2","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_CI_TRIGGER_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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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"}]} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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 # # ___ _ _ diff --git a/.github/workflows/add-community-preset.lock.yml b/.github/workflows/add-community-preset.lock.yml index c63f89df27..7583d155d6 100644 --- a/.github/workflows/add-community-preset.lock.yml +++ b/.github/workflows/add-community-preset.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"b4ba1db5fdec754fa825cc3160879924118bc454a781eed70ef6c90beab83a95","body_hash":"cb6c19088fa13da0a8320c174e8c14c4887d2c8a005a5cb2d2d2faa3f890de39","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_CI_TRIGGER_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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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"}]} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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 # # ___ _ _ diff --git a/.github/workflows/bug-assess.lock.yml b/.github/workflows/bug-assess.lock.yml index c6eb131fba..f3bc7f4730 100644 --- a/.github/workflows/bug-assess.lock.yml +++ b/.github/workflows/bug-assess.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"00c226f69fb7ec2b63755304328cee6ecddbcedbe4a9840310e5f430bd3949f0","body_hash":"44428ecd81ba0e5ed7bb16436052e6cc3479fe4ad02414812e574d17830a464e","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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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"}]} +# 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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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 # # ___ _ _ diff --git a/.github/workflows/bug-fix.lock.yml b/.github/workflows/bug-fix.lock.yml index a3544d0a4f..43ed0d0eff 100644 --- a/.github/workflows/bug-fix.lock.yml +++ b/.github/workflows/bug-fix.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"aafdb01f262d603577971994522575829802b93d9042d62446313955485df558","body_hash":"4596de2b7de95c7c73c05caedc5c1e97724b39d09d21e9b0dbfc8b570312798a","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_CI_TRIGGER_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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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"}]} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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 # # ___ _ _ diff --git a/.github/workflows/bug-test.lock.yml b/.github/workflows/bug-test.lock.yml index 884c863d9c..810be3ae77 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":"ed734f6b123dcce3257c147be573cae4eaa6383018b65759a0e8d74049a38d95","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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"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/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.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"}]} +# 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"}]} # 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 # # ___ _ _ @@ -36,7 +36,9 @@ # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - 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 # - github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8 # # Container images used: @@ -123,7 +125,7 @@ jobs: GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" GH_AW_INFO_STAGED: "false" - GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]' + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","pypi.org","files.pythonhosted.org"]' GH_AW_INFO_FIREWALL_ENABLED: "true" GH_AW_INFO_AWF_VERSION: "v0.27.2" GH_AW_INFO_AWMG_VERSION: "" @@ -208,7 +210,7 @@ jobs: id: sanitized uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -435,12 +437,21 @@ jobs: with: persist-credentials: false fetch-depth: 0 + - name: Setup uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - 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 run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" env: GH_TOKEN: ${{ github.token }} + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - name: Install Python test dependencies + run: uv pip install --system -e ".[test]" + - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} @@ -848,7 +859,7 @@ jobs: export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" (umask 177 && touch /tmp/gh-aw/agent-stdio.log) GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}" - printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.2/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"github.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.2,squid=sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591,agent=sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6,api-proxy=sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4,cli-proxy=sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.2/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"files.pythonhosted.org\",\"github.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"ppa.launchpad.net\",\"pypi.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.2,squid=sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591,agent=sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6,api-proxy=sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4,cli-proxy=sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="" @@ -955,7 +966,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} with: @@ -1621,7 +1632,7 @@ jobs: env: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} - GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1},\"add_labels\":{\"allowed\":[\"tests-passing\",\"tests-failing\",\"tests-inconclusive\"],\"max\":1},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}" diff --git a/.github/workflows/bug-test.md b/.github/workflows/bug-test.md index eedda3aa7e..87656d7eec 100644 --- a/.github/workflows/bug-test.md +++ b/.github/workflows/bug-test.md @@ -60,6 +60,22 @@ permissions: checkout: fetch-depth: 0 +network: + allowed: + - defaults + - pypi.org + - files.pythonhosted.org + +steps: + - name: Setup uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - name: Install Python test dependencies + run: uv pip install --system -e ".[test]" + safe-outputs: noop: report-as-issue: false diff --git a/.github/workflows/catalog-assign.yml b/.github/workflows/catalog-assign.yml index 9655394b06..60f7b81798 100644 --- a/.github/workflows/catalog-assign.yml +++ b/.github/workflows/catalog-assign.yml @@ -1,11 +1,11 @@ -name: "Catalog: Auto-assign submission" +name: "Catalog: Notify submission" on: issues: types: [opened, labeled] jobs: - assign: + notify: if: > (github.event.action == 'opened' && ( contains(github.event.issue.labels.*.name, 'extension-submission') || @@ -24,24 +24,8 @@ jobs: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | - const issue = context.payload.issue; - const assigned = (issue.assignees || []).map(a => a.login); const marker = ''; - // Assign mnriem if not already assigned - if (!assigned.includes('mnriem')) { - try { - await github.rest.issues.addAssignees({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - assignees: ['mnriem'], - }); - } catch (e) { - console.log(`Warning: could not assign mnriem: ${e.message}`); - } - } - // Post team notification if not already posted const comments = await github.paginate( github.rest.issues.listComments, diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a854a09ab3..dd6c2b0dc3 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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 637a4582b9..de4eb9a30b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -37,7 +37,7 @@ jobs: fi - name: Run markdownlint-cli2 - uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0 + uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0 with: globs: | '**/*.md' diff --git a/CHANGELOG.md b/CHANGELOG.md index 17928d9c0c..205889b395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to the Specify CLI and templates are documented here. +# [0.16.2+adlc1] - 2026-08-12 + +### Changed + +- **Upstream merge**: Synced with github/spec-kit 0.16.2 (53 commits, base 0.16.0+adlc3 → 0.16.2+adlc1). Includes post-0.16.2 fix: `fix: Alquimia argument hints after folded descriptions` (#4063). + - New integration: `command-code` (Command Code CLI, skills-based, `$speckit-*` invocation) + - New feature: `feat(presets): resolve constitution templates at command time` (#3984) — constitution template resolved via `resolve-template` script at command time instead of install-time materialization + - New feature: `feat(extensions): accept provides.templates and provides.scripts in manifest` (#4012) — extension manifests can declare templates and scripts + - New feature: `feat(presets): resolve constitution templates at command time` (#3984) + - Checklist ownership semantics: reviewer-owned markers (`[x]` = reviewed, not implemented), read-only gate in implement command + - Template content resolution: `TASKS_TEMPLATE_CONTENT` and `TEMPLATE_CONTENT` replace path-based template loading + - `scripts:` frontmatter added to `constitution` command for `resolve-template.sh/ps1/py` + - `--template checklist-template` flag added to `check-prerequisites` scripts + - Upstream fixes: bounded response read in integration catalog fetch (#3818), non-UTF-8 catalog response wrapping (#4011), Rich markup escaping in bundle CLI (#4023), workflow condition/overlay hardening (#3881, #3883), duplicate provides.templates/scripts rejection (#4016), preset remove unreadable restore source (#4020), extension cache clear missing_ok (#3845), integration JSON removal missing_ok (#3846), preset catalog config read failure details (#3840), bug-test Python dependency provisioning (#4030) + - Community catalog additions: Keel Discovery, SpecKit Grill Me, Model Routing Governance; updates: Reconcile v1.1.0, Architecture Governance v0.5.2, Security Governance v0.6.2, Archive v1.2.2 +- **9 conflicts resolved**: `pyproject.toml` (version → 0.16.2+adlc1), `scripts/bash/common.sh` (kept fork `extract_constitution_rules`/`load_team_directives_config` + adopted upstream `_python3_command`/`_sorted_extension_ids`/`resolve_template_content`), `scripts/bash/create-new-feature.sh` (adopted upstream content-based template write + kept fork `replace_date_placeholders` + restored missing `SPEC_FILE` definition), `scripts/powershell/create-new-feature.ps1` (parallel merge), `scripts/python/create_new_feature.py` (union imports), `src/specify_cli/commands/bundle/__init__.py` (kept fork `accent()` theming + adopted upstream `_escape_markup` for user-supplied values), `presets/catalog.json` (updated_at), `extensions/EXTENSION-API-REFERENCE.md` (union: fork `runtime_hooks` + upstream `templates`/`scripts`), `README.md` (fork `/spec.*` prefix + upstream Command Code mention) +- **Test adaptations**: `test_integration_catalog.py` (took upstream version — fork's `adbb0146` `open_url` mock workaround obsolete), `test_integration_command_code.py` (fork `PKG_NAMES` prefix adaptation for `$spec-constitution` vs `$speckit-constitution`) +- **Template-to-preset alignment**: ported upstream changes to `adlc.spec.checklist.md` (`--template` flag, ownership section, TEMPLATE_CONTENT), `adlc.spec.constitution.md` (`scripts:` frontmatter for resolve-template), `adlc.spec.implement.md` (checklist marker semantics: completed→checked, read-only gate), `adlc.spec.tasks.md` (TASKS_TEMPLATE_CONTENT), `checklist-template.md` (Review Ownership + Marker Semantics) + # [0.16.0+adlc3] - 2026-08-11 ### Changed @@ -4326,6 +4345,57 @@ This release migrates fork-specific customizations to a preset system to reduce The following entries are from the upstream spec-kit project and are included for reference. +## [0.16.2] - 2026-08-10 + +### Changed + +- Add Command Code integration to spec-kit (#4019) +- fix(workflows): strip a resolved condition before the true/false check (#3883) +- fix(workflows): guard a non-string overlay edit 'operation' (#3881) +- fix: bound response read in integration catalog fetch (#3818) +- Fix bug-test Python dependency provisioning (#4030) +- fix(bundle): escape Rich markup in bundle CLI error and status output (#4023) +- fix(presets): skip an unreadable restore source in `preset remove` (#4020) +- Add Keel Discovery extension to community catalog (#4035) +- fix: show error details in preset catalog config read failure (#3840) +- Update Reconcile Extension to v1.1.0 (#4034) +- Add Model Routing Governance preset to community catalog (#4033) +- fix: use missing_ok=True in integration JSON removal (#3846) +- fix: use missing_ok=True in extension cache clear (#3845) +- fix(extensions): reject duplicate provides.templates/scripts names (#4016) +- feat(presets): resolve constitution templates at command time (#3984) +- [bug-fix] Fix preset-wrap-drops-argument-hint: inherit argument-hint from core template (#3996) +- docs: document installing specify-cli from a custom package index (#4032) +- feat(extensions): accept provides.templates and provides.scripts in manifest (#4012) +- fix(presets): treat an unreadable core template as missing (#3961) +- chore: release 0.16.1, begin 0.16.2.dev0 development (#4014) + +## [0.16.1] - 2026-08-07 + +### Changed + +- fix(integrations): wrap a non-UTF-8 catalog response (#4011) +- fix(events): skip an unreadable command template (#3956) +- fix(bundle): wrap malformed YAML in a local .zip bundle manifest (#4013) +- chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.5 (#4005) +- chore(deps): bump DavidAnson/markdownlint-cli2-action (#4006) +- fix(agent-context): recurse for nested plans in Python mtime fallback (#3757) +- fix: add utf-8 encoding to extension and preset registry file I/O (#3834) +- fix(init): escape user-supplied values in `specify init` output (#3787) +- fix: bound response read in integration catalog fetch (#3812) +- fix: use missing_ok for temp file cleanup to avoid masking errors (#3803) +- fix(workflows): handle an unreadable run state in `workflow status` (#3999) +- feat(init): scaffold managed .specify/.gitignore (#4000) +- fix(scripts): stop check-prerequisites text mode crashing on a legacy stdout code page (#3890) +- fix(presets): return None for an unreadable layer in resolve_content (#3959) +- fix(extensions): start fresh on a non-UTF-8 extension registry (#3998) +- Fix init-force-preset-desync: reapply presets/extensions on init --here --force (#3995) +- fix(skills): apply the line-anchored delimiter scan to hermes and kimi (#3739) +- fix(archives): wrap the bare EOFError a truncated tar.gz raises (#3938) +- test(integrations): guard multiline/control-char SKILL.md frontmatter escaping (#3392) +- fix(scripts): stop setup-tasks text mode crashing on a legacy code page (#3892) +- chore: release 0.16.0, begin 0.16.1.dev0 development (#3992) + ## [0.16.0] - 2026-08-05 ### Changed diff --git a/FORK.md b/FORK.md index b9d2c134d9..2aeafd169c 100644 --- a/FORK.md +++ b/FORK.md @@ -72,6 +72,7 @@ When a fork release changes only bundled extension behavior, keep the CLI versio | Version | Date | Base Upstream | Changes | |---------|------|---------------|---------| +| 0.16.2+adlc1 | 2026-08-12 | 0.16.2 (`bd595cf8`) | Upstream merge (50 commits, 0.16.0+adlc3 → 0.16.2). New upstream features: `command-code` integration (Command Code CLI, skills-based, `$speckit-*` invocation, registered alphabetically in `_register_builtins()`); `feat(presets): resolve constitution templates at command time` (#3984 — constitution template resolved via `resolve-template` script at command time); `feat(extensions): accept provides.templates and provides.scripts in manifest` (#4012); checklist ownership semantics (reviewer-owned markers, read-only gate in implement, `--template checklist-template` flag); template content resolution (`TASKS_TEMPLATE_CONTENT`/`TEMPLATE_CONTENT` replace path-based loading); `scripts:` frontmatter added to `constitution` command. Upstream fixes: bounded response read in integration catalog fetch (#3818), non-UTF-8 catalog response wrapping (#4011), Rich markup escaping in bundle CLI (#4023), workflow condition/overlay hardening (#3881, #3883), duplicate provides.templates/scripts rejection (#4016), preset remove unreadable restore source (#4020), extension cache clear missing_ok (#3845), integration JSON removal missing_ok (#3846), preset catalog config read failure details (#3840), bug-test Python dependency provisioning (#4030). Community catalog: Keel Discovery, SpecKit Grill Me, Model Routing Governance; updates: Reconcile v1.1.0, Architecture Governance v0.5.2, Security Governance v0.6.2, Archive v1.2.2. **9 conflicts resolved**: `pyproject.toml` (version → `0.16.2+adlc1`), `scripts/bash/common.sh` (kept fork `extract_constitution_rules`/`load_team_directives_config` + adopted upstream `_python3_command`/`_sorted_extension_ids`/`resolve_template_content`), `scripts/bash/create-new-feature.sh` (adopted upstream content-based template write + kept fork `replace_date_placeholders` + restored missing `SPEC_FILE` definition), `scripts/powershell/create-new-feature.ps1` (parallel merge + restored `$specFile`), `scripts/python/create_new_feature.py` (union imports), `src/specify_cli/commands/bundle/__init__.py` (kept fork `accent()` theming + adopted upstream `_escape_markup` for user-supplied values across 9 console.print sites), `presets/catalog.json` (updated_at), `extensions/EXTENSION-API-REFERENCE.md` (union: fork `runtime_hooks` + upstream `templates`/`scripts`), `README.md` (fork `/spec.*` prefix + upstream Command Code mention). **Test adaptations**: `test_integration_catalog.py` (took upstream version — fork's `adbb0146` `open_url` mock workaround obsolete with upstream's `route_opener_open_through_urlopen` fixture), `test_integration_command_code.py` (fork `PKG_NAMES` prefix adaptation for `$spec-constitution` vs `$speckit-constitution`). **Template-to-preset alignment**: ported upstream changes to `adlc.spec.checklist.md` (`--template` flag, ownership section, TEMPLATE_CONTENT), `adlc.spec.constitution.md` (`scripts:` frontmatter for resolve-template), `adlc.spec.implement.md` (checklist marker semantics: completed→checked, read-only gate), `adlc.spec.tasks.md` (TASKS_TEMPLATE_CONTENT), `checklist-template.md` (Review Ownership + Marker Semantics). Ruff clean. 3760+ tests pass. | | 0.16.0+adlc3 | 2026-08-11 | 0.16.0 (`adb2413a`) | `agentic-change` preset no longer preinstalled. `presets/catalog.json` flips `agentic-change.preinstall` from `true` to `false`; the preset's 4 commands (`/change.specify`, `/change.implement`, `/change.converge`, `/change.levelup`) are no longer auto-scaffolded by `specify init`. The preset remains `bundled: true` (shipped in the wheel) and installable on demand via `specify preset add agentic-change`. `agentic-sdlc` and `agentic-quick` presets are unaffected. No Python source changed — the install scaffold (`_init_fork.py:339-369`) already honors the catalog `preinstall` flag, so the flip is purely data-driven. Doc consistency: `presets/agentic-change/README.md` Installation section rewritten to declare opt-in status; `README.md` bundled-presets blurb (line 33) and table (line 284-294) split bundled-vs-preinstalled and mark `agentic-change` as ❌ opt-in. | | 0.16.0+adlc1 | 2026-08-06 | 0.16.0 (`adb2413a`) | Upstream merge (18 commits, post-0.15.2 → 0.16.0 release `6fa8c9aa`). New upstream feature: `feat(init): scaffold managed .specify/.gitignore` (#4000 — manifest-tracked `.specify/.gitignore` excludes `feature.json` + `extensions/*/local-config.yml`; routed through shared-infra overwrite/skip/preserve policy; 8 integration test inventories + new `test_shared_infra_gitignore.py`). New upstream fixes: escape user-supplied values in `specify init` output (#3787 — `_escape_markup` + `_shell_quote_arg` for the `cd` line, the only CLI module without escaping), reapply presets/extensions on `init --here --force` (#3995 — `_register_presets_for_agent`/`_register_extensions_for_agent` after `manifest.save()`), bound response read in integration catalog fetch (#3812 — `read_response_limited` + `MAX_JSON_METADATA_BYTES`), `missing_ok` temp-file cleanup (#3803), unreadable run state in `workflow status` (#3999), skip corrupted run state in `list_runs` (#3814/#3817), non-UTF-8 extension registry (#3998) + unreadable layer in `resolve_content` (#3959) + EOFError wrap for truncated tar.gz (#3938), line-anchored `---` delimiter scan for hermes+kimi (#3739), keep long frontmatter on single line (#3989 — `yaml.dump(width=float("inf"))`), legacy code-page fix for `check_prerequisites`/`setup_tasks` Python scripts (#3890/#3892). **4 conflicts resolved**: `pyproject.toml` (kept fork name/description, version → `0.16.0+adlc1`), `commands/init.py` (wrapped user-controlled display values with `_escape_markup` *inside* fork's `accent()` theming — project name, paths, echoed `--integration`/`--script`, agent folder in gitignore hint; adopted `_shell_quote_arg` for the Next Steps `cd` line; `f31b2b45` reapply-on-`--force` block auto-merged in un-themed region), `tests/integrations/test_integration_catalog.py` (adopted upstream's restructured file + bounded `FakeResponse`; **fixed mock target**: upstream's `_patch_urlopen` patched `urllib.request.urlopen` but `open_url` uses `opener.open()` which never calls module-level `urlopen` → tests hit real network; switched mock to `open_url` directly, preserving upstream's bounded-read `FakeResponse` for `read_response_limited` contract; applies to both `_patch_urlopen` and the oversized-response regression test), `tests/integrations/test_integration_base_toml.py` (added `.specify/.gitignore` to fork's `stem_pfx` inventory). All other conflict-candidate files auto-merged cleanly, preserving fork customizations: `shared_infra.py` (`.gitignore` block + `missing_ok` + `COMMAND_PREFIX`/`project_path`/theming), `extensions/__init__.py` (non-UTF-8 registry + catalog-URL override + alias logic), `hermes/__init__.py` (line-anchored delimiter + `resolve_command_alias`/`COMMAND_PREFIX`), `agents.py` (`width=float("inf")` + `_skip_primary`/`inject_model_invocation_flag`), `presets/__init__.py` (`resolve_content` guard + `_cleanup_replaced_commands`), `workflows/_commands.py` (unreadable-run-state guard + theming), `update_agent_context.py` (symlink-safe recursive plan discovery + `missing_ok` + fork team-directives block). Fork modules (`_*_fork.py`, `extensions_fork.py`) untouched. No `templates/` changes upstream → no preset porting. Ruff clean (`ruff@0.15.0`). 2629 tests pass across merge-affected files (110 catalog, 122 init/cli, 138 generic/cline/copilot, 2259 infra/workflow/preset/extension/parity). Live smoke: `specify init` scaffolds `.specify/.gitignore` (manifest-tracked, excludes `feature.json` + `extensions/*/local-config.yml`); `specify extension update` (after clearing `.specify/extensions/.cache/`) finds all fork-bundled extensions up-to-date via fork-repo catalog URL. | | 0.15.2+adlc4 | 2026-08-05 | 0.15.2 (`68daed8f`) | Extension catalog URL override. `ExtensionCatalog.DEFAULT_CATALOG_URL` and `COMMUNITY_CATALOG_URL` now point at the fork's repo (`tikalk/agentic-sdlc-spec-kit`) instead of upstream (`github/spec-kit`). Previously `specify extension update` fetched upstream's catalog → fork-bundled extensions (`levelup`, `team-ai-directives`, `evals`, `edd`, `architect`) showed "Not found in catalog" and `tdd`/`product` showed "Updates not allowed from 'community'" (found in upstream's community catalog instead of the fork's bundled catalog). Fork constants `FORK_DEFAULT_CATALOG_URL`/`FORK_COMMUNITY_CATALOG_URL` added to `_core_fork.py`; override applied in `extensions/__init__.py` ExtensionCatalog class body (try/except with fallback to upstream URLs). 106 catalog tests pass. Live smoke: `specify extension update` now finds all 8 fork-bundled extensions as up-to-date. Note: stale catalog cache (from old upstream URL) may persist for up to 1 hour after upgrade; clear `.specify/extensions/.cache/` to force immediate refresh. | diff --git a/README.md b/README.md index c9518d7c0d..b4149f3f0b 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ Bare `specify self upgrade` executes immediately, matching the no-prompt behavio ### 3. Establish project principles -Launch your coding agent in the project directory. Most agents expose spec-kit as `/spec.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt. +Launch your coding agent in the project directory. Most agents expose spec-kit as `/spec.*` slash commands; Codex CLI and Command Code in skills mode use `$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt. Use the **`/spec.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development. @@ -542,9 +542,11 @@ specify bundle validate --path ./my-bundle # structural + reference checks specify bundle build --path ./my-bundle # produce a versioned .zip artifact ``` -Four ready-to-read example manifests live under +Four ready-to-read example bundle manifests live under [`examples/bundles/`](examples/bundles/) (product manager, business analyst, -security researcher, developer). +security researcher, developer). These are bundle packaging examples, not +filled generated feature specs; for end-to-end community examples, see the +[community walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html). Key guarantees: `info` shows exactly what `install` adds (transparency); installs are idempotent and confined to the project root; `remove` never touches diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 44f7e16717..e77c40bcfa 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -34,7 +34,7 @@ The following community-contributed extensions are available in [`catalog.commun | 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 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 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. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | +| 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) | | 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) | | 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) | @@ -72,6 +72,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) | +| Keel Discovery | A Spec Kit extension that puts customer evidence upstream of /speckit.specify, and audits what you shipped against it afterwards | `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) | | Linear Weave | Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses | `integration` | Read+Write | [spec-kit-linear-weave](https://github.com/tonydwoodhouse/spec-kit-linear-weave) | @@ -145,6 +146,7 @@ The following community-contributed extensions are available in [`catalog.commun | 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) | | 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) | | 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) | diff --git a/docs/community/presets.md b/docs/community/presets.md index 2d6cdb30a2..aba5907c66 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -10,7 +10,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | A11Y Governance | Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) | | Agent Parity Governance | Adds shared-guidance and generated-command parity, repository-fleet completion evidence, secret-free runner/status metadata, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across declared AI-agent surfaces. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) | | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | -| Architecture Governance | Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) | +| 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) | | 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) | @@ -22,14 +22,15 @@ The following community-contributed presets customize how Spec Kit behaves — o | Intake Authoring Governance | Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring. | 12 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. | 8 templates, 3 commands, 4 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) | -| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) | +| 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) | | 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 and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) | +| 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) | | SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) | | Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | diff --git a/docs/community/walkthroughs.md b/docs/community/walkthroughs.md index b32c025803..c2dc1c5304 100644 --- a/docs/community/walkthroughs.md +++ b/docs/community/walkthroughs.md @@ -3,7 +3,7 @@ > [!NOTE] > Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion. -See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs: +See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs. They are useful read-only examples of completed flows, but they are not official golden outputs for generated specs, plans, or tasks. - **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents. diff --git a/docs/install/pypi.md b/docs/install/pypi.md index 1b89d78e44..6f36df0a54 100644 --- a/docs/install/pypi.md +++ b/docs/install/pypi.md @@ -35,6 +35,27 @@ pipx install specify-cli==0.12.11 pip install specify-cli==0.12.11 ``` +## Install from a custom or private package index + +Some environments (corporate networks, mirrors, proxies, or artifact feeds) require installing `specify-cli` from a package index other than the default public PyPI. Each Python tool exposes a way to point at a different index — configure it before running the install commands above. Substitute your own index URL for the placeholder shown here. + +```bash +# uv — via environment variable (applies to the whole command) +UV_DEFAULT_INDEX=https://your-index.example.com/pypi/simple/ uv tool install specify-cli + +# uv — via flag +uv tool install --default-index https://your-index.example.com/pypi/simple/ specify-cli + +# pipx — pass a pip argument through +pipx install specify-cli --index-url https://your-index.example.com/pypi/simple/ + +# pip +pip install specify-cli --index-url https://your-index.example.com/pypi/simple/ +``` + +> [!NOTE] +> The same index configuration applies to pinned installs, upgrades (`--force`/`--upgrade`), and one-time usage — set the environment variable or flag on those commands too. If your index requires authentication, follow your tool's documentation and prefer credential environment variables, keyring, or netrc; do not embed secrets in command-line URLs because they can leak through shell history, process listings, or logs. Avoid committing secrets. For fully offline installs, see the [air-gapped installation guide](air-gapped.md). + ## Verify ```bash diff --git a/docs/installation.md b/docs/installation.md index 4fa2795647..67b69505e6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -50,7 +50,7 @@ pipx install specify-cli pip install specify-cli ``` -To install a specific release, pin the version — for example `uv tool install specify-cli==0.12.11`. See the [PyPI installation guide](install/pypi.md) for details, including how to upgrade. +To install a specific release, pin the version — for example `uv tool install specify-cli==0.12.11`. See the [PyPI installation guide](install/pypi.md) for details, including how to upgrade and how to [install from a custom or private package index](install/pypi.md#install-from-a-custom-or-private-package-index). ### One-time Usage diff --git a/docs/quickstart.md b/docs/quickstart.md index ddf6337356..4d4eaf89e0 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -5,8 +5,7 @@ This guide will help you get started with Spec-Driven Development using Spec Kit > [!NOTE] > Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly. -> [!NOTE] -> Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical. +Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical. ## Recommended Process @@ -83,7 +82,7 @@ Generates the design artifacts from the spec. This is where implementation detai ### Step 5: `/speckit.checklist` — validate the spec -Generates a quality checklist — "unit tests for your requirements" — to confirm the spec is complete, clear, and consistent before you break the work down. +Generates a custom quality checklist — "unit tests for your requirements" — to confirm the spec is complete, clear, and consistent before you break the work down. These custom checklists are reviewer-owned requirements-quality review artifacts: mark an item `[x]` only when the reviewer determines that requirement-quality criterion is satisfied. Checked custom items do not mean implementation work is complete. ```text /speckit.checklist @@ -107,7 +106,7 @@ Reports conflicts, gaps, and ambiguities across `spec.md`, `plan.md`, and `tasks ### Step 8: `/speckit.implement` — build it -Executes the tasks in `tasks.md` in dependency order. Run it once to build everything, or scope it to one phase at a time for large features. +Executes the tasks in `tasks.md` in dependency order. Before implementation, it reads checklist checkbox state as a gate and asks before proceeding if any checklist items are unchecked; it does not change any checklist files or markers. The built-in `checklists/requirements.md` checklist is maintained by `/speckit.specify` and `/speckit.clarify`, while custom checklists remain reviewer-owned. Run it once to build everything, or scope it to one phase at a time for large features. ```text /speckit.implement @@ -136,5 +135,6 @@ Checks the codebase against the spec, plan, and tasks. If it finds gaps, it appe - See the [Agentic SDD](reference/agentic-sdd.md) reference for full detail on every command - Read the [complete methodology](https://github.com/github/spec-kit/blob/main/spec-driven.md) for in-depth guidance -- Check out [more examples](https://github.com/github/spec-kit/tree/main/templates) in the repository +- Compare the [core templates](https://github.com/github/spec-kit/tree/main/templates) with + [community walkthroughs](community/walkthroughs.md) to see how Spec-Driven Development is used in real projects - Explore the [source code on GitHub](https://github.com/github/spec-kit) diff --git a/docs/reference/agentic-sdd.md b/docs/reference/agentic-sdd.md index 053268d66b..dc38e76a5a 100644 --- a/docs/reference/agentic-sdd.md +++ b/docs/reference/agentic-sdd.md @@ -23,6 +23,8 @@ Creates or updates the project **constitution** — the guiding principles that Creates or updates the feature **specification** from a natural-language description. Focus on the **what** and **why** — the user-facing behavior and goals — not the tech stack, which belongs in `/speckit.plan`. +This workflow may also maintain `checklists/requirements.md`, the built-in spec-quality checklist that `/speckit.specify` creates and `/speckit.clarify` re-evaluates. That lifecycle is separate from custom checklists generated by `/speckit.checklist`. + ```text /speckit.specify Build an application that helps me organize photos into albums grouped by date, re-orderable by drag-and-drop on the main page, with a tile preview inside each album. ``` @@ -37,6 +39,8 @@ Asks up to five targeted questions about underspecified areas of the current spe Clarifying before planning keeps you from designing on top of ambiguity. If `/speckit.analyze` later surfaces requirement gaps, come back and run `/speckit.clarify` (or `/speckit.specify`) again. +When `checklists/requirements.md` exists, `/speckit.clarify` may update its evaluated state as part of tightening the spec. This exception applies only to the built-in requirements checklist, not to custom review checklists. + ## `/speckit.plan` Runs the planning process to generate design artifacts from the spec. This is where implementation detail belongs — provide your tech stack, architecture, and technical constraints as arguments. @@ -49,6 +53,8 @@ Runs the planning process to generate design artifacts from the spec. This is wh Generates a quality checklist for the feature — think of it as **"unit tests for your requirements."** Rather than testing code, it checks whether the spec itself is complete, clear, unambiguous, and consistent (for example: "Are the drag-and-drop rules defined for every column?", "Is behavior specified for a deleted assigned user?"). +Custom checklists generated by this command are reviewer-owned requirements-quality review artifacts. An agent may help evaluate them when explicitly asked, but implementation must not silently self-approve them. In a custom checklist, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does not mean implementation work is complete. + Run it with no arguments for a broad pass, or pass a focus area to target one aspect: ```text @@ -59,7 +65,7 @@ Run it with no arguments for a broad pass, or pass a focus area to target one as /speckit.checklist Focus on the Kanban board interactions and comment permissions. ``` -Review the generated checklist. If it surfaces gaps, loop back to `/speckit.clarify` or `/speckit.specify` to tighten the spec before breaking the work down. +Review the generated checklist. If it surfaces gaps, loop back to `/speckit.clarify` or `/speckit.specify` to tighten the spec before breaking the work down, then mark each custom checklist item `[x]` only after the requirements-quality criterion has been reviewed and satisfied. ## `/speckit.tasks` @@ -83,6 +89,8 @@ Run it before implementing, while the artifacts can still be adjusted cheaply. I Executes the tasks in `tasks.md`, running each phase in dependency order and respecting parallel markers. +Before executing tasks, it reads checklist checkbox state as a gate. Checklist markers are read-only for this command: `/speckit.implement` counts checked and unchecked items and asks before proceeding when any are unchecked, but it must not change checklist markers. For custom checklists, checked items mean reviewer approval of requirements quality, not completed implementation work. + For a small feature, run it once to build everything: ```text diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 808d0cf752..57bb46b10c 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -14,6 +14,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent | | [CodeBuddy CLI](https://www.codebuddy.cn/docs/cli/installation) | `codebuddy` | | | [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` | | | [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | | [Factory Droid](https://docs.factory.ai/cli/getting-started/overview) | `droid` | Skills-based integration; installs skills into `.factory/skills/` and invokes them as `/speckit-` | @@ -279,6 +280,7 @@ The currently declared multi-install safe integrations are: | `cline` | `.clinerules/workflows` | | `codebuddy` | `.codebuddy/commands` | | `codex` | `.agents/skills` | +| `command-code` | `.commandcode/skills` | | `cursor-agent` | `.cursor/skills` | | `droid` | `.factory/skills` | | `firebender` | `.firebender/commands` | diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index e17d976643..ab50d3e200 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -40,12 +40,25 @@ requires: required: boolean # Optional, default: false provides: - commands: # Required, at least one command + commands: # At least one of commands/templates/scripts/hooks/events required - name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$ file: string # Required, relative path to command file description: string # Required aliases: [string] # Optional, same pattern as name; namespace must match extension.id and must not shadow core or installed extension commands + templates: # Optional, array of declared templates. Always resolve + # as "replace" -- 'strategy' is not an authorable field here. + - name: string # Required, pattern: ^[a-z0-9-]+$ + file: string # Required, relative path to template file + description: string # Optional + + scripts: # Optional, array of declared scripts. Always resolve + # as "replace" -- 'strategy' is not an authorable field here. + - name: string # Required, pattern: ^[a-z0-9-]+$ + file: string # Required, relative path to script file + description: string # Optional + runtimes: [string] # Optional, subset of: bash, powershell, python + config: # Optional, array of config files - name: string # Config file name template: string # Template file path @@ -111,6 +124,29 @@ defaults: # Optional, default configuration values - **Examples**: `speckit.jira.specstoissues`, `speckit.linear.sync` - **Invalid**: `jira.specstoissues`, `speckit.command`, `speckit.jira.CreateIssues` +#### `provides.templates[].name` / `provides.scripts[].name` + +- **Type**: string +- **Pattern**: `^[a-z0-9-]+$` +- **Description**: Unlike commands, templates and scripts are not invoked by + name, so they use the same plain slug pattern as `extension.id` rather than + the namespaced command pattern. +- **Examples**: `myext-template`, `myext-collect` + +#### `provides.templates[].strategy` / `provides.scripts[].strategy` + +- Not an authorable field. Extension-contributed templates and scripts are + always resolved as `replace`; a manifest that includes a `strategy` key on + one of these entries is rejected with a `ValidationError`. Composable + strategies (`wrap`/`prepend`/`append`) are preset-only. + +#### `provides.scripts[].runtimes` + +- **Type**: array of strings +- **Values**: `bash`, `powershell`, `python` +- **Description**: Declares which runtimes the script supports. Purely + informational metadata — it is not used to select or invoke the script. + #### `hooks` - **Type**: object @@ -171,6 +207,8 @@ manifest.version # str: Version manifest.description # str: Description manifest.requires_speckit_version # str: Required spec-kit version manifest.commands # List[Dict]: Command definitions +manifest.templates # List[Dict]: Declared template definitions +manifest.scripts # List[Dict]: Declared script definitions manifest.hooks # Dict: Hook definitions (workflow lifecycle) manifest.runtime_hooks # Dict: Runtime hook definitions (agent lifecycle) ``` diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index 18ba017959..2b7599b828 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -177,9 +177,16 @@ Compatibility requirements. What the extension provides. -**Optional sub-fields**: +**Optional sub-fields:** + +- `commands`: Array of command objects +- `templates`: Array of template objects +- `scripts`: Array of script objects -- `commands`: Array of command objects (at least one command or hook is required) +`hooks` and `events` are separate top-level manifest fields (siblings of +`provides`, not nested under it — see [`hooks`](#hooks) below). At least one +of `provides.commands`, `provides.templates`, `provides.scripts`, `hooks`, or +`events` is required. **Command object**: @@ -188,6 +195,21 @@ What the extension provides. - `description`: Command description (optional) - `aliases`: Alternative command names (optional, array; each must match `speckit.{ext-id}.{command}`) +**Template object**: + +- `name`: Template name (lowercase, alphanumeric, hyphens — e.g. `myext-template`) +- `file`: Path to template file (relative to extension root) +- `description`: Template description (optional) + +**Script object**: + +- `name`: Script name (lowercase, alphanumeric, hyphens — e.g. `myext-collect`) +- `file`: Path to script file (relative to extension root) +- `description`: Script description (optional) +- `runtimes`: Runtimes the script supports (optional, array; subset of `bash`, `powershell`, `python` — informational only, not used to select or invoke the script) + +Extension-provided templates and scripts always resolve as `replace`; a manifest that includes a `strategy` key on one of these entries is rejected with a `ValidationError`. Composable strategies (`wrap`/`prepend`/`append`) are preset-only. + ### Optional Fields #### `hooks` diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md index 13fd08b79c..f0eff5417b 100644 --- a/extensions/EXTENSION-PUBLISHING-GUIDE.md +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -151,7 +151,7 @@ To submit your extension to the community catalog, file a new issue using the ** ### What Happens After You Submit -1. Your issue is automatically labeled and assigned to a maintainer for review +1. A maintainer reviews the issue during issue triage and applies the `extension-submission` label, which starts the automated catalog validation. On this public repository, contributors cannot apply that label themselves, so there is nothing to label or re-request — the issue simply waits in triage. 2. A maintainer verifies that the catalog entry is complete and correctly formatted 3. Once approved, the maintainer adds your extension to `extensions/catalog.community.json` and the Community Extensions table in the README 4. Your extension becomes discoverable via `specify extension search` diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 9657122cc7..b35f1c849d 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-04T00:00:00Z", + "updated_at": "2026-08-11T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -362,8 +362,8 @@ "id": "archive", "description": "Archive merged features into main project memory, resolving gaps and conflicts.", "author": "Stanislav Deviatov", - "version": "1.1.0", - "download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.1.0.zip", + "version": "1.2.2", + "download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.2.2.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", @@ -388,7 +388,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-14T00:00:00Z", - "updated_at": "2026-08-04T00:00:00Z" + "updated_at": "2026-08-11T00:00:00Z" }, "azure-devops": { "name": "Azure DevOps Integration", @@ -1815,6 +1815,41 @@ "created_at": "2026-06-24T00:00:00Z", "updated_at": "2026-07-07T00:00:00Z" }, + "grill": { + "name": "SpecKit Grill Me", + "id": "grill", + "description": "Exhaustively resolve specification ambiguities and decisions before planning.", + "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", + "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", + "changelog": "https://github.com/yoshi1220/speckit-grill-me/blob/main/spec-kit-extension/CHANGELOG.md", + "license": "MIT", + "category": "process", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.16.2", + "tools": [{ "name": "bash", "required": true }] + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "clarification", + "requirements", + "specification", + "elicitation", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-11T00:00:00Z", + "updated_at": "2026-08-11T00:00:00Z" + }, "harness": { "name": "Research Harness", "id": "harness", @@ -2107,6 +2142,40 @@ "created_at": "2026-06-08T00:00:00Z", "updated_at": "2026-06-24T00:00:00Z" }, + "keel": { + "name": "Keel Discovery", + "id": "keel", + "description": "A Spec Kit extension that puts customer evidence upstream of /speckit.specify, and audits what you shipped against it afterwards.", + "author": "Keel Discovery", + "version": "0.1.1", + "download_url": "https://github.com/keeldiscovery/spec-kit-keel/archive/refs/tags/v0.1.1.zip", + "repository": "https://github.com/keeldiscovery/spec-kit-keel", + "homepage": "https://keeldiscovery.com", + "documentation": "https://github.com/keeldiscovery/spec-kit-keel/blob/main/README.md", + "changelog": "https://github.com/keeldiscovery/spec-kit-keel/blob/main/CHANGELOG.md", + "license": "Apache-2.0", + "category": "process", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.15.0" + }, + "provides": { + "commands": 5, + "hooks": 2 + }, + "tags": [ + "discovery", + "evidence", + "customer-research", + "validation", + "traceability" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-08-10T00:00:00Z", + "updated_at": "2026-08-10T00:00:00Z" + }, "learn": { "name": "Learning Extension", "id": "learn", @@ -3406,8 +3475,8 @@ "id": "reconcile", "description": "Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.", "author": "Stanislav Deviatov", - "version": "1.0.0", - "download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.0.0.zip", + "version": "1.1.0", + "download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.1.0.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", @@ -3432,7 +3501,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-14T00:00:00Z", - "updated_at": "2026-03-14T00:00:00Z" + "updated_at": "2026-08-10T00:00:00Z" }, "red-team": { "name": "Red Team", diff --git a/integrations/catalog.json b/integrations/catalog.json index abaabb8ece..f3f7a7fe7f 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -84,6 +84,15 @@ "repository": "https://github.com/github/spec-kit", "tags": ["cli", "skills"] }, + "command-code": { + "id": "command-code", + "name": "Command Code", + "version": "1.0.0", + "description": "Command Code CLI skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills"] + }, "devin": { "id": "devin", "name": "Devin for Terminal", diff --git a/presets/ARCHITECTURE.md b/presets/ARCHITECTURE.md index c533976b8a..2ef78add27 100644 --- a/presets/ARCHITECTURE.md +++ b/presets/ARCHITECTURE.md @@ -59,6 +59,19 @@ Content resolution functions for composition: - **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver) - **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver) +### Constitution lifecycle + +Initialization resolves `constitution-template` through the full stack and seeds +`.specify/memory/constitution.md` once. Existing files are preserved byte-for-byte. On subsequent +`/constitution` runs, the command resolves the current composed template at runtime and uses the live +constitution as the source of project-specific values and amendments. + +Preset installation, removal, enablement, disablement, and priority changes do not materialize +`constitution-template` by default. When the enabled preset registry contains `constitution-sync`, +those operations may reconcile the live file, but only if its provenance hash proves it is still +generated content. Missing files may be seeded when the preset is installed; authored or edited +constitutions are never overwritten. + ## Command Registration When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`. diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md index 24abffda54..f71c1f45d8 100644 --- a/presets/PUBLISHING.md +++ b/presets/PUBLISHING.md @@ -300,6 +300,12 @@ git push origin add-your-preset ## Verification Process +> **How submissions get picked up:** the automated catalog-validation workflow only runs +> once the `preset-submission` label is on the issue. On this public repository, contributors +> cannot apply that label themselves — a maintainer applies it during issue triage. Until then +> the issue simply waits in triage; there is no action required from you, and there is no need to +> re-request the label in a comment. + After submission, maintainers will review: 1. **Manifest validation** — valid `preset.yml`, all files exist diff --git a/presets/README.md b/presets/README.md index 29cce64248..539da08786 100644 --- a/presets/README.md +++ b/presets/README.md @@ -15,6 +15,16 @@ If no preset is installed, core templates are used — exactly the same behavior Template resolution happens **at runtime** — although preset files are copied into `.specify/presets//` during installation, Spec Kit walks the resolution stack on every template lookup rather than merging templates into a single location. +`constitution-template` follows the same runtime model. Project initialization seeds +`.specify/memory/constitution.md` once so downstream commands always have a constitution to read. +After that, installing, removing, enabling, disabling, or reprioritizing presets does not rewrite the +live constitution. Each `/constitution` run resolves the current composed `constitution-template`, +then applies existing project values and amendments to that scaffold. + +Teams that intentionally want preset stack changes to refresh an unchanged generated constitution can +install the bundled `constitution-sync` preset. It restores guarded install-time materialization in +addition to its command-time propagation behavior; authored constitutions remain protected. + For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md). ## Command Overrides diff --git a/presets/agentic-sdlc/commands/adlc.spec.checklist.md b/presets/agentic-sdlc/commands/adlc.spec.checklist.md index 81a1eaea44..f112f48e95 100644 --- a/presets/agentic-sdlc/commands/adlc.spec.checklist.md +++ b/presets/agentic-sdlc/commands/adlc.spec.checklist.md @@ -1,13 +1,22 @@ --- description: Generate a custom checklist for the current feature based on user requirements. scripts: - sh: scripts/bash/check-prerequisites.sh --json - ps: scripts/powershell/check-prerequisites.ps1 -Json - py: scripts/python/check_prerequisites.py --json + sh: scripts/bash/check-prerequisites.sh --json --template checklist-template + ps: scripts/powershell/check-prerequisites.ps1 -Json -Template checklist-template + py: scripts/python/check_prerequisites.py --json --template checklist-template --- ## Checklist Purpose: "Unit Tests for English" +**Ownership and checkbox lifecycle**: + +- Custom checklists generated by this command are reviewer-owned requirements-quality review artifacts. +- `[x]` means the reviewer determined the requirements-quality criterion is satisfied. +- `[x]` does NOT mean implementation work is complete. +- This command generates or appends checklist items; it MUST NOT mark generated items `[x]`. +- An agent may assist with evaluating items only when explicitly asked by the reviewer. +- `checklists/requirements.md` is a separate built-in spec-quality checklist maintained by `/spec.specify` and `/spec.clarify`; do not treat that exception as applying to custom checklists generated here. + **CRITICAL CONCEPT**: Checklists validate the **quality, clarity, and completeness of requirements** — not implementation behavior. **NOT for verification**: @@ -74,7 +83,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Execution Steps -1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list. +1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR, AVAILABLE_DOCS list, and TEMPLATE_CONTENT. - All file paths must be absolute. - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). @@ -139,7 +148,8 @@ You **MUST** consider the user input before proceeding (if not empty). - Use progressive disclosure: add follow-on retrieval only if gaps detected - If source docs are large, generate interim summary items instead of embedding raw text -6. **Generate checklist** - Create "Unit Tests for Requirements": +6. **Generate checklist** - Use TEMPLATE_CONTENT as the structural template and create "Unit Tests for Requirements": + - Leave every newly generated item unchecked (`[ ]`); checkbox state belongs to the reviewer - Create `FEATURE_DIR/checklists/` directory if it doesn't exist - Generate unique checklist filename: - Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`, `mission-brief.md`) diff --git a/presets/agentic-sdlc/commands/adlc.spec.constitution.md b/presets/agentic-sdlc/commands/adlc.spec.constitution.md index 18f451b639..23a9d21e4f 100644 --- a/presets/agentic-sdlc/commands/adlc.spec.constitution.md +++ b/presets/agentic-sdlc/commands/adlc.spec.constitution.md @@ -4,6 +4,10 @@ handoffs: - label: Build Specification agent: adlc.spec.specify prompt: Implement the feature specification based on the updated constitution. I want to build... +scripts: + sh: scripts/bash/resolve-template.sh constitution-template --json + ps: scripts/powershell/resolve-template.ps1 constitution-template -Json + py: scripts/python/resolve_template.py constitution-template --json --- ## MANDATORY: Pre-Execution Hooks diff --git a/presets/agentic-sdlc/commands/adlc.spec.implement.md b/presets/agentic-sdlc/commands/adlc.spec.implement.md index 9238bfb9b9..8351d5e1f7 100644 --- a/presets/agentic-sdlc/commands/adlc.spec.implement.md +++ b/presets/agentic-sdlc/commands/adlc.spec.implement.md @@ -102,28 +102,31 @@ You **MUST** consider the user input before proceeding (if not empty). If `tasks_meta.json` already exists (e.g., created by `/spec.tasks`), skip this step. 4. **Check checklists status** (if FEATURE_DIR/checklists/ exists): + - Treat checklist markers as a read-only gate: scan checkbox state, report status, and ask before proceeding when needed; do NOT modify checklist files or markers + - `checklists/requirements.md` is the built-in spec-quality checklist maintained by `/spec.specify` and `/spec.clarify`; custom checklists generated by `/spec.checklist` are reviewer-owned requirements-quality review artifacts + - For custom checklists, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does NOT mean implementation work is complete - Scan all checklist files in the checklists/ directory - - Count: total items (`- [ ]` / `- [X]` / `- [x]`), completed (`- [X]` / `- [x]`), incomplete (`- [ ]`) + - Count: total items (`- [ ]` / `- [X]` / `- [x]`), checked (`- [X]` / `- [x]`), unchecked (`- [ ]`) - Create status table: ```text - | Checklist | Total | Completed | Incomplete | Status | - |-----------|-------|-----------|------------|--------| - | ux.md | 12 | 12 | 0 | ✓ PASS | - | test.md | 8 | 5 | 3 | ✗ FAIL | + | Checklist | Total | Checked | Unchecked | Status | + |-----------|-------|---------|-----------|--------| + | ux.md | 12 | 12 | 0 | ✓ PASS | + | test.md | 8 | 5 | 3 | ✗ FAIL | ``` - - **PASS**: All checklists have 0 incomplete items - - **FAIL**: One or more checklists have incomplete items + - **PASS**: All checklists have 0 unchecked items + - **FAIL**: One or more checklists have unchecked items - - **If any checklist is incomplete**: - - Display the table with incomplete item counts - - **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)" + - **If any checklist is unchecked**: + - Display the table with unchecked item counts + - **STOP** and ask: "Some checklists have unchecked items. Do you want to proceed with implementation anyway? (yes/no)" - Wait for user response before continuing - If user says "no" / "wait" / "stop", halt execution - If user says "yes" / "proceed" / "continue", proceed to step 5 - - **If all checklists are complete**: Display the table and automatically proceed to step 5 + - **If all checklists are checked**: Display the table and automatically proceed to step 5 5. Load and analyze the implementation context: - **REQUIRED**: Read tasks.md for the complete task list and execution plan diff --git a/presets/agentic-sdlc/commands/adlc.spec.tasks.md b/presets/agentic-sdlc/commands/adlc.spec.tasks.md index 82901e1b3b..1049d86373 100644 --- a/presets/agentic-sdlc/commands/adlc.spec.tasks.md +++ b/presets/agentic-sdlc/commands/adlc.spec.tasks.md @@ -64,7 +64,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline -1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, AVAILABLE_DOCS list, and (if provided) TASKS_TEMPLATE. All paths must be absolute when provided. AVAILABLE_DOCS is a list of document names/relative paths available under FEATURE_DIR (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). +1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE_CONTENT, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). ### CRITICAL - Path Validation @@ -138,9 +138,8 @@ If working in a non-git repository: 6. **Generate tasks.md**: - Resolve the tasks template: - - If `{SCRIPT}` JSON output included `TASKS_TEMPLATE`, use that path (must be an absolute path) - - Otherwise fall back to `.specify/templates/tasks-template.md` - - Otherwise fall back to `templates/tasks-template.md` + - Use TASKS_TEMPLATE_CONTENT (from the JSON output above) as the structure + - For compatibility with older setup scripts that omit TASKS_TEMPLATE_CONTENT, read TASKS_TEMPLATE instead - Use the resolved template as structure, fill with: - Correct feature name from plan.md - Phase 1: Setup tasks (project initialization) diff --git a/presets/agentic-sdlc/templates/checklist-template.md b/presets/agentic-sdlc/templates/checklist-template.md index ac99cee4c6..33de1fe51f 100644 --- a/presets/agentic-sdlc/templates/checklist-template.md +++ b/presets/agentic-sdlc/templates/checklist-template.md @@ -4,7 +4,9 @@ **Created**: [DATE] **Feature**: [Link to spec.md or relevant documentation] -**Note**: This checklist is generated by the `/spec.checklist` command based on feature context and requirements. +**Note**: This custom checklist is generated by the `/spec.checklist` command based on feature context and requirements. +**Review Ownership**: This checklist is a reviewer-owned requirements-quality review artifact. Mark an item `[x]` only when the reviewer determines the requirements-quality criterion is satisfied. +**Marker Semantics**: `[x]` means the criterion has been reviewed and satisfied for requirements quality. It does not mean implementation work is complete. " in content - assert b"" in content - assert b"at specs/001-demo/plan.md" in content - - @requires_posix_bash def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None: files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"] diff --git a/tests/integration/test_bundler_local_install.py b/tests/integration/test_bundler_local_install.py index ad9010aa0c..9ad50d13af 100644 --- a/tests/integration/test_bundler_local_install.py +++ b/tests/integration/test_bundler_local_install.py @@ -186,6 +186,50 @@ def test_local_zip_uses_bounded_archive_open(tmp_path: Path): _local_manifest_source(str(artifact)) +def test_local_zip_wraps_malformed_manifest_yaml(tmp_path: Path): + """A malformed bundle.yml inside a .zip must raise BundlerError. + + The zip branch parses YAML inline rather than through load_yaml(), so the + raw yaml.YAMLError used to escape. It is neither a ValueError nor an + OSError, so nothing upstream caught it. + """ + artifact = tmp_path / "bad-manifest.zip" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", "bundle: [unclosed\n id: demo\n") + + with pytest.raises(BundlerError, match="Invalid YAML"): + _local_manifest_source(str(artifact)) + + +def test_malformed_manifest_yaml_fails_alike_for_every_local_source(tmp_path: Path): + """`bundle install` reports malformed YAML the same way for all 3 sources. + + Directory and bundle.yml sources already exited 1 with an "Invalid YAML" + message; the .zip source dumped a yaml.parser.ParserError traceback. + """ + bad_yaml = "bundle: [unclosed\n id: demo\n" + + directory = tmp_path / "dir-src" + directory.mkdir() + (directory / "bundle.yml").write_text(bad_yaml, encoding="utf-8") + + manifest_file = tmp_path / "standalone.yml" + manifest_file.write_text(bad_yaml, encoding="utf-8") + + artifact = tmp_path / "artifact.zip" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", bad_yaml) + + runner = CliRunner() + for source in (directory, manifest_file, artifact): + result = runner.invoke(app, ["bundle", "install", str(source)]) + assert result.exit_code == 1, f"{source.name}: {result.output}" + assert result.exception is None or isinstance( + result.exception, SystemExit + ), f"{source.name} leaked {type(result.exception).__name__}" + assert "Invalid YAML" in result.output, f"{source.name}: {result.output}" + + def test_invalid_local_manifest_is_rejected_before_project_init( tmp_path: Path, monkeypatch, diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 556e05caef..5dfc497b95 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1263,6 +1263,53 @@ def test_unparseable_script_command_returns_none(self, tmp_path): ) argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) + + assert argv is None + + def test_unreadable_template_returns_none(self, tmp_path): + """A command template that cannot be read must resolve to no argv. + + Every other failure inside ``_resolve_event_command_argv`` — missing + frontmatter, malformed YAML, absent scripts — degrades to ``None`` so + the dispatcher treats the command as declaring no runnable script. + The initial ``read_text`` was the one step outside that boundary: a + non-UTF-8 template raised a raw ``UnicodeDecodeError`` through + ``resolve_and_run_event_command`` and out of ``specify event run``. + """ + from specify_cli.events import _resolve_event_command_argv + + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "boot.md").write_bytes( + b"---\ndescription: \"B\xff\xfeoot\"\n---\nBody\n" + ) + + argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) + assert argv is None + + def test_permission_denied_template_returns_none(self, tmp_path, monkeypatch): + """The same boundary must cover ``OSError`` (e.g. permission denied). + + Mocked rather than chmod-based so the case also holds under + privileged CI where permission bits are not enforced. + """ + from specify_cli.events import _resolve_event_command_argv + + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + template = cmd_dir / "boot.md" + template.write_text("---\ndescription: Boot\n---\nBody\n") + + original_read_text = Path.read_text + + def failing_read_text(self_path, *args, **kwargs): + if self_path == template: + raise PermissionError(13, "Permission denied") + return original_read_text(self_path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", failing_read_text) + + argv = _resolve_event_command_argv(template, tmp_path, None) assert argv is None def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): diff --git a/tests/integrations/test_integration_alquimia.py b/tests/integrations/test_integration_alquimia.py index bdf4fa32cd..e8eab8281c 100644 --- a/tests/integrations/test_integration_alquimia.py +++ b/tests/integrations/test_integration_alquimia.py @@ -471,6 +471,101 @@ def test_inject_argument_hint_skips_if_already_present(self): hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) assert hint_count == 1 + def test_inject_argument_hint_survives_folded_description(self): + """A long description folded across lines must not corrupt the YAML (#4044). + + A description long enough for the YAML dumper to fold it into a + multi-line plain scalar previously had ``argument-hint:`` spliced + into the *middle* of that scalar, producing invalid YAML. + """ + from specify_cli.integrations.alquimia import AlquimiaAIIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "Create or update the feature specification from a natural " + "language feature description. Also accepts an issue URL " + "resolved via gh CLI (demo customization)." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n " in content, "fixture description must actually fold across lines" + + result = AlquimiaAIIntegration.inject_argument_hint( + content, "Describe the feature" + ) + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + + def test_inject_argument_hint_survives_quoted_folded_description(self): + """A folded description forced into quotes must not absorb the hint (#4044).""" + from specify_cli.integrations.alquimia import AlquimiaAIIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "Create or update the feature specification from a natural " + "language feature description. Also accepts a GitHub " + "issue/PR URL or #N reference resolved via gh CLI (demo)." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n " in content, "fixture description must actually fold across lines" + + result = AlquimiaAIIntegration.inject_argument_hint( + content, "Describe the feature" + ) + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + + def test_inject_argument_hint_survives_multi_paragraph_description(self): + """A description with an embedded blank line must not absorb the hint. + + PyYAML serializes an embedded ``\\n\\n`` inside a quoted scalar as + unindented blank lines, not indented ones, so a fix that only skips + indented continuation lines still fails on this case. + """ + from specify_cli.integrations.alquimia import AlquimiaAIIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "First paragraph of a fairly long description that will " + "need to wrap across multiple lines when dumped by PyYAML." + "\n\n" + "Second paragraph continues the description after a blank " + "line separator to force embedded newlines in the scalar." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n\n" in frontmatter_text, ( + "fixture must produce a blank continuation line" + ) + + result = AlquimiaAIIntegration.inject_argument_hint( + content, "Describe the feature" + ) + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + class TestAlquimiaDisableModelInvocation: """Verify disable-model-invocation is false for Alquimia skills.""" diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 1c1e592caf..4029c16f65 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -252,11 +252,11 @@ def _expected_files(self, script_variant: str, project=None) -> list[str]: if script_variant == "sh": for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", - "setup-plan.sh", "setup-tasks.sh"]: + "resolve-template.sh", "setup-plan.sh", "setup-tasks.sh"]: files.append(f".specify/scripts/bash/{name}") else: for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", - "setup-plan.ps1", "setup-tasks.ps1"]: + "resolve-template.ps1", "setup-plan.ps1", "setup-tasks.ps1"]: files.append(f".specify/scripts/powershell/{name}") for name in ["checklist-template.md", diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 1dd0b6bf08..a961bcb503 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -540,6 +540,7 @@ def _expected_files(self, script_variant: str, project: Path | None = None) -> l ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/resolve-template.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", ] @@ -548,6 +549,7 @@ def _expected_files(self, script_variant: str, project: Path | None = None) -> l ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/resolve-template.ps1", ".specify/scripts/powershell/setup-plan.ps1", ".specify/scripts/powershell/setup-tasks.ps1", ] diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 7f6dc4ddd1..0b2def2039 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -501,6 +501,7 @@ def _expected_files(self, script_variant: str, project=None) -> list[str]: "check-prerequisites.sh", "common.sh", "create-new-feature.sh", + "resolve-template.sh", "setup-plan.sh", "setup-tasks.sh", ]: @@ -510,6 +511,7 @@ def _expected_files(self, script_variant: str, project=None) -> list[str]: "check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", + "resolve-template.ps1", "setup-plan.ps1", "setup-tasks.ps1", ]: diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index f85ba82139..7150ac9f73 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -425,6 +425,7 @@ def _expected_files(self, script_variant: str, project=None) -> list[str]: "check-prerequisites.sh", "common.sh", "create-new-feature.sh", + "resolve-template.sh", "setup-plan.sh", "setup-tasks.sh", ]: @@ -434,6 +435,7 @@ def _expected_files(self, script_variant: str, project=None) -> list[str]: "check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", + "resolve-template.ps1", "setup-plan.ps1", "setup-tasks.ps1", ]: diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index b89ec26f4b..9b02632992 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -6,6 +6,8 @@ import pytest import yaml +from tests.http_helpers import route_opener_open_through_urlopen # noqa: F401 + from specify_cli.integrations.catalog import ( IntegrationCatalog, IntegrationCatalogEntry, @@ -13,9 +15,34 @@ IntegrationDescriptor, IntegrationDescriptorError, IntegrationValidationError, + _catalog_shape_error, ) +class TestCatalogShapeValidator: + """The shared shape validator used by BOTH the fresh-fetch and cache-read + paths, so a poisoned/older cache can't bypass the format contract the fresh + fetch enforces (dict + 'schema_version' + dict 'integrations').""" + + def test_valid_payload_returns_none(self): + assert _catalog_shape_error({"schema_version": "1.0", "integrations": {}}) is None + + def test_missing_schema_version_is_rejected(self): + # The exact bypass the two paths used to disagree on: a dict with a dict + # 'integrations' but no 'schema_version'. + assert _catalog_shape_error({"integrations": {}}) is not None + + def test_missing_integrations_is_rejected(self): + assert _catalog_shape_error({"schema_version": "1.0"}) is not None + + def test_non_dict_integrations_is_rejected(self): + assert _catalog_shape_error({"schema_version": "1.0", "integrations": []}) is not None + + @pytest.mark.parametrize("payload", [[], "x", 5, None]) + def test_non_dict_payload_is_rejected(self, payload): + assert _catalog_shape_error(payload) is not None + + # --------------------------------------------------------------------------- # IntegrationCatalogEntry # --------------------------------------------------------------------------- @@ -134,20 +161,11 @@ def test_project_config_overrides_defaults(self, tmp_path): specify = tmp_path / ".specify" specify.mkdir() cfg = specify / "integration-catalogs.yml" - cfg.write_text( - yaml.dump( - { - "catalogs": [ - { - "url": "https://my.example.com/cat.json", - "name": "mine", - "priority": 1, - "install_allowed": True, - }, - ] - } - ) - ) + cfg.write_text(yaml.dump({ + "catalogs": [ + {"url": "https://my.example.com/cat.json", "name": "mine", "priority": 1, "install_allowed": True}, + ] + })) cat = IntegrationCatalog(tmp_path) active = cat.get_active_catalogs() assert len(active) == 1 @@ -209,13 +227,7 @@ class TestCatalogFetch: """Tests that use a local HTTP server stub via monkeypatch.""" def _patch_urlopen(self, monkeypatch, catalog_data): - """Patch authentication.http.open_url to return *catalog_data*. - - ``_fetch_single_catalog`` calls ``open_url`` (which uses an opener, not - the module-level ``urllib.request.urlopen``), so the mock must target - ``open_url`` directly. Each call gets a fresh ``FakeResponse`` so the - bounded-read offset resets per fetch (the default + community catalogs - are both fetched).""" + """Patch authentication.http.urllib.request.urlopen to return *catalog_data*.""" class FakeResponse: def __init__(self, data, url=""): @@ -241,12 +253,12 @@ def __enter__(self): def __exit__(self, *a): pass - def fake_open_url(url, timeout=10, **kwargs): + def fake_urlopen(req, timeout=10): + url = req if isinstance(req, str) else req.full_url return FakeResponse(catalog_data, url) import specify_cli.authentication.http as _auth_http - - monkeypatch.setattr(_auth_http, "open_url", fake_open_url) + monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen) def test_fetch_and_search_all(self, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) @@ -363,10 +375,10 @@ def __exit__(self, *a): import specify_cli.authentication.http as _auth_http - def fake_open_url(url, timeout=10, **kwargs): + def fake_urlopen(req, timeout=10): return _OversizedResponse() - monkeypatch.setattr(_auth_http, "open_url", fake_open_url) + monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen) from specify_cli.integrations.catalog import IntegrationCatalogEntry @@ -380,6 +392,125 @@ def fake_open_url(url, timeout=10, **kwargs): with pytest.raises(IntegrationCatalogError, match="exceeds maximum size"): cat._fetch_single_catalog(entry, force_refresh=True) + def _patch_urlopen_bytes(self, monkeypatch, bodies): + """Patch urlopen to serve raw *bodies* keyed by URL substring. + + Mirrors ``_patch_urlopen`` but passes the bytes through verbatim: these + tests need a body that is not valid UTF-8, which ``json.dumps`` cannot + produce. + """ + + class _RawResponse: + def __init__(self, data, url): + self._data = data + self._url = url + self._offset = 0 + + def read(self, size=-1): + if size == -1: + chunk = self._data[self._offset:] + self._offset = len(self._data) + else: + chunk = self._data[self._offset:self._offset + size] + self._offset += len(chunk) + return chunk + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + def fake_urlopen(req, timeout=10): + url = req if isinstance(req, str) else req.full_url + for marker, body in bodies.items(): + if marker in url: + return _RawResponse(body, url) + raise AssertionError(f"unexpected URL requested: {url}") + + import specify_cli.authentication.http as _auth_http + monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen) + + def test_fetch_wraps_non_utf8_catalog_response(self, tmp_path, monkeypatch): + """Regression: a non-UTF-8 response body must raise IntegrationCatalogError. + + ``.decode("utf-8")`` runs before ``json.loads``, so the resulting + UnicodeDecodeError is not a JSONDecodeError and slipped past both + handlers as a raw traceback. + """ + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir(exist_ok=True) + cat = IntegrationCatalog(tmp_path) + + self._patch_urlopen_bytes( + monkeypatch, + {"catalog.json": b'{"schema_version": "1.0", "name": "\xff\xfe"}'}, + ) + + entry = IntegrationCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=True, + ) + + with pytest.raises(IntegrationCatalogError, match="not valid UTF-8"): + cat._fetch_single_catalog(entry, force_refresh=True) + + def test_search_skips_non_utf8_catalog(self, tmp_path, monkeypatch, capsys): + """A single non-UTF-8 catalog must not take down the whole search. + + ``_get_merged_integrations`` is built to warn and continue on a bad + catalog; an unwrapped UnicodeDecodeError defeated that entirely. + """ + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + specify = tmp_path / ".specify" + specify.mkdir(exist_ok=True) + (specify / "integration-catalogs.yml").write_text( + "catalogs:\n" + " - name: broken\n" + " url: https://example.com/broken.json\n" + " priority: 1\n" + " - name: healthy\n" + " url: https://example.com/healthy.json\n" + " priority: 2\n", + encoding="utf-8", + ) + + healthy = json.dumps( + { + "schema_version": "1.0", + "integrations": { + "acme-coder": { + "name": "Acme Coder", + "version": "1.0.0", + "description": "Acme integration", + } + }, + } + ).encode("utf-8") + + self._patch_urlopen_bytes( + monkeypatch, + { + "broken.json": b'{"schema_version": "1.0", "name": "\xff\xfe"}', + "healthy.json": healthy, + }, + ) + + cat = IntegrationCatalog(tmp_path) + results = cat.search() + + assert "acme-coder" in [r["id"] for r in results] + assert "broken" in capsys.readouterr().err + def test_search_by_tag(self, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) @@ -411,20 +542,8 @@ def test_search_by_query(self, tmp_path, monkeypatch): "schema_version": "1.0", "updated_at": "2026-01-01T00:00:00Z", "integrations": { - "claude": { - "id": "claude", - "name": "Claude Code", - "version": "1.0.0", - "description": "Anthropic", - "tags": [], - }, - "gemini": { - "id": "gemini", - "name": "Gemini CLI", - "version": "1.0.0", - "description": "Google", - "tags": [], - }, + "claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0", "description": "Anthropic", "tags": []}, + "gemini": {"id": "gemini", "name": "Gemini CLI", "version": "1.0.0", "description": "Google", "tags": []}, }, } self._patch_urlopen(monkeypatch, catalog) @@ -462,13 +581,9 @@ def test_invalid_catalog_format(self, tmp_path, monkeypatch): (tmp_path / ".specify").mkdir() cat = IntegrationCatalog(tmp_path) - self._patch_urlopen( - monkeypatch, {"schema_version": "1.0"} - ) # missing "integrations" + self._patch_urlopen(monkeypatch, {"schema_version": "1.0"}) # missing "integrations" - with pytest.raises( - IntegrationCatalogError, match="Failed to fetch any integration catalog" - ): + with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"): cat.search() def test_clear_cache(self, tmp_path): @@ -526,24 +641,17 @@ def test_missing_schema_version(self, tmp_path): data = {**VALID_DESCRIPTOR} del data["schema_version"] p = self._write(tmp_path, data) - with pytest.raises( - IntegrationDescriptorError, match="Missing required field: schema_version" - ): + with pytest.raises(IntegrationDescriptorError, match="Missing required field: schema_version"): IntegrationDescriptor(p) def test_unsupported_schema_version(self, tmp_path): data = {**VALID_DESCRIPTOR, "schema_version": "99.0"} p = self._write(tmp_path, data) - with pytest.raises( - IntegrationDescriptorError, match="Unsupported schema version" - ): + with pytest.raises(IntegrationDescriptorError, match="Unsupported schema version"): IntegrationDescriptor(p) def test_missing_integration_id(self, tmp_path): - data = { - **VALID_DESCRIPTOR, - "integration": {"name": "X", "version": "1.0.0", "description": "Y"}, - } + data = {**VALID_DESCRIPTOR, "integration": {"name": "X", "version": "1.0.0", "description": "Y"}} p = self._write(tmp_path, data) with pytest.raises(IntegrationDescriptorError, match="Missing integration.id"): IntegrationDescriptor(p) @@ -565,44 +673,29 @@ def test_invalid_version(self, tmp_path): def test_missing_speckit_version(self, tmp_path): data = {**VALID_DESCRIPTOR, "requires": {}} p = self._write(tmp_path, data) - with pytest.raises( - IntegrationDescriptorError, match="requires.speckit_version" - ): + with pytest.raises(IntegrationDescriptorError, match="requires.speckit_version"): IntegrationDescriptor(p) def test_no_commands_or_scripts(self, tmp_path): data = {**VALID_DESCRIPTOR, "provides": {}} p = self._write(tmp_path, data) - with pytest.raises( - IntegrationDescriptorError, match="at least one command or script" - ): + with pytest.raises(IntegrationDescriptorError, match="at least one command or script"): IntegrationDescriptor(p) def test_command_missing_name(self, tmp_path): data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"file": "x.md"}]}} p = self._write(tmp_path, data) - with pytest.raises( - IntegrationDescriptorError, match="missing 'name' or 'file'" - ): + with pytest.raises(IntegrationDescriptorError, match="missing 'name' or 'file'"): IntegrationDescriptor(p) def test_commands_not_a_list(self, tmp_path): - data = { - **VALID_DESCRIPTOR, - "provides": {"commands": "not-a-list", "scripts": ["a.sh"]}, - } + data = {**VALID_DESCRIPTOR, "provides": {"commands": "not-a-list", "scripts": ["a.sh"]}} p = self._write(tmp_path, data) with pytest.raises(IntegrationDescriptorError, match="expected a list"): IntegrationDescriptor(p) def test_scripts_not_a_list(self, tmp_path): - data = { - **VALID_DESCRIPTOR, - "provides": { - "commands": [{"name": "a", "file": "b"}], - "scripts": "not-a-list", - }, - } + data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"name": "a", "file": "b"}], "scripts": "not-a-list"}} p = self._write(tmp_path, data) with pytest.raises(IntegrationDescriptorError, match="expected a list"): IntegrationDescriptor(p) @@ -624,13 +717,10 @@ def test_get_hash(self, tmp_path): assert h.startswith("sha256:") def test_tools_accessor(self, tmp_path): - data = { - **VALID_DESCRIPTOR, - "requires": { - "speckit_version": ">=0.6.0", - "tools": [{"name": "my-agent", "version": ">=1.0.0", "required": True}], - }, - } + data = {**VALID_DESCRIPTOR, "requires": { + "speckit_version": ">=0.6.0", + "tools": [{"name": "my-agent", "version": ">=1.0.0", "required": True}], + }} p = self._write(tmp_path, data) desc = IntegrationDescriptor(p) assert len(desc.tools) == 1 @@ -649,26 +739,18 @@ def _init_project(self, tmp_path): """Create a minimal spec-kit project.""" from typer.testing import CliRunner from specify_cli import app - runner = CliRunner() project = tmp_path / "proj" project.mkdir() old = os.getcwd() try: os.chdir(project) - result = runner.invoke( - app, - [ - "init", - "--here", - "--integration", - "copilot", - "--script", - "sh", - "--ignore-agent-tools", - ], - catch_exceptions=False, - ) + result = runner.invoke(app, [ + "init", "--here", + "--integration", "copilot", + "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) finally: os.chdir(old) assert result.exit_code == 0, result.output @@ -678,7 +760,6 @@ def test_list_catalog_flag(self, tmp_path, monkeypatch): """--catalog should show catalog entries.""" from typer.testing import CliRunner from specify_cli import app - runner = CliRunner() project = self._init_project(tmp_path) @@ -722,15 +803,8 @@ def __enter__(self): def __exit__(self, *a): pass - import io - from unittest.mock import MagicMock - mock_resp = MagicMock() - mock_resp.read.side_effect = io.BytesIO(json.dumps(catalog).encode("utf-8")).read - mock_resp.headers = {} - mock_resp.geturl.return_value = "https://example.com/catalog.json" - mock_resp.__enter__ = lambda s: s - mock_resp.__exit__ = MagicMock(return_value=False) - monkeypatch.setattr(_auth_http, "open_url", lambda req, timeout=10: mock_resp) + monkeypatch.setattr(_auth_http.urllib.request, "urlopen", + lambda req, timeout=10: FakeResponse(catalog, req if isinstance(req, str) else req.full_url)) old = os.getcwd() try: @@ -747,7 +821,6 @@ def test_list_without_catalog_still_works(self, tmp_path): """Default list (no --catalog) works as before.""" from typer.testing import CliRunner from specify_cli import app - runner = CliRunner() project = self._init_project(tmp_path) @@ -762,6 +835,40 @@ def test_list_without_catalog_still_works(self, tmp_path): assert "copilot" in result.output assert "installed" in result.output + def test_catalog_list_escapes_rich_markup(self, tmp_path, monkeypatch): + """User-editable catalog name/url/description must not be parsed as Rich markup.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.integrations.catalog import IntegrationCatalog + runner = CliRunner() + project = self._init_project(tmp_path) + + configs = [ + { + "name": "Bracket [Catalog]", + "url": "https://example.com/[cat].json", + "description": "desc [with] brackets", + "install_allowed": True, + }, + ] + monkeypatch.setattr( + IntegrationCatalog, + "get_project_catalog_configs", + lambda self: [dict(c) for c in configs], + ) + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "catalog", "list"]) + finally: + os.chdir(old) + + assert result.exit_code == 0, result.output + assert "Bracket [Catalog]" in result.output + assert "https://example.com/[cat].json" in result.output + assert "desc [with] brackets" in result.output + # --------------------------------------------------------------------------- # CLI: integration upgrade @@ -774,26 +881,18 @@ class TestIntegrationUpgrade: def _init_project(self, tmp_path, integration="copilot"): from typer.testing import CliRunner from specify_cli import app - runner = CliRunner() project = tmp_path / "proj" project.mkdir() old = os.getcwd() try: os.chdir(project) - result = runner.invoke( - app, - [ - "init", - "--here", - "--integration", - integration, - "--script", - "sh", - "--ignore-agent-tools", - ], - catch_exceptions=False, - ) + result = runner.invoke(app, [ + "init", "--here", + "--integration", integration, + "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) finally: os.chdir(old) assert result.exit_code == 0, result.output @@ -802,7 +901,6 @@ def _init_project(self, tmp_path, integration="copilot"): def test_upgrade_requires_speckit_project(self, tmp_path): from typer.testing import CliRunner from specify_cli import app - runner = CliRunner() old = os.getcwd() try: @@ -816,7 +914,6 @@ def test_upgrade_requires_speckit_project(self, tmp_path): def test_upgrade_no_integration_installed(self, tmp_path): from typer.testing import CliRunner from specify_cli import app - runner = CliRunner() project = tmp_path / "proj" project.mkdir() @@ -833,16 +930,13 @@ def test_upgrade_no_integration_installed(self, tmp_path): def test_upgrade_succeeds(self, tmp_path): from typer.testing import CliRunner from specify_cli import app - runner = CliRunner() project = self._init_project(tmp_path, "copilot") old = os.getcwd() try: os.chdir(project) - result = runner.invoke( - app, ["integration", "upgrade", "--force"], catch_exceptions=False - ) + result = runner.invoke(app, ["integration", "upgrade"], catch_exceptions=False) finally: os.chdir(old) assert result.exit_code == 0 @@ -851,7 +945,6 @@ def test_upgrade_succeeds(self, tmp_path): def test_upgrade_blocks_on_modified_files(self, tmp_path): from typer.testing import CliRunner from specify_cli import app - runner = CliRunner() project = self._init_project(tmp_path, "copilot") @@ -878,7 +971,6 @@ def test_upgrade_blocks_on_modified_files(self, tmp_path): def test_upgrade_force_overwrites_modified(self, tmp_path): from typer.testing import CliRunner from specify_cli import app - runner = CliRunner() project = self._init_project(tmp_path, "copilot") @@ -895,9 +987,7 @@ def test_upgrade_force_overwrites_modified(self, tmp_path): old = os.getcwd() try: os.chdir(project) - result = runner.invoke( - app, ["integration", "upgrade", "--force"], catch_exceptions=False - ) + result = runner.invoke(app, ["integration", "upgrade", "--force"], catch_exceptions=False) finally: os.chdir(old) assert result.exit_code == 0 @@ -906,7 +996,6 @@ def test_upgrade_force_overwrites_modified(self, tmp_path): def test_upgrade_wrong_integration_key(self, tmp_path): from typer.testing import CliRunner from specify_cli import app - runner = CliRunner() project = self._init_project(tmp_path, "copilot") @@ -923,7 +1012,6 @@ def test_upgrade_no_manifest(self, tmp_path): """Upgrade with missing manifest suggests fresh install.""" from typer.testing import CliRunner from specify_cli import app - runner = CliRunner() project = self._init_project(tmp_path, "copilot") @@ -1139,6 +1227,57 @@ def test_add_catalog_rejects_non_mapping_entry_with_config_path( assert str(cfg_path) in message assert "expected a mapping" in message + def test_add_catalog_rejects_inf_priority_in_existing_entry( + self, tmp_path, monkeypatch + ): + # ``priority: .inf`` loads as float('inf'); int() on it raises + # OverflowError, which used to escape the IntegrationValidationError + # contract as a raw traceback (github/spec-kit#3526 fixed the sibling + # workflow/step loaders the same way). + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://a.example.com/catalog.json", + "priority": float("inf"), + } + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="must be an integer" + ): + cat.add_catalog("https://new.example.com/catalog.json") + + def test_remove_catalog_tolerates_inf_priority(self, tmp_path, monkeypatch): + # Building the remove display order must not crash on a ``priority: + # .inf`` entry; it falls back to positional order like the other + # non-integer priorities do. + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://a.example.com/catalog.json", + "priority": float("inf"), + }, + {"url": "https://b.example.com/catalog.json", "priority": 2}, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + cat.remove_catalog(0) # must not raise OverflowError + def test_add_catalog_skips_blank_url_entries(self, tmp_path, monkeypatch): self._isolate(tmp_path, monkeypatch) cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index a56ba90c43..7eb3190b62 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -463,6 +463,93 @@ def test_inject_argument_hint_skips_if_already_present(self): hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) assert hint_count == 1 + def test_inject_argument_hint_survives_folded_description(self): + """A long description folded across lines must not corrupt the YAML (#4044). + + A description long enough for the YAML dumper to fold it into a + multi-line plain scalar previously had ``argument-hint:`` spliced + into the *middle* of that scalar, producing invalid YAML. + """ + from specify_cli.integrations.claude import ClaudeIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "Create or update the feature specification from a natural " + "language feature description. Also accepts an issue URL " + "resolved via gh CLI (demo customization)." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n " in content, "fixture description must actually fold across lines" + + result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature") + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + + def test_inject_argument_hint_survives_quoted_folded_description(self): + """A folded description forced into quotes must not absorb the hint (#4044).""" + from specify_cli.integrations.claude import ClaudeIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "Create or update the feature specification from a natural " + "language feature description. Also accepts a GitHub " + "issue/PR URL or #N reference resolved via gh CLI (demo)." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n " in content, "fixture description must actually fold across lines" + + result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature") + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + + def test_inject_argument_hint_survives_multi_paragraph_description(self): + """A description with an embedded blank line must not absorb the hint. + + PyYAML serializes an embedded ``\\n\\n`` inside a quoted scalar as + unindented blank lines, not indented ones, so a fix that only skips + indented continuation lines still fails on this case. + """ + from specify_cli.integrations.claude import ClaudeIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "First paragraph of a fairly long description that will " + "need to wrap across multiple lines when dumped by PyYAML." + "\n\n" + "Second paragraph continues the description after a blank " + "line separator to force embedded newlines in the scalar." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n\n" in frontmatter_text, "fixture must produce a blank continuation line" + + result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature") + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + class TestClaudeDisableModelInvocation: """Verify disable-model-invocation is false for Claude skills.""" diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index 1917c11608..ea910f4602 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -209,6 +209,7 @@ def _expected_files(self, script_variant: str, project=None) -> list[str]: "check-prerequisites.sh", "common.sh", "create-new-feature.sh", + "resolve-template.sh", "setup-plan.sh", "setup-tasks.sh", ]: @@ -218,6 +219,7 @@ def _expected_files(self, script_variant: str, project=None) -> list[str]: "check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", + "resolve-template.ps1", "setup-plan.ps1", "setup-tasks.ps1", ]: diff --git a/tests/integrations/test_integration_command_code.py b/tests/integrations/test_integration_command_code.py new file mode 100644 index 0000000000..3e86df709d --- /dev/null +++ b/tests/integrations/test_integration_command_code.py @@ -0,0 +1,53 @@ +"""Tests for CommandCodeIntegration — skills-based integration (Command Code).""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestCommandCodeIntegration(SkillsIntegrationTests): + KEY = "command-code" + FOLDER = ".commandcode/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".commandcode/skills" + + +class TestCommandCodeInvocation: + """Command Code renders $speckit-* chat invocations (like Codex/ZCode).""" + + def test_next_steps_show_dollar_skill_invocation(self, tmp_path): + import os + + from typer.testing import CliRunner + + from specify_cli import PKG_NAMES, app + + # Fork uses "spec" command prefix instead of upstream's "speckit". + if any("agentic-sdlc" in pkg for pkg in PKG_NAMES): + _cmd_prefix = "spec" + else: + _cmd_prefix = "speckit" + + project = tmp_path / "command-code-next-steps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + "command-code", + "--ignore-agent-tools", + "--script", + "sh", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + assert f"${_cmd_prefix}-constitution" in result.output + assert f"/{_cmd_prefix}.constitution" not in result.output diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index a890824dea..425f510b9d 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -283,6 +283,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/resolve-template.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", ".specify/templates/checklist-template.md", @@ -358,6 +359,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/resolve-template.ps1", ".specify/scripts/powershell/setup-plan.ps1", ".specify/scripts/powershell/setup-tasks.ps1", ".specify/templates/checklist-template.md", @@ -911,6 +913,7 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/resolve-template.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", # Templates diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index ae54afe493..067638aaf4 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -350,6 +350,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/resolve-template.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", ".specify/templates/checklist-template.md", @@ -418,6 +419,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/resolve-template.ps1", ".specify/scripts/powershell/setup-plan.ps1", ".specify/scripts/powershell/setup-tasks.ps1", ".specify/templates/checklist-template.md", diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index ba6cd45c88..84a742f743 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", + "droid", "command-code", ] diff --git a/tests/parity_helpers.py b/tests/parity_helpers.py index 67f49e50e2..939813ebcd 100644 --- a/tests/parity_helpers.py +++ b/tests/parity_helpers.py @@ -131,6 +131,67 @@ def write_feature_json( ) +def install_composition_stack( + repo: Path, template_name: str, core_content: str +) -> str: + """Install wrap/prepend/append presets over a core template.""" + templates = repo / ".specify" / "templates" + templates.mkdir(parents=True, exist_ok=True) + (templates / f"{template_name}.md").write_text(core_content, encoding="utf-8") + + layers = [ + ("wrap-pack", 1, "wrap", "## Wrapper\n{CORE_TEMPLATE}\n## End\n"), + ("prepend-pack", 2, "prepend", "# Prepended\n"), + ("append-pack", 3, "append", "# Appended\n"), + ] + registry: dict[str, object] = {"presets": {}} + registry_presets = registry["presets"] + assert isinstance(registry_presets, dict) + + for preset_id, priority, strategy, content in layers: + preset_dir = repo / ".specify" / "presets" / preset_id + template_dir = preset_dir / "templates" + template_dir.mkdir(parents=True) + (template_dir / f"{template_name}.md").write_text(content, encoding="utf-8") + (preset_dir / "preset.yml").write_text( + "provides:\n" + " templates:\n" + " - type: template\n" + f" name: {template_name}\n" + f" file: templates/{template_name}.md\n" + f" strategy: {strategy}\n", + encoding="utf-8", + ) + registry_presets[preset_id] = { + "enabled": True, + "priority": priority, + } + + (repo / ".specify" / "presets" / ".registry").write_text( + json.dumps(registry, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + + appended = "# Appended\n" + prepended = "# Prepended\n" + wrapper = "## Wrapper\n{CORE_TEMPLATE}\n## End\n" + composed = f"{core_content}\n\n{appended}" + composed = f"{prepended}\n\n{composed}" + return wrapper.replace("{CORE_TEMPLATE}", composed) + + +def break_wrap_layer(repo: Path, template_name: str) -> None: + """Replace the installed wrap layer with one missing its placeholder.""" + ( + repo + / ".specify" + / "presets" + / "wrap-pack" + / "templates" + / f"{template_name}.md" + ).write_text("# Broken wrapper\n", encoding="utf-8") + + def normalize_repo_paths(text: str, repo: Path) -> str: """Replace the repo path with a placeholder so two-repo runs compare equal.""" repo_paths = sorted({str(repo), str(repo.resolve())}, key=len, reverse=True) diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index c03ea73dc4..3ef737d9cd 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -21,6 +21,7 @@ "cline", "codebuddy", "codex", + "command-code", "cursor-agent", "devin", "droid", diff --git a/tests/test_check_prerequisites_python_parity.py b/tests/test_check_prerequisites_python_parity.py index 5c5083f61f..6dbd4c62e7 100644 --- a/tests/test_check_prerequisites_python_parity.py +++ b/tests/test_check_prerequisites_python_parity.py @@ -12,6 +12,7 @@ import pytest from tests.conftest import requires_bash +from tests.parity_helpers import install_composition_stack PROJECT_ROOT = Path(__file__).resolve().parent.parent COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" @@ -136,6 +137,87 @@ def _normalize_help_text(text: str) -> str: return "\n".join("" if not line.strip() else line for line in normalized.split("\n")) +@requires_bash +@pytest.mark.parametrize("missing", [False, True], ids=["composed", "missing"]) +def test_all_variants_resolve_requested_template( + prereq_repo: Path, missing: bool +) -> None: + _write_feature_json(prereq_repo) + feature = prereq_repo / "specs" / "001-my-feature" + feature.mkdir(parents=True) + (feature / "plan.md").write_text("# Plan\n", encoding="utf-8") + template_name = "missing-template" if missing else "checklist-template" + expected = install_composition_stack( + prereq_repo, "checklist-template", "# Checklist\n" + ) + + results = [ + _run( + _bash_cmd(prereq_repo, "--json", "--template", template_name), + prereq_repo, + ), + _run( + _py_cmd(prereq_repo, "--json", "--template", template_name), + prereq_repo, + ), + ] + if HAS_PWSH or _WINDOWS_POWERSHELL: + results.append( + _run( + _ps_cmd(prereq_repo, "-Json", "-Template", template_name), + prereq_repo, + ) + ) + + expected_status = 1 if missing else 0 + assert all(result.returncode == expected_status for result in results) + if missing: + assert all(result.stdout == "" for result in results) + else: + assert all( + _json_stdout(result)["TEMPLATE_CONTENT"] == expected + for result in results + ) + + +@requires_bash +@pytest.mark.parametrize("missing", [False, True], ids=["composed", "missing"]) +def test_all_variants_validate_requested_template_in_text_mode( + prereq_repo: Path, missing: bool +) -> None: + _write_feature_json(prereq_repo) + feature = prereq_repo / "specs" / "001-my-feature" + feature.mkdir(parents=True) + (feature / "plan.md").write_text("# Plan\n", encoding="utf-8") + template_name = "missing-template" if missing else "checklist-template" + install_composition_stack( + prereq_repo, "checklist-template", "# Checklist\n" + ) + + results = [ + _run( + _bash_cmd(prereq_repo, "--template", template_name), + prereq_repo, + ), + _run( + _py_cmd(prereq_repo, "--template", template_name), + prereq_repo, + ), + ] + if HAS_PWSH or _WINDOWS_POWERSHELL: + results.append( + _run( + _ps_cmd(prereq_repo, "-Template", template_name), + prereq_repo, + ) + ) + + expected_status = 1 if missing else 0 + assert all(result.returncode == expected_status for result in results) + if missing: + assert all(result.stdout == "" for result in results) + + @requires_bash @pytest.mark.parametrize( "args", diff --git a/tests/test_command_template_py_scripts.py b/tests/test_command_template_py_scripts.py index a634f1f2f0..07ef62c590 100644 --- a/tests/test_command_template_py_scripts.py +++ b/tests/test_command_template_py_scripts.py @@ -79,7 +79,7 @@ def test_template_renders_python_invocation(name: str): result = IntegrationBase.process_template(content, "agent", "py") assert "{SCRIPT}" not in result assert re.search( - r"python3 \.specify/scripts/python/\w+\.py(?: --[\w-]+)*", result + r"python3 \.specify/scripts/python/\w+\.py(?: [\w-]+)*", result ), f"{name} did not render a Python invocation" diff --git a/tests/test_create_new_feature_python_parity.py b/tests/test_create_new_feature_python_parity.py index bee7a73e24..8905b07d33 100644 --- a/tests/test_create_new_feature_python_parity.py +++ b/tests/test_create_new_feature_python_parity.py @@ -13,6 +13,8 @@ from tests.parity_helpers import ( HAS_POWERSHELL, bash_cmd, + break_wrap_layer, + install_composition_stack, install_scripts, json_stdout, make_repo, @@ -383,12 +385,108 @@ def test_python_full_run_matches_bash(repo_pair: tuple[Path, Path]) -> None: branch = json_stdout(py)["BRANCH_NAME"] for repo in repo_pair: spec = repo / "specs" / branch / "spec.md" - assert spec.read_text(encoding="utf-8") == TEMPLATE_BODY + assert spec.read_bytes() == TEMPLATE_BODY.encode("utf-8") assert (repo_b / ".specify" / "feature.json").read_bytes() == ( repo_a / ".specify" / "feature.json" ).read_bytes() +@requires_bash +def test_all_variants_materialize_composed_spec_template(tmp_path: Path) -> None: + repos = [ + _setup_repo(tmp_path, "bash"), + _setup_repo(tmp_path, "powershell"), + _setup_repo(tmp_path, "python"), + ] + expected = "" + for current in repos: + expected = install_composition_stack( + current, "spec-template", TEMPLATE_BODY + ) + + bash = run( + bash_cmd( + repos[0], + SCRIPT, + "--json", + "--number", + "1", + "--short-name", + "composed", + "x", + ), + repos[0], + ) + py = run( + py_cmd( + repos[2], + SCRIPT, + "--json", + "--number", + "1", + "--short-name", + "composed", + "x", + ), + repos[2], + ) + results = [bash, py] + checked_repos = [repos[0], repos[2]] + if HAS_POWERSHELL: + results.insert( + 1, + run( + ps_cmd( + repos[1], + SCRIPT, + "-Json", + "-Number", + "1", + "-ShortName", + "composed", + "x", + ), + repos[1], + ), + ) + checked_repos.insert(1, repos[1]) + + assert all(result.returncode == 0 for result in results) + for current in checked_repos: + assert ( + current / "specs" / "001-composed" / "spec.md" + ).read_text(encoding="utf-8") == expected + + +@requires_bash +def test_all_variants_fail_for_broken_spec_composition(tmp_path: Path) -> None: + repos = [ + _setup_repo(tmp_path, "bash"), + _setup_repo(tmp_path, "powershell"), + _setup_repo(tmp_path, "python"), + ] + for current in repos: + install_composition_stack(current, "spec-template", TEMPLATE_BODY) + break_wrap_layer(current, "spec-template") + + bash = run(bash_cmd(repos[0], SCRIPT, "--json", "x"), repos[0]) + py = run(py_cmd(repos[2], SCRIPT, "--json", "x"), repos[2]) + results = [(bash, repos[0]), (py, repos[2])] + if HAS_POWERSHELL: + results.append( + ( + run(ps_cmd(repos[1], SCRIPT, "-Json", "x"), repos[1]), + repos[1], + ) + ) + + assert all(result.returncode != 0 for result, _ in results) + assert all( + not (current / "specs" / "001-x").exists() + for _, current in results + ) + + @requires_bash def test_python_missing_template_warning_matches_bash( repo_pair: tuple[Path, Path], diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 5698bf357e..997ea97924 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1020,6 +1020,268 @@ def test_manifest_hash(self, extension_dir): assert len(hash_value) > 10 +class TestExtensionManifestTemplatesAndScripts: + """Tests for the optional provides.templates / provides.scripts sections.""" + + def test_templates_and_scripts_declared(self, temp_dir, valid_manifest_data): + """A manifest declaring templates and scripts exposes them via properties.""" + import yaml + + valid_manifest_data["provides"]["templates"] = [ + { + "name": "myext-template", + "file": "templates/myext-template.md", + "description": "Report scaffold contributed by myext", + } + ] + valid_manifest_data["provides"]["scripts"] = [ + { + "name": "myext-collect", + "file": "scripts/bash/myext-collect.sh", + "description": "Data-collection helper", + "runtimes": ["bash", "python"], + } + ] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.templates == valid_manifest_data["provides"]["templates"] + assert manifest.scripts == valid_manifest_data["provides"]["scripts"] + assert manifest.warnings == [] + + def test_templates_only_extension_is_valid(self, temp_dir, valid_manifest_data): + """An extension with only a declared template (no commands/hooks/events) is valid.""" + import yaml + + valid_manifest_data["provides"]["commands"] = [] + valid_manifest_data.pop("hooks", None) + valid_manifest_data["provides"]["templates"] = [ + {"name": "myext-template", "file": "templates/myext-template.md"} + ] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + assert len(manifest.templates) == 1 + assert len(manifest.commands) == 0 + + def test_scripts_only_extension_is_valid(self, temp_dir, valid_manifest_data): + """An extension with only a declared script (no commands/hooks/events) is valid.""" + import yaml + + valid_manifest_data["provides"]["commands"] = [] + valid_manifest_data.pop("hooks", None) + valid_manifest_data["provides"]["scripts"] = [ + {"name": "myext-collect", "file": "scripts/bash/myext-collect.sh"} + ] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + assert len(manifest.scripts) == 1 + + def test_no_provides_at_all_still_rejected(self, temp_dir, valid_manifest_data): + """Without commands, hooks, events, templates, or scripts the manifest is + still rejected — the relaxed rule only widens what counts, it doesn't + drop the requirement that an extension provide *something*.""" + import yaml + + valid_manifest_data["provides"]["commands"] = [] + valid_manifest_data.pop("hooks", None) + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="must provide at least one command, hook, or event"): + ExtensionManifest(manifest_path) + + @pytest.mark.parametrize("section", ["templates", "scripts"]) + def test_provides_section_must_be_a_list(self, temp_dir, valid_manifest_data, section): + """provides.templates / provides.scripts must be a list, not e.g. a mapping.""" + import yaml + + valid_manifest_data["provides"][section] = {"not": "a list"} + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match=f"Invalid provides.{section}: expected a list"): + ExtensionManifest(manifest_path) + + @pytest.mark.parametrize("section", ["templates", "scripts"]) + def test_provides_entry_must_be_a_mapping(self, temp_dir, valid_manifest_data, section): + """Each provides.templates / provides.scripts entry must be a mapping.""" + import yaml + + valid_manifest_data["provides"][section] = ["not-a-mapping"] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match=f"Each entry in 'provides.{section}' must be a mapping"): + ExtensionManifest(manifest_path) + + @pytest.mark.parametrize("section", ["templates", "scripts"]) + def test_provides_entry_missing_name_or_file(self, temp_dir, valid_manifest_data, section): + """Each entry requires both 'name' and 'file'.""" + import yaml + + valid_manifest_data["provides"][section] = [{"name": "only-a-name"}] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="missing 'name' or 'file'"): + ExtensionManifest(manifest_path) + + @pytest.mark.parametrize("section", ["templates", "scripts"]) + def test_provides_entry_invalid_name_format(self, temp_dir, valid_manifest_data, section): + """Names must be lowercase alphanumeric with hyphens only.""" + import yaml + + valid_manifest_data["provides"][section] = [ + {"name": "Bad_Name", "file": f"{section}/bad.txt"} + ] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="must be lowercase alphanumeric with hyphens only"): + ExtensionManifest(manifest_path) + + @pytest.mark.parametrize("section", ["templates", "scripts"]) + def test_provides_entry_duplicate_name_rejected(self, temp_dir, valid_manifest_data, section): + """Two entries in the same section sharing a name are rejected. + + The resolver (PresetResolver._extension_manifest_declared_template) + returns the first entry matching a name, so a later duplicate would + be silently unreachable while still counted by ExtensionManifest + properties -- reject it up front instead. + """ + import yaml + + valid_manifest_data["provides"][section] = [ + {"name": "dup", "file": f"{section}/a.txt"}, + {"name": "dup", "file": f"{section}/b.txt"}, + ] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match=f"Duplicate .* name 'dup' in 'provides.{section}'"): + ExtensionManifest(manifest_path) + + @pytest.mark.parametrize("section", ["templates", "scripts"]) + def test_provides_entry_path_traversal_rejected(self, temp_dir, valid_manifest_data, section): + """The 'file' field is checked with the same path-safety policy as commands.""" + import yaml + + valid_manifest_data["provides"][section] = [ + {"name": "escape", "file": "../evil"} + ] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="relative path within the extension directory"): + ExtensionManifest(manifest_path) + + @pytest.mark.parametrize("section", ["templates", "scripts"]) + def test_provides_entry_strategy_rejected(self, temp_dir, valid_manifest_data, section): + """'strategy' is preset-only; extension-provided artifacts are always 'replace'.""" + import yaml + + valid_manifest_data["provides"][section] = [ + {"name": "has-strategy", "file": f"{section}/x.txt", "strategy": "replace"} + ] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="'strategy' is not authorable"): + ExtensionManifest(manifest_path) + + def test_script_runtimes_accepted(self, temp_dir, valid_manifest_data): + """A valid 'runtimes' list on a script entry is accepted as-is.""" + import yaml + + valid_manifest_data["provides"]["scripts"] = [ + { + "name": "myext-collect", + "file": "scripts/bash/myext-collect.sh", + "runtimes": ["bash", "powershell", "python"], + } + ] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + assert manifest.scripts[0]["runtimes"] == ["bash", "powershell", "python"] + + def test_script_runtimes_must_be_a_list_of_strings(self, temp_dir, valid_manifest_data): + """A non-list 'runtimes' value is rejected.""" + import yaml + + valid_manifest_data["provides"]["scripts"] = [ + {"name": "myext-collect", "file": "scripts/bash/myext-collect.sh", "runtimes": "bash"} + ] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="expected a list of strings"): + ExtensionManifest(manifest_path) + + def test_script_runtimes_rejects_unknown_runtime(self, temp_dir, valid_manifest_data): + """An unrecognized runtime name is rejected with the valid set in the message.""" + import yaml + + valid_manifest_data["provides"]["scripts"] = [ + {"name": "myext-collect", "file": "scripts/bash/myext-collect.sh", "runtimes": ["ruby"]} + ] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid runtimes.*must be one of"): + ExtensionManifest(manifest_path) + + def test_provides_entry_description_must_be_a_string(self, temp_dir, valid_manifest_data): + """An optional 'description' field must be a string when present.""" + import yaml + + valid_manifest_data["provides"]["templates"] = [ + {"name": "myext-template", "file": "templates/myext-template.md", "description": 123} + ] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="expected a string"): + ExtensionManifest(manifest_path) + + # ===== ExtensionRegistry Tests ===== class TestExtensionRegistry: diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py index 907f3fa014..c2287127f6 100644 --- a/tests/test_github_workflows.py +++ b/tests/test_github_workflows.py @@ -57,3 +57,38 @@ def test_community_bundle_submission_automation_is_wired(): assert "verified: false" in source_text assert "allowed-files:" in source_text assert "bundle-submission" in assignment_text + + +def test_bug_test_workflow_provisions_python_dependencies(): + source = WORKFLOWS_DIR / "bug-test.md" + compiled = WORKFLOWS_DIR / "bug-test.lock.yml" + + assert source.is_file() + assert compiled.is_file() + source_text = source.read_text(encoding="utf-8") + compiled_text = compiled.read_text(encoding="utf-8") + + setup_uv = ( + "astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0" + ) + setup_python = ( + "actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0" + ) + + assert " - pypi.org" in source_text + assert " - files.pythonhosted.org" in source_text + assert setup_uv in source_text + assert setup_python in source_text + assert 'run: uv pip install --system -e ".[test]"' in source_text + + assert '"pypi.org"' in compiled_text + assert '"files.pythonhosted.org"' in compiled_text + checkout_index = compiled_text.index("- name: Checkout repository") + uv_index = compiled_text.index("- name: Setup uv") + python_index = compiled_text.index("- name: Set up Python") + sync_index = compiled_text.index("- name: Install Python test dependencies") + agent_index = compiled_text.index("- name: Execute GitHub Copilot CLI") + assert checkout_index < uv_index < python_index < sync_index < agent_index + assert setup_uv in compiled_text + assert setup_python in compiled_text + assert 'run: uv pip install --system -e ".[test]"' in compiled_text diff --git a/tests/test_presets.py b/tests/test_presets.py index 358583e4bd..1da1101001 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1135,6 +1135,40 @@ def test_resolve_nonexistent(self, project_dir): result = resolver.resolve("nonexistent-template") assert result is None + def test_resolver_ignores_traversing_registry_ids(self, project_dir): + """Registry IDs cannot escape preset or extension install roots.""" + for registry_dir, registry_key, outside_name in ( + ("presets", "presets", "outside-preset"), + ("extensions", "extensions", "outside-extension"), + ): + outside = project_dir.parent / outside_name + (outside / "templates").mkdir(parents=True) + (outside / "templates" / "spec-template.md").write_text( + f"# Sensitive {registry_key}\n", + encoding="utf-8", + ) + installed = project_dir / ".specify" / registry_dir + installed.mkdir(parents=True, exist_ok=True) + (installed / ".registry").write_text( + json.dumps( + { + registry_key: { + f"../../../{outside_name}": { + "enabled": True, + "priority": 1, + } + } + } + ), + encoding="utf-8", + ) + + content = PresetResolver(project_dir).resolve_content("spec-template") + + assert content is not None + assert "Core Spec Template" in content + assert "Sensitive" not in content + def test_resolve_higher_priority_pack_wins(self, project_dir, temp_dir, valid_pack_data): """Test that a pack with lower priority number wins over higher number.""" manager = PresetManager(project_dir) @@ -1447,6 +1481,65 @@ def test_resolve_disabled_extension_not_picked_up_as_unregistered(self, project_ result = resolver.resolve("unique-disabled-template") assert result is None, "Disabled extension should not be picked up as unregistered" + @pytest.mark.parametrize( + "registry_bytes", + [b"{ not valid json", b'{"extensions": []}', b"[]"], + ids=["invalid_json", "non_mapping_extensions", "non_mapping_root"], + ) + def test_resolve_fails_closed_on_corrupt_extension_registry( + self, project_dir, registry_bytes + ): + """A corrupt extension registry must fail closed rather than let the + directory scan admit every on-disk extension as enabled.""" + extensions_dir = project_dir / ".specify" / "extensions" + ext_templates_dir = extensions_dir / "sneaky-ext" / "templates" + ext_templates_dir.mkdir(parents=True) + (ext_templates_dir / "custom-template.md").write_text( + "# Should not be served\n" + ) + (extensions_dir / ".registry").write_bytes(registry_bytes) + + resolver = PresetResolver(project_dir) + with pytest.raises(PresetValidationError, match="Invalid extension registry"): + resolver._get_all_extensions_by_priority() + with pytest.raises(PresetValidationError, match="Invalid extension registry"): + resolver.resolve("custom-template") + + def test_resolve_fails_closed_when_registry_is_directory(self, project_dir): + """A directory at the registry path must fail closed, not be treated as + an absent registry that enables every on-disk extension.""" + extensions_dir = project_dir / ".specify" / "extensions" + ext_templates_dir = extensions_dir / "sneaky-ext" / "templates" + ext_templates_dir.mkdir(parents=True) + (ext_templates_dir / "custom-template.md").write_text( + "# Should not be served\n" + ) + (extensions_dir / ".registry").mkdir() + + resolver = PresetResolver(project_dir) + with pytest.raises(PresetValidationError, match="Invalid extension registry"): + resolver.resolve("custom-template") + + def test_resolve_fails_closed_when_registry_is_broken_symlink(self, project_dir): + """A dangling ``.registry`` symlink must fail closed. ``Path.exists()`` + follows symlinks and would mistake it for an absent registry, reopening + the fail-open directory scan.""" + extensions_dir = project_dir / ".specify" / "extensions" + ext_templates_dir = extensions_dir / "sneaky-ext" / "templates" + ext_templates_dir.mkdir(parents=True) + (ext_templates_dir / "custom-template.md").write_text( + "# Should not be served\n" + ) + (extensions_dir / ".registry").symlink_to( + extensions_dir / "does-not-exist" + ) + + registry = ExtensionRegistry(extensions_dir) + assert registry.is_corrupt() + resolver = PresetResolver(project_dir) + with pytest.raises(PresetValidationError, match="Invalid extension registry"): + resolver.resolve("custom-template") + def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data): """Test that pack templates take priority over extension templates.""" # Create extension with templates @@ -3454,6 +3547,9 @@ def test_url_cache_expired(self, project_dir): SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test" +CONSTITUTION_SYNC_PRESET_DIR = ( + Path(__file__).parent.parent / "presets" / "constitution-sync" +) SELF_TEST_WRAP_WARNING = ( r"Cannot compose command 'speckit\.wrap-test': no base layer\. " r"Stale command files may remain\." @@ -3480,6 +3576,11 @@ def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1 return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version) +def install_constitution_sync_preset(manager: PresetManager) -> PresetManifest: + """Enable guarded install-time constitution materialization.""" + return manager.install_from_directory(CONSTITUTION_SYNC_PRESET_DIR, "0.15.0") + + def _make_convention_constitution_preset(temp_dir: Path) -> Path: """Create a preset whose constitution is found by convention, not its manifest.""" preset_dir = temp_dir / "convention-constitution" @@ -3612,6 +3713,7 @@ def test_self_test_removal_restores_core(self, project_dir): (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) install_self_test_preset(manager) manager.remove("self-test") @@ -3630,6 +3732,7 @@ def test_self_test_removal_preserves_edited_constitution(self, project_dir): (templates_dir / "constitution-template.md").write_text("# Core Constitution\n") manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) install_self_test_preset(manager) memory = project_dir / ".specify" / "memory" / "constitution.md" edited = memory.read_text() + "\n## Authored amendment\n" @@ -3713,19 +3816,16 @@ def test_self_test_no_commands_without_agent_dirs(self, project_dir): metadata = manager.registry.get("self-test") assert metadata["registered_commands"] == {} - def test_self_test_seeds_constitution_when_memory_absent(self, project_dir): - """Installing a preset seeds memory/constitution.md from its template.""" + def test_self_test_does_not_seed_constitution_without_sync(self, project_dir): + """Installing a preset does not materialize its constitution by default.""" manager = PresetManager(project_dir) install_self_test_preset(manager) memory = project_dir / ".specify" / "memory" / "constitution.md" - assert memory.exists(), "constitution.md was not seeded from the preset" - assert "preset:self-test" in memory.read_text(), ( - "constitution.md was not seeded from the self-test preset template" - ) + assert not memory.exists() - def test_self_test_reseeds_exact_core_constitution(self, project_dir): - """An unchanged core constitution is re-seeded from the preset template.""" + def test_self_test_preserves_generated_constitution_without_sync(self, project_dir): + """Preset install and removal preserve generated content without the opt-in.""" resolver = PresetResolver(project_dir) bundled_core = resolver._find_bundled_core( "constitution-template", "template", ".md" @@ -3738,10 +3838,19 @@ def test_self_test_reseeds_exact_core_constitution(self, project_dir): manager = PresetManager(project_dir) install_self_test_preset(manager) + manager.remove("self-test") - content = memory.read_text() - assert "preset:self-test" in content, "placeholder constitution was not re-seeded" - assert "[PROJECT_NAME]" not in content + assert memory.read_bytes() == core + + def test_self_test_seeds_constitution_with_sync(self, project_dir): + """constitution-sync preserves the previous install-time seeding behavior.""" + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert "preset:self-test" in memory.read_text() + assert "[PROJECT_NAME]" not in memory.read_text() @pytest.mark.parametrize( "provenance_content", @@ -3769,6 +3878,7 @@ def test_self_test_preserves_core_content_with_existing_invalid_provenance( original = memory.read_bytes() manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) install_self_test_preset(manager) assert memory.read_bytes() == original @@ -3785,6 +3895,7 @@ def test_self_test_preserves_mutable_project_core_copy(self, project_dir): memory.write_text(authored) manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) install_self_test_preset(manager) assert memory.read_text() == authored @@ -3831,7 +3942,9 @@ def test_core_prefixed_preset_does_not_establish_generated_provenance( ) ) - PresetManager(project_dir).install_from_directory(preset_dir, "0.1.5") + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + manager.install_from_directory(preset_dir, "0.1.5") assert memory.read_text() == authored assert not (memory.parent / ".constitution-template.json").exists() @@ -3846,6 +3959,7 @@ def test_self_test_preserves_authored_constitution_with_placeholder( memory.write_text(authored) manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) install_self_test_preset(manager) assert memory.read_text() == authored @@ -3858,6 +3972,7 @@ def test_self_test_preserves_authored_constitution(self, project_dir): memory.write_text(authored) manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) install_self_test_preset(manager) assert memory.read_text() == authored, "authored constitution was overwritten" @@ -3915,6 +4030,7 @@ def test_constitution_seed_composes_wrap_strategy(self, project_dir, temp_dir): ) manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) manager.install_from_directory(preset_dir, "0.1.5") memory = project_dir / ".specify" / "memory" / "constitution.md" @@ -3928,6 +4044,7 @@ def test_constitution_follows_priority_when_winning_preset_removed( ): """An unchanged generated constitution follows priority and fallback layers.""" manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) install_self_test_preset(manager) preset_dir = temp_dir / "higher-priority" @@ -3975,6 +4092,7 @@ def test_convention_constitution_removal_restores_remaining_layer( ): """Removing a convention layer rematerializes the remaining resolver layer.""" manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) install_self_test_preset(manager) manager.install_from_directory( _make_convention_constitution_preset(temp_dir), "0.1.5", priority=1 @@ -3996,6 +4114,7 @@ def test_convention_constitution_removal_preserves_edited_content( templates_dir = project_dir / ".specify" / "templates" (templates_dir / "constitution-template.md").write_text("# Core Constitution\n") manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) manager.install_from_directory( _make_convention_constitution_preset(temp_dir), "0.1.5" ) @@ -4013,6 +4132,7 @@ def test_custom_constitution_removal_recovers_with_invalid_manifest( ): """Provenance triggers fallback when a custom-path manifest is invalid.""" manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) install_self_test_preset(manager) preset_dir = temp_dir / "custom-constitution" @@ -4073,9 +4193,9 @@ def test_constitution_seed_rejects_symlinked_memory_directory( manager = PresetManager(project_dir) with pytest.warns(UserWarning, match="symlinked"): - install_self_test_preset(manager) + install_constitution_sync_preset(manager) - assert manager.registry.is_installed("self-test") + assert manager.registry.is_installed("constitution-sync") assert not (outside / "constitution.md").exists() def test_constitution_seed_rejects_dangling_destination_symlink( @@ -4092,9 +4212,9 @@ def test_constitution_seed_rejects_dangling_destination_symlink( manager = PresetManager(project_dir) with pytest.warns(UserWarning, match="symlinked"): - install_self_test_preset(manager) + install_constitution_sync_preset(manager) - assert manager.registry.is_installed("self-test") + assert manager.registry.is_installed("constitution-sync") assert not outside.exists() def test_constitution_materialization_error_is_nonfatal( @@ -4133,6 +4253,7 @@ def test_constitution_materialization_error_is_nonfatal( ) manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) with pytest.warns(UserWarning, match="Failed to seed constitution"): manifest = manager.install_from_directory(preset_dir, "0.1.5") @@ -4727,6 +4848,163 @@ def test_argument_hint_not_added_for_non_claude_preset_command(self, project_dir parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) assert "argument-hint" not in parsed + def test_wrap_preset_inherits_argument_hint_from_core(self, project_dir, temp_dir): + """A wrap-strategy preset that omits argument-hint must inherit it from the core template. + + Regression for issue #3991: the wrap-composition path in _register_skills + previously inherited only scripts/agent_scripts from core_frontmatter, + silently discarding argument-hint and leaking its value into description. + """ + core_arg_hint = "Describe the feature you want to specify" + preset_description = "Wrapped speckit.specify — extra project context added" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + # Place a core template that declares argument-hint + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\n" + "description: Core specify description.\n" + f'argument-hint: "{core_arg_hint}"\n' + "---\n\n" + "Core specify body.\n", + encoding="utf-8", + ) + + # Wrap preset: only declares description (no argument-hint) + preset_dir = temp_dir / "wrap-hint-preset" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.specify.md").write_text( + "---\n" + f'description: "{preset_description}"\n' + "strategy: wrap\n" + "---\n\n" + "{CORE_TEMPLATE}\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "wrap-hint-preset", + "name": "Wrap Hint Preset", + "version": "1.0.0", + "description": "Test wrap hint inheritance", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + "strategy": "wrap", + } + ] + }, + } + import yaml as _yaml + with open(preset_dir / "preset.yml", "w") as f: + _yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "1.0.0") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert skill_file.exists() + parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) + # argument-hint must be inherited from core, not dropped + assert parsed.get("argument-hint") == core_arg_hint, ( + f"argument-hint was not inherited from core; parsed={parsed}" + ) + # description must be exactly the preset's declared value, not concatenated + assert parsed["description"] == preset_description, ( + f"description was corrupted; parsed={parsed}" + ) + + def test_wrap_preset_inherits_argument_hint_for_unmapped_command(self, project_dir, temp_dir): + """Wrap inheritance must carry argument-hint for a command NOT in ARGUMENT_HINTS. + + Regression guard for issue #3991. The companion test above wraps + ``speckit.specify``, whose stem is in Claude's ``ARGUMENT_HINTS`` map, so + the string-injection fallback in ``post_process_skill_content`` re-adds + ``argument-hint`` even when wrap composition drops it — masking the bug. + This test wraps an extension-like command (``speckit.myfeature``) that is + absent from that map, so the *only* thing that can carry the hint into the + SKILL.md is the wrap-composition inheritance fix itself. Without the fix + the key is dropped and this test fails. + """ + core_arg_hint = "Custom hint that lives only on the core template" + preset_description = "Wrapped speckit.myfeature — extra project context added" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-myfeature") + + # Place a core template (extension-like command) that declares argument-hint + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "myfeature.md").write_text( + "---\n" + "description: Core myfeature description.\n" + f'argument-hint: "{core_arg_hint}"\n' + "---\n\n" + "Core myfeature body.\n", + encoding="utf-8", + ) + + # Wrap preset: only declares description (no argument-hint) + preset_dir = temp_dir / "wrap-hint-preset-unmapped" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.myfeature.md").write_text( + "---\n" + f'description: "{preset_description}"\n' + "strategy: wrap\n" + "---\n\n" + "{CORE_TEMPLATE}\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "wrap-hint-preset-unmapped", + "name": "Wrap Hint Preset Unmapped", + "version": "1.0.0", + "description": "Test wrap hint inheritance for an unmapped command", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.myfeature", + "file": "commands/speckit.myfeature.md", + "strategy": "wrap", + } + ] + }, + } + import yaml as _yaml + with open(preset_dir / "preset.yml", "w") as f: + _yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "1.0.0") + + skill_file = skills_dir / "speckit-myfeature" / "SKILL.md" + assert skill_file.exists() + parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) + # argument-hint must be inherited from core, not dropped + assert parsed.get("argument-hint") == core_arg_hint, ( + f"argument-hint was not inherited from core; parsed={parsed}" + ) + # description must be exactly the preset's declared value, not concatenated + assert parsed["description"] == preset_description, ( + f"description was corrupted; parsed={parsed}" + ) + def test_register_skills_resolves_command_refs(self, project_dir, temp_dir): """Preset skill overrides must resolve __SPECKIT_COMMAND_*__ tokens (issue #2717). @@ -9209,6 +9487,155 @@ def test_unregister_legacy_fallback_skips_non_owned_skill( "---\nname: speckit-specify\n---\n\nuser-owned content\n" ) + def test_unregister_skills_in_dir_unreadable_core_template_skips( + self, project_dir + ): + """An undecodable core template must not crash `preset remove`. + + Every other failure in the restore loop — an unsafe registry name, + a missing skill subdirectory, a foreign owner — skips the skill + with ``continue``. The core-template read was outside that + boundary, so one non-UTF-8 project-owned override in + ``.specify/templates/commands/`` raised a raw ``UnicodeDecodeError`` + straight out of ``PresetManager.remove()``, which has no handler + for it. Sibling reads of the very same directory are already + guarded (``_substitute_core_template``, the provenance reads in + ``_infer_legacy_skill_provenance``). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = project_dir / ".claude" / "skills" + skill_dir = self._create_skill( + skills_dir, "speckit-specify", "installed content" + ) + core_commands = project_dir / ".specify" / "templates" / "commands" + core_commands.mkdir(parents=True, exist_ok=True) + (core_commands / "specify.md").write_bytes( + b"---\ndescription: \xff\xfe not utf-8\n---\n\nCore body\n" + ) + + manager = PresetManager(project_dir) + with pytest.warns(UserWarning, match="speckit-specify"): + mutated = manager._unregister_skills_in_dir( + ["speckit-specify"], skills_dir, "claude" + ) + + assert mutated == [], ( + "a skill whose restore source could not be read was not " + "restored, so it must not be reported as mutated" + ) + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( + "---\nname: speckit-specify\n---\n\ninstalled content\n" + ), ( + "an unreadable core template must leave the skill untouched — " + "falling through to the rmtree branch would delete it exactly " + "when its replacement cannot be generated" + ) + + def test_unregister_skills_in_dir_unreadable_core_template_oserror_skips( + self, project_dir, monkeypatch + ): + """The same boundary must cover ``OSError`` (e.g. permission denied). + + Mocked rather than chmod-based so the case also holds under + privileged CI, where permission bits are not enforced. + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = project_dir / ".claude" / "skills" + skill_dir = self._create_skill( + skills_dir, "speckit-specify", "installed content" + ) + core_commands = project_dir / ".specify" / "templates" / "commands" + core_commands.mkdir(parents=True, exist_ok=True) + core_template = core_commands / "specify.md" + core_template.write_text( + "---\ndescription: Core specify\n---\n\nCore body\n", + encoding="utf-8", + ) + + original_read_text = Path.read_text + + def failing_read_text(self_path, *args, **kwargs): + if self_path == core_template: + raise PermissionError(13, "Permission denied") + return original_read_text(self_path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", failing_read_text) + + manager = PresetManager(project_dir) + with pytest.warns(UserWarning, match="speckit-specify"): + mutated = manager._unregister_skills_in_dir( + ["speckit-specify"], skills_dir, "claude" + ) + + monkeypatch.undo() + + assert mutated == [] + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( + "---\nname: speckit-specify\n---\n\ninstalled content\n" + ) + + def test_unregister_skills_in_dir_unreadable_extension_source_skips( + self, project_dir + ): + """The extension-restore arm needs the same boundary as the core arm. + + The two restore reads are independent branches — a skill backed by an + installed extension never reaches the core-template read — so this + half of the guard can regress on its own. An undecodable extension + command file must warn, leave the skill byte-for-byte intact, and stay + out of ``mutated_names``. + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = project_dir / ".claude" / "skills" + skill_dir = self._create_skill( + skills_dir, "speckit-fakeext-cmd", "installed content" + ) + + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + (extension_dir / "commands" / "cmd.md").write_bytes( + b"---\ndescription: \xff\xfe not utf-8\n---\n\nExtension body\n" + ) + extension_manifest = { + "schema_version": "1.0", + "extension": { + "id": "fakeext", + "name": "Fake Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.fakeext.cmd", + "file": "commands/cmd.md", + "description": "Fake extension command", + } + ] + }, + } + with open(extension_dir / "extension.yml", "w") as f: + yaml.dump(extension_manifest, f) + + manager = PresetManager(project_dir) + with pytest.warns(UserWarning, match="speckit-fakeext-cmd"): + mutated = manager._unregister_skills_in_dir( + ["speckit-fakeext-cmd"], skills_dir, "claude" + ) + + assert mutated == [], ( + "a skill whose extension restore source could not be read was " + "not restored, so it must not be reported as mutated" + ) + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( + "---\nname: speckit-fakeext-cmd\n---\n\ninstalled content\n" + ), ( + "an unreadable extension source must leave the skill untouched — " + "falling through to the rmtree branch would delete it exactly " + "when its replacement cannot be generated" + ) + def test_unregister_skills_in_dir_rejects_absolute_registry_name( self, project_dir ): @@ -9664,6 +10091,7 @@ def test_set_priority_reconciles_generated_constitution( from specify_cli import app manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) install_self_test_preset(manager) manager.install_from_directory( _make_convention_constitution_preset(temp_dir), "0.1.5", priority=20 @@ -9909,6 +10337,7 @@ def test_enable_disable_reconciles_generated_constitution( from specify_cli import app manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) install_self_test_preset(manager) manager.install_from_directory( _make_convention_constitution_preset(temp_dir), "0.1.5", priority=1 @@ -10129,6 +10558,29 @@ def test_constitution_commands_guard_against_non_governance_work(command_path): assert "do not invoke it" in normalized_content or "without invoking it" in normalized_content +def test_core_constitution_command_resolves_template_at_runtime(): + """The core command must consume the composed scaffold on every invocation.""" + content = CORE_CONSTITUTION_COMMAND.read_text() + + assert "resolve-template.sh constitution-template --json" in content + assert "resolve-template.ps1 constitution-template -Json" in content + assert "resolve_template.py constitution-template --json" in content + assert "parse `TEMPLATE_CONTENT` as the active template" in content + assert "do not continue with only one contributing" in content + assert "Do not write back to any versioned template layer" in content + + +def test_core_checklist_command_resolves_template_at_runtime(): + """The checklist command must consume the composed scaffold.""" + content = (CORE_CONSTITUTION_COMMAND.parent / "checklist.md").read_text( + encoding="utf-8" + ) + + assert "--template checklist-template" in content + assert "TEMPLATE_CONTENT" in content + assert "Use TEMPLATE_CONTENT as the structural template" in content + + class TestLeanPreset: """Tests for the lean preset that ships with the repo.""" @@ -10825,6 +11277,35 @@ def test_substitute_core_template_no_op_when_core_missing(self, project_dir): assert "{CORE_TEMPLATE}" in result assert core_fm == {} + def test_substitute_core_template_unreadable_core_treated_as_missing( + self, project_dir + ): + """An undecodable core template must not crash substitution. + + The wrap-strategy callers (``CommandRegistrar.register_pack`` and + ``_register_commands``) skip an unreadable preset source with a + warning, but the core template read inside + ``_substitute_core_template`` had no boundary, so one corrupted + project-owned override in ``.specify/templates/commands/`` crashed + the whole registration with a raw ``UnicodeDecodeError``. An + unreadable core is treated like a missing one. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_bytes(b"\xff\xfe not utf-8") + + registrar = CommandRegistrar() + body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" + with pytest.warns(UserWarning, match="Ignoring core template"): + result, core_fm = _substitute_core_template( + body, "specify", project_dir, registrar + ) + assert result == body + assert core_fm == {} + def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir): """register_commands substitutes {CORE_TEMPLATE} when strategy: wrap.""" from specify_cli.agents import CommandRegistrar @@ -11255,6 +11736,181 @@ def test_extension_command_resolves_via_manifest_when_filename_differs(self, pro assert "# Selftest Core" in result assert "{CORE_TEMPLATE}" not in result + def test_extension_template_resolves_via_manifest_when_filename_differs(self, project_dir): + """provides.templates entries resolve via extension.yml when the file + doesn't sit at the conventional path. + + Regression coverage for #4010: manifest-declared templates/scripts + must actually be consulted by the resolver, not just accepted by + manifest validation. + """ + ext_dir = project_dir / ".specify" / "extensions" / "reportext" + tmpl_dir = ext_dir / "templates" / "nested" + tmpl_dir.mkdir(parents=True, exist_ok=True) + + # File lives at a path convention-based lookup (templates/.md) + # would never find. + (tmpl_dir / "actual.md").write_text("# Report Scaffold\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: reportext\n name: Report Ext\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " templates:\n" + " - name: report-scaffold\n" + " file: templates/nested/actual.md\n" + " description: Report scaffold\n" + ) + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("report-scaffold", "template") + assert layers, "expected the manifest-declared template to resolve" + assert layers[0]["path"] == tmpl_dir / "actual.md" + assert layers[0]["strategy"] == "replace" + + def test_extension_script_resolves_via_manifest_when_filename_differs(self, project_dir): + """provides.scripts entries resolve via extension.yml when the file + doesn't sit at the conventional path.""" + ext_dir = project_dir / ".specify" / "extensions" / "collectext" + script_dir = ext_dir / "scripts" / "bash" + script_dir.mkdir(parents=True, exist_ok=True) + + # File is under scripts/bash/, not directly under scripts/, so + # convention-based lookup (scripts/.sh) would never find it. + (script_dir / "collect.sh").write_text("#!/usr/bin/env bash\necho collect\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: collectext\n name: Collect Ext\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " scripts:\n" + " - name: myext-collect\n" + " file: scripts/bash/collect.sh\n" + " description: Data-collection helper\n" + " runtimes: [bash]\n" + ) + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("myext-collect", "script") + assert layers, "expected the manifest-declared script to resolve" + assert layers[0]["path"] == script_dir / "collect.sh" + assert layers[0]["strategy"] == "replace" + + def test_extension_template_convention_lookup_unaffected_when_undeclared(self, project_dir): + """An extension template with no manifest entry still resolves via + the pre-existing filename convention (no regression).""" + ext_dir = project_dir / ".specify" / "extensions" / "conventionext" + tmpl_dir = ext_dir / "templates" + tmpl_dir.mkdir(parents=True, exist_ok=True) + (tmpl_dir / "legacy-template.md").write_text("# Legacy Template\n") + # No extension.yml at all -- purely convention-based, unregistered extension. + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("legacy-template", "template") + assert layers, "expected convention-based lookup to still find the template" + assert layers[0]["path"] == tmpl_dir / "legacy-template.md" + + def test_extension_manifest_wins_over_stale_conventional_file(self, project_dir): + """A declared entry is authoritative even when a stale file also sits at + the conventional path (templates/.md) — the manifest must win, + not the convention lookup, per #4010's acceptance criteria.""" + ext_dir = project_dir / ".specify" / "extensions" / "bothpathsext" + (ext_dir / "templates").mkdir(parents=True, exist_ok=True) + (ext_dir / "custom").mkdir(parents=True, exist_ok=True) + + # Stale file at the conventional path -- must NOT win. + (ext_dir / "templates" / "report-scaffold.md").write_text("# Stale\n") + # Declared file at a non-conventional path -- must win. + (ext_dir / "custom" / "bar.md").write_text("# Actual\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: bothpathsext\n name: Both Paths Ext\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " templates:\n" + " - name: report-scaffold\n" + " file: custom/bar.md\n" + " description: Report scaffold\n" + ) + + resolver = PresetResolver(project_dir) + + layers = resolver.collect_all_layers("report-scaffold", "template") + assert layers, "expected the manifest-declared template to resolve" + assert layers[0]["path"] == ext_dir / "custom" / "bar.md" + + resolved = resolver.resolve("report-scaffold", "template") + assert resolved == ext_dir / "custom" / "bar.md" + + with_source = resolver.resolve_with_source("report-scaffold", "template") + assert with_source["path"] == str(ext_dir / "custom" / "bar.md") + + def test_extension_manifest_declared_but_missing_file_does_not_fall_back(self, project_dir): + """A declared entry whose file is missing is authoritative -- the + resolver must not silently mask the typo by falling back to a + conventional file that happens to also exist.""" + ext_dir = project_dir / ".specify" / "extensions" / "missingfileext" + (ext_dir / "scripts").mkdir(parents=True, exist_ok=True) + + # A conventional file exists, but the manifest declares a different, + # non-existent file for the same name. + (ext_dir / "scripts" / "myext-collect.sh").write_text("#!/usr/bin/env bash\necho legacy\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: missingfileext\n name: Missing File Ext\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " scripts:\n" + " - name: myext-collect\n" + " file: scripts/does-not-exist.sh\n" + " description: Data-collection helper\n" + ) + + resolver = PresetResolver(project_dir) + + assert resolver.collect_all_layers("myext-collect", "script") == [] + assert resolver.resolve("myext-collect", "script") is None + + def test_extension_script_resolve_and_resolve_with_source_parity(self, project_dir): + """resolve() and resolve_with_source() must find a manifest-declared + script at a non-conventional path, matching collect_all_layers().""" + ext_dir = project_dir / ".specify" / "extensions" / "collectext2" + script_dir = ext_dir / "scripts" / "bash" + script_dir.mkdir(parents=True, exist_ok=True) + + (script_dir / "collect.sh").write_text("#!/usr/bin/env bash\necho collect\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: collectext2\n name: Collect Ext 2\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " scripts:\n" + " - name: myext-collect2\n" + " file: scripts/bash/collect.sh\n" + " description: Data-collection helper\n" + " runtimes: [bash]\n" + ) + + resolver = PresetResolver(project_dir) + + resolved = resolver.resolve("myext-collect2", "script") + assert resolved == script_dir / "collect.sh" + + with_source = resolver.resolve_with_source("myext-collect2", "script") + assert with_source is not None + assert with_source["path"] == str(script_dir / "collect.sh") + assert with_source["source"] == "extension:collectext2 (unregistered)" + # ===== _replay_wraps_for_command Tests ===== @@ -12453,10 +13109,10 @@ def fake_open(url, timeout=None, extra_headers=None): class TestEnsureConstitutionResolverAware: """`ensure_constitution_from_template` must resolve through PresetResolver. - The constitution is the only template materialized to a live file rather - than resolved on demand. These tests pin the regression from issue #3272: - a preset-provided ``constitution-template`` must seed memory, while the - core template is used when no preset overrides it. + Init materializes the live constitution once, while later /constitution + runs resolve on demand. These tests pin the regression from issue #3272: + a preset-provided ``constitution-template`` must win during the init seed, + while the core template is used when no preset overrides it. """ def _core_constitution(self, project_dir): @@ -12517,10 +13173,8 @@ def test_seeds_from_preset_when_installed(self, project_dir): manager = PresetManager(project_dir) install_self_test_preset(manager) - # Remove the memory file seeded during install to test ensure() in - # isolation; it must re-seed from the preset, not the core template. memory = project_dir / ".specify" / "memory" / "constitution.md" - memory.unlink() + assert not memory.exists() ensure_constitution_from_template(project_dir) @@ -12563,9 +13217,8 @@ def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir): manager = PresetManager(project_dir) manager.install_from_directory(self._wrap_constitution_preset(temp_dir), "0.1.5") - # Ensure we validate ensure() behavior directly. memory = project_dir / ".specify" / "memory" / "constitution.md" - memory.unlink() + assert not memory.exists() ensure_constitution_from_template(project_dir) content = memory.read_text() @@ -12934,11 +13587,44 @@ def test_unbalanced_markup_does_not_crash_list_or_info(self, temp_dir, project_d assert result.exit_code == 0, (args, result.output, result.exception) assert "Broken [/red] tag" in strip_ansi(result.output) - def test_resolve_escapes_template_name(self, project_dir): - """``preset resolve`` echoes its argument; an unbalanced tag must not crash.""" + def test_resolve_rejects_invalid_template_name(self, project_dir): + """``preset resolve`` rejects names before joining them into paths.""" result = self._invoke(project_dir, ["preset", "resolve", "no[/red]such"]) + assert result.exit_code == 1, (result.output, result.exception) + assert "invalid template name" in strip_ansi(result.output) + + def test_resolve_rejects_path_traversal(self, project_dir): + """The resolver rejects traversal before joining names into paths.""" + result = self._invoke( + project_dir, + ["preset", "resolve", "../../../README"], + ) + + assert result.exit_code == 1 + assert "invalid template name" in strip_ansi(result.output) + + def test_resolve_accepts_dotted_command_name(self, project_dir): + """Documented dotted command identifiers use command resolution.""" + result = self._invoke( + project_dir, + ["preset", "resolve", "speckit.constitution"], + ) + assert result.exit_code == 0, (result.output, result.exception) - assert "no[/red]such" in strip_ansi(result.output) + # Strip ALL whitespace so the assertion is stable when Rich wraps + # long runner worktree paths (e.g. Windows CI) mid-word across lines. + normalized = "".join(strip_ansi(result.output).split()) + assert "constitution.md" in normalized + + def test_resolve_rejects_empty_command_segments(self, project_dir): + """Dotted command identifiers cannot contain empty path-like segments.""" + result = self._invoke( + project_dir, + ["preset", "resolve", "speckit..constitution"], + ) + + assert result.exit_code == 1 + assert "invalid template name" in strip_ansi(result.output) def test_resolve_escapes_layer_path_and_source(self, project_dir): """The top-layer path/source lines must render markup literally. @@ -13032,14 +13718,13 @@ def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir assert "[base]" in output, output assert "[append]" in output, output - class TestConstitutionSyncPreset: - """The bundled opt-in ``constitution-sync`` preset re-adds propagation. + """The bundled opt-in ``constitution-sync`` preset re-adds materialization. Follow-up to #3790: core ``/constitution`` no longer propagates guidance - into templates. This preset restores that behavior for teams that treat - materialized templates as reviewed artifacts, delivered as a ``wrap`` of - the core command so it stays forward-compatible with core changes. + into templates. Issue #3950 also gates install-time constitution seeding on + this preset. Its command override remains a ``wrap`` of core so it stays + forward-compatible with core changes. """ PRESET_DIR = Path(__file__).parent.parent / "presets" / "constitution-sync" diff --git a/tests/test_resolve_template_python_parity.py b/tests/test_resolve_template_python_parity.py new file mode 100644 index 0000000000..9af5554b44 --- /dev/null +++ b/tests/test_resolve_template_python_parity.py @@ -0,0 +1,753 @@ +"""Parity tests for composed runtime template resolution.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash +from tests.parity_helpers import ( + HAS_POWERSHELL, + bash_cmd, + clean_env, + install_composition_stack, + install_scripts, + json_stdout, + make_repo, + ps_cmd, + py_cmd, + run, +) + +SCRIPT = "resolve-template" +TEMPLATE = "constitution-template" + + +def _setup_repo(tmp_path: Path) -> tuple[Path, str]: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + expected = install_composition_stack(repo, TEMPLATE, "# Core\n") + return repo, expected + + +@requires_bash +def test_all_variants_emit_composed_template_content(tmp_path: Path) -> None: + repo, expected = _setup_repo(tmp_path) + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode == 0 for result in results) + assert all(result.stderr == "" for result in results) + assert all( + json_stdout(result) + == {"TEMPLATE_NAME": TEMPLATE, "TEMPLATE_CONTENT": expected} + for result in results + ) + + +@requires_bash +@pytest.mark.parametrize( + "without_registry,core_content", + [ + (True, "# Core\n"), + (False, "# Café ✓\n"), + ], + ids=["directory_fallback", "unicode"], +) +def test_all_variants_preserve_composition_parity( + tmp_path: Path, without_registry: bool, core_content: str +) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + expected = install_composition_stack(repo, TEMPLATE, core_content) + if without_registry: + (repo / ".specify" / "presets" / ".registry").unlink() + expected = ( + "# Prepended\n\n\n" + "## Wrapper\n" + f"{core_content}\n" + "## End\n\n\n" + "# Appended\n" + ) + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode == 0 for result in results) + 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, +) -> None: + """Registry/manifest reads must force UTF-8, not the process locale. + + With UTF-8 mode disabled and a C locale, the interpreter's default text + encoding is ASCII. Non-ASCII *metadata* in the registry or a manifest must + still resolve, because the resolvers open those files as UTF-8 explicitly. + Template content stays ASCII so the pure-Python variant can emit it on the + ASCII stdout this configuration forces. + """ + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + expected = install_composition_stack(repo, TEMPLATE, "# Core\n") + + # Inject non-ASCII metadata into the preset registry and a manifest so a + # locale-dependent decode would raise instead of resolving cleanly. + registry = repo / ".specify" / "presets" / ".registry" + registry_data = json.loads(registry.read_text(encoding="utf-8")) + registry_data["presets"]["wrap-pack"]["description"] = "Café ✓ wrapper" + registry.write_text( + json.dumps(registry_data, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + manifest = repo / ".specify" / "presets" / "wrap-pack" / "preset.yml" + manifest.write_text( + manifest.read_text(encoding="utf-8") + ' description: "Café ✓"\n', + encoding="utf-8", + ) + + env = clean_env() + # Force the interpreter's default text encoding to ASCII so an unqualified + # open() would fail on the non-ASCII metadata above. + env["PYTHONUTF8"] = "0" + env["PYTHONCOERCECLOCALE"] = "0" + env["LC_ALL"] = "C" + env["LANG"] = "C" + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, env)) + + assert all(result.returncode == 0 for result in results) + assert all( + json_stdout(result)["TEMPLATE_CONTENT"] == expected + for result in results + ) + + +@requires_bash +@pytest.mark.parametrize( + "template_name", + ["missing-template", "../../../outside"], + ids=["missing", "path_traversal"], +) +def test_all_variants_reject_unresolvable_template( + tmp_path: Path, template_name: str +) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + (repo / "outside.md").write_text("sensitive content\n", encoding="utf-8") + + results = [ + run(bash_cmd(repo, SCRIPT, template_name, "--json"), repo), + run(py_cmd(repo, SCRIPT, template_name, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, template_name, "-Json"), repo)) + + assert all(result.returncode == 1 for result in results) + assert all(result.stdout == "" for result in results) + assert all("sensitive content" not in result.stderr for result in results) + + +@requires_bash +def test_all_variants_ignore_traversing_preset_registry_ids(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + outside = repo.parent / "outside" + outside.mkdir() + (outside / f"{TEMPLATE}.md").write_text("sensitive content\n", encoding="utf-8") + presets = repo / ".specify" / "presets" + presets.mkdir(parents=True) + (presets / ".registry").write_text( + '{"presets":{"../../../outside":{"enabled":true,"priority":1}}}\n', + encoding="utf-8", + ) + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode == 1 for result in results) + assert all("sensitive content" not in result.stdout for result in results) + + +@requires_bash +def test_all_variants_support_root_level_preset_convention(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + preset = repo / ".specify" / "presets" / "root-pack" + preset.mkdir(parents=True) + (preset / f"{TEMPLATE}.md").write_text("# Root convention\n", encoding="utf-8") + (repo / ".specify" / "presets" / ".registry").write_text( + '{"presets":{"root-pack":{"enabled":true,"priority":1}}}\n', + encoding="utf-8", + ) + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode == 0 for result in results) + assert all( + json_stdout(result)["TEMPLATE_CONTENT"] == "# Root convention\n" + for result in results + ) + + +@requires_bash +def test_all_variants_honor_extension_registry_state_and_priority( + tmp_path: Path, +) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + extensions = repo / ".specify" / "extensions" + for extension_id, content in ( + ("disabled-ext", "# Disabled\n"), + ("low-priority", "# Low priority\n"), + ("high-priority", "# High priority\n"), + ): + template_dir = extensions / extension_id / "templates" + template_dir.mkdir(parents=True) + (template_dir / f"{TEMPLATE}.md").write_text(content, encoding="utf-8") + (extensions / ".registry").write_text( + '{"extensions":{' + '"disabled-ext":{"enabled":null,"priority":1},' + '"low-priority":{"enabled":true,"priority":20},' + '"high-priority":{"enabled":true,"priority":5}' + "}}\n", + encoding="utf-8", + ) + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode == 0 for result in results) + assert all( + json_stdout(result)["TEMPLATE_CONTENT"] == "# High priority\n" + for result in results + ) + + +@requires_bash +def test_all_variants_support_root_level_extension_convention( + tmp_path: Path, +) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + extension = repo / ".specify" / "extensions" / "root-extension" + extension.mkdir(parents=True) + (extension / f"{TEMPLATE}.md").write_text( + "# Root extension\n", + encoding="utf-8", + ) + (repo / ".specify" / "extensions" / ".registry").write_text( + '{"extensions":{"root-extension":{"enabled":true,"priority":1}}}\n', + encoding="utf-8", + ) + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode == 0 for result in results) + assert all( + json_stdout(result)["TEMPLATE_CONTENT"] == "# Root extension\n" + for result in results + ) + + +@requires_bash +def test_all_variants_treat_extension_registry_ids_case_sensitively( + tmp_path: Path, +) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + extension = repo / ".specify" / "extensions" / "foo" / "templates" + extension.mkdir(parents=True) + (extension / f"{TEMPLATE}.md").write_text( + "# Lowercase extension\n", + encoding="utf-8", + ) + (repo / ".specify" / "extensions" / ".registry").write_text( + '{"extensions":{"FOO":{"enabled":true,"priority":1}}}\n', + encoding="utf-8", + ) + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode == 0 for result in results) + assert all( + json_stdout(result)["TEMPLATE_CONTENT"] == "# Lowercase extension\n" + for result in results + ) + + +@requires_bash +@pytest.mark.parametrize( + "registry_content", + ["{ not valid json", '{"extensions":[]}\n', "[]\n"], + ids=["invalid_json", "non_mapping_extensions", "non_mapping_root"], +) +def test_all_variants_fail_for_malformed_extension_registry( + tmp_path: Path, registry_content: str +) -> None: + """A corrupt extension registry must fail closed, not silently enable + every on-disk extension directory as unregistered.""" + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + extensions = repo / ".specify" / "extensions" + template_dir = extensions / "sneaky-ext" / "templates" + template_dir.mkdir(parents=True) + (template_dir / f"{TEMPLATE}.md").write_text( + "# Should not be served\n", encoding="utf-8" + ) + (extensions / ".registry").write_text(registry_content, encoding="utf-8") + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode != 0 for result in results) + assert all(result.stdout == "" for result in results) + assert all( + "Should not be served" not in result.stdout for result in results + ) + + +@requires_bash +def test_all_variants_fail_when_registry_is_a_directory( + tmp_path: Path, +) -> None: + """A directory at the extension registry path must fail closed, not be + treated as an absent registry that enables every on-disk extension.""" + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + extensions = repo / ".specify" / "extensions" + template_dir = extensions / "sneaky-ext" / "templates" + template_dir.mkdir(parents=True) + (template_dir / f"{TEMPLATE}.md").write_text( + "# Should not be served\n", encoding="utf-8" + ) + # Create ``.registry`` as a directory rather than a regular file. + (extensions / ".registry").mkdir() + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode != 0 for result in results) + assert all(result.stdout == "" for result in results) + + +@requires_bash +def test_all_variants_fail_when_registry_is_broken_symlink( + tmp_path: Path, +) -> None: + """A broken symlink at the extension registry path must fail closed across + Bash, Python, and PowerShell resolvers rather than being treated as absent.""" + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + extensions = repo / ".specify" / "extensions" + template_dir = extensions / "sneaky-ext" / "templates" + template_dir.mkdir(parents=True) + (template_dir / f"{TEMPLATE}.md").write_text( + "# Should not be served\n", encoding="utf-8" + ) + (extensions / ".registry").symlink_to(extensions / "does-not-exist") + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode != 0 for result in results) + assert all(result.stdout == "" for result in results) + + +@requires_bash +@pytest.mark.parametrize("base_kind", ["override", "preset"]) +def test_all_variants_ignore_malformed_layers_below_replace_base( + tmp_path: Path, + base_kind: str, +) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + expected = "# Winning base\r\nBody\r\n" + presets = repo / ".specify" / "presets" + + if base_kind == "override": + override = repo / ".specify" / "templates" / "overrides" + override.mkdir(parents=True) + (override / f"{TEMPLATE}.md").write_bytes(expected.encode("utf-8")) + registry = {"presets": {"broken-pack": {"enabled": True, "priority": 1}}} + else: + winning = presets / "winning-pack" / "templates" + winning.mkdir(parents=True) + (winning / f"{TEMPLATE}.md").write_bytes(expected.encode("utf-8")) + registry = { + "presets": { + "winning-pack": {"enabled": True, "priority": 1}, + "broken-pack": {"enabled": True, "priority": 2}, + } + } + + broken = presets / "broken-pack" + broken.mkdir(parents=True) + (broken / "preset.yml").write_text("provides: [\n", encoding="utf-8") + (presets / ".registry").write_text( + json.dumps(registry, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode == 0 for result in results) + assert all( + json_stdout(result)["TEMPLATE_CONTENT"] == expected + for result in results + ) + + +@requires_bash +@pytest.mark.parametrize( + ("entries", "expected"), + [ + ( + [ + ("disabled-pack", {"enabled": False, "priority": 0}), + ("numeric-pack", {"enabled": True, "priority": 2}), + ("string-pack", {"enabled": True, "priority": "1"}), + ], + "# string-pack\n", + ), + ( + [ + ("z-pack", {"enabled": True}), + ("a-pack", {"enabled": True}), + ], + "# a-pack\n", + ), + ( + [ + ("float-pack", {"enabled": True, "priority": 5.9}), + ("six-pack", {"enabled": True, "priority": 6}), + ], + "# float-pack\n", + ), + ( + [ + ("a-huge-pack", {"enabled": True, "priority": 2147483648}), + ("z-default-pack", {"enabled": True, "priority": "invalid"}), + ], + "# z-default-pack\n", + ), + ( + [ + ("decimal-string-pack", {"enabled": True, "priority": "5.9"}), + ("exponent-string-pack", {"enabled": True, "priority": "1e3"}), + ("hex-string-pack", {"enabled": True, "priority": "0x10"}), + ("six-pack", {"enabled": True, "priority": 6}), + ], + "# six-pack\n", + ), + ], + ids=[ + "mixed_priorities", + "equal_priority_id_tiebreaker", + "float_priority", + "large_integer_priority", + "non_integer_numeric_strings", + ], +) +def test_all_variants_normalize_and_tiebreak_preset_priorities( + tmp_path: Path, + entries: list[tuple[str, dict[str, object]]], + expected: str, +) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + presets = repo / ".specify" / "presets" + registry: dict[str, object] = {"presets": {}} + registry_presets = registry["presets"] + assert isinstance(registry_presets, dict) + for preset_id, metadata in entries: + template_dir = presets / preset_id / "templates" + template_dir.mkdir(parents=True) + (template_dir / f"{TEMPLATE}.md").write_text( + f"# {preset_id}\n", + encoding="utf-8", + ) + registry_presets[preset_id] = metadata + (presets / ".registry").write_text( + json.dumps(registry, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode == 0 for result in results) + assert all( + json_stdout(result)["TEMPLATE_CONTENT"] == expected + for result in results + ) + + +@requires_bash +def test_all_variants_fail_when_wrap_placeholder_is_missing( + tmp_path: Path, +) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + templates = repo / ".specify" / "templates" + templates.mkdir(parents=True) + (templates / f"{TEMPLATE}.md").write_text("# Core\n", encoding="utf-8") + preset = repo / ".specify" / "presets" / "wrap-pack" + (preset / "templates").mkdir(parents=True) + (preset / "templates" / f"{TEMPLATE}.md").write_text( + "# Broken wrapper\n", encoding="utf-8" + ) + (preset / "preset.yml").write_text( + "provides:\n" + " templates:\n" + " - type: template\n" + f" name: {TEMPLATE}\n" + f" file: templates/{TEMPLATE}.md\n" + " strategy: wrap\n", + encoding="utf-8", + ) + (repo / ".specify" / "presets" / ".registry").write_text( + '{"presets":{"wrap-pack":{"enabled":true,"priority":1}}}\n', + encoding="utf-8", + ) + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode != 0 for result in results) + assert all(result.stdout == "" for result in results) + + +@requires_bash +def test_all_variants_fail_when_yaml_parser_is_unavailable( + tmp_path: Path, +) -> None: + repo, _ = _setup_repo(tmp_path) + blocker = tmp_path / "blocker" + blocker.mkdir() + (blocker / "yaml.py").write_text( + "raise ImportError('simulated missing PyYAML')\n", + encoding="utf-8", + ) + env = clean_env() + env["PYTHONPATH"] = str(blocker) + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env), + ] + if HAS_POWERSHELL: + results.append( + run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, env) + ) + + assert all(result.returncode != 0 for result in results) + assert all(result.stdout == "" for result in results) + + +@requires_bash +def test_bash_fails_when_override_read_fails(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + override = repo / ".specify" / "templates" / "overrides" + override.mkdir(parents=True) + (override / f"{TEMPLATE}.md").write_text("# Override\n", encoding="utf-8") + shim_dir = tmp_path / "bin" + shim_dir.mkdir() + cat_shim = shim_dir / "cat" + cat_shim.write_text( + "#!/bin/sh\n" + "case \"$1\" in\n" + " */.specify/templates/overrides/*) exit 1 ;;\n" + "esac\n" + "exec /bin/cat \"$@\"\n", + encoding="utf-8", + ) + cat_shim.chmod(0o755) + env = clean_env() + env["PATH"] = f"{shim_dir}{os.pathsep}{env.get('PATH', '')}" + + result = run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env) + + assert result.returncode != 0 + assert result.stdout == "" + + +@requires_bash +@pytest.mark.parametrize( + "manifest_content", + [ + "provides: [\n", + "", + "provides:\n templates:\n - null\n", + "provides:\n templates: {}\n", + "preset:\n id: wrap-pack\n", + "provides:\n templates: []\n", + f"""provides: + templates: + - type: template + name: {TEMPLATE} + file: null + strategy: wrap +""", + f"""provides: + templates: + - type: template + name: {TEMPLATE} + file: templates/{TEMPLATE}.md + strategy: 123 +""", + f"""provides: + templates: + - type: template + name: {TEMPLATE} + file: templates/{TEMPLATE}.md + strategy: wrap + - type: template + name: unrelated-template + file: null + strategy: append +""", + f"""provides: + templates: + - name: {TEMPLATE} + file: templates/{TEMPLATE}.md + strategy: wrap + - type: template + name: unrelated-template + file: templates/other.md +""", + f"""provides: + templates: + - type: template + name: {TEMPLATE} + file: templates/{TEMPLATE}.md + strategy: wrap + - type: template + name: unrelated-template +""", + f"""provides: + templates: + - type: template + name: {TEMPLATE} + file: templates/{TEMPLATE}.md + strategy: wrap + - type: bogus + name: unrelated-template + file: templates/other.md +""", + f"""provides: + templates: + - type: template + name: {TEMPLATE} + file: templates/{TEMPLATE}.md + strategy: wrap + - type: template + name: unrelated-template + file: templates/other.md + strategy: merge +""", + ], + ids=[ + "invalid_yaml", + "empty_document", + "non_mapping_template_entry", + "non_list_templates", + "missing_provides", + "empty_templates", + "non_string_file", + "non_string_strategy", + "malformed_entry_after_match", + "entry_missing_type", + "entry_missing_file", + "unsupported_type", + "unsupported_strategy", + ], +) +def test_all_variants_fail_for_malformed_preset_manifest( + tmp_path: Path, + manifest_content: str, +) -> None: + repo, _ = _setup_repo(tmp_path) + ( + repo / ".specify" / "presets" / "wrap-pack" / "preset.yml" + ).write_text(manifest_content, encoding="utf-8") + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo)) + + assert all(result.returncode != 0 for result in results) + assert all(result.stdout == "" for result in results) diff --git a/tests/test_setup_plan_python_parity.py b/tests/test_setup_plan_python_parity.py index 9d9a67b620..e8372125a3 100644 --- a/tests/test_setup_plan_python_parity.py +++ b/tests/test_setup_plan_python_parity.py @@ -11,7 +11,9 @@ HAS_POWERSHELL, POWERSHELL_EXE, bash_cmd, + break_wrap_layer, clean_env, + install_composition_stack, install_scripts, json_stdout, make_repo, @@ -60,7 +62,57 @@ def test_python_fresh_copy_matches_bash(tmp_path: Path) -> None: ) for repo in (repo_a, repo_b): plan = repo / "specs" / "001-my-feature" / "plan.md" - assert plan.read_text(encoding="utf-8") == TEMPLATE_BODY + assert plan.read_bytes() == TEMPLATE_BODY.encode("utf-8") + + +@requires_bash +def test_all_variants_materialize_composed_plan_template(tmp_path: Path) -> None: + repos = [ + _setup_repo(tmp_path, "bash"), + _setup_repo(tmp_path, "powershell"), + _setup_repo(tmp_path, "python"), + ] + expected = "" + for current in repos: + expected = install_composition_stack( + current, "plan-template", TEMPLATE_BODY + ) + + results = [ + run(bash_cmd(repos[0], SCRIPT, "--json"), repos[0]), + run(py_cmd(repos[2], SCRIPT, "--json"), repos[2]), + ] + checked_repos = [repos[0], repos[2]] + if HAS_POWERSHELL: + results.insert(1, run(ps_cmd(repos[1], SCRIPT, "-Json"), repos[1])) + checked_repos.insert(1, repos[1]) + + assert all(result.returncode == 0 for result in results) + for current in checked_repos: + assert ( + current / "specs" / "001-my-feature" / "plan.md" + ).read_text(encoding="utf-8") == expected + + +@requires_bash +def test_all_variants_fail_for_broken_plan_composition(tmp_path: Path) -> None: + repos = [ + _setup_repo(tmp_path, "bash"), + _setup_repo(tmp_path, "powershell"), + _setup_repo(tmp_path, "python"), + ] + for current in repos: + install_composition_stack(current, "plan-template", TEMPLATE_BODY) + break_wrap_layer(current, "plan-template") + + results = [ + run(bash_cmd(repos[0], SCRIPT, "--json"), repos[0]), + run(py_cmd(repos[2], SCRIPT, "--json"), repos[2]), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repos[1], SCRIPT, "-Json"), repos[1])) + + assert all(result.returncode != 0 for result in results) @requires_bash @@ -119,13 +171,19 @@ def test_python_missing_template_matches_bash(tmp_path: Path) -> None: @requires_bash @pytest.mark.parametrize( - "registry", + ("registry", "expected"), [ - '{"presets": {"alpha": {"priority": "high"}, "beta": {"priority": 1}}}', - '{"presets": {"alpha": {"priority": 2}, "beta": {"priority": 1}, "gamma": {"priority": null}}}', - "[]", - '{"presets":[]}', - '{"presets":null}', + ( + '{"presets": {"alpha": {"priority": "high"}, "beta": {"priority": 1}}}', + "# beta plan\n", + ), + ( + '{"presets": {"alpha": {"priority": 2}, "beta": {"priority": 1}, "gamma": {"priority": null}}}', + "# beta plan\n", + ), + ("[]", "# alpha plan\n"), + ('{"presets":[]}', "# alpha plan\n"), + ('{"presets":null}', "# alpha plan\n"), ], ids=[ "mixed_priorities", @@ -135,10 +193,10 @@ def test_python_missing_template_matches_bash(tmp_path: Path) -> None: "null_presets", ], ) -def test_all_variants_broken_registry_falls_back_to_dir_scan( - tmp_path: Path, registry: str +def test_all_variants_normalize_or_fallback_for_registry( + tmp_path: Path, registry: str, expected: str ) -> None: - """Malformed registries fall back to the alphabetical directory scan.""" + """Priorities normalize canonically; malformed shapes fall back to directories.""" repos = [ _setup_repo(tmp_path, "bash", template=False), _setup_repo(tmp_path, "powershell", template=False), @@ -183,7 +241,7 @@ def test_all_variants_broken_registry_falls_back_to_dir_scan( ) == 1 for _, repo in results: plan = repo / "specs" / "001-my-feature" / "plan.md" - assert plan.read_text(encoding="utf-8") == "# alpha plan\n" + assert plan.read_text(encoding="utf-8") == expected @pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available") diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index 26d1c798eb..a3f02b63a2 100644 --- a/tests/test_setup_tasks.py +++ b/tests/test_setup_tasks.py @@ -719,7 +719,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None: [exe, "-NoProfile", "-File", str(script), "-Json"], cwd=tasks_repo, capture_output=True, - text=True, + encoding="utf-8", check=False, env=_clean_env(), ) diff --git a/tests/test_setup_tasks_python_parity.py b/tests/test_setup_tasks_python_parity.py index afa303b6bd..5cd6e85ecb 100644 --- a/tests/test_setup_tasks_python_parity.py +++ b/tests/test_setup_tasks_python_parity.py @@ -10,7 +10,9 @@ from tests.parity_helpers import ( HAS_POWERSHELL, bash_cmd, + break_wrap_layer, clean_env, + install_composition_stack, install_scripts, json_stdout, make_repo, @@ -87,6 +89,42 @@ def test_python_override_template_wins_matches_bash(repo: Path) -> None: assert json_stdout(py)["TASKS_TEMPLATE"].endswith("overrides/tasks-template.md") +@requires_bash +def test_all_variants_return_composed_tasks_template(repo: Path) -> None: + expected = install_composition_stack( + repo, "tasks-template", "# Tasks Template\n" + ) + + results = [ + run(bash_cmd(repo, SCRIPT, "--json"), repo), + run(py_cmd(repo, SCRIPT, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, "-Json"), repo)) + + assert all(result.returncode == 0 for result in results) + assert all( + json_stdout(result)["TASKS_TEMPLATE_CONTENT"] == expected + for result in results + ) + + +@requires_bash +def test_all_variants_fail_for_broken_tasks_composition(repo: Path) -> None: + install_composition_stack(repo, "tasks-template", "# Tasks Template\n") + break_wrap_layer(repo, "tasks-template") + + results = [ + run(bash_cmd(repo, SCRIPT, "--json"), repo), + run(py_cmd(repo, SCRIPT, "--json"), repo), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, "-Json"), repo)) + + assert all(result.returncode != 0 for result in results) + assert all(result.stdout == "" for result in results) + + @requires_bash @pytest.mark.parametrize( "missing", diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 238e41e216..9a4e9fe330 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -796,6 +796,39 @@ def test_condition_evaluation(self): assert evaluate_condition("{{ inputs.ready }}", ctx) is True assert evaluate_condition("{{ inputs.missing }}", ctx) is False + def test_condition_strips_captured_command_output(self): + """A condition resolving to captured stdout must honour "false". + + A ``shell`` step stores ``proc.stdout`` verbatim, so ``run: echo false`` + resolves to ``"false\\n"``. Without stripping, the trailing newline + matched neither the "false" nor the "true" branch and fell through to + ``bool("false\\n")`` -> True, so an ``if`` step took its ``then`` branch + on a step that printed "false". There is no ``trim`` filter, so a + workflow author cannot strip it themselves. + """ + from specify_cli.workflows.expressions import evaluate_condition + from specify_cli.workflows.base import StepContext + + ctx = StepContext(steps={"check": {"output": {"stdout": "false\n"}}}) + assert evaluate_condition("{{ steps.check.output.stdout }}", ctx) is False + + for raw in ("false\n", "false\r\n", " false", "false ", "FALSE\n"): + assert evaluate_condition(raw, StepContext()) is False, raw + for raw in ("true\n", " true ", "TRUE\r\n"): + assert evaluate_condition(raw, StepContext()) is True, raw + + def test_condition_whitespace_only_string_stays_truthy(self): + """Stripping must not turn a whitespace-only string into False. + + Only the "false"/"true" special case is stripped; everything else still + falls through to ``bool(result)`` on the raw string. + """ + from specify_cli.workflows.expressions import evaluate_condition + from specify_cli.workflows.base import StepContext + + assert evaluate_condition(" ", StepContext()) is True + assert evaluate_condition("falsey", StepContext()) is True + def test_non_string_passthrough(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext diff --git a/tests/workflows/test_overlay_schema.py b/tests/workflows/test_overlay_schema.py index 08813f853b..77e0432eca 100644 --- a/tests/workflows/test_overlay_schema.py +++ b/tests/workflows/test_overlay_schema.py @@ -136,6 +136,44 @@ def test_invalid_operation_field_rejected(self): assert overlay is None assert any("operation" in e.lower() for e in errors), errors + @pytest.mark.parametrize( + "operation", + [ + {"insert_after": "a"}, + ["insert_after"], + ], + ) + def test_non_string_operation_rejected_without_raising(self, operation): + """An unhashable 'operation' must be reported, not raised. + + `VALID_OPERATIONS` is a frozenset, so `operation not in ...` hashes the + value. Nesting the shorthand form under the explicit key by mistake + (`operation: {insert_after: a}`) therefore raised + `TypeError: unhashable type: 'dict'` out of a validator whose docstring + promises "validation never raises" — and nothing upstream catches + TypeError, so the CLI died with a raw traceback. + """ + overlay, errors = validate_overlay_yaml( + { + "id": "ov", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": operation, + "anchor": "a", + "step": { + "id": "b", + "type": "command", + "command": "echo", + }, + } + ], + } + ) + assert overlay is None + assert any("invalid operation" in err for err in errors), errors + def test_shorthand_and_explicit_mixed_list(self): overlay, errors = validate_overlay_yaml( {