diff --git a/bundles/assess/README.md b/bundles/assess/README.md new file mode 100644 index 0000000000..4aae8459f6 --- /dev/null +++ b/bundles/assess/README.md @@ -0,0 +1,40 @@ +# Idea Assessment Bundle + +A first-party GitHub Spec Kit bundle that installs an idea-triage pipeline before committing to Spec-Driven Development. + +## What it provides + +- **Assess extension** (`extensions/assess`) — the `speckit.assess.intake`, `speckit.assess.research`, `speckit.assess.define`, `speckit.assess.shape`, and `speckit.assess.decide` commands. +- **Assess workflow** (`workflows/assess`) — a guided, resumable pipeline: + 1. `intake` the raw idea. + 2. `research` the evidence. + 3. `define` the problem. + 4. `shape` the concept. + 5. `decide` the verdict. + 6. `review-verdict` gate — approve to complete the assessment; reject to abort. A `go` verdict is then handed off manually to `/speckit.specify`. + +## Install + +```bash +specify bundle install assess +# or +specify bundle add assess +``` + +## Run the workflow + +```bash +specify workflow run assess \ + --input idea="Let users work offline and sync when they reconnect" \ + --input slug="offline-mode" +``` + +Required inputs must be supplied with `--input`: `idea` and `slug`. The slug is used as the working directory under `.specify/assessments//` for all artifacts. + +## Remove + +```bash +specify bundle remove assess +``` + +Removing the bundle uninstalls the workflow and the extension it contributed, unless they are still depended on by another installed bundle (FR-022). Components you installed independently are not attributed to this bundle and survive removal. diff --git a/bundles/assess/bundle.yml b/bundles/assess/bundle.yml new file mode 100644 index 0000000000..d788a5e7de --- /dev/null +++ b/bundles/assess/bundle.yml @@ -0,0 +1,25 @@ +schema_version: "1.0" + +bundle: + id: "assess" + name: "Idea Assessment Pipeline" + version: "1.0.0" + role: "developer" + description: "Idea triage before Spec-Driven Development: intake, research, define, shape, decide with a verdict review gate; surviving ideas hand off manually to the specify command." + author: "GitHub" + license: "MIT" + +requires: + speckit_version: ">=0.9.0" + tools: [] + mcp: [] + +provides: + extensions: + - id: "assess" + version: "1.0.0" + workflows: + - id: "assess" + version: "1.0.0" + +tags: ["assessment", "discovery", "triage", "product"] diff --git a/bundles/bugfix/README.md b/bundles/bugfix/README.md new file mode 100644 index 0000000000..f30a952bb7 --- /dev/null +++ b/bundles/bugfix/README.md @@ -0,0 +1,38 @@ +# Bug Fix Bundle + +A first-party GitHub Spec Kit bundle that installs an orchestrated bug-fixing pipeline. + +## What it provides + +- **Bug extension** (`extensions/bug`) — the `speckit.bug.assess`, `speckit.bug.fix`, and `speckit.bug.test` commands. +- **Bugfix workflow** (`workflows/bugfix`) — a guided, resumable pipeline: + 1. `assess` the bug report. + 2. `review-assessment` gate — approve to proceed, reject to abort. + 3. `fix` the bug. + 4. `test` the fix. + +## Install + +```bash +specify bundle install bugfix +# or +specify bundle add bugfix +``` + +## Run the workflow + +```bash +specify workflow run bugfix \ + --input report="https://github.com/example/repo/issues/1234" \ + --input slug="callback-token" +``` + +Required inputs must be supplied with `--input`: `report` and `slug`. The slug is used as the working directory under `.specify/bugs//` for all artifacts. + +## Remove + +```bash +specify bundle remove bugfix +``` + +Removing the bundle uninstalls the workflow and the extension it contributed, unless they are still depended on by another installed bundle (FR-022). Components you installed independently are not attributed to this bundle and survive removal. diff --git a/bundles/bugfix/bundle.yml b/bundles/bugfix/bundle.yml new file mode 100644 index 0000000000..8c81f76679 --- /dev/null +++ b/bundles/bugfix/bundle.yml @@ -0,0 +1,25 @@ +schema_version: "1.0" + +bundle: + id: "bugfix" + name: "Guided Bug Fix" + version: "1.0.0" + role: "developer" + description: "Orchestrated bug triage: assess a bug report, review the assessment behind a human gate, apply the fix, and verify it with tests." + author: "GitHub" + license: "MIT" + +requires: + speckit_version: ">=0.9.0" + tools: [] + mcp: [] + +provides: + extensions: + - id: "bug" + version: "1.0.0" + workflows: + - id: "bugfix" + version: "1.0.0" + +tags: ["bug", "triage", "workflow", "qa"] diff --git a/bundles/catalog.json b/bundles/catalog.json new file mode 100644 index 0000000000..789bdd149b --- /dev/null +++ b/bundles/catalog.json @@ -0,0 +1,51 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-09-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.json", + "bundles": { + "bugfix": { + "id": "bugfix", + "name": "Guided Bug Fix", + "version": "1.0.0", + "role": "developer", + "description": "Orchestrated bug triage: assess a bug report, review behind a human gate, apply the fix, and verify with tests.", + "author": "GitHub", + "license": "MIT", + "download_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/bugfix/bundle.yml", + "repository": "https://github.com/github/spec-kit", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "extensions": 1, + "presets": 0, + "steps": 0, + "workflows": 1 + }, + "tags": ["bug", "triage", "workflow", "qa"], + "verified": true + }, + "assess": { + "id": "assess", + "name": "Idea Assessment Pipeline", + "version": "1.0.0", + "role": "developer", + "description": "Idea triage before Spec-Driven Development: intake, research, define, shape, decide with a verdict review gate; surviving ideas hand off manually to specify.", + "author": "GitHub", + "license": "MIT", + "download_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/assess/bundle.yml", + "repository": "https://github.com/github/spec-kit", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "extensions": 1, + "presets": 0, + "steps": 0, + "workflows": 1 + }, + "tags": ["assessment", "discovery", "triage", "product"], + "verified": true + } + } +} diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index bb2a6aa7c7..4e93eff5e2 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -11,6 +11,24 @@ These demonstrate packaging a role-based setup, not filled generated feature specs; for end-to-end usage examples, see [community walkthroughs](../community/walkthroughs.md). +## First-party Bundles + +Spec Kit ships a first-party bundle catalog in `bundles/catalog.json`. These bundles are curated, marked `verified: true`, and resolve through the built-in `builtin://default` catalog source. + +| Bundle | Role | Components | Use case | +| --------- | ----------- | ----------------------------------------------------- | --------------------------------------- | +| `bugfix` | `developer` | `bug` extension + `bugfix` workflow | Guided assess → gate → fix → test | +| `assess` | `developer` | `assess` extension + `assess` workflow | Idea triage before Spec-Driven Development | + +Install a first-party bundle the same way you install any bundle (`add` is an alias for `install`): + +```bash +specify bundle install bugfix +specify bundle add assess +``` + +The first-party catalog is fetched from the repository online and falls back to the packaged wheel snapshot offline so discovery works without network access. A local bundle manifest can install bundled extensions and workflows with `--offline`. Catalog-discovered bundle manifests still resolve from their `download_url`, so `specify bundle add ` requires network today; fully offline catalog installation is tracked as follow-up work. + ## Search Available Bundles ```bash @@ -146,7 +164,10 @@ If your bundle references components from non-default catalogs, document those c ## Manage Catalog Sources -Bundles are discovered through a priority-ordered stack of catalog sources (project, user, and built-in scopes). +Bundles are discovered through a priority-ordered stack of catalog sources (project, user, and built-in scopes). The built-in sources are: + +- `builtin://default` — first-party bundles shipped in `bundles/catalog.json` (`bugfix`, `assess`, ...), install-allowed. +- `builtin://community` — community submissions in `bundles/catalog.community.json`, discovery-only. Each source has an install policy. `install-allowed` sources can be installed from; `discovery-only` sources appear in `search` and `info` but refuse diff --git a/pyproject.toml b/pyproject.toml index 217c42d4e2..de40e18a95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,12 +45,15 @@ packages = ["src/specify_cli"] "extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context" "extensions/assess" = "specify_cli/core_pack/extensions/assess" "extensions/bug" = "specify_cli/core_pack/extensions/bug" -# Bundled workflows (auto-installed during `specify init`) +# Bundled workflows (`specify init` installs only `speckit`; `bugfix`/`assess` are opt-in via first-party bundles) "workflows/speckit" = "specify_cli/core_pack/workflows/speckit" +"workflows/bugfix" = "specify_cli/core_pack/workflows/bugfix" +"workflows/assess" = "specify_cli/core_pack/workflows/assess" # Bundled presets (installable via `specify preset add ` or `specify init --preset `) "presets/lean" = "specify_cli/core_pack/presets/lean" "presets/constitution-sync" = "specify_cli/core_pack/presets/constitution-sync" -# Community bundle catalog snapshot (used for offline discovery) +# Bundle catalog snapshots (used for offline discovery) +"bundles/catalog.json" = "specify_cli/core_pack/bundles/catalog.json" "bundles/catalog.community.json" = "specify_cli/core_pack/bundles/catalog.community.json" [project.optional-dependencies] diff --git a/src/specify_cli/authentication/http.py b/src/specify_cli/authentication/http.py index d200bf9258..32a6ed67c7 100644 --- a/src/specify_cli/authentication/http.py +++ b/src/specify_cli/authentication/http.py @@ -65,9 +65,13 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool: RedirectValidator = Callable[[str, str], None] +class RedirectPolicyError(urllib.error.URLError): + """A redirect rejected because it violates the client's security policy.""" + + def _validate_strict_redirect(old_url: str, new_url: str) -> None: if not is_safe_download_redirect(old_url, new_url): - raise urllib.error.URLError( + raise RedirectPolicyError( f"unsafe redirect to {new_url}: target must use HTTPS with a hostname, " "must not enter a local target from a remote host, and may use HTTP only " "within loopback (for example localhost, 127.0.0.1, ::1)" @@ -100,7 +104,7 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): except ValueError as exc: # Malformed redirect target (e.g. unterminated IPv6 bracket). # Surface as URLError so callers' download error handling applies. - raise urllib.error.URLError(f"malformed redirect URL: {exc}") from exc + raise RedirectPolicyError(f"malformed redirect URL: {exc}") from exc if self._redirect_validator is not None: self._redirect_validator(req.full_url, newurl) diff --git a/src/specify_cli/bundler/services/adapters.py b/src/specify_cli/bundler/services/adapters.py index ca39a2489b..7700f5e63e 100644 --- a/src/specify_cli/bundler/services/adapters.py +++ b/src/specify_cli/bundler/services/adapters.py @@ -10,7 +10,11 @@ """ from __future__ import annotations +import http.client import re +import ssl +import urllib.error +import warnings from pathlib import Path from urllib.parse import ParseResult, urlparse from urllib.request import url2pathname @@ -26,19 +30,38 @@ "https://raw.githubusercontent.com/github/spec-kit/main/" "bundles/catalog.community.json" ) +FIRSTPARTY_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + "bundles/catalog.json" +) -# The default catalog is reserved for first-party bundles. The community -# catalog is loaded from the repository online and from the packaged snapshot -# offline so discovery remains useful without network access. -_BUILTIN_CATALOGS: dict[str, dict] = { - "builtin://default": { - "schema_version": "1.0", - "catalog_url": "builtin://default", - "bundles": {}, - }, +# Built-in catalogs are resolved directly by URL. ``builtin://default`` is the +# repository-shipped first-party bundle catalog; ``builtin://community`` is the +# community catalog. Both are fetched from the repository online and fall back +# to the packaged wheel snapshot offline so discovery works without network. +_BUILTIN_REPOSITORY_URLS: dict[str, str] = { + "builtin://default": FIRSTPARTY_CATALOG_URL, + "builtin://community": COMMUNITY_CATALOG_URL, +} +_BUILTIN_PACKAGED_SNAPSHOTS: dict[str, str] = { + "builtin://default": "catalog.json", + "builtin://community": "catalog.community.json", } HTTP_TIMEOUT_SECONDS = 10 +_TRANSIENT_HTTP_STATUS_CODES = (408, 429) + + +class _CatalogUnavailable(BundlerError): + """A built-in catalog could not be reached (transport/availability failure). + + Marks only transient fetch failures — connection/DNS errors, timeouts, + truncated responses, and availability HTTP responses (408, 429, 5xx) — so + the built-in catalog fallback does not swallow content or security + validation failures (malformed JSON, oversized or non-UTF-8 bodies, unsafe + redirects, TLS certificate verification failures, other HTTP 4xx). + """ + # Windows absolute paths like ``C:\catalog.json`` parse with a single-letter # ``scheme`` under urlparse; treat them as local files rather than URLs. @@ -99,15 +122,16 @@ def _validate_remote_url(source_id: str, url: str) -> None: ) -def _load_packaged_community_catalog() -> dict: +def _load_packaged_catalog(filename: str) -> dict: + """Load a packaged bundle catalog snapshot from the wheel or repo root.""" core_pack = _locate_core_pack() path = ( - core_pack / "bundles" / "catalog.community.json" + core_pack / "bundles" / filename if core_pack is not None - else _repo_root() / "bundles" / "catalog.community.json" + else _repo_root() / "bundles" / filename ) if not path.is_file(): - raise BundlerError(f"Bundled community catalog not found: {path}") + raise BundlerError(f"Bundled catalog not found: {path}") return loads_json(path.read_text(encoding="utf-8"), origin=str(path)) @@ -132,14 +156,30 @@ def fetch(source: CatalogSource) -> dict: scheme = parsed.scheme.lower() if scheme == "builtin": - if url == "builtin://community": - if allow_network: - return _http_get_json(source.id, COMMUNITY_CATALOG_URL) - return _load_packaged_community_catalog() - payload = _BUILTIN_CATALOGS.get(url) - if payload is None: + repository_url = _BUILTIN_REPOSITORY_URLS.get(url) + if repository_url is None: raise BundlerError(f"Unknown built-in catalog '{url}'.") - return payload + snapshot_name = _BUILTIN_PACKAGED_SNAPSHOTS[url] + if allow_network: + try: + return _http_get_json(source.id, repository_url) + except _CatalogUnavailable as exc: + # Built-in catalogs remain usable when the repository is + # temporarily unavailable; the packaged snapshot is the + # authoritative offline fallback. Only transient fetch + # failures take this path -- content/security validation + # errors propagate so a malformed response is never masked. + warnings.warn( + f"Built-in catalog '{url}' is unavailable ({exc}); " + "using the packaged snapshot.", + UserWarning, + stacklevel=2, + ) + try: + return _load_packaged_catalog(snapshot_name) + except BundlerError as snapshot_exc: + raise snapshot_exc from exc + return _load_packaged_catalog(snapshot_name) if scheme == "file": path = _file_url_to_path(parsed) @@ -178,7 +218,7 @@ def _http_get_json(source_id: str, url: str) -> dict: HTTPS/host guarantee from ``_validate_remote_url`` is preserved end to end rather than only on the initial URL. """ - from ...authentication.http import open_url + from ...authentication.http import RedirectPolicyError, open_url def _validate_redirect(_old_url: str, new_url: str) -> None: _validate_remote_url(source_id, new_url) @@ -198,8 +238,63 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: label=f"bundle catalog '{source_id}'", ).decode("utf-8") except BundlerError: + # Size limits, redirect/URL validation: content or security failures, + # never transient -- must not be downgraded to availability. raise - except Exception as exc: # noqa: BLE001 + except ssl.SSLCertVerificationError as exc: + # TLS certificate verification is a security check, not a transient + # availability failure: never mask it with the packaged snapshot. Some + # call paths raise this directly (it subclasses OSError). + raise BundlerError( + f"Failed to fetch catalog from {url}: {exc}" + ) from exc + except RedirectPolicyError as exc: + # Unsafe/malformed redirects are security failures and must surface as + # a hard error instead of being downgraded to catalog unavailability. + raise BundlerError( + f"Failed to fetch catalog from {url}: {exc}" + ) from exc + except urllib.error.HTTPError as exc: + # urllib raises HTTPError for any non-2xx status; only transient + # server-side availability responses (408, 429, 5xx) fall back to the + # snapshot. Any other 4xx (404, 403, ...) is definitive and must + # surface as a hard error. + if exc.code in _TRANSIENT_HTTP_STATUS_CODES or exc.code >= 500: + raise _CatalogUnavailable( + f"Failed to fetch catalog from {url}: HTTP {exc.code} {exc.reason}" + ) from exc + raise BundlerError( + f"Failed to fetch catalog from {url}: HTTP {exc.code} {exc.reason}" + ) from exc + except urllib.error.URLError as exc: + # urllib wraps a TLS handshake failure as URLError(reason=); + # a certificate-verification failure is a security failure, not a + # transient availability problem, so it must not use the snapshot. + if isinstance(exc.reason, ssl.SSLCertVerificationError): + raise BundlerError( + f"Failed to fetch catalog from {url}: {exc.reason}" + ) from exc + raise _CatalogUnavailable( + f"Failed to fetch catalog from {url}: {exc.reason}" + ) from exc + except (TimeoutError, OSError) as exc: + # socket.timeout is TimeoutError; OSError covers connection resets and + # other low-level transport failures not wrapped in URLError. + raise _CatalogUnavailable( + f"Failed to fetch catalog from {url}: {exc}" + ) from exc + except http.client.IncompleteRead as exc: + # A chunked response truncated mid-read is a transport failure, not a + # definitive or content error, so it should use the packaged snapshot. + raise _CatalogUnavailable( + f"Failed to fetch catalog from {url}: incomplete read ({exc})" + ) from exc + except UnicodeDecodeError as exc: + # A non-UTF-8 body is a malformed response, not an availability issue. + raise BundlerError( + f"Failed to fetch catalog from {url}: response was not valid UTF-8 ({exc})" + ) from exc + except Exception as exc: raise BundlerError(f"Failed to fetch catalog from {url}: {exc}") from exc return loads_json(raw, origin=final_url) diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundler/services/primitives.py index 61fd43d7ff..94bbc0b8ee 100644 --- a/src/specify_cli/bundler/services/primitives.py +++ b/src/specify_cli/bundler/services/primitives.py @@ -335,7 +335,38 @@ def is_installed(self, component: ComponentRef) -> bool: return False def install(self, component: ComponentRef) -> None: - if not self._allow_network and not self._is_bundled(component.id): + from ..._assets import _locate_bundled_workflow + + bundled = _locate_bundled_workflow(component.id) + if bundled is not None: + workflow_file = bundled / "workflow.yml" + try: + from ...workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_yaml(workflow_file) + except (OSError, ValueError) as exc: + raise BundlerError( + f"Failed to load bundled workflow '{component.id}': {exc}" + ) from exc + if definition.id != component.id: + raise BundlerError( + f"Bundled workflow at {workflow_file} declares ID " + f"'{definition.id}', expected '{component.id}'." + ) + _assert_pinned_version( + "Workflow", component.id, component.version, definition.version + ) + from ... import workflow_add + + with _chdir(self._root): + _delegate_command( + "install", + f"workflow '{component.id}'", + lambda: workflow_add(str(workflow_file), dev=True, from_url=None), + ) + return + + if not self._allow_network: raise BundlerError( f"Workflow '{component.id}' installs from a catalog and network " "access is disabled. Installing or refreshing this component " @@ -369,13 +400,6 @@ def _assert_pinned_version(self, component: ComponentRef) -> None: "Workflow", component.id, component.version, info.get("version") ) - @staticmethod - def _is_bundled(workflow_id: str) -> bool: - # A workflow that ships with Spec Kit installs fully offline. - from ..._assets import _locate_bundled_workflow - - return _locate_bundled_workflow(workflow_id) is not None - def remove(self, component: ComponentRef) -> None: from ... import workflow_remove diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index b809afba80..fb7a0c73c2 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -449,6 +449,28 @@ def bundle_install( ) +@bundle_app.command("add") +def bundle_add( + bundle_id: str = typer.Argument( + ..., + help="Bundle id (from the catalog stack) or a local path to a .zip " + "artifact, bundle directory, or bundle.yml", + ), + integration: str = typer.Option(None, "--integration", help="Override integration"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), + refresh: bool = typer.Option( + False, "--refresh", help="Refresh owned components from this bundle source", + ), +) -> None: + """Install a bundle's full component set (alias for install).""" + return bundle_install( + bundle_id=bundle_id, + integration=integration, + offline=offline, + refresh=refresh, + ) + + @bundle_app.command("update") def bundle_update( bundle_id: str = typer.Argument(None, help="Bundle id, or omit with --all"), diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index 6db4dab769..4cf8d35150 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -16,6 +16,7 @@ from typer.testing import CliRunner from specify_cli import app +from specify_cli.bundler.services.adapters import FIRSTPARTY_CATALOG_URL from specify_cli.bundler.services.packager import build_bundle from tests.conftest import strip_ansi from tests.bundler_helpers import ( @@ -25,6 +26,7 @@ ) runner = CliRunner() +REPO_ROOT = Path(__file__).parents[2] MARKUP_BUNDLE_ID = "[red]markup-id[/red]" MARKUP_SOURCE_ID = "[underline]markup-source[/underline]" @@ -73,8 +75,8 @@ def project(tmp_path: Path, monkeypatch) -> Path: def test_bundle_help_lists_all_commands(): result = runner.invoke(app, ["bundle", "--help"]) assert result.exit_code == 0 - for cmd in ("search", "info", "list", "install", "update", "remove", - "validate", "build", "init", "catalog"): + for cmd in ("search", "info", "list", "install", "add", "update", "remove", + "validate", "build", "init", "catalog"): assert cmd in result.output @@ -88,6 +90,21 @@ def test_update_accepts_integration_override(): assert "integration" in result.output +def test_add_forwards_refresh_default_without_refreshing(project: Path): + from specify_cli.commands import bundle as bundle_commands + + with patch.object(bundle_commands, "bundle_install") as install: + result = runner.invoke(app, ["bundle", "add", "demo"]) + + assert result.exit_code == 0, result.output + install.assert_called_once_with( + bundle_id="demo", + integration=None, + offline=False, + refresh=False, + ) + + def test_list_empty_project(project: Path): result = runner.invoke(app, ["bundle", "list"]) assert result.exit_code == 0 @@ -397,6 +414,133 @@ def _mock_manifest_download(monkeypatch, source_path: Path) -> None: ) +def _bundled_workflow_manifest(workflow_id: str, version: str = "1.0.0") -> dict: + return valid_manifest_dict( + provides={"workflows": [{"id": workflow_id, "version": version}]} + ) + + +@pytest.mark.parametrize( + ("command", "bundle_id", "extension_id"), + [("install", "bugfix", "bug"), ("add", "assess", "assess")], +) +def test_local_firstparty_bundle_installs_bundled_components_offline( + project: Path, command: str, bundle_id: str, extension_id: str +): + bundle_dir = REPO_ROOT / "bundles" / bundle_id + + result = runner.invoke( + app, ["bundle", command, str(bundle_dir), "--offline"] + ) + + assert result.exit_code == 0, result.output + assert ( + project / ".specify" / "extensions" / extension_id / "extension.yml" + ).is_file() + assert (project / ".specify" / "workflows" / bundle_id / "workflow.yml").is_file() + registry = json.loads( + (project / ".specify" / "workflows" / "workflow-registry.json").read_text( + encoding="utf-8" + ) + ) + assert registry["workflows"][bundle_id]["version"] == "1.0.0" + + +@pytest.mark.parametrize( + ("bundle_id", "extension_id"), + [("bugfix", "bug"), ("assess", "assess")], +) +def test_bundle_add_by_id_initializes_empty_project_from_firstparty_catalog( + tmp_path: Path, monkeypatch, bundle_id: str, extension_id: str +): + """``bundle add `` from an empty directory resolves ``builtin://default``. + + The command fetches the first-party catalog and bundle manifest over the + network (both mocked here), initializes a new Spec Kit project, and installs + the bundled extension and workflow without further network access. + """ + project = tmp_path / "fresh" + project.mkdir() + monkeypatch.chdir(project) + + catalog_bytes = (REPO_ROOT / "bundles" / "catalog.json").read_bytes() + manifest_bytes = (REPO_ROOT / "bundles" / bundle_id / "bundle.yml").read_bytes() + expected_manifest_url = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + f"bundles/{bundle_id}/bundle.yml" + ) + captured_urls: list[str] = [] + + def fake_open_url( + url: str, + timeout: int | None = None, + extra_headers: dict[str, str] | None = None, + redirect_validator=None, + ): + captured_urls.append(url) + if url == FIRSTPARTY_CATALOG_URL: + return FakeBundleResponse(catalog_bytes, url=url) + if url == expected_manifest_url: + return FakeBundleResponse(manifest_bytes, url=url) + raise AssertionError( + f"Unexpected network request in by-ID bundle test: {url}" + ) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke( + app, ["bundle", "add", bundle_id, "--integration", "copilot"] + ) + + assert result.exit_code == 0, result.output + assert "No Spec Kit project here" in result.output + assert (project / ".specify").is_dir() + assert ( + project / ".specify" / "extensions" / extension_id / "extension.yml" + ).is_file() + assert ( + project / ".specify" / "workflows" / bundle_id / "workflow.yml" + ).is_file() + registry = json.loads( + (project / ".specify" / "workflows" / "workflow-registry.json").read_text( + encoding="utf-8" + ) + ) + assert registry["workflows"][bundle_id]["version"] == "1.0.0" + assert FIRSTPARTY_CATALOG_URL in captured_urls + assert expected_manifest_url in captured_urls + + +def test_local_bundle_rejects_mismatched_bundled_workflow_pin_offline(project: Path): + bundle_dir = project / "mismatched-workflow-pin" + (bundle_dir / "bundle.yml").parent.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(_bundled_workflow_manifest("bugfix", "9.9.9")), encoding="utf-8" + ) + + result = runner.invoke( + app, ["bundle", "install", str(bundle_dir), "--offline"] + ) + + assert result.exit_code == 1 + assert "pinned to version 9.9.9" in result.output + assert not (project / ".specify" / "workflows" / "bugfix").exists() + + +def test_local_bundle_refuses_unbundled_workflow_offline(project: Path): + bundle_dir = project / "unbundled-workflow" + (bundle_dir / "bundle.yml").parent.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(_bundled_workflow_manifest("not-bundled")), encoding="utf-8" + ) + + result = runner.invoke( + app, ["bundle", "install", str(bundle_dir), "--offline"] + ) + + assert result.exit_code == 1 + assert "network access is disabled" in " ".join(result.output.lower().split()) + + def test_info_expands_full_component_set(project: Path, monkeypatch): bundle_dir = project / "src-bundle" bundle_dir.mkdir() @@ -649,7 +793,11 @@ def test_search_json_offline(project: Path): config = { "schema_version": "1.0", "catalogs": [ - {"id": "c", "url": str(catalog), "priority": 1, + # Priority 0 wins over the built-in first-party catalog so the demo + # entry is resolved from this project catalog, while the offline + # packaged first-party catalog (bugfix / assess) still appears in + # search results alongside it. + {"id": "c", "url": str(catalog), "priority": 0, "install_policy": "install-allowed"} ], } @@ -659,10 +807,11 @@ def test_search_json_offline(project: Path): result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) assert result.exit_code == 0 payload = json.loads(result.output) - assert payload[0]["id"] == "demo" + by_id = {entry["id"]: entry for entry in payload} + assert "demo" in by_id # Trust indicator is exposed on the discovery surface (FR-010 / FR-027). - assert payload[0]["verified"] is True - assert payload[0]["trust"] == "verified" + assert by_id["demo"]["verified"] is True + assert by_id["demo"]["trust"] == "verified" def test_search_text_shows_trust(project: Path): @@ -727,14 +876,19 @@ def geturl(self) -> str: def _make_catalog_config(catalog_path: Path, project: Path) -> None: - """Write a bundle-catalogs.yml pointing at *catalog_path* in *project*.""" + """Write a bundle-catalogs.yml pointing at *catalog_path* in *project*. + + Uses priority 0 so the test catalog wins over the built-in first-party + ``builtin://default`` catalog and the command under test does not need to + fetch the repository catalog from the network. + """ config = { "schema_version": "1.0", "catalogs": [ { "id": "test", "url": str(catalog_path), - "priority": 1, + "priority": 0, "install_policy": "install-allowed", } ], diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py index 3fd3a5c53d..c32b059d53 100644 --- a/tests/contract/test_catalog_schema.py +++ b/tests/contract/test_catalog_schema.py @@ -229,6 +229,19 @@ def test_wheel_packages_community_bundle_catalog(): ) +def test_wheel_packages_firstparty_bundle_catalog(): + repo_root = Path(__file__).parents[2] + with (repo_root / "pyproject.toml").open("rb") as pyproject_file: + pyproject = tomllib.load(pyproject_file) + + force_include = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][ + "force-include" + ] + assert force_include["bundles/catalog.json"] == ( + "specify_cli/core_pack/bundles/catalog.json" + ) + + def test_catalog_entry_rejects_string_tags(): from specify_cli.bundler.models.catalog import CatalogEntry diff --git a/tests/contract/test_firstparty_bundle_catalog_consistency.py b/tests/contract/test_firstparty_bundle_catalog_consistency.py new file mode 100644 index 0000000000..5a7737ab55 --- /dev/null +++ b/tests/contract/test_firstparty_bundle_catalog_consistency.py @@ -0,0 +1,155 @@ +"""Consistency tests for the first-party bundle catalog and manifests. + +``_validate_catalog_manifest`` enforces that a bundle manifest's ``bundle.id`` +and ``bundle.version`` match the catalog entry that pointed to it. This test +locks that relationship in at the source files so it cannot drift without +failing CI. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).parents[2] + + +def _read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _read_yaml(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _extension_version(extension_id: str) -> str: + manifest = _read_yaml(REPO_ROOT / "extensions" / extension_id / "extension.yml") + return str(manifest["extension"]["version"]) + + +def _workflow_version(workflow_id: str) -> str: + manifest = _read_yaml(REPO_ROOT / "workflows" / workflow_id / "workflow.yml") + return str(manifest["workflow"]["version"]) + + +def _manifest_component_versions(bundle_id: str) -> dict[tuple[str, str], str]: + """Return {(kind, id): version, ...} for pinned components in a bundle manifest.""" + manifest = _read_yaml(REPO_ROOT / "bundles" / bundle_id / "bundle.yml") + provides = manifest.get("provides", {}) + versions: dict[tuple[str, str], str] = {} + for kind in ("extensions", "presets", "steps", "workflows"): + for ref in provides.get(kind, []): + if "version" in ref: + versions[(kind.rstrip("s"), ref["id"])] = str(ref["version"]) + return versions + + +def test_firstparty_catalog_matches_manifests(): + catalog = _read_json(REPO_ROOT / "bundles" / "catalog.json") + + for bundle_id, entry in catalog["bundles"].items(): + manifest = _read_yaml(REPO_ROOT / "bundles" / bundle_id / "bundle.yml") + meta = manifest["bundle"] + + assert entry["id"] == bundle_id, ( + f"catalog key '{bundle_id}' does not match entry id '{entry['id']}'" + ) + assert meta["id"] == bundle_id, ( + f"manifest id '{meta['id']}' does not match catalog key '{bundle_id}'" + ) + assert entry["version"] == meta["version"], ( + f"catalog version for '{bundle_id}' ({entry['version']}) does not match " + f"manifest version ({meta['version']})" + ) + + +def _workflow_catalog_entry(workflow_id: str) -> dict: + catalog = _read_json(REPO_ROOT / "workflows" / "catalog.json") + entry = catalog["workflows"].get(workflow_id) + assert entry is not None, ( + f"workflow '{workflow_id}' is missing from workflows/catalog.json" + ) + return entry + + +def test_firstparty_workflow_catalog_entries_match_shipped_yamls(): + """workflows/catalog.json entries must match the shipped workflow YAMLs. + + ``specify workflow add`` resolves a catalog entry by fetching its ``url`` + and then comparing the downloaded manifest's ``version`` against the + catalog's — a stale entry version or URL makes the install fail (or worse, + serve an old workflow). Lock id, version, and URL in at the source files so + they cannot drift without failing CI. + """ + for workflow_id in ("speckit", "bugfix", "assess"): + entry = _workflow_catalog_entry(workflow_id) + manifest = _read_yaml(REPO_ROOT / "workflows" / workflow_id / "workflow.yml") + meta = manifest["workflow"] + + assert entry["id"] == workflow_id, ( + f"catalog entry id '{entry['id']}' does not match catalog key " + f"'{workflow_id}'" + ) + assert meta["id"] == workflow_id, ( + f"workflows/{workflow_id}/workflow.yml declares id '{meta['id']}', " + f"expected '{workflow_id}'" + ) + assert entry["version"] == str(meta["version"]), ( + f"catalog version for workflow '{workflow_id}' ({entry['version']}) " + f"does not match the shipped workflow.yml version " + f"({meta['version']})" + ) + assert entry["url"] == ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + f"workflows/{workflow_id}/workflow.yml" + ), ( + f"catalog URL for workflow '{workflow_id}' does not point at the " + "shipped workflow.yml on the repository default branch" + ) + + +def test_firstparty_manifest_pins_match_shipped_versions(): + for bundle_id in ("bugfix", "assess"): + manifest = _read_yaml(REPO_ROOT / "bundles" / bundle_id / "bundle.yml") + provides = manifest.get("provides", {}) + + for ext_ref in provides.get("extensions", []): + expected = _extension_version(ext_ref["id"]) + assert str(ext_ref.get("version")) == expected, ( + f"{bundle_id} manifest pins extension {ext_ref['id']} at " + f"{ext_ref.get('version')}, but extensions/{ext_ref['id']}/extension.yml " + f"ships {expected}" + ) + + for wf_ref in provides.get("workflows", []): + expected = _workflow_version(wf_ref["id"]) + assert str(wf_ref.get("version")) == expected, ( + f"{bundle_id} manifest pins workflow {wf_ref['id']} at " + f"{wf_ref.get('version')}, but workflows/{wf_ref['id']}/workflow.yml " + f"ships {expected}" + ) + + +def test_firstparty_catalog_provides_counts_match_manifests(): + catalog = _read_json(REPO_ROOT / "bundles" / "catalog.json") + + for bundle_id, entry in catalog["bundles"].items(): + manifest = _read_yaml(REPO_ROOT / "bundles" / bundle_id / "bundle.yml") + provides = manifest.get("provides", {}) + + for kind in ("extensions", "presets", "steps", "workflows"): + expected_count = len(provides.get(kind, [])) + assert entry["provides"][kind] == expected_count, ( + f"catalog entry '{bundle_id}' claims {entry['provides'][kind]} {kind}, " + f"but the manifest lists {expected_count}" + ) + + +def test_firstparty_catalog_entries_are_verified(): + catalog = _read_json(REPO_ROOT / "bundles" / "catalog.json") + for bundle_id, entry in catalog["bundles"].items(): + assert entry.get("verified") is True, ( + f"first-party catalog entry '{bundle_id}' must be marked verified: true" + ) diff --git a/tests/contract/test_wheel_bundled_workflows.py b/tests/contract/test_wheel_bundled_workflows.py new file mode 100644 index 0000000000..1346d9c5d2 --- /dev/null +++ b/tests/contract/test_wheel_bundled_workflows.py @@ -0,0 +1,52 @@ +"""Contract tests: every bundled workflow must ship inside the wheel's core_pack. + +``specify workflow add `` (and the bundler's workflow primitive) resolve a +bundled workflow via ``specify_cli._assets._locate_bundled_workflow``, which +checks the wheel's ``specify_cli/core_pack/workflows//`` directory first. +Any workflow marked ``bundled: true`` in ``workflows/catalog.json`` must +therefore be force-included at build time; otherwise the released wheel +advertises a bundled workflow it does not actually ship. +""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +REPO_ROOT = Path(__file__).parents[2] + + +def _force_include() -> dict[str, str]: + with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject_file: + pyproject = tomllib.load(pyproject_file) + return pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"] + + +def _bundled_workflow_ids() -> list[str]: + catalog = json.loads((REPO_ROOT / "workflows" / "catalog.json").read_text()) + return sorted( + workflow_id + for workflow_id, entry in catalog["workflows"].items() + if entry.get("bundled") + ) + + +def test_every_bundled_workflow_is_force_included(): + force_include = _force_include() + bundled = _bundled_workflow_ids() + + assert bundled, "expected at least one bundled workflow in workflows/catalog.json" + for workflow_id in bundled: + assert force_include.get(f"workflows/{workflow_id}") == ( + f"specify_cli/core_pack/workflows/{workflow_id}" + ), f"bundled workflow '{workflow_id}' is missing from the wheel force-include list" + + +def test_stock_bundled_workflows_are_force_included(): + # Explicit regression guard for the first-party workflows shipped in #4495. + force_include = _force_include() + for workflow_id in ("speckit", "bugfix", "assess"): + assert force_include[f"workflows/{workflow_id}"] == ( + f"specify_cli/core_pack/workflows/{workflow_id}" + ) diff --git a/tests/integration/test_bundler_offline.py b/tests/integration/test_bundler_offline.py index 8cbc7af9cc..f85e58e2cf 100644 --- a/tests/integration/test_bundler_offline.py +++ b/tests/integration/test_bundler_offline.py @@ -24,11 +24,67 @@ def _src(source_id, url, priority=1, policy="install-allowed"): ) -def test_builtin_catalog_resolves_offline(): +def test_builtin_default_catalog_resolves_first_party_bundles_offline(): fetcher = make_catalog_fetcher(allow_network=False) stack = CatalogStack([_src("default", "builtin://default")], fetcher) - # Built-in default ships empty; search works without network and returns []. - assert stack.search() == [] + # Built-in default now ships the first-party bundles bugfix and assess. + results = {r.entry.id: r for r in stack.search()} + assert set(results) == {"bugfix", "assess"} + assert all(r.source.id == "default" and r.install_allowed for r in results.values()) + + resolved = stack.resolve("bugfix") + assert resolved.entry.id == "bugfix" + assert resolved.install_allowed is True + + resolved = stack.resolve("assess") + assert resolved.entry.id == "assess" + assert resolved.install_allowed is True + + +@pytest.mark.parametrize( + "source_id, builtin_id, builtin_priority, project_priority", + [ + pytest.param("default", "builtin://default", 1, 10, id="default"), + pytest.param("community", "builtin://community", 20, 30, id="community"), + ], +) +def test_builtin_catalog_failure_does_not_block_lower_priority_source( + monkeypatch, source_id, builtin_id, builtin_priority, project_priority +): + from specify_cli.bundler.services import adapters + + def fail_http_get_json(source_id, url): + raise adapters._CatalogUnavailable("repository unavailable") + + monkeypatch.setattr( + "specify_cli.bundler.services.adapters._http_get_json", fail_http_get_json + ) + monkeypatch.setattr( + "specify_cli.bundler.services.adapters._load_packaged_catalog", + lambda filename: {"schema_version": "1.0", "bundles": {}}, + ) + + project = _src( + "project", "https://example.com/catalog.json", priority=project_priority + ) + fetcher = make_catalog_fetcher(allow_network=True) + + def fetch_project(source): + if source.id == "project": + return { + "schema_version": "1.0", + "bundles": {"company": catalog_entry_dict("company")}, + } + return fetcher(source) + + stack = CatalogStack( + [_src(source_id, builtin_id, priority=builtin_priority), project], + fetch_project, + ) + + with pytest.warns(UserWarning, match="packaged snapshot"): + resolved = stack.resolve("company") + assert resolved.source.id == "project" def test_builtin_community_catalog_resolves_from_packaged_snapshot_offline(): diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 0168f4302c..2598e4b64d 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1328,10 +1328,11 @@ def failing_read_text(self_path, *args, **kwargs): argv = _resolve_event_command_argv(template, tmp_path, None) assert argv is None - def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): + def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path, monkeypatch): """S6: the ps variant prefixes argv with pwsh/powershell -File so subprocess.run(shell=False) can execute the .ps1 script.""" from specify_cli.events import _resolve_event_command_argv + import shutil as _shutil cmd_dir = tmp_path / ".specify" / "templates" / "commands" cmd_dir.mkdir(parents=True) @@ -1347,6 +1348,13 @@ def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): ps_dir.mkdir(parents=True) (ps_dir / "boot.ps1").write_text("exit 0\n", encoding="utf-8") + # The argv contract under test does not depend on a real PowerShell + # install; pin the launcher (mirroring the no-launcher sibling below) + # so the test runs on platforms without pwsh/powershell on PATH. + monkeypatch.setattr( + _shutil, "which", lambda name: "/usr/bin/pwsh" if name == "pwsh" else None + ) + argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) assert argv is not None # Launcher (pwsh or powershell), -File, then the .specify-anchored diff --git a/tests/unit/test_bundler_adapters.py b/tests/unit/test_bundler_adapters.py index 854e60df3f..d435d29c4d 100644 --- a/tests/unit/test_bundler_adapters.py +++ b/tests/unit/test_bundler_adapters.py @@ -1,8 +1,17 @@ """Unit tests for catalog-fetch adapters (auth + redirect safety).""" from __future__ import annotations +import http.client +import io +import ssl +import urllib.error +import urllib.request +import warnings +from typing import Self + import pytest +from specify_cli.authentication.http import _StripAuthOnRedirect from specify_cli.bundler import BundlerError from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy from specify_cli.bundler.services import adapters @@ -23,7 +32,7 @@ def __init__(self, body: bytes, final_url: str) -> None: self._offset = 0 self._final_url = final_url - def __enter__(self) -> "_FakeResponse": + def __enter__(self) -> Self: return self def __exit__(self, *exc) -> bool: @@ -90,6 +99,73 @@ def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): fetcher(_source("https://example.com/c.json")) +def test_http_get_json_marks_connection_error_unavailable(monkeypatch): + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + raise urllib.error.URLError("name resolution failed") + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + + with pytest.raises(adapters._CatalogUnavailable): + adapters._http_get_json("team", "https://example.com/c.json") + + +def test_http_get_json_marks_server_error_unavailable(monkeypatch): + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + raise urllib.error.HTTPError(url, 503, "Service Unavailable", {}, None) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + + with pytest.raises(adapters._CatalogUnavailable, match="503"): + adapters._http_get_json("team", "https://example.com/c.json") + + +def test_http_get_json_preserves_client_error_as_bundler_error(monkeypatch): + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + raise urllib.error.HTTPError(url, 404, "Not Found", {}, None) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + + with pytest.raises(BundlerError, match="404") as excinfo: + adapters._http_get_json("team", "https://example.com/c.json") + assert not isinstance(excinfo.value, adapters._CatalogUnavailable) + + +def test_http_get_json_preserves_malformed_json(monkeypatch): + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + return _FakeResponse(b"not json", url) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + + with pytest.raises(BundlerError) as excinfo: + adapters._http_get_json("team", "https://example.com/c.json") + assert not isinstance(excinfo.value, adapters._CatalogUnavailable) + + +def test_http_get_json_preserves_invalid_utf8(monkeypatch): + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + return _FakeResponse(b"\xff\xfe", url) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + + with pytest.raises(BundlerError, match="not valid UTF-8") as excinfo: + adapters._http_get_json("team", "https://example.com/c.json") + assert not isinstance(excinfo.value, adapters._CatalogUnavailable) + + +def test_http_get_json_preserves_oversized_response(monkeypatch): + body = b'{"schema_version":"1.0","bundles":{}}' + + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + return _FakeResponse(body, url) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + monkeypatch.setattr(adapters, "MAX_JSON_CATALOG_BYTES", len(body) - 1) + + with pytest.raises(BundlerError) as excinfo: + adapters._http_get_json("team", "https://example.com/c.json") + assert not isinstance(excinfo.value, adapters._CatalogUnavailable) + + @pytest.mark.parametrize( "url", [ @@ -116,7 +192,43 @@ def test_local_catalog_decode_errors_are_wrapped(tmp_path, use_file_url): fetcher(_source(url)) -def test_builtin_community_catalog_fetches_repository_catalog_online(monkeypatch): +_SNAPSHOT_BODY = ( + '{"schema_version":"1.0","bundles":{"packaged":{' + '"id":"packaged","name":"Packaged","version":"1.0.0",' + '"role":"developer","description":"Packaged catalog entry.",' + '"author":"Spec Kit","license":"MIT","download_url":"",' + '"requires":{"speckit_version":">=0.1.0"},' + '"provides":{},"verified":false}}}' +) + + +def _write_snapshot(tmp_path, filename): + path = tmp_path / "bundles" / filename + path.parent.mkdir(exist_ok=True) + path.write_text(_SNAPSHOT_BODY, encoding="utf-8") + return path + + +_BUILTIN_CASES = [ + pytest.param( + "builtin://default", + "catalog.json", + adapters.FIRSTPARTY_CATALOG_URL, + id="default", + ), + pytest.param( + "builtin://community", + "catalog.community.json", + adapters.COMMUNITY_CATALOG_URL, + id="community", + ), +] + + +@pytest.mark.parametrize("builtin_id, snapshot_name, expected_url", _BUILTIN_CASES) +def test_builtin_catalog_fetches_repository_catalog_online( + monkeypatch, builtin_id, snapshot_name, expected_url +): captured: dict = {} def fake_http_get_json(source_id, url): @@ -127,35 +239,237 @@ def fake_http_get_json(source_id, url): monkeypatch.setattr(adapters, "_http_get_json", fake_http_get_json) fetcher = adapters.make_catalog_fetcher(allow_network=True) - result = fetcher(_source("builtin://community")) + result = fetcher(_source(builtin_id)) assert result["bundles"] == {} - assert captured == { - "source_id": "team", - "url": adapters.COMMUNITY_CATALOG_URL, + assert captured == {"source_id": "team", "url": expected_url} + + +@pytest.mark.parametrize("builtin_id, snapshot_name, expected_url", _BUILTIN_CASES) +def test_builtin_catalog_falls_back_to_snapshot_on_availability_error( + monkeypatch, tmp_path, builtin_id, snapshot_name, expected_url +): + _write_snapshot(tmp_path, snapshot_name) + monkeypatch.setattr(adapters, "_locate_core_pack", lambda: tmp_path) + + def fail_http_get_json(source_id, url): + raise adapters._CatalogUnavailable("repository unavailable") + + monkeypatch.setattr(adapters, "_http_get_json", fail_http_get_json) + + fetcher = adapters.make_catalog_fetcher(allow_network=True) + with pytest.warns(UserWarning, match="packaged snapshot"): + result = fetcher(_source(builtin_id)) + + assert "packaged" in result["bundles"] + + +@pytest.mark.parametrize("builtin_id, snapshot_name, expected_url", _BUILTIN_CASES) +def test_builtin_catalog_validation_error_is_not_masked_by_snapshot( + monkeypatch, tmp_path, builtin_id, snapshot_name, expected_url +): + _write_snapshot(tmp_path, snapshot_name) + monkeypatch.setattr(adapters, "_locate_core_pack", lambda: tmp_path) + + def fail_http_get_json(source_id, url): + raise BundlerError("Invalid catalog payload") + + monkeypatch.setattr(adapters, "_http_get_json", fail_http_get_json) + monkeypatch.setattr( + adapters, + "_load_packaged_catalog", + lambda filename: pytest.fail( + "snapshot must not be used for validation errors" + ), + ) + + fetcher = adapters.make_catalog_fetcher(allow_network=True) + with pytest.raises(BundlerError, match="Invalid catalog payload"): + fetcher(_source(builtin_id)) + + +@pytest.mark.parametrize("builtin_id, snapshot_name, expected_url", _BUILTIN_CASES) +def test_builtin_catalog_uses_core_pack_snapshot_offline_quietly( + monkeypatch, tmp_path, builtin_id, snapshot_name, expected_url +): + _write_snapshot(tmp_path, snapshot_name) + monkeypatch.setattr(adapters, "_locate_core_pack", lambda: tmp_path) + + fetcher = adapters.make_catalog_fetcher(allow_network=False) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = fetcher(_source(builtin_id)) + + assert "packaged" in result["bundles"] + assert not [w for w in caught if "snapshot" in str(w.message)] + + +@pytest.mark.parametrize("status_code", [408, 429, 500]) +def test_builtin_community_catalog_falls_back_for_transient_http_failures( + monkeypatch, tmp_path, status_code +): + catalog_path = tmp_path / "bundles" / "catalog.community.json" + catalog_path.parent.mkdir() + catalog_path.write_text( + '{"schema_version":"1.0","bundles":{}}', encoding="utf-8" + ) + monkeypatch.setattr(adapters, "_locate_core_pack", lambda: tmp_path) + + def fail(url, timeout=10, extra_headers=None, redirect_validator=None): + raise urllib.error.HTTPError(url, status_code, "transient", {}, None) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fail) + fetcher = adapters.make_catalog_fetcher(allow_network=True) + + assert fetcher(_source("builtin://community")) == { + "schema_version": "1.0", + "bundles": {}, } -def test_builtin_community_catalog_uses_core_pack_snapshot_offline( +def test_builtin_community_catalog_falls_back_for_transport_errors(monkeypatch, tmp_path): + catalog_path = tmp_path / "bundles" / "catalog.community.json" + catalog_path.parent.mkdir() + catalog_path.write_text( + '{"schema_version":"1.0","bundles":{}}', encoding="utf-8" + ) + monkeypatch.setattr(adapters, "_locate_core_pack", lambda: tmp_path) + + def fail(url, timeout=10, extra_headers=None, redirect_validator=None): + raise urllib.error.URLError("network unreachable") + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fail) + fetcher = adapters.make_catalog_fetcher(allow_network=True) + + assert fetcher(_source("builtin://community")) == { + "schema_version": "1.0", + "bundles": {}, + } + + +@pytest.mark.parametrize( + "target", + [ + # Remote-to-loopback redirect (accepted by URL validation, rejected by + # the strict redirect policy). + "https://localhost/internal/catalog.json", + # Malformed redirect target (unterminated IPv6 bracket). + "https://[::1/internal/catalog.json", + ], +) +def test_builtin_community_catalog_does_not_fall_back_for_redirect_policy_errors( + monkeypatch, tmp_path, target +): + """A redirect the shared client rejects as a policy violation must surface + as a hard error even though ``RedirectPolicyError`` subclasses ``URLError``. + + The fake ``open_url`` runs the real ``_StripAuthOnRedirect`` handler, so the + production classification is exercised instead of injecting the exception. + A snapshot is present to prove the fallback is not taken. + """ + catalog_path = tmp_path / "bundles" / "catalog.community.json" + catalog_path.parent.mkdir() + catalog_path.write_text( + '{"schema_version":"1.0","bundles":{}}', encoding="utf-8" + ) + monkeypatch.setattr(adapters, "_locate_core_pack", lambda: tmp_path) + + def redirect_into_policy_violation( + url, timeout=10, extra_headers=None, redirect_validator=None + ): + handler = _StripAuthOnRedirect((), redirect_validator) + handler.redirect_request( + urllib.request.Request(url), + io.BytesIO(b""), + 302, + "Found", + {}, + target, + ) + raise AssertionError("redirect should have been rejected") + + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", redirect_into_policy_violation + ) + fetcher = adapters.make_catalog_fetcher(allow_network=True) + + with pytest.raises(BundlerError, match="Failed to fetch catalog") as excinfo: + fetcher(_source("builtin://community")) + assert not isinstance(excinfo.value, adapters._CatalogUnavailable) + + +def test_builtin_community_catalog_falls_back_for_incomplete_read( monkeypatch, tmp_path ): + """A chunked response truncated mid-read is a transient transport failure + (``http.client.IncompleteRead`` is not an ``OSError``/``URLError``), so it + must use the packaged snapshot rather than surface as a hard error.""" catalog_path = tmp_path / "bundles" / "catalog.community.json" catalog_path.parent.mkdir() catalog_path.write_text( - '{"schema_version":"1.0","bundles":{"packaged":{' - '"id":"packaged","name":"Packaged","version":"1.0.0",' - '"role":"developer","description":"Packaged catalog entry.",' - '"author":"Spec Kit","license":"MIT","download_url":"",' - '"requires":{"speckit_version":">=0.1.0"},' - '"provides":{},"verified":false}}}', - encoding="utf-8", + '{"schema_version":"1.0","bundles":{}}', encoding="utf-8" ) monkeypatch.setattr(adapters, "_locate_core_pack", lambda: tmp_path) - fetcher = adapters.make_catalog_fetcher(allow_network=False) - result = fetcher(_source("builtin://community")) + class _TruncatedResponse: + def __enter__(self) -> Self: + return self - assert "packaged" in result["bundles"] + def __exit__(self, *exc) -> bool: + return False + + def geturl(self) -> str: + return adapters.COMMUNITY_CATALOG_URL + + def read(self, size: int = -1) -> bytes: + raise http.client.IncompleteRead(b"partial") + + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda *args, **kwargs: _TruncatedResponse(), + ) + fetcher = adapters.make_catalog_fetcher(allow_network=True) + + assert fetcher(_source("builtin://community")) == { + "schema_version": "1.0", + "bundles": {}, + } + + +@pytest.mark.parametrize( + "error", + [ + # urllib wraps a TLS handshake failure as URLError(reason=). + urllib.error.URLError( + ssl.SSLCertVerificationError("certificate verify failed") + ), + # Other call paths raise the ssl error directly (it subclasses OSError). + ssl.SSLCertVerificationError("certificate verify failed"), + ], + ids=["urlerror-wrapped", "direct"], +) +def test_builtin_community_catalog_does_not_fall_back_for_cert_verification_errors( + monkeypatch, tmp_path, error +): + """A TLS certificate-verification failure is a security failure, not a + transient availability problem, so it must surface as a hard error instead + of using the packaged snapshot. A snapshot is present to prove that.""" + catalog_path = tmp_path / "bundles" / "catalog.community.json" + catalog_path.parent.mkdir() + catalog_path.write_text( + '{"schema_version":"1.0","bundles":{}}', encoding="utf-8" + ) + monkeypatch.setattr(adapters, "_locate_core_pack", lambda: tmp_path) + + def fail(url, timeout=10, extra_headers=None, redirect_validator=None): + raise error + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fail) + fetcher = adapters.make_catalog_fetcher(allow_network=True) + + with pytest.raises(BundlerError, match="certificate verify failed") as excinfo: + fetcher(_source("builtin://community")) + assert not isinstance(excinfo.value, adapters._CatalogUnavailable) @pytest.mark.parametrize( diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py index 4e9120776c..21666cf1a7 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/unit/test_bundler_primitives.py @@ -87,8 +87,13 @@ def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch): import specify_cli import specify_cli._assets as assets + bundled = tmp_path / "wf" + bundled.mkdir() + (bundled / "workflow.yml").write_text( + "workflow:\n id: bundled-wf\n version: 1.0.0\n", encoding="utf-8" + ) monkeypatch.setattr( - assets, "_locate_bundled_workflow", lambda wid: tmp_path / "wf" + assets, "_locate_bundled_workflow", lambda wid: bundled ) calls: list[tuple] = [] monkeypatch.setattr( @@ -100,7 +105,7 @@ def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch): manager = primitive_manager("workflows", tmp_path, allow_network=False) manager.install(_component("workflows", "bundled-wf")) - assert calls == [("bundled-wf", False, None)] + assert calls == [(str(bundled / "workflow.yml"), True, None)] def test_assert_pinned_version_matches_passes(): diff --git a/tests/workflows/test_bundled_bugfix_assess_workflows.py b/tests/workflows/test_bundled_bugfix_assess_workflows.py new file mode 100644 index 0000000000..f9467abf62 --- /dev/null +++ b/tests/workflows/test_bundled_bugfix_assess_workflows.py @@ -0,0 +1,93 @@ +"""Guards for the bundled bugfix and assess workflows.""" + +from __future__ import annotations + +import pytest + +from specify_cli._assets import _locate_bundled_workflow +from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + +def _load_workflow(workflow_id: str) -> WorkflowDefinition: + directory = _locate_bundled_workflow(workflow_id) + assert directory is not None, f"bundled workflow '{workflow_id}' not found" + return WorkflowDefinition.from_yaml(directory / "workflow.yml") + + +@pytest.mark.parametrize("workflow_id", ["bugfix", "assess"]) +def test_bundled_workflow_validates_cleanly(workflow_id: str) -> None: + definition = _load_workflow(workflow_id) + assert validate_workflow(definition) == [] + + +def test_bugfix_workflow_has_expected_steps() -> None: + definition = _load_workflow("bugfix") + assert [step["id"] for step in definition.steps] == [ + "assess", + "review-assessment", + "fix", + "test", + ] + + expected_commands = { + "assess": ("speckit.bug.assess", "{{ inputs.report }} slug={{ inputs.slug }}"), + "fix": ("speckit.bug.fix", "slug={{ inputs.slug }}"), + "test": ("speckit.bug.test", "slug={{ inputs.slug }}"), + } + for step in definition.steps: + if step["id"] not in expected_commands: + continue + command, args = expected_commands[step["id"]] + assert step["command"] == command + assert step["integration"] == "{{ inputs.integration }}" + assert step["input"]["args"] == args + + gate = definition.steps[1] + assert gate.get("type") == "gate" + assert gate.get("options") == ["approve", "reject"] + assert gate.get("on_reject") == "abort" + + +def test_assess_workflow_has_expected_steps() -> None: + definition = _load_workflow("assess") + assert [step["id"] for step in definition.steps] == [ + "intake", + "research", + "define", + "shape", + "decide", + "review-verdict", + ] + + expected_commands = { + "intake": ("speckit.assess.intake", "{{ inputs.idea }} slug={{ inputs.slug }}"), + "research": ("speckit.assess.research", "slug={{ inputs.slug }}"), + "define": ("speckit.assess.define", "slug={{ inputs.slug }}"), + "shape": ("speckit.assess.shape", "slug={{ inputs.slug }}"), + "decide": ("speckit.assess.decide", "slug={{ inputs.slug }}"), + } + for step in definition.steps: + if step["id"] not in expected_commands: + continue + command, args = expected_commands[step["id"]] + assert step["command"] == command + assert step["integration"] == "{{ inputs.integration }}" + assert step["input"]["args"] == args + + final_gate = definition.steps[-1] + assert final_gate.get("type") == "gate" + assert final_gate.get("options") == ["approve", "reject"] + assert final_gate.get("on_reject") == "abort" + + +@pytest.mark.parametrize( + ("workflow_id", "required_inputs"), + [("bugfix", ("report", "slug")), ("assess", ("idea", "slug"))], +) +def test_bundled_workflow_has_required_inputs( + workflow_id: str, required_inputs: tuple[str, ...] +) -> None: + definition = _load_workflow(workflow_id) + for input_id in required_inputs: + assert definition.inputs[input_id].get("required") is True + assert definition.inputs.get("integration", {}).get("default") == "auto" diff --git a/workflows/README.md b/workflows/README.md index 2c1a9f2bb7..7382d6e625 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -539,6 +539,10 @@ workflows/ ├── README.md # This file ├── catalog.json # Official workflow catalog ├── catalog.community.json # Community workflow catalog -└── speckit/ # Built-in SDD cycle workflow +├── speckit/ # Built-in SDD cycle workflow +│ └── workflow.yml +├── bugfix/ # Built-in bug-fixing pipeline +│ └── workflow.yml +└── assess/ # Built-in idea-assessment pipeline └── workflow.yml ``` diff --git a/workflows/assess/workflow.yml b/workflows/assess/workflow.yml new file mode 100644 index 0000000000..1cab4d5cbf --- /dev/null +++ b/workflows/assess/workflow.yml @@ -0,0 +1,62 @@ +schema_version: "1.0" +workflow: + id: "assess" + name: "Idea Assessment Pipeline" + version: "1.0.0" + author: "GitHub" + description: "Runs intake → research → define → shape → decide with a verdict review gate before a manual /speckit.specify handoff" + +requires: + # Matches the composed assess extension's requirement (extensions/assess/extension.yml). + speckit_version: ">=0.9.0" + +inputs: + idea: + type: string + required: true + prompt: "Raw idea (pasted text, URL, ticket, or codebase pointer)" + slug: + type: string + required: true + prompt: "Assessment slug (short kebab-case name, e.g. offline-mode)" + integration: + type: string + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" + +steps: + - id: intake + command: speckit.assess.intake + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.idea }} slug={{ inputs.slug }}" + + - id: research + command: speckit.assess.research + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: define + command: speckit.assess.define + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: shape + command: speckit.assess.shape + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: decide + command: speckit.assess.decide + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: review-verdict + type: gate + message: "Review the assessment verdict. A go verdict is handed off manually with the specify command; this workflow stops here." + options: [approve, reject] + on_reject: abort diff --git a/workflows/bugfix/workflow.yml b/workflows/bugfix/workflow.yml new file mode 100644 index 0000000000..a914eeafa9 --- /dev/null +++ b/workflows/bugfix/workflow.yml @@ -0,0 +1,50 @@ +schema_version: "1.0" +workflow: + id: "bugfix" + name: "Guided Bug Fix" + version: "1.0.0" + author: "GitHub" + description: "Runs bug assess → human review gate → bug fix → bug test on a bug report" + +requires: + # Matches the composed bug extension's requirement (extensions/bug/extension.yml). + speckit_version: ">=0.9.0" + +inputs: + report: + type: string + required: true + prompt: "Bug report (pasted text or URL)" + slug: + type: string + required: true + prompt: "Bug slug (short kebab-case name, e.g. login-timeout)" + integration: + type: string + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" + +steps: + - id: assess + command: speckit.bug.assess + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.report }} slug={{ inputs.slug }}" + + - id: review-assessment + type: gate + message: "Review the assessment before any code change." + options: [approve, reject] + on_reject: abort + + - id: fix + command: speckit.bug.fix + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: test + command: speckit.bug.test + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" diff --git a/workflows/catalog.json b/workflows/catalog.json index c26a3230a1..d55754a819 100644 --- a/workflows/catalog.json +++ b/workflows/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-09-03T00:00:00Z", + "updated_at": "2026-09-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.json", "workflows": { "speckit": { @@ -10,7 +10,28 @@ "author": "GitHub", "version": "1.0.1", "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/speckit/workflow.yml", - "tags": ["sdd", "full-cycle"] + "tags": ["sdd", "full-cycle"], + "bundled": true + }, + "bugfix": { + "id": "bugfix", + "name": "Guided Bug Fix", + "description": "Runs bug assess → human review gate → bug fix → bug test on a bug report", + "author": "GitHub", + "version": "1.0.0", + "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/bugfix/workflow.yml", + "tags": ["bug", "triage", "workflow", "qa"], + "bundled": true + }, + "assess": { + "id": "assess", + "name": "Idea Assessment Pipeline", + "description": "Runs intake → research → define → shape → decide with a verdict review gate before a manual specify handoff", + "author": "GitHub", + "version": "1.0.0", + "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/assess/workflow.yml", + "tags": ["assessment", "discovery", "triage", "product"], + "bundled": true } } }