From 0b25a4e4e40eb823e5016cca4b969545020aad42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AE=B8=E6=9C=AC=E5=BC=BA?= <820396428@qq.com> Date: Mon, 26 Oct 2026 20:53:07 +0800 Subject: [PATCH 1/2] fix(presets): treat non-mapping command frontmatter as empty _parse_fm_yaml returned whatever yaml.safe_load produced, and resolve_content then called .pop on it, so a composing command whose frontmatter was valid YAML but not a mapping crashed preset add with AttributeError. Fall back to {} like CommandRegistrar.parse_frontmatter already does. --- src/specify_cli/presets/_resolver.py | 5 ++- tests/specify_cli/presets/test_resolver.py | 46 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/presets/_resolver.py b/src/specify_cli/presets/_resolver.py index ea1c4e03f9..8103090389 100644 --- a/src/specify_cli/presets/_resolver.py +++ b/src/specify_cli/presets/_resolver.py @@ -916,9 +916,12 @@ def _parse_fm_yaml(fm_block: str) -> dict: else: yaml_lines = [] try: - return yaml.safe_load("\n".join(yaml_lines)) or {} + parsed = yaml.safe_load("\n".join(yaml_lines)) or {} except yaml.YAMLError: return {} + # Valid YAML that is not a mapping is treated like invalid + # frontmatter, matching CommandRegistrar.parse_frontmatter. + return parsed if isinstance(parsed, dict) else {} top_fm = _parse_fm_yaml(top_frontmatter_text) diff --git a/tests/specify_cli/presets/test_resolver.py b/tests/specify_cli/presets/test_resolver.py index d1e416f7a1..c9d16045f4 100644 --- a/tests/specify_cli/presets/test_resolver.py +++ b/tests/specify_cli/presets/test_resolver.py @@ -1096,6 +1096,52 @@ def test_resolve_content_nonexistent(self, project_dir): content = resolver.resolve_content("nonexistent") assert content is None + def test_resolve_content_non_mapping_command_frontmatter( + self, project_dir, temp_dir, valid_pack_data + ): + """Valid YAML that is not a mapping must not crash composition. + + ``_parse_fm_yaml`` returned whatever ``yaml.safe_load`` produced, and the + caller then called ``.pop`` on it, so ``preset add`` surfaced a raw + ``AttributeError``. ``CommandRegistrar.parse_frontmatter`` already treats + a non-dict result as ``{}``; the resolver now agrees. + """ + pack_data = {**valid_pack_data} + pack_data["preset"] = { + **valid_pack_data["preset"], + "id": "odd-frontmatter-pack", + "name": "Odd Frontmatter", + } + pack_data["provides"] = { + "templates": [ + { + "type": "command", + "name": "speckit.demo", + "file": "commands/speckit.demo.md", + "strategy": "append", + } + ] + } + pack_dir = temp_dir / "odd-frontmatter-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", "w") as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.demo.md").write_text( + "---\njust a note\n---\nAPPENDED\n", encoding="utf-8" + ) + core_command = project_dir / ".specify" / "templates" / "commands" / "speckit.demo.md" + core_command.parent.mkdir(parents=True, exist_ok=True) + core_command.write_text("---\ndescription: core\n---\nCORE BODY\n", encoding="utf-8") + + PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5") + + content = PresetResolver(project_dir).resolve_content("speckit.demo", "command") + + assert content is not None + assert "APPENDED" in content + assert "just a note" not in content + def test_resolve_content_unreadable_winning_layer_returns_none(self, project_dir): """An undecodable winning layer must yield None, not a raw traceback. From 0ae870236fa7d22f77013784781bcbedf459b573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AE=B8=E6=9C=AC=E5=BC=BA?= <820396428@qq.com> Date: Tue, 27 Oct 2026 11:31:58 +0800 Subject: [PATCH 2/2] test(presets): assert the base command survives the composition Addresses the Copilot review note: the test would have passed even if non-mapping frontmatter had dropped the base layer, so assert its body is still in the resolved content. --- tests/specify_cli/presets/test_resolver.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/specify_cli/presets/test_resolver.py b/tests/specify_cli/presets/test_resolver.py index c9d16045f4..77353787aa 100644 --- a/tests/specify_cli/presets/test_resolver.py +++ b/tests/specify_cli/presets/test_resolver.py @@ -1139,6 +1139,7 @@ def test_resolve_content_non_mapping_command_frontmatter( content = PresetResolver(project_dir).resolve_content("speckit.demo", "command") assert content is not None + assert "CORE BODY" in content assert "APPENDED" in content assert "just a note" not in content