From 81d5cdbbf2c96f7ce1a2801c6185f2951f1f61be Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 7 Aug 2026 00:32:35 +0500 Subject: [PATCH 01/42] fix(agent-context): recurse for nested plans in Python mtime fallback (#3757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-context): recurse for nested plans in Python mtime fallback The Python port's mtime fallback discovered plans with a one-level specs/*/plan.md glob, so a scoped layout created via SPECIFY_FEATURE_DIRECTORY (specs///plan.md) was missed when feature.json is absent — the fallback returned no plan and the managed context section omitted the 'at ' line. The bash and PowerShell twins were already fixed to recurse (#3024); the Python twin was left behind. Switch to specs.rglob('plan.md') with the same symlink-safe containment check the bash twin uses (resolve each candidate and confirm it stays within the project root before ranking by mtime), so a plan reached through a specs/ symlink pointing outside the project is not selected. Adds parity regression tests (vs bash and vs PowerShell) covering a nested specs///plan.md; both fail on the pre-fix one-level glob. Fixes #3733 * test(agent-context): cover symlink containment in the mtime fallback The recursive fallback resolves each candidate before the relative_to() containment check, but nothing exercised that path. Add a parity test for a plan reachable only through a specs/ symlink pointing outside the project: relative_to() is lexical and would accept it, emitting an in-project-looking path for an out-of-project file. Both the bash twin and the Python port skip it, so the "at " line is omitted. Also correct the module docstring, which still described the fallback as scanning specs/*/plan.md one level deep. --- ...test_update_agent_context_python_parity.py | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/tests/extensions/test_update_agent_context_python_parity.py b/tests/extensions/test_update_agent_context_python_parity.py index 36e7fd4557..06015bbdc7 100644 --- a/tests/extensions/test_update_agent_context_python_parity.py +++ b/tests/extensions/test_update_agent_context_python_parity.py @@ -222,32 +222,6 @@ def test_python_custom_markers_matching_bash(tmp_path: Path) -> None: assert "old" not in content -@requires_posix_bash -def test_python_blank_markers_use_defaults_matching_bash(tmp_path: Path) -> None: - # Regression: with blank markers (config relying on the built-in defaults), - # the Bash port must fall back to DEFAULT_START/END, matching the Python and - # PowerShell ports. Previously the Bash config-parser transport dropped the - # trailing empty marker lines under $(...) command substitution, tripping the - # "malformed config parser output" guard so the default-marker substitution - # became unreachable and the context file was never updated. - markers = {"start": "", "end": ""} - repo_a, repo_b = twin_projects( - tmp_path, context_file="AGENTS.md", context_markers=markers - ) - add_plan(repo_a) - add_plan(repo_b) - - bash = run_bash(repo_a) - py = run_python(repo_b) - - assert_parity(bash, py, repo_a, repo_b) - content = (repo_b / "AGENTS.md").read_bytes() - assert content == (repo_a / "AGENTS.md").read_bytes() - assert b"" 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"] From d2befe40211a47bfd15eb55d086d3299bd40fe72 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:16:39 -0500 Subject: [PATCH 02/42] chore(deps): bump DavidAnson/markdownlint-cli2-action (#4006) Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 24.1.0 to 24.2.0. - [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases) - [Commits](https://github.com/davidanson/markdownlint-cli2-action/compare/6bf21b07787794f89a243495939cd651942aeabe...21c1be1b93ad9ed58fa840aacc3f279cde2a72ff) --- updated-dependencies: - dependency-name: DavidAnson/markdownlint-cli2-action dependency-version: 24.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' From 8865e57344672b4f11639e78f8ca207ead4b75ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:44:47 -0500 Subject: [PATCH 03/42] chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.5 (#4005) * chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.5 Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.37.3 to 4.37.5. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...d1ba80a13dd99fba24a470575428917156a28b43) --- updated-dependencies: - dependency-name: github/codeql-action/analyze dependency-version: 4.37.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * chore(deps): bump github/codeql-action/init to match analyze 4.37.5 Dependabot only bumped the analyze step; keep init on the same 4.37.5 SHA so both CodeQL steps use the same release. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 126ceec5-7d64-444c-8cd4-d60b225d46f5 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 126ceec5-7d64-444c-8cd4-d60b225d46f5 --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 }}" From 920ed7546dbfb3292fb909803548b94b08594823 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Fri, 7 Aug 2026 22:04:39 +0500 Subject: [PATCH 04/42] fix(bundle): wrap malformed YAML in a local .zip bundle manifest (#4013) `_local_manifest_source` handles three local bundle sources. The directory and `bundle.yml` branches both go through `BundleManifest.from_file` -> `load_yaml`, which converts a parse failure into a `BundlerError`. The `.zip` branch instead parses inline with a bare `_yaml.safe_load`. `yaml.YAMLError` derives directly from `Exception` -- it is neither a `ValueError` nor an `OSError` -- so it escapes `bundle_install`'s `except BundlerError` and reaches the user as a raw `yaml.parser.ParserError` traceback. The remote counterpart of this same call, `_download_manifest`, already guards it and even names `_yaml.YAMLError` explicitly. Only the local zip path was missed, so the same corrupt manifest is reported cleanly when fetched from a catalog but crashes when installed from disk. Before, for the identical malformed bundle.yml: specify bundle install ./bundle-dir -> Error: Invalid YAML in ... (exit 1) specify bundle install ./bundle.yml -> Error: Invalid YAML in ... (exit 1) specify bundle install ./bundle.zip -> ParserError traceback Two regression tests: one pins the `BundlerError` contract on the zip branch, and one drives all three local sources through the CLI to assert they now fail alike. Co-authored-by: Claude Opus 4.8 (1M context) --- src/specify_cli/commands/bundle/__init__.py | 12 ++++- .../integration/test_bundler_local_install.py | 44 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 7476cb41b5..10df8aca14 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -771,7 +771,17 @@ def _local_manifest_source(arg: str): error_type=BundlerError, label="bundle manifest", ) - data = _yaml.safe_load(io.BytesIO(raw)) + try: + data = _yaml.safe_load(io.BytesIO(raw)) + except _yaml.YAMLError as exc: + # The sibling directory/bundle.yml branches reach YAML through + # load_yaml(), which turns a parse failure into a BundlerError. This + # branch parses inline, so without this it raises a raw YAMLError -- + # neither a ValueError nor an OSError -- which escapes + # bundle_install()'s `except BundlerError` as a traceback. + raise BundlerError( + f"Invalid YAML in bundle.yml inside '{candidate}': {exc}" + ) from exc return BundleManifest.from_dict(data) if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"): diff --git a/tests/integration/test_bundler_local_install.py b/tests/integration/test_bundler_local_install.py index 164de57006..5ca873c78a 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, From 5d9ac6a3f56e647499060db077d178db93eb499f Mon Sep 17 00:00:00 2001 From: Marsel Safin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:16:09 +0200 Subject: [PATCH 05/42] fix(events): skip an unreadable command template (#3956) * fix(events): skip an unreadable command template _render_command_template() read the resolved template with a bare read_text(), so a template file that exists but cannot be read or decoded (permission error, non-UTF-8 bytes) crashed event dispatch with a raw OSError/UnicodeDecodeError. Every sibling failure in this path (missing template, unresolvable command) already returns None so the dispatcher falls back cleanly. Wrap the read and return None on OSError/UnicodeDecodeError, matching the sibling contract. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: cover the OSError half of the unreadable-template boundary Review follow-up: add a mocked PermissionError case so both promised exception paths are protected under privileged CI. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/events.py | 10 ++++++- tests/integrations/test_events.py | 47 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 96405a7391..3469115d6e 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -548,7 +548,15 @@ def _resolve_event_command_argv( """ from .integrations.base import IntegrationBase - content = template_path.read_text(encoding="utf-8") + try: + content = template_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + # An unreadable or undecodable template cannot declare a runnable + # script. Degrade to "no argv" like every other failure in this + # resolver (missing frontmatter, malformed YAML, absent scripts) + # instead of leaking a raw traceback through + # resolve_and_run_event_command. + return None m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) if not m: return None 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): From 2a28f62e25727f23c1bb83303f7161c104f80198 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Fri, 7 Aug 2026 22:17:46 +0500 Subject: [PATCH 06/42] fix(integrations): wrap a non-UTF-8 catalog response (#4011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(integrations): wrap a non-UTF-8 catalog response `_fetch_single_catalog` decodes the response body with `.decode("utf-8")` before handing it to `json.loads`. A non-UTF-8 body therefore raises `UnicodeDecodeError`, which is a sibling of `json.JSONDecodeError` under `ValueError` rather than a subclass of it, so neither the `URLError` nor the `JSONDecodeError` handler catches it. The raw exception escapes `_get_merged_integrations`, whose `except IntegrationCatalogError` is specifically designed to warn and skip a bad catalog and carry on with the remaining ones. One catalog served over a misconfigured proxy or truncated mid-multibyte-sequence thus takes down `specify integration search` entirely instead of degrading to a warning. Wrap it in `IntegrationCatalogError`, matching the convention already used for the same decode in `authentication/azure_devops.py`, which lists `UnicodeDecodeError` alongside `JSONDecodeError`. Note that the cache-read path in this same method already tolerates this via its `UnicodeError` clause; only the network path was unguarded. Two regression tests: one pins the wrapped-error contract on the fetch, and one covers the behaviour that actually motivates it — a broken catalog is skipped with a warning while a healthy sibling catalog still resolves. Co-Authored-By: Claude Opus 4.8 (1M context) * test(integrations): use the shared urlopen routing fixture The raw-bytes helper patched `open_url` wholesale, which skipped the real URL validation and redirect handling inside it. This module already imports `route_opener_open_through_urlopen`, the repo's shared fixture that routes `build_opener().open()` back through `urlopen` for exactly this reason, so patching `urlopen` instead keeps the stub effective while still exercising `open_url` itself. Renamed to `_patch_urlopen_bytes` to sit alongside the existing `_patch_urlopen`, whose signature it now mirrors. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(integrations): restore the non-UTF-8 handler The previous commit reverted the source change by accident while reworking the tests, leaving the regression tests passing against an unfixed module. Restores the `except UnicodeDecodeError` clause. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/specify_cli/integrations/catalog.py | 9 ++ .../integrations/test_integration_catalog.py | 119 ++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py index b3be8a84e3..e18d30a6fa 100644 --- a/src/specify_cli/integrations/catalog.py +++ b/src/specify_cli/integrations/catalog.py @@ -237,6 +237,15 @@ def _fetch_single_catalog( raise IntegrationCatalogError( f"Failed to fetch catalog from {entry.url}: {exc}" ) + except UnicodeDecodeError as exc: + # A non-UTF-8 response body fails at .decode() before json.loads() + # ever runs, so JSONDecodeError below does not cover it (the two are + # sibling ValueError subclasses, not parent/child). Without this the + # raw UnicodeDecodeError escapes _get_merged_integrations()'s + # "warn and skip this catalog" handler and kills the whole command. + raise IntegrationCatalogError( + f"Catalog from {entry.url} is not valid UTF-8: {exc}" + ) except json.JSONDecodeError as exc: raise IntegrationCatalogError( f"Invalid JSON in catalog from {entry.url}: {exc}" diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index 68e8970c42..9b02632992 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -392,6 +392,125 @@ def fake_urlopen(req, timeout=10): 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)) From 6da6956c3ee1d7321df360554c0d387d0ee933a9 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:31:44 -0500 Subject: [PATCH 07/42] chore: release 0.16.1, begin 0.16.2.dev0 development (#4014) * chore: bump version to 0.16.1 * chore: begin 0.16.2.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26a1f3bea1..476616bc40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ +## [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/pyproject.toml b/pyproject.toml index 593f5fa218..ca19633915 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.16.1.dev0" +version = "0.16.2.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From 247abbf5e4e23b6e1f0c1fe6316abb356937184f Mon Sep 17 00:00:00 2001 From: Marsel Safin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:49:58 +0200 Subject: [PATCH 08/42] fix(presets): treat an unreadable core template as missing (#3961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(presets): treat an unreadable core template as missing _substitute_core_template() read the resolved core template with a bare read_text(), so one corrupted project-owned override in .specify/templates/commands/ crashed the whole wrap-strategy command registration with a raw UnicodeDecodeError. Both callers (CommandRegistrar.register_pack and _register_commands) are unguarded here, even though register_pack already skips an unreadable preset source with a warning a few lines above the call. Treat an unreadable core template like a missing one — warn and return the body unchanged with empty frontmatter — matching the function's documented no-core contract. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: assert the unreadable-core warning instead of suppressing it Review follow-up: use pytest.warns so removing or changing the promised warning fails the test. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 20 ++++++++++++++++++-- tests/test_presets.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 157bac6c46..bd9efeac2f 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -178,7 +178,7 @@ def _substitute_core_template( by the core template body and core_frontmatter holds the core template's parsed frontmatter (so callers can inherit scripts/agent_scripts from it). Both are unchanged / empty when the placeholder is absent or the core template file does - not exist. + not exist or cannot be read. """ if "{CORE_TEMPLATE}" not in body: return body, {} @@ -208,7 +208,23 @@ def _substitute_core_template( if core_file is None: return body, {} - core_frontmatter, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8")) + # Treat an unreadable/undecodable core template like a missing one so a + # single corrupted project override cannot crash command registration — + # the wrap-strategy callers already skip an unreadable preset source with + # a warning (CommandRegistrar.register_pack). + try: + core_content = core_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + import warnings + + warnings.warn( + f"Ignoring core template for command '{cmd_name}': could not read " + f"'{core_file.name}' ({exc.__class__.__name__}: {exc}).", + stacklevel=2, + ) + return body, {} + + core_frontmatter, core_body = registrar.parse_frontmatter(core_content) return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter diff --git a/tests/test_presets.py b/tests/test_presets.py index 80f2ddab58..593c11dbcf 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -10790,6 +10790,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 From 684b3d8e05263a7c1948d3d0699ab1cb4f77c3d5 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Sat, 8 Aug 2026 01:55:55 +0800 Subject: [PATCH 09/42] feat(extensions): accept provides.templates and provides.scripts in manifest (#4012) * feat(extensions): accept provides.templates and provides.scripts in manifest Extensions could only formally declare commands under `provides` (plus config/hooks/events); templates and scripts shipped by an extension were picked up purely by filename convention, with no id, description, or metadata. Add optional `provides.templates` and `provides.scripts` sections to the extension manifest schema, mirroring the preset template shape minus an authorable `strategy` (extension artifacts always resolve as replace, so a present `strategy` key is now a validation error rather than a silently accepted no-op). ExtensionManifest gains `templates`/`scripts` properties so tooling can enumerate an extension's declared artifacts directly from the manifest. An extension may now satisfy the "must provide something" rule with only a template or script, not just a command/hook/event. Addresses the manifest-schema portion of #4010; resolver authoritative-vs-convention precedence for these new sections is left for a follow-up. * fix(presets): wire extension-declared templates/scripts into resolver collect_all_layers only consulted ExtensionManifest for command resolution, leaving provides.templates/.scripts purely decorative -- a declared entry whose file didn't sit at the conventional path was validated but never resolved. Extend the existing manifest-fallback branch to cover template_type "template" and "script" the same way it already does "command": convention lookup first, manifest lookup as fallback so undeclared on-disk files keep resolving unchanged. * fix(presets): make extension manifest lookup authoritative over convention Copilot review on #4012 found the manifest-declared template/script lookup was gated on convention lookup missing first, so a stale conventional file could shadow a declared entry at a non-conventional path, and resolve() never consulted the manifest at all (only collect_all_layers() did). Add a shared _extension_manifest_declared_template() helper and check it before convention-based lookup in both resolve() and collect_all_layers(), mirroring the preset manifest precedence. Also update EXTENSION-DEVELOPMENT-GUIDE.md, which still claimed provides only supports commands and required a command or hook. * fix(presets): stop resolving symlinks in extension manifest candidate path _extension_manifest_declared_template() resolved ext_dir/rel_path before returning it, which follows symlinks in ext_dir's ancestors (e.g. macOS's symlinked tmp dir) and diverges from the unresolved paths convention-based lookup returns for the same directory. Resolve only for the traversal containment check; return the unresolved candidate. Fixes the 4 CI test failures across all OS/Python matrix jobs on #4012. --- extensions/EXTENSION-API-REFERENCE.md | 40 +++- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 21 +- src/specify_cli/extensions/__init__.py | 99 ++++++++- src/specify_cli/presets/__init__.py | 98 +++++++-- tests/test_extensions.py | 239 ++++++++++++++++++++++ tests/test_presets.py | 175 ++++++++++++++++ 6 files changed, 646 insertions(+), 26 deletions(-) diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index bf85d18826..a7bece0b89 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 @@ -143,6 +179,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 ``` diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index 5da95c9d54..5030565b14 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -177,9 +177,11 @@ Compatibility requirements. What the extension provides. -**Optional sub-fields**: +**Optional sub-fields** (at least one of `commands`, `templates`, `scripts`, `hooks`, or `events` is required): -- `commands`: Array of command objects (at least one command or hook is required) +- `commands`: Array of command objects +- `templates`: Array of template objects +- `scripts`: Array of script objects **Command object**: @@ -188,6 +190,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/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 9fa44d3809..2becae9bc1 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -61,6 +61,13 @@ ) EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") +# Naming pattern for provides.templates / provides.scripts entries. Unlike +# commands, these are not namespaced (they aren't invoked via a command +# name), so they follow the same plain slug pattern as extension.id. +VALID_EXTENSION_ARTIFACT_NAME_PATTERN = re.compile(r"^[a-z0-9-]+$") + +VALID_SCRIPT_RUNTIMES = frozenset({"bash", "powershell", "python"}) + VALID_EFFECTS = frozenset({"read-only", "read-write"}) DEFAULT_HOOK_PRIORITY = 10 @@ -368,11 +375,17 @@ def _validate(self): f"Invalid provides: expected a mapping, got {type(provides).__name__}" ) commands = provides.get("commands", []) + templates = provides.get("templates", []) + scripts = provides.get("scripts", []) hooks = self.data.get("hooks") events = self.data.get("events") if "commands" in provides and not isinstance(commands, list): raise ValidationError("Invalid provides.commands: expected a list") + if "templates" in provides and not isinstance(templates, list): + raise ValidationError("Invalid provides.templates: expected a list") + if "scripts" in provides and not isinstance(scripts, list): + raise ValidationError("Invalid provides.scripts: expected a list") if "hooks" in self.data and not isinstance(hooks, dict): raise ValidationError("Invalid hooks: expected a mapping") if "events" in self.data: @@ -382,9 +395,17 @@ def _validate(self): has_commands = bool(commands) has_hooks = bool(hooks) has_events = bool(events) + has_templates = bool(templates) + has_scripts = bool(scripts) + + if not has_commands and not has_hooks and not has_events and not has_templates and not has_scripts: + raise ValidationError( + "Extension must provide at least one command, hook, or event " + "(or a declared template/script)" + ) - if not has_commands and not has_hooks and not has_events: - raise ValidationError("Extension must provide at least one command, hook, or event") + self._validate_provided_artifacts(templates, section="templates", singular="template") + self._validate_provided_artifacts(scripts, section="scripts", singular="script") # Validate hook values (if present). # Each event is a single mapping or a list of mappings. @@ -545,6 +566,70 @@ def _validate(self): f"The extension author should update the manifest." ) + @staticmethod + def _validate_provided_artifacts(entries: List[Any], section: str, singular: str) -> None: + """Validate provides.templates / provides.scripts entries. + + Mirrors the shape/path-safety checks PresetManifest applies to its + non-command templates, minus 'type' (the section name already + distinguishes template vs script) and 'strategy' (extension-provided + artifacts are always 'replace' -- see the forced-replace resolver + behavior for extension layers in presets/__init__.py). A present + 'strategy' key is rejected rather than silently ignored, so an author + who copies a preset-style entry gets a clear error instead of a + silently-dropped field. + """ + for entry in entries: + if not isinstance(entry, dict): + raise ValidationError( + f"Each entry in 'provides.{section}' must be a mapping" + ) + if "name" not in entry or "file" not in entry: + raise ValidationError(f"{singular.capitalize()} missing 'name' or 'file'") + + name = entry["name"] + if not isinstance(name, str): + raise ValidationError( + f"Invalid {singular} name: expected a string, got {type(name).__name__}" + ) + if not VALID_EXTENSION_ARTIFACT_NAME_PATTERN.match(name): + raise ValidationError( + f"Invalid {singular} name '{name}': " + "must be lowercase alphanumeric with hyphens only" + ) + + file_value = entry["file"] + reason = relative_extension_path_violation(file_value) + if reason: + label = repr(file_value) if isinstance(file_value, str) else f"for {singular} '{name}'" + raise ValidationError(f"Invalid {singular} 'file' {label}: {reason}") + + if "description" in entry and not isinstance(entry["description"], str): + raise ValidationError( + f"Invalid {singular} description for '{name}': expected a string" + ) + + if "strategy" in entry: + raise ValidationError( + f"Invalid {singular} entry '{name}': 'strategy' is not authorable for " + "extension-provided artifacts, which always use 'replace' semantics" + ) + + if section == "scripts" and "runtimes" in entry: + runtimes = entry["runtimes"] + if not isinstance(runtimes, list) or not all( + isinstance(r, str) for r in runtimes + ): + raise ValidationError( + f"Invalid runtimes for script '{name}': expected a list of strings" + ) + invalid = sorted(set(runtimes) - VALID_SCRIPT_RUNTIMES) + if invalid: + raise ValidationError( + f"Invalid runtimes {invalid} for script '{name}': " + f"must be one of {sorted(VALID_SCRIPT_RUNTIMES)}" + ) + @staticmethod def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]: """Try to auto-correct a non-conforming command name to the required pattern. @@ -615,6 +700,16 @@ def config(self) -> List[Dict[str, Any]]: return [] return raw + @property + def templates(self) -> List[Dict[str, Any]]: + """Get list of declared templates (provides.templates).""" + return self.data.get("provides", {}).get("templates", []) + + @property + def scripts(self) -> List[Dict[str, Any]]: + """Get list of declared scripts (provides.scripts).""" + return self.data.get("provides", {}).get("scripts", []) + @property def hooks(self) -> Dict[str, Any]: """Get hook definitions.""" diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index bd9efeac2f..f01d0c1561 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -4995,6 +4995,64 @@ def _manifest_declared_template( return tmpl, None return None, None + def _extension_manifest_declared_template( + self, ext_dir: Path, template_name: str, template_type: str + ) -> tuple[dict | None, Path | None]: + """Resolve an extension's manifest-declared command/template/script entry and usable file. + + Mirrors ``_manifest_declared_template`` (for presets): returns ``(entry, candidate)`` + where ``entry`` is the matching ``provides.`` mapping, or ``None`` if the + extension has no (valid) manifest or doesn't declare this ``(name, type)``. + ``candidate`` is the declared ``file:`` resolved under ``ext_dir`` IFF it is a + regular file that stays within ``ext_dir`` (guards against path traversal via a + malformed manifest, mirroring ``resolve_extension_command_via_manifest``); + ``None`` otherwise. + + The manifest is authoritative: when ``entry`` is not ``None`` but ``candidate`` is + ``None``, callers must NOT fall back to convention-based lookup — that would mask + a typo or pick up an undeclared file. Shared by ``resolve()`` and + ``collect_all_layers()`` so their manifest-first resolution cannot silently + diverge (the divergence flagged in review on #4012). + """ + if template_type not in ("command", "template", "script"): + return None, None + ext_manifest_path = ext_dir / "extension.yml" + if not ext_manifest_path.exists(): + return None, None + from ..extensions import ExtensionManifest, ValidationError as ExtValidationError + + try: + ext_manifest = ExtensionManifest(ext_manifest_path) + except (ExtValidationError, yaml.YAMLError, OSError, TypeError, AttributeError): + return None, None + if template_type == "command": + entries = ext_manifest.commands + elif template_type == "template": + entries = ext_manifest.templates + else: + entries = ext_manifest.scripts + for entry in entries: + if entry.get("name") != template_name: + continue + file_rel = entry.get("file") + if not file_rel: + return entry, None + rel_path = Path(file_rel) + if rel_path.is_absolute(): + return entry, None + candidate = ext_dir / rel_path + try: + # Resolve only for the containment check, not for the + # returned path -- resolving the returned path would follow + # symlinks in ext_dir's ancestors (e.g. a symlinked tmp dir + # on macOS) and diverge from the unresolved paths convention + # lookup returns for the same directory. + candidate.resolve().relative_to(ext_dir.resolve()) # raises ValueError if outside + except (OSError, ValueError): + return entry, None + return entry, (candidate if candidate.is_file() else None) + return None, None + def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: """Build unified list of registered and unregistered extensions sorted by priority. @@ -5131,6 +5189,16 @@ def resolve( ext_dir = self.extensions_dir / ext_id if not ext_dir.is_dir(): continue + # The extension manifest is authoritative, same as preset manifests + # above: check it before convention-based lookup so a declared entry + # at a non-conventional path wins over a stale conventional file. + entry, manifest_candidate = self._extension_manifest_declared_template( + ext_dir, template_name, template_type + ) + if manifest_candidate is not None: + return manifest_candidate + if entry is not None: + continue for subdir in subdirs: if subdir: candidate = ext_dir / subdir / f"{template_name}{ext}" @@ -5440,27 +5508,15 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: ext_dir = self.extensions_dir / ext_id if not ext_dir.is_dir(): continue - # Try convention-based lookup first - candidate = _find_in_subdirs(ext_dir) - # If not found and this is a command, check extension manifest - if candidate is None and template_type == "command": - ext_manifest_path = ext_dir / "extension.yml" - if ext_manifest_path.exists(): - try: - from ..extensions import ExtensionManifest, ValidationError as ExtValidationError - ext_manifest = ExtensionManifest(ext_manifest_path) - for cmd in ext_manifest.commands: - if cmd.get("name") == template_name: - cmd_file = cmd.get("file") - if cmd_file: - c = ext_dir / cmd_file - if c.exists(): - candidate = c - break - except (ExtValidationError, yaml.YAMLError): - # Invalid extension manifest — fall back to - # convention-based lookup (already attempted above). - pass + # The extension manifest is authoritative, same as preset manifests + # above: check it before convention-based lookup so a declared entry + # at a non-conventional path wins over a stale conventional file, and + # a declared-but-missing file isn't silently masked by convention. + entry, candidate = self._extension_manifest_declared_template( + ext_dir, template_name, template_type + ) + if entry is None: + candidate = _find_in_subdirs(ext_dir) if candidate: if ext_meta: version = ext_meta.get("version", "?") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index d668019087..6508826dc9 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1020,6 +1020,245 @@ 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_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_presets.py b/tests/test_presets.py index 593c11dbcf..c35a370608 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -11249,6 +11249,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 ===== From d751231468223551a67140ba927fc1a78b0a3aa5 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:15:14 -0500 Subject: [PATCH 10/42] docs: document installing specify-cli from a custom package index (#4032) * docs: document installing specify-cli from a custom package index Add a generic section to the PyPI install guide covering how to point uv, pipx, and pip at a non-default package index (env var and flags), with a placeholder URL, plus notes on pins/upgrades and authentication. Link to it from the main installation guide. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6ef3f75-54e9-4789-902b-4f0adeaadfad * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Copilot-Session: d6ef3f75-54e9-4789-902b-4f0adeaadfad --- docs/install/pypi.md | 21 +++++++++++++++++++++ docs/installation.md | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) 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 From 1a60d1b6b8390310ce4521f207035cfc1620e1e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:19:09 -0500 Subject: [PATCH 11/42] [bug-fix] Fix preset-wrap-drops-argument-hint: inherit argument-hint from core template (#3996) * Fix preset-wrap-drops-argument-hint: inherit argument-hint from core Apply the remediation from the bug assessment on issue #3991. Extend the inheritance allowlist in _register_skills and _compose_layers to include 'argument-hint', so wrap-strategy presets that omit this key will inherit it from the core template rather than silently dropping it and risking its value being leaked into description. Refs #3991 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(presets): guard wrap argument-hint inheritance for unmapped command The existing regression test for #3991 wraps `speckit.specify`, whose stem is in Claude's ARGUMENT_HINTS map. The string-injection fallback in post_process_skill_content re-adds argument-hint even when wrap composition drops it, so that test passes with or without the inheritance fix and does not actually guard the regression. Add a parallel test that wraps an extension-like command (`speckit.myfeature`) absent from ARGUMENT_HINTS, so the wrap-composition inheritance is the only path that can carry argument-hint into the SKILL.md. This test fails without the fix and passes with it. Refs #3991 Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 970babe2-48cd-4c41-adae-0282d879a9ce --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Copilot-Session: 970babe2-48cd-4c41-adae-0282d879a9ce --- src/specify_cli/presets/__init__.py | 4 +- tests/test_presets.py | 157 ++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index f01d0c1561..891b5e45bf 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -2759,7 +2759,7 @@ def _register_skills( if frontmatter.get("strategy") == "wrap": body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar) frontmatter = dict(frontmatter) - for key in ("scripts", "agent_scripts"): + for key in ("scripts", "agent_scripts", "argument-hint"): if key not in frontmatter and key in core_frontmatter: frontmatter[key] = core_frontmatter[key] @@ -5814,7 +5814,7 @@ def _parse_fm_yaml(fm_block: str) -> dict: # Inherit scripts/agent_scripts from base frontmatter if missing if base_frontmatter_text and base_frontmatter_text != top_frontmatter_text: base_fm = _parse_fm_yaml(base_frontmatter_text) - for key in ("scripts", "agent_scripts"): + for key in ("scripts", "agent_scripts", "argument-hint"): if key not in top_fm and key in base_fm: top_fm[key] = base_fm[key] diff --git a/tests/test_presets.py b/tests/test_presets.py index c35a370608..86847b1784 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4727,6 +4727,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). From 16cfab7724a02da6e35fb842f34da70ab883b355 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:22:31 -0500 Subject: [PATCH 12/42] feat(presets): resolve constitution templates at command time (#3984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(presets): resolve constitutions at command time Gate install-time constitution materialization behind the constitution-sync preset while preserving one-time init seeding and authored-file safeguards. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84 * fix(presets): emit composed template content Add a machine-readable preset resolve mode backed by PresetResolver.resolve_content and require the constitution command to consume it. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84 * fix(presets): unify runtime template composition Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84 * fix(presets): secure runtime template resolution Align runtime resolution across script variants, validate registry path components, and honor canonical extension ordering and convention paths. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(presets): align runtime priority semantics Normalize and tie-break preset priorities consistently across script variants, and preserve template bytes when Python materializes generated files. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(presets): stop at effective template base Avoid parsing irrelevant lower layers once resolution reaches a replace base, and decode raw bytes so Python preserves source line endings. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(presets): align extension template resolution Support root-level extension templates across runtime resolvers, fail safely when Bash cannot parse an extension registry, and validate requested templates in every prerequisite output mode. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(presets): resolve dotted command identifiers Route safe dotted names through command resolution, correct traversal coverage, and make Windows CI text decoding explicit. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Avoid orphan feature directories on template errors Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Align malformed preset manifest handling Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * Complete runtime resolver parity Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * Fail closed on resolver input errors Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * Force UTF-8 and full manifest validation Force UTF-8 decoding for registry and manifest reads in the Bash and PowerShell embedded-Python parsers so resolution no longer depends on the process locale, and validate every manifest template entry's required fields, type, and strategy consistent with the canonical PresetManifest. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * Fail closed on empty manifests and corrupt registries Reject manifests missing the provides/templates sections or declaring an empty template list in all three runtime resolvers, matching the canonical PresetManifest which treats those as invalid instead of silently degrading a composing layer to a convention `replace` lookup. Make a corrupt or unreadable extension registry fail closed in Bash, PowerShell, and Python instead of swallowing the error and treating every on-disk extension directory as unregistered-and-enabled, which could activate a disabled extension. Read the preset and extension registries as explicit UTF-8 in the PowerShell resolver so priority/enabled-state decoding no longer depends on the process code page under Windows PowerShell 5.1. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * fix(presets): fail closed when extension registry is not a regular file The Bash and Python resolvers used is_file()/`-f` to gate reading the extension `.registry`, which returns false for a directory or a broken symlink at that path. In those cases the resolvers treated the registry as absent and scanned every on-disk extension directory as unregistered and enabled — a fail-open path. Detect any filesystem entry at the registry path (including broken symlinks) and reject unless it is a readable regular file. PowerShell now rejects a non-leaf entry explicitly for parity. Adds directory- and broken-symlink parity regressions. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * fix(presets): fail closed on corrupt registry in canonical resolver and PowerShell Two remaining fail-open paths for an invalid extension registry: - The canonical PresetResolver enumerated extensions through ExtensionRegistry, whose _load() normalizes a corrupt or unreadable registry to an empty mapping. The directory scan then admitted every on-disk extension directory as unregistered-and-enabled, so a corrupt registry could still supply constitution content at init and through constitution-sync materialization. Add a non-invasive is_corrupt() probe (recovery behavior for install/enable/disable is unchanged) and raise from _get_all_extensions_by_priority() when the registry exists but is invalid. _load() now also recovers from OSError/UnicodeDecodeError so a directory or unreadable registry no longer crashes construction. - The PowerShell resolver gated the registry read with Test-Path, which returns false for a dangling symlink on Windows, letting a broken .registry symlink bypass the guard and enable every on-disk extension. Detect the entry via directory enumeration (which observes a broken symlink) and reject it unless it is a readable regular file. Adds canonical corrupt/directory-registry regressions and extends the broken-symlink parity test to PowerShell. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * fix(presets): detect dangling registry symlink in ExtensionRegistry.is_corrupt is_corrupt() gated on Path.exists(), which follows symlinks and returns False for a dangling .registry symlink — so the canonical PresetResolver treated it as an absent registry and fell back to scanning every on-disk extension directory as unregistered-and-enabled, reopening the fail-open path this guard closes. Detect lexical existence with os.path.lexists and require a regular file before parsing, so a broken symlink (or directory) is reported corrupt and resolution fails closed. Adds a canonical broken-symlink regression alongside the directory case. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84 Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c --- presets/ARCHITECTURE.md | 13 + presets/README.md | 10 + presets/catalog.json | 4 +- presets/constitution-sync/README.md | 21 +- presets/constitution-sync/preset.yml | 2 +- scripts/bash/check-prerequisites.sh | 51 +- scripts/bash/common.sh | 424 +++++++--- scripts/bash/create-new-feature.sh | 23 +- scripts/bash/resolve-template.sh | 57 ++ scripts/bash/setup-plan.sh | 10 +- scripts/bash/setup-tasks.sh | 11 +- scripts/powershell/check-prerequisites.ps1 | 20 +- scripts/powershell/common.ps1 | 321 ++++++-- scripts/powershell/create-new-feature.ps1 | 13 +- scripts/powershell/resolve-template.ps1 | 38 + scripts/powershell/setup-plan.ps1 | 6 +- scripts/powershell/setup-tasks.ps1 | 10 +- scripts/python/check_prerequisites.py | 59 +- scripts/python/common.py | 299 ++++++- scripts/python/create_new_feature.py | 33 +- scripts/python/resolve_template.py | 63 ++ scripts/python/setup_plan.py | 23 +- scripts/python/setup_tasks.py | 21 +- src/specify_cli/extensions/__init__.py | 39 + src/specify_cli/presets/__init__.py | 69 +- src/specify_cli/presets/_commands.py | 24 +- templates/commands/checklist.md | 10 +- templates/commands/constitution.md | 25 +- templates/commands/tasks.md | 4 +- .../test_integration_base_markdown.py | 4 +- .../test_integration_base_skills.py | 2 + .../test_integration_base_toml.py | 2 + .../test_integration_base_yaml.py | 2 + tests/integrations/test_integration_cline.py | 2 + .../integrations/test_integration_copilot.py | 3 + .../integrations/test_integration_generic.py | 2 + tests/parity_helpers.py | 61 ++ .../test_check_prerequisites_python_parity.py | 82 ++ tests/test_command_template_py_scripts.py | 2 +- .../test_create_new_feature_python_parity.py | 100 ++- tests/test_presets.py | 238 +++++- tests/test_resolve_template_python_parity.py | 753 ++++++++++++++++++ tests/test_setup_plan_python_parity.py | 80 +- tests/test_setup_tasks.py | 2 +- tests/test_setup_tasks_python_parity.py | 38 + 45 files changed, 2718 insertions(+), 358 deletions(-) create mode 100644 scripts/bash/resolve-template.sh create mode 100644 scripts/powershell/resolve-template.ps1 create mode 100644 scripts/python/resolve_template.py create mode 100644 tests/test_resolve_template_python_parity.py 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/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/catalog.json b/presets/catalog.json index 196115ffb4..39bacb4157 100644 --- a/presets/catalog.json +++ b/presets/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-24T00:00:00Z", + "updated_at": "2026-08-04T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json", "presets": { "lean": { @@ -30,7 +30,7 @@ "name": "Constitution Template Sync", "id": "constitution-sync", "version": "1.0.0", - "description": "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts.", + "description": "Opt-in: restores guarded install-time constitution seeding and /constitution propagation for teams that treat materialized templates as reviewed artifacts.", "author": "github", "repository": "https://github.com/github/spec-kit", "license": "MIT", diff --git a/presets/constitution-sync/README.md b/presets/constitution-sync/README.md index 5c4a9825b4..8eb01d7b7c 100644 --- a/presets/constitution-sync/README.md +++ b/presets/constitution-sync/README.md @@ -1,13 +1,15 @@ # Constitution Template Sync -An **opt-in** preset that restores `/constitution`'s ability to propagate amended guidance into your -project's own templates and command files. After you update the constitution, it aligns -`plan-template.md`, `spec-template.md`, `tasks-template.md`, project-local command files, and +An **opt-in** preset that restores materialized constitution workflows. It refreshes an unchanged +generated `.specify/memory/constitution.md` when constitution-providing presets are installed, +removed, enabled, disabled, or reprioritized. After `/constitution` updates the live file, it also +aligns `plan-template.md`, `spec-template.md`, `tasks-template.md`, project-local command files, and guidance docs so they reflect the current principles. This propagation used to be built into `/constitution`; it was dropped when the command moved to the -preset model. Installing this preset opts you back into it: you get the guidance materialized into -reviewed, committed artifacts instead of relying on runtime resolution alone. +preset model. Installing this preset opts you back into materialization: preset stack changes refresh +the generated constitution, and `/constitution` propagates its guidance into reviewed, committed +artifacts instead of relying on runtime resolution alone. > **What you're opting into.** Propagation was removed deliberately — it duplicates the constitution > as the source of truth and can fight the composition stack (materialized edits get shadowed or @@ -28,7 +30,12 @@ versioned preset a core team maintains. ## What it does -Ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the +Its presence enables core's guarded install-time constitution reconciliation. Installing the preset +materializes the currently resolved `constitution-template`; later stack changes re-materialize it +only while the live file still matches its recorded generated-content hash. Human edits disable +automatic replacement. + +It also ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the current core command (via `{CORE_TEMPLATE}`), so it stays forward-compatible with core changes, and appends a propagation pass that, after the constitution is written: @@ -43,6 +50,8 @@ appends a propagation pass that, after the constitution is written: - It does **not** disable runtime resolution. `plan`, `tasks`, and `analyze` still read the live constitution every run; this preset adds materialized copies on top — it does not replace the source of truth. +- It does **not** overwrite an authored or edited constitution. Install-time reconciliation only + replaces content whose provenance proves it is an unchanged generated file. - It does **not** edit versioned, package-owned files — templates or command files provided or wrapped by another preset or extension. Those are recomposed from the resolution stack, so it only ever writes into your project's own `.specify/templates/` scaffolds and command files that diff --git a/presets/constitution-sync/preset.yml b/presets/constitution-sync/preset.yml index 574faa9698..a54265f65a 100644 --- a/presets/constitution-sync/preset.yml +++ b/presets/constitution-sync/preset.yml @@ -4,7 +4,7 @@ preset: id: "constitution-sync" name: "Constitution Template Sync" version: "1.0.0" - description: "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts." + description: "Opt-in: restores guarded install-time constitution seeding and /constitution propagation for teams that treat materialized templates as reviewed artifacts." author: "github" repository: "https://github.com/github/spec-kit" license: "MIT" diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index b9688d6742..c21edc41f0 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -12,6 +12,7 @@ # --require-tasks Require tasks.md to exist (for implementation phase) # --include-tasks Include tasks.md in AVAILABLE_DOCS list # --paths-only Only output path variables (no validation) +# --template NAME Include composed template content in JSON output # --help, -h Show help message # # OUTPUTS: @@ -26,9 +27,10 @@ JSON_MODE=false REQUIRE_TASKS=false INCLUDE_TASKS=false PATHS_ONLY=false +TEMPLATE_NAME="" -for arg in "$@"; do - case "$arg" in +while [[ $# -gt 0 ]]; do + case "$1" in --json) JSON_MODE=true ;; @@ -41,6 +43,14 @@ for arg in "$@"; do --paths-only) PATHS_ONLY=true ;; + --template) + shift + if [[ $# -eq 0 ]]; then + echo "ERROR: --template requires a template name" >&2 + exit 1 + fi + TEMPLATE_NAME="$1" + ;; --help|-h) cat << 'EOF' Usage: check-prerequisites.sh [OPTIONS] @@ -52,6 +62,7 @@ OPTIONS: --require-tasks Require tasks.md to exist (for implementation phase) --include-tasks Include tasks.md in AVAILABLE_DOCS list --paths-only Only output path variables (no prerequisite validation) + --template NAME Include composed template content in JSON output --help, -h Show this help message EXAMPLES: @@ -68,10 +79,11 @@ EOF exit 0 ;; *) - echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2 + echo "ERROR: Unknown option '$1'. Use --help for usage information." >&2 exit 1 ;; esac + shift done # Source common functions @@ -156,6 +168,16 @@ if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then docs+=("tasks.md") fi +TEMPLATE_CONTENT="" +if [[ -n "$TEMPLATE_NAME" ]]; then + if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then + TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}" + else + echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2 + exit 1 + fi +fi + # Output results if $JSON_MODE; then # Build JSON array of documents @@ -165,10 +187,18 @@ if $JSON_MODE; then else json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) fi - jq -cn \ - --arg feature_dir "$FEATURE_DIR" \ - --argjson docs "$json_docs" \ - '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' + if [[ -n "$TEMPLATE_NAME" ]]; then + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + --arg template_content "$TEMPLATE_CONTENT" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TEMPLATE_CONTENT:$template_content}' + else + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' + fi else if [[ ${#docs[@]} -eq 0 ]]; then json_docs="[]" @@ -176,7 +206,12 @@ if $JSON_MODE; then json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) json_docs="[${json_docs%,}]" fi - printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" + if [[ -n "$TEMPLATE_NAME" ]]; then + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TEMPLATE_CONTENT":"%s"}\n' \ + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "$TEMPLATE_CONTENT")" + else + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" + fi fi else # Text output diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index dc60f9ff5d..33f90b8dbb 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -398,6 +398,101 @@ json_escape() { check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } +_python3_command() { + if command -v python3 >/dev/null 2>&1 && + python3 -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then + printf '%s\n' "python3" + elif command -v python >/dev/null 2>&1 && + python -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then + printf '%s\n' "python" + elif command -v py >/dev/null 2>&1 && + py -3 -c 'import sys' >/dev/null 2>&1; then + printf '%s\n' "py -3" + else + return 1 + fi +} + +_sorted_extension_ids() { + local ext_dir="$1" + local python_spec + if python_spec=$(_python3_command); then + local -a python_cmd + read -r -a python_cmd <<< "$python_spec" + local py_stderr sorted_ids + py_stderr=$(mktemp) + if sorted_ids=$(SPECKIT_EXTENSIONS="$ext_dir" "${python_cmd[@]}" -c " +import json, os, re, sys +from pathlib import Path + +root = Path(os.environ['SPECKIT_EXTENSIONS']) +registered = {} +registry = root / '.registry' +if os.path.lexists(registry): + if not registry.is_file(): + print('registry_invalid: not a regular file', file=sys.stderr) + sys.exit(1) + try: + data = json.loads(registry.read_text(encoding='utf-8')) + except Exception as exc: + print('registry_invalid: ' + str(exc), file=sys.stderr) + sys.exit(1) + if not isinstance(data, dict): + print('registry_invalid: root must be a mapping', file=sys.stderr) + sys.exit(1) + raw_extensions = data.get('extensions', {}) + if not isinstance(raw_extensions, dict): + print('registry_invalid: extensions must be a mapping', file=sys.stderr) + sys.exit(1) + registered = raw_extensions + +def priority(value): + if isinstance(value, bool): + return 10 + try: + parsed = int(value) + return parsed if parsed >= 1 else 10 + except (TypeError, ValueError, OverflowError): + return 10 + +ranked = [] +for ext_id, meta in registered.items(): + if isinstance(ext_id, str) and re.fullmatch(r'[a-z0-9-]+', ext_id) and isinstance(meta, dict) and bool(meta.get('enabled', True)): + ranked.append((priority(meta.get('priority')), ext_id)) +for path in root.iterdir(): + if path.is_dir() and re.fullmatch(r'[a-z0-9-]+', path.name) and path.name not in registered: + ranked.append((10, path.name)) +for _, ext_id in sorted(ranked): + print(ext_id) +" 2>"$py_stderr"); then + rm -f "$py_stderr" + printf '%s\n' "$sorted_ids" + return 0 + else + echo "Error: invalid extension registry $ext_dir/.registry" >&2 + rm -f "$py_stderr" + return 1 + fi + fi + + if [ -e "$ext_dir/.registry" ] || [ -L "$ext_dir/.registry" ]; then + if [ ! -f "$ext_dir/.registry" ] || [ ! -r "$ext_dir/.registry" ]; then + echo "Error: invalid extension registry $ext_dir/.registry" >&2 + return 1 + fi + echo "Error: Python 3 is required to honor the extension registry" >&2 + return 2 + fi + + local ext extension_id + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + extension_id=$(basename "$ext") + case "$extension_id" in *[!a-z0-9-]*) continue ;; esac + printf '%s\n' "$extension_id" + done +} + # Resolve a template name to a file path using the priority stack: # 1. .specify/templates/overrides/ # 2. .specify/presets//templates/ (sorted by priority from .registry) @@ -408,6 +503,8 @@ resolve_template() { local repo_root="$2" local base="$repo_root/.specify/templates" + case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac + # Priority 1: Project overrides local override="$base/overrides/${template_name}.md" [ -f "$override" ] && echo "$override" && return 0 @@ -416,19 +513,32 @@ resolve_template() { local presets_dir="$repo_root/.specify/presets" if [ -d "$presets_dir" ]; then local registry_file="$presets_dir/.registry" - if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + local python_spec="" + local -a python_cmd=() + if python_spec=$(_python3_command); then + read -r -a python_cmd <<< "$python_spec" + fi + if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then # Read preset IDs sorted by priority (lower number = higher precedence). # The python3 call is wrapped in an if-condition so that set -e does not # abort the function when python3 exits non-zero (e.g. invalid JSON). local sorted_presets="" - if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " -import json, sys, os + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c " +import json, re, sys, os try: - with open(os.environ['SPECKIT_REGISTRY']) as f: + with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f: data = json.load(f) presets = data.get('presets', {}) - for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): - if isinstance(meta, dict) and meta.get('enabled', True) is not False: + def priority(meta): + if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool): + return 10 + try: + value = int(meta.get('priority', 10)) + return value if value >= 1 else 10 + except (TypeError, ValueError, OverflowError): + return 10 + for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])): + if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid): print(pid) except Exception: sys.exit(1) @@ -438,6 +548,8 @@ except Exception: while IFS= read -r preset_id; do local candidate="$presets_dir/$preset_id/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 + candidate="$presets_dir/$preset_id/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 done <<< "$sorted_presets" fi # python3 succeeded but registry has no presets — nothing to search @@ -447,6 +559,8 @@ except Exception: [ -d "$preset" ] || continue local candidate="$preset/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 + candidate="$preset/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 done fi else @@ -455,6 +569,8 @@ except Exception: [ -d "$preset" ] || continue local candidate="$preset/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 + candidate="$preset/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 done fi fi @@ -462,13 +578,17 @@ except Exception: # Priority 3: Extension-provided templates local ext_dir="$repo_root/.specify/extensions" if [ -d "$ext_dir" ]; then - for ext in "$ext_dir"/*/; do - [ -d "$ext" ] || continue - # Skip hidden directories (e.g. .backup, .cache) - case "$(basename "$ext")" in .*) continue;; esac + local sorted_extensions="" + if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then + return 2 + fi + while IFS= read -r extension_id; do + [ -n "$extension_id" ] || continue + local ext="$ext_dir/$extension_id" local candidate="$ext/templates/${template_name}.md" + [ -f "$candidate" ] || candidate="$ext/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 - done + done <<< "$sorted_extensions" fi # Priority 4: Core templates @@ -492,6 +612,8 @@ resolve_template_content() { local repo_root="$2" local base="$repo_root/.specify/templates" + case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac + # Collect all layers (highest priority first) local -a layer_paths=() local -a layer_strategies=() @@ -499,133 +621,206 @@ resolve_template_content() { # Priority 1: Project overrides (always "replace") local override="$base/overrides/${template_name}.md" if [ -f "$override" ]; then - layer_paths+=("$override") - layer_strategies+=("replace") + if ! cat "$override"; then + echo "Error: failed to read template layer $override" >&2 + return 2 + fi + return 0 fi + local effective_base_found=false + # Priority 2: Installed presets (sorted by priority from .registry) local presets_dir="$repo_root/.specify/presets" if [ -d "$presets_dir" ]; then local registry_file="$presets_dir/.registry" local sorted_presets="" - if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then - if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " -import json, sys, os + local registry_parsed=false + local python_spec="" + local -a python_cmd=() + if python_spec=$(_python3_command); then + read -r -a python_cmd <<< "$python_spec" + fi + if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c " +import json, re, sys, os try: - with open(os.environ['SPECKIT_REGISTRY']) as f: + with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f: data = json.load(f) presets = data.get('presets', {}) - for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): - if isinstance(meta, dict) and meta.get('enabled', True) is not False: + def priority(meta): + if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool): + return 10 + try: + value = int(meta.get('priority', 10)) + return value if value >= 1 else 10 + except (TypeError, ValueError, OverflowError): + return 10 + for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])): + if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid): print(pid) except Exception: sys.exit(1) " 2>/dev/null); then - if [ -n "$sorted_presets" ]; then - local yaml_warned=false - while IFS= read -r preset_id; do - # Read strategy and file path from preset manifest - local strategy="replace" - local manifest_file="" - local manifest="$presets_dir/$preset_id/preset.yml" - if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then - # Requires PyYAML; falls back to replace/convention if unavailable - local result - local py_stderr - py_stderr=$(mktemp) - result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c " + registry_parsed=true + fi + fi + if [ "$registry_parsed" = false ]; then + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local fallback_id + fallback_id=$(basename "$preset") + case "$fallback_id" in *[!a-z0-9-]*) continue ;; esac + sorted_presets+="${sorted_presets:+$'\n'}$fallback_id" + done + fi + + if [ -n "$sorted_presets" ]; then + while IFS= read -r preset_id; do + local strategy="replace" + local manifest_file="" + local manifest="$presets_dir/$preset_id/preset.yml" + local manifest_declared=false + if [ -f "$manifest" ]; then + if [ "${#python_cmd[@]}" -eq 0 ]; then + echo "Error: Python 3 and PyYAML are required to resolve preset template composition" >&2 + return 2 + fi + local result + local py_stderr + local parse_status + py_stderr=$(mktemp) + if result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" "${python_cmd[@]}" -c " import sys, os try: import yaml except ImportError: print('yaml_missing', file=sys.stderr) - print('replace\t') - sys.exit(0) + sys.exit(2) try: - with open(os.environ['SPECKIT_MANIFEST']) as f: + with open(os.environ['SPECKIT_MANIFEST'], encoding='utf-8') as f: data = yaml.safe_load(f) - for t in data.get('provides', {}).get('templates', []): + if not isinstance(data, dict): + raise ValueError('manifest root must be a mapping') + if 'provides' not in data: + raise ValueError('manifest missing provides section') + provides = data['provides'] + if not isinstance(provides, dict): + raise ValueError('manifest provides must be a mapping') + if 'templates' not in provides: + raise ValueError('manifest provides missing templates') + templates = provides['templates'] + if not isinstance(templates, list): + raise ValueError('manifest templates must be a list') + if not templates: + raise ValueError('manifest must provide at least one template') + valid_types = ('template', 'command', 'script') + valid_strategies = ('replace', 'prepend', 'append', 'wrap') + for t in templates: + if not isinstance(t, dict): + raise ValueError('manifest template entries must be mappings') + if 'type' not in t or 'name' not in t or 'file' not in t: + raise ValueError('manifest template entry missing type, name, or file') + for field in ('type', 'name', 'file'): + if not isinstance(t[field], str): + raise ValueError('manifest template ' + field + ' must be a string') + if t['type'] not in valid_types: + raise ValueError('invalid manifest template type') + strategy = t.get('strategy', 'replace') + if not isinstance(strategy, str): + raise ValueError('manifest template strategy must be a string') + strategy = strategy.lower() + if strategy not in valid_strategies: + raise ValueError('invalid manifest template strategy') + if t['type'] == 'script' and strategy not in ('replace', 'wrap'): + raise ValueError('invalid manifest script strategy') + for t in templates: if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template': - print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) + file_value = t.get('file', '') + strategy = t.get('strategy', 'replace') + print('found\t' + strategy + '\t' + file_value) sys.exit(0) - print('replace\t') -except Exception: - print('replace\t') -" 2>"$py_stderr") - local parse_status=$? - if [ $parse_status -eq 0 ] && [ -n "$result" ]; then - IFS=$'\t' read -r strategy manifest_file <<< "$result" - strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') - fi - if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then - echo "Warning: PyYAML not available; composition strategies may be ignored" >&2 - yaml_warned=true - fi - rm -f "$py_stderr" - fi - # Try manifest file path first, then convention path - local candidate="" - if [ -n "$manifest_file" ]; then - # Reject absolute paths and parent traversal - case "$manifest_file" in - /*|*../*|../*) manifest_file="" ;; - esac - fi - if [ -n "$manifest_file" ]; then - local mf="$presets_dir/$preset_id/$manifest_file" - [ -f "$mf" ] && candidate="$mf" - fi - if [ -z "$candidate" ]; then - local cf="$presets_dir/$preset_id/templates/${template_name}.md" - [ -f "$cf" ] && candidate="$cf" - fi - if [ -n "$candidate" ]; then - layer_paths+=("$candidate") - layer_strategies+=("$strategy") + print('absent\treplace\t') +except Exception as exc: + print(f'manifest_invalid: {exc}', file=sys.stderr) + sys.exit(3) +" 2>"$py_stderr"); then + parse_status=0 + else + parse_status=$? + fi + if [ "$parse_status" -ne 0 ]; then + if [ "$parse_status" -eq 2 ]; then + echo "Error: PyYAML is required to resolve preset template composition" >&2 + else + echo "Error: invalid preset manifest $manifest" >&2 fi - done <<< "$sorted_presets" + rm -f "$py_stderr" + return 2 + fi + if [ -n "$result" ]; then + local declaration + IFS=$'\t' read -r declaration strategy manifest_file <<< "$result" + [ "$declaration" = "found" ] && manifest_declared=true + strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') + fi + rm -f "$py_stderr" fi - else - # python3 failed — fall back to unordered directory scan (replace only) - for preset in "$presets_dir"/*/; do - [ -d "$preset" ] || continue - local candidate="$preset/templates/${template_name}.md" - if [ -f "$candidate" ]; then - layer_paths+=("$candidate") - layer_strategies+=("replace") + + local candidate="" + if [ -n "$manifest_file" ]; then + case "$manifest_file" in + /*|*../*|../*) manifest_file="" ;; + esac + fi + if [ -n "$manifest_file" ]; then + local mf="$presets_dir/$preset_id/$manifest_file" + [ -f "$mf" ] && candidate="$mf" + fi + if [ -z "$candidate" ] && [ "$manifest_declared" = false ]; then + local cf="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$cf" ] && candidate="$cf" + if [ -z "$candidate" ]; then + cf="$presets_dir/$preset_id/${template_name}.md" + [ -f "$cf" ] && candidate="$cf" fi - done - fi - else - # No python3 or registry — fall back to unordered directory scan (replace only) - for preset in "$presets_dir"/*/; do - [ -d "$preset" ] || continue - local candidate="$preset/templates/${template_name}.md" - if [ -f "$candidate" ]; then + fi + if [ -n "$candidate" ]; then layer_paths+=("$candidate") - layer_strategies+=("replace") + layer_strategies+=("$strategy") + if [ "$strategy" = "replace" ]; then + effective_base_found=true + break + fi fi - done + done <<< "$sorted_presets" fi fi # Priority 3: Extension-provided templates (always "replace") local ext_dir="$repo_root/.specify/extensions" - if [ -d "$ext_dir" ]; then - for ext in "$ext_dir"/*/; do - [ -d "$ext" ] || continue - case "$(basename "$ext")" in .*) continue;; esac + if [ "$effective_base_found" = false ] && [ -d "$ext_dir" ]; then + local sorted_extensions="" + if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then + return 2 + fi + while IFS= read -r extension_id; do + [ -n "$extension_id" ] || continue + local ext="$ext_dir/$extension_id" local candidate="$ext/templates/${template_name}.md" + [ -f "$candidate" ] || candidate="$ext/${template_name}.md" if [ -f "$candidate" ]; then layer_paths+=("$candidate") layer_strategies+=("replace") + effective_base_found=true + break fi - done + done <<< "$sorted_extensions" fi # Priority 4: Core templates (always "replace") local core="$base/${template_name}.md" - if [ -f "$core" ]; then + if [ "$effective_base_found" = false ] && [ -f "$core" ]; then layer_paths+=("$core") layer_strategies+=("replace") fi @@ -642,12 +837,18 @@ except Exception: # If the top (highest-priority) layer is replace, it wins entirely — # lower layers are irrelevant regardless of their strategies. if [ "${layer_strategies[0]}" = "replace" ]; then - cat "${layer_paths[0]}" + if ! cat "${layer_paths[0]}"; then + echo "Error: failed to read template layer ${layer_paths[0]}" >&2 + return 2 + fi return 0 fi if [ "$has_composition" = false ]; then - cat "${layer_paths[0]}" + if ! cat "${layer_paths[0]}"; then + echo "Error: failed to read template layer ${layer_paths[0]}" >&2 + return 2 + fi return 0 fi @@ -663,12 +864,16 @@ except Exception: done if [ $base_idx -lt 0 ]; then - return 1 # no base layer found + echo "Error: template '$template_name' has composing layers but no replace base" >&2 + return 2 fi # Read the base content; compose layers above the base (higher priority) local content - content=$(cat "${layer_paths[$base_idx]}"; printf x) + if ! content=$(cat "${layer_paths[$base_idx]}"; status=$?; printf x; exit "$status"); then + echo "Error: failed to read template layer ${layer_paths[$base_idx]}" >&2 + return 2 + fi content="${content%x}" for (( i=base_idx-1; i>=0; i-- )); do @@ -676,17 +881,26 @@ except Exception: local strat="${layer_strategies[$i]}" local layer_content # Preserve trailing newlines - layer_content=$(cat "$path"; printf x) + if ! layer_content=$(cat "$path"; status=$?; printf x; exit "$status"); then + echo "Error: failed to read template layer $path" >&2 + return 2 + fi layer_content="${layer_content%x}" case "$strat" in replace) content="$layer_content" ;; - prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;; - append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;; + prepend) + content=$(printf '%s\n\n%s' "$layer_content" "$content"; printf x) + content="${content%x}" + ;; + append) + content=$(printf '%s\n\n%s' "$content" "$layer_content"; printf x) + content="${content%x}" + ;; wrap) case "$layer_content" in *'{CORE_TEMPLATE}'*) ;; - *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;; + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 2 ;; esac while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do local before="${layer_content%%\{CORE_TEMPLATE\}*}" @@ -695,7 +909,7 @@ except Exception: done content="$layer_content" ;; - *) echo "Error: unknown strategy '$strat'" >&2; return 1 ;; + *) echo "Error: unknown strategy '$strat'" >&2; return 2 ;; esac done diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index c1b189dc08..abdb2194b1 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -339,12 +339,27 @@ if [ "$DRY_RUN" != true ]; then exit 1 fi + NEEDS_SPEC=false + SPEC_TEMPLATE_FOUND=false + SPEC_TEMPLATE_CONTENT="" + if [ ! -f "$SPEC_FILE" ]; then + NEEDS_SPEC=true + if SPEC_TEMPLATE_CONTENT=$(resolve_template_content "spec-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then + SPEC_TEMPLATE_CONTENT="${SPEC_TEMPLATE_CONTENT%x}" + SPEC_TEMPLATE_FOUND=true + else + resolve_status=$? + if [ "$resolve_status" -ne 1 ]; then + exit "$resolve_status" + fi + fi + fi + mkdir -p "$FEATURE_DIR" - if [ ! -f "$SPEC_FILE" ]; then - TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true - if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then - cp "$TEMPLATE" "$SPEC_FILE" + if [ "$NEEDS_SPEC" = true ]; then + if [ "$SPEC_TEMPLATE_FOUND" = true ]; then + printf '%s' "$SPEC_TEMPLATE_CONTENT" > "$SPEC_FILE" else echo "Warning: Spec template not found; created empty spec file" >&2 touch "$SPEC_FILE" diff --git a/scripts/bash/resolve-template.sh b/scripts/bash/resolve-template.sh new file mode 100644 index 0000000000..da05d2df6d --- /dev/null +++ b/scripts/bash/resolve-template.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -e + +SCRIPT_DIR="$(CDPATH="" cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +JSON_MODE=false +TEMPLATE_NAME="" + +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --help|-h) + echo "Usage: $0 [--json]" + exit 0 + ;; + -*) + echo "ERROR: Unknown option '$arg'" >&2 + exit 1 + ;; + *) + if [[ -n "$TEMPLATE_NAME" ]]; then + echo "ERROR: Unexpected argument '$arg'" >&2 + exit 1 + fi + TEMPLATE_NAME="$arg" + ;; + esac +done + +if [[ -z "$TEMPLATE_NAME" ]]; then + echo "ERROR: Template name is required" >&2 + exit 1 +fi + +REPO_ROOT=$(get_repo_root) +if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then + TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}" +else + echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2 + exit 1 +fi + +if $JSON_MODE; then + if has_jq; then + jq -cn \ + --arg template_name "$TEMPLATE_NAME" \ + --arg template_content "$TEMPLATE_CONTENT" \ + '{TEMPLATE_NAME:$template_name,TEMPLATE_CONTENT:$template_content}' + else + printf '{"TEMPLATE_NAME":"%s","TEMPLATE_CONTENT":"%s"}\n' \ + "$(json_escape "$TEMPLATE_NAME")" "$(json_escape "$TEMPLATE_CONTENT")" + fi +else + printf '%s' "$TEMPLATE_CONTENT" +fi diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index e01dc44bce..03eaf713b0 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -43,21 +43,23 @@ if [[ -f "$IMPL_PLAN" ]]; then echo "Plan already exists at $IMPL_PLAN, skipping template copy" fi else - TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true - if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then - cp "$TEMPLATE" "$IMPL_PLAN" + if resolve_template_content "plan-template" "$REPO_ROOT" > "$IMPL_PLAN"; then if $JSON_MODE; then echo "Copied plan template to $IMPL_PLAN" >&2 else echo "Copied plan template to $IMPL_PLAN" fi else + resolve_status=$? + rm -f "$IMPL_PLAN" + if [ "$resolve_status" -ne 1 ]; then + exit "$resolve_status" + fi if $JSON_MODE; then echo "Warning: Plan template not found" >&2 else echo "Warning: Plan template not found" fi - # Create a basic plan file if template doesn't exist touch "$IMPL_PLAN" fi fi diff --git a/scripts/bash/setup-tasks.sh b/scripts/bash/setup-tasks.sh index 8c989060ba..a5a685cd0e 100644 --- a/scripts/bash/setup-tasks.sh +++ b/scripts/bash/setup-tasks.sh @@ -51,7 +51,9 @@ fi # Resolve tasks template through override stack TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true -if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then +if TASKS_TEMPLATE_CONTENT=$(resolve_template_content "tasks-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then + TASKS_TEMPLATE_CONTENT="${TASKS_TEMPLATE_CONTENT%x}" +else echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2 echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2 exit 1 @@ -69,7 +71,8 @@ if $JSON_MODE; then --arg feature_dir "$FEATURE_DIR" \ --argjson docs "$json_docs" \ --arg tasks_template "${TASKS_TEMPLATE:-}" \ - '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}' + --arg tasks_template_content "$TASKS_TEMPLATE_CONTENT" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template,TASKS_TEMPLATE_CONTENT:$tasks_template_content}' else if [[ ${#docs[@]} -eq 0 ]]; then json_docs="[]" @@ -77,8 +80,8 @@ if $JSON_MODE; then json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) json_docs="[${json_docs%,}]" fi - printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \ - "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s","TASKS_TEMPLATE_CONTENT":"%s"}\n' \ + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" "$(json_escape "$TASKS_TEMPLATE_CONTENT")" fi else echo "FEATURE_DIR: $FEATURE_DIR" diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index 07ece76e21..c547d5f8c8 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -12,6 +12,7 @@ # -RequireTasks Require tasks.md to exist (for implementation phase) # -IncludeTasks Include tasks.md in AVAILABLE_DOCS list # -PathsOnly Only output path variables (no validation) +# -Template NAME Include composed template content in JSON output # -Help, -h Show help message [CmdletBinding()] @@ -20,6 +21,7 @@ param( [switch]$RequireTasks, [switch]$IncludeTasks, [switch]$PathsOnly, + [string]$Template, [switch]$Help ) @@ -37,6 +39,7 @@ OPTIONS: -RequireTasks Require tasks.md to exist (for implementation phase) -IncludeTasks Include tasks.md in AVAILABLE_DOCS list -PathsOnly Only output path variables (no prerequisite validation) + -Template NAME Include composed template content in JSON output -Help, -h Show this help message EXAMPLES: @@ -129,13 +132,26 @@ if ($IncludeTasks -and (Test-Path $paths.TASKS)) { $docs += 'tasks.md' } +$templateContent = $null +if ($Template) { + $templateContent = Resolve-TemplateContent -TemplateName $Template -RepoRoot $paths.REPO_ROOT + if ($null -eq $templateContent) { + [Console]::Error.WriteLine("ERROR: Could not resolve required $Template from the template override stack for $($paths.REPO_ROOT)") + exit 1 + } +} + # Output results if ($Json) { # JSON output - [PSCustomObject]@{ + $result = [ordered]@{ FEATURE_DIR = $paths.FEATURE_DIR AVAILABLE_DOCS = $docs - } | ConvertTo-Json -Compress + } + if ($Template) { + $result.TEMPLATE_CONTENT = $templateContent + } + [PSCustomObject]$result | ConvertTo-Json -Compress } else { # Text output Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)" diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 7922e94032..585e884702 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -332,6 +332,82 @@ function Get-Python3Command { return $null } +function Get-NormalizedPriority { + param($Value) + + if ($Value -is [bool]) { return 10 } + if ($Value -is [string]) { + $integerText = $Value.Trim() + if ($integerText -cnotmatch '^[+-]?[0-9]+(?:_[0-9]+)*$') { return 10 } + $Value = $integerText.Replace('_', '') + } + try { + $parsedPriority = [System.Numerics.BigInteger]$Value + } catch { + return 10 + } + return $(if ($parsedPriority -ge 1) { $parsedPriority } else { 10 }) +} + +function Get-SortedExtensionIds { + param([Parameter(Mandatory=$true)][string]$ExtensionsDir) + + $registeredNames = @() + $ranked = @() + $registryFile = Join-Path $ExtensionsDir '.registry' + # Detect any filesystem entry at the registry path without following symlinks. + # Test-Path follows links and reports $false for a dangling symlink, so a + # broken .registry symlink would otherwise bypass this guard and let the + # directory scan below enable every on-disk extension. Enumerating the parent + # directory still observes a broken symlink as an entry. + $registryEntry = Get-ChildItem -LiteralPath $ExtensionsDir -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq '.registry' } | + Select-Object -First 1 + if ($registryEntry) { + if (-not (Test-Path -LiteralPath $registryFile -PathType Leaf)) { + throw "Invalid extension registry ${registryFile}: not a regular file" + } + try { + $data = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json + } catch { + throw "Invalid extension registry ${registryFile}: $($_.Exception.Message)" + } + if ($null -eq $data -or $data -isnot [PSCustomObject]) { + throw "Invalid extension registry ${registryFile}: root must be a mapping" + } + $extensionsProperty = $data.PSObject.Properties['extensions'] + if ($extensionsProperty) { + if ($extensionsProperty.Value -isnot [PSCustomObject]) { + throw "Invalid extension registry ${registryFile}: 'extensions' must be a mapping" + } + $extensions = $extensionsProperty.Value + } else { + $extensions = [PSCustomObject]@{} + } + $registeredNames = @($extensions.PSObject.Properties | ForEach-Object { $_.Name }) + foreach ($entry in $extensions.PSObject.Properties) { + if ($entry.Name -cnotmatch '^[a-z0-9-]+$' -or $entry.Value -isnot [PSCustomObject]) { + continue + } + $enabledProperty = $entry.Value.PSObject.Properties['enabled'] + if ($enabledProperty -and -not [bool]$enabledProperty.Value) { continue } + $priority = 10 + $priorityProperty = $entry.Value.PSObject.Properties['priority'] + if ($priorityProperty) { + $priority = Get-NormalizedPriority -Value $priorityProperty.Value + } + $ranked += [PSCustomObject]@{ Priority = $priority; Id = $entry.Name } + } + } + + foreach ($directory in Get-ChildItem -Path $ExtensionsDir -Directory -ErrorAction SilentlyContinue) { + if ($directory.Name -cmatch '^[a-z0-9-]+$' -and $directory.Name -cnotin $registeredNames) { + $ranked += [PSCustomObject]@{ Priority = 10; Id = $directory.Name } + } + } + return $ranked | Sort-Object Priority, Id | ForEach-Object { $_.Id } +} + # Resolve a template name to a file path using the priority stack: # 1. .specify/templates/overrides/ # 2. .specify/presets//templates/ (sorted by priority from .registry) @@ -343,6 +419,8 @@ function Resolve-Template { [Parameter(Mandatory=$true)][string]$RepoRoot ) + if ($TemplateName -cnotmatch '^[a-z0-9-]+$') { return $null } + $base = Join-Path $RepoRoot '.specify/templates' # Priority 1: Project overrides @@ -357,7 +435,7 @@ function Resolve-Template { $registryParsed = $false if (Test-Path $registryFile) { try { - $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json + $registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) { throw 'Registry root must be an object' } @@ -372,30 +450,20 @@ function Resolve-Template { param($Entry) if ($Entry.Value -is [PSCustomObject]) { $priorityProperty = $Entry.Value.PSObject.Properties['priority'] - if ($priorityProperty) { return $priorityProperty.Value } - } - return 10 - } - if ($presetEntries.Count -gt 1) { - $allNumeric = $true - $allStrings = $true - foreach ($entry in $presetEntries) { - $priority = & $priorityFor $entry - if ($null -eq $priority -or $priority -isnot [ValueType]) { - $allNumeric = $false - } - if ($null -eq $priority -or $priority -isnot [string]) { - $allStrings = $false + if ($priorityProperty) { + return Get-NormalizedPriority -Value $priorityProperty.Value } } - if (-not $allNumeric -and -not $allStrings) { - throw 'Registry priorities are not mutually orderable' - } + return 10 } $sortedPresets = $presetEntries | Where-Object { $_.Value -is [PSCustomObject] } | - Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } | - Sort-Object { & $priorityFor $_ } | + Where-Object { + $enabled = $_.Value.PSObject.Properties['enabled'] + -not $enabled -or [bool]$enabled.Value + } | + Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } | + Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } | ForEach-Object { $_.Name } } $registryParsed = $true @@ -408,12 +476,16 @@ function Resolve-Template { foreach ($presetId in $sortedPresets) { $candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md" if (Test-Path $candidate) { return $candidate } + $candidate = Join-Path $presetsDir "$presetId/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } } } else { # Fallback: alphabetical directory order foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { $candidate = Join-Path $preset.FullName "templates/$TemplateName.md" if (Test-Path $candidate) { return $candidate } + $candidate = Join-Path $preset.FullName "$TemplateName.md" + if (Test-Path $candidate) { return $candidate } } } } @@ -421,8 +493,11 @@ function Resolve-Template { # Priority 3: Extension-provided templates $extDir = Join-Path $RepoRoot '.specify/extensions' if (Test-Path $extDir) { - foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { - $candidate = Join-Path $ext.FullName "templates/$TemplateName.md" + foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) { + $candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md" + if (-not (Test-Path $candidate)) { + $candidate = Join-Path $extDir "$extensionId/$TemplateName.md" + } if (Test-Path $candidate) { return $candidate } } } @@ -443,6 +518,10 @@ function Resolve-TemplateContent { [Parameter(Mandatory=$true)][string]$RepoRoot ) + if ($TemplateName -cnotmatch '^[a-z0-9-]+$') { + return $null + } + $base = Join-Path $RepoRoot '.specify/templates' # Collect all layers (highest priority first) @@ -452,49 +531,77 @@ function Resolve-TemplateContent { # Priority 1: Project overrides (always "replace") $override = Join-Path $base "overrides/$TemplateName.md" if (Test-Path $override) { - $layerPaths += $override - $layerStrategies += 'replace' + return [System.IO.File]::ReadAllText( + $override, + [System.Text.Encoding]::UTF8 + ) } + $effectiveBaseFound = $false + # Priority 2: Installed presets (sorted by priority from .registry) $presetsDir = Join-Path $RepoRoot '.specify/presets' if (Test-Path $presetsDir) { $registryFile = Join-Path $presetsDir '.registry' $sortedPresets = @() + $registryParsed = $false if (Test-Path $registryFile) { try { - $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json - $presets = $registryData.presets - if ($presets) { - $sortedPresets = $presets.PSObject.Properties | - Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } | - Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | + $registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json + if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) { + throw 'Registry root must be an object' + } + $presetsProperty = $registryData.PSObject.Properties['presets'] + if ($presetsProperty) { + $presets = $presetsProperty.Value + if ($null -eq $presets -or $presets -isnot [PSCustomObject]) { + throw 'Registry presets must be an object' + } + $presetEntries = @($presets.PSObject.Properties) + $priorityFor = { + param($Entry) + if ($Entry.Value -is [PSCustomObject]) { + $priorityProperty = $Entry.Value.PSObject.Properties['priority'] + if ($priorityProperty) { + return Get-NormalizedPriority -Value $priorityProperty.Value + } + } + return 10 + } + $sortedPresets = $presetEntries | + Where-Object { $_.Value -is [PSCustomObject] } | + Where-Object { + $enabled = $_.Value.PSObject.Properties['enabled'] + -not $enabled -or [bool]$enabled.Value + } | + Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } | + Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } | ForEach-Object { $_.Name } } + $registryParsed = $true } catch { - $sortedPresets = @() + $registryParsed = $false } } - if ($sortedPresets.Count -gt 0) { - $pyCmd = Get-Python3Command - if (-not $pyCmd) { - # Check if any preset has strategy fields that would be ignored - foreach ($pid in $sortedPresets) { - $mf = Join-Path $presetsDir "$pid/preset.yml" - if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) { - Write-Warning "No Python 3 found; preset composition strategies will be ignored" - break - } - } - } - $yamlWarned = $false - foreach ($presetId in $sortedPresets) { + if (-not $registryParsed) { + $sortedPresets = Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } | + Sort-Object Name | + ForEach-Object { $_.Name } + } + + $pyCmd = @(Get-Python3Command) + foreach ($presetId in $sortedPresets) { # Read strategy and file path from preset manifest $strategy = 'replace' $manifestFilePath = '' + $manifestDeclared = $false $manifest = Join-Path $presetsDir "$presetId/preset.yml" - if ((Test-Path $manifest) -and $pyCmd) { + if ((Test-Path $manifest) -and -not $pyCmd) { + throw "Python 3 and PyYAML are required to resolve preset template composition" + } + if (Test-Path $manifest) { try { # Use Python to parse YAML manifest for strategy and file path $pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() } @@ -505,32 +612,71 @@ try: import yaml except ImportError: print('yaml_missing', file=sys.stderr) - print('replace\t') - sys.exit(0) + sys.exit(2) try: - with open(sys.argv[1]) as f: + with open(sys.argv[1], encoding='utf-8') as f: data = yaml.safe_load(f) - for t in data.get('provides', {}).get('templates', []): + if not isinstance(data, dict): + raise ValueError('manifest root must be a mapping') + if 'provides' not in data: + raise ValueError('manifest missing provides section') + provides = data['provides'] + if not isinstance(provides, dict): + raise ValueError('manifest provides must be a mapping') + if 'templates' not in provides: + raise ValueError('manifest provides missing templates') + templates = provides['templates'] + if not isinstance(templates, list): + raise ValueError('manifest templates must be a list') + if not templates: + raise ValueError('manifest must provide at least one template') + valid_types = ('template', 'command', 'script') + valid_strategies = ('replace', 'prepend', 'append', 'wrap') + for t in templates: + if not isinstance(t, dict): + raise ValueError('manifest template entries must be mappings') + if 'type' not in t or 'name' not in t or 'file' not in t: + raise ValueError('manifest template entry missing type, name, or file') + for field in ('type', 'name', 'file'): + if not isinstance(t[field], str): + raise ValueError('manifest template ' + field + ' must be a string') + if t['type'] not in valid_types: + raise ValueError('invalid manifest template type') + strategy = t.get('strategy', 'replace') + if not isinstance(strategy, str): + raise ValueError('manifest template strategy must be a string') + strategy = strategy.lower() + if strategy not in valid_strategies: + raise ValueError('invalid manifest template strategy') + if t['type'] == 'script' and strategy not in ('replace', 'wrap'): + raise ValueError('invalid manifest script strategy') + for t in templates: if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template': - print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) + file_value = t.get('file', '') + strategy = t.get('strategy', 'replace') + print('found\t' + strategy + '\t' + file_value) sys.exit(0) - print('replace\t') -except Exception: - print('replace\t') + print('absent\treplace\t') +except Exception as exc: + print(f'manifest_invalid: {exc}', file=sys.stderr) + sys.exit(3) "@ $manifest $TemplateName 2>$pyStderrFile + if ($LASTEXITCODE -ne 0) { + if ($LASTEXITCODE -eq 2) { + throw "PyYAML is required to resolve preset template composition" + } + throw "Invalid preset manifest $manifest" + } if ($stratResult) { - $parts = $stratResult.Trim() -split "`t", 2 - $strategy = $parts[0].ToLowerInvariant() - if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] } - } - if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') { - Write-Warning "PyYAML not available; composition strategies may be ignored" - $yamlWarned = $true + $parts = $stratResult.Trim() -split "`t", 3 + $manifestDeclared = $parts[0] -eq 'found' + $strategy = $parts[1].ToLowerInvariant() + if ($parts.Count -gt 2 -and $parts[2]) { $manifestFilePath = $parts[2] } } Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue } catch { - $strategy = 'replace' if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue } + throw } } # Try manifest file path first, then convention path @@ -545,42 +691,45 @@ except Exception: $mf = Join-Path $presetsDir "$presetId/$manifestFilePath" if (Test-Path $mf) { $candidate = $mf } } - if (-not $candidate) { + if (-not $candidate -and -not $manifestDeclared) { $cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md" if (Test-Path $cf) { $candidate = $cf } + if (-not $candidate) { + $cf = Join-Path $presetsDir "$presetId/$TemplateName.md" + if (Test-Path $cf) { $candidate = $cf } + } } if ($candidate) { $layerPaths += $candidate $layerStrategies += $strategy + if ($strategy -eq 'replace') { + $effectiveBaseFound = $true + break + } } } - } else { - # Fallback: alphabetical directory order (no registry or parse failure) - foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) { - $candidate = Join-Path $preset.FullName "templates/$TemplateName.md" - if (Test-Path $candidate) { - $layerPaths += $candidate - $layerStrategies += 'replace' - } - } - } } # Priority 3: Extension-provided templates (always "replace") $extDir = Join-Path $RepoRoot '.specify/extensions' - if (Test-Path $extDir) { - foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { - $candidate = Join-Path $ext.FullName "templates/$TemplateName.md" + if (-not $effectiveBaseFound -and (Test-Path $extDir)) { + foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) { + $candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md" + if (-not (Test-Path $candidate)) { + $candidate = Join-Path $extDir "$extensionId/$TemplateName.md" + } if (Test-Path $candidate) { $layerPaths += $candidate $layerStrategies += 'replace' + $effectiveBaseFound = $true + break } } } # Priority 4: Core templates (always "replace") $core = Join-Path $base "$TemplateName.md" - if (Test-Path $core) { + if (-not $effectiveBaseFound -and (Test-Path $core)) { $layerPaths += $core $layerStrategies += 'replace' } @@ -590,7 +739,7 @@ except Exception: # If the top (highest-priority) layer is replace, it wins entirely -- # lower layers are irrelevant regardless of their strategies. if ($layerStrategies[0] -eq 'replace') { - return (Get-Content $layerPaths[0] -Raw) + return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8) } # Check if any layer uses a non-replace strategy @@ -600,7 +749,7 @@ except Exception: } if (-not $hasComposition) { - return (Get-Content $layerPaths[0] -Raw) + return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8) } # Find the effective base: scan from highest priority (index 0) downward @@ -612,14 +761,22 @@ except Exception: break } } - if ($baseIdx -lt 0) { return $null } + if ($baseIdx -lt 0) { + throw "Template '$TemplateName' has composing layers but no replace base" + } - $content = Get-Content $layerPaths[$baseIdx] -Raw + $content = [System.IO.File]::ReadAllText( + $layerPaths[$baseIdx], + [System.Text.Encoding]::UTF8 + ) for ($i = $baseIdx - 1; $i -ge 0; $i--) { $path = $layerPaths[$i] $strat = $layerStrategies[$i] - $layerContent = Get-Content $path -Raw + $layerContent = [System.IO.File]::ReadAllText( + $path, + [System.Text.Encoding]::UTF8 + ) switch ($strat) { 'replace' { $content = $layerContent } diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index abe70f65ed..e7a68c4076 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -262,13 +262,16 @@ if (-not $DryRun) { exit 1 } + $needsSpec = -not (Test-Path -PathType Leaf $specFile) + $content = $null + if ($needsSpec) { + $content = Resolve-TemplateContent -TemplateName 'spec-template' -RepoRoot $repoRoot + } + New-Item -ItemType Directory -Path $featureDir -Force | Out-Null - if (-not (Test-Path -PathType Leaf $specFile)) { - $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot - if ($template -and (Test-Path $template)) { - # Read the template content and write it to the spec file with UTF-8 encoding without BOM - $content = [System.IO.File]::ReadAllText($template) + if ($needsSpec) { + if ($null -ne $content) { $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom) } else { diff --git a/scripts/powershell/resolve-template.ps1 b/scripts/powershell/resolve-template.ps1 new file mode 100644 index 0000000000..70aee0aca0 --- /dev/null +++ b/scripts/powershell/resolve-template.ps1 @@ -0,0 +1,38 @@ +#!/usr/bin/env pwsh + +param( + [Parameter(Position=0)] + [string]$TemplateName, + [switch]$Json, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Output "Usage: resolve-template.ps1 [-Json]" + exit 0 +} + +if (-not $TemplateName) { + [Console]::Error.WriteLine("ERROR: Template name is required") + exit 1 +} + +. "$PSScriptRoot/common.ps1" + +$repoRoot = Get-RepoRoot +$templateContent = Resolve-TemplateContent -TemplateName $TemplateName -RepoRoot $repoRoot +if ($null -eq $templateContent) { + [Console]::Error.WriteLine("ERROR: Could not resolve required $TemplateName from the template override stack for $repoRoot") + exit 1 +} + +if ($Json) { + [PSCustomObject]@{ + TEMPLATE_NAME = $TemplateName + TEMPLATE_CONTENT = $templateContent + } | ConvertTo-Json -Compress +} else { + [Console]::Out.Write($templateContent) +} diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index 6ed0344dd9..52f615aaad 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -41,10 +41,8 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) { Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy" } } else { - $template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT - if ($template -and (Test-Path $template)) { - # Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM - $content = [System.IO.File]::ReadAllText($template) + $content = Resolve-TemplateContent -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT + if ($null -ne $content) { $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom) # Emit the copy status like the bash twin (setup-plan.sh); route to stderr diff --git a/scripts/powershell/setup-tasks.ps1 b/scripts/powershell/setup-tasks.ps1 index 1d091360e7..828ff4a5b3 100644 --- a/scripts/powershell/setup-tasks.ps1 +++ b/scripts/powershell/setup-tasks.ps1 @@ -57,12 +57,17 @@ if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' } # Resolve tasks template through override stack $tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT -if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) { +$tasksTemplateContent = Resolve-TemplateContent -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT +if ($null -eq $tasksTemplateContent) { [Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)") [Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.") exit 1 } -$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path +if ($tasksTemplate -and (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) { + $tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path +} else { + $tasksTemplate = '' +} # Output results if ($Json) { @@ -70,6 +75,7 @@ if ($Json) { FEATURE_DIR = $paths.FEATURE_DIR AVAILABLE_DOCS = $docs TASKS_TEMPLATE = $tasksTemplate + TASKS_TEMPLATE_CONTENT = $tasksTemplateContent } | ConvertTo-Json -Compress } else { Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)" diff --git a/scripts/python/check_prerequisites.py b/scripts/python/check_prerequisites.py index e909ffb507..a5dc3e7e39 100644 --- a/scripts/python/check_prerequisites.py +++ b/scripts/python/check_prerequisites.py @@ -9,10 +9,22 @@ from pathlib import Path try: - from common import FeaturePaths, format_speckit_command, get_feature_paths + from common import ( + FeaturePaths, + TemplateResolutionError, + format_speckit_command, + get_feature_paths, + resolve_template_content, + ) except ImportError: # pragma: no cover - direct execution from unusual cwd sys.path.insert(0, str(Path(__file__).resolve().parent)) - from common import FeaturePaths, format_speckit_command, get_feature_paths + from common import ( + FeaturePaths, + TemplateResolutionError, + format_speckit_command, + get_feature_paths, + resolve_template_content, + ) def _json_line(payload: object) -> str: @@ -28,6 +40,7 @@ def _json_line(payload: object) -> str: --require-tasks Require tasks.md to exist (for implementation phase) --include-tasks Include tasks.md in AVAILABLE_DOCS list --paths-only Only output path variables (no prerequisite validation) + --template NAME Include composed template content in JSON output --help, -h Show this help message EXAMPLES: @@ -49,6 +62,7 @@ class Args: require_tasks: bool = False include_tasks: bool = False paths_only: bool = False + template_name: str | None = None def _parse_args(argv: list[str]) -> Args: @@ -56,8 +70,11 @@ def _parse_args(argv: list[str]) -> Args: require_tasks = False include_tasks = False paths_only = False + template_name = None - for arg in argv: + index = 0 + while index < len(argv): + arg = argv[index] if arg == "--json": json_mode = True elif arg == "--require-tasks": @@ -66,6 +83,15 @@ def _parse_args(argv: list[str]) -> Args: include_tasks = True elif arg == "--paths-only": paths_only = True + elif arg == "--template": + index += 1 + if index >= len(argv): + print( + "ERROR: --template requires a template name", + file=sys.stderr, + ) + raise SystemExit(1) + template_name = argv[index] elif arg in {"--help", "-h"}: sys.stdout.write(HELP_TEXT) raise SystemExit(0) @@ -75,12 +101,14 @@ def _parse_args(argv: list[str]) -> Args: file=sys.stderr, ) raise SystemExit(1) + index += 1 return Args( json_mode=json_mode, require_tasks=require_tasks, include_tasks=include_tasks, paths_only=paths_only, + template_name=template_name, ) @@ -211,9 +239,32 @@ def main(argv: list[str] | None = None) -> int: return 1 docs = _available_docs(paths, args.include_tasks) + template_content = None + if args.template_name: + try: + template_content = resolve_template_content( + args.template_name, paths.repo_root + ) + except TemplateResolutionError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + if template_content is None: + print( + f"ERROR: Could not resolve required {args.template_name} from " + f"the template override stack for {paths.repo_root}", + file=sys.stderr, + ) + return 1 + if args.json_mode: + payload: dict[str, object] = { + "FEATURE_DIR": str(paths.feature_dir), + "AVAILABLE_DOCS": docs, + } + if args.template_name: + payload["TEMPLATE_CONTENT"] = template_content sys.stdout.write( - _json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs}) + _json_line(payload) ) else: _print_text_results(paths, args.include_tasks) diff --git a/scripts/python/common.py b/scripts/python/common.py index 72f61d3782..db958dc1cb 100644 --- a/scripts/python/common.py +++ b/scripts/python/common.py @@ -4,6 +4,7 @@ import json import os +import re import sys from dataclasses import dataclass from pathlib import Path @@ -182,12 +183,30 @@ def get_feature_paths( ) +_SAFE_COMPONENT_PATTERN = re.compile(r"[a-z0-9-]+") + + +def _is_safe_component(value: object) -> bool: + return ( + isinstance(value, str) + and _SAFE_COMPONENT_PATTERN.fullmatch(value) is not None + ) + + +def _normalize_priority(value: object) -> int: + if isinstance(value, bool): + return 10 + try: + priority = int(value) + except (TypeError, ValueError, OverflowError): + return 10 + return priority if priority >= 1 else 10 + + def _sorted_preset_ids(presets_dir: Path) -> list[str]: registry = presets_dir / ".registry" if registry.is_file(): - # Mirrors bash: any failure while reading or sorting the registry - # (invalid JSON, non-dict shapes, unorderable priority values) falls - # back to the directory scan below. + # Invalid JSON or registry shapes fall back to the directory scan below. try: data = json.loads(registry.read_text(encoding="utf-8")) presets = data.get("presets", {}) @@ -195,11 +214,18 @@ def _sorted_preset_ids(presets_dir: Path) -> list[str]: pid for pid, meta in sorted( presets.items(), - key=lambda kv: kv[1].get("priority", 10) - if isinstance(kv[1], dict) - else 10, + key=lambda kv: ( + _normalize_priority(kv[1].get("priority")) + if isinstance(kv[1], dict) + else 10, + kv[0], + ), + ) + if ( + _is_safe_component(pid) + and isinstance(meta, dict) + and bool(meta.get("enabled", True)) ) - if isinstance(meta, dict) and meta.get("enabled", True) is not False ] except Exception: pass @@ -207,12 +233,78 @@ def _sorted_preset_ids(presets_dir: Path) -> list[str]: return sorted( p.name for p in presets_dir.iterdir() - if p.is_dir() and not p.name.startswith(".") + if p.is_dir() and _is_safe_component(p.name) ) except OSError: return [] +def _sorted_extension_ids(extensions_dir: Path) -> list[str]: + registry = extensions_dir / ".registry" + registered_ids: set[str] = set() + extensions: dict[object, object] = {} + if os.path.lexists(registry): + if not registry.is_file(): + raise TemplateResolutionError( + f"Invalid extension registry {registry}: not a regular file" + ) + try: + data = json.loads(registry.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise TemplateResolutionError( + f"Failed to parse extension registry {registry}: {exc}" + ) from exc + if not isinstance(data, dict): + raise TemplateResolutionError( + f"Invalid extension registry {registry}: root must be a mapping" + ) + raw_extensions = data.get("extensions", {}) + if not isinstance(raw_extensions, dict): + raise TemplateResolutionError( + f"Invalid extension registry {registry}: " + "'extensions' must be a mapping" + ) + extensions = raw_extensions + registered_ids = { + ext_id for ext_id in extensions if isinstance(ext_id, str) + } + + ranked: list[tuple[int, str]] = [] + for ext_id, metadata in extensions.items(): + if ( + _is_safe_component(ext_id) + and isinstance(metadata, dict) + and bool(metadata.get("enabled", True)) + ): + ranked.append((_normalize_priority(metadata.get("priority")), ext_id)) + + try: + ranked.extend( + (10, path.name) + for path in extensions_dir.iterdir() + if ( + path.is_dir() + and _is_safe_component(path.name) + and path.name not in registered_ids + ) + ) + except OSError: + pass + return [ext_id for _, ext_id in sorted(ranked)] + + +def _conventional_template( + base_dir: Path, template_name: str +) -> Path | None: + for candidate in ( + base_dir / "templates" / f"{template_name}.md", + base_dir / f"{template_name}.md", + ): + if candidate.is_file(): + return candidate + return None + + def resolve_template(template_name: str, repo_root: Path) -> Path | None: """Resolve a template name to a file path using the priority stack. @@ -222,6 +314,9 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None: 3. .specify/extensions//templates/ (hidden directories skipped) 4. .specify/templates/ (core) """ + if not _is_safe_component(template_name): + return None + base = repo_root / ".specify" / "templates" override = base / "overrides" / f"{template_name}.md" @@ -231,21 +326,18 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None: presets_dir = repo_root / ".specify" / "presets" if presets_dir.is_dir(): for preset_id in _sorted_preset_ids(presets_dir): - candidate = presets_dir / preset_id / "templates" / f"{template_name}.md" - if candidate.is_file(): + candidate = _conventional_template( + presets_dir / preset_id, template_name + ) + if candidate is not None: return candidate ext_dir = repo_root / ".specify" / "extensions" if ext_dir.is_dir(): - try: - extensions = sorted(p for p in ext_dir.iterdir() if p.is_dir()) - except OSError: - extensions = [] - for ext in extensions: - if ext.name.startswith("."): - continue - candidate = ext / "templates" / f"{template_name}.md" - if candidate.is_file(): + for extension_id in _sorted_extension_ids(ext_dir): + ext = ext_dir / extension_id + candidate = _conventional_template(ext, template_name) + if candidate is not None: return candidate core = base / f"{template_name}.md" @@ -254,6 +346,175 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None: return None +class TemplateResolutionError(RuntimeError): + """Raised when template layers exist but cannot be composed safely.""" + + +# Mirror the canonical PresetManifest contract (see src/specify_cli/presets) +# so runtime resolution rejects the same structurally malformed manifests. +_VALID_TEMPLATE_TYPES = ("template", "command", "script") +_VALID_TEMPLATE_STRATEGIES = ("replace", "prepend", "append", "wrap") +_VALID_SCRIPT_STRATEGIES = ("replace", "wrap") + + +def _validate_manifest_template_entry(entry: object) -> None: + """Validate a single manifest template entry against the canonical rules.""" + if not isinstance(entry, dict): + raise ValueError("manifest template entries must be mappings") + if "type" not in entry or "name" not in entry or "file" not in entry: + raise ValueError("manifest template entry missing type, name, or file") + for field in ("type", "name", "file"): + if not isinstance(entry[field], str): + raise ValueError(f"manifest template {field} must be a string") + if entry["type"] not in _VALID_TEMPLATE_TYPES: + raise ValueError(f"invalid manifest template type '{entry['type']}'") + strategy = entry.get("strategy", "replace") + if not isinstance(strategy, str): + raise ValueError("manifest template strategy must be a string") + strategy = strategy.lower() + if strategy not in _VALID_TEMPLATE_STRATEGIES: + raise ValueError(f"invalid manifest template strategy '{strategy}'") + if entry["type"] == "script" and strategy not in _VALID_SCRIPT_STRATEGIES: + raise ValueError( + f"invalid manifest script strategy '{strategy}'" + ) + + +def _preset_template_layer( + preset_dir: Path, template_name: str +) -> tuple[Path, str] | None: + """Return the preset template path and composition strategy.""" + manifest_path = preset_dir / "preset.yml" + conventional = _conventional_template(preset_dir, template_name) + + try: + import yaml + except ImportError as exc: + if manifest_path.is_file(): + raise TemplateResolutionError( + "PyYAML is required to resolve preset template composition" + ) from exc + return (conventional, "replace") if conventional is not None else None + + if manifest_path.is_file(): + try: + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if not isinstance(manifest, dict): + raise ValueError("manifest root must be a mapping") + if "provides" not in manifest: + raise ValueError("manifest missing provides section") + provides = manifest["provides"] + if not isinstance(provides, dict): + raise ValueError("manifest provides must be a mapping") + if "templates" not in provides: + raise ValueError("manifest provides missing templates") + templates = provides["templates"] + if not isinstance(templates, list): + raise ValueError("manifest templates must be a list") + if not templates: + raise ValueError("manifest must provide at least one template") + for entry in templates: + _validate_manifest_template_entry(entry) + for entry in templates: + if ( + entry.get("name") != template_name + or entry.get("type", "template") != "template" + ): + continue + file_value = entry.get("file", "") + strategy = entry.get("strategy", "replace") + relative = Path(file_value) + if ( + not relative + or relative.is_absolute() + or ".." in relative.parts + ): + return None + candidate = preset_dir / relative + if not candidate.is_file(): + return None + return candidate, strategy.lower() + except (OSError, UnicodeError, ValueError, yaml.YAMLError) as exc: + raise TemplateResolutionError( + f"Failed to parse preset manifest {manifest_path}: {exc}" + ) from exc + + return (conventional, "replace") if conventional is not None else None + + +def resolve_template_content(template_name: str, repo_root: Path) -> str | None: + """Resolve and compose template content through the project layer stack.""" + if not _is_safe_component(template_name): + return None + + layers: list[tuple[Path, str]] = [] + + def compose_from_base() -> str: + try: + content = layers[-1][0].read_bytes().decode("utf-8") + for path, strategy in reversed(layers[:-1]): + layer_content = path.read_bytes().decode("utf-8") + if strategy == "prepend": + content = f"{layer_content}\n\n{content}" + elif strategy == "append": + content = f"{content}\n\n{layer_content}" + elif strategy == "wrap": + placeholder = "{CORE_TEMPLATE}" + if placeholder not in layer_content: + raise TemplateResolutionError( + f"Wrap layer {path} is missing {placeholder}" + ) + content = layer_content.replace(placeholder, content) + else: + raise TemplateResolutionError( + f"Unknown template composition strategy '{strategy}' in {path}" + ) + except (OSError, UnicodeError) as exc: + raise TemplateResolutionError( + f"Failed to read template layer for '{template_name}': {exc}" + ) from exc + return content + + override = ( + repo_root + / ".specify" + / "templates" + / "overrides" + / f"{template_name}.md" + ) + if override.is_file(): + layers.append((override, "replace")) + return compose_from_base() + + presets_dir = repo_root / ".specify" / "presets" + for preset_id in _sorted_preset_ids(presets_dir): + layer = _preset_template_layer(presets_dir / preset_id, template_name) + if layer is not None: + layers.append(layer) + if layer[1] == "replace": + return compose_from_base() + + extensions_dir = repo_root / ".specify" / "extensions" + for extension_id in _sorted_extension_ids(extensions_dir): + extension_dir = extensions_dir / extension_id + candidate = _conventional_template(extension_dir, template_name) + if candidate is not None: + layers.append((candidate, "replace")) + return compose_from_base() + + core = repo_root / ".specify" / "templates" / f"{template_name}.md" + if core.is_file(): + layers.append((core, "replace")) + return compose_from_base() + + if not layers: + return None + + raise TemplateResolutionError( + f"Template '{template_name}' has composing layers but no replace base" + ) + + def get_invoke_separator(repo_root: Path) -> str: integration_json = repo_root / ".specify" / "integration.json" if not integration_json.is_file(): diff --git a/scripts/python/create_new_feature.py b/scripts/python/create_new_feature.py index c46837d9d5..f36064afbb 100644 --- a/scripts/python/create_new_feature.py +++ b/scripts/python/create_new_feature.py @@ -7,16 +7,25 @@ import json import re import shlex -import shutil import sys from dataclasses import dataclass from pathlib import Path try: - from common import get_repo_root, persist_feature_json, resolve_template + from common import ( + TemplateResolutionError, + get_repo_root, + persist_feature_json, + resolve_template_content, + ) except ImportError: # pragma: no cover - direct execution from unusual cwd sys.path.insert(0, str(Path(__file__).resolve().parent)) - from common import get_repo_root, persist_feature_json, resolve_template + from common import ( + TemplateResolutionError, + get_repo_root, + persist_feature_json, + resolve_template_content, + ) def _json_line(payload: object) -> str: @@ -374,12 +383,22 @@ def main(argv: list[str] | None = None) -> int: ) return 1 + template_content = None + needs_spec = not spec_file.is_file() + if needs_spec: + try: + template_content = resolve_template_content( + "spec-template", repo_root + ) + except TemplateResolutionError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + feature_dir.mkdir(parents=True, exist_ok=True) - if not spec_file.is_file(): - template = resolve_template("spec-template", repo_root) - if template is not None and template.is_file(): - shutil.copy(template, spec_file) + if needs_spec: + if template_content is not None: + spec_file.write_bytes(template_content.encode("utf-8")) else: print( "Warning: Spec template not found; created empty spec file", diff --git a/scripts/python/resolve_template.py b/scripts/python/resolve_template.py new file mode 100644 index 0000000000..d2a7da89b2 --- /dev/null +++ b/scripts/python/resolve_template.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Resolve composed template content from the project template stack.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +try: + from common import ( + TemplateResolutionError, + get_repo_root, + resolve_template_content, + ) +except ImportError: # pragma: no cover - direct execution from unusual cwd + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from common import ( + TemplateResolutionError, + get_repo_root, + resolve_template_content, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("template_name") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + repo_root = get_repo_root(Path(__file__)) + try: + content = resolve_template_content(args.template_name, repo_root) + except TemplateResolutionError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + if content is None: + print( + f"ERROR: Could not resolve required {args.template_name} from the " + f"template override stack for {repo_root}", + file=sys.stderr, + ) + return 1 + + if args.json: + print( + json.dumps( + { + "TEMPLATE_NAME": args.template_name, + "TEMPLATE_CONTENT": content, + }, + ensure_ascii=False, + separators=(",", ":"), + ) + ) + else: + sys.stdout.write(content) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/python/setup_plan.py b/scripts/python/setup_plan.py index 7b8e77ce5a..d25fdd7829 100644 --- a/scripts/python/setup_plan.py +++ b/scripts/python/setup_plan.py @@ -4,15 +4,22 @@ from __future__ import annotations import json -import shutil import sys from pathlib import Path try: - from common import get_feature_paths, resolve_template + from common import ( + TemplateResolutionError, + get_feature_paths, + resolve_template_content, + ) except ImportError: # pragma: no cover - direct execution from unusual cwd sys.path.insert(0, str(Path(__file__).resolve().parent)) - from common import get_feature_paths, resolve_template + from common import ( + TemplateResolutionError, + get_feature_paths, + resolve_template_content, + ) def _json_line(payload: object) -> str: @@ -55,9 +62,13 @@ def main(argv: list[str] | None = None) -> int: file=status_stream, ) else: - template = resolve_template("plan-template", paths.repo_root) - if template is not None and template.is_file(): - shutil.copy(template, paths.impl_plan) + try: + template_content = resolve_template_content("plan-template", paths.repo_root) + except TemplateResolutionError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + if template_content is not None: + paths.impl_plan.write_bytes(template_content.encode("utf-8")) print(f"Copied plan template to {paths.impl_plan}", file=status_stream) else: print("Warning: Plan template not found", file=status_stream) diff --git a/scripts/python/setup_tasks.py b/scripts/python/setup_tasks.py index 21b0018620..a69fc3c956 100644 --- a/scripts/python/setup_tasks.py +++ b/scripts/python/setup_tasks.py @@ -10,17 +10,21 @@ try: from common import ( FeaturePaths, + TemplateResolutionError, format_speckit_command, get_feature_paths, resolve_template, + resolve_template_content, ) except ImportError: # pragma: no cover - direct execution from unusual cwd sys.path.insert(0, str(Path(__file__).resolve().parent)) from common import ( FeaturePaths, + TemplateResolutionError, format_speckit_command, get_feature_paths, resolve_template, + resolve_template_content, ) @@ -120,8 +124,14 @@ def main(argv: list[str] | None = None) -> int: docs = _available_docs(paths) - tasks_template = resolve_template("tasks-template", paths.repo_root) - if tasks_template is None or not tasks_template.is_file(): + try: + tasks_template_content = resolve_template_content( + "tasks-template", paths.repo_root + ) + except TemplateResolutionError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + if tasks_template_content is None: print( "ERROR: Could not resolve required tasks-template from the template " f"override stack for {paths.repo_root}", @@ -138,18 +148,21 @@ def main(argv: list[str] | None = None) -> int: return 1 if json_mode: + tasks_template = resolve_template("tasks-template", paths.repo_root) sys.stdout.write( _json_line( { "FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs, - "TASKS_TEMPLATE": str(tasks_template), + "TASKS_TEMPLATE": str(tasks_template) if tasks_template else "", + "TASKS_TEMPLATE_CONTENT": tasks_template_content, } ) ) else: + tasks_template = resolve_template("tasks-template", paths.repo_root) print(f"FEATURE_DIR: {paths.feature_dir}") - print(f"TASKS_TEMPLATE: {tasks_template}") + print(f"TASKS_TEMPLATE: {tasks_template or 'not found'}") print("AVAILABLE_DOCS:") _check_file(paths.research, "research.md") _check_file(paths.data_model, "data-model.md") diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 2becae9bc1..299a5f22c0 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -745,6 +745,13 @@ def _load(self) -> dict: if not self.registry_path.exists(): return {"schema_version": self.SCHEMA_VERSION, "extensions": {}} + # A non-regular file (e.g. a directory at the registry path) is not a + # readable registry. Recover to empty so construction — used by the + # install/enable/disable flows — does not crash. Resolution paths that + # must fail closed consult is_corrupt() instead of relying on this. + if not self.registry_path.is_file(): + return {"schema_version": self.SCHEMA_VERSION, "extensions": {}} + try: with open(self.registry_path, "r", encoding="utf-8") as f: data = json.load(f) @@ -764,6 +771,38 @@ def _load(self) -> dict: # starting fresh would let a later _save() wipe it. return {"schema_version": self.SCHEMA_VERSION, "extensions": {}} + def is_corrupt(self) -> bool: + """Report whether an existing registry file is present but unreadable. + + ``_load`` deliberately recovers from a corrupt registry by normalizing + it to an empty mapping so install/enable/disable flows keep working. + Resolution paths, however, must fail closed: a corrupt registry that + normalizes to ``{}`` would otherwise cause every on-disk extension + directory to be admitted as an unregistered, enabled extension. This + probe lets those callers distinguish "no registry" (safe) from + "registry exists but is invalid" (unsafe) without changing recovery + behavior. An absent registry returns ``False``; a directory, broken + or dangling symlink, non-regular file, unreadable file, non-mapping + root, or non-mapping ``extensions`` value returns ``True``. + """ + # os.path.lexists (not Path.exists) so a dangling symlink is detected + # rather than followed to a non-existent target and mistaken for an + # absent registry — which would reopen the fail-open directory scan. + if not os.path.lexists(self.registry_path): + return False + if not self.registry_path.is_file(): + return True + try: + with open(self.registry_path, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + return True + if not isinstance(data, dict): + return True + if "extensions" in data and not isinstance(data["extensions"], dict): + return True + return False + def _save(self): """Save registry to disk.""" self.extensions_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 891b5e45bf..224e286810 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -56,6 +56,7 @@ _CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json" +_CONSTITUTION_SYNC_PRESET_ID = "constitution-sync" def _content_sha256(content: bytes) -> str: @@ -3602,13 +3603,10 @@ def install_from_directory( stacklevel=2, ) - # Seed/re-seed memory/constitution.md from a preset-provided - # constitution-template. The constitution is the only template that is - # materialized to a live file rather than resolved on demand, so a - # preset that ships one (e.g. strategy: replace with a ratified - # constitution) must be propagated here. Guard against clobbering an - # already-authored constitution by only replacing a file whose recorded - # hash (or exact legacy core-template content) proves it was generated. + # Materialize constitution-template changes only for projects that opt + # into the constitution-sync preset. The core /constitution command + # resolves this template on demand; constitution-sync preserves the + # previous install-time behavior for teams that want reviewed snapshots. self._seed_constitution_from_preset(manifest, dest_dir) return manifest @@ -3616,14 +3614,13 @@ def install_from_directory( def _seed_constitution_from_preset( self, manifest: PresetManifest, preset_dir: Path ) -> None: - """Seed memory/constitution.md from a preset constitution-template. + """Seed memory/constitution.md when constitution-sync opts into snapshots. - Only runs when the preset declares a ``type: template`` entry named - ``constitution-template`` or provides one at a convention path, and the - live memory file is either missing or is an unchanged generated file. - Authored constitutions are never overwritten. + Installing constitution-sync itself materializes the currently resolved + stack. Later preset installs only reconcile when they provide a + ``constitution-template``. Authored constitutions are never overwritten. """ - provides_constitution = any( + provides_constitution = manifest.id == _CONSTITUTION_SYNC_PRESET_ID or any( t.get("type") == "template" and t.get("name") == "constitution-template" for t in manifest.templates ) or any( @@ -3644,7 +3641,7 @@ def _seed_constitution_from_preset( def reconcile_constitution( self, failure_context: str, *, create_if_missing: bool = False ) -> None: - """Reconcile generated constitution content without failing a persisted change.""" + """Reconcile an opted-in generated constitution without failing a change.""" try: self._reconcile_constitution(create_if_missing=create_if_missing) except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc: @@ -3656,7 +3653,11 @@ def reconcile_constitution( ) def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None: - """Materialize the winning constitution layer when the live file is generated.""" + """Materialize the winning layer when constitution-sync is enabled.""" + sync_metadata = self.registry.get(_CONSTITUTION_SYNC_PRESET_ID) + if sync_metadata is None or not sync_metadata.get("enabled", True): + return + memory_constitution = ( self.project_root / ".specify" / "memory" / "constitution.md" ) @@ -4962,6 +4963,18 @@ def _get_manifest(self, pack_dir: Path) -> Optional["PresetManifest"]: self._manifest_cache[key] = None return self._manifest_cache[key] + @staticmethod + def _is_safe_registry_id(value: object) -> bool: + return isinstance(value, str) and re.fullmatch(r"[a-z0-9-]+", value) is not None + + def _get_all_presets_by_priority(self) -> List[tuple[str, dict]]: + registry = PresetRegistry(self.presets_dir) + return [ + (pack_id, metadata) + for pack_id, metadata in registry.list_by_priority() + if self._is_safe_registry_id(pack_id) + ] + def _manifest_declared_template( self, pack_dir: Path, template_name: str, template_type: str ) -> tuple[dict | None, Path | None]: @@ -5067,6 +5080,16 @@ def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: return [] registry = ExtensionRegistry(self.extensions_dir) + # Fail closed on a corrupt registry. ExtensionRegistry._load() recovers + # by normalizing an unreadable registry to an empty mapping, which would + # otherwise cause the directory scan below to admit every on-disk + # directory as an unregistered, enabled extension — a fail-open path + # that could supply constitution content from an invalid registry state. + if registry.is_corrupt(): + raise PresetValidationError( + f"Invalid extension registry {registry.registry_path}: " + "refusing to enumerate extensions" + ) # Use keys() to track ALL extensions (including corrupted entries) without deep copy # This prevents corrupted entries from being picked up as "unregistered" dirs registered_extension_ids = registry.keys() @@ -5078,6 +5101,8 @@ def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: # Only include enabled extensions in the result for ext_id, metadata in all_registered: + if not self._is_safe_registry_id(ext_id): + continue # Skip disabled extensions if not metadata.get("enabled", True): continue @@ -5086,7 +5111,7 @@ def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: # Add unregistered directories with implicit priority=10 for ext_dir in self.extensions_dir.iterdir(): - if not ext_dir.is_dir() or ext_dir.name.startswith("."): + if not ext_dir.is_dir() or not self._is_safe_registry_id(ext_dir.name): continue if ext_dir.name not in registered_extension_ids: all_extensions.append((10, ext_dir.name, None)) @@ -5152,8 +5177,7 @@ def resolve( # Priority 2: Installed presets (sorted by priority — lower number wins) if not skip_presets and self.presets_dir.exists(): - registry = PresetRegistry(self.presets_dir) - for pack_id, _metadata in registry.list_by_priority(): + for pack_id, _metadata in self._get_all_presets_by_priority(): pack_dir = self.presets_dir / pack_id # The preset manifest is authoritative: if it declares this # template with an explicit ``file:``, resolve to that path — @@ -5356,13 +5380,11 @@ def resolve_with_source( return {"path": resolved_str, "source": "project override"} if str(self.presets_dir) in resolved_str and self.presets_dir.exists(): - registry = PresetRegistry(self.presets_dir) - for pack_id, _metadata in registry.list_by_priority(): + for pack_id, metadata in self._get_all_presets_by_priority(): pack_dir = self.presets_dir / pack_id try: resolved.relative_to(pack_dir) - meta = registry.get(pack_id) - version = meta.get("version", "?") if meta else "?" + version = metadata.get("version", "?") return { "path": resolved_str, "source": f"{pack_id} v{version}", @@ -5448,8 +5470,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: # Priority 2: Installed presets (sorted by priority — lower number = higher precedence) if self.presets_dir.exists(): - registry = PresetRegistry(self.presets_dir) - for pack_id, metadata in registry.list_by_priority(): + for pack_id, metadata in self._get_all_presets_by_priority(): pack_dir = self.presets_dir / pack_id # Read strategy and manifest file path from preset manifest strategy = "replace" diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index e601152766..145dc6e9df 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -9,6 +9,7 @@ from __future__ import annotations import os +import re from pathlib import Path import typer @@ -352,9 +353,26 @@ def preset_resolve( from .. import _require_specify_project from . import PresetResolver + is_command = "." in template_name + valid_name = ( + re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", template_name) + if is_command + else re.fullmatch(r"[a-z0-9-]+", template_name) + ) + if valid_name is None: + typer.echo( + f"Error: invalid template name '{template_name}'; " + "use lowercase letters, digits, and hyphens, with non-empty " + "dot-separated segments for commands", + err=True, + ) + raise typer.Exit(1) + project_root = _require_specify_project() resolver = PresetResolver(project_root) - layers = resolver.collect_all_layers(template_name) + template_type = "command" if is_command else "template" + + layers = resolver.collect_all_layers(template_name, template_type) safe_template_name = _escape_markup(str(template_name)) if layers: @@ -377,7 +395,7 @@ def preset_resolve( if has_composition: # Verify composition is actually possible try: - composed = resolver.resolve_content(template_name) + composed = resolver.resolve_content(template_name, template_type) except Exception as exc: composed = None console.print( @@ -416,7 +434,7 @@ def preset_resolve( ) else: # No layers found — fall back to resolve_with_source for non-composition cases - result = resolver.resolve_with_source(template_name) + result = resolver.resolve_with_source(template_name, template_type) if result: console.print( f" [bold]{safe_template_name}[/bold]: " diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index 6a5c6d8745..b5bccfdc06 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -1,9 +1,9 @@ --- 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" @@ -72,7 +72,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"). @@ -127,7 +127,7 @@ 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": - 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`) diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index c631e8e84c..7b2f3684fb 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -4,6 +4,10 @@ handoffs: - label: Build Specification agent: speckit.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 --- ## User Input @@ -70,13 +74,22 @@ and commands read the constitution at runtime and are not modified here. ## Outline -You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values and (b) fill the template precisely. - -**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first. +You are updating the project constitution at `.specify/memory/constitution.md`. The active +constitution scaffold is resolved at command time from `constitution-template` through the Spec Kit +preset/template resolution stack. Follow this execution flow: -1. Load the existing constitution at `.specify/memory/constitution.md`. +1. Run `{SCRIPT}` from the repository root and parse `TEMPLATE_CONTENT` as the active template. + - The shared resolver applies project overrides, composing preset layers, and extension layers + before the core template fallback. It MUST succeed before continuing. + - If it fails, stop and report the resolution error; do not continue with only one contributing + template layer. + - If `.specify/memory/constitution.md` exists, load it as the source of current project-specific + values and amendments. Preserve information that is still applicable when applying the newly + resolved scaffold. + - If it does not exist, use the resolved template as the initial document. + - Do not write back to any versioned template layer. - Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`. **IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly. @@ -90,7 +103,7 @@ Follow this execution flow: - PATCH: Clarifications, wording, typo fixes, non-semantic refinements. - If version bump type ambiguous, propose reasoning before finalizing. -3. Draft the updated constitution content: +3. Draft the updated constitution content using the resolved template as the required structure: - Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left). - Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance. - Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious. @@ -128,7 +141,7 @@ If the user supplies partial updates (e.g., only one principle revision), still If critical info missing (e.g., ratification date truly unknown), insert `TODO(): explanation` and include in the Sync Impact Report under deferred items. -Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file. +Write only `.specify/memory/constitution.md`; do not create or modify template source files. ## Post-Execution Checks diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 00d73354e3..64146a35aa 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -60,7 +60,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline -1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, 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"). +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"). 2. **Load design documents**: Read from FEATURE_DIR: - **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities) @@ -79,7 +79,7 @@ You **MUST** consider the user input before proceeding (if not empty). - Create parallel execution examples per user story - Validate task completeness (each user story has all needed tasks, independently testable) -4. **Generate tasks.md**: Read the tasks template from TASKS_TEMPLATE (from the JSON output above) and use it as structure. If TASKS_TEMPLATE is empty, fall back to `.specify/templates/tasks-template.md`. Fill with: +4. **Generate tasks.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. Fill with: - Correct feature name from plan.md - Phase 1: Setup tasks (project initialization) - Phase 2: Foundational tasks (blocking prerequisites for all user stories) diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 310a0347de..e94f58c6ff 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -242,11 +242,11 @@ def _expected_files(self, script_variant: str) -> 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 d064224014..015152be01 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -494,6 +494,7 @@ def _expected_files(self, script_variant: str) -> list[str]: ".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", ] @@ -502,6 +503,7 @@ def _expected_files(self, script_variant: str) -> list[str]: ".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 5469f1350e..1b1bb18807 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -495,6 +495,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "check-prerequisites.sh", "common.sh", "create-new-feature.sh", + "resolve-template.sh", "setup-plan.sh", "setup-tasks.sh", ]: @@ -504,6 +505,7 @@ def _expected_files(self, script_variant: str) -> 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 3312dfec07..01914c5988 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -409,6 +409,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "check-prerequisites.sh", "common.sh", "create-new-feature.sh", + "resolve-template.sh", "setup-plan.sh", "setup-tasks.sh", ]: @@ -418,6 +419,7 @@ def _expected_files(self, script_variant: str) -> 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_cline.py b/tests/integrations/test_integration_cline.py index 5bd25c7d85..3c813e8300 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -192,6 +192,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "check-prerequisites.sh", "common.sh", "create-new-feature.sh", + "resolve-template.sh", "setup-plan.sh", "setup-tasks.sh", ]: @@ -201,6 +202,7 @@ def _expected_files(self, script_variant: str) -> 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_copilot.py b/tests/integrations/test_integration_copilot.py index b75eac9714..35d30faf0e 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -278,6 +278,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", @@ -342,6 +343,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", @@ -854,6 +856,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 fab64a9f0a..02176be1b0 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -348,6 +348,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", @@ -406,6 +407,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/parity_helpers.py b/tests/parity_helpers.py index 9289471eaf..27627dab5b 100644 --- a/tests/parity_helpers.py +++ b/tests/parity_helpers.py @@ -109,6 +109,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_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 7c2c0e5622..41122b1f5f 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, @@ -382,12 +384,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_presets.py b/tests/test_presets.py index 86847b1784..3cad6608d5 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") @@ -9786,6 +9907,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 @@ -10031,6 +10153,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 @@ -10251,6 +10374,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.""" @@ -12779,10 +12925,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): @@ -12843,10 +12989,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) @@ -12889,9 +13033,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() @@ -13260,11 +13403,41 @@ 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) + assert "constitution.md" in strip_ansi(result.output) + + 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. @@ -13358,14 +13531,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", From 11e3176fd13cb736fd1c205809f25aa51141dc4f Mon Sep 17 00:00:00 2001 From: chelsealong Date: Mon, 10 Aug 2026 23:23:23 +0800 Subject: [PATCH 13/42] fix(extensions): reject duplicate provides.templates/scripts names (#4016) The resolver returns the first entry matching a declared name, so a later duplicate within provides.templates or provides.scripts was silently unreachable while still counted by ExtensionManifest properties. Reject duplicates at manifest-validation time instead. Also clarify EXTENSION-DEVELOPMENT-GUIDE.md's provides section: hooks and events are top-level manifest fields, not provides sub-fields, so the "at least one of ..." wording doesn't imply they can be nested under provides. --- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 7 ++++++- src/specify_cli/extensions/__init__.py | 12 +++++++++++- tests/test_extensions.py | 23 +++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index 5030565b14..ac78029f2a 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -177,12 +177,17 @@ Compatibility requirements. What the extension provides. -**Optional sub-fields** (at least one of `commands`, `templates`, `scripts`, `hooks`, or `events` is required): +**Optional sub-fields:** - `commands`: Array of command objects - `templates`: Array of template objects - `scripts`: Array of script objects +`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**: - `name`: Command name (must match `speckit.{ext-id}.{command}`) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 299a5f22c0..249a5d40fa 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -577,8 +577,13 @@ def _validate_provided_artifacts(entries: List[Any], section: str, singular: str behavior for extension layers in presets/__init__.py). A present 'strategy' key is rejected rather than silently ignored, so an author who copies a preset-style entry gets a clear error instead of a - silently-dropped field. + silently-dropped field. Duplicate names within a section are also + rejected: the resolver returns the first matching entry by name + (``PresetResolver._extension_manifest_declared_template``), so a + later duplicate would be silently unreachable while still being + exposed by ``ExtensionManifest.templates``/``.scripts``. """ + seen_names: set[str] = set() for entry in entries: if not isinstance(entry, dict): raise ValidationError( @@ -597,6 +602,11 @@ def _validate_provided_artifacts(entries: List[Any], section: str, singular: str f"Invalid {singular} name '{name}': " "must be lowercase alphanumeric with hyphens only" ) + if name in seen_names: + raise ValidationError( + f"Duplicate {singular} name '{name}' in 'provides.{section}'" + ) + seen_names.add(name) file_value = entry["file"] reason = relative_extension_path_violation(file_value) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6508826dc9..36e7d67aab 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1162,6 +1162,29 @@ def test_provides_entry_invalid_name_format(self, temp_dir, valid_manifest_data, 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.""" From 29efcbe2c4a5c3db8a36dd2b17d3131374c8b5b4 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Mon, 10 Aug 2026 21:07:11 +0500 Subject: [PATCH 14/42] fix: use missing_ok=True in extension cache clear (#3845) Replace check-then-act pattern (exists()+unlink()) with unlink(missing_ok=True) to eliminate TOCTOU race condition. Matches the pattern already used for per-URL cache files in the same method. From ea1f5b8a2ddbe3dd34c6a194938ba49d7b9b318f Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Mon, 10 Aug 2026 21:08:29 +0500 Subject: [PATCH 15/42] fix: use missing_ok=True in integration JSON removal (#3846) Replace check-then-act pattern (exists()+unlink()) with unlink(missing_ok=True) to eliminate TOCTOU race condition. From a2c7ff29db114c718f8225b1be26cfb1b93aedd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:11:20 -0500 Subject: [PATCH 16/42] Add Model Routing Governance preset to community catalog (#4033) Add model-routing-governance preset submitted by @hindermath to: - presets/catalog.community.json (alphabetical order) - docs/community/presets.md community presets table Closes #4021 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/presets.md | 1 + presets/catalog.community.json | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index 2d6cdb30a2..bf204cfb75 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -25,6 +25,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | 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) | | 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) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 751d57d318..47a9a9d509 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-07-28T00:00:00Z", + "updated_at": "2026-08-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { @@ -541,6 +541,35 @@ "created_at": "2026-05-08T00:00:00Z", "updated_at": "2026-05-08T00:00:00Z" }, + "model-routing-governance": { + "name": "Model Routing Governance", + "id": "model-routing-governance", + "version": "0.1.4", + "description": "Maps provider-neutral Spec Kit roles to validated harness-local runner profiles without storing model availability, credentials, or machine-specific selections in Git.", + "author": "Thorsten Hindermann", + "repository": "https://github.com/hindermath/spec-kit-preset-model-routing-governance", + "download_url": "https://github.com/hindermath/spec-kit-preset-model-routing-governance/archive/refs/tags/v0.1.4.zip", + "homepage": "https://github.com/hindermath/spec-kit-preset-model-routing-governance", + "documentation": "https://github.com/hindermath/spec-kit-preset-model-routing-governance/blob/v0.1.4/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.3" + }, + "provides": { + "templates": 4, + "commands": 2, + "scripts": 2 + }, + "tags": [ + "model-routing", + "agents", + "governance", + "provider-neutral", + "cross-platform" + ], + "created_at": "2026-08-10T00:00:00Z", + "updated_at": "2026-08-10T00:00:00Z" + }, "multi-repo-branching": { "name": "Multi-Repo Branching", "id": "multi-repo-branching", From 2100f59f7e33d6fbfe84d1f44cc3b97e974a606b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:13:19 -0500 Subject: [PATCH 17/42] Update Reconcile Extension to v1.1.0 (#4034) Update reconcile extension submitted by @stn1slv: - extensions/catalog.community.json (version, download_url, updated_at) - docs/community/extensions.md community extensions table Closes #4024 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 9657122cc7..0f649bbdbb 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-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "adrkit": { @@ -3406,8 +3406,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 +3432,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", From 44c3dc7d3e3d1735600cabef030b26470fa2af26 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Mon, 10 Aug 2026 22:19:36 +0500 Subject: [PATCH 18/42] fix: show error details in preset catalog config read failure (#3840) Capture and display the exception message when reading preset-catalogs.yml fails, instead of swallowing the error details. Matches the pattern used in preset_catalog_add 54 lines earlier. --- src/specify_cli/presets/_commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index 145dc6e9df..b7e5ad06e5 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -818,8 +818,8 @@ def preset_catalog_remove( try: config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - except Exception: - console.print("[red]Error:[/red] Failed to read preset catalog config.") + except Exception as e: + console.print(f"[red]Error:[/red] Failed to read preset catalog config: {e}") raise typer.Exit(1) catalogs = config.get("catalogs", []) From f0ee1fc419f8f50493170c406a8f8e93a9fa7768 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:23:10 -0500 Subject: [PATCH 19/42] Add Keel Discovery extension to community catalog (#4035) Add keel extension submitted by @athulrajeev to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #4026 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 34 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 44f7e16717..b97e2cb990 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 0f649bbdbb..7d3d8c69c4 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -2107,6 +2107,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", From 1a44a6aa080cef8dcaa9474d8494aa8a47b93d89 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Mon, 10 Aug 2026 22:26:07 +0500 Subject: [PATCH 20/42] fix(presets): skip an unreadable restore source in `preset remove` (#4020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(presets): skip an unreadable restore source in `preset remove` `_unregister_skills_in_dir` restores each preset-owned SKILL.md from a core command template or an extension source. Both of those reads were bare `read_text(encoding="utf-8")` calls, so a project-owned override in `.specify/templates/commands/` that exists but cannot be read or decoded raised a raw `UnicodeDecodeError`/`OSError` straight out of `PresetManager.remove()`, which has no handler for it — `specify preset remove` dies with a traceback. Every other failure in this loop degrades with `continue`: an unsafe registry name, a missing skill subdirectory, a foreign owner. Sibling reads of the very same directory are already guarded — `_infer_legacy_skill_ provenance` and `_delete_agent_preset_skills` both wrap their SKILL.md read in `except (OSError, UnicodeDecodeError): continue`, and the read inside `_substitute_core_template` was just given the same boundary in #3961. The two restore reads were the remaining gap. `continue` is the right recovery here rather than falling through: the `else` branch below removes the skill outright, so treating an unreadable source as "no source" would delete a user's skill at exactly the moment its replacement cannot be generated. Skipping leaves the skill in place and keeps it out of the returned `mutated_names`, so callers don't record a restore that never happened. Two regression tests, one per exception arm: a non-UTF-8 core template, and a mocked `PermissionError` so the `OSError` half is also covered under privileged CI where permission bits aren't enforced. Both assert the skill survives untouched and is not reported as mutated. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(presets): warn when a skill keeps preset content after a failed restore Review follow-up on two points. Surface the skipped restore. Skipping is still the correct recovery — the alternative branch deletes the skill — but it was silent, and it is a partial removal: `remove()` goes on to delete the preset directory and the registry entry, while this `SKILL.md` keeps the removed preset's content, and leaving the name out of `mutated_names` also keeps it out of reconciliation, so nothing retries it. Both arms now emit a warning naming the skill, the unreadable source, and the exception, and pointing at the re-run that refreshes it once the file is fixed. `warnings.warn` matches how the surrounding code reports non-fatal degradation (the reconciliation failures in `remove()`/`install_from_directory`, the unreadable core template in `_substitute_core_template` from #3961). Cover the extension arm. A skill backed by an installed extension never reaches the core-template read, so the two branches can regress independently and both prior tests exercised only the core one. `test_unregister_skills_in_dir_unreadable_extension_source_skips` installs an extension whose command file is non-UTF-8 and asserts the skill survives byte-for-byte and is absent from `mutated_names`. Verified it raises the raw `UnicodeDecodeError` against unpatched source. The two existing tests now assert the warning via `pytest.warns` so dropping it fails the suite. pytest tests/test_presets.py -> 583 passed, 2 skipped, 7 failed; the 7 are the pre-existing Windows symlink tests that need elevation, unchanged from main. ruff check passes. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/specify_cli/presets/__init__.py | 51 +++++++++- tests/test_presets.py | 149 ++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 224e286810..6a359f5b29 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -3268,6 +3268,30 @@ def _delete_agent_preset_skills( if source in owned_sources: shutil.rmtree(skill_subdir) + @staticmethod + def _warn_unrestored_skill( + skill_name: str, source_file: Path, exc: BaseException + ) -> None: + """Warn that a skill kept preset content because its restore source is unreadable. + + Skipping the restore is the safe recovery — the alternative branch + deletes the skill outright — but it is still a partial removal: the + preset directory and registry entry go away while this ``SKILL.md`` + keeps the removed preset's content, and reconciliation never revisits + it because the name is left out of ``mutated_names``. Name the skill + and the source so the condition is actionable instead of silent. + """ + import warnings + + warnings.warn( + f"Skill '{skill_name}' still contains the removed preset's content: " + f"its restore source '{source_file}' could not be read " + f"({exc.__class__.__name__}: {exc}). The skill was left in place " + f"rather than deleted. Fix or remove that file and re-run " + f"'specify preset add'/'specify preset remove' to refresh it.", + stacklevel=2, + ) + def _unregister_skills_in_dir( self, skill_names: List[str], @@ -3394,8 +3418,20 @@ def _unregister_skills_in_dir( core_file = None if core_file: - # Restore from core template - content = core_file.read_text(encoding="utf-8") + # Restore from core template. An unreadable/undecodable + # source cannot produce restored content, so leave the + # existing skill untouched rather than leaking a raw + # OSError/UnicodeDecodeError out of `preset remove` — and + # rather than falling through to the rmtree below, which + # would delete a skill precisely when its replacement + # cannot be generated. Matches the `continue` guards above + # (unsafe name, missing subdir, foreign owner), which also + # skip without recording the name as mutated. + try: + content = core_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + self._warn_unrestored_skill(skill_name, core_file, exc) + continue frontmatter, body = registrar.parse_frontmatter(content) if isinstance(selected_ai, str): body = registrar.resolve_skill_placeholders( @@ -3436,7 +3472,16 @@ def _unregister_skills_in_dir( continue if extension_restore: - content = extension_restore["source_file"].read_text(encoding="utf-8") + # Same boundary as the core-template branch above: an + # unreadable extension source leaves the skill in place + # instead of crashing or being deleted. + try: + content = extension_restore["source_file"].read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + self._warn_unrestored_skill( + skill_name, extension_restore["source_file"], exc + ) + continue frontmatter, body = registrar.parse_frontmatter(content) # Mirror the register-time rewrite (#2101): resolve # extension-relative subdir references (agents/, diff --git a/tests/test_presets.py b/tests/test_presets.py index 3cad6608d5..317a1b437c 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -9452,6 +9452,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 ): From 36da77f86453d2b80ddf60a1c639ec1e0b7a121d Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Mon, 10 Aug 2026 22:34:02 +0500 Subject: [PATCH 21/42] fix(bundle): escape Rich markup in bundle CLI error and status output (#4023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bundle): escape Rich markup in bundle CLI error and status output `specify bundle`'s `_fail` helper interpolated its message straight into `err_console.print`, which has Rich markup enabled. Every caller passes `str(exc)` from a `BundlerError`, and those messages embed untrusted data -- including the command's own argument -- so a `[...]` in it was parsed as a style tag. Balanced tags were silently swallowed; an unbalanced closer raised `MarkupError`, which replaced the error message with a traceback and left the output completely empty. Three commands crashed on user input alone, with no project state required: specify bundle catalog add 'ssh://ex[/red]ample.com/c.json' specify bundle catalog remove 'no[/red]such' specify bundle update 'no[/red]such' `bundle validate` had the same failure on both branches: its errors echo `requires.speckit_version`, and its warnings echo component ids, which are not charset-validated -- so a structurally *valid* manifest crashed on the success path too. Fixed centrally in `_fail`, plus the remaining raw interpolations: the `validate` warning/error/success lines, the install overlap and plan warnings, the install/update/remove/catalog-add confirmations, the `catalog list` id/url, and the `bundle init` project path. Regression tests cover the four crashing error paths (parametrized) and both `validate` branches; all six fail without this change. Assisted-by: Claude Opus 4.8 (1M context) * fix(bundle): escape markup in `bundle list` records and `bundle build` output path Review follow-up: two raw interpolations the first sweep missed, both on success paths rather than error paths. `bundle_list` rendered `record.bundle_id`, `record.version` and `record.installed_at` unescaped. `InstalledBundleRecord.from_dict` only requires non-empty strings for the first two and applies no charset check to any of them, so a records file that *loads cleanly* still crashed the command that displays it — confirmed as `MarkupError: closing tag '[/red]' at position 12 doesn't match any open tag`. `bundle_build` echoed `result.artifact_path` twice in its success line. Brackets are legal in a directory name, so a bracketed `--output` built the artifact and then misreported it: the work is already on disk when the markup is consumed, so the line names a path that does not exist. Re-scanned every `{...}` interpolation in the module to confirm nothing else remains: the rest are either `BundlerError` messages that funnel through the already-escaped `_fail`, `_format_component` output escaped at its call site (:293), ints, or hardcoded enum `.value`s. Two regression tests. The list case uses the unbalanced-closer form that raises outright. The build case deliberately uses `[bold]` instead: `/` is a path separator on Windows, so `dist[/red]out` becomes the directory `dist[\red]out` and the fixture stops testing what it claims — the silent-swallow form keeps it portable while still asserting the reported path matches what was written. Verified both fail against 1d2184d. tests/contract/test_bundle_cli.py -> 42 passed. tests/contract tests/integration tests/unit -> 364 passed, 6 skipped, 5 failed; the 5 are the pre-existing `*_refuses_symlinked_*` tests needing symlink privileges on Windows, unchanged from main. ruff check passes. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/specify_cli/commands/bundle/__init__.py | 60 +++++++---- tests/contract/test_bundle_cli.py | 111 ++++++++++++++++++++ 2 files changed, 151 insertions(+), 20 deletions(-) diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 10df8aca14..7ccc6cba31 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -45,7 +45,12 @@ def _fail(message: str) -> None: """Print an actionable error to stderr and exit non-zero.""" # Use the stderr console so the error never lands on stdout, which under # ``--json`` carries the machine-readable payload and must stay parseable. - err_console.print(f"[red]Error:[/red] {message}", style=None) + # Escape the message: every caller passes ``str(exc)`` from a BundlerError + # that interpolates untrusted data (a CLI argument, a catalog url, a + # bundle.yml field), so a '[...]' in it would be parsed as a Rich style tag + # -- silently swallowing the text, or raising MarkupError on an unbalanced + # closer and replacing the whole message with a traceback. + err_console.print(f"[red]Error:[/red] {_escape_markup(message)}", style=None) raise typer.Exit(code=1) @@ -332,9 +337,10 @@ def bundle_list( console.print("\n[bold cyan]Installed bundles:[/bold cyan]\n") for record in records: console.print( - f" [bold]{record.bundle_id}[/bold] v{record.version} " + f" [bold]{_escape_markup(str(record.bundle_id))}[/bold] " + f"v{_escape_markup(str(record.version))} " f"[dim]({len(record.contributed_components)} components, " - f"installed {record.installed_at})[/dim]" + f"installed {_escape_markup(str(record.installed_at))})[/dim]" ) @@ -394,13 +400,13 @@ def bundle_install( ) console.print( f"[cyan]No Spec Kit project here; initializing with integration " - f"'{init_integration}'…[/cyan]" + f"'{_escape_markup(str(init_integration))}'…[/cyan]" ) _run_init(init_integration, script_type=_default_script_type(), offline=offline) project_root = require_project_root() for overlap in _bundle_overlaps(project_root, manifest, offline=offline): - console.print(f"[yellow]![/yellow] {overlap}") + console.print(f"[yellow]![/yellow] {_escape_markup(str(overlap))}") # For an already-initialized project, the project's recorded active # integration is authoritative — an explicit --integration must not be @@ -415,7 +421,7 @@ def bundle_install( integration_explicit=bool(integration) and detected is None, ) for warning in plan.warnings: - console.print(f"[yellow]![/yellow] {warning}") + console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}") result = install_bundle( project_root, @@ -428,7 +434,7 @@ def bundle_install( return console.print( - f"[green]✓[/green] Installed '{result.bundle_id}' " + f"[green]✓[/green] Installed '{_escape_markup(str(result.bundle_id))}' " f"({len(result.installed)} added, {len(result.skipped)} already present)." ) @@ -480,7 +486,10 @@ def bundle_update( integration_explicit=bool(integration) and detected is None, ) install_bundle(project_root, plan, installer, manifest=manifest, refresh=True) - console.print(f"[green]✓[/green] Updated '{target}' to v{plan.version}.") + console.print( + f"[green]✓[/green] Updated '{_escape_markup(str(target))}' " + f"to v{_escape_markup(str(plan.version))}." + ) except BundlerError as exc: _fail(str(exc)) return @@ -502,7 +511,7 @@ def bundle_remove( return console.print( - f"[green]✓[/green] Removed '{result.bundle_id}' " + f"[green]✓[/green] Removed '{_escape_markup(str(result.bundle_id))}' " f"({len(result.uninstalled)} uninstalled, {len(result.skipped)} kept for other bundles)." ) @@ -542,13 +551,16 @@ def bundle_validate( return for warning in report.warnings: - console.print(f"[yellow]![/yellow] {warning}") + console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}") if not report.ok: console.print("[red]Manifest is invalid:[/red]") for error in report.errors: - console.print(f" [red]-[/red] {error}") + console.print(f" [red]-[/red] {_escape_markup(str(error))}") raise typer.Exit(code=1) - console.print(f"[green]✓[/green] {manifest.bundle.id} is well-formed and valid.") + console.print( + f"[green]✓[/green] {_escape_markup(str(manifest.bundle.id))} " + "is well-formed and valid." + ) @bundle_app.command("build") @@ -571,8 +583,9 @@ def bundle_build( return console.print( - f"[green]✓[/green] Built {result.artifact_path.name} " - f"({result.file_count} files) → {result.artifact_path}" + f"[green]✓[/green] Built {_escape_markup(result.artifact_path.name)} " + f"({result.file_count} files) → " + f"{_escape_markup(str(result.artifact_path))}" ) @@ -591,7 +604,7 @@ def bundle_init( init_integration = _resolve_init_integration(integration, None) console.print( f"[cyan]Initializing a Spec Kit project with integration " - f"'{init_integration}'…[/cyan]" + f"'{_escape_markup(str(init_integration))}'…[/cyan]" ) _run_init(init_integration, script_type=_default_script_type(), offline=offline) project_root = require_project_root() @@ -599,7 +612,10 @@ def bundle_init( _fail(str(exc)) return - console.print(f"[green]✓[/green] Spec Kit project ready at {project_root}.") + console.print( + f"[green]✓[/green] Spec Kit project ready at " + f"{_escape_markup(str(project_root))}." + ) if bundle: bundle_install(bundle, integration=integration, offline=offline) @@ -623,10 +639,11 @@ def catalog_list() -> None: only_builtin = all(s.scope == Scope.BUILTIN for s in sources) for source in sources: console.print( - f" [bold]{source.id}[/bold] priority={source.priority} " + f" [bold]{_escape_markup(str(source.id))}[/bold] " + f"priority={source.priority} " f"policy={source.install_policy.value} scope={source.scope.value}" ) - console.print(f" [dim]{source.url}[/dim]") + console.print(f" [dim]{_escape_markup(str(source.url))}[/dim]") if only_builtin: console.print("\n[dim]Using the built-in default stack.[/dim]") @@ -651,7 +668,7 @@ def catalog_add( return console.print( - f"[green]✓[/green] Added catalog '{source.id}' " + f"[green]✓[/green] Added catalog '{_escape_markup(str(source.id))}' " f"(priority {source.priority}, {source.install_policy.value})." ) @@ -670,7 +687,10 @@ def catalog_remove( _fail(str(exc)) return - console.print(f"[green]✓[/green] Removed catalog source '{removed}'.") + console.print( + f"[green]✓[/green] Removed catalog source " + f"'{_escape_markup(str(removed))}'." + ) # ZIP magic-byte signatures used to detect .zip payloads from REST API asset diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index 830a22c5dc..bed2f8964d 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -217,6 +217,31 @@ def test_catalog_remove_builtin_is_refused(project: Path): assert "built-in" in result.output +# Every ``bundle`` error path funnels through ``_fail(str(exc))``, and the +# BundlerError messages interpolate untrusted data -- including the command's +# own argument. An unbalanced closer used to raise MarkupError instead of the +# error, leaving the user with a traceback and no message at all. +@pytest.mark.parametrize( + "argv, expected", + [ + ( + ["bundle", "catalog", "add", "ssh://ex[/red]ample.com/c.json"], + "ssh://ex[/red]ample.com/c.json", + ), + (["bundle", "catalog", "remove", "no[/red]such"], "no[/red]such"), + (["bundle", "update", "no[/red]such"], "no[/red]such"), + (["bundle", "remove", "no[/red]such"], "no[/red]such"), + ], +) +def test_error_paths_escape_rich_markup(project: Path, argv: list, expected: str): + result = runner.invoke(app, argv) + + assert result.exit_code == 1 + # A MarkupError would surface here as an exception rather than a clean exit. + assert isinstance(result.exception, SystemExit) + assert expected in strip_ansi(result.output) + + def test_validate_reports_invalid_manifest(project: Path): data = valid_manifest_dict() del data["bundle"]["license"] @@ -237,6 +262,33 @@ def test_validate_accepts_valid_manifest(project: Path): assert "valid" in result.output +def test_validate_escapes_manifest_markup_in_errors(project: Path): + data = valid_manifest_dict() + # An invalid constraint is echoed back inside the validation error. + data["requires"] = {"speckit_version": ">=1.0[/bold]"} + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + + result = runner.invoke(app, ["bundle", "validate", "--offline"]) + + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + assert ">=1.0[/bold]" in strip_ansi(result.output) + + +def test_validate_escapes_manifest_markup_in_warnings(project: Path): + data = valid_manifest_dict() + # Step ids are not charset-validated, and the unresolved-reference warning + # echoes them -- so an otherwise *valid* manifest crashed just as readily as + # an invalid one, on the success path. + data["provides"]["steps"] = [{"id": "step[/bold]a"}] + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + + result = runner.invoke(app, ["bundle", "validate", "--offline"]) + + assert result.exit_code == 0, repr(result.exception) + assert "step[/bold]a" in strip_ansi(result.output) + + def test_validate_rejects_broken_reference(project: Path): # Synthetic component ids resolve to nothing in any catalog → hard failure. (project / "bundle.yml").write_text( @@ -267,6 +319,65 @@ def test_build_produces_artifact(project: Path): assert len(artifacts) == 1 +def test_build_escapes_markup_in_output_path(project: Path): + """The build success line echoes a caller-supplied ``--output`` path. + + Brackets are legal in a directory name on both POSIX and Windows, so the + artifact is built and *then* misreported: ``[bold]`` is consumed as a style + tag, and the success line names a path that does not exist on disk. + + A closing tag (``[/red]``) would raise MarkupError outright, but ``/`` is a + path separator on Windows, so this uses the silent-swallow form to keep the + fixture portable. + """ + (project / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + (project / "README.md").write_text("# Demo", encoding="utf-8") + out_dir = project / "dist[bold]out" + + result = runner.invoke(app, ["bundle", "build", "--output", str(out_dir)]) + + assert result.exit_code == 0, repr(result.exception) + assert list(out_dir.glob("*.zip")), "the artifact should still be built" + assert "dist[bold]out" in strip_ansi(result.output), ( + "the reported path must match the directory actually written" + ) + + +def test_list_escapes_markup_in_records(project: Path): + """``bundle list`` renders record fields that are never charset-validated. + + ``InstalledBundleRecord.from_dict`` accepts any non-empty string for + ``bundle_id``/``version`` and any string for ``installed_at``, so a records + file that *loads cleanly* could still crash the command that displays it. + """ + (project / ".specify" / "bundle-records.json").write_text( + json.dumps( + { + "schema_version": "1.0", + "bundles": [ + { + "bundle_id": "demo[/red]id", + "version": "1.0.0[/bold]", + "installed_at": "2026-01-01T00:00:00Z[/dim]", + "contributed_components": [], + } + ], + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["bundle", "list"]) + + assert result.exit_code == 0, repr(result.exception) + output = strip_ansi(result.output) + assert "demo[/red]id" in output + assert "1.0.0[/bold]" in output + assert "2026-01-01T00:00:00Z[/dim]" in output + + def _mock_manifest_download(monkeypatch, source_path: Path) -> None: """Mock the HTTPS manifest fetch to return a locally-authored manifest. From 2df78f33fb41bdaf5a63ef839ed6fd5cb8f1cbc4 Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:07:50 +0800 Subject: [PATCH 22/42] Fix bug-test Python dependency provisioning (#4030) * fix: provision Python test deps for bug-test workflow * test: anchor bug-test workflow domain assertions Address CodeQL py/incomplete-url-substring-sanitization alerts (14-17) by anchoring the PyPI domain assertions to their structural context: the `network.allowed` YAML list items in the source and the quoted JSON entries in the compiled lock. This defeats the incomplete-URL-substring pattern and strengthens the test to confirm the domains are real allowlist entries rather than incidental substrings. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5 * fix: provision test deps without creating a project lock Replace `uv sync --extra test` with `uv pip install --system -e ".[test]"` in the bug-test provisioning step. `uv sync` writes a root `uv.lock` (and `.venv`) into the working tree. This repository intentionally has no `uv.lock`/`[tool.uv]` (uv.lock is gitignored), so the sync produced an untracked lockfile before the agent checks out the fix ref in Step 2. `uv pip install` installs the test extra into the runner's Python without generating a project lock, keeping the working tree clean before the fix checkout. The editable install means the agent's `python3 -m pytest` runs against the checked-out fix code. Recompiled the lock and updated the assertions accordingly. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5 * chore(workflows): sync gh-aw action-pin metadata to latest across all workflows Dependabot bumps the third-party action `uses:` pins (and header comments) directly, but does not update gh-aw's own metadata: the per-file `gh-aw-manifest` JSON blob and the shared `.github/aw/actions-lock.json` pin cache. As a result the executing pins were already uniform and current (checkout v7.0.1, setup-node v7.0.0) while the manifest/cache metadata still recorded checkout v6.0.3 / setup-node v6.4.0. This is a latent downgrade hazard: a plain `gh aw compile` reads the stale cache and can silently revert the `uses:` lines back to the older pins, undoing Dependabot's bumps and breaking lockstep. Sync all four pin surfaces (uses / header comment / manifest / cache) to the current pins so every workflow agrees and a future recompile is a no-op: - actions-lock.json: checkout v6.0.3 -> v7.0.1, setup-node v6.4.0 -> v7.0.0, and add the setup-python v7.0.0 + setup-uv v9.0.0 entries now used by bug-test. - gh-aw-manifest blobs in the 5 non-bug-test lock files: checkout + setup-node bumped to match their own uses lines (bug-test was already current). No workflow body changes; only pin metadata. `uses:` pins are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5 Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) --------- Co-authored-by: root Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5 --- .github/aw/actions-lock.json | 22 ++++++++---- .../workflows/add-community-bundle.lock.yml | 2 +- .../add-community-extension.lock.yml | 2 +- .../workflows/add-community-preset.lock.yml | 2 +- .github/workflows/bug-assess.lock.yml | 2 +- .github/workflows/bug-fix.lock.yml | 2 +- .github/workflows/bug-test.lock.yml | 25 +++++++++---- .github/workflows/bug-test.md | 16 +++++++++ tests/test_github_workflows.py | 35 +++++++++++++++++++ 9 files changed, 90 insertions(+), 18 deletions(-) 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/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 From fd8cc6da22fca39fe934732d6713f8251869bd22 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Mon, 10 Aug 2026 23:18:36 +0500 Subject: [PATCH 23/42] fix: bound response read in integration catalog fetch (#3818) From 3451a21277f724abb2abe733cfd7cf8378f38202 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:19:50 +0500 Subject: [PATCH 24/42] fix(workflows): guard a non-string overlay edit 'operation' (#3881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_parse_edit` reads `operation` straight from hand-edited YAML and then does `if operation not in VALID_OPERATIONS`. `VALID_OPERATIONS` is a frozenset, so that membership test hashes the value — and an unhashable one raises: operation={'insert_after': 'a'} -> TypeError: unhashable type: 'dict' operation=['insert_after'] -> TypeError: unhashable type: 'list' `validate_overlay_yaml`'s docstring promises "validation never raises", and nothing upstream catches TypeError (layer_sources wraps only YAMLError/OSError/UnicodeDecodeError; _commands catches only ValueError), so the CLI dies with a raw traceback instead of reporting the error. The trigger is an ordinary authoring mistake: nesting the recommended shorthand form under the explicit key. Every other field in the same function is isinstance-guarded first (`anchor`, `step`, `step["id"]`); `operation` was the outlier. Check the type first and return the message the function already uses for `operation: None` / `operation: 7`. Co-authored-by: Claude Opus 5 (1M context) --- src/specify_cli/workflows/overlays/schema.py | 7 +++- tests/workflows/test_overlay_schema.py | 38 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/overlays/schema.py b/src/specify_cli/workflows/overlays/schema.py index 221d2fe8e5..0a018b7af0 100644 --- a/src/specify_cli/workflows/overlays/schema.py +++ b/src/specify_cli/workflows/overlays/schema.py @@ -87,7 +87,12 @@ def _parse_edit(edit_raw: dict[str, Any], idx: int) -> tuple[OverlayEdit | None, else: return None, f"Edit at index {idx} has no operation; expected one of {sorted(VALID_OPERATIONS)}." - if operation not in VALID_OPERATIONS: + # ``operation`` comes straight from hand-edited YAML, so it may be an + # unhashable mapping/sequence (``operation: {insert_after: a}`` when the + # shorthand form is nested by mistake). Membership-testing an unhashable + # value against the frozenset raises TypeError, which would escape this + # never-raising validator; check the type first, like 'anchor' below. + if not isinstance(operation, str) or operation not in VALID_OPERATIONS: return None, f"Edit at index {idx} has invalid operation {operation!r}." if not isinstance(anchor, str) or not anchor: 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( { From 1b3695bfb362bcd32c1463d25a7f6ffb01ec396d Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:20:47 +0500 Subject: [PATCH 25/42] fix(workflows): strip a resolved condition before the true/false check (#3883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit evaluate_condition() special-cases the strings "false"/"true" so that `condition: "false"` behaves as a boolean, but it matches with `result.lower()` and never strips. The most common way a *string* reaches a condition is captured command output, and the shell step stores stdout verbatim (steps/shell/__init__.py:67 `"stdout": proc.stdout`). So `run: echo false` resolves to "false\n", which matches neither branch and falls through to `bool("false\n")` -> True: 'false' -> False 'false\n' -> True <-- bug 'false\r\n' -> True <-- bug ' false' -> True <-- bug An `if` step therefore takes its `then` branch on a step that printed "false", and `while`/`do-while` keep dispatching their body. A workflow author cannot work around it: the registered filters are default/join/map/contains/from_json — there is no `trim`. `InitStep._resolve_bool` and both catalog readers already strip before matching boolean text. `bool(result)` still sees the raw string, so no non-boolean text changes truthiness. Co-authored-by: Claude Opus 5 (1M context) --- src/specify_cli/workflows/expressions.py | 14 +++++++++- tests/test_workflows.py | 33 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index a38cd6cb68..38a29890ae 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -671,8 +671,20 @@ def evaluate_condition(condition: str, context: Any) -> bool: result = evaluate_expression(condition, context) # Treat plain "false"/"true" strings as booleans so that # condition: "false" (without {{ }}) behaves as expected. + # + # Strip before matching: the string a condition resolves to is most often + # captured command output, and a ``shell`` step stores ``proc.stdout`` + # verbatim, so ``run: echo false`` resolves to ``"false\n"``. Without the + # strip that trailing newline matches neither branch and falls through to + # ``bool("false\n")`` -> True, silently taking an ``if`` step's ``then`` + # branch (and keeping a ``while``/``do-while`` looping) on a step that + # printed "false". A workflow cannot strip it itself -- the registered + # filters are default/join/map/contains/from_json, there is no ``trim``. + # ``InitStep._resolve_bool`` and the catalog readers already strip before + # matching boolean text. ``bool(result)`` below still sees the raw string, + # so no non-boolean text changes truthiness. if isinstance(result, str): - lower = result.lower() + lower = result.strip().lower() if lower == "false": return False if lower == "true": diff --git a/tests/test_workflows.py b/tests/test_workflows.py index f3a42c1c2c..95baf22d3c 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -786,6 +786,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 From 6aa9431b249375fe4b238fdcf647472b4e6bccb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Pe=C3=B1a?= Date: Mon, 10 Aug 2026 14:44:20 -0500 Subject: [PATCH 26/42] Add Command Code integration to spec-kit (#4019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Command Code integration to spec-kit Adds `command-code` as a built-in skills-based integration so Spec Kit can be installed into Command Code. Command Code loads agent skills from `.commandcode/skills/speckit-/SKILL.md` and invokes them in chat as `$speckit-`. - New `CommandCodeIntegration` (SkillsIntegration) writing to `.commandcode/skills/`; declared multi-install safe (static, isolated agent root). - Register in `_register_builtins()` and the integration catalog. - Add `command-code` to `DOLLAR_SKILLS_AGENTS` so next-steps guidance renders `$speckit-*` invocations. - Tests: reuse `SkillsIntegrationTests` mixin plus a dollar-invocation next-steps test; registry completeness updated. - Docs: README and docs/reference/integrations.md (supported agents + multi-install-safe table). Co-authored-by: CommandCodeBot Assisted-by: Command Code (autonomous) * Fix issue template agent lists to include command-code The runtime AGENT_CONFIG now includes command-code, but the GitHub issue templates and the consistency test's expected key list were not updated, failing test_issue_template_agent_lists_match_runtime_integrations. Co-authored-by: CommandCodeBot Assisted-by: Command Code (autonomous) --------- Co-authored-by: CommandCodeBot --- .github/ISSUE_TEMPLATE/agent_request.yml | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 1 + README.md | 2 +- docs/reference/integrations.md | 2 + integrations/catalog.json | 9 ++++ src/specify_cli/_invocation_style.py | 2 +- src/specify_cli/integrations/__init__.py | 2 + .../integrations/command_code/__init__.py | 41 ++++++++++++++++ .../test_integration_command_code.py | 47 +++++++++++++++++++ tests/integrations/test_registry.py | 2 +- tests/test_agent_config_consistency.py | 1 + 12 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 src/specify_cli/integrations/command_code/__init__.py create mode 100644 tests/integrations/test_integration_command_code.py 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/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/README.md b/README.md index cd48ae9fe3..5c0f2a592f 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,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 `/speckit.*` 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 `/speckit.*` 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 **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development. 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/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/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py index 29018e863a..5cc7098837 100644 --- a/src/specify_cli/_invocation_style.py +++ b/src/specify_cli/_invocation_style.py @@ -9,7 +9,7 @@ from __future__ import annotations # Agents that render $speckit- (chat invocation) when in skills mode. -DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode"}) +DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode", "command-code"}) # Agents that always render /speckit-, regardless of ai_skills. ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "droid", "grok", "trae", "zed"}) diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index e251395b72..75c2f9d0de 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -56,6 +56,7 @@ def _register_builtins() -> None: from .cline import ClineIntegration from .codebuddy import CodebuddyIntegration from .codex import CodexIntegration + from .command_code import CommandCodeIntegration from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration from .devin import DevinIntegration @@ -95,6 +96,7 @@ def _register_builtins() -> None: _register(ClineIntegration()) _register(CodebuddyIntegration()) _register(CodexIntegration()) + _register(CommandCodeIntegration()) _register(CopilotIntegration()) _register(CursorAgentIntegration()) _register(DevinIntegration()) diff --git a/src/specify_cli/integrations/command_code/__init__.py b/src/specify_cli/integrations/command_code/__init__.py new file mode 100644 index 0000000000..8eef9f5579 --- /dev/null +++ b/src/specify_cli/integrations/command_code/__init__.py @@ -0,0 +1,41 @@ +"""Command Code integration — skills-based agent. + +Command Code loads agent skills from ``.commandcode/skills/speckit-/SKILL.md`` +(project) or ``~/.commandcode/skills/`` (personal). Skills are invoked in chat +with ``$speckit-``. +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class CommandCodeIntegration(SkillsIntegration): + """Integration for Command Code CLI.""" + + key = "command-code" + config = { + "name": "Command Code", + "folder": ".commandcode/", + "commands_subdir": "skills", + "install_url": "https://commandcode.ai/docs", + "requires_cli": True, + } + registrar_config = { + "dir": ".commandcode/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + multi_install_safe = True + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Command Code)", + ), + ] diff --git a/tests/integrations/test_integration_command_code.py b/tests/integrations/test_integration_command_code.py new file mode 100644 index 0000000000..5075fe7a65 --- /dev/null +++ b/tests/integrations/test_integration_command_code.py @@ -0,0 +1,47 @@ +"""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 app + + 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 "$speckit-constitution" in result.output + assert "/speckit.constitution" not in result.output diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 4f9cff274b..0d0a724bd8 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/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 0ccaf99aae..0cebe7bc33 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", From 39b56626c947c130b63b2539bb3b80d17817c74e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:46:12 -0500 Subject: [PATCH 27/42] chore: release 0.16.2, begin 0.16.3.dev0 development (#4038) * chore: bump version to 0.16.2 * chore: begin 0.16.3.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 25 +++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 476616bc40..e335136b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ +## [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 diff --git a/pyproject.toml b/pyproject.toml index ca19633915..0da1571934 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.16.2.dev0" +version = "0.16.3.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From c3bbcc40a02ad61d9c08bfe32f94323ab065d128 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:37:51 -0500 Subject: [PATCH 28/42] docs: clarify maintainer applies submission label during triage (#4041) Community extension, preset, and bundle submissions are validated by label-triggered agentic workflows that only run once the corresponding `*-submission` label is applied. On this public repo contributors cannot apply that label themselves, so a maintainer applies it during issue triage. Document this in the three submission issue templates, the preset publishing guide, and correct the extension guide's inaccurate claim that issues are "automatically labeled and assigned". Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3b6f6854-9be6-4b67-b5b9-34b19ededcb8 --- .github/ISSUE_TEMPLATE/bundle_submission.yml | 2 ++ .github/ISSUE_TEMPLATE/extension_submission.yml | 2 ++ .github/ISSUE_TEMPLATE/preset_submission.yml | 2 ++ extensions/EXTENSION-PUBLISHING-GUIDE.md | 2 +- presets/PUBLISHING.md | 6 ++++++ 5 files changed, 13 insertions(+), 1 deletion(-) 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/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/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/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 From 9d15554c08ac5d01dc669dbd1a161a9638bc673b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:22:36 -0500 Subject: [PATCH 29/42] Update Security Governance preset to v0.6.2 (#4040) Update security-governance preset submitted by @hindermath to: - presets/catalog.community.json (version, download_url, description, documentation, provides, tags, updated_at) - docs/community/presets.md community presets table Closes #4039 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/presets.md | 2 +- presets/catalog.community.json | 41 ++++++++-------------------------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index bf204cfb75..ffe587573d 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -30,7 +30,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | 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/presets/catalog.community.json b/presets/catalog.community.json index 47a9a9d509..acc6449cb2 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,7 +1,6 @@ { "schema_version": "1.0", "updated_at": "2026-08-10T00:00:00Z", - "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -609,7 +608,7 @@ "documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.4/README.md", "license": "MIT", "requires": { -"speckit_version": ">=0.8.3" + "speckit_version": ">=0.8.3" }, "provides": { "templates": 9, @@ -692,52 +691,30 @@ "security-governance": { "name": "Security Governance", "id": "security-governance", - "version": "0.6.1", - "description": "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.", + "version": "0.6.2", + "description": "Adds memory-safe-language and secure-coding governance, exact-head security evidence, ASVS, supply-chain transparency, EU regulatory screening, and provider-neutral model routing.", "author": "Thorsten Hindermann", "repository": "https://github.com/hindermath/spec-kit-preset-security-governance", - "download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.1.zip", + "download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.2.zip", "homepage": "https://github.com/hindermath/spec-kit-preset-security-governance", - "documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.1/README.md", + "documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.2/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.8.0" }, "provides": { - "templates": 14, + "templates": 15, "commands": 3 }, "tags": [ "security", "governance", - "msl", - "ssdf", - "asvs", - "supply-chain", - "sbom", - "ai-sbom", - "vex", - "slsa", - "cwe-top-25", "secure-coding", - "rust", - "go", - "swift", - "java", - "kotlin", - "python", - "typescript", - "g7", - "bsi", - "cra", - "cyber-resilience-act", - "nis2", - "ai-act", - "dora", - "regulatory" + "supply-chain", + "model-routing" ], "created_at": "2026-04-27T00:00:00Z", - "updated_at": "2026-07-23T00:00:00Z" + "updated_at": "2026-08-10T00:00:00Z" }, "sicario-core": { "name": "SicarioSpec Core", From c5258e680b77a426fcffb05e1ecd34c5dee3d1c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:29:58 -0500 Subject: [PATCH 30/42] Update Archive Extension to v1.2.2 (#4053) Update archive extension submitted by @stn1slv: - extensions/catalog.community.json (version, download_url, updated_at) - docs/community/extensions.md community extensions table Closes #4049 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index b97e2cb990..c9a7c60973 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 7d3d8c69c4..6ddcf49cb5 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-10T00: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", From bd04776dcc439521552ee0a1fa16627c9fddfde2 Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:09:56 +0800 Subject: [PATCH 31/42] Clarify custom checklist ownership and lifecycle (#4028) * docs: clarify custom checklist lifecycle * docs: clarify implement checklist marker ownership --------- Co-authored-by: root --- docs/quickstart.md | 4 ++-- docs/reference/agentic-sdd.md | 10 +++++++++- templates/checklist-template.md | 9 +++++++-- templates/commands/checklist.md | 12 +++++++++++- templates/commands/implement.md | 29 ++++++++++++++++------------- 5 files changed, 45 insertions(+), 19 deletions(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index ddf6337356..2c69d1da51 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -83,7 +83,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 +107,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 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/templates/checklist-template.md b/templates/checklist-template.md index 78ee7fd4dc..9d1e801c3e 100644 --- a/templates/checklist-template.md +++ b/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 `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements. +**Note**: This custom checklist is generated by the `__SPECKIT_COMMAND_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. '; - // 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, From 5ec937e07ab0f41d8678bc766e63a8bd3c88dc28 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:17:33 -0500 Subject: [PATCH 34/42] Update Architecture Governance preset to v0.5.2 (#4050) Update architecture-governance preset submitted by @hindermath to: - presets/catalog.community.json (version, download_url, documentation, description, provides, tags, updated_at) - docs/community/presets.md community presets table Closes #4042 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/presets.md | 2 +- presets/catalog.community.json | 26 ++++++++------------------ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index ffe587573d..a54b29ee8a 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) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index acc6449cb2..fcc4af7610 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-08-10T00:00:00Z", + "updated_at": "2026-08-11T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -96,40 +96,30 @@ "architecture-governance": { "name": "Architecture Governance", "id": "architecture-governance", - "version": "0.5.1", - "description": "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.", + "version": "0.5.2", + "description": "Adds secure architecture, STRIDE/CAPEC threat modeling, arc42/S-ADR guidance, Zero Trust, SAMM, BSI cloud assurance, audit evidence, and provider-neutral model routing.", "author": "Thorsten Hindermann", "repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance", - "download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.1.zip", + "download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.2.zip", "homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance", - "documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.1/README.md", + "documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.2/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.8.0" }, "provides": { - "templates": 13, + "templates": 14, "commands": 3 }, "tags": [ "architecture", "governance", "threat-modeling", - "stride", - "capec", - "arc42", - "adr", - "zero-trust", - "samm", - "isaqb", "cloud", - "sovereignty", - "c3a", - "c5", - "assurance" + "model-routing" ], "created_at": "2026-04-27T00:00:00Z", - "updated_at": "2026-07-23T00:00:00Z" + "updated_at": "2026-08-11T00:00:00Z" }, "autonomous-run-governance": { "name": "Autonomous Run Governance", From 85d3ed289df560bc19270da34b3df600f0c9099d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:12:48 -0500 Subject: [PATCH 35/42] Add SpecKit Grill Me extension to community catalog (#4052) Add grill extension submitted by @yoshi1220 to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #4047 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 35 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index c9a7c60973..e77c40bcfa 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -146,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/extensions/catalog.community.json b/extensions/catalog.community.json index 6ddcf49cb5..b35f1c849d 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -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", From bd595cf838cc200f84fee9e9327b643dfe277d2c Mon Sep 17 00:00:00 2001 From: chelsealong Date: Wed, 12 Aug 2026 02:14:19 +0800 Subject: [PATCH 36/42] fix(claude): make argument-hint injection fold-aware for long descriptions (#4045) * fix(claude): make argument-hint injection fold-aware for long descriptions ClaudeIntegration.inject_argument_hint spliced argument-hint: "..." as a raw text line right after the first line starting with "description:". When a description is long enough for the YAML dumper to fold it across indented continuation lines, that splice landed inside the scalar, producing invalid YAML (plain scalar) or silently absorbing the hint into the description string (quoted scalar). This reproduces #3991 for the case #3996 didn't cover: bundled core commands have no argument-hint in their source frontmatter, so the structural apply_argument_hint path is a no-op and this raw-text fallback is what actually runs. Skip every continuation line of the description scalar (anything more indented than the key itself) before inserting, so the new key always lands after the whole scalar ends rather than in the middle of it. Fixes #4044 * fix(claude): also skip unindented blank lines in description scalar PyYAML serializes an embedded paragraph break ("\n\n") inside a quoted description as unindented blank lines, not indented continuation lines. inject_argument_hint only skipped indented lines, so it still inserted argument-hint mid-scalar for multi-paragraph descriptions, reproducing the #4044 failure modes. Skip blank lines too, and add a regression test for the multi-paragraph case. --- .../integrations/claude/__init__.py | 28 +++++- tests/integrations/test_integration_claude.py | 87 +++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 39732794af..2ce7fb6dcc 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -67,7 +67,16 @@ class ClaudeIntegration(SkillsIntegration): @staticmethod def inject_argument_hint(content: str, hint: str) -> str: - """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. + """Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter. + + A long ``description`` gets folded by the YAML dumper across + indented continuation lines (plain or quoted), and an embedded + paragraph break can add unindented blank lines inside a quoted + scalar. Inserting the new line right after the *first* line of + that scalar — instead of after the whole scalar — either produces + invalid YAML or gets silently absorbed into the description + string (#4044), so every continuation line (indented, or blank) + is skipped first. Skips injection if ``argument-hint:`` already exists in the frontmatter to avoid duplicate keys. @@ -90,15 +99,29 @@ def inject_argument_hint(content: str, hint: str) -> str: in_fm = False dash_count = 0 injected = False - for line in lines: + i = 0 + n = len(lines) + while i < n: + line = lines[i] stripped = line.rstrip("\n\r") if stripped == "---": dash_count += 1 in_fm = dash_count == 1 out.append(line) + i += 1 continue if in_fm and not injected and stripped.startswith("description:"): out.append(line) + i += 1 + # Skip past folded/quoted continuation lines of the scalar + # before inserting, so the new key lands after it ends. + # Blank lines count too: PyYAML emits unindented blank + # lines for embedded "\n\n" inside a quoted scalar. + while i < n and ( + lines[i][:1] in (" ", "\t") or lines[i].rstrip("\r\n") == "" + ): + out.append(lines[i]) + i += 1 # Preserve the exact line-ending style (\r\n vs \n) if line.endswith("\r\n"): eol = "\r\n" @@ -111,6 +134,7 @@ def inject_argument_hint(content: str, hint: str) -> str: injected = True continue out.append(line) + i += 1 return "".join(out) def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str: diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 7916fdeba9..3718af9740 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -451,6 +451,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.""" From 7dd706880e73cd05ccda95fb8d5ce6cf2d652ae4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:58:14 -0500 Subject: [PATCH 37/42] Update iSAQB Architecture Governance preset to v0.2.2 (#4056) Update isaqb-architecture-governance preset submitted by @hindermath: - presets/catalog.community.json (version, download_url, documentation, description, templates count, tags, updated_at) - docs/community/presets.md community presets table Closes #4055 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/presets.md | 2 +- presets/catalog.community.json | 18 +++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index a54b29ee8a..aba5907c66 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -22,7 +22,7 @@ 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) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index fcc4af7610..de1aa375b1 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -443,34 +443,30 @@ "isaqb-architecture-governance": { "name": "iSAQB Architecture Governance", "id": "isaqb-architecture-governance", - "version": "0.2.1", - "description": "Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt.", + "version": "0.2.2", + "description": "Adds iSAQB/CPSA-F and arc42 architecture governance, architecture views, quality scenarios, ADRs, risks, technical-debt evidence, and provider-neutral model routing.", "author": "Thorsten Hindermann", "repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance", - "download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.1.zip", + "download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.2.zip", "homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance", - "documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.1/README.md", + "documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.2/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.8.0" }, "provides": { - "templates": 13, + "templates": 14, "commands": 3 }, "tags": [ "architecture", "governance", "isaqb", - "cpsa-f", "arc42", - "adr", - "quality-attributes", - "architecture-views", - "technical-debt" + "model-routing" ], "created_at": "2026-04-27T00:00:00Z", - "updated_at": "2026-07-23T00:00:00Z" + "updated_at": "2026-08-11T00:00:00Z" }, "jira": { "name": "Jira Issue Tracking", From c1bceb625cd40c2de87a73493a49b4419f77ab00 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 12 Aug 2026 18:29:36 +0500 Subject: [PATCH 38/42] fix: use bounded read for bundle download HTTP responses (#3764) * fix: use bounded read for bundle download HTTP responses The bundle download used unbounded resp.read() to read HTTP responses into memory. A malicious or misconfigured catalog server could return an arbitrarily large payload causing OOM. Replace with read_response_limited() capped at MAX_DOWNLOAD_BYTES (50 MiB), consistent with how other download paths in the codebase enforce bounded reads. Add regression test that monkeypatches MAX_DOWNLOAD_BYTES to 100 bytes and verifies oversized responses are rejected. * fix: remove duplicate import of MAX_DOWNLOAD_BYTES and read_response_limited --- src/specify_cli/commands/bundle/__init__.py | 2 +- tests/contract/test_bundle_cli.py | 30 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 7ccc6cba31..b816e6fd01 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -14,8 +14,8 @@ import typer from rich.markup import escape as _escape_markup -from ..._console import console, err_console from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited +from ..._console import console, err_console from ...bundler import BundlerError from ...bundler.lib.project import ( active_integration, diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index bed2f8964d..c458a810ba 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -1010,3 +1010,33 @@ def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None payload = json.loads(result.output) assert payload["id"] == "demo-bundle" + + +def test_bundle_download_rejects_oversized_response(project: Path, monkeypatch): + """Bundle download rejects responses exceeding MAX_DOWNLOAD_BYTES.""" + # Monkeypatch to a small limit so the test is fast and low-memory. + monkeypatch.setattr( + "specify_cli.commands.bundle.MAX_DOWNLOAD_BYTES", 100 + ) + + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + # Return a response that exceeds 100 bytes. + return FakeBundleResponse(b"x" * 200, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + # Must fail with a size-limit error, not an unhandled traceback. + assert result.exit_code == 1 + # Rich may wrap the message across lines; normalise whitespace before checking. + output_flat = " ".join(result.output.split()) + assert "exceeds maximum size of 100 bytes" in output_flat From 049bd0ff06fb0e100cb66bb3a38c7d19b78d7284 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Wed, 12 Aug 2026 18:05:17 +0300 Subject: [PATCH 39/42] fix(tests): make preset resolve test width-agnostic The test_resolve_accepts_dotted_command_name test asserted 'constitution.md' as a substring of CliRunner output, but Rich wraps long runner worktree paths (e.g. Windows CI: 'D:\a\...\constitutio\nn.md') at 80 columns mid-word, breaking the assertion. Strip all whitespace from the output before checking so the assertion is stable regardless of terminal width or wrap position. Assisted-by: opencode (model: glm-5.2, supervised) --- tests/test_presets.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_presets.py b/tests/test_presets.py index 1e02952771..1da1101001 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -13611,7 +13611,10 @@ def test_resolve_accepts_dotted_command_name(self, project_dir): ) assert result.exit_code == 0, (result.output, result.exception) - assert "constitution.md" 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.""" From b77ca572ca37bf1539404c1cbfc596087a4531fd Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:13:02 +0800 Subject: [PATCH 40/42] Fix Alquimia argument hints after folded descriptions (#4063) Co-authored-by: root --- .../integrations/alquimia/__init__.py | 28 +++++- .../integrations/test_integration_alquimia.py | 95 +++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/alquimia/__init__.py b/src/specify_cli/integrations/alquimia/__init__.py index 507ce879e7..132615206d 100644 --- a/src/specify_cli/integrations/alquimia/__init__.py +++ b/src/specify_cli/integrations/alquimia/__init__.py @@ -65,7 +65,16 @@ def _build_skill_fm(self, name: str, description: str, source: str) -> dict: @staticmethod def inject_argument_hint(content: str, hint: str) -> str: - """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. + """Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter. + + A long ``description`` gets folded by the YAML dumper across + indented continuation lines (plain or quoted), and an embedded + paragraph break can add unindented blank lines inside a quoted + scalar. Inserting the new line right after the *first* line of + that scalar — instead of after the whole scalar — either produces + invalid YAML or gets silently absorbed into the description + string (#4044), so every continuation line (indented, or blank) + is skipped first. Skips injection if ``argument-hint:`` already exists in the frontmatter to avoid duplicate keys. @@ -88,15 +97,29 @@ def inject_argument_hint(content: str, hint: str) -> str: in_fm = False dash_count = 0 injected = False - for line in lines: + i = 0 + n = len(lines) + while i < n: + line = lines[i] stripped = line.rstrip("\n\r") if stripped == "---": dash_count += 1 in_fm = dash_count == 1 out.append(line) + i += 1 continue if in_fm and not injected and stripped.startswith("description:"): out.append(line) + i += 1 + # Skip folded/quoted continuation lines before inserting + # so the new key lands after the description scalar ends. + # Blank lines count too: PyYAML emits unindented blank + # lines for embedded "\n\n" inside a quoted scalar. + while i < n and ( + lines[i][:1] in (" ", "\t") or lines[i].rstrip("\r\n") == "" + ): + out.append(lines[i]) + i += 1 # Preserve the exact line-ending style (\r\n vs \n) if line.endswith("\r\n"): eol = "\r\n" @@ -109,6 +132,7 @@ def inject_argument_hint(content: str, hint: str) -> str: injected = True continue out.append(line) + i += 1 return "".join(out) @staticmethod 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.""" From b57a1b1cf2eca9d825e8287fe5ca9431372d935a Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Wed, 12 Aug 2026 19:39:26 +0300 Subject: [PATCH 41/42] Merge upstream/main: Alquimia argument hints fix (#4063) + sync with main - fix: Alquimia argument hints after folded descriptions (#4063) - Merge origin/main to incorporate PR #109/#110/#111 merge commits (0.16.0+adlc3 release tagged on main) Assisted-by: opencode (model: glm-5.2, supervised) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 461619d7b1..205889b395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to the Specify CLI and templates are documented here. ### Changed -- **Upstream merge**: Synced with github/spec-kit 0.16.2 (50 commits, base 0.16.0+adlc3 → 0.16.2+adlc1). +- **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 From cbbe25c81d4b61a68fdc5f1f7af62cf2dc1141ed Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Wed, 12 Aug 2026 20:14:51 +0300 Subject: [PATCH 42/42] fix(tests): increase agent-context PowerShell script timeout to 60s The fork's update-agent-context.ps1 does extra work vs upstream (team- directives block, self-create template, init-options.json read) that adds latency on Windows PowerShell 5.1. Under CI load the 30s timeout was too tight, causing test_powershell_script_discovers_nested_plan to time out. Assisted-by: opencode (model: glm-5.2, supervised) --- tests/extensions/test_extension_agent_context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index b410050f1c..ea7edee5a1 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -305,7 +305,7 @@ def _run_powershell_agent_context_script( env=env, capture_output=True, text=True, - timeout=30, + timeout=60, ) @@ -329,7 +329,7 @@ def _run_powershell_agent_context_script_with_env( env=env, capture_output=True, text=True, - timeout=30, + timeout=60, )