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] [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 f01d0c156..891b5e45b 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -2759,7 +2759,7 @@ class PresetManager: 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 @@ class PresetResolver: # 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 c35a37060..86847b178 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4727,6 +4727,163 @@ class TestPresetSkills: 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).