From 7855903dc7bffadc115609085396e7033c53d96f Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Sat, 15 Aug 2026 20:56:02 +0500 Subject: [PATCH] fix(rovodev): guard non-string prompt names when merging prompts.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_read_prompts_yml` documents that it "Returns an empty list if the file is missing, malformed, or contains no valid prompt entries", but it only filters at the entry level (`isinstance(item, dict)`) — it never validates the entry's `name`. `_merge_prompt_entries` then does `name = entry.get("name", "")` followed by `if name in generated_by_name:`, a dict membership test. A hand-edited `.rovodev/prompts.yml` whose entry has a YAML sequence or mapping `name` therefore raises an unhandled TypeError out of setup(): name=list -> TypeError: unhashable type: 'list' name=mapping -> TypeError: unhashable type: 'dict' name=int -> OK name=null -> OK Only the unhashable shapes crash. Every `specify init` / `integration install` / `integration upgrade` for rovodev on that project then aborts with a raw traceback, and the user's prompts.yml is never rewritten. A non-string name can never match a generated entry, so treat it like any other unmatched entry and preserve it verbatim. Co-Authored-By: Claude Opus 5 (1M context) --- .../integrations/rovodev/__init__.py | 11 ++++++ .../integrations/test_integration_rovodev.py | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/specify_cli/integrations/rovodev/__init__.py b/src/specify_cli/integrations/rovodev/__init__.py index 01aa870c66..fe0fcb30b6 100644 --- a/src/specify_cli/integrations/rovodev/__init__.py +++ b/src/specify_cli/integrations/rovodev/__init__.py @@ -179,6 +179,17 @@ def _merge_prompt_entries( for entry in existing: name = entry.get("name", "") + # ``prompts.yml`` is user-editable, and ``_read_prompts_yml`` only + # filters at the entry level -- it never validates the entry's + # ``name``. A YAML sequence or mapping there is unhashable, so this + # dict-membership test raised a raw ``TypeError`` out of ``setup()`` + # and aborted every ``specify init`` / ``integration install`` for + # rovodev on that project, leaving prompts.yml unwritten. A + # non-string name can never match a generated entry, so treat it + # like any other unmatched entry and preserve it verbatim. + if not isinstance(name, str): + merged.append(entry) + continue if name in generated_by_name: merged.append(generated_by_name[name]) seen.add(name) diff --git a/tests/integrations/test_integration_rovodev.py b/tests/integrations/test_integration_rovodev.py index 5bdafc25f9..5e36c7551f 100644 --- a/tests/integrations/test_integration_rovodev.py +++ b/tests/integrations/test_integration_rovodev.py @@ -155,6 +155,45 @@ def test_prompt_wrapper_format(self, tmp_path): f"{prompt_file} has unexpected wrapper format" ) + @pytest.mark.parametrize( + "bad_name", + [["speckit-plan", "speckit-tasks"], {"a": 1}], + ids=["sequence", "mapping"], + ) + def test_prompts_manifest_merge_tolerates_non_scalar_name( + self, tmp_path, bad_name + ): + """An unhashable `name` in a user-edited prompts.yml must not crash setup. + + `_read_prompts_yml` only filters at the entry level, never validating + the entry's `name`, so a YAML sequence or mapping there reached a dict + membership test and raised a raw `TypeError: unhashable type` out of + `setup()` — aborting every `specify init` / `integration install` for + rovodev on that project and leaving prompts.yml unwritten. + """ + impl = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + + prompts_manifest = tmp_path / ".rovodev" / "prompts.yml" + prompts_manifest.parent.mkdir(parents=True, exist_ok=True) + prompts_manifest.write_text( + yaml.safe_dump( + {"prompts": [{"name": bad_name, "content_file": "prompts/x.md"}]} + ), + encoding="utf-8", + ) + + impl.setup(tmp_path, manifest, script_type="sh") + + data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8")) + names = [entry.get("name") for entry in data["prompts"]] + # The malformed user entry is preserved verbatim... + assert bad_name in names, names + # ...and the generated entries were still written. + assert any( + isinstance(n, str) and n.startswith("speckit-") for n in names + ), names + def test_prompts_manifest_merge_preserves_user_entries(self, tmp_path): impl = get_integration(self.KEY) manifest = IntegrationManifest(self.KEY, tmp_path)